@zeropress/build-core 0.7.0 → 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 +25 -3
- package/package.json +8 -7
- package/src/api/build-site.js +159 -111
- package/src/assets/asset-processor.js +0 -13
- package/src/render/content-renderer.js +98 -168
- package/src/theme/format-theme-validation.js +54 -0
- package/src/theme/load-theme-dir.js +134 -68
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ Notes:
|
|
|
107
107
|
- `previewData` must already satisfy the canonical preview-data contract
|
|
108
108
|
- `themePackage` must already be a validated in-memory theme package
|
|
109
109
|
- `sitemap.xml` is emitted only when `site.url` is a non-empty canonical URL
|
|
110
|
-
- `feed.xml` is emitted only when `site.feed.enabled` is not `false`, `site.url` is a non-empty canonical URL, and `generateFeed` is not `false`
|
|
110
|
+
- `feed.xml` is emitted only when `site.feed.enabled` is not `false`, `site.url` is a non-empty canonical URL, and `generateFeed` is not `false`; its `lastBuildDate` is derived from required `previewData.generated_at`, never the build machine clock
|
|
111
111
|
- callers may pass `sitemapStylesheetHref` to add an XML stylesheet processing instruction to generated `sitemap.xml`
|
|
112
112
|
- fallback `robots.txt` is emitted when `generateRobotsTxt` is not `false`
|
|
113
113
|
- fallback `robots.txt` uses the effective `site.robots.allow_indexing` policy; omitted Preview Data defaults to `true`, `false` emits `Disallow: /`, and `true` emits `Allow: /`
|
|
@@ -165,10 +165,14 @@ 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
|
-
|
|
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
|
+
|
|
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.
|
|
169
171
|
|
|
170
172
|
User-defined scalar metadata under `site.meta`, `post.meta`, `page.meta`, and item `meta` is always HTML-escaped during template interpolation. Names ending in `_html` or `_url` do not opt metadata into raw rendering; use the explicit content and custom HTML contracts for trusted raw markup.
|
|
171
173
|
|
|
174
|
+
Post/Page HTML, Markdown raw HTML, and text-widget HTML pass through a parser-based explicit allowlist. Safe structural, media, table, task-list, and alert markup is retained; event handlers and unsafe URL schemes are removed. Links permit relative URLs, HTTP(S), `mailto:`, and `tel:`. Media and iframe URLs permit relative URLs or HTTP(S), and protocol-relative URLs are rejected. Each `srcset` candidate is checked independently. This sanitizer boundary does not include trusted `custom_html` slots or `standalone_html`, which remain intentionally unchanged.
|
|
175
|
+
|
|
172
176
|
Build-core derives:
|
|
173
177
|
|
|
174
178
|
- index/archive/category/tag routes
|
|
@@ -178,8 +182,22 @@ Build-core derives:
|
|
|
178
182
|
- localized fallback `published_at` / `updated_at` values plus unconditional `published_at_iso` / `updated_at_iso` values
|
|
179
183
|
- datetime formatting from `site.locale`, `site.timezone`, `site.date_style`, and `site.time_style`
|
|
180
184
|
- `reading_time`
|
|
185
|
+
- effective `post.summary` and `page.summary` values for theme listings and metadata
|
|
181
186
|
- a route-root `comments` discriminated context
|
|
182
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
|
+
|
|
183
201
|
Every ordinary rendered route receives effective feature state on `site`, regardless of whether the corresponding Preview Data preference was omitted:
|
|
184
202
|
|
|
185
203
|
```js
|
|
@@ -250,6 +268,10 @@ The active state requires all of the following:
|
|
|
250
268
|
- the post or page has `allow_comments: true` and a positive `public_id`
|
|
251
269
|
- the ZeroPress provider has a non-empty item `comments.request_token`
|
|
252
270
|
|
|
271
|
+
Post and Page `allow_comments` are optional Preview Data fields. Omission is
|
|
272
|
+
normalized to `false`; producers should emit `true` only for items that opt in
|
|
273
|
+
to comments.
|
|
274
|
+
|
|
253
275
|
The theme-facing `request_token` key exists only for the `zeropress` provider. Preview-data items may retain ignored token metadata for `wordpress` or inactive comment states, but Build Core drops it. An active `wordpress` context omits that key entirely. Post/page objects, structured list items, collection items and cursors, adjacent-item summaries, search data, feeds, and non-detail route roots do not receive a copy of the item token. Themes must use the route-root `comments.enabled` discriminator.
|
|
254
276
|
|
|
255
277
|
The canonical `preview-data v0.7` site contract uses:
|
|
@@ -304,7 +326,7 @@ For ordinary theme-rendered HTML routes, site customization order is determinist
|
|
|
304
326
|
|
|
305
327
|
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.
|
|
306
328
|
|
|
307
|
-
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. `search_pagefind.js` is a Pagefind adapter that can replace `search.js` after a post-build Pagefind step.
|
|
329
|
+
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.
|
|
308
330
|
|
|
309
331
|
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.
|
|
310
332
|
|
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,14 +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": "
|
|
41
|
-
"markdown-it-anchor": "9.2.1"
|
|
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
|
+
"markdown-it-anchor": "9.2.1",
|
|
42
|
+
"sanitize-html": "2.17.7"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -2,8 +2,10 @@ 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';
|
|
8
|
+
import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
|
|
7
9
|
import { ZeroPressEngine } from '../render/zeropress-engine.js';
|
|
8
10
|
|
|
9
11
|
const DEFAULT_OPTIONS = {
|
|
@@ -51,6 +53,8 @@ const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:']);
|
|
|
51
53
|
const MEDIA_DELIVERY_MODES = new Set(['none', 'media_domain']);
|
|
52
54
|
const DISCOVERABILITY_VALUES = new Set(['default', 'noindex', 'delist']);
|
|
53
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;
|
|
54
58
|
const HEAD_CLOSING_TAG_PATTERN = /<\/head\s*>/i;
|
|
55
59
|
const BODY_CLOSING_TAG_PATTERN = /<\/body\s*>/i;
|
|
56
60
|
const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 768, 1024, 1280, 1600, 1920];
|
|
@@ -64,6 +68,7 @@ const SEARCH_FIELD_WEIGHTS = Object.freeze({
|
|
|
64
68
|
content_text: 1,
|
|
65
69
|
});
|
|
66
70
|
const SEARCH_RECENCY_BOOST_MAX = 0.15;
|
|
71
|
+
const RFC3339_TIMESTAMP_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:)(60|[0-5]\d)(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
67
72
|
export async function buildSite(input) {
|
|
68
73
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
69
74
|
const state = await createBuildState(input, options);
|
|
@@ -109,7 +114,7 @@ export async function buildSite(input) {
|
|
|
109
114
|
|
|
110
115
|
if (shouldGenerateSearchArtifacts(state)) {
|
|
111
116
|
await writeOutput(state.writer, state.summaries, SEARCH_INDEX_OUTPUT_PATH, buildSearchIndexJson(state), 'application/json');
|
|
112
|
-
await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(), 'application/javascript');
|
|
117
|
+
await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(state.previewData.site.locale), 'application/javascript');
|
|
113
118
|
await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
|
|
114
119
|
}
|
|
115
120
|
|
|
@@ -119,11 +124,11 @@ export async function buildSite(input) {
|
|
|
119
124
|
state.writer,
|
|
120
125
|
state.summaries,
|
|
121
126
|
'sitemap.xml',
|
|
122
|
-
buildSitemapXml(state.previewData.site, state.emitted,
|
|
127
|
+
buildSitemapXml(state.previewData.site, state.emitted, options.sitemapStylesheetHref),
|
|
123
128
|
'application/xml',
|
|
124
129
|
);
|
|
125
130
|
if (shouldGenerateFeed(state)) {
|
|
126
|
-
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.
|
|
131
|
+
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.feedGeneratedAt), 'application/rss+xml');
|
|
127
132
|
}
|
|
128
133
|
}
|
|
129
134
|
if (shouldGenerateRobotsTxt(options)) {
|
|
@@ -151,8 +156,8 @@ async function createBuildState(input, options) {
|
|
|
151
156
|
|
|
152
157
|
engine.initialize(themePackage);
|
|
153
158
|
|
|
154
|
-
const assetOutputs =
|
|
155
|
-
const customCssAsset =
|
|
159
|
+
const assetOutputs = buildAssetOutputs(themePackage.assets, assetProcessor, options);
|
|
160
|
+
const customCssAsset = buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
|
|
156
161
|
if (customCssAsset) {
|
|
157
162
|
assetOutputs.push(customCssAsset);
|
|
158
163
|
}
|
|
@@ -175,7 +180,7 @@ async function createBuildState(input, options) {
|
|
|
175
180
|
favicon: previewData.site.favicon,
|
|
176
181
|
exposeGenerator: previewData.site.expose_generator !== false,
|
|
177
182
|
options,
|
|
178
|
-
|
|
183
|
+
feedGeneratedAt: toDate(previewData.generated_at),
|
|
179
184
|
emitted: {
|
|
180
185
|
frontPage: null,
|
|
181
186
|
indexRoutes: [],
|
|
@@ -259,6 +264,7 @@ async function renderFrontPage(state, route) {
|
|
|
259
264
|
...route.page,
|
|
260
265
|
url: currentUrl,
|
|
261
266
|
};
|
|
267
|
+
const description = page.summary || state.previewData.site.description;
|
|
262
268
|
let html = await state.engine.render(
|
|
263
269
|
'page',
|
|
264
270
|
{
|
|
@@ -271,7 +277,7 @@ async function renderFrontPage(state, route) {
|
|
|
271
277
|
meta: buildPageMeta(state.previewData.site, {
|
|
272
278
|
currentUrl,
|
|
273
279
|
title: buildFrontPageTitle(state.previewData.site),
|
|
274
|
-
description
|
|
280
|
+
description,
|
|
275
281
|
ogType: 'website',
|
|
276
282
|
image: page.featured_image,
|
|
277
283
|
robotsNoindex: shouldNoindexDocument(page),
|
|
@@ -292,7 +298,7 @@ async function renderFrontPage(state, route) {
|
|
|
292
298
|
state.emitted.frontPage = {
|
|
293
299
|
url: currentUrl,
|
|
294
300
|
title: page.title,
|
|
295
|
-
description
|
|
301
|
+
description,
|
|
296
302
|
updatedAt: page.updated_at_iso,
|
|
297
303
|
includeInFeed: false,
|
|
298
304
|
includeInSitemap: !isDelistedDocument(page),
|
|
@@ -347,7 +353,7 @@ async function renderPost(state, post) {
|
|
|
347
353
|
meta: buildPageMeta(state.previewData.site, {
|
|
348
354
|
currentUrl,
|
|
349
355
|
title: buildDocumentTitle(post.title, state.previewData.site.title),
|
|
350
|
-
description: post.
|
|
356
|
+
description: post.summary,
|
|
351
357
|
ogType: 'article',
|
|
352
358
|
image: post.featured_image,
|
|
353
359
|
publishedTime: post.published_at_iso,
|
|
@@ -371,7 +377,7 @@ async function renderPost(state, post) {
|
|
|
371
377
|
state.emitted.posts.push({
|
|
372
378
|
url: currentUrl,
|
|
373
379
|
title: post.title,
|
|
374
|
-
description: post.
|
|
380
|
+
description: post.summary,
|
|
375
381
|
publishedAt: post.published_at_iso,
|
|
376
382
|
updatedAt: post.updated_at_iso,
|
|
377
383
|
status: post.status,
|
|
@@ -396,7 +402,7 @@ async function renderPage(state, page) {
|
|
|
396
402
|
currentUrl,
|
|
397
403
|
canonicalUrl,
|
|
398
404
|
title: buildDocumentTitle(page.title, state.previewData.site.title),
|
|
399
|
-
description: page.
|
|
405
|
+
description: page.summary,
|
|
400
406
|
ogType: 'website',
|
|
401
407
|
image: page.featured_image,
|
|
402
408
|
robotsNoindex: shouldNoindexDocument(page),
|
|
@@ -417,7 +423,7 @@ async function renderPage(state, page) {
|
|
|
417
423
|
state.emitted.pages.push({
|
|
418
424
|
url: currentUrl,
|
|
419
425
|
title: page.title,
|
|
420
|
-
description: page.
|
|
426
|
+
description: page.summary,
|
|
421
427
|
updatedAt: page.updated_at_iso,
|
|
422
428
|
status: page.status,
|
|
423
429
|
includeInSitemap: page.omit_from_sitemap !== true && !isDelistedDocument(page),
|
|
@@ -775,8 +781,8 @@ function normalizeWidgetItem(item, media_origin) {
|
|
|
775
781
|
}
|
|
776
782
|
|
|
777
783
|
function normalizeCustomCss(customCss) {
|
|
778
|
-
const content =
|
|
779
|
-
return content ? { content } : undefined;
|
|
784
|
+
const content = customCss?.content;
|
|
785
|
+
return typeof content === 'string' && content.trim() ? { content } : undefined;
|
|
780
786
|
}
|
|
781
787
|
|
|
782
788
|
function normalizeCustomHtml(customHtml) {
|
|
@@ -1289,6 +1295,7 @@ function buildCollectionPageSummary(page, frontPage, pageReferencePath) {
|
|
|
1289
1295
|
slug: page.slug,
|
|
1290
1296
|
url: frontPage?.type === 'page' && frontPage.page_path === pageReferencePath ? '/' : page.url,
|
|
1291
1297
|
excerpt: page.excerpt || '',
|
|
1298
|
+
summary: page.summary || '',
|
|
1292
1299
|
featured_image: page.featured_image || '',
|
|
1293
1300
|
updated_at: page.updated_at || '',
|
|
1294
1301
|
updated_at_iso: page.updated_at_iso || '',
|
|
@@ -1346,6 +1353,7 @@ function buildCollectionCursorItemSummary(item) {
|
|
|
1346
1353
|
slug: item.slug,
|
|
1347
1354
|
url: item.url,
|
|
1348
1355
|
excerpt: item.excerpt || '',
|
|
1356
|
+
summary: item.summary || '',
|
|
1349
1357
|
featured_image: item.featured_image || '',
|
|
1350
1358
|
updated_at: item.updated_at || '',
|
|
1351
1359
|
updated_at_iso: item.updated_at_iso || '',
|
|
@@ -1679,12 +1687,15 @@ function preparePage(page, site) {
|
|
|
1679
1687
|
const renderedDocument = renderDocument(page.content, documentType);
|
|
1680
1688
|
const permalink = resolvePagePermalink(site, page);
|
|
1681
1689
|
const pageFields = { ...page };
|
|
1690
|
+
const excerpt = typeof page.excerpt === 'string' ? page.excerpt : '';
|
|
1682
1691
|
delete pageFields.comments;
|
|
1683
1692
|
|
|
1684
1693
|
return {
|
|
1685
1694
|
...pageFields,
|
|
1686
1695
|
url: permalink.url,
|
|
1687
1696
|
document_type: documentType,
|
|
1697
|
+
excerpt,
|
|
1698
|
+
summary: buildDocumentSummary(excerpt, renderedDocument.html, page.title),
|
|
1688
1699
|
html: renderedDocument.html,
|
|
1689
1700
|
toc: renderedDocument.toc,
|
|
1690
1701
|
updated_at: page.updated_at_iso ? formatTimestamp(page.updated_at_iso, site) : '',
|
|
@@ -1694,6 +1705,7 @@ function preparePage(page, site) {
|
|
|
1694
1705
|
function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
|
|
1695
1706
|
const documentType = normalizeDocumentType(post.document_type);
|
|
1696
1707
|
const renderedDocument = renderDocument(post.content, documentType);
|
|
1708
|
+
const excerpt = typeof post.excerpt === 'string' ? post.excerpt : '';
|
|
1697
1709
|
const author = authorsById.get(post.author_id);
|
|
1698
1710
|
const permalink = resolvePermalink(site, 'posts', post);
|
|
1699
1711
|
const categories = post.category_slugs
|
|
@@ -1720,7 +1732,8 @@ function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
|
|
|
1720
1732
|
url: permalink.url,
|
|
1721
1733
|
content: post.content,
|
|
1722
1734
|
document_type: documentType,
|
|
1723
|
-
excerpt
|
|
1735
|
+
excerpt,
|
|
1736
|
+
summary: buildDocumentSummary(excerpt, renderedDocument.html, post.title),
|
|
1724
1737
|
published_at_iso: post.published_at_iso,
|
|
1725
1738
|
updated_at_iso: post.updated_at_iso,
|
|
1726
1739
|
author_id: post.author_id,
|
|
@@ -1962,6 +1975,7 @@ function buildStructuredPostSummary(post) {
|
|
|
1962
1975
|
slug: post.slug,
|
|
1963
1976
|
url: post.url,
|
|
1964
1977
|
excerpt: post.excerpt,
|
|
1978
|
+
summary: post.summary,
|
|
1965
1979
|
published_at: post.published_at,
|
|
1966
1980
|
published_at_iso: post.published_at_iso,
|
|
1967
1981
|
reading_time: post.reading_time,
|
|
@@ -1989,6 +2003,7 @@ function buildAdjacentPostSummary(post) {
|
|
|
1989
2003
|
slug: post.slug,
|
|
1990
2004
|
url: post.url,
|
|
1991
2005
|
excerpt: post.excerpt,
|
|
2006
|
+
summary: post.summary,
|
|
1992
2007
|
published_at: post.published_at,
|
|
1993
2008
|
published_at_iso: post.published_at_iso,
|
|
1994
2009
|
data: post.data,
|
|
@@ -2248,61 +2263,6 @@ async function normalizeAndValidateThemePackage(themePackage) {
|
|
|
2248
2263
|
};
|
|
2249
2264
|
}
|
|
2250
2265
|
|
|
2251
|
-
function formatThemeValidationFailure(validation) {
|
|
2252
|
-
const blocks = [
|
|
2253
|
-
[
|
|
2254
|
-
'Theme validation failed',
|
|
2255
|
-
`Errors: ${validation.errors.length}`,
|
|
2256
|
-
`Checked files: ${validation.checkedFiles}`,
|
|
2257
|
-
].join('\n'),
|
|
2258
|
-
...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
|
|
2259
|
-
];
|
|
2260
|
-
return blocks.join('\n\n');
|
|
2261
|
-
}
|
|
2262
|
-
|
|
2263
|
-
function formatThemeValidationIssue(issue) {
|
|
2264
|
-
if (!issue) {
|
|
2265
|
-
return 'Reason: Unknown error';
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
|
|
2269
|
-
const location = splitIssuePath(issue.path);
|
|
2270
|
-
if (location.file) {
|
|
2271
|
-
lines.push(`File: ${location.file}`);
|
|
2272
|
-
}
|
|
2273
|
-
if (location.path) {
|
|
2274
|
-
lines.push(`Path: ${location.path}`);
|
|
2275
|
-
}
|
|
2276
|
-
if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
|
|
2277
|
-
lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
|
|
2278
|
-
}
|
|
2279
|
-
if (issue.category) {
|
|
2280
|
-
lines.push(`Category: ${issue.category}`);
|
|
2281
|
-
}
|
|
2282
|
-
lines.push(`Reason: ${issue.message || 'Unknown error'}`);
|
|
2283
|
-
if (issue.snippet) {
|
|
2284
|
-
const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
|
|
2285
|
-
lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
|
|
2286
|
-
}
|
|
2287
|
-
if (issue.hint) {
|
|
2288
|
-
lines.push('', 'Hint:', issue.hint);
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
return lines.join('\n');
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
function splitIssuePath(issuePath) {
|
|
2295
|
-
const normalizedPath = String(issuePath || '');
|
|
2296
|
-
if (normalizedPath.startsWith('theme.json.')) {
|
|
2297
|
-
return {
|
|
2298
|
-
file: 'theme.json',
|
|
2299
|
-
path: normalizedPath.slice('theme.json.'.length),
|
|
2300
|
-
};
|
|
2301
|
-
}
|
|
2302
|
-
|
|
2303
|
-
return { file: normalizedPath, path: '' };
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
2266
|
function normalizeThemePackageMetadata(sourceMetadata, manifest) {
|
|
2307
2267
|
return {
|
|
2308
2268
|
...manifest,
|
|
@@ -2310,50 +2270,37 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
|
|
|
2310
2270
|
};
|
|
2311
2271
|
}
|
|
2312
2272
|
|
|
2313
|
-
|
|
2273
|
+
function buildAssetOutputs(assets, assetProcessor, options) {
|
|
2314
2274
|
const outputs = [];
|
|
2315
2275
|
|
|
2316
2276
|
for (const [assetPath, content] of assets.entries()) {
|
|
2317
|
-
const ext = assetPath.split('.').pop()?.toLowerCase();
|
|
2318
|
-
let processedContent = content;
|
|
2319
|
-
let contentType = getContentType(assetPath);
|
|
2320
|
-
|
|
2321
|
-
if (ext === 'css') {
|
|
2322
|
-
processedContent = await assetProcessor.processCSS(new TextDecoder().decode(content));
|
|
2323
|
-
contentType = 'text/css';
|
|
2324
|
-
} else if (ext === 'js') {
|
|
2325
|
-
processedContent = await assetProcessor.processJavaScript(new TextDecoder().decode(content));
|
|
2326
|
-
contentType = 'application/javascript';
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
2277
|
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
|
|
2330
2278
|
const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
|
|
2331
2279
|
|
|
2332
2280
|
outputs.push({
|
|
2333
2281
|
originalPath: assetPath,
|
|
2334
2282
|
path: targetPath,
|
|
2335
|
-
content
|
|
2336
|
-
contentType,
|
|
2283
|
+
content,
|
|
2284
|
+
contentType: getContentType(assetPath),
|
|
2337
2285
|
});
|
|
2338
2286
|
}
|
|
2339
2287
|
|
|
2340
2288
|
return outputs;
|
|
2341
2289
|
}
|
|
2342
2290
|
|
|
2343
|
-
|
|
2344
|
-
const content =
|
|
2345
|
-
if (!content) {
|
|
2291
|
+
function buildCustomCssAsset(customCss, assetProcessor, options) {
|
|
2292
|
+
const content = customCss?.content;
|
|
2293
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
2346
2294
|
return null;
|
|
2347
2295
|
}
|
|
2348
2296
|
|
|
2349
2297
|
const sourceBuffer = new TextEncoder().encode(content);
|
|
2350
|
-
const processedContent = await assetProcessor.processCSS(content);
|
|
2351
2298
|
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
|
|
2352
2299
|
|
|
2353
2300
|
return {
|
|
2354
2301
|
originalPath: '__zeropress_custom.css',
|
|
2355
2302
|
path: `assets/zeropress-custom${hash}.css`,
|
|
2356
|
-
content
|
|
2303
|
+
content,
|
|
2357
2304
|
contentType: 'text/css',
|
|
2358
2305
|
};
|
|
2359
2306
|
}
|
|
@@ -3253,6 +3200,63 @@ function buildSearchPageItem(page, url, pageReferencePath) {
|
|
|
3253
3200
|
};
|
|
3254
3201
|
}
|
|
3255
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
|
+
|
|
3256
3260
|
function buildSearchHeadings(toc) {
|
|
3257
3261
|
return Array.isArray(toc)
|
|
3258
3262
|
? toc.map((item) => normalizeSearchText(item?.title)).filter(Boolean)
|
|
@@ -3296,11 +3300,13 @@ function normalizeSearchText(value) {
|
|
|
3296
3300
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
3297
3301
|
}
|
|
3298
3302
|
|
|
3299
|
-
function buildSearchAdapterJs() {
|
|
3303
|
+
function buildSearchAdapterJs(locale) {
|
|
3300
3304
|
const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
|
|
3305
|
+
const localeJson = JSON.stringify(locale);
|
|
3301
3306
|
return `const FIELD_WEIGHTS = ${fieldWeightsJson};
|
|
3302
3307
|
const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
|
|
3303
3308
|
const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
|
|
3309
|
+
const SEGMENTER_LOCALE = ${localeJson};
|
|
3304
3310
|
const DEFAULT_LIMIT = 20;
|
|
3305
3311
|
const BM25_K1 = 1.2;
|
|
3306
3312
|
const BM25_B = 0.75;
|
|
@@ -3540,44 +3546,59 @@ function tokenize(value) {
|
|
|
3540
3546
|
}
|
|
3541
3547
|
|
|
3542
3548
|
const tokens = [];
|
|
3549
|
+
const cjkRuns = Array.from(
|
|
3550
|
+
text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu),
|
|
3551
|
+
(match) => match[0],
|
|
3552
|
+
);
|
|
3553
|
+
const nonCjkText = text.replace(
|
|
3554
|
+
/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu,
|
|
3555
|
+
' ',
|
|
3556
|
+
);
|
|
3557
|
+
let segmented = false;
|
|
3558
|
+
|
|
3543
3559
|
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
3544
3560
|
try {
|
|
3545
|
-
const segmenter = new Intl.Segmenter(
|
|
3546
|
-
for (const part of segmenter.segment(
|
|
3561
|
+
const segmenter = new Intl.Segmenter(SEGMENTER_LOCALE, { granularity: 'word' });
|
|
3562
|
+
for (const part of segmenter.segment(nonCjkText)) {
|
|
3547
3563
|
if (part.isWordLike && isUsefulToken(part.segment)) {
|
|
3548
3564
|
tokens.push(part.segment);
|
|
3549
3565
|
}
|
|
3550
3566
|
}
|
|
3567
|
+
segmented = true;
|
|
3551
3568
|
} catch {
|
|
3552
3569
|
// Fall through to regex tokenization.
|
|
3553
3570
|
}
|
|
3554
3571
|
}
|
|
3555
3572
|
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3573
|
+
if (!segmented) {
|
|
3574
|
+
for (const match of nonCjkText.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
|
|
3575
|
+
if (isUsefulToken(match[0])) {
|
|
3576
|
+
tokens.push(match[0]);
|
|
3577
|
+
}
|
|
3559
3578
|
}
|
|
3560
3579
|
}
|
|
3561
3580
|
|
|
3562
|
-
for (const
|
|
3563
|
-
tokens.push(...
|
|
3581
|
+
for (const run of cjkRuns) {
|
|
3582
|
+
tokens.push(...buildCjkTokens(run));
|
|
3564
3583
|
}
|
|
3565
3584
|
|
|
3566
3585
|
return tokens;
|
|
3567
3586
|
}
|
|
3568
3587
|
|
|
3569
|
-
function
|
|
3570
|
-
const
|
|
3571
|
-
|
|
3572
|
-
|
|
3588
|
+
function buildCjkTokens(value) {
|
|
3589
|
+
const characters = Array.from(value);
|
|
3590
|
+
const tokens = new Set();
|
|
3591
|
+
if (isUsefulToken(value)) {
|
|
3592
|
+
tokens.add(value);
|
|
3573
3593
|
}
|
|
3574
3594
|
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3595
|
+
if (characters.length > 2) {
|
|
3596
|
+
for (let index = 0; index <= characters.length - 2; index += 1) {
|
|
3597
|
+
tokens.add(characters.slice(index, index + 2).join(''));
|
|
3598
|
+
}
|
|
3578
3599
|
}
|
|
3579
|
-
|
|
3580
|
-
return tokens;
|
|
3600
|
+
|
|
3601
|
+
return Array.from(tokens);
|
|
3581
3602
|
}
|
|
3582
3603
|
|
|
3583
3604
|
function isUsefulToken(value) {
|
|
@@ -3683,7 +3704,7 @@ function normalizeLimit(value) {
|
|
|
3683
3704
|
`;
|
|
3684
3705
|
}
|
|
3685
3706
|
|
|
3686
|
-
function buildSitemapXml(site, emitted,
|
|
3707
|
+
function buildSitemapXml(site, emitted, stylesheetHref = '') {
|
|
3687
3708
|
const entries = [
|
|
3688
3709
|
...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
|
|
3689
3710
|
? [{
|
|
@@ -3732,7 +3753,7 @@ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
|
|
|
3732
3753
|
return `<?xml version="1.0" encoding="UTF-8"?>${stylesheet}\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
|
|
3733
3754
|
}
|
|
3734
3755
|
|
|
3735
|
-
function buildFeedXml(site, emitted,
|
|
3756
|
+
function buildFeedXml(site, emitted, feedGeneratedAt) {
|
|
3736
3757
|
const channelLink = resolveSiteUrl(site.url, '/');
|
|
3737
3758
|
const selfLink = resolveSiteUrl(site.url, '/feed.xml');
|
|
3738
3759
|
const items = [...emitted.posts]
|
|
@@ -3744,7 +3765,7 @@ function buildFeedXml(site, emitted, generatedAt) {
|
|
|
3744
3765
|
})
|
|
3745
3766
|
.join('\n');
|
|
3746
3767
|
|
|
3747
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${
|
|
3768
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeXml(site.title)}</title>\n <link>${escapeXml(channelLink)}</link>\n <description>${escapeXml(site.description)}</description>\n <language>${site.locale}</language>\n <lastBuildDate>${feedGeneratedAt.toUTCString()}</lastBuildDate>\n <atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />\n${items}\n </channel>\n</rss>`;
|
|
3748
3769
|
}
|
|
3749
3770
|
|
|
3750
3771
|
function buildRobotsTxt(site) {
|
|
@@ -3779,6 +3800,7 @@ function getContentType(assetPath) {
|
|
|
3779
3800
|
html: 'text/html',
|
|
3780
3801
|
css: 'text/css',
|
|
3781
3802
|
js: 'application/javascript',
|
|
3803
|
+
mjs: 'application/javascript',
|
|
3782
3804
|
json: 'application/json',
|
|
3783
3805
|
xml: 'application/xml',
|
|
3784
3806
|
txt: 'text/plain',
|
|
@@ -3830,7 +3852,33 @@ function hasCanonicalSiteUrl(siteUrl) {
|
|
|
3830
3852
|
}
|
|
3831
3853
|
|
|
3832
3854
|
function toDate(value) {
|
|
3833
|
-
|
|
3855
|
+
if (value instanceof Date) {
|
|
3856
|
+
const timestamp = value.getTime();
|
|
3857
|
+
if (!Number.isFinite(timestamp)) {
|
|
3858
|
+
throw new Error('Invalid RFC 3339 timestamp');
|
|
3859
|
+
}
|
|
3860
|
+
return new Date(timestamp);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
if (typeof value !== 'string' || !value) {
|
|
3864
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${String(value)}`);
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
const timestampMatch = value.match(RFC3339_TIMESTAMP_PATTERN);
|
|
3868
|
+
if (!timestampMatch) {
|
|
3869
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
const isLeapSecond = timestampMatch[2] === '60';
|
|
3873
|
+
const parseValue = isLeapSecond
|
|
3874
|
+
? `${timestampMatch[1]}59${timestampMatch[3] || ''}${timestampMatch[4]}`
|
|
3875
|
+
: value;
|
|
3876
|
+
const timestamp = Date.parse(parseValue);
|
|
3877
|
+
if (!Number.isFinite(timestamp)) {
|
|
3878
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
return new Date(timestamp + (isLeapSecond ? 1000 : 0));
|
|
3834
3882
|
}
|
|
3835
3883
|
|
|
3836
3884
|
function formatUtcIsoSeconds(value) {
|
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
export class AssetProcessor {
|
|
4
|
-
async processCSS(css) {
|
|
5
|
-
return css
|
|
6
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
7
|
-
.replace(/\s+/g, ' ')
|
|
8
|
-
.replace(/\s*([{}:;,>+~])\s*/g, '$1')
|
|
9
|
-
.replace(/;}/g, '}')
|
|
10
|
-
.trim();
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
async processJavaScript(js) {
|
|
14
|
-
return js;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
4
|
generateAssetHash(content) {
|
|
18
5
|
const hash = createHash('sha256');
|
|
19
6
|
hash.update(content);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import MarkdownIt from 'markdown-it';
|
|
2
2
|
import hljs from 'highlight.js';
|
|
3
3
|
import anchor from 'markdown-it-anchor';
|
|
4
|
+
import sanitizeHtmlParser from 'sanitize-html';
|
|
4
5
|
|
|
5
6
|
const TOC_LEVELS = new Set([2, 3, 4]);
|
|
6
7
|
const ALERT_TYPES = new Map([
|
|
@@ -19,6 +20,57 @@ const ADMONITION_CONTAINER_TYPES = new Map([
|
|
|
19
20
|
['CAUTION', { className: 'caution', title: 'Caution' }],
|
|
20
21
|
['DANGER', { className: 'caution', title: 'Danger' }],
|
|
21
22
|
]);
|
|
23
|
+
const CONTENT_ALLOWED_TAGS = [
|
|
24
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
25
|
+
'p', 'br', 'hr',
|
|
26
|
+
'strong', 'b', 'em', 'u', 's', 'sup', 'sub', 'code', 'pre',
|
|
27
|
+
'a', 'img',
|
|
28
|
+
'ul', 'ol', 'li',
|
|
29
|
+
'blockquote', 'aside',
|
|
30
|
+
'figure', 'figcaption', 'picture', 'source',
|
|
31
|
+
'video', 'audio', 'track',
|
|
32
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
33
|
+
'div', 'span', 'nav',
|
|
34
|
+
'iframe', 'input',
|
|
35
|
+
];
|
|
36
|
+
const CONTENT_ALLOWED_ATTRIBUTES = {
|
|
37
|
+
a: ['href', 'title', 'class', 'id', 'target', 'rel'],
|
|
38
|
+
aside: ['role', 'class', 'id'],
|
|
39
|
+
img: ['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding'],
|
|
40
|
+
iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class', 'title'],
|
|
41
|
+
input: ['type', 'checked', 'disabled', 'class', 'id', 'aria-label'],
|
|
42
|
+
source: ['src', 'srcset', 'sizes', 'type', 'media', 'width', 'height', 'class', 'id'],
|
|
43
|
+
th: ['rowspan', 'colspan', 'align', 'class', 'id'],
|
|
44
|
+
td: ['rowspan', 'colspan', 'align', 'class', 'id'],
|
|
45
|
+
video: ['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'width', 'height', 'class', 'id', 'title'],
|
|
46
|
+
audio: ['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'preload', 'class', 'id', 'title'],
|
|
47
|
+
track: ['src', 'kind', 'srclang', 'label', 'default', 'class', 'id'],
|
|
48
|
+
'*': ['class', 'id'],
|
|
49
|
+
};
|
|
50
|
+
const CONTENT_SANITIZER_OPTIONS = {
|
|
51
|
+
allowedTags: CONTENT_ALLOWED_TAGS,
|
|
52
|
+
allowedAttributes: CONTENT_ALLOWED_ATTRIBUTES,
|
|
53
|
+
allowedSchemes: ['http', 'https'],
|
|
54
|
+
allowedSchemesByTag: {
|
|
55
|
+
a: ['http', 'https', 'mailto', 'tel'],
|
|
56
|
+
},
|
|
57
|
+
allowedSchemesAppliedToAttributes: ['href', 'src', 'poster'],
|
|
58
|
+
allowProtocolRelative: false,
|
|
59
|
+
allowIframeRelativeUrls: true,
|
|
60
|
+
parseStyleAttributes: false,
|
|
61
|
+
selfClosing: ['img', 'br', 'hr', 'input', 'source', 'track'],
|
|
62
|
+
nonTextTags: ['script', 'style', 'template', 'noscript', 'textarea', 'option', 'xmp'],
|
|
63
|
+
transformTags: {
|
|
64
|
+
a: transformAnchor,
|
|
65
|
+
th: transformTableCell,
|
|
66
|
+
td: transformTableCell,
|
|
67
|
+
video: transformNativeMedia,
|
|
68
|
+
audio: transformNativeMedia,
|
|
69
|
+
},
|
|
70
|
+
exclusiveFilter(frame) {
|
|
71
|
+
return frame.tag === 'input' && frame.attribs.type !== 'checkbox';
|
|
72
|
+
},
|
|
73
|
+
};
|
|
22
74
|
|
|
23
75
|
export function renderDocumentContent(content, documentType = 'markdown') {
|
|
24
76
|
return renderDocument(content, documentType).html;
|
|
@@ -429,178 +481,68 @@ function escapeHtml(value) {
|
|
|
429
481
|
}
|
|
430
482
|
|
|
431
483
|
function sanitizeHtml(html) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
'p', 'br', 'hr',
|
|
435
|
-
'strong', 'b', 'em', 'u', 's', 'sup', 'sub', 'code', 'pre',
|
|
436
|
-
'a', 'img',
|
|
437
|
-
'ul', 'ol', 'li',
|
|
438
|
-
'blockquote', 'aside',
|
|
439
|
-
'figure', 'figcaption', 'picture', 'source',
|
|
440
|
-
'video', 'audio', 'track',
|
|
441
|
-
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
442
|
-
'div', 'span', 'nav',
|
|
443
|
-
'iframe', 'input',
|
|
444
|
-
]);
|
|
445
|
-
|
|
446
|
-
const allowedAttributes = {
|
|
447
|
-
a: new Set(['href', 'title', 'class', 'id', 'target', 'rel']),
|
|
448
|
-
aside: new Set(['role', 'class', 'id']),
|
|
449
|
-
img: new Set(['src', 'srcset', 'sizes', 'alt', 'title', 'class', 'id', 'width', 'height', 'loading', 'decoding']),
|
|
450
|
-
iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class', 'title']),
|
|
451
|
-
input: new Set(['type', 'checked', 'disabled', 'class', 'id', 'aria-label']),
|
|
452
|
-
source: new Set(['src', 'srcset', 'sizes', 'type', 'media', 'width', 'height', 'class', 'id']),
|
|
453
|
-
th: new Set(['rowspan', 'colspan', 'align', 'class', 'id']),
|
|
454
|
-
td: new Set(['rowspan', 'colspan', 'align', 'class', 'id']),
|
|
455
|
-
video: new Set(['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'width', 'height', 'class', 'id', 'title']),
|
|
456
|
-
audio: new Set(['src', 'controls', 'controlslist', 'autoplay', 'loop', 'muted', 'preload', 'class', 'id', 'title']),
|
|
457
|
-
track: new Set(['src', 'kind', 'srclang', 'label', 'default', 'class', 'id']),
|
|
458
|
-
'*': new Set(['class', 'id']),
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
const safeUriPattern = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
|
462
|
-
|
|
463
|
-
const stripped = html
|
|
464
|
-
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
465
|
-
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
466
|
-
|
|
467
|
-
const sanitized = stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tag, attributeString) => {
|
|
468
|
-
const normalizedTag = tag.toLowerCase();
|
|
469
|
-
|
|
470
|
-
if (match.startsWith('</')) {
|
|
471
|
-
return allowedTags.has(normalizedTag) ? `</${normalizedTag}>` : '';
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
if (!allowedTags.has(normalizedTag)) {
|
|
475
|
-
return '';
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const tagAllowedAttributes = allowedAttributes[normalizedTag] || new Set();
|
|
479
|
-
const globalAllowedAttributes = allowedAttributes['*'];
|
|
480
|
-
const filteredAttributes = [];
|
|
481
|
-
let anchorTarget = '';
|
|
482
|
-
let anchorRel = '';
|
|
483
|
-
let tableCellAlignClass = '';
|
|
484
|
-
|
|
485
|
-
if (attributeString) {
|
|
486
|
-
const attributePattern = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
487
|
-
let attributeMatch;
|
|
488
|
-
|
|
489
|
-
while ((attributeMatch = attributePattern.exec(attributeString)) !== null) {
|
|
490
|
-
const attributeName = attributeMatch[1].toLowerCase();
|
|
491
|
-
const attributeValue = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? '';
|
|
492
|
-
|
|
493
|
-
if (!tagAllowedAttributes.has(attributeName) && !globalAllowedAttributes.has(attributeName)) {
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if ((attributeName === 'href' || attributeName === 'src' || attributeName === 'poster') && !safeUriPattern.test(attributeValue)) {
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (normalizedTag === 'a' && attributeName === 'target') {
|
|
502
|
-
const target = sanitizeAnchorTarget(attributeValue);
|
|
503
|
-
if (!target) {
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
anchorTarget = target;
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (normalizedTag === 'a' && attributeName === 'rel') {
|
|
511
|
-
const rel = sanitizeRelList(attributeValue);
|
|
512
|
-
if (!rel) {
|
|
513
|
-
continue;
|
|
514
|
-
}
|
|
515
|
-
anchorRel = rel;
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (attributeName === 'srcset' && !isSafeSrcset(attributeValue, safeUriPattern)) {
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (attributeName === 'controlslist') {
|
|
524
|
-
const controlsList = sanitizeControlsList(attributeValue);
|
|
525
|
-
if (!controlsList) {
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
filteredAttributes.push(`${attributeName}="${controlsList}"`);
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
484
|
+
return sanitizeHtmlParser(html, CONTENT_SANITIZER_OPTIONS);
|
|
485
|
+
}
|
|
531
486
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
filteredAttributes.push(`${attributeName}="${span}"`);
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
487
|
+
function transformAnchor(tagName, attributes) {
|
|
488
|
+
const transformed = { ...attributes };
|
|
489
|
+
const target = sanitizeAnchorTarget(transformed.target);
|
|
490
|
+
const rel = sanitizeRelList(transformed.rel);
|
|
540
491
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
tableCellAlignClass = `zp-align-${alignment}`;
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
492
|
+
if (target) {
|
|
493
|
+
transformed.target = target;
|
|
494
|
+
} else {
|
|
495
|
+
delete transformed.target;
|
|
496
|
+
}
|
|
549
497
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
498
|
+
const safeRel = target === '_blank' ? ensureBlankTargetRel(rel) : rel;
|
|
499
|
+
if (safeRel) {
|
|
500
|
+
transformed.rel = safeRel;
|
|
501
|
+
} else {
|
|
502
|
+
delete transformed.rel;
|
|
503
|
+
}
|
|
553
504
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
505
|
+
return { tagName, attribs: transformed };
|
|
506
|
+
}
|
|
557
507
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
508
|
+
function transformTableCell(tagName, attributes) {
|
|
509
|
+
const transformed = { ...attributes };
|
|
561
510
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
if (anchorRel) {
|
|
570
|
-
filteredAttributes.push(`rel="${anchorRel}"`);
|
|
571
|
-
}
|
|
511
|
+
for (const attributeName of ['rowspan', 'colspan']) {
|
|
512
|
+
const value = sanitizePositiveIntegerAttribute(transformed[attributeName]);
|
|
513
|
+
if (value) {
|
|
514
|
+
transformed[attributeName] = value;
|
|
515
|
+
} else {
|
|
516
|
+
delete transformed[attributeName];
|
|
572
517
|
}
|
|
518
|
+
}
|
|
573
519
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
return sanitized
|
|
580
|
-
.split(/(<[^>]+>)/g)
|
|
581
|
-
.map((part) => {
|
|
582
|
-
if (part.startsWith('<') && part.endsWith('>')) {
|
|
583
|
-
return part;
|
|
584
|
-
}
|
|
520
|
+
const alignment = sanitizeTableAlignment(transformed.align);
|
|
521
|
+
delete transformed.align;
|
|
522
|
+
if (alignment) {
|
|
523
|
+
transformed.class = appendClassName(transformed.class, `zp-align-${alignment}`);
|
|
524
|
+
}
|
|
585
525
|
|
|
586
|
-
|
|
587
|
-
})
|
|
588
|
-
.join('');
|
|
526
|
+
return { tagName, attribs: transformed };
|
|
589
527
|
}
|
|
590
528
|
|
|
591
|
-
function
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
529
|
+
function transformNativeMedia(tagName, attributes) {
|
|
530
|
+
const transformed = { ...attributes };
|
|
531
|
+
const controlsList = sanitizeControlsList(transformed.controlslist);
|
|
532
|
+
if (controlsList) {
|
|
533
|
+
transformed.controlslist = controlsList;
|
|
534
|
+
} else {
|
|
535
|
+
delete transformed.controlslist;
|
|
596
536
|
}
|
|
537
|
+
return { tagName, attribs: transformed };
|
|
538
|
+
}
|
|
597
539
|
|
|
598
|
-
|
|
599
|
-
const classes =
|
|
540
|
+
function appendClassName(value, className) {
|
|
541
|
+
const classes = String(value || '').split(/\s+/).filter(Boolean);
|
|
600
542
|
if (!classes.includes(className)) {
|
|
601
543
|
classes.push(className);
|
|
602
544
|
}
|
|
603
|
-
|
|
545
|
+
return classes.join(' ');
|
|
604
546
|
}
|
|
605
547
|
|
|
606
548
|
function sanitizePositiveIntegerAttribute(value) {
|
|
@@ -668,18 +610,6 @@ function sanitizeControlsList(value) {
|
|
|
668
610
|
return tokens.join(' ');
|
|
669
611
|
}
|
|
670
612
|
|
|
671
|
-
function isSafeSrcset(value, safeUriPattern) {
|
|
672
|
-
const candidates = String(value)
|
|
673
|
-
.split(',')
|
|
674
|
-
.map((candidate) => candidate.trim())
|
|
675
|
-
.filter(Boolean);
|
|
676
|
-
|
|
677
|
-
return candidates.length > 0 && candidates.every((candidate) => {
|
|
678
|
-
const [url] = candidate.split(/\s+/);
|
|
679
|
-
return Boolean(url) && safeUriPattern.test(url);
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
|
|
683
613
|
function slugify(value) {
|
|
684
614
|
return String(value || '')
|
|
685
615
|
.toLowerCase()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function formatThemeValidationFailure(validation) {
|
|
2
|
+
const blocks = [
|
|
3
|
+
[
|
|
4
|
+
'Theme validation failed',
|
|
5
|
+
`Errors: ${validation.errors.length}`,
|
|
6
|
+
`Checked files: ${validation.checkedFiles}`,
|
|
7
|
+
].join('\n'),
|
|
8
|
+
...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
|
|
9
|
+
];
|
|
10
|
+
return blocks.join('\n\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatThemeValidationIssue(issue) {
|
|
14
|
+
if (!issue) {
|
|
15
|
+
return 'Reason: Unknown error';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
|
|
19
|
+
const location = splitIssuePath(issue.path);
|
|
20
|
+
if (location.file) {
|
|
21
|
+
lines.push(`File: ${location.file}`);
|
|
22
|
+
}
|
|
23
|
+
if (location.path) {
|
|
24
|
+
lines.push(`Path: ${location.path}`);
|
|
25
|
+
}
|
|
26
|
+
if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
|
|
27
|
+
lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
|
|
28
|
+
}
|
|
29
|
+
if (issue.category) {
|
|
30
|
+
lines.push(`Category: ${issue.category}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push(`Reason: ${issue.message || 'Unknown error'}`);
|
|
33
|
+
if (issue.snippet) {
|
|
34
|
+
const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
|
|
35
|
+
lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
|
|
36
|
+
}
|
|
37
|
+
if (issue.hint) {
|
|
38
|
+
lines.push('', 'Hint:', issue.hint);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function splitIssuePath(issuePath) {
|
|
45
|
+
const normalizedPath = String(issuePath || '');
|
|
46
|
+
if (normalizedPath.startsWith('theme.json.')) {
|
|
47
|
+
return {
|
|
48
|
+
file: 'theme.json',
|
|
49
|
+
path: normalizedPath.slice('theme.json.'.length),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { file: normalizedPath, path: '' };
|
|
54
|
+
}
|
|
@@ -1,15 +1,51 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
validateThemeFiles,
|
|
3
|
+
validateThemePackageLimits,
|
|
4
|
+
} from '@zeropress/theme-validator';
|
|
5
|
+
import { formatThemeValidationFailure } from './format-theme-validation.js';
|
|
2
6
|
|
|
3
7
|
const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
|
|
4
8
|
|
|
5
9
|
export async function loadThemePackageFromDir(themeDir) {
|
|
6
10
|
const fs = await import('node:fs/promises');
|
|
11
|
+
const { constants: fsConstants } = await import('node:fs');
|
|
7
12
|
const path = await import('node:path');
|
|
13
|
+
if (typeof themeDir !== 'string' || !themeDir.trim()) {
|
|
14
|
+
throw new Error('Theme directory must be a non-empty path');
|
|
15
|
+
}
|
|
16
|
+
const requestedThemeDir = path.resolve(themeDir);
|
|
8
17
|
|
|
9
|
-
const
|
|
10
|
-
|
|
18
|
+
const rootStat = await fs.lstat(requestedThemeDir);
|
|
19
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
20
|
+
throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
|
|
21
|
+
}
|
|
22
|
+
const canonicalThemeDir = await fs.realpath(requestedThemeDir);
|
|
23
|
+
const confirmedRootStat = await fs.lstat(requestedThemeDir);
|
|
24
|
+
if (confirmedRootStat.isSymbolicLink() || !confirmedRootStat.isDirectory()) {
|
|
25
|
+
throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
|
|
26
|
+
}
|
|
11
27
|
|
|
12
|
-
const
|
|
28
|
+
const fileMap = new Map();
|
|
29
|
+
const packageState = {
|
|
30
|
+
entryCount: 0,
|
|
31
|
+
fileSizes: new Map(),
|
|
32
|
+
pathEntries: [],
|
|
33
|
+
seenCanonicalPaths: new Map(),
|
|
34
|
+
};
|
|
35
|
+
await readThemeDir(
|
|
36
|
+
fs,
|
|
37
|
+
fsConstants,
|
|
38
|
+
path,
|
|
39
|
+
canonicalThemeDir,
|
|
40
|
+
canonicalThemeDir,
|
|
41
|
+
fileMap,
|
|
42
|
+
packageState,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const validation = await validateThemeFiles(fileMap, {
|
|
46
|
+
entryCount: packageState.entryCount,
|
|
47
|
+
pathEntries: packageState.pathEntries,
|
|
48
|
+
});
|
|
13
49
|
if (!validation.ok) {
|
|
14
50
|
throw new Error(formatThemeValidationFailure(validation));
|
|
15
51
|
}
|
|
@@ -58,82 +94,112 @@ export async function loadThemePackageFromDir(themeDir) {
|
|
|
58
94
|
};
|
|
59
95
|
}
|
|
60
96
|
|
|
61
|
-
function
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
|
|
69
|
-
];
|
|
70
|
-
return blocks.join('\n\n');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function formatThemeValidationIssue(issue) {
|
|
74
|
-
if (!issue) {
|
|
75
|
-
return 'Reason: Unknown error';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
|
|
79
|
-
const location = splitIssuePath(issue.path);
|
|
80
|
-
if (location.file) {
|
|
81
|
-
lines.push(`File: ${location.file}`);
|
|
82
|
-
}
|
|
83
|
-
if (location.path) {
|
|
84
|
-
lines.push(`Path: ${location.path}`);
|
|
85
|
-
}
|
|
86
|
-
if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
|
|
87
|
-
lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
|
|
88
|
-
}
|
|
89
|
-
if (issue.category) {
|
|
90
|
-
lines.push(`Category: ${issue.category}`);
|
|
91
|
-
}
|
|
92
|
-
lines.push(`Reason: ${issue.message || 'Unknown error'}`);
|
|
93
|
-
if (issue.snippet) {
|
|
94
|
-
const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
|
|
95
|
-
lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
|
|
96
|
-
}
|
|
97
|
-
if (issue.hint) {
|
|
98
|
-
lines.push('', 'Hint:', issue.hint);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return lines.join('\n');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function splitIssuePath(issuePath) {
|
|
105
|
-
const normalizedPath = String(issuePath || '');
|
|
106
|
-
if (normalizedPath.startsWith('theme.json.')) {
|
|
107
|
-
return {
|
|
108
|
-
file: 'theme.json',
|
|
109
|
-
path: normalizedPath.slice('theme.json.'.length),
|
|
110
|
-
};
|
|
97
|
+
async function readThemeDir(fs, fsConstants, path, rootDir, currentDir, fileMap, packageState) {
|
|
98
|
+
const entries = [];
|
|
99
|
+
const directory = await fs.opendir(currentDir);
|
|
100
|
+
for await (const entry of directory) {
|
|
101
|
+
packageState.entryCount += 1;
|
|
102
|
+
assertThemePackageLimits(packageState);
|
|
103
|
+
entries.push(entry);
|
|
111
104
|
}
|
|
112
|
-
|
|
113
|
-
return { file: normalizedPath, path: '' };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function readThemeDir(fs, path, rootDir, currentDir, fileMap) {
|
|
117
|
-
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
105
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
118
106
|
|
|
119
107
|
for (const entry of entries) {
|
|
120
108
|
const fullPath = path.join(currentDir, entry.name);
|
|
121
|
-
const
|
|
109
|
+
const rawRelativePath = path.relative(rootDir, fullPath);
|
|
110
|
+
const relativePath = rawRelativePath.replace(/\\/g, '/');
|
|
111
|
+
if (entry.name.includes('\\')) {
|
|
112
|
+
throw createThemePathError(
|
|
113
|
+
'PATH_ESCAPE',
|
|
114
|
+
rawRelativePath,
|
|
115
|
+
`Backslashes are not allowed in theme package paths: ${rawRelativePath}`,
|
|
116
|
+
packageState.fileSizes.size,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const collisionKey = relativePath.normalize('NFC').toLowerCase();
|
|
120
|
+
const existingPath = packageState.seenCanonicalPaths.get(collisionKey);
|
|
121
|
+
if (existingPath !== undefined) {
|
|
122
|
+
throw createThemePathError(
|
|
123
|
+
'THEME_PATH_COLLISION',
|
|
124
|
+
relativePath,
|
|
125
|
+
`Theme package path collision: '${relativePath}' conflicts with '${existingPath}' after NFC and case normalization`,
|
|
126
|
+
packageState.fileSizes.size,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
packageState.seenCanonicalPaths.set(collisionKey, relativePath);
|
|
130
|
+
const stat = await fs.lstat(fullPath);
|
|
131
|
+
const isSymlink = stat.isSymbolicLink();
|
|
132
|
+
packageState.pathEntries.push({ path: relativePath, isSymlink });
|
|
133
|
+
|
|
134
|
+
if (isSymlink) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
122
137
|
|
|
123
|
-
if (
|
|
124
|
-
await readThemeDir(fs, path, rootDir, fullPath, fileMap);
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
await readThemeDir(fs, fsConstants, path, rootDir, fullPath, fileMap, packageState);
|
|
125
140
|
continue;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
if (!stat.isFile()) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const noFollow = fsConstants.O_NOFOLLOW || 0;
|
|
148
|
+
let handle;
|
|
149
|
+
try {
|
|
150
|
+
handle = await fs.open(fullPath, fsConstants.O_RDONLY | noFollow);
|
|
151
|
+
const openedStat = await handle.stat();
|
|
152
|
+
if (!openedStat.isFile()) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
packageState.fileSizes.set(relativePath, openedStat.size);
|
|
156
|
+
assertThemePackageLimits(packageState);
|
|
157
|
+
|
|
158
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
159
|
+
if (TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
160
|
+
fileMap.set(relativePath, await handle.readFile('utf8'));
|
|
161
|
+
} else {
|
|
162
|
+
fileMap.set(relativePath, new Uint8Array(await handle.readFile()));
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code === 'ELOOP') {
|
|
166
|
+
packageState.pathEntries[packageState.pathEntries.length - 1].isSymlink = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
} finally {
|
|
171
|
+
await handle?.close();
|
|
133
172
|
}
|
|
134
173
|
}
|
|
135
174
|
}
|
|
136
175
|
|
|
176
|
+
function createThemePathError(code, filePath, message, checkedFiles) {
|
|
177
|
+
return new Error(formatThemeValidationFailure({
|
|
178
|
+
errors: [{
|
|
179
|
+
code,
|
|
180
|
+
path: filePath,
|
|
181
|
+
message,
|
|
182
|
+
severity: 'error',
|
|
183
|
+
category: 'theme_package_paths',
|
|
184
|
+
}],
|
|
185
|
+
checkedFiles,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertThemePackageLimits(packageState) {
|
|
190
|
+
const errors = validateThemePackageLimits(packageState.fileSizes, {
|
|
191
|
+
entryCount: packageState.entryCount,
|
|
192
|
+
});
|
|
193
|
+
if (errors.length === 0) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(formatThemeValidationFailure({
|
|
198
|
+
errors,
|
|
199
|
+
checkedFiles: packageState.fileSizes.size,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
137
203
|
function toUint8Array(value) {
|
|
138
204
|
if (value instanceof Uint8Array) {
|
|
139
205
|
return value;
|