@zeropress/build-core 0.7.4 → 0.7.6
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 +39 -3
- package/package.json +9 -4
- package/src/api/build-site.js +33 -91
- package/src/content.d.ts +17 -0
- package/src/content.js +1 -0
- package/src/index.d.ts +10 -0
- package/src/render/code-blocks.js +98 -0
- package/src/render/content-renderer.js +3 -18
- package/src/render/html-text.js +62 -0
- package/src/render/menus.js +44 -0
package/README.md
CHANGED
|
@@ -42,7 +42,28 @@ import {
|
|
|
42
42
|
} from '@zeropress/build-core';
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
##
|
|
45
|
+
## Rendering a document body
|
|
46
|
+
|
|
47
|
+
For a document body without a theme or filesystem access:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
import { renderDocument } from '@zeropress/build-core/content';
|
|
51
|
+
|
|
52
|
+
const { html, toc } = renderDocument(source, 'html'); // html, markdown, or plaintext
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This entry uses the same sanitization and code rendering as a site build.
|
|
56
|
+
HTML code blocks accept `pre > code` or a `language-*` / `lang-*` class on
|
|
57
|
+
`pre`. Editor line breaks become newlines inside `code`, and recognized
|
|
58
|
+
languages receive highlight.js token classes. Unknown languages and Mermaid
|
|
59
|
+
remain plain code. Code samples longer than 100,000 UTF-16 code units retain
|
|
60
|
+
their text without syntax highlighting. Theme or preview CSS supplies the
|
|
61
|
+
visual styling; `html` is a body fragment, not a complete page.
|
|
62
|
+
|
|
63
|
+
The content entry can be bundled for browsers. For large documents, render in
|
|
64
|
+
a Web Worker to keep editing responsive.
|
|
65
|
+
|
|
66
|
+
## Site builds
|
|
46
67
|
|
|
47
68
|
`@zeropress/build-core` is responsible for:
|
|
48
69
|
|
|
@@ -97,7 +118,8 @@ Returns:
|
|
|
97
118
|
size: 1234,
|
|
98
119
|
sha256: '...'
|
|
99
120
|
}
|
|
100
|
-
]
|
|
121
|
+
],
|
|
122
|
+
warnings: []
|
|
101
123
|
}
|
|
102
124
|
```
|
|
103
125
|
|
|
@@ -124,6 +146,20 @@ This is useful for local tooling that wants filesystem theme loading but still u
|
|
|
124
146
|
Theme validation failures from either build entry point carry `error.code === 'THEME_VALIDATION_FAILED'`.
|
|
125
147
|
Their messages preserve report and hint line breaks while escaping terminal control characters in diagnostic values such as paths, messages, and source snippets. CLI consumers can preserve the layout of these reports while escaping arbitrary errors as single-line text.
|
|
126
148
|
|
|
149
|
+
## Menu depth
|
|
150
|
+
|
|
151
|
+
`theme.json.menu_slots.<menu_id>.max_depth` optionally limits rendered depth
|
|
152
|
+
(top-level items are depth 1). Omission leaves that menu unchanged. Items at the
|
|
153
|
+
limit remain, with empty `children`; deeper descendants are excluded, without
|
|
154
|
+
changing Preview Data. Direct `menus.<id>.items` loops and `{{menu:<id>}}` use
|
|
155
|
+
the same tree.
|
|
156
|
+
|
|
157
|
+
Both build APIs always return `warnings`. A depth overrun produces one
|
|
158
|
+
`MENU_MAX_DEPTH_EXCEEDED` warning per menu per build, containing `message`,
|
|
159
|
+
`menuId`, `maxDepth`, `actualDepth`, and `omittedItems` (all excluded
|
|
160
|
+
descendants). It does not fail the build. The library does not print warnings;
|
|
161
|
+
callers decide how to display them.
|
|
162
|
+
|
|
127
163
|
## Writers
|
|
128
164
|
|
|
129
165
|
### `MemoryWriter`
|
|
@@ -193,7 +229,7 @@ Build-core derives:
|
|
|
193
229
|
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:
|
|
194
230
|
|
|
195
231
|
- a trim-nonempty authored excerpt becomes the summary after trimming only its outer whitespace, without a length limit
|
|
196
|
-
- 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
|
|
232
|
+
- 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 once, and collapses whitespace
|
|
197
233
|
- a derived body summary is at most 160 Unicode code points, including a final `…` when truncated
|
|
198
234
|
- a document with no displayable text has `summary: ""`
|
|
199
235
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./content": {
|
|
10
|
+
"types": "./src/content.d.ts",
|
|
11
|
+
"default": "./src/content.js"
|
|
12
|
+
}
|
|
9
13
|
},
|
|
10
14
|
"types": "./src/index.d.ts",
|
|
11
15
|
"files": [
|
|
@@ -34,9 +38,10 @@
|
|
|
34
38
|
},
|
|
35
39
|
"dependencies": {
|
|
36
40
|
"@zeropress/slug-policy": "0.2.2",
|
|
37
|
-
"@zeropress/preview-data-validator": "0.7.
|
|
38
|
-
"@zeropress/theme-validator": "0.7.
|
|
41
|
+
"@zeropress/preview-data-validator": "0.7.4",
|
|
42
|
+
"@zeropress/theme-validator": "0.7.4",
|
|
39
43
|
"highlight.js": "11.12.0",
|
|
44
|
+
"htmlparser2": "12.0.0",
|
|
40
45
|
"markdown-it": "15.0.2",
|
|
41
46
|
"markdown-it-anchor": "10.0.0",
|
|
42
47
|
"sanitize-html": "2.17.7"
|
package/src/api/build-site.js
CHANGED
|
@@ -2,11 +2,12 @@ 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';
|
|
6
5
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
7
6
|
import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
|
|
7
|
+
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
|
+
import { prepareRenderMenus } from '../render/menus.js';
|
|
10
11
|
|
|
11
12
|
const DEFAULT_OPTIONS = {
|
|
12
13
|
assetHashing: true,
|
|
@@ -54,7 +55,6 @@ const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
|
|
|
54
55
|
const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
|
|
55
56
|
const CUSTOM_HTML_SLOT_MAX_CODE_POINTS = 65_536;
|
|
56
57
|
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;
|
|
58
58
|
const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
|
|
59
59
|
const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
|
|
60
60
|
const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
|
|
@@ -137,6 +137,7 @@ export async function buildSite(input) {
|
|
|
137
137
|
|
|
138
138
|
return {
|
|
139
139
|
files: state.summaries,
|
|
140
|
+
warnings: state.warnings,
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -152,6 +153,7 @@ async function createBuildState(input, options) {
|
|
|
152
153
|
const assetProcessor = new AssetProcessor();
|
|
153
154
|
const summaries = [];
|
|
154
155
|
const previewData = normalizePreviewData(input.previewData, options);
|
|
156
|
+
const { menus, warnings } = prepareRenderMenus(previewData.menus, themePackage.metadata.menu_slots);
|
|
155
157
|
const renderData = createRenderData(previewData, themePackage, options);
|
|
156
158
|
|
|
157
159
|
engine.initialize(themePackage);
|
|
@@ -168,6 +170,8 @@ async function createBuildState(input, options) {
|
|
|
168
170
|
return {
|
|
169
171
|
writer: input.writer,
|
|
170
172
|
previewData,
|
|
173
|
+
menus,
|
|
174
|
+
warnings,
|
|
171
175
|
renderData,
|
|
172
176
|
widgets: resolveWidgetAreas(previewData, renderData),
|
|
173
177
|
engine,
|
|
@@ -215,7 +219,7 @@ async function renderRoute(state, templateName, route) {
|
|
|
215
219
|
let html = await state.engine.render(
|
|
216
220
|
templateName,
|
|
217
221
|
{
|
|
218
|
-
menus: state.
|
|
222
|
+
menus: state.menus,
|
|
219
223
|
widgets: state.widgets,
|
|
220
224
|
collections: state.renderData.collections,
|
|
221
225
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -268,7 +272,7 @@ async function renderFrontPage(state, route) {
|
|
|
268
272
|
let html = await state.engine.render(
|
|
269
273
|
'page',
|
|
270
274
|
{
|
|
271
|
-
menus: state.
|
|
275
|
+
menus: state.menus,
|
|
272
276
|
widgets: state.widgets,
|
|
273
277
|
collections: state.renderData.collections,
|
|
274
278
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -309,7 +313,7 @@ async function renderFrontPage(state, route) {
|
|
|
309
313
|
let html = await state.engine.render(
|
|
310
314
|
'index',
|
|
311
315
|
{
|
|
312
|
-
menus: state.
|
|
316
|
+
menus: state.menus,
|
|
313
317
|
widgets: state.widgets,
|
|
314
318
|
collections: state.renderData.collections,
|
|
315
319
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -344,7 +348,7 @@ async function renderPost(state, post) {
|
|
|
344
348
|
let html = await state.engine.render(
|
|
345
349
|
'post',
|
|
346
350
|
{
|
|
347
|
-
menus: state.
|
|
351
|
+
menus: state.menus,
|
|
348
352
|
widgets: state.widgets,
|
|
349
353
|
collections: state.renderData.collections,
|
|
350
354
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -392,7 +396,7 @@ async function renderPage(state, page) {
|
|
|
392
396
|
let html = await state.engine.render(
|
|
393
397
|
'page',
|
|
394
398
|
{
|
|
395
|
-
menus: state.
|
|
399
|
+
menus: state.menus,
|
|
396
400
|
widgets: state.widgets,
|
|
397
401
|
collections: state.renderData.collections,
|
|
398
402
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -438,7 +442,7 @@ async function maybeRenderNotFoundPage(state) {
|
|
|
438
442
|
let html = await state.engine.render(
|
|
439
443
|
'404',
|
|
440
444
|
{
|
|
441
|
-
menus: state.
|
|
445
|
+
menus: state.menus,
|
|
442
446
|
widgets: state.widgets,
|
|
443
447
|
collections: state.renderData.collections,
|
|
444
448
|
taxonomies: state.renderData.taxonomies,
|
|
@@ -912,7 +916,7 @@ function normalizeCommentsApiBaseUrl(value) {
|
|
|
912
916
|
if (!normalized || normalized === '/') {
|
|
913
917
|
return normalized;
|
|
914
918
|
}
|
|
915
|
-
return normalized
|
|
919
|
+
return trimSlashes(normalized, { leading: false });
|
|
916
920
|
}
|
|
917
921
|
|
|
918
922
|
function normalizeTargetComments(comments) {
|
|
@@ -1840,7 +1844,7 @@ function normalizePaginationBasePath(basePath) {
|
|
|
1840
1844
|
return '/';
|
|
1841
1845
|
}
|
|
1842
1846
|
|
|
1843
|
-
const normalized = decodeRoutePath(String(basePath))
|
|
1847
|
+
const normalized = trimSlashes(decodeRoutePath(String(basePath)));
|
|
1844
1848
|
return `/${normalized}/`;
|
|
1845
1849
|
}
|
|
1846
1850
|
|
|
@@ -2123,7 +2127,7 @@ function formatTimestamp(value, site) {
|
|
|
2123
2127
|
}
|
|
2124
2128
|
|
|
2125
2129
|
function calculateReadingTime(html) {
|
|
2126
|
-
const plainText =
|
|
2130
|
+
const plainText = extractHtmlText(html);
|
|
2127
2131
|
const wordCount = plainText.trim().split(/\s+/).filter(Boolean).length;
|
|
2128
2132
|
const minutes = Math.max(1, Math.ceil(wordCount / 200));
|
|
2129
2133
|
return minutes === 1 ? '1 min read' : `${minutes} min read`;
|
|
@@ -2624,7 +2628,7 @@ function routePathToOutputPath(routePath, outputStyle = DEFAULT_PERMALINKS.outpu
|
|
|
2624
2628
|
return 'index.html';
|
|
2625
2629
|
}
|
|
2626
2630
|
if (outputStyle === 'html-extension') {
|
|
2627
|
-
return `${normalizedPath
|
|
2631
|
+
return `${trimSlashes(normalizedPath)}.html`;
|
|
2628
2632
|
}
|
|
2629
2633
|
return `${normalizedPath.replace(/^\//, '')}index.html`;
|
|
2630
2634
|
}
|
|
@@ -2656,11 +2660,21 @@ function pagePathToPublicUrl(routePath, outputStyle = DEFAULT_PERMALINKS.output_
|
|
|
2656
2660
|
return withoutTrailingSlash;
|
|
2657
2661
|
}
|
|
2658
2662
|
|
|
2663
|
+
function trimSlashes(value, { leading = true } = {}) {
|
|
2664
|
+
let start = 0;
|
|
2665
|
+
let end = value.length;
|
|
2666
|
+
if (leading) {
|
|
2667
|
+
while (start < end && value[start] === '/') start += 1;
|
|
2668
|
+
}
|
|
2669
|
+
while (end > start && value[end - 1] === '/') end -= 1;
|
|
2670
|
+
return value.slice(start, end);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2659
2673
|
function normalizeRoutePath(routePath) {
|
|
2660
2674
|
if (!routePath || routePath === '/') {
|
|
2661
2675
|
return '/';
|
|
2662
2676
|
}
|
|
2663
|
-
const normalized = decodeRoutePath(String(routePath))
|
|
2677
|
+
const normalized = trimSlashes(decodeRoutePath(String(routePath)));
|
|
2664
2678
|
return `/${normalized}/`;
|
|
2665
2679
|
}
|
|
2666
2680
|
|
|
@@ -2676,8 +2690,7 @@ function resolveEffectivePageReferencePath(site, page) {
|
|
|
2676
2690
|
}
|
|
2677
2691
|
|
|
2678
2692
|
function normalizePageReferencePath(value) {
|
|
2679
|
-
const normalized = decodeRoutePath(normalizeOptionalString(value))
|
|
2680
|
-
.replace(/^\/+|\/+$/gu, '')
|
|
2693
|
+
const normalized = trimSlashes(decodeRoutePath(normalizeOptionalString(value)))
|
|
2681
2694
|
.normalize('NFC');
|
|
2682
2695
|
return normalized;
|
|
2683
2696
|
}
|
|
@@ -2718,7 +2731,7 @@ function pageToOutputPath(page, outputStyle) {
|
|
|
2718
2731
|
|
|
2719
2732
|
function applyPermalinkPattern(pattern, kind, item, site) {
|
|
2720
2733
|
const tokenValues = buildPermalinkTokenValues(kind, item, site);
|
|
2721
|
-
const body = String(pattern || '')
|
|
2734
|
+
const body = trimSlashes(String(pattern || ''));
|
|
2722
2735
|
const segments = body.split('/').filter(Boolean).map((segment) => {
|
|
2723
2736
|
if (segment.startsWith(':')) {
|
|
2724
2737
|
return tokenValues[segment.slice(1)] || '';
|
|
@@ -2938,7 +2951,7 @@ function assertUniqueOutputPaths(plannedPaths) {
|
|
|
2938
2951
|
}
|
|
2939
2952
|
|
|
2940
2953
|
function normalizeRouteCollisionKey(url) {
|
|
2941
|
-
return String(url || '')
|
|
2954
|
+
return trimSlashes(String(url || ''), { leading: false }) || '/';
|
|
2942
2955
|
}
|
|
2943
2956
|
|
|
2944
2957
|
function assertSafeSlugDerivedOutputPath(rawSlug, outputPath) {
|
|
@@ -3160,7 +3173,7 @@ function buildSearchIndexItems(state) {
|
|
|
3160
3173
|
tags: Array.isArray(post.tags) ? post.tags.map((tag) => tag.name).filter(Boolean) : [],
|
|
3161
3174
|
published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
|
|
3162
3175
|
updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
|
|
3163
|
-
content_text:
|
|
3176
|
+
content_text: extractHtmlText(post.html),
|
|
3164
3177
|
}));
|
|
3165
3178
|
|
|
3166
3179
|
const frontPagePage = state.renderData.frontPageRoute?.front_page_type === 'page'
|
|
@@ -3196,7 +3209,7 @@ function buildSearchPageItem(page, url, pageReferencePath) {
|
|
|
3196
3209
|
tags: [],
|
|
3197
3210
|
published_at_iso: '',
|
|
3198
3211
|
updated_at_iso: normalizeIsoTimestamp(page.updated_at_iso),
|
|
3199
|
-
content_text:
|
|
3212
|
+
content_text: extractHtmlText(page.html),
|
|
3200
3213
|
};
|
|
3201
3214
|
}
|
|
3202
3215
|
|
|
@@ -3206,7 +3219,7 @@ function buildDocumentSummary(excerpt, html, title) {
|
|
|
3206
3219
|
return authoredExcerpt;
|
|
3207
3220
|
}
|
|
3208
3221
|
|
|
3209
|
-
const visibleText =
|
|
3222
|
+
const visibleText = extractHtmlText(html, { omitLeadingHeading: title });
|
|
3210
3223
|
const codePoints = [...visibleText];
|
|
3211
3224
|
if (codePoints.length <= GENERATED_SUMMARY_MAX_CODE_POINTS) {
|
|
3212
3225
|
return visibleText;
|
|
@@ -3219,83 +3232,12 @@ function buildDocumentSummary(excerpt, html, title) {
|
|
|
3219
3232
|
return `${truncated}…`;
|
|
3220
3233
|
}
|
|
3221
3234
|
|
|
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
|
-
|
|
3260
3235
|
function buildSearchHeadings(toc) {
|
|
3261
3236
|
return Array.isArray(toc)
|
|
3262
3237
|
? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
|
|
3263
3238
|
: [];
|
|
3264
3239
|
}
|
|
3265
3240
|
|
|
3266
|
-
function htmlToSearchText(html) {
|
|
3267
|
-
return normalizeSearchText(decodeHtmlEntities(
|
|
3268
|
-
String(html || '')
|
|
3269
|
-
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
3270
|
-
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
3271
|
-
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
3272
|
-
.replace(/<[^>]+>/g, ' '),
|
|
3273
|
-
));
|
|
3274
|
-
}
|
|
3275
|
-
|
|
3276
|
-
function decodeHtmlEntities(value) {
|
|
3277
|
-
const namedEntities = {
|
|
3278
|
-
amp: '&',
|
|
3279
|
-
lt: '<',
|
|
3280
|
-
gt: '>',
|
|
3281
|
-
quot: '"',
|
|
3282
|
-
apos: "'",
|
|
3283
|
-
nbsp: ' ',
|
|
3284
|
-
};
|
|
3285
|
-
|
|
3286
|
-
return String(value || '').replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]+);/g, (match, entity) => {
|
|
3287
|
-
if (entity.startsWith('#x')) {
|
|
3288
|
-
const codePoint = Number.parseInt(entity.slice(2), 16);
|
|
3289
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
3290
|
-
}
|
|
3291
|
-
if (entity.startsWith('#')) {
|
|
3292
|
-
const codePoint = Number.parseInt(entity.slice(1), 10);
|
|
3293
|
-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
3294
|
-
}
|
|
3295
|
-
return Object.prototype.hasOwnProperty.call(namedEntities, entity) ? namedEntities[entity] : match;
|
|
3296
|
-
});
|
|
3297
|
-
}
|
|
3298
|
-
|
|
3299
3241
|
function normalizeSearchText(value) {
|
|
3300
3242
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
3301
3243
|
}
|
package/src/content.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type DocumentType = 'html' | 'markdown' | 'plaintext';
|
|
2
|
+
|
|
3
|
+
export interface ContentHeading {
|
|
4
|
+
level: number;
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
href: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RenderedDocument {
|
|
11
|
+
html: string;
|
|
12
|
+
toc: ContentHeading[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Render a body using the same sanitization and code highlighting as site builds. */
|
|
16
|
+
export function renderDocument(content: string, documentType?: DocumentType): RenderedDocument;
|
|
17
|
+
export function renderDocumentContent(content: string, documentType?: DocumentType): string;
|
package/src/content.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderDocument, renderDocumentContent } from './render/content-renderer.js';
|
package/src/index.d.ts
CHANGED
|
@@ -80,8 +80,18 @@ export interface BuildSummaryFile {
|
|
|
80
80
|
sha256: string;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export interface BuildWarning {
|
|
84
|
+
code: 'MENU_MAX_DEPTH_EXCEEDED';
|
|
85
|
+
message: string;
|
|
86
|
+
menuId: string;
|
|
87
|
+
maxDepth: number;
|
|
88
|
+
actualDepth: number;
|
|
89
|
+
omittedItems: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
export interface BuildSiteResult {
|
|
84
93
|
files: BuildSummaryFile[];
|
|
94
|
+
warnings: BuildWarning[];
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
export function buildSite(input: {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import hljs from 'highlight.js';
|
|
2
|
+
import { parseDocument } from 'htmlparser2';
|
|
3
|
+
|
|
4
|
+
const MAX_HIGHLIGHT_LENGTH = 100_000;
|
|
5
|
+
const LANGUAGE_CLASS = /^(?:language|lang)-([a-z\d_+-]+)$/i;
|
|
6
|
+
|
|
7
|
+
function escapeHtml(value) {
|
|
8
|
+
return value.replace(/&/g, '&').replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function languageOf(attributes) {
|
|
13
|
+
for (const token of (attributes?.class || '').split(/\s+/)) {
|
|
14
|
+
const match = LANGUAGE_CLASS.exec(token);
|
|
15
|
+
if (match) return match[1].toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function attributesHtml(attributes) {
|
|
21
|
+
return Object.entries(attributes).map(([name, value]) => (
|
|
22
|
+
` ${name}="${escapeHtml(value).replace(/"/g, '"')}"`
|
|
23
|
+
)).join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function codeText(children) {
|
|
27
|
+
const stack = [...children].reverse();
|
|
28
|
+
const parts = [];
|
|
29
|
+
while (stack.length) {
|
|
30
|
+
const node = stack.pop();
|
|
31
|
+
if (node.type === 'text') parts.push(node.data);
|
|
32
|
+
else if (node.name === 'br') parts.push('\n');
|
|
33
|
+
else if (node.children) {
|
|
34
|
+
for (let index = node.children.length - 1; index >= 0; index--) {
|
|
35
|
+
stack.push(node.children[index]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return parts.join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function highlight(text, language, autoDetect) {
|
|
43
|
+
if (text.length <= MAX_HIGHLIGHT_LENGTH && !['mermaid', 'none', 'text', 'plaintext'].includes(language)) {
|
|
44
|
+
try {
|
|
45
|
+
const result = language && hljs.getLanguage(language)
|
|
46
|
+
? hljs.highlight(text, { language }).value
|
|
47
|
+
: !language && autoDetect ? hljs.highlightAuto(text).value : null;
|
|
48
|
+
if (result !== null) return result.replace(/"/g, '"').replace(/'/g, "'");
|
|
49
|
+
} catch {
|
|
50
|
+
// A highlighting failure must preserve the readable code sample.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return escapeHtml(text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Normalize sanitized code blocks without reserializing unrelated authored HTML. */
|
|
57
|
+
export function renderCodeBlocks(html, { autoDetect = false } = {}) {
|
|
58
|
+
if (!/<pre[\s>]/i.test(html)) return html;
|
|
59
|
+
const document = parseDocument(html, { withStartIndices: true, withEndIndices: true });
|
|
60
|
+
const stack = [...document.children].reverse();
|
|
61
|
+
const replacements = [];
|
|
62
|
+
while (stack.length) {
|
|
63
|
+
const node = stack.pop();
|
|
64
|
+
if (node.name === 'pre') {
|
|
65
|
+
const code = node.children.find((child) => child.name === 'code');
|
|
66
|
+
const language = languageOf(code?.attribs) || languageOf(node.attribs);
|
|
67
|
+
if (!code && !language) continue;
|
|
68
|
+
const onlyCode = code && node.children.every((child) => (
|
|
69
|
+
child === code || (child.type === 'text' && !child.data.trim())
|
|
70
|
+
));
|
|
71
|
+
const text = codeText(onlyCode ? code.children : node.children);
|
|
72
|
+
const attributes = { ...(code?.attribs || {}) };
|
|
73
|
+
if (language) {
|
|
74
|
+
attributes.class = [
|
|
75
|
+
...(attributes.class || '').split(/\s+/).filter((token) => token && !LANGUAGE_CLASS.test(token)),
|
|
76
|
+
`language-${language}`,
|
|
77
|
+
].join(' ');
|
|
78
|
+
}
|
|
79
|
+
replacements.push({
|
|
80
|
+
start: node.startIndex,
|
|
81
|
+
end: node.endIndex + 1,
|
|
82
|
+
html: `<pre${attributesHtml(node.attribs)}><code${attributesHtml(attributes)}>${highlight(text, language, autoDetect)}</code></pre>`,
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (node.children) {
|
|
87
|
+
for (let index = node.children.length - 1; index >= 0; index--) stack.push(node.children[index]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const parts = [];
|
|
91
|
+
let start = 0;
|
|
92
|
+
for (const replacement of replacements) {
|
|
93
|
+
parts.push(html.slice(start, replacement.start), replacement.html);
|
|
94
|
+
start = replacement.end;
|
|
95
|
+
}
|
|
96
|
+
parts.push(html.slice(start));
|
|
97
|
+
return parts.join('');
|
|
98
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
|
-
import hljs from 'highlight.js';
|
|
3
2
|
import anchor from 'markdown-it-anchor';
|
|
4
3
|
import sanitizeHtmlParser from 'sanitize-html';
|
|
4
|
+
import { renderCodeBlocks } from './code-blocks.js';
|
|
5
5
|
|
|
6
6
|
const TOC_LEVELS = new Set([2, 3, 4]);
|
|
7
7
|
const ALERT_TYPES = new Map([
|
|
@@ -109,7 +109,7 @@ export function renderDocument(content, documentType = 'markdown') {
|
|
|
109
109
|
|
|
110
110
|
if (normalizedType === 'html') {
|
|
111
111
|
return {
|
|
112
|
-
html: sanitizeHtml(normalizedContent),
|
|
112
|
+
html: renderCodeBlocks(sanitizeHtml(normalizedContent)),
|
|
113
113
|
toc: [],
|
|
114
114
|
};
|
|
115
115
|
}
|
|
@@ -122,7 +122,7 @@ function renderMarkdownDocument(content) {
|
|
|
122
122
|
const markdown = createMarkdownRenderer(toc);
|
|
123
123
|
|
|
124
124
|
return {
|
|
125
|
-
html: sanitizeHtml(markdown.render(content)),
|
|
125
|
+
html: renderCodeBlocks(sanitizeHtml(markdown.render(content)), { autoDetect: true }),
|
|
126
126
|
toc,
|
|
127
127
|
};
|
|
128
128
|
}
|
|
@@ -133,21 +133,6 @@ function createMarkdownRenderer(toc) {
|
|
|
133
133
|
linkify: false,
|
|
134
134
|
typographer: true,
|
|
135
135
|
breaks: false,
|
|
136
|
-
highlight(value, language) {
|
|
137
|
-
if (language && hljs.getLanguage(language)) {
|
|
138
|
-
try {
|
|
139
|
-
return hljs.highlight(value, { language }).value;
|
|
140
|
-
} catch {
|
|
141
|
-
return value;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
return hljs.highlightAuto(value).value;
|
|
147
|
-
} catch {
|
|
148
|
-
return value;
|
|
149
|
-
}
|
|
150
|
-
},
|
|
151
136
|
});
|
|
152
137
|
|
|
153
138
|
markdown.use(markdownAdmonitionContainers);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseDocument } from 'htmlparser2';
|
|
2
|
+
|
|
3
|
+
const NON_VISIBLE_TAGS = new Set(['script', 'style', 'template', 'noscript']);
|
|
4
|
+
const TEXT_BOUNDARY_TAGS = new Set([
|
|
5
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'pre',
|
|
6
|
+
'ul', 'ol', 'li', 'blockquote', 'aside', 'figure', 'figcaption',
|
|
7
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'div', 'nav',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** Extract plain text with entities decoded once; escape it when rendering HTML. */
|
|
11
|
+
export function extractHtmlText(html, { omitLeadingHeading } = {}) {
|
|
12
|
+
const document = parseDocument(String(html || ''), { decodeEntities: true });
|
|
13
|
+
return collectText(document.children, omitLeadingHeading);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collectText(nodes, omitLeadingHeading) {
|
|
17
|
+
const pending = [...nodes].reverse();
|
|
18
|
+
const parts = [];
|
|
19
|
+
let hasVisibleText = false;
|
|
20
|
+
let foundHeading = false;
|
|
21
|
+
|
|
22
|
+
while (pending.length > 0) {
|
|
23
|
+
const node = pending.pop();
|
|
24
|
+
if (typeof node === 'string') {
|
|
25
|
+
parts.push(node);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (node.type === 'text') {
|
|
29
|
+
parts.push(node.data);
|
|
30
|
+
hasVisibleText ||= node.data.trim() !== '';
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (node.type !== 'tag' || NON_VISIBLE_TAGS.has(node.name)) continue;
|
|
34
|
+
|
|
35
|
+
if (node.name === 'h1' && !foundHeading) {
|
|
36
|
+
foundHeading = true;
|
|
37
|
+
if (
|
|
38
|
+
!hasVisibleText
|
|
39
|
+
&& omitLeadingHeading !== undefined
|
|
40
|
+
&& collectText(node.children).normalize('NFC')
|
|
41
|
+
=== normalizeText(String(omitLeadingHeading)).normalize('NFC')
|
|
42
|
+
) {
|
|
43
|
+
parts.push(' ');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (TEXT_BOUNDARY_TAGS.has(node.name)) {
|
|
49
|
+
parts.push(' ');
|
|
50
|
+
pending.push(' ');
|
|
51
|
+
}
|
|
52
|
+
for (let index = node.children.length - 1; index >= 0; index -= 1) {
|
|
53
|
+
pending.push(node.children[index]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return normalizeText(parts.join(''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeText(value) {
|
|
61
|
+
return value.replace(/\s+/gu, ' ').trim();
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Work on rendering copies only; validation and exported Preview Data keep the full tree.
|
|
2
|
+
export function prepareRenderMenus(menus, slots = {}) {
|
|
3
|
+
const rendered = { ...menus };
|
|
4
|
+
const warnings = [];
|
|
5
|
+
|
|
6
|
+
for (const [menuId, slot] of Object.entries(slots)) {
|
|
7
|
+
if (slot.max_depth === undefined || !Object.hasOwn(menus, menuId)) continue;
|
|
8
|
+
const maxDepth = slot.max_depth;
|
|
9
|
+
const items = [];
|
|
10
|
+
const stack = [{ source: menus[menuId].items, output: items, depth: 1 }];
|
|
11
|
+
let actualDepth = 0;
|
|
12
|
+
let omittedItems = 0;
|
|
13
|
+
|
|
14
|
+
while (stack.length) {
|
|
15
|
+
const { source, output, depth } = stack.pop();
|
|
16
|
+
for (const item of source) {
|
|
17
|
+
actualDepth = Math.max(actualDepth, depth);
|
|
18
|
+
const children = [];
|
|
19
|
+
if (depth <= maxDepth) {
|
|
20
|
+
output.push({ ...item, children });
|
|
21
|
+
} else {
|
|
22
|
+
omittedItems += 1;
|
|
23
|
+
}
|
|
24
|
+
if (item.children.length) {
|
|
25
|
+
stack.push({ source: item.children, output: children, depth: depth + 1 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
rendered[menuId] = { ...menus[menuId], items };
|
|
31
|
+
if (omittedItems > 0) {
|
|
32
|
+
warnings.push({
|
|
33
|
+
code: 'MENU_MAX_DEPTH_EXCEEDED',
|
|
34
|
+
message: `Menu "${menuId}" reaches depth ${actualDepth}; the theme supports ${maxDepth}. ${omittedItems} deeper item(s) were omitted from rendering.`,
|
|
35
|
+
menuId,
|
|
36
|
+
maxDepth,
|
|
37
|
+
actualDepth,
|
|
38
|
+
omittedItems,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { menus: rendered, warnings };
|
|
44
|
+
}
|