@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 CHANGED
@@ -42,7 +42,28 @@ import {
42
42
  } from '@zeropress/build-core';
43
43
  ```
44
44
 
45
- ## Purpose
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.4",
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.3",
38
- "@zeropress/theme-validator": "0.7.3",
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"
@@ -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.previewData.menus,
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.previewData.menus,
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.previewData.menus,
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.previewData.menus,
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.previewData.menus,
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.previewData.menus,
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.replace(/\/+$/u, '');
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)).replace(/^\/+|\/+$/g, '');
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 = String(html || '').replace(/<[^>]*>/g, ' ');
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.replace(/^\/+|\/+$/g, '')}.html`;
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)).replace(/^\/+|\/+$/g, '');
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 || '').replace(/^\/+|\/+$/g, '');
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 || '').replace(/\/+$/, '') || '/';
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: htmlToSearchText(post.html),
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: htmlToSearchText(page.html),
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 = htmlToSummaryText(html, title);
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
  }
@@ -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, '&amp;').replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;');
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, '&quot;')}"`
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(/&quot;/g, '"').replace(/&#x27;/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
+ }