@zeropress/build-core 0.7.0 → 0.7.1
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 +11 -3
- package/package.json +5 -4
- package/src/api/build-site.js +82 -104
- 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 expanded 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 also rejected so directory and ZIP adapters use the same cross-platform identity. `buildSite()` validates the same limits for an already-loaded in-memory package. Packaged ZIP input is handled by `@zeropress/theme`, which additionally limits the archive itself to 2 MiB.
|
|
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
|
|
@@ -250,6 +254,10 @@ The active state requires all of the following:
|
|
|
250
254
|
- the post or page has `allow_comments: true` and a positive `public_id`
|
|
251
255
|
- the ZeroPress provider has a non-empty item `comments.request_token`
|
|
252
256
|
|
|
257
|
+
Post and Page `allow_comments` are optional Preview Data fields. Omission is
|
|
258
|
+
normalized to `false`; producers should emit `true` only for items that opt in
|
|
259
|
+
to comments.
|
|
260
|
+
|
|
253
261
|
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
262
|
|
|
255
263
|
The canonical `preview-data v0.7` site contract uses:
|
|
@@ -304,7 +312,7 @@ For ordinary theme-rendered HTML routes, site customization order is determinist
|
|
|
304
312
|
|
|
305
313
|
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
314
|
|
|
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.
|
|
315
|
+
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
316
|
|
|
309
317
|
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
318
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Shared ZeroPress build core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,10 +34,11 @@
|
|
|
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.
|
|
37
|
+
"@zeropress/preview-data-validator": "0.7.1",
|
|
38
|
+
"@zeropress/theme-validator": "0.7.1",
|
|
39
39
|
"highlight.js": "11.11.1",
|
|
40
40
|
"markdown-it": "14.3.0",
|
|
41
|
-
"markdown-it-anchor": "9.2.1"
|
|
41
|
+
"markdown-it-anchor": "9.2.1",
|
|
42
|
+
"sanitize-html": "2.17.6"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -4,6 +4,7 @@ import { isSafeSlugSegment, normalizeStoredSlug } from '@zeropress/slug-policy';
|
|
|
4
4
|
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
5
5
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
6
6
|
import { renderDocument, renderDocumentContent } from '../render/content-renderer.js';
|
|
7
|
+
import { formatThemeValidationFailure } from '../theme/format-theme-validation.js';
|
|
7
8
|
import { ZeroPressEngine } from '../render/zeropress-engine.js';
|
|
8
9
|
|
|
9
10
|
const DEFAULT_OPTIONS = {
|
|
@@ -64,6 +65,7 @@ const SEARCH_FIELD_WEIGHTS = Object.freeze({
|
|
|
64
65
|
content_text: 1,
|
|
65
66
|
});
|
|
66
67
|
const SEARCH_RECENCY_BOOST_MAX = 0.15;
|
|
68
|
+
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
69
|
export async function buildSite(input) {
|
|
68
70
|
const options = { ...DEFAULT_OPTIONS, ...(input.options || {}) };
|
|
69
71
|
const state = await createBuildState(input, options);
|
|
@@ -109,7 +111,7 @@ export async function buildSite(input) {
|
|
|
109
111
|
|
|
110
112
|
if (shouldGenerateSearchArtifacts(state)) {
|
|
111
113
|
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');
|
|
114
|
+
await writeOutput(state.writer, state.summaries, SEARCH_ADAPTER_OUTPUT_PATH, buildSearchAdapterJs(state.previewData.site.locale), 'application/javascript');
|
|
113
115
|
await writeOutput(state.writer, state.summaries, SEARCH_PAGEFIND_ADAPTER_OUTPUT_PATH, buildSearchPagefindAdapterJs(), 'application/javascript');
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -119,11 +121,11 @@ export async function buildSite(input) {
|
|
|
119
121
|
state.writer,
|
|
120
122
|
state.summaries,
|
|
121
123
|
'sitemap.xml',
|
|
122
|
-
buildSitemapXml(state.previewData.site, state.emitted,
|
|
124
|
+
buildSitemapXml(state.previewData.site, state.emitted, options.sitemapStylesheetHref),
|
|
123
125
|
'application/xml',
|
|
124
126
|
);
|
|
125
127
|
if (shouldGenerateFeed(state)) {
|
|
126
|
-
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.
|
|
128
|
+
await writeOutput(state.writer, state.summaries, 'feed.xml', buildFeedXml(state.previewData.site, state.emitted, state.feedGeneratedAt), 'application/rss+xml');
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
if (shouldGenerateRobotsTxt(options)) {
|
|
@@ -151,8 +153,8 @@ async function createBuildState(input, options) {
|
|
|
151
153
|
|
|
152
154
|
engine.initialize(themePackage);
|
|
153
155
|
|
|
154
|
-
const assetOutputs =
|
|
155
|
-
const customCssAsset =
|
|
156
|
+
const assetOutputs = buildAssetOutputs(themePackage.assets, assetProcessor, options);
|
|
157
|
+
const customCssAsset = buildCustomCssAsset(previewData.custom_css, assetProcessor, options);
|
|
156
158
|
if (customCssAsset) {
|
|
157
159
|
assetOutputs.push(customCssAsset);
|
|
158
160
|
}
|
|
@@ -175,7 +177,7 @@ async function createBuildState(input, options) {
|
|
|
175
177
|
favicon: previewData.site.favicon,
|
|
176
178
|
exposeGenerator: previewData.site.expose_generator !== false,
|
|
177
179
|
options,
|
|
178
|
-
|
|
180
|
+
feedGeneratedAt: toDate(previewData.generated_at),
|
|
179
181
|
emitted: {
|
|
180
182
|
frontPage: null,
|
|
181
183
|
indexRoutes: [],
|
|
@@ -775,8 +777,8 @@ function normalizeWidgetItem(item, media_origin) {
|
|
|
775
777
|
}
|
|
776
778
|
|
|
777
779
|
function normalizeCustomCss(customCss) {
|
|
778
|
-
const content =
|
|
779
|
-
return content ? { content } : undefined;
|
|
780
|
+
const content = customCss?.content;
|
|
781
|
+
return typeof content === 'string' && content.trim() ? { content } : undefined;
|
|
780
782
|
}
|
|
781
783
|
|
|
782
784
|
function normalizeCustomHtml(customHtml) {
|
|
@@ -2248,61 +2250,6 @@ async function normalizeAndValidateThemePackage(themePackage) {
|
|
|
2248
2250
|
};
|
|
2249
2251
|
}
|
|
2250
2252
|
|
|
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
2253
|
function normalizeThemePackageMetadata(sourceMetadata, manifest) {
|
|
2307
2254
|
return {
|
|
2308
2255
|
...manifest,
|
|
@@ -2310,50 +2257,37 @@ function normalizeThemePackageMetadata(sourceMetadata, manifest) {
|
|
|
2310
2257
|
};
|
|
2311
2258
|
}
|
|
2312
2259
|
|
|
2313
|
-
|
|
2260
|
+
function buildAssetOutputs(assets, assetProcessor, options) {
|
|
2314
2261
|
const outputs = [];
|
|
2315
2262
|
|
|
2316
2263
|
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
2264
|
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(content)}` : '';
|
|
2330
2265
|
const targetPath = `assets/${assetPath.replace(/(\.[^.]+)$/, `${hash}$1`)}`;
|
|
2331
2266
|
|
|
2332
2267
|
outputs.push({
|
|
2333
2268
|
originalPath: assetPath,
|
|
2334
2269
|
path: targetPath,
|
|
2335
|
-
content
|
|
2336
|
-
contentType,
|
|
2270
|
+
content,
|
|
2271
|
+
contentType: getContentType(assetPath),
|
|
2337
2272
|
});
|
|
2338
2273
|
}
|
|
2339
2274
|
|
|
2340
2275
|
return outputs;
|
|
2341
2276
|
}
|
|
2342
2277
|
|
|
2343
|
-
|
|
2344
|
-
const content =
|
|
2345
|
-
if (!content) {
|
|
2278
|
+
function buildCustomCssAsset(customCss, assetProcessor, options) {
|
|
2279
|
+
const content = customCss?.content;
|
|
2280
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
2346
2281
|
return null;
|
|
2347
2282
|
}
|
|
2348
2283
|
|
|
2349
2284
|
const sourceBuffer = new TextEncoder().encode(content);
|
|
2350
|
-
const processedContent = await assetProcessor.processCSS(content);
|
|
2351
2285
|
const hash = options.assetHashing ? `.${assetProcessor.generateAssetHash(sourceBuffer)}` : '';
|
|
2352
2286
|
|
|
2353
2287
|
return {
|
|
2354
2288
|
originalPath: '__zeropress_custom.css',
|
|
2355
2289
|
path: `assets/zeropress-custom${hash}.css`,
|
|
2356
|
-
content
|
|
2290
|
+
content,
|
|
2357
2291
|
contentType: 'text/css',
|
|
2358
2292
|
};
|
|
2359
2293
|
}
|
|
@@ -3296,11 +3230,13 @@ function normalizeSearchText(value) {
|
|
|
3296
3230
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
3297
3231
|
}
|
|
3298
3232
|
|
|
3299
|
-
function buildSearchAdapterJs() {
|
|
3233
|
+
function buildSearchAdapterJs(locale) {
|
|
3300
3234
|
const fieldWeightsJson = JSON.stringify(SEARCH_FIELD_WEIGHTS, null, 2);
|
|
3235
|
+
const localeJson = JSON.stringify(locale);
|
|
3301
3236
|
return `const FIELD_WEIGHTS = ${fieldWeightsJson};
|
|
3302
3237
|
const FIELD_NAMES = Object.keys(FIELD_WEIGHTS);
|
|
3303
3238
|
const RECENCY_BOOST_MAX = ${SEARCH_RECENCY_BOOST_MAX};
|
|
3239
|
+
const SEGMENTER_LOCALE = ${localeJson};
|
|
3304
3240
|
const DEFAULT_LIMIT = 20;
|
|
3305
3241
|
const BM25_K1 = 1.2;
|
|
3306
3242
|
const BM25_B = 0.75;
|
|
@@ -3540,44 +3476,59 @@ function tokenize(value) {
|
|
|
3540
3476
|
}
|
|
3541
3477
|
|
|
3542
3478
|
const tokens = [];
|
|
3479
|
+
const cjkRuns = Array.from(
|
|
3480
|
+
text.matchAll(/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu),
|
|
3481
|
+
(match) => match[0],
|
|
3482
|
+
);
|
|
3483
|
+
const nonCjkText = text.replace(
|
|
3484
|
+
/[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]+/gu,
|
|
3485
|
+
' ',
|
|
3486
|
+
);
|
|
3487
|
+
let segmented = false;
|
|
3488
|
+
|
|
3543
3489
|
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
3544
3490
|
try {
|
|
3545
|
-
const segmenter = new Intl.Segmenter(
|
|
3546
|
-
for (const part of segmenter.segment(
|
|
3491
|
+
const segmenter = new Intl.Segmenter(SEGMENTER_LOCALE, { granularity: 'word' });
|
|
3492
|
+
for (const part of segmenter.segment(nonCjkText)) {
|
|
3547
3493
|
if (part.isWordLike && isUsefulToken(part.segment)) {
|
|
3548
3494
|
tokens.push(part.segment);
|
|
3549
3495
|
}
|
|
3550
3496
|
}
|
|
3497
|
+
segmented = true;
|
|
3551
3498
|
} catch {
|
|
3552
3499
|
// Fall through to regex tokenization.
|
|
3553
3500
|
}
|
|
3554
3501
|
}
|
|
3555
3502
|
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3503
|
+
if (!segmented) {
|
|
3504
|
+
for (const match of nonCjkText.matchAll(/[\\p{Letter}\\p{Number}]+/gu)) {
|
|
3505
|
+
if (isUsefulToken(match[0])) {
|
|
3506
|
+
tokens.push(match[0]);
|
|
3507
|
+
}
|
|
3559
3508
|
}
|
|
3560
3509
|
}
|
|
3561
3510
|
|
|
3562
|
-
for (const
|
|
3563
|
-
tokens.push(...
|
|
3511
|
+
for (const run of cjkRuns) {
|
|
3512
|
+
tokens.push(...buildCjkTokens(run));
|
|
3564
3513
|
}
|
|
3565
3514
|
|
|
3566
3515
|
return tokens;
|
|
3567
3516
|
}
|
|
3568
3517
|
|
|
3569
|
-
function
|
|
3570
|
-
const
|
|
3571
|
-
|
|
3572
|
-
|
|
3518
|
+
function buildCjkTokens(value) {
|
|
3519
|
+
const characters = Array.from(value);
|
|
3520
|
+
const tokens = new Set();
|
|
3521
|
+
if (isUsefulToken(value)) {
|
|
3522
|
+
tokens.add(value);
|
|
3573
3523
|
}
|
|
3574
3524
|
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3525
|
+
if (characters.length > 2) {
|
|
3526
|
+
for (let index = 0; index <= characters.length - 2; index += 1) {
|
|
3527
|
+
tokens.add(characters.slice(index, index + 2).join(''));
|
|
3528
|
+
}
|
|
3578
3529
|
}
|
|
3579
|
-
|
|
3580
|
-
return tokens;
|
|
3530
|
+
|
|
3531
|
+
return Array.from(tokens);
|
|
3581
3532
|
}
|
|
3582
3533
|
|
|
3583
3534
|
function isUsefulToken(value) {
|
|
@@ -3683,7 +3634,7 @@ function normalizeLimit(value) {
|
|
|
3683
3634
|
`;
|
|
3684
3635
|
}
|
|
3685
3636
|
|
|
3686
|
-
function buildSitemapXml(site, emitted,
|
|
3637
|
+
function buildSitemapXml(site, emitted, stylesheetHref = '') {
|
|
3687
3638
|
const entries = [
|
|
3688
3639
|
...(emitted.frontPage && emitted.frontPage.includeInSitemap !== false
|
|
3689
3640
|
? [{
|
|
@@ -3732,7 +3683,7 @@ function buildSitemapXml(site, emitted, generatedAt, stylesheetHref = '') {
|
|
|
3732
3683
|
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
3684
|
}
|
|
3734
3685
|
|
|
3735
|
-
function buildFeedXml(site, emitted,
|
|
3686
|
+
function buildFeedXml(site, emitted, feedGeneratedAt) {
|
|
3736
3687
|
const channelLink = resolveSiteUrl(site.url, '/');
|
|
3737
3688
|
const selfLink = resolveSiteUrl(site.url, '/feed.xml');
|
|
3738
3689
|
const items = [...emitted.posts]
|
|
@@ -3744,7 +3695,7 @@ function buildFeedXml(site, emitted, generatedAt) {
|
|
|
3744
3695
|
})
|
|
3745
3696
|
.join('\n');
|
|
3746
3697
|
|
|
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>${
|
|
3698
|
+
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
3699
|
}
|
|
3749
3700
|
|
|
3750
3701
|
function buildRobotsTxt(site) {
|
|
@@ -3779,6 +3730,7 @@ function getContentType(assetPath) {
|
|
|
3779
3730
|
html: 'text/html',
|
|
3780
3731
|
css: 'text/css',
|
|
3781
3732
|
js: 'application/javascript',
|
|
3733
|
+
mjs: 'application/javascript',
|
|
3782
3734
|
json: 'application/json',
|
|
3783
3735
|
xml: 'application/xml',
|
|
3784
3736
|
txt: 'text/plain',
|
|
@@ -3830,7 +3782,33 @@ function hasCanonicalSiteUrl(siteUrl) {
|
|
|
3830
3782
|
}
|
|
3831
3783
|
|
|
3832
3784
|
function toDate(value) {
|
|
3833
|
-
|
|
3785
|
+
if (value instanceof Date) {
|
|
3786
|
+
const timestamp = value.getTime();
|
|
3787
|
+
if (!Number.isFinite(timestamp)) {
|
|
3788
|
+
throw new Error('Invalid RFC 3339 timestamp');
|
|
3789
|
+
}
|
|
3790
|
+
return new Date(timestamp);
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
if (typeof value !== 'string' || !value) {
|
|
3794
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${String(value)}`);
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
const timestampMatch = value.match(RFC3339_TIMESTAMP_PATTERN);
|
|
3798
|
+
if (!timestampMatch) {
|
|
3799
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
const isLeapSecond = timestampMatch[2] === '60';
|
|
3803
|
+
const parseValue = isLeapSecond
|
|
3804
|
+
? `${timestampMatch[1]}59${timestampMatch[3] || ''}${timestampMatch[4]}`
|
|
3805
|
+
: value;
|
|
3806
|
+
const timestamp = Date.parse(parseValue);
|
|
3807
|
+
if (!Number.isFinite(timestamp)) {
|
|
3808
|
+
throw new Error(`Invalid RFC 3339 timestamp: ${value}`);
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
return new Date(timestamp + (isLeapSecond ? 1000 : 0));
|
|
3834
3812
|
}
|
|
3835
3813
|
|
|
3836
3814
|
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', '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;
|