@thomas.f/prnext 0.1.0-alpha.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 +54 -0
- package/build/action-exports.mjs +107 -0
- package/build/actions.mjs +214 -0
- package/build/app-config.mjs +138 -0
- package/build/app-routing.mjs +119 -0
- package/build/app-static.mjs +158 -0
- package/build/app.mjs +536 -0
- package/build/cache-components.mjs +91 -0
- package/build/cache-handlers.mjs +54 -0
- package/build/check.mjs +40 -0
- package/build/compiler.mjs +222 -0
- package/build/config.mjs +193 -0
- package/build/css-modules.mjs +114 -0
- package/build/custom-routes.mjs +363 -0
- package/build/dev-worker.mjs +18 -0
- package/build/dev.mjs +76 -0
- package/build/directory-cache.mjs +29 -0
- package/build/dynamic.mjs +125 -0
- package/build/edge.mjs +141 -0
- package/build/font-data.LICENSE.txt +25 -0
- package/build/font-data.json +1 -0
- package/build/font-files.mjs +221 -0
- package/build/fonts.mjs +131 -0
- package/build/framework-imports.mjs +17 -0
- package/build/i18n.mjs +39 -0
- package/build/images-config.mjs +62 -0
- package/build/images.mjs +86 -0
- package/build/incremental.mjs +76 -0
- package/build/index.mjs +356 -0
- package/build/loader-cache.mjs +69 -0
- package/build/metadata-images.mjs +72 -0
- package/build/metadata-routes.mjs +77 -0
- package/build/middleware.mjs +231 -0
- package/build/migrate.mjs +289 -0
- package/build/module-resolution.mjs +78 -0
- package/build/native-discovery.mjs +134 -0
- package/build/native-packages.mjs +105 -0
- package/build/npm-packages.mjs +110 -0
- package/build/og.mjs +14 -0
- package/build/page-extensions.mjs +12 -0
- package/build/passthrough-loader.cjs +4 -0
- package/build/precompress.mjs +122 -0
- package/build/scan.mjs +178 -0
- package/build/script-workers.mjs +25 -0
- package/build/server-actions-config.mjs +27 -0
- package/build/source-maps.mjs +34 -0
- package/build/standalone-config.mjs +19 -0
- package/build/standalone-runner.mjs +21 -0
- package/build/standalone-worker.mjs +5 -0
- package/build/standalone.mjs +240 -0
- package/build/static-export.mjs +93 -0
- package/build/styles.mjs +174 -0
- package/build/transform-cache.mjs +50 -0
- package/build/transform.mjs +155 -0
- package/build/turbopack-conditions.mjs +59 -0
- package/build/webpack-bridge.cjs +3 -0
- package/build/webpack-graph.mjs +256 -0
- package/build/webpack-loader.cjs +9 -0
- package/cli.mjs +210 -0
- package/compat/app-client-page.cjs +32 -0
- package/compat/app-context.cjs +40 -0
- package/compat/app-context.d.cts +20 -0
- package/compat/app-dynamic.cjs +34 -0
- package/compat/app-error-boundary.cjs +53 -0
- package/compat/app-global-error-boundary.cjs +34 -0
- package/compat/app-layout-context.cjs +27 -0
- package/compat/app-navigation-boundary.cjs +47 -0
- package/compat/app.cjs +33 -0
- package/compat/app.d.cts +9 -0
- package/compat/cache-handlers.cjs +223 -0
- package/compat/cache-life.cjs +34 -0
- package/compat/cache.cjs +99 -0
- package/compat/cache.d.cts +36 -0
- package/compat/compat/router.d.cts +2 -0
- package/compat/compat-router.cjs +7 -0
- package/compat/constants.cjs +8 -0
- package/compat/constants.d.cts +7 -0
- package/compat/cookies.cjs +129 -0
- package/compat/data-cache.cjs +389 -0
- package/compat/dist/shared/lib/app-router-context.shared-runtime.d.cts +3 -0
- package/compat/dist/shared/lib/router-context.d.cts +1 -0
- package/compat/dist/shared/lib/router-context.shared-runtime.d.cts +3 -0
- package/compat/document.cjs +73 -0
- package/compat/document.d.cts +41 -0
- package/compat/draft.cjs +28 -0
- package/compat/dynamic-bailout.cjs +13 -0
- package/compat/dynamic.cjs +119 -0
- package/compat/dynamic.d.cts +32 -0
- package/compat/error.cjs +36 -0
- package/compat/error.d.cts +15 -0
- package/compat/fetch.d.cts +10 -0
- package/compat/font/google.d.cts +1 -0
- package/compat/font/local.d.cts +2 -0
- package/compat/font-google.cjs +2 -0
- package/compat/font-google.d.cts +1944 -0
- package/compat/font-local.cjs +5 -0
- package/compat/font-local.d.cts +3 -0
- package/compat/font-types.d.cts +25 -0
- package/compat/head.cjs +180 -0
- package/compat/head.d.cts +3 -0
- package/compat/headers.cjs +89 -0
- package/compat/headers.d.cts +26 -0
- package/compat/image-config.cjs +3 -0
- package/compat/image-imports.d.ts +12 -0
- package/compat/image-shared.cjs +62 -0
- package/compat/image.cjs +64 -0
- package/compat/image.d.cts +35 -0
- package/compat/incremental-cache.cjs +186 -0
- package/compat/index.d.ts +159 -0
- package/compat/instant-samples.cjs +39 -0
- package/compat/instant.cjs +13 -0
- package/compat/link.cjs +68 -0
- package/compat/link.d.cts +17 -0
- package/compat/locale.cjs +30 -0
- package/compat/metadata.d.cts +77 -0
- package/compat/navigation-controls.cjs +27 -0
- package/compat/navigation-server.cjs +25 -0
- package/compat/navigation.cjs +52 -0
- package/compat/navigation.d.cts +28 -0
- package/compat/next-app-router-context.cjs +6 -0
- package/compat/next-router-context.cjs +3 -0
- package/compat/og.cjs +32 -0
- package/compat/og.d.cts +1 -0
- package/compat/paths.cjs +33 -0
- package/compat/preview.cjs +41 -0
- package/compat/rewrite.cjs +41 -0
- package/compat/route-request.cjs +64 -0
- package/compat/router.cjs +137 -0
- package/compat/router.d.cts +58 -0
- package/compat/script-context.cjs +5 -0
- package/compat/script-loader.cjs +135 -0
- package/compat/script.cjs +69 -0
- package/compat/script.d.cts +17 -0
- package/compat/server-primitives.cjs +109 -0
- package/compat/server.cjs +13 -0
- package/compat/server.d.cts +37 -0
- package/compat/static-generation.cjs +108 -0
- package/compat/use-cache.cjs +129 -0
- package/index.mjs +1 -0
- package/native/platforms.json +6 -0
- package/native/resolve.mjs +81 -0
- package/package.json +127 -0
- package/release/verify.mjs +59 -0
- package/runtime/action-client.mjs +19 -0
- package/runtime/action-crypto.mjs +138 -0
- package/runtime/action-reference.mjs +2 -0
- package/runtime/action-server.mjs +161 -0
- package/runtime/action-ssr.mjs +5 -0
- package/runtime/api.mjs +326 -0
- package/runtime/app-client.mjs +539 -0
- package/runtime/app-content.mjs +8 -0
- package/runtime/app-errors.mjs +12 -0
- package/runtime/app-instant.mjs +30 -0
- package/runtime/app-metadata.mjs +410 -0
- package/runtime/app-partial-model.mjs +228 -0
- package/runtime/app-partial.mjs +130 -0
- package/runtime/app-prefetch.mjs +34 -0
- package/runtime/app-recovery.mjs +87 -0
- package/runtime/app-render.mjs +544 -0
- package/runtime/app-routing-access.mjs +143 -0
- package/runtime/app-routing-context.mjs +35 -0
- package/runtime/app-routing-state.mjs +61 -0
- package/runtime/app-routing.mjs +323 -0
- package/runtime/app-segments.mjs +22 -0
- package/runtime/app-static-params.mjs +120 -0
- package/runtime/app-static.mjs +86 -0
- package/runtime/app-stream-channel.mjs +92 -0
- package/runtime/app-stream-html.mjs +279 -0
- package/runtime/build-directory.mjs +48 -0
- package/runtime/cache-arguments.mjs +33 -0
- package/runtime/cache-components-rsc.mjs +64 -0
- package/runtime/cache-request.mjs +23 -0
- package/runtime/client-navigation.mjs +40 -0
- package/runtime/dev-client.mjs +129 -0
- package/runtime/dev-file-changes.mjs +110 -0
- package/runtime/document.mjs +96 -0
- package/runtime/edge.mjs +105 -0
- package/runtime/env.mjs +68 -0
- package/runtime/fetch-cache.mjs +379 -0
- package/runtime/font-preload.mjs +4 -0
- package/runtime/http.mjs +190 -0
- package/runtime/incremental-cache.mjs +83 -0
- package/runtime/metadata-route.mjs +83 -0
- package/runtime/middleware.mjs +186 -0
- package/runtime/module-loader.mjs +21 -0
- package/runtime/navigation.mjs +16 -0
- package/runtime/og-loader.mjs +17 -0
- package/runtime/pages-client.mjs +197 -0
- package/runtime/pages-error-props.mjs +6 -0
- package/runtime/pages-errors.mjs +85 -0
- package/runtime/pages-initial-props.mjs +43 -0
- package/runtime/pages-navigation.mjs +565 -0
- package/runtime/pages-paths.mjs +80 -0
- package/runtime/pages-revalidate.mjs +33 -0
- package/runtime/partial-artifact-cache.mjs +37 -0
- package/runtime/partial-template.mjs +174 -0
- package/runtime/profile.mjs +9 -0
- package/runtime/profiles.json +47 -0
- package/runtime/render.mjs +304 -0
- package/runtime/request-transport.mjs +41 -0
- package/runtime/request-work.mjs +7 -0
- package/runtime/route-static.mjs +117 -0
- package/runtime/rsc-worker.mjs +426 -0
- package/runtime/script-html.mjs +61 -0
- package/runtime/stream-select.mjs +25 -0
- package/runtime/stream-utils.mjs +127 -0
- package/runtime/transport.mjs +108 -0
- package/runtime/watch-project.mjs +130 -0
- package/runtime/worker-artifacts.mjs +22 -0
- package/runtime/worker-metadata.mjs +31 -0
- package/runtime/worker.mjs +197 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import googleFonts from './font-data.json' with { type: 'json' };
|
|
5
|
+
|
|
6
|
+
const formats = { woff: 'woff', woff2: 'woff2', ttf: 'truetype', otf: 'opentype', eot: 'embedded-opentype' };
|
|
7
|
+
const types = { woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject' };
|
|
8
|
+
const MAX_FONT = 16 * 1024 * 1024;
|
|
9
|
+
const quote = value => JSON.stringify(String(value));
|
|
10
|
+
const list = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
11
|
+
const commonOptions = ['display', 'preload', 'fallback', 'variable', 'adjustFontFallback', 'weight', 'style'];
|
|
12
|
+
const safeValue = value => typeof value === 'string' && !/[;{}\u0000-\u001f\u007f]/.test(value);
|
|
13
|
+
function fail(message) { throw new Error(`next/font: ${message}`); }
|
|
14
|
+
function weight(value) {
|
|
15
|
+
if (typeof value !== 'string' || !/^(?:normal|bold|[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(value)) fail(`Invalid font weight ${JSON.stringify(value)}.`);
|
|
16
|
+
const values = value.split(' ').map(Number);
|
|
17
|
+
if (values.length > 1 && values[0] > values[1]) fail('Font weight ranges must be increasing.');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function style(value) {
|
|
21
|
+
if (!['normal', 'italic', 'oblique'].includes(value)) fail(`Invalid font style ${JSON.stringify(value)}.`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function validate(kind, options) {
|
|
25
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) fail('Expected an options object.');
|
|
26
|
+
const allowed = new Set([...commonOptions, ...(kind === 'local' ? ['src', 'declarations'] : ['subsets', 'axes'])]);
|
|
27
|
+
for (const key of Object.keys(options)) if (!allowed.has(key)) fail(`Unsupported ${kind} font option ${key}.`);
|
|
28
|
+
const result = { display: 'swap', preload: true, fallback: [], ...options };
|
|
29
|
+
if (!['auto', 'block', 'swap', 'fallback', 'optional'].includes(result.display)) fail('Invalid display option.');
|
|
30
|
+
if (typeof result.preload !== 'boolean') fail('preload must be a boolean.');
|
|
31
|
+
if (!Array.isArray(result.fallback) || result.fallback.some(value => !safeValue(value) || !value)) fail('fallback must be an array of font family names.');
|
|
32
|
+
if (result.variable !== undefined && (typeof result.variable !== 'string' || !/^--[A-Za-z_][A-Za-z0-9_-]*$/.test(result.variable))) fail('variable must be a CSS custom property, such as --font-body.');
|
|
33
|
+
if (kind === 'google' ? result.adjustFontFallback !== undefined && typeof result.adjustFontFallback !== 'boolean'
|
|
34
|
+
: result.adjustFontFallback !== undefined && ![false, 'Arial', 'Times New Roman'].includes(result.adjustFontFallback)) fail(`Invalid adjustFontFallback option for ${kind} fonts.`);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function responseBytes(url, maximum, accept) {
|
|
39
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { accept,
|
|
40
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' } });
|
|
41
|
+
if (!response.ok) { await response.body?.cancel(); fail(`Unable to download ${url}: HTTP ${response.status}.`); }
|
|
42
|
+
if (Number(response.headers.get('content-length')) > maximum) { await response.body?.cancel(); fail(`Font resource exceeds ${maximum} bytes.`); }
|
|
43
|
+
const reader = response.body?.getReader();
|
|
44
|
+
if (!reader) fail(`Empty font resource at ${url}.`);
|
|
45
|
+
const chunks = []; let length = 0;
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const { value, done } = await reader.read();
|
|
49
|
+
if (done) break;
|
|
50
|
+
length += value.byteLength;
|
|
51
|
+
if (length > maximum) fail(`Font resource exceeds ${maximum} bytes.`);
|
|
52
|
+
chunks.push(value);
|
|
53
|
+
}
|
|
54
|
+
} catch (error) { await reader.cancel().catch(() => {}); throw error; }
|
|
55
|
+
finally { reader.releaseLock(); }
|
|
56
|
+
if (!length) fail(`Empty font resource at ${url}.`);
|
|
57
|
+
return Buffer.concat(chunks, length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function emit(bytes, ext, stage, assetBase) {
|
|
61
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 24);
|
|
62
|
+
const filename = `font-${hash}.${ext}`;
|
|
63
|
+
await mkdir(path.join(stage, 'assets'), { recursive: true });
|
|
64
|
+
await writeFile(path.join(stage, 'assets', filename), bytes);
|
|
65
|
+
return { href: `${assetBase}/${filename}`, type: types[ext], crossOrigin: 'anonymous' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function local(options, context) {
|
|
69
|
+
const sources = typeof options.src === 'string' ? [{ path: options.src, weight: options.weight, style: options.style }] : options.src;
|
|
70
|
+
if (!Array.isArray(sources) || !sources.length || sources.length > 64) fail('src must be a font path or a nonempty array of at most 64 font descriptors.');
|
|
71
|
+
if (options.weight !== undefined) weight(options.weight);
|
|
72
|
+
if (options.style !== undefined) style(options.style);
|
|
73
|
+
const declarations = options.declarations || [];
|
|
74
|
+
if (!Array.isArray(declarations)) fail('declarations must be an array.');
|
|
75
|
+
for (const item of declarations) {
|
|
76
|
+
if (!item || !/^[a-z][a-z-]*$/.test(item.prop) || ['src', 'font-display', 'font-weight', 'font-style'].includes(item.prop) || !safeValue(item.value)) fail('Invalid font-face declaration.');
|
|
77
|
+
}
|
|
78
|
+
const faces = [];
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const item of sources) {
|
|
81
|
+
if (!item || typeof item.path !== 'string' || !item.path || /^(?:https?:|data:)/i.test(item.path) || item.path.includes('\0')) fail('Local fonts require a local file path.');
|
|
82
|
+
for (const key of Object.keys(item)) if (!['path', 'weight', 'style'].includes(key)) fail(`Invalid local font descriptor property ${key}.`);
|
|
83
|
+
const ext = path.extname(item.path).slice(1).toLowerCase();
|
|
84
|
+
if (!formats[ext]) fail(`Unsupported local font extension in ${item.path}.`);
|
|
85
|
+
const filename = path.resolve(path.dirname(context.file), item.path);
|
|
86
|
+
const size = (await stat(filename)).size;
|
|
87
|
+
if (size > MAX_FONT || (total += size) > 64 * 1024 * 1024) fail('Local fonts exceed the build resource limit (16 MiB per file, 64 MiB per call).');
|
|
88
|
+
const bytes = await readFile(filename);
|
|
89
|
+
if (!bytes.length) fail(`Empty local font ${item.path}.`);
|
|
90
|
+
const resource = await emit(bytes, ext, context.stage, context.assetBase);
|
|
91
|
+
const fontWeight = item.weight ?? options.weight, fontStyle = item.style ?? options.style;
|
|
92
|
+
if (fontWeight !== undefined) weight(fontWeight);
|
|
93
|
+
if (fontStyle !== undefined) style(fontStyle);
|
|
94
|
+
const descriptors = [...declarations.map(item => `${item.prop}:${item.value}`),
|
|
95
|
+
...(declarations.some(item => item.prop === 'font-family') ? [] : [`font-family:${quote(context.family)}`]),
|
|
96
|
+
`src:url(${quote(resource.href)}) format(${quote(formats[ext])})`, `font-display:${options.display}`,
|
|
97
|
+
...(fontWeight ? [`font-weight:${fontWeight}`] : []), ...(fontStyle ? [`font-style:${fontStyle}`] : [])];
|
|
98
|
+
faces.push({ css: `@font-face{${descriptors.join(';')};}`, bytes, resource, preload: options.preload, weight: fontWeight, style: fontStyle });
|
|
99
|
+
}
|
|
100
|
+
return { faces, weight: sources.length === 1 ? faces[0].weight : undefined, style: sources.length === 1 ? faces[0].style : undefined };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function googleOptions(name, options) {
|
|
104
|
+
const family = name.replaceAll('_', ' '), metadata = googleFonts[family];
|
|
105
|
+
if (!metadata) fail(`Unknown Google font ${name}.`);
|
|
106
|
+
const weights = [...new Set(list(options.weight))], styles = [...new Set(list(options.style))];
|
|
107
|
+
if (!weights.length) {
|
|
108
|
+
if (!metadata.weights.includes('variable')) fail(`Missing weight for ${family}. Available: ${metadata.weights.join(', ')}.`);
|
|
109
|
+
weights.push('variable');
|
|
110
|
+
}
|
|
111
|
+
if (weights.some(value => !metadata.weights.includes(value)) || (weights.length > 1 && weights.includes('variable'))) fail(`Invalid weight for ${family}. Available: ${metadata.weights.join(', ')}.`);
|
|
112
|
+
if (!styles.length) styles.push(metadata.styles.length === 1 ? metadata.styles[0] : 'normal');
|
|
113
|
+
if (styles.some(value => !metadata.styles.includes(value))) fail(`Invalid style for ${family}.`);
|
|
114
|
+
const subsets = options.subsets || [];
|
|
115
|
+
if (!Array.isArray(subsets) || subsets.some(value => !metadata.subsets.includes(value))) fail(`Invalid subset for ${family}. Available: ${metadata.subsets.join(', ')}.`);
|
|
116
|
+
const preload = options.preload && metadata.subsets.length > 0;
|
|
117
|
+
if (preload && !subsets.length) fail(`Preload is enabled for ${family}: specify subsets or preload:false.`);
|
|
118
|
+
const axes = options.axes || [];
|
|
119
|
+
if (!Array.isArray(axes) || (axes.length && weights[0] !== 'variable')) fail('axes requires a variable font.');
|
|
120
|
+
const extraAxes = [];
|
|
121
|
+
for (const tag of new Set(axes)) {
|
|
122
|
+
const axis = metadata.axes?.find(axis => axis.tag === tag && axis.tag !== 'wght');
|
|
123
|
+
if (!axis) fail(`Unknown variable axis ${tag} for ${family}.`);
|
|
124
|
+
extraAxes.push([tag, `${axis.min}..${axis.max}`]);
|
|
125
|
+
}
|
|
126
|
+
const variableWeight = metadata.axes?.find(axis => axis.tag === 'wght');
|
|
127
|
+
const variants = [];
|
|
128
|
+
for (const currentStyle of styles) for (const currentWeight of weights) {
|
|
129
|
+
const entries = [...extraAxes];
|
|
130
|
+
if (styles.includes('italic')) entries.push(['ital', currentStyle === 'italic' ? '1' : '0']);
|
|
131
|
+
if (currentWeight !== 'variable') entries.push(['wght', currentWeight]);
|
|
132
|
+
else if (variableWeight) entries.push(['wght', `${variableWeight.min}..${variableWeight.max}`]);
|
|
133
|
+
entries.sort(([a], [b]) => (/^[a-z]/.test(a) === /^[a-z]/.test(b)) ? a.localeCompare(b, 'en', { sensitivity: 'variant' }) : /^[a-z]/.test(a) ? -1 : 1);
|
|
134
|
+
variants.push(entries);
|
|
135
|
+
}
|
|
136
|
+
const values = variants.map(entries => entries.map(([, value]) => value).join(',')).sort((a, b) => {
|
|
137
|
+
const left = a.split(',').map(parseFloat), right = b.split(',').map(parseFloat);
|
|
138
|
+
for (let index = 0; index < left.length; index++) if (left[index] !== right[index]) return left[index] - right[index];
|
|
139
|
+
return 0;
|
|
140
|
+
});
|
|
141
|
+
const axesQuery = variants[0].length ? `:${variants[0].map(([tag]) => tag).join(',')}@${[...new Set(values)].join(';')}` : '';
|
|
142
|
+
return { url: `https://fonts.googleapis.com/css2?family=${family.replaceAll(' ', '+')}${axesQuery}&display=${options.display}`, preload, subsets,
|
|
143
|
+
weight: weights.length === 1 && weights[0] !== 'variable' ? weights[0] : undefined, style: styles.length === 1 ? styles[0] : undefined };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function google(name, options, context) {
|
|
147
|
+
const normalized = googleOptions(name, options);
|
|
148
|
+
const css = (await responseBytes(normalized.url, 1024 * 1024, 'text/css')).toString('utf8');
|
|
149
|
+
const faces = [], downloaded = new Map();
|
|
150
|
+
let total = 0;
|
|
151
|
+
const facePattern = /(?:\/\*\s*([^*]+?)\s*\*\/\s*)?@font-face\s*\{([^}]+)\}/g;
|
|
152
|
+
for (const match of css.matchAll(facePattern)) {
|
|
153
|
+
if (faces.length >= 128) fail('Google font CSS has too many font faces.');
|
|
154
|
+
const subset = match[1]?.trim();
|
|
155
|
+
const urls = [...match[2].matchAll(/url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/g)];
|
|
156
|
+
if (!urls.length) fail('Google font face is missing its source.');
|
|
157
|
+
let content = match[2].replace(/font-family\s*:[^;]+;/i, `font-family:${quote(context.family)};`);
|
|
158
|
+
let representative;
|
|
159
|
+
for (const urlMatch of urls) {
|
|
160
|
+
const url = new URL(urlMatch[2]);
|
|
161
|
+
if (url.protocol !== 'https:' || url.hostname !== 'fonts.gstatic.com' || url.username || url.password) fail('Google font CSS referenced an unexpected font origin.');
|
|
162
|
+
if (!downloaded.has(url.href)) {
|
|
163
|
+
if (downloaded.size >= 64) fail('Google font call exceeds 64 files.');
|
|
164
|
+
const bytes = await responseBytes(url.href, MAX_FONT, 'font/woff2,font/woff,*/*');
|
|
165
|
+
total += bytes.length;
|
|
166
|
+
if (total > 64 * 1024 * 1024) fail('Google font call exceeds 64 MiB.');
|
|
167
|
+
const ext = path.extname(url.pathname).slice(1).toLowerCase();
|
|
168
|
+
if (!formats[ext]) fail(`Unsupported Google font extension ${ext}.`);
|
|
169
|
+
downloaded.set(url.href, { bytes, resource: await emit(bytes, ext, context.stage, context.assetBase) });
|
|
170
|
+
}
|
|
171
|
+
const source = downloaded.get(url.href);
|
|
172
|
+
representative ||= source;
|
|
173
|
+
content = content.replace(urlMatch[0], `url(${quote(source.resource.href)})`);
|
|
174
|
+
}
|
|
175
|
+
const fontWeight = /font-weight\s*:\s*([^;]+)/i.exec(content)?.[1]?.trim();
|
|
176
|
+
const fontStyle = /font-style\s*:\s*([^;]+)/i.exec(content)?.[1]?.trim();
|
|
177
|
+
faces.push({ css: `@font-face{${content}}`, ...representative, preload: normalized.preload && normalized.subsets.includes(subset), weight: fontWeight, style: fontStyle });
|
|
178
|
+
}
|
|
179
|
+
if (!faces.length) fail(`No font faces returned for ${name}.`);
|
|
180
|
+
return { faces, weight: normalized.weight, style: normalized.style };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fallbackMetrics(faces, fallbackName) {
|
|
184
|
+
const { create } = await import('fontkit');
|
|
185
|
+
// Prefer an upright face near the normal text weight for a shared fallback.
|
|
186
|
+
const candidates = [...faces].sort((a, b) => Number(a.style === 'italic') - Number(b.style === 'italic') || Math.abs((parseFloat(a.weight) || 400) - 400) - Math.abs((parseFloat(b.weight) || 400) - 400));
|
|
187
|
+
for (const face of candidates) {
|
|
188
|
+
let font;
|
|
189
|
+
try { font = create(face.bytes); } catch { continue; }
|
|
190
|
+
if (!font.unitsPerEm) continue;
|
|
191
|
+
const sample = 'aaabcdeeeefghiijklmnnoopqrrssttuvwxyz ';
|
|
192
|
+
let size = 1;
|
|
193
|
+
if ([...sample].every(character => font.hasGlyphForCodePoint(character.codePointAt(0)))) {
|
|
194
|
+
const glyphs = font.glyphsForString(sample);
|
|
195
|
+
const average = glyphs.reduce((sum, glyph) => sum + glyph.advanceWidth, 0) / glyphs.length;
|
|
196
|
+
size = average / font.unitsPerEm / ((fallbackName === 'Times New Roman' ? 854.3953488372093 : 934.5116279069767) / 2048);
|
|
197
|
+
}
|
|
198
|
+
if (!Number.isFinite(size) || size <= 0) size = 1;
|
|
199
|
+
const percent = value => `${Math.abs(value * 100).toFixed(2)}%`;
|
|
200
|
+
return `src:local(${quote(fallbackName)});ascent-override:${percent(font.ascent / font.unitsPerEm / size)};descent-override:${percent(font.descent / font.unitsPerEm / size)};line-gap-override:${percent(font.lineGap / font.unitsPerEm / size)};size-adjust:${percent(size)};`;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function compileFont({ kind, name, options: input, ...context }) {
|
|
206
|
+
const options = validate(kind, input);
|
|
207
|
+
const declaredFamily = kind === 'local' && options.declarations?.find(item => item?.prop === 'font-family')?.value;
|
|
208
|
+
const family = declaredFamily ? String(declaredFamily).replace(/['"]/g, '') : `__prnext_${kind === 'google' ? name : 'local'}_${context.id}`;
|
|
209
|
+
const compiled = await (kind === 'local' ? local(options, { ...context, family }) : google(name, options, { ...context, family }));
|
|
210
|
+
const fallback = options.adjustFontFallback === false ? null : await fallbackMetrics(compiled.faces, options.adjustFontFallback === 'Times New Roman' ? 'Times New Roman' : 'Arial');
|
|
211
|
+
const familyList = [quote(family), ...(fallback ? [quote(family + '_Fallback')] : []), ...options.fallback].join(',');
|
|
212
|
+
const className = `__font_${context.id}`, variable = options.variable ? `__variable_${context.id}` : undefined;
|
|
213
|
+
const fontWeight = compiled.weight && !compiled.weight.includes(' ') ? (compiled.weight === 'normal' ? 400 : compiled.weight === 'bold' ? 700 : Number(compiled.weight)) : undefined;
|
|
214
|
+
const fontStyle = compiled.style;
|
|
215
|
+
const value = { className, style: { fontFamily: familyList, ...(fontWeight !== undefined ? { fontWeight } : {}), ...(fontStyle ? { fontStyle } : {}) }, ...(variable ? { variable } : {}) };
|
|
216
|
+
const classRules = `font-family:${familyList};${fontWeight !== undefined ? `font-weight:${fontWeight};` : ''}${fontStyle ? `font-style:${fontStyle};` : ''}`;
|
|
217
|
+
const css = compiled.faces.map(face => face.css).join('\n') + (fallback ? `\n@font-face{font-family:${quote(family + '_Fallback')};${fallback}}` : '')
|
|
218
|
+
+ `\n.${className}{${classRules}}` + (variable ? `\n.${variable}{${options.variable}:${familyList};}` : '');
|
|
219
|
+
const preloads = [...new Map(compiled.faces.filter(face => face.preload).map(face => [face.resource.href, face.resource])).values()];
|
|
220
|
+
return { value, css, preloads };
|
|
221
|
+
}
|
package/build/fonts.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {generateMapped} from './source-maps.mjs';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverseModule from '@babel/traverse';
|
|
7
|
+
import generateModule from '@babel/generator';
|
|
8
|
+
import * as t from '@babel/types';
|
|
9
|
+
import { compileFont } from './font-files.mjs';
|
|
10
|
+
|
|
11
|
+
const traverse = traverseModule.default || traverseModule;
|
|
12
|
+
const generate = generateModule.default || generateModule;
|
|
13
|
+
const fontImport = /^(?:next|prnext|@thomas\.f\/prnext)\/font\/(local|google)(?:\.js)?$/;
|
|
14
|
+
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 20);
|
|
15
|
+
|
|
16
|
+
function literal(node, file) {
|
|
17
|
+
while (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSNonNullExpression(node)) node = node.expression;
|
|
18
|
+
if (t.isStringLiteral(node) || t.isBooleanLiteral(node) || t.isNumericLiteral(node)) return node.value;
|
|
19
|
+
if (t.isNullLiteral(node)) return null;
|
|
20
|
+
if (t.isUnaryExpression(node, { operator: '-' }) && t.isNumericLiteral(node.argument)) return -node.argument.value;
|
|
21
|
+
if (t.isArrayExpression(node)) return node.elements.map(item => literal(item, file));
|
|
22
|
+
if (t.isObjectExpression(node)) {
|
|
23
|
+
const result = Object.create(null);
|
|
24
|
+
for (const item of node.properties) {
|
|
25
|
+
if (!t.isObjectProperty(item) || item.computed || item.method) throw new Error(`Font options must contain only literal properties (${file}).`);
|
|
26
|
+
const key = t.isIdentifier(item.key) ? item.key.name : item.key.value;
|
|
27
|
+
result[key] = literal(item.value, file);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Font options must be statically written literals, without variables, spreads or function calls (${file}).`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fonts are compiled once per build; generated modules contain only objects and CSS imports. */
|
|
35
|
+
export function createFonts({ projectRoot, stage, assetBase }) {
|
|
36
|
+
const sources = new Map(), compilations = new Map(), modules = new Map(), styles = new Set(), graph = new Map();
|
|
37
|
+
async function prepare(kind, name, options, file, position) {
|
|
38
|
+
const id = hash(JSON.stringify([path.relative(projectRoot, file), position, kind, name, options]));
|
|
39
|
+
if (!compilations.has(id)) compilations.set(id, (async () => {
|
|
40
|
+
const compiled = await compileFont({ kind, name, options, file, id, stage, assetBase });
|
|
41
|
+
const moduleFile = path.join(stage, '.entries', `font-${id}.mjs`);
|
|
42
|
+
const cssFile = path.join(stage, '.entries', `font-${id}.css`);
|
|
43
|
+
await mkdir(path.dirname(moduleFile), { recursive: true });
|
|
44
|
+
await writeFile(cssFile, compiled.css);
|
|
45
|
+
await writeFile(moduleFile, `import ${JSON.stringify(cssFile)};\nexport default ${JSON.stringify(compiled.value)};\n`);
|
|
46
|
+
modules.set(moduleFile, compiled.preloads);
|
|
47
|
+
styles.add(cssFile);
|
|
48
|
+
return moduleFile;
|
|
49
|
+
})());
|
|
50
|
+
return compilations.get(id);
|
|
51
|
+
}
|
|
52
|
+
async function transformSource(source, file) {
|
|
53
|
+
if (/(?:^|[/\\])pages[/\\]_document\.[cm]?[jt]sx?$/.test(file)) throw new Error('next/font cannot be used in pages/_document. Define fonts in pages/_app or a page component.');
|
|
54
|
+
const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: file, plugins: ['jsx', ...(/\.tsx?$/.test(file) ? ['typescript'] : [])] });
|
|
55
|
+
let program;
|
|
56
|
+
traverse(ast, { Program(value) { program = value; value.stop(); } });
|
|
57
|
+
const imports = [], calls = [];
|
|
58
|
+
for (const statement of program.get('body')) {
|
|
59
|
+
if (!statement.isImportDeclaration() || !fontImport.test(statement.node.source.value) || statement.node.importKind === 'type') continue;
|
|
60
|
+
const kind = fontImport.exec(statement.node.source.value)[1];
|
|
61
|
+
for (const item of statement.node.specifiers) {
|
|
62
|
+
if (item.importKind === 'type') continue;
|
|
63
|
+
if (kind === 'local' ? !t.isImportDefaultSpecifier(item) : !t.isImportSpecifier(item)) throw new Error(`${statement.node.source.value} requires ${kind === 'local' ? 'a default import' : 'named font imports'} (${file}).`);
|
|
64
|
+
const name = kind === 'local' ? 'local' : item.imported.name || item.imported.value;
|
|
65
|
+
const binding = program.scope.getBinding(item.local.name);
|
|
66
|
+
for (const reference of binding.referencePaths) {
|
|
67
|
+
const call = reference.parentPath;
|
|
68
|
+
const declaration = call.parentPath;
|
|
69
|
+
const variable = declaration?.parentPath;
|
|
70
|
+
const owner = variable?.parentPath?.isExportNamedDeclaration() ? variable.parentPath.parentPath : variable?.parentPath;
|
|
71
|
+
if (!call.isCallExpression() || call.node.callee !== reference.node || !declaration.isVariableDeclarator() || declaration.node.init !== call.node || !t.isIdentifier(declaration.node.id) || !variable.isVariableDeclaration({ kind: 'const' }) || !owner?.isProgram()) {
|
|
72
|
+
throw new Error(`Font loaders must be called and assigned to a const at module scope (${file}).`);
|
|
73
|
+
}
|
|
74
|
+
if (call.node.arguments.length > 1) throw new Error(`Font loaders accept a single options object (${file}).`);
|
|
75
|
+
const options = call.node.arguments.length ? literal(call.node.arguments[0], file) : {};
|
|
76
|
+
const identifier = program.scope.generateUidIdentifier('prnextFont');
|
|
77
|
+
calls.push({ kind, name, options, position: call.node.start, call, identifier });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
imports.push(statement);
|
|
81
|
+
}
|
|
82
|
+
if (!imports.length) return source;
|
|
83
|
+
for (const item of calls) {
|
|
84
|
+
const moduleFile = await prepare(item.kind, item.name, item.options, file, item.position);
|
|
85
|
+
item.call.replaceWith(item.identifier);
|
|
86
|
+
program.node.body.unshift(t.importDeclaration([t.importDefaultSpecifier(item.identifier)], t.stringLiteral(moduleFile)));
|
|
87
|
+
}
|
|
88
|
+
for (const statement of imports) statement.remove();
|
|
89
|
+
return generateMapped(generate, ast, { retainLines: true }, source, file);
|
|
90
|
+
}
|
|
91
|
+
const normalize = file => path.isAbsolute(file) ? file : path.resolve(projectRoot, file);
|
|
92
|
+
return {
|
|
93
|
+
transform(source, file) {
|
|
94
|
+
if (!/(?:next|prnext|@thomas\.f\/prnext)\/font\//.test(source)) return source;
|
|
95
|
+
const key = `${file}\0${hash(source)}`;
|
|
96
|
+
if (!sources.has(key)) sources.set(key, transformSource(source, file));
|
|
97
|
+
return sources.get(key);
|
|
98
|
+
},
|
|
99
|
+
plugin() {
|
|
100
|
+
return { name: 'prnext-font-assets', setup(build) {
|
|
101
|
+
build.onResolve({ filter: /font-[a-f0-9]+\.mjs$/ }, args => modules.has(args.path) ? { path: args.path } : undefined);
|
|
102
|
+
// Generated CSS must not run through application PostCSS plugins again.
|
|
103
|
+
build.onLoad({ filter: /\.css$/, namespace: 'file' }, async args => styles.has(args.path) ? { contents: await readFile(args.path, 'utf8'), loader: 'css', resolveDir: path.dirname(args.path) } : undefined);
|
|
104
|
+
build.onResolve({ filter: /^\// }, args => args.kind === 'url-token' && styles.has(args.importer) ? { path: args.path, external: true } : undefined);
|
|
105
|
+
build.onResolve({ filter: /^https?:\/\// }, args => args.kind === 'url-token' && styles.has(args.importer) ? { path: args.path, external: true } : undefined);
|
|
106
|
+
} };
|
|
107
|
+
},
|
|
108
|
+
record(metafile) {
|
|
109
|
+
for (const [file, input] of Object.entries(metafile.inputs)) {
|
|
110
|
+
const key = normalize(file);
|
|
111
|
+
if (!graph.has(key)) graph.set(key, new Set());
|
|
112
|
+
for (const dependency of input.imports) if (!dependency.external) graph.get(key).add(normalize(dependency.path));
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
attach(manifest) {
|
|
116
|
+
for (const route of manifest.routes) {
|
|
117
|
+
if (route.kind !== 'page') continue;
|
|
118
|
+
const pending = [path.join(stage, '.entries', `${route.id}.server.mjs`)];
|
|
119
|
+
const visited = new Set(), preloads = new Map();
|
|
120
|
+
while (pending.length) {
|
|
121
|
+
const file = pending.pop();
|
|
122
|
+
if (visited.has(file)) continue;
|
|
123
|
+
visited.add(file);
|
|
124
|
+
for (const preload of modules.get(file) || []) preloads.set(preload.href, preload);
|
|
125
|
+
for (const dependency of graph.get(file) || []) pending.push(dependency);
|
|
126
|
+
}
|
|
127
|
+
if (preloads.size) route.fonts = [...preloads.values()];
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const adapters = new Map([
|
|
2
|
+
['dist/shared/lib/router-context.shared-runtime','next-router-context'],
|
|
3
|
+
['dist/shared/lib/router-context','next-router-context'],
|
|
4
|
+
['dist/shared/lib/app-router-context.shared-runtime','next-app-router-context'],
|
|
5
|
+
]);
|
|
6
|
+
// Keep the historical import alias for existing applications. npm's scoped
|
|
7
|
+
// package name adds a slash of its own, so strip the entire package prefix.
|
|
8
|
+
export const frameworkPackages = ['next', 'prnext', '@thomas.f/prnext'];
|
|
9
|
+
export const frameworkImportPattern = /^(?:next|prnext|@thomas\.f\/prnext)(?:\/|$)/;
|
|
10
|
+
export const frameworkSubpathPattern = /^(?:next|prnext|@thomas\.f\/prnext)\//;
|
|
11
|
+
export const frameworkRuntimeImportPattern = /^(?:next(?:\/|$)|(?:prnext|@thomas\.f\/prnext)\/)/;
|
|
12
|
+
// Only explicit, tested private contracts are adapted. Unknown internals still
|
|
13
|
+
// fail instead of silently importing a second framework runtime from npm.
|
|
14
|
+
export function frameworkImportName(specifier) {
|
|
15
|
+
const name=specifier.replace(frameworkSubpathPattern, '').replace(/\.js$/,'');
|
|
16
|
+
return adapters.get(name) || name;
|
|
17
|
+
}
|
package/build/i18n.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { localePath, withLocale } from '../compat/locale.cjs';
|
|
2
|
+
export { localizedStaticPaths } from '../runtime/pages-paths.mjs';
|
|
3
|
+
|
|
4
|
+
export function validateI18n(input) {
|
|
5
|
+
if (input === undefined) return undefined;
|
|
6
|
+
const fail = message => {throw new TypeError('i18n: '+message)};
|
|
7
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('expected an object');
|
|
8
|
+
for (const key of Object.keys(input)) if (!['locales','defaultLocale','localeDetection','domains'].includes(key)) fail(`unknown option ${key}`);
|
|
9
|
+
const {locales,defaultLocale,localeDetection=true,domains=[]}=input;
|
|
10
|
+
if (!Array.isArray(locales)||!locales.length||locales.length>100||locales.some(value=>typeof value!=='string'||!/^[A-Za-z][A-Za-z0-9-]{0,63}$/.test(value))) fail('locales must contain 1–100 locale identifiers');
|
|
11
|
+
if (new Set(locales.map(value=>value.toLowerCase())).size!==locales.length) fail('duplicate locales');
|
|
12
|
+
if (!locales.includes(defaultLocale)) fail('defaultLocale must belong to locales');
|
|
13
|
+
if (typeof localeDetection!=='boolean') fail('localeDetection must be boolean');
|
|
14
|
+
if (!Array.isArray(domains)||domains.length>100) fail('domains must contain at most 100 entries');
|
|
15
|
+
const hosts=new Set(), defaults=new Set();
|
|
16
|
+
for (const value of domains) {
|
|
17
|
+
if (!value||typeof value!=='object'||Object.keys(value).some(key=>!['domain','defaultLocale','locales','http'].includes(key))) fail('invalid domain entry');
|
|
18
|
+
if (typeof value.domain!=='string'||value.domain.length>256||!/^[a-zA-Z0-9.-]+(?::[0-9]{1,5})?$/.test(value.domain)||hosts.has(value.domain.toLowerCase())) fail('invalid or duplicate domain');
|
|
19
|
+
if (!locales.includes(value.defaultLocale)||defaults.has(value.defaultLocale)) fail('invalid or duplicate domain defaultLocale');
|
|
20
|
+
if (value.http!==undefined&&typeof value.http!=='boolean') fail('domain http must be boolean');
|
|
21
|
+
if (value.locales!==undefined&&(!Array.isArray(value.locales)||value.locales.length>100||value.locales.some(locale=>!locales.includes(locale)))) fail('domain locales must belong to locales');
|
|
22
|
+
hosts.add(value.domain.toLowerCase());defaults.add(value.defaultLocale);
|
|
23
|
+
}
|
|
24
|
+
return {locales:[...locales],defaultLocale,localeDetection,domains};
|
|
25
|
+
}
|
|
26
|
+
export function expandLocales(manifest) {
|
|
27
|
+
const i18n=manifest.config.i18n;
|
|
28
|
+
if (!i18n) return;
|
|
29
|
+
if (manifest.routes.length * i18n.locales.length > 100000) throw new Error('i18n route expansion exceeds 100000 routes');
|
|
30
|
+
const occupied=new Set(manifest.routes.map(route=>route.pattern));
|
|
31
|
+
manifest.routes=manifest.routes.flatMap(route=>{
|
|
32
|
+
if(route.kind!=='page'||route.router==='app'||route.internal && !route.errorStatus && route.pattern !== '/_error') return [route];
|
|
33
|
+
return i18n.locales.map(locale=>{
|
|
34
|
+
const pattern=withLocale(route.pattern,locale,i18n.defaultLocale);
|
|
35
|
+
if(locale!==i18n.defaultLocale&&occupied.has(pattern)) throw new Error(`i18n route conflicts with ${pattern}`);
|
|
36
|
+
return {...route,pattern,locale,originalPattern:route.pattern,id:locale===i18n.defaultLocale?route.id:route.id+'-locale-'+locale};
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const defaults = {
|
|
2
|
+
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [32, 48, 64, 96, 128, 256, 384],
|
|
3
|
+
loader: 'default', loaderFile: '', domains: [], disableStaticImages: false, minimumCacheTTL: 14400,
|
|
4
|
+
formats: ['image/webp'], maximumDiskCacheSize: 256 * 1024 * 1024, maximumRedirects: 3, maximumResponseBody: 50_000_000,
|
|
5
|
+
dangerouslyAllowLocalIP: false, dangerouslyAllowSVG: false, contentSecurityPolicy: "script-src 'none'; frame-src 'none'; sandbox;",
|
|
6
|
+
contentDispositionType: 'attachment', remotePatterns: [], qualities: [75], unoptimized: false,
|
|
7
|
+
};
|
|
8
|
+
const keys = new Set([...Object.keys(defaults), 'localPatterns', 'path']);
|
|
9
|
+
function patterns(values, remote) {
|
|
10
|
+
if (!Array.isArray(values) || values.length > 50) throw new Error(`images.${remote ? 'remote' : 'local'}Patterns must be an array of at most 50 patterns`);
|
|
11
|
+
return values.map(value => {
|
|
12
|
+
if (value instanceof URL) value = { protocol: value.protocol.slice(0, -1), hostname: value.hostname, port: value.port, pathname: value.pathname, search: value.search };
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Image patterns must be objects or URL instances');
|
|
14
|
+
const allowed = remote ? ['protocol', 'hostname', 'port', 'pathname', 'search'] : ['pathname', 'search'];
|
|
15
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unsupported image pattern field ${key}`);
|
|
16
|
+
const pattern = { ...value };
|
|
17
|
+
for (const key of allowed) if (pattern[key] !== undefined && (typeof pattern[key] !== 'string' || pattern[key].length > 4096 || /[\u0000-\u001f\u007f\\]/.test(pattern[key]))) throw new Error(`Invalid image pattern ${key}`);
|
|
18
|
+
if (remote && (!pattern.hostname || /[\s/:?#]/.test(pattern.hostname.replace(/^\[.*\]$/, 'ipv6')))) throw new Error('Image remotePatterns require a hostname');
|
|
19
|
+
if (pattern.protocol !== undefined && !['http', 'https'].includes(pattern.protocol)) throw new Error('Image remotePatterns protocol must be http or https');
|
|
20
|
+
if (pattern.port !== undefined && !/^(?:\d{1,5})?$/.test(pattern.port)) throw new Error('Invalid image remotePatterns port');
|
|
21
|
+
if (pattern.pathname !== undefined && (!pattern.pathname.startsWith('/') && !pattern.pathname.startsWith('*') || /[{}[\]()!]/.test(pattern.pathname))) throw new Error('Image pattern pathname must start with / and use only * or ** wildcards');
|
|
22
|
+
if (pattern.hostname && /[{}[\]()!]/.test(pattern.hostname) && !/^\[[a-f\d:]+\]$/i.test(pattern.hostname)) throw new Error('Image pattern hostname supports only * or ** wildcards');
|
|
23
|
+
if (pattern.search !== undefined && pattern.search !== '' && !pattern.search.startsWith('?')) throw new Error('Image pattern search must be empty or start with ?');
|
|
24
|
+
return pattern;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function validateImagesConfig(input = {}, { basePath = '', assetPrefix = '', trailingSlash = false } = {}) {
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('images must be a configuration object');
|
|
29
|
+
for (const key of Object.keys(input)) if (!keys.has(key)) throw new Error(`images.${key} is not supported`);
|
|
30
|
+
const config = { ...defaults, path: `${basePath}/_prnext/image`, ...Object.fromEntries(Object.entries(input).filter(([,value]) => value !== undefined)) };
|
|
31
|
+
for (const key of ['deviceSizes', 'imageSizes', 'qualities']) {
|
|
32
|
+
const value = config[key], max = key === 'qualities' ? 100 : 10000;
|
|
33
|
+
if (!Array.isArray(value) || (key !== 'imageSizes' && !value.length) || value.length > 50 || value.some(item => !Number.isSafeInteger(item) || item < 1 || item > max)) throw new Error(`images.${key} must contain 1 to 50 integers between 1 and ${max}`);
|
|
34
|
+
config[key] = [...new Set(value)].sort((a, b) => a - b);
|
|
35
|
+
}
|
|
36
|
+
if (!['default', 'custom'].includes(config.loader)) throw new Error('images.loader supports default or custom; pass a loader function for another image service');
|
|
37
|
+
if (typeof config.loaderFile !== 'string') throw new Error('images.loaderFile must be a project-relative path');
|
|
38
|
+
if (typeof config.path !== 'string' || !config.path.startsWith('/') || config.path.startsWith('//') || /[\s\\?#]/.test(config.path)) throw new Error('images.path must be a local URL pathname');
|
|
39
|
+
for (const key of ['disableStaticImages', 'dangerouslyAllowLocalIP', 'dangerouslyAllowSVG', 'unoptimized']) if (typeof config[key] !== 'boolean') throw new Error(`images.${key} must be boolean`);
|
|
40
|
+
for (const [key, max] of [['minimumCacheTTL', 31536000], ['maximumDiskCacheSize', 4 * 1024 ** 3], ['maximumRedirects', 20], ['maximumResponseBody', 100 * 1024 ** 2]]) {
|
|
41
|
+
if (!Number.isSafeInteger(config[key]) || config[key] < (key === 'maximumResponseBody' ? 1 : 0) || config[key] > max) throw new Error(`images.${key} must be an integer from ${key === 'maximumResponseBody' ? 1 : 0} to ${max}`);
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(config.formats) || !config.formats.length || config.formats.length > 2 || config.formats.some(value => !['image/avif', 'image/webp'].includes(value))) throw new Error('images.formats must contain image/avif and/or image/webp');
|
|
44
|
+
if (!['attachment', 'inline'].includes(config.contentDispositionType)) throw new Error('images.contentDispositionType must be attachment or inline');
|
|
45
|
+
if (typeof config.contentSecurityPolicy !== 'string' || /[\r\n]/.test(config.contentSecurityPolicy)) throw new Error('images.contentSecurityPolicy must be a single-line string');
|
|
46
|
+
if (!Array.isArray(config.domains) || config.domains.length > 50 || config.domains.some(value => typeof value !== 'string' || !value || /[\s/?#]/.test(value))) throw new Error('images.domains must be an array of hostnames');
|
|
47
|
+
if (trailingSlash && !config.path.endsWith('/')) config.path += '/';
|
|
48
|
+
config.remotePatterns = patterns(config.remotePatterns, true);
|
|
49
|
+
if (/^https?:\/\//.test(assetPrefix)) {
|
|
50
|
+
const url = new URL(assetPrefix);
|
|
51
|
+
if (!config.remotePatterns.some(pattern => pattern.hostname === url.hostname && pattern.protocol === url.protocol.slice(0, -1) && pattern.port === url.port && pattern.pathname === undefined && pattern.search === undefined)) config.remotePatterns.push({ protocol: url.protocol.slice(0, -1), hostname: url.hostname, port: url.port });
|
|
52
|
+
}
|
|
53
|
+
if (config.localPatterns !== undefined) {
|
|
54
|
+
config.localPatterns = patterns(config.localPatterns, false);
|
|
55
|
+
if (!/^https?:\/\//.test(assetPrefix)) {
|
|
56
|
+
const prefix = (assetPrefix || basePath).replace(/\/$/, '');
|
|
57
|
+
const pathname = `${prefix}/_prnext/assets/**`;
|
|
58
|
+
if (!config.localPatterns.some(pattern => pattern.pathname === pathname && pattern.search === '')) config.localPatterns.push({ pathname, search: '' });
|
|
59
|
+
}
|
|
60
|
+
} else config.localPatterns = [{ pathname: '**', search: '' }];
|
|
61
|
+
return config;
|
|
62
|
+
}
|
package/build/images.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { mkdir, readFile, writeFile, realpath, stat } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { build as bundle } from 'esbuild';
|
|
8
|
+
import { validateImagesConfig } from './images-config.mjs';
|
|
9
|
+
import { resolveNativeBinary } from '../native/resolve.mjs';
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const imageExtension = /\.(?:png|jpe?g|webp|avif|gif|ico|bmp|tiff?|svg)$/i;
|
|
13
|
+
export async function createImages({ projectRoot, stage, assetBase, config: input }) {
|
|
14
|
+
// Project configuration is already normalized, including generated static/CDN patterns.
|
|
15
|
+
const config = input || validateImagesConfig();
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
let active = 0;
|
|
18
|
+
const waiting = [];
|
|
19
|
+
async function bounded(operation) {
|
|
20
|
+
if (active >= 2) await new Promise(resolve => waiting.push(resolve));
|
|
21
|
+
active++;
|
|
22
|
+
try { return await operation(); }
|
|
23
|
+
finally { active--; waiting.shift()?.(); }
|
|
24
|
+
}
|
|
25
|
+
const configured = path.join(stage, 'compat', 'image-config.cjs');
|
|
26
|
+
await mkdir(path.dirname(configured), { recursive: true });
|
|
27
|
+
const clientConfig = { deviceSizes: config.deviceSizes, imageSizes: config.imageSizes, qualities: config.qualities, path: config.path, loader: config.loader, unoptimized: config.unoptimized, dangerouslyAllowSVG: config.dangerouslyAllowSVG, localPatterns: config.localPatterns, remotePatterns: config.remotePatterns, domains: config.domains };
|
|
28
|
+
let loaderSource = '';
|
|
29
|
+
if (config.loaderFile) {
|
|
30
|
+
const file = await realpath(path.resolve(projectRoot, config.loaderFile));
|
|
31
|
+
const root = await realpath(projectRoot);
|
|
32
|
+
if (file !== root && !file.startsWith(root + path.sep)) throw new Error('images.loaderFile must be inside the project');
|
|
33
|
+
loaderSource = `\nconst custom=require(${JSON.stringify(file)});module.exports.customLoader=custom.default||custom;`;
|
|
34
|
+
}
|
|
35
|
+
await writeFile(configured, `'use strict';module.exports=${JSON.stringify(clientConfig)};${loaderSource}\n`);
|
|
36
|
+
if (loaderSource) {
|
|
37
|
+
const result = await bundle({ entryPoints: [configured], bundle: true, write: false, format: 'cjs', platform: 'neutral', target: 'es2022', logLevel: 'silent' });
|
|
38
|
+
await writeFile(configured, result.outputFiles[0].contents);
|
|
39
|
+
}
|
|
40
|
+
const files = new Map();
|
|
41
|
+
async function loadFile(filename) {
|
|
42
|
+
const size = (await stat(filename)).size;
|
|
43
|
+
if (size > 50_000_000) throw new Error(`Static image exceeds 50 MB: ${filename}`);
|
|
44
|
+
const bytes = await readFile(filename);
|
|
45
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 20);
|
|
46
|
+
const extension = path.extname(filename).toLowerCase();
|
|
47
|
+
const key = hash + extension;
|
|
48
|
+
if (!cache.has(key)) cache.set(key, (async () => {
|
|
49
|
+
let info;
|
|
50
|
+
if (extension === '.svg') {
|
|
51
|
+
const source = bytes.toString('utf8');
|
|
52
|
+
const svg = /<svg\b([^>]*)>/i.exec(source)?.[1] || '';
|
|
53
|
+
const attribute = name => new RegExp(`(?:^|\\s)${name}\\s*=\\s*["']([^"']+)["']`, 'i').exec(svg)?.[1];
|
|
54
|
+
const viewbox = (attribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
|
55
|
+
const dimension = name => /^\d+(?:\.\d+)?(?:px)?$/.test(attribute(name) || '') ? parseFloat(attribute(name)) : undefined;
|
|
56
|
+
info = { width: dimension('width') || viewbox[2], height: dimension('height') || viewbox[3] };
|
|
57
|
+
if (!info.width || !info.height) throw new Error(`Static SVG ${filename} requires dimensions or a viewBox`);
|
|
58
|
+
} else {
|
|
59
|
+
const binary = await resolveNativeBinary();
|
|
60
|
+
const { stdout } = await execute(binary, ['image-info', filename], { timeout: 30_000, maxBuffer: 64 * 1024 });
|
|
61
|
+
info = JSON.parse(stdout);
|
|
62
|
+
}
|
|
63
|
+
const name = `image-${hash}${extension}`;
|
|
64
|
+
await mkdir(path.join(stage, 'assets'), { recursive: true });
|
|
65
|
+
await writeFile(path.join(stage, 'assets', name), bytes);
|
|
66
|
+
return { src: `${assetBase}/${name}`, ...info };
|
|
67
|
+
})());
|
|
68
|
+
return cache.get(key);
|
|
69
|
+
}
|
|
70
|
+
function load(filename) {
|
|
71
|
+
if (!files.has(filename)) files.set(filename, bounded(() => loadFile(filename)));
|
|
72
|
+
return files.get(filename);
|
|
73
|
+
}
|
|
74
|
+
return { plugin() { return { name: 'prnext-images', setup(build) {
|
|
75
|
+
build.onResolve({ filter: /(?:^|\/)image-config\.cjs$/ }, args => args.importer && path.resolve(args.importer) === path.join(packageRoot, 'compat/image-shared.cjs') ? { path: configured } : undefined);
|
|
76
|
+
if (config.disableStaticImages) return;
|
|
77
|
+
build.onResolve({ filter: imageExtension }, async args => {
|
|
78
|
+
if (args.kind !== 'import-statement' && args.kind !== 'require-call' && args.kind !== 'dynamic-import') return;
|
|
79
|
+
if (args.pluginData?.prnextImageResolve) return;
|
|
80
|
+
const result = await build.resolve(args.path, { kind: args.kind, resolveDir: args.resolveDir, importer: args.importer, pluginData: { ...args.pluginData, prnextImageResolve: true } });
|
|
81
|
+
if (result.errors.length || result.external || !result.path) return;
|
|
82
|
+
return { path: result.path, namespace: 'prnext-image' };
|
|
83
|
+
});
|
|
84
|
+
build.onLoad({ filter: /.*/, namespace: 'prnext-image' }, async args => ({ contents: `export default ${JSON.stringify(await load(args.path))};`, loader: 'js', watchFiles: [args.path] }));
|
|
85
|
+
} }; } };
|
|
86
|
+
}
|