edgepress 2026.10.12 → 2026.10.13
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 +12 -2
- package/config.yml +171 -171
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
- package/content/assets/edgepress-markdown-guide.svg +21 -21
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/product-demos.md +30 -30
- package/content/pages/deployment/en.json +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/languages/base/en.json +98 -98
- package/languages/packs/zh-CN.json +98 -98
- package/package.json +90 -90
- package/src/assets.js +187 -186
- package/src/browser-audit.js +158 -158
- package/src/build.js +293 -291
- package/src/cli.js +364 -364
- package/src/content.js +233 -233
- package/src/data-saver.js +3 -3
- package/src/generators.js +414 -420
- package/src/i18n.js +86 -86
- package/src/markdown.js +195 -160
- package/src/page-check.js +1 -1
- package/src/plugin-api.js +77 -77
- package/src/report-visuals.js +642 -642
- package/src/revision-cards.js +30 -30
- package/src/rss.js +100 -0
- package/src/theme.js +4 -4
- package/src/video-metadata.js +36 -0
- package/static/edgepress/fonts/faces.css +2217 -2217
- package/static/edgepress/media-viewer.js +67 -10
- package/static/edgepress/project-demo.js +83 -83
- package/static/edgepress/search.js +84 -84
- package/tests/assets-runtime.test.mjs +26 -26
- package/tests/audit.test.mjs +142 -142
- package/tests/brand-regression.test.mjs +110 -110
- package/tests/document-package.test.mjs +2 -2
- package/tests/embed-syntax.test.mjs +68 -68
- package/tests/image-viewer.test.mjs +36 -36
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/oembed-layout.test.mjs +6 -1
- package/tests/project-demo.test.mjs +73 -73
- package/tests/release-version.test.mjs +28 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/writing.test.mjs +83 -83
- package/themes/atelier/assets/style.css +340 -340
- package/themes/default/assets/style.css +371 -371
- package/themes/default/layouts/partials/head.html +1 -1
- package/themes/folio/assets/style.css +515 -515
- package/themes/folio/theme.json +5 -5
- package/themes/signal/assets/style.css +332 -332
- package/tools/README.md +10 -10
- package/tools/assets/generate-brand-assets.py +160 -160
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/demo-workbench.mjs +53 -53
- package/tools/recordings/desktop-recording.mjs +70 -70
- package/tools/recordings/vm-desktop.mjs +59 -59
package/src/assets.js
CHANGED
|
@@ -1,130 +1,131 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { copyFile, readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
3
|
-
import { dirname, extname, relative, resolve, sep } from 'node:path';
|
|
4
|
-
import * as posix from 'node:path/posix';
|
|
5
|
-
import {fileURLToPath} from 'node:url';
|
|
6
|
-
import { mapLimit } from './concurrency.js';
|
|
7
|
-
import {extendConsentPolicy,serializeConsentHeaders} from './consent-csp.js';
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import * as posix from 'node:path/posix';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import { mapLimit } from './concurrency.js';
|
|
7
|
+
import {extendConsentPolicy,serializeConsentHeaders} from './consent-csp.js';
|
|
8
8
|
import {imageViewerAssets, mediaViewerAssets} from './media-assets.js';
|
|
9
9
|
import {documentViewerAssets} from '@jsw-teams/document-viewer/assets';
|
|
10
10
|
import {collectImageDimensions} from './image-dimensions.js';
|
|
11
|
+
import {collectVideoMetadata} from './video-metadata.js';
|
|
11
12
|
import {dataSaverLayout} from './data-saver.js';
|
|
12
13
|
import { build, transform } from 'esbuild';
|
|
13
|
-
|
|
14
|
-
async function walk(directory) {
|
|
15
|
-
let entries;
|
|
16
|
-
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
17
|
-
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
18
|
-
const files = [];
|
|
19
|
-
for (const entry of entries) {
|
|
20
|
-
const full = resolve(directory, entry.name);
|
|
21
|
-
if (entry.isDirectory()) files.push(...await walk(full));
|
|
22
|
-
else if (entry.isFile()) files.push(full);
|
|
23
|
-
}
|
|
24
|
-
return files;
|
|
25
|
-
}
|
|
26
|
-
|
|
14
|
+
|
|
15
|
+
async function walk(directory) {
|
|
16
|
+
let entries;
|
|
17
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
18
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
19
|
+
const files = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const full = resolve(directory, entry.name);
|
|
22
|
+
if (entry.isDirectory()) files.push(...await walk(full));
|
|
23
|
+
else if (entry.isFile()) files.push(full);
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
27
28
|
function isCodeAsset(path) {
|
|
28
29
|
const extension = extname(path).toLowerCase();
|
|
29
30
|
return extension === '.css' || extension === '.js' || extension === '.mjs';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function referencedPaths(path, content, known) {
|
|
33
|
-
if (!isCodeAsset(path)) return [];
|
|
34
|
-
const text = content.toString('utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function referencedPaths(path, content, known) {
|
|
34
|
+
if (!isCodeAsset(path)) return [];
|
|
35
|
+
const text = content.toString('utf8');
|
|
35
36
|
const expression = ['.js', '.mjs'].includes(extname(path).toLowerCase())
|
|
36
37
|
? /\b(?:from\s*|import\s*(?:\(\s*)?|new\s+URL\s*\(\s*)['"]([^'"]+)['"]/g
|
|
37
38
|
: /(?:@import\s*(?:url\()?\s*|url\(\s*)['"]?([^'")\s;]+)['"]?\s*\)?/gi;
|
|
38
|
-
const refs = [];
|
|
39
|
-
for (const match of text.matchAll(expression)) {
|
|
40
|
-
const specifier = match[1];
|
|
41
|
-
if (/^(?:[a-z]+:|\/\/|#)/i.test(specifier)) continue;
|
|
42
|
-
const clean = specifier.split(/[?#]/, 1)[0];
|
|
43
|
-
const target = clean.startsWith('/')
|
|
44
|
-
? clean.slice(1)
|
|
45
|
-
: posix.normalize(posix.join(posix.dirname(path), clean));
|
|
46
|
-
if (known.has(target) && isCodeAsset(target)) refs.push(target);
|
|
47
|
-
}
|
|
48
|
-
return [...new Set(refs)];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function rewriteCodeReferences(item, text, outputByOriginal) {
|
|
52
|
-
const extension = extname(item.path).toLowerCase();
|
|
53
|
-
const rewriteSpecifier = (specifier) => {
|
|
54
|
-
if (/^(?:[a-z]+:|\/\/|#)/i.test(specifier)) return null;
|
|
55
|
-
const splitAt = specifier.search(/[?#]/);
|
|
56
|
-
const clean = splitAt < 0 ? specifier : specifier.slice(0, splitAt);
|
|
57
|
-
const tail = splitAt < 0 ? '' : specifier.slice(splitAt);
|
|
58
|
-
const target = clean.startsWith('/')
|
|
59
|
-
? clean.slice(1)
|
|
60
|
-
: posix.normalize(posix.join(posix.dirname(item.path), clean));
|
|
61
|
-
const outputTarget = outputByOriginal.get(target);
|
|
62
|
-
if (!outputTarget) return null;
|
|
63
|
-
let rewritten;
|
|
64
|
-
if (clean.startsWith('/')) rewritten = '/' + outputTarget;
|
|
65
|
-
else {
|
|
66
|
-
rewritten = posix.relative(posix.dirname(item.outputPath), outputTarget);
|
|
67
|
-
if (!rewritten.startsWith('.')) rewritten = './' + rewritten;
|
|
68
|
-
}
|
|
69
|
-
return rewritten + tail;
|
|
70
|
-
};
|
|
71
|
-
|
|
39
|
+
const refs = [];
|
|
40
|
+
for (const match of text.matchAll(expression)) {
|
|
41
|
+
const specifier = match[1];
|
|
42
|
+
if (/^(?:[a-z]+:|\/\/|#)/i.test(specifier)) continue;
|
|
43
|
+
const clean = specifier.split(/[?#]/, 1)[0];
|
|
44
|
+
const target = clean.startsWith('/')
|
|
45
|
+
? clean.slice(1)
|
|
46
|
+
: posix.normalize(posix.join(posix.dirname(path), clean));
|
|
47
|
+
if (known.has(target) && isCodeAsset(target)) refs.push(target);
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(refs)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rewriteCodeReferences(item, text, outputByOriginal) {
|
|
53
|
+
const extension = extname(item.path).toLowerCase();
|
|
54
|
+
const rewriteSpecifier = (specifier) => {
|
|
55
|
+
if (/^(?:[a-z]+:|\/\/|#)/i.test(specifier)) return null;
|
|
56
|
+
const splitAt = specifier.search(/[?#]/);
|
|
57
|
+
const clean = splitAt < 0 ? specifier : specifier.slice(0, splitAt);
|
|
58
|
+
const tail = splitAt < 0 ? '' : specifier.slice(splitAt);
|
|
59
|
+
const target = clean.startsWith('/')
|
|
60
|
+
? clean.slice(1)
|
|
61
|
+
: posix.normalize(posix.join(posix.dirname(item.path), clean));
|
|
62
|
+
const outputTarget = outputByOriginal.get(target);
|
|
63
|
+
if (!outputTarget) return null;
|
|
64
|
+
let rewritten;
|
|
65
|
+
if (clean.startsWith('/')) rewritten = '/' + outputTarget;
|
|
66
|
+
else {
|
|
67
|
+
rewritten = posix.relative(posix.dirname(item.outputPath), outputTarget);
|
|
68
|
+
if (!rewritten.startsWith('.')) rewritten = './' + rewritten;
|
|
69
|
+
}
|
|
70
|
+
return rewritten + tail;
|
|
71
|
+
};
|
|
72
|
+
|
|
72
73
|
if (extension === '.js' || extension === '.mjs') {
|
|
73
74
|
const expression = /(\b(?:from\s*|import\s*(?:\(\s*)?|new\s+URL\s*\(\s*))(['"])([^'"]+)\2/g;
|
|
74
|
-
return text.replace(expression, (match, prefix, quote, specifier) => {
|
|
75
|
-
const rewritten = rewriteSpecifier(specifier);
|
|
76
|
-
return rewritten === null ? match : prefix + quote + rewritten + quote;
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
75
|
+
return text.replace(expression, (match, prefix, quote, specifier) => {
|
|
76
|
+
const rewritten = rewriteSpecifier(specifier);
|
|
77
|
+
return rewritten === null ? match : prefix + quote + rewritten + quote;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
80
81
|
const expression = /((?:@import\s*(?:url\()?\s*|url\(\s*))(['"]?)([^)'"\s;]+)(['"]?\s*\)?)/gi;
|
|
81
|
-
return text.replace(expression, (match, prefix, quote, specifier, suffix) => {
|
|
82
|
-
const rewritten = rewriteSpecifier(specifier);
|
|
83
|
-
return rewritten === null ? match : prefix + quote + rewritten + suffix;
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
82
|
+
return text.replace(expression, (match, prefix, quote, specifier, suffix) => {
|
|
83
|
+
const rewritten = rewriteSpecifier(specifier);
|
|
84
|
+
return rewritten === null ? match : prefix + quote + rewritten + suffix;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
87
88
|
export async function collectAssets(config, { documentViewer = false, mediaViewer = false } = {}) {
|
|
88
|
-
const sources = [];
|
|
89
|
-
const directories = [
|
|
90
|
-
{ root: config.resolvedPaths.static, name: 'static' },
|
|
91
|
-
{ root: resolve(config.resolvedPaths.content, 'assets'), name: 'content/assets' },
|
|
92
|
-
{ root: resolve(config.resolvedPaths.theme, 'assets'), name: 'theme assets' }
|
|
93
|
-
];
|
|
94
|
-
for (const { root, name } of directories) {
|
|
95
|
-
for (const source of await walk(root)) {
|
|
96
|
-
const path = relative(root, source).split(sep).join('/');
|
|
97
|
-
if (path.split('/').includes('.gitkeep')) continue;
|
|
98
|
-
sources.push({ source, path, sourceName: name });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Library consumers do not need to copy framework runtime source into their
|
|
102
|
-
// repositories. Project-owned overrides keep precedence for existing sites.
|
|
103
|
-
const runtimeRoot=fileURLToPath(new URL('../static/edgepress/',import.meta.url));
|
|
89
|
+
const sources = [];
|
|
90
|
+
const directories = [
|
|
91
|
+
{ root: config.resolvedPaths.static, name: 'static' },
|
|
92
|
+
{ root: resolve(config.resolvedPaths.content, 'assets'), name: 'content/assets' },
|
|
93
|
+
{ root: resolve(config.resolvedPaths.theme, 'assets'), name: 'theme assets' }
|
|
94
|
+
];
|
|
95
|
+
for (const { root, name } of directories) {
|
|
96
|
+
for (const source of await walk(root)) {
|
|
97
|
+
const path = relative(root, source).split(sep).join('/');
|
|
98
|
+
if (path.split('/').includes('.gitkeep')) continue;
|
|
99
|
+
sources.push({ source, path, sourceName: name });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Library consumers do not need to copy framework runtime source into their
|
|
103
|
+
// repositories. Project-owned overrides keep precedence for existing sites.
|
|
104
|
+
const runtimeRoot=fileURLToPath(new URL('../static/edgepress/',import.meta.url));
|
|
104
105
|
const supplied=new Set(sources.map(item=>item.path));
|
|
105
106
|
if (documentViewer) for (const item of await documentViewerAssets('edgepress/document-viewer')) if (!supplied.has(item.path)) sources.push(item);
|
|
106
107
|
for(const item of await imageViewerAssets())if(!supplied.has(item.path))sources.push(item);
|
|
107
108
|
if (mediaViewer) for (const item of await mediaViewerAssets()) if (!supplied.has(item.path)) sources.push(item);
|
|
108
|
-
for(const source of await walk(runtimeRoot)) {
|
|
109
|
-
const path='edgepress/'+relative(runtimeRoot,source).split(sep).join('/');
|
|
110
|
-
if(!supplied.has(path))sources.push({source,path,sourceName:'EdgePress runtime'});
|
|
111
|
-
}
|
|
112
|
-
// Built-in navigation and page icons live with the package's content assets,
|
|
113
|
-
// not its browser scripts. Include only this library, not example-site media.
|
|
114
|
-
const iconsRoot=fileURLToPath(new URL('../content/assets/edgepress/icons/',import.meta.url));
|
|
115
|
-
for(const source of await walk(iconsRoot)) {
|
|
116
|
-
const path='edgepress/icons/'+relative(iconsRoot,source).split(sep).join('/');
|
|
117
|
-
if(!supplied.has(path))sources.push({source,path,sourceName:'EdgePress icons'});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const headers = [];
|
|
121
|
-
const assetSources = [];
|
|
122
|
-
for (const item of sources) {
|
|
123
|
-
if (item.path.toLowerCase() === '_headers') headers.push(item);
|
|
124
|
-
else assetSources.push(item);
|
|
125
|
-
}
|
|
126
|
-
const [headerContents, items] = await Promise.all([
|
|
127
|
-
mapLimit(headers, config.concurrency, async (item) => (await readFile(item.source)).toString('utf8')),
|
|
109
|
+
for(const source of await walk(runtimeRoot)) {
|
|
110
|
+
const path='edgepress/'+relative(runtimeRoot,source).split(sep).join('/');
|
|
111
|
+
if(!supplied.has(path))sources.push({source,path,sourceName:'EdgePress runtime'});
|
|
112
|
+
}
|
|
113
|
+
// Built-in navigation and page icons live with the package's content assets,
|
|
114
|
+
// not its browser scripts. Include only this library, not example-site media.
|
|
115
|
+
const iconsRoot=fileURLToPath(new URL('../content/assets/edgepress/icons/',import.meta.url));
|
|
116
|
+
for(const source of await walk(iconsRoot)) {
|
|
117
|
+
const path='edgepress/icons/'+relative(iconsRoot,source).split(sep).join('/');
|
|
118
|
+
if(!supplied.has(path))sources.push({source,path,sourceName:'EdgePress icons'});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const headers = [];
|
|
122
|
+
const assetSources = [];
|
|
123
|
+
for (const item of sources) {
|
|
124
|
+
if (item.path.toLowerCase() === '_headers') headers.push(item);
|
|
125
|
+
else assetSources.push(item);
|
|
126
|
+
}
|
|
127
|
+
const [headerContents, items] = await Promise.all([
|
|
128
|
+
mapLimit(headers, config.concurrency, async (item) => (await readFile(item.source)).toString('utf8')),
|
|
128
129
|
mapLimit(assetSources, config.concurrency, async (item) => {
|
|
129
130
|
if (!isCodeAsset(item.path)) return { ...item, content: null };
|
|
130
131
|
const source = item.content ?? await readFile(item.source);
|
|
@@ -191,68 +192,68 @@ export async function collectAssets(config, { documentViewer = false, mediaViewe
|
|
|
191
192
|
const layout = await dataSaverLayout(config, items);
|
|
192
193
|
if (layout) items.push(layout);
|
|
193
194
|
const originals = new Map();
|
|
194
|
-
for (const item of items) {
|
|
195
|
-
const normalized = item.path.toLowerCase();
|
|
196
|
-
const previousSource = originals.get(normalized);
|
|
197
|
-
if (previousSource) {
|
|
198
|
-
throw new Error(previousSource + ' and ' + item.sourceName + ' assets have the same output path: ' + item.path);
|
|
199
|
-
}
|
|
200
|
-
originals.set(normalized, item.sourceName);
|
|
201
|
-
}
|
|
202
|
-
const byOriginal = new Map(items.map((item) => [item.path, item]));
|
|
203
|
-
const dependencies = new Map();
|
|
204
|
-
for (const item of items) dependencies.set(item.path, referencedPaths(item.path, item.content, byOriginal));
|
|
205
|
-
|
|
206
|
-
const outputByOriginal = new Map();
|
|
207
|
-
for (const item of items) {
|
|
208
|
-
if (!isCodeAsset(item.path)) outputByOriginal.set(item.path, item.path);
|
|
209
|
-
else {
|
|
210
|
-
const reachable = new Set();
|
|
211
|
-
const visit = (path) => {
|
|
212
|
-
for (const dependency of dependencies.get(path) ?? []) {
|
|
213
|
-
if (reachable.has(dependency)) continue;
|
|
214
|
-
reachable.add(dependency);
|
|
215
|
-
visit(dependency);
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
visit(item.path);
|
|
219
|
-
const sourceDigests = [...reachable].sort().map((path) =>
|
|
220
|
-
path + ':' + createHash('sha256').update(byOriginal.get(path).content).digest('hex')
|
|
221
|
-
).join('\n');
|
|
222
|
-
const digest = createHash('sha256')
|
|
223
|
-
.update(item.content)
|
|
224
|
-
.update('\n')
|
|
225
|
-
.update(sourceDigests)
|
|
226
|
-
.digest('hex')
|
|
227
|
-
.slice(0, 16);
|
|
228
|
-
const extension = extname(item.path).toLowerCase();
|
|
229
|
-
outputByOriginal.set(item.path, item.path.slice(0, -extname(item.path).length) + '.' + digest + extension);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const assets = items.map((item) => {
|
|
234
|
-
const outputPath = outputByOriginal.get(item.path);
|
|
235
|
-
if (!isCodeAsset(item.path)) return { path: outputPath, source: item.source };
|
|
236
|
-
const content = Buffer.from(rewriteCodeReferences(
|
|
237
|
-
{ ...item, outputPath }, item.content.toString('utf8'), outputByOriginal
|
|
238
|
-
), 'utf8');
|
|
239
|
-
return { path: outputPath, content };
|
|
240
|
-
});
|
|
241
|
-
const urlMap = Object.create(null);
|
|
242
|
-
for (const item of items) urlMap['/' + item.path] = '/' + outputByOriginal.get(item.path);
|
|
243
|
-
config.assetManifest = urlMap;
|
|
244
|
-
return { assets, urlMap, imageDimensions:await collectImageDimensions(assets), existingHeaders: extendConsentPolicy(headerContents.join('\n\n'),config) };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export async function writeAssets(bundle, output, concurrency = 8) {
|
|
248
|
-
await mapLimit(bundle.assets, concurrency, async (asset) => {
|
|
249
|
-
const target = resolve(output, ...asset.path.split('/'));
|
|
250
|
-
await mkdir(dirname(target), { recursive: true });
|
|
251
|
-
if (asset.source) await copyFile(asset.source, target);
|
|
252
|
-
else await writeFile(target, asset.content);
|
|
253
|
-
});
|
|
254
|
-
// Cloudflare already revalidates unversioned assets by default. A broad cache
|
|
255
|
-
// rule would be concatenated with exact immutable rules instead of overridden.
|
|
195
|
+
for (const item of items) {
|
|
196
|
+
const normalized = item.path.toLowerCase();
|
|
197
|
+
const previousSource = originals.get(normalized);
|
|
198
|
+
if (previousSource) {
|
|
199
|
+
throw new Error(previousSource + ' and ' + item.sourceName + ' assets have the same output path: ' + item.path);
|
|
200
|
+
}
|
|
201
|
+
originals.set(normalized, item.sourceName);
|
|
202
|
+
}
|
|
203
|
+
const byOriginal = new Map(items.map((item) => [item.path, item]));
|
|
204
|
+
const dependencies = new Map();
|
|
205
|
+
for (const item of items) dependencies.set(item.path, referencedPaths(item.path, item.content, byOriginal));
|
|
206
|
+
|
|
207
|
+
const outputByOriginal = new Map();
|
|
208
|
+
for (const item of items) {
|
|
209
|
+
if (!isCodeAsset(item.path)) outputByOriginal.set(item.path, item.path);
|
|
210
|
+
else {
|
|
211
|
+
const reachable = new Set();
|
|
212
|
+
const visit = (path) => {
|
|
213
|
+
for (const dependency of dependencies.get(path) ?? []) {
|
|
214
|
+
if (reachable.has(dependency)) continue;
|
|
215
|
+
reachable.add(dependency);
|
|
216
|
+
visit(dependency);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
visit(item.path);
|
|
220
|
+
const sourceDigests = [...reachable].sort().map((path) =>
|
|
221
|
+
path + ':' + createHash('sha256').update(byOriginal.get(path).content).digest('hex')
|
|
222
|
+
).join('\n');
|
|
223
|
+
const digest = createHash('sha256')
|
|
224
|
+
.update(item.content)
|
|
225
|
+
.update('\n')
|
|
226
|
+
.update(sourceDigests)
|
|
227
|
+
.digest('hex')
|
|
228
|
+
.slice(0, 16);
|
|
229
|
+
const extension = extname(item.path).toLowerCase();
|
|
230
|
+
outputByOriginal.set(item.path, item.path.slice(0, -extname(item.path).length) + '.' + digest + extension);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const assets = items.map((item) => {
|
|
235
|
+
const outputPath = outputByOriginal.get(item.path);
|
|
236
|
+
if (!isCodeAsset(item.path)) return { path: outputPath, source: item.source };
|
|
237
|
+
const content = Buffer.from(rewriteCodeReferences(
|
|
238
|
+
{ ...item, outputPath }, item.content.toString('utf8'), outputByOriginal
|
|
239
|
+
), 'utf8');
|
|
240
|
+
return { path: outputPath, content };
|
|
241
|
+
});
|
|
242
|
+
const urlMap = Object.create(null);
|
|
243
|
+
for (const item of items) urlMap['/' + item.path] = '/' + outputByOriginal.get(item.path);
|
|
244
|
+
config.assetManifest = urlMap;
|
|
245
|
+
return { assets, urlMap, imageDimensions:await collectImageDimensions(assets), videoMetadata:await collectVideoMetadata(assets), existingHeaders: extendConsentPolicy(headerContents.join('\n\n'),config) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function writeAssets(bundle, output, concurrency = 8) {
|
|
249
|
+
await mapLimit(bundle.assets, concurrency, async (asset) => {
|
|
250
|
+
const target = resolve(output, ...asset.path.split('/'));
|
|
251
|
+
await mkdir(dirname(target), { recursive: true });
|
|
252
|
+
if (asset.source) await copyFile(asset.source, target);
|
|
253
|
+
else await writeFile(target, asset.content);
|
|
254
|
+
});
|
|
255
|
+
// Cloudflare already revalidates unversioned assets by default. A broad cache
|
|
256
|
+
// rule would be concatenated with exact immutable rules instead of overridden.
|
|
256
257
|
const securityRules = '/*\n X-Content-Type-Options: nosniff\n Referrer-Policy: strict-origin-when-cross-origin';
|
|
257
258
|
// Unicode fonts contain hundreds of subsets. Static hosts accept 100 header
|
|
258
259
|
// rules, so group a directory only when every WOFF2 file in it is hashed.
|
|
@@ -272,10 +273,10 @@ export async function writeAssets(bundle, output, concurrency = 8) {
|
|
|
272
273
|
for (const directory of fontDirectories) immutableRules.push('/' + (directory === '.' ? '' : directory + '/') + '*.woff2\n Cache-Control: public, max-age=31536000, immutable');
|
|
273
274
|
const documentDirectories = new Set(bundle.assets.map(asset => asset.path.match(/^(.*\/pdf-assets\.[a-f0-9]{16})\//)?.[1]).filter(Boolean));
|
|
274
275
|
for (const directory of documentDirectories) immutableRules.push('/' + directory + '/*\n Cache-Control: public, max-age=31536000, immutable');
|
|
275
|
-
const mediaRules = await Promise.all(bundle.assets.filter(asset=>/\.(mp4|webm)$/i.test(asset.path)).map(async asset=>{
|
|
276
|
-
const {size}=await stat(resolve(output,...asset.path.split('/')));
|
|
277
|
-
return '/'+asset.path+'\n Content-Length: '+size+'\n Accept-Ranges: bytes';
|
|
278
|
-
}));
|
|
276
|
+
const mediaRules = await Promise.all(bundle.assets.filter(asset=>/\.(mp4|webm)$/i.test(asset.path)).map(async asset=>{
|
|
277
|
+
const {size}=await stat(resolve(output,...asset.path.split('/')));
|
|
278
|
+
return '/'+asset.path+'\n Content-Length: '+size+'\n Accept-Ranges: bytes';
|
|
279
|
+
}));
|
|
279
280
|
if (immutableRules.length + mediaRules.length > 85) {
|
|
280
281
|
const immutablePath = /\.[a-f0-9]{16}\.(?:css|m?js|json|png|jpe?g|webp|avif|svg|gif|mp4|webm|woff2?)$/i;
|
|
281
282
|
const patterns = new Map();
|
|
@@ -298,15 +299,15 @@ export async function writeAssets(bundle, output, concurrency = 8) {
|
|
|
298
299
|
const headers = [bundle.existingHeaders.trim(), securityRules, ...immutableRules, ...mediaRules].filter(Boolean).join('\n\n') + '\n';
|
|
299
300
|
const count = headers.split(/\r?\n/).filter(line => line && !/^\s|^#/.test(line)).length;
|
|
300
301
|
if (count > 100) throw new Error('Static header rules exceed 100 after safe cache grouping; reduce custom rules or per-file media headers.');
|
|
301
|
-
await writeFile(resolve(output, '_headers'), serializeConsentHeaders(headers), 'utf8');
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function rewriteAssetLinks(html, urlMap) {
|
|
305
|
-
return html.replace(/(\b(?:src|href)\s*=\s*)(["'])([^"']+)\2/gi, (match, prefix, quote, value) => {
|
|
306
|
-
const suffixAt = value.search(/[?#]/);
|
|
307
|
-
const sourcePath = suffixAt < 0 ? value : value.slice(0, suffixAt);
|
|
308
|
-
const suffix = suffixAt < 0 ? '' : value.slice(suffixAt);
|
|
309
|
-
const replacement = urlMap[sourcePath];
|
|
310
|
-
return replacement ? prefix + quote + replacement + suffix + quote : match;
|
|
311
|
-
});
|
|
312
|
-
}
|
|
302
|
+
await writeFile(resolve(output, '_headers'), serializeConsentHeaders(headers), 'utf8');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function rewriteAssetLinks(html, urlMap) {
|
|
306
|
+
return html.replace(/(\b(?:src|href)\s*=\s*)(["'])([^"']+)\2/gi, (match, prefix, quote, value) => {
|
|
307
|
+
const suffixAt = value.search(/[?#]/);
|
|
308
|
+
const sourcePath = suffixAt < 0 ? value : value.slice(0, suffixAt);
|
|
309
|
+
const suffix = suffixAt < 0 ? '' : value.slice(suffixAt);
|
|
310
|
+
const replacement = urlMap[sourcePath];
|
|
311
|
+
return replacement ? prefix + quote + replacement + suffix + quote : match;
|
|
312
|
+
});
|
|
313
|
+
}
|