@zeropress/build-core 0.7.9 → 0.7.10
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 +76 -1
- package/package.json +1 -1
- package/src/api/build-site.js +46 -36
- package/src/index.d.ts +21 -1
- package/src/search/build-search-index.js +129 -0
package/README.md
CHANGED
|
@@ -238,7 +238,7 @@ Preview Data `post.excerpt` and `page.excerpt` are authored values. Build Core d
|
|
|
238
238
|
|
|
239
239
|
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.
|
|
240
240
|
|
|
241
|
-
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.
|
|
241
|
+
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. When generated, the native search index uses 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.
|
|
242
242
|
|
|
243
243
|
Every ordinary rendered route receives effective feature state on `site`, regardless of whether the corresponding Preview Data preference was omitted:
|
|
244
244
|
|
|
@@ -368,8 +368,81 @@ For ordinary theme-rendered HTML routes, site customization order is determinist
|
|
|
368
368
|
|
|
369
369
|
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.
|
|
370
370
|
|
|
371
|
+
### Native search
|
|
372
|
+
|
|
371
373
|
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.
|
|
372
374
|
|
|
375
|
+
Pass `options.generateSearchIndex: false` to `buildSite()` or
|
|
376
|
+
`buildSiteFromThemeDir()` to skip native indexing work. Omission or `true`
|
|
377
|
+
generates the index subject to the limits below. This option does not enable
|
|
378
|
+
search on a site or theme that disables it, and it does not delete search
|
|
379
|
+
files: `search.json`, `search.js`, and `search_pagefind.js` are still emitted
|
|
380
|
+
when search is enabled. Explicit and count-based skips avoid extracting
|
|
381
|
+
searchable body text; size checks stop incrementally before serializing an
|
|
382
|
+
oversized record. Normal indexes retain their existing bytes.
|
|
383
|
+
|
|
384
|
+
The limits use the uncompressed UTF-8 size of the existing two-space-indented
|
|
385
|
+
JSON, including its final newline (1 MiB = 1,048,576 bytes). Searchable records
|
|
386
|
+
are the published, non-delisted posts, pages, and page front page already
|
|
387
|
+
included by the renderer. The front page is counted once.
|
|
388
|
+
|
|
389
|
+
The first matching rule applies:
|
|
390
|
+
|
|
391
|
+
| Condition | `search.json` | Search warning |
|
|
392
|
+
| --- | --- | --- |
|
|
393
|
+
| Site or theme search disabled | No search artifacts | None |
|
|
394
|
+
| No searchable records | `[]` followed by a newline | None, even with indexing explicitly skipped |
|
|
395
|
+
| Indexing explicitly skipped | One non-searchable placeholder | `NATIVE_SEARCH_INDEX_SKIPPED` (`requested`) |
|
|
396
|
+
| More than 5,000 searchable records | One non-searchable placeholder | `NATIVE_SEARCH_INDEX_SKIPPED` (`record_limit`) |
|
|
397
|
+
| JSON size at least 5 MiB | One non-searchable placeholder | `NATIVE_SEARCH_INDEX_SKIPPED` (`size_limit`) |
|
|
398
|
+
| JSON size at least 2 MiB and less than 5 MiB | Complete index | `NATIVE_SEARCH_INDEX_LARGE` |
|
|
399
|
+
| JSON size less than 2 MiB | Complete index | None |
|
|
400
|
+
|
|
401
|
+
Exactly 5,000 records are allowed if the size limit is not reached. Limits
|
|
402
|
+
cannot be bypassed, and no partial index is emitted. Skipping the index does
|
|
403
|
+
not fail the build or change rendered HTML, effective `site.search.enabled`,
|
|
404
|
+
search widgets, or either JavaScript adapter.
|
|
405
|
+
|
|
406
|
+
When indexing is skipped for a nonempty site, the entire index is replaced by:
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
[
|
|
410
|
+
{
|
|
411
|
+
"id": "zeropress:search-omitted",
|
|
412
|
+
"type": "page",
|
|
413
|
+
"title": "",
|
|
414
|
+
"url": "/",
|
|
415
|
+
"excerpt": "",
|
|
416
|
+
"headings": [],
|
|
417
|
+
"categories": [],
|
|
418
|
+
"tags": [],
|
|
419
|
+
"published_at_iso": "",
|
|
420
|
+
"updated_at_iso": "",
|
|
421
|
+
"content_text": ""
|
|
422
|
+
}
|
|
423
|
+
]
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
All searchable fields are empty, so the existing native adapter returns no
|
|
427
|
+
results for every query. This record is a placeholder, not a document: an array
|
|
428
|
+
length of one must not be interpreted as one searchable page. A genuinely
|
|
429
|
+
empty site keeps `[]` instead.
|
|
430
|
+
|
|
431
|
+
The existing WXR starter Pagefind step uses that distinction: a placeholder
|
|
432
|
+
allows it to index the rendered HTML and replace `search.js` with
|
|
433
|
+
`search_pagefind.js`, while `[]` preserves its guard against crawling unmarked
|
|
434
|
+
pages on a site without searchable content. Pagefind does not index the
|
|
435
|
+
placeholder itself. No search manifest or additional output file is needed.
|
|
436
|
+
|
|
437
|
+
At most one search warning is returned per build. `NATIVE_SEARCH_INDEX_LARGE`
|
|
438
|
+
includes `byteLength` and `recordCount`; `NATIVE_SEARCH_INDEX_SKIPPED` includes
|
|
439
|
+
`reason` and the actual `recordCount`. A size-limit warning does not report an
|
|
440
|
+
exact final size because measurement stops at the limit. Warnings recommend
|
|
441
|
+
an external search index such as Pagefind and state when native search will
|
|
442
|
+
return no results.
|
|
443
|
+
|
|
444
|
+
### Optional outputs
|
|
445
|
+
|
|
373
446
|
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.
|
|
374
447
|
|
|
375
448
|
Optional route templates behave as rendering capabilities, not guaranteed outputs:
|
|
@@ -391,6 +464,7 @@ Supported options:
|
|
|
391
464
|
- `favicon`
|
|
392
465
|
- `sitemapStylesheetHref`
|
|
393
466
|
- `generateFeed`
|
|
467
|
+
- `generateSearchIndex`
|
|
394
468
|
- `generateRobotsTxt`
|
|
395
469
|
- `reservedOutputPaths`
|
|
396
470
|
|
|
@@ -398,6 +472,7 @@ Defaults:
|
|
|
398
472
|
|
|
399
473
|
- `assetHashing: true`
|
|
400
474
|
- `generateFeed: true`
|
|
475
|
+
- `generateSearchIndex: true` (automatic limits still apply)
|
|
401
476
|
- `generateRobotsTxt: true`
|
|
402
477
|
|
|
403
478
|
`reservedOutputPaths` is an internal orchestration boundary for callers that already own files in the final output tree, such as a copied public directory. Reserved files are safety-validated and participate in clean-URL alias, exact-path, and file/directory hierarchy collision checks, but Build Core does not write them or include them in the returned file summaries.
|
package/package.json
CHANGED
package/src/api/build-site.js
CHANGED
|
@@ -8,10 +8,12 @@ import { extractHtmlText } from '../render/html-text.js';
|
|
|
8
8
|
import { createThemeValidationError } from '../theme/format-theme-validation.js';
|
|
9
9
|
import { ZeroPressEngine } from '../render/zeropress-engine.js';
|
|
10
10
|
import { prepareRenderMenus } from '../render/menus.js';
|
|
11
|
+
import { buildSearchIndex } from '../search/build-search-index.js';
|
|
11
12
|
|
|
12
13
|
const DEFAULT_OPTIONS = {
|
|
13
14
|
assetHashing: true,
|
|
14
15
|
generateFeed: true,
|
|
16
|
+
generateSearchIndex: true,
|
|
15
17
|
generateRobotsTxt: true,
|
|
16
18
|
};
|
|
17
19
|
|
|
@@ -3156,45 +3158,53 @@ function insertBeforeClosingTag(html, closingTagPattern, content) {
|
|
|
3156
3158
|
}
|
|
3157
3159
|
|
|
3158
3160
|
function buildSearchIndexJson(state) {
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
+
const documents = [];
|
|
3162
|
+
for (const post of state.renderData.posts) {
|
|
3163
|
+
if (post.status === 'published' && !isDelistedDocument(post)) {
|
|
3164
|
+
documents.push({ type: 'post', document: post });
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3161
3167
|
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
content_text: extractHtmlText(post.html),
|
|
3177
|
-
}));
|
|
3168
|
+
const addPage = (page, url) => {
|
|
3169
|
+
if (page && page.status === 'published' && !isDelistedDocument(page)) {
|
|
3170
|
+
documents.push({
|
|
3171
|
+
type: 'page',
|
|
3172
|
+
document: page,
|
|
3173
|
+
url,
|
|
3174
|
+
referencePath: state.renderData.pageReferencePathByPage.get(page),
|
|
3175
|
+
});
|
|
3176
|
+
}
|
|
3177
|
+
};
|
|
3178
|
+
if (state.renderData.frontPageRoute?.front_page_type === 'page') {
|
|
3179
|
+
addPage(state.renderData.frontPageRoute.page, '/');
|
|
3180
|
+
}
|
|
3181
|
+
for (const page of state.renderData.pages) addPage(page, page.url);
|
|
3178
3182
|
|
|
3179
|
-
const
|
|
3180
|
-
|
|
3181
|
-
:
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
const pageItems = state.renderData.pages
|
|
3190
|
-
.filter((page) => page.status === 'published' && !isDelistedDocument(page))
|
|
3191
|
-
.map((page) => buildSearchPageItem(
|
|
3192
|
-
page,
|
|
3193
|
-
page.url,
|
|
3194
|
-
state.renderData.pageReferencePathByPage.get(page),
|
|
3195
|
-
));
|
|
3183
|
+
const result = buildSearchIndex({
|
|
3184
|
+
documents,
|
|
3185
|
+
generateSearchIndex: state.options.generateSearchIndex,
|
|
3186
|
+
createItem: (entry) => entry.type === 'post'
|
|
3187
|
+
? buildSearchPostItem(entry.document)
|
|
3188
|
+
: buildSearchPageItem(entry.document, entry.url, entry.referencePath),
|
|
3189
|
+
});
|
|
3190
|
+
state.warnings.push(...result.warnings);
|
|
3191
|
+
return result.json;
|
|
3192
|
+
}
|
|
3196
3193
|
|
|
3197
|
-
|
|
3194
|
+
function buildSearchPostItem(post) {
|
|
3195
|
+
return {
|
|
3196
|
+
id: `post:${post.slug}`,
|
|
3197
|
+
type: 'post',
|
|
3198
|
+
title: post.title,
|
|
3199
|
+
url: post.url,
|
|
3200
|
+
excerpt: normalizeSearchText(post.excerpt),
|
|
3201
|
+
headings: buildSearchHeadings(post.toc),
|
|
3202
|
+
categories: Array.isArray(post.categories) ? post.categories.map((category) => category.name).filter(Boolean) : [],
|
|
3203
|
+
tags: Array.isArray(post.tags) ? post.tags.map((tag) => tag.name).filter(Boolean) : [],
|
|
3204
|
+
published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
|
|
3205
|
+
updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
|
|
3206
|
+
content_text: extractHtmlText(post.html),
|
|
3207
|
+
};
|
|
3198
3208
|
}
|
|
3199
3209
|
|
|
3200
3210
|
function buildSearchPageItem(page, url, pageReferencePath) {
|
package/src/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface BuildOptions {
|
|
|
31
31
|
};
|
|
32
32
|
sitemapStylesheetHref?: string;
|
|
33
33
|
generateFeed?: boolean;
|
|
34
|
+
generateSearchIndex?: boolean;
|
|
34
35
|
generateRobotsTxt?: boolean;
|
|
35
36
|
reservedOutputPaths?: readonly string[];
|
|
36
37
|
}
|
|
@@ -80,7 +81,7 @@ export interface BuildSummaryFile {
|
|
|
80
81
|
sha256: string;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
export interface
|
|
84
|
+
export interface MenuDepthWarning {
|
|
84
85
|
code: 'MENU_MAX_DEPTH_EXCEEDED';
|
|
85
86
|
message: string;
|
|
86
87
|
menuId: string;
|
|
@@ -89,6 +90,25 @@ export interface BuildWarning {
|
|
|
89
90
|
omittedItems: number;
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
export interface NativeSearchIndexLargeWarning {
|
|
94
|
+
code: 'NATIVE_SEARCH_INDEX_LARGE';
|
|
95
|
+
message: string;
|
|
96
|
+
byteLength: number;
|
|
97
|
+
recordCount: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface NativeSearchIndexSkippedWarning {
|
|
101
|
+
code: 'NATIVE_SEARCH_INDEX_SKIPPED';
|
|
102
|
+
message: string;
|
|
103
|
+
reason: 'requested' | 'record_limit' | 'size_limit';
|
|
104
|
+
recordCount: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type BuildWarning =
|
|
108
|
+
| MenuDepthWarning
|
|
109
|
+
| NativeSearchIndexLargeWarning
|
|
110
|
+
| NativeSearchIndexSkippedWarning;
|
|
111
|
+
|
|
92
112
|
export interface BuildSiteResult {
|
|
93
113
|
files: BuildSummaryFile[];
|
|
94
114
|
warnings: BuildWarning[];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const WARNING_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
3
|
+
const MAX_RECORDS = 5000;
|
|
4
|
+
|
|
5
|
+
const OMITTED_INDEX_JSON = `${JSON.stringify([{
|
|
6
|
+
id: 'zeropress:search-omitted',
|
|
7
|
+
type: 'page',
|
|
8
|
+
title: '',
|
|
9
|
+
url: '/',
|
|
10
|
+
excerpt: '',
|
|
11
|
+
headings: [],
|
|
12
|
+
categories: [],
|
|
13
|
+
tags: [],
|
|
14
|
+
published_at_iso: '',
|
|
15
|
+
updated_at_iso: '',
|
|
16
|
+
content_text: '',
|
|
17
|
+
}], null, 2)}\n`;
|
|
18
|
+
|
|
19
|
+
// createItem stays lazy: skip/count limits must not extract searchable body text.
|
|
20
|
+
export function buildSearchIndex({ documents, createItem, generateSearchIndex = true }) {
|
|
21
|
+
const recordCount = documents.length;
|
|
22
|
+
if (recordCount === 0) return { json: '[]\n', warnings: [] };
|
|
23
|
+
if (generateSearchIndex === false) return skippedIndex('requested', recordCount);
|
|
24
|
+
if (recordCount > MAX_RECORDS) return skippedIndex('record_limit', recordCount);
|
|
25
|
+
|
|
26
|
+
const chunks = [];
|
|
27
|
+
// Opening "[\n" and closing "\n]\n", including the final newline.
|
|
28
|
+
let byteLength = 5;
|
|
29
|
+
for (const document of documents) {
|
|
30
|
+
const item = createItem(document);
|
|
31
|
+
const separatorBytes = chunks.length === 0 ? 0 : 2;
|
|
32
|
+
const remaining = MAX_BYTES - byteLength - separatorBytes;
|
|
33
|
+
const itemBytes = measureItemBytes(item, remaining);
|
|
34
|
+
if (itemBytes >= remaining) return skippedIndex('size_limit', recordCount);
|
|
35
|
+
|
|
36
|
+
// Only serialize a record after proving it fits. Indentation matches the
|
|
37
|
+
// original JSON.stringify(items, null, 2) output byte for byte.
|
|
38
|
+
chunks.push(' ' + JSON.stringify(item, null, 2).replaceAll('\n', '\n '));
|
|
39
|
+
byteLength += separatorBytes + itemBytes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const warnings = byteLength >= WARNING_BYTES ? [{
|
|
43
|
+
code: 'NATIVE_SEARCH_INDEX_LARGE',
|
|
44
|
+
message: `Native search index is ${byteLength} bytes (${recordCount} searchable records). Consider an external search index such as Pagefind for larger sites.`,
|
|
45
|
+
byteLength,
|
|
46
|
+
recordCount,
|
|
47
|
+
}] : [];
|
|
48
|
+
return { json: `[\n${chunks.join(',\n')}\n]\n`, warnings };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function skippedIndex(reason, recordCount) {
|
|
52
|
+
const explanation = {
|
|
53
|
+
requested: 'Native search indexing was skipped by request.',
|
|
54
|
+
record_limit: `Native search indexing was skipped because it exceeds ${MAX_RECORDS} searchable records.`,
|
|
55
|
+
size_limit: 'Native search indexing was skipped because the index reached the 5 MiB size limit.',
|
|
56
|
+
}[reason];
|
|
57
|
+
return {
|
|
58
|
+
json: OMITTED_INDEX_JSON,
|
|
59
|
+
warnings: [{
|
|
60
|
+
code: 'NATIVE_SEARCH_INDEX_SKIPPED',
|
|
61
|
+
message: `${explanation} Native search will return no results (${recordCount} searchable records). Use an external search index such as Pagefind.`,
|
|
62
|
+
reason,
|
|
63
|
+
recordCount,
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Search records contain only strings and arrays of strings. Count the JSON
|
|
69
|
+
// representation with two-space indentation, stopping at the remaining budget.
|
|
70
|
+
// In particular, never stringify a potentially oversized string to measure it.
|
|
71
|
+
function measureItemBytes(item, limit) {
|
|
72
|
+
let bytes = 2; // Indent the record inside the outer array.
|
|
73
|
+
const add = (size) => {
|
|
74
|
+
bytes += size;
|
|
75
|
+
return bytes < limit;
|
|
76
|
+
};
|
|
77
|
+
const string = (value) => {
|
|
78
|
+
// UTF-8 JSON is never shorter than its input's UTF-16 length plus quotes.
|
|
79
|
+
if (value.length + 2 >= limit - bytes || !add(2)) return false;
|
|
80
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
81
|
+
const code = value.charCodeAt(index);
|
|
82
|
+
let size;
|
|
83
|
+
if (code === 0x22 || code === 0x5c) {
|
|
84
|
+
size = 2;
|
|
85
|
+
} else if (code < 0x20) {
|
|
86
|
+
size = [8, 9, 10, 12, 13].includes(code) ? 2 : 6;
|
|
87
|
+
} else if (code < 0x80) {
|
|
88
|
+
size = 1;
|
|
89
|
+
} else if (code < 0x800) {
|
|
90
|
+
size = 2;
|
|
91
|
+
} else if (code >= 0xd800 && code <= 0xdfff) {
|
|
92
|
+
const next = value.charCodeAt(index + 1);
|
|
93
|
+
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
|
|
94
|
+
size = 4;
|
|
95
|
+
index += 1;
|
|
96
|
+
} else {
|
|
97
|
+
size = 6; // Well-formed JSON escapes unpaired UTF-16 surrogates.
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
size = 3;
|
|
101
|
+
}
|
|
102
|
+
if (!add(size)) return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!add(2)) return limit; // "{\n"
|
|
108
|
+
const entries = Object.entries(item);
|
|
109
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
110
|
+
const [key, value] = entries[index];
|
|
111
|
+
if (index > 0 && !add(2)) return limit; // ",\n"
|
|
112
|
+
if (!add(4) || !string(key) || !add(2)) return limit; // indent, key, ": "
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
if (value.length === 0) {
|
|
115
|
+
if (!add(2)) return limit;
|
|
116
|
+
} else {
|
|
117
|
+
if (!add(2)) return limit; // "[\n"
|
|
118
|
+
for (let entry = 0; entry < value.length; entry += 1) {
|
|
119
|
+
if (entry > 0 && !add(2)) return limit;
|
|
120
|
+
if (!add(6) || !string(value[entry])) return limit;
|
|
121
|
+
}
|
|
122
|
+
if (!add(6)) return limit; // "\n ]"
|
|
123
|
+
}
|
|
124
|
+
} else if (!string(value)) {
|
|
125
|
+
return limit;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return add(4) ? bytes : limit; // "\n }"
|
|
129
|
+
}
|