@megabudino/stack-utils 1.0.0
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/LICENSE +33 -0
- package/README.md +125 -0
- package/dist/images/class-split.d.ts +6 -0
- package/dist/images/class-split.js +49 -0
- package/dist/images/config.d.ts +16 -0
- package/dist/images/config.js +15 -0
- package/dist/images/generator.d.ts +7 -0
- package/dist/images/generator.js +203 -0
- package/dist/images/index.d.ts +4 -0
- package/dist/images/index.js +3 -0
- package/dist/images/internal/StaticImage.svelte +76 -0
- package/dist/images/types.d.ts +14 -0
- package/dist/images/types.js +1 -0
- package/dist/images/vite-plugin.d.ts +2 -0
- package/dist/images/vite-plugin.js +254 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/proxy/handle.d.ts +9 -0
- package/dist/proxy/handle.js +90 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/rewrite.d.ts +13 -0
- package/dist/proxy/rewrite.js +50 -0
- package/dist/proxy/types.d.ts +28 -0
- package/dist/proxy/types.js +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Source-Available License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davide Ruggeri
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
This software is made available in source form for inspection and limited use.
|
|
7
|
+
It is not open source software.
|
|
8
|
+
|
|
9
|
+
Permission is granted to any person obtaining a copy of this software and
|
|
10
|
+
associated documentation files (the "Software") to:
|
|
11
|
+
|
|
12
|
+
1. view, download, and inspect the source code;
|
|
13
|
+
2. use the Software internally for personal, evaluation, or non-commercial use;
|
|
14
|
+
3. modify the Software for internal use only.
|
|
15
|
+
|
|
16
|
+
Without prior written permission from the copyright holder, you may not:
|
|
17
|
+
|
|
18
|
+
1. publish, distribute, sublicense, rent, lease, or resell the Software;
|
|
19
|
+
2. make the Software publicly available in source or binary form;
|
|
20
|
+
3. use the Software, or derivatives of it, for commercial purposes;
|
|
21
|
+
4. offer the Software as part of a hosted or managed service;
|
|
22
|
+
5. remove or alter any copyright, license, or attribution notices.
|
|
23
|
+
|
|
24
|
+
For commercial use, redistribution, or any other use outside the permissions
|
|
25
|
+
above, contact the copyright holder for a separate license.
|
|
26
|
+
|
|
27
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
|
31
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
|
|
32
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @megabudino/stack-utils
|
|
2
|
+
|
|
3
|
+
A single package with reusable utilities for a SvelteKit stack.
|
|
4
|
+
|
|
5
|
+
This package is source-available. The source is provided for inspection and limited use under the terms in `LICENSE`. It is not open source.
|
|
6
|
+
|
|
7
|
+
Today it exposes two modules:
|
|
8
|
+
|
|
9
|
+
- `@megabudino/stack-utils/proxy`
|
|
10
|
+
- `@megabudino/stack-utils/images`
|
|
11
|
+
|
|
12
|
+
Module-specific docs:
|
|
13
|
+
|
|
14
|
+
- `src/proxy/README.md`
|
|
15
|
+
- `src/images/README.md`
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @megabudino/stack-utils
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Modules
|
|
24
|
+
|
|
25
|
+
### `proxy`
|
|
26
|
+
|
|
27
|
+
A SvelteKit `Handle` hook that reverse-proxies unmatched routes to an origin server.
|
|
28
|
+
|
|
29
|
+
Import:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createProxyHandle } from '@megabudino/stack-utils/proxy';
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Usage in `src/hooks.server.ts`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { createProxyHandle } from '@megabudino/stack-utils/proxy';
|
|
39
|
+
|
|
40
|
+
export const handle = createProxyHandle({
|
|
41
|
+
originUrl: 'https://cms.example.com',
|
|
42
|
+
siteUrl: 'https://www.example.com'
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Proxy config:
|
|
47
|
+
|
|
48
|
+
| Option | Type | Default | Description |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| `originUrl` | `string` | required | Origin server URL |
|
|
51
|
+
| `siteUrl` | `string` | required | Public site URL |
|
|
52
|
+
| `sitemaps` | `string[]` | `[]` | SvelteKit sitemap URLs to inject into sitemap indexes |
|
|
53
|
+
| `sitemapCacheSeconds` | `number` | `3600` | Cache max-age for sitemap responses |
|
|
54
|
+
| `proxyAssets` | `boolean` | `false` | Rewrite origin asset URLs to relative paths |
|
|
55
|
+
| `additionalOrigins` | `string[]` | `[]` | Additional absolute origins to rewrite like `originUrl` |
|
|
56
|
+
|
|
57
|
+
What it does:
|
|
58
|
+
|
|
59
|
+
- lets SvelteKit routes resolve normally when a route exists
|
|
60
|
+
- proxies unmatched requests to the configured origin
|
|
61
|
+
- rewrites redirects back to the public domain
|
|
62
|
+
- rewrites HTML and injects extra sitemap entries when configured
|
|
63
|
+
|
|
64
|
+
### `images`
|
|
65
|
+
|
|
66
|
+
A static image pipeline for Svelte projects that keep source assets in `static/images` and want responsive variants plus automatic `<img>` upgrades.
|
|
67
|
+
|
|
68
|
+
Import:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { staticImagePipeline } from '@megabudino/stack-utils/images';
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Usage in `vite.config.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
78
|
+
import { defineConfig } from 'vite';
|
|
79
|
+
import { staticImagePipeline } from '@megabudino/stack-utils/images';
|
|
80
|
+
|
|
81
|
+
export default defineConfig({
|
|
82
|
+
plugins: [sveltekit(), staticImagePipeline()]
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `images` module also exports:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import {
|
|
90
|
+
generateStaticImageVariants,
|
|
91
|
+
staticImagePipelineConfig
|
|
92
|
+
} from '@megabudino/stack-utils/images';
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
What it does:
|
|
96
|
+
|
|
97
|
+
- reads source images from `static/images`
|
|
98
|
+
- writes responsive variants to `static/_image-variants`
|
|
99
|
+
- writes its generated manifest to `.stack-utils/image-variants.generated.json`
|
|
100
|
+
- rewrites compatible `<img src="/images/...">` tags to an internal `StaticImage` component
|
|
101
|
+
- keeps `StaticImage` internal to the package so the public API stays small
|
|
102
|
+
|
|
103
|
+
### Consumer gitignore
|
|
104
|
+
|
|
105
|
+
In the consuming app, ignore the generated pipeline output:
|
|
106
|
+
|
|
107
|
+
```gitignore
|
|
108
|
+
static/_image-variants
|
|
109
|
+
.stack-utils
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Publishing Notes
|
|
113
|
+
|
|
114
|
+
The package is intended to be consumed via subpath imports:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createProxyHandle } from '@megabudino/stack-utils/proxy';
|
|
118
|
+
import { staticImagePipeline } from '@megabudino/stack-utils/images';
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`StaticImage.svelte` is not part of the documented public API even though the package resolves it internally for the image pipeline.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
Source-available. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const WRAPPER_CLASS_PATTERNS = [
|
|
2
|
+
/^row-span-/,
|
|
3
|
+
/^col-span-/,
|
|
4
|
+
/^row-start-/,
|
|
5
|
+
/^row-end-/,
|
|
6
|
+
/^col-start-/,
|
|
7
|
+
/^col-end-/,
|
|
8
|
+
/^order-/,
|
|
9
|
+
/^self-/,
|
|
10
|
+
/^justify-self-/,
|
|
11
|
+
/^place-self-/,
|
|
12
|
+
/^aspect-/
|
|
13
|
+
];
|
|
14
|
+
const IMAGE_CLASS_PATTERNS = [
|
|
15
|
+
/^rounded(?:-|$)/,
|
|
16
|
+
/^object(?:-|$)/,
|
|
17
|
+
/^shadow(?:-|$)/,
|
|
18
|
+
/^ring(?:-|$)/,
|
|
19
|
+
/^opacity(?:-|$)/,
|
|
20
|
+
/^mix-blend-/,
|
|
21
|
+
/^filter$/,
|
|
22
|
+
/^blur(?:-|$)/
|
|
23
|
+
];
|
|
24
|
+
export function splitStaticImageClasses(classValue) {
|
|
25
|
+
const wrapperTokens = [];
|
|
26
|
+
const imageTokens = [];
|
|
27
|
+
for (const token of classValue.split(/\s+/).filter(Boolean)) {
|
|
28
|
+
const utilityName = getUtilityName(token);
|
|
29
|
+
if (WRAPPER_CLASS_PATTERNS.some((pattern) => pattern.test(utilityName))) {
|
|
30
|
+
wrapperTokens.push(token);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (IMAGE_CLASS_PATTERNS.some((pattern) => pattern.test(utilityName))) {
|
|
34
|
+
imageTokens.push(token);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
imageTokens.push(token);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
wrapperClass: wrapperTokens.join(' '),
|
|
41
|
+
imageClass: imageTokens.join(' ')
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function joinClassNames(...values) {
|
|
45
|
+
return values.filter(Boolean).join(' ');
|
|
46
|
+
}
|
|
47
|
+
function getUtilityName(classToken) {
|
|
48
|
+
return classToken.split(':').pop() ?? classToken;
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const staticImagePipelineConfig: {
|
|
2
|
+
readonly sourceDirectory: "static/images";
|
|
3
|
+
readonly outputDirectory: "static/_image-variants";
|
|
4
|
+
readonly publicSourcePrefix: "/images";
|
|
5
|
+
readonly publicOutputPrefix: "/_image-variants";
|
|
6
|
+
readonly manifestPath: ".stack-utils/image-variants.generated.json";
|
|
7
|
+
readonly supportedExtensions: readonly [".jpg", ".jpeg", ".png", ".webp"];
|
|
8
|
+
readonly widths: readonly [320, 480, 640, 768, 960, 1280, 1536, 1920];
|
|
9
|
+
readonly quality: {
|
|
10
|
+
readonly avif: 50;
|
|
11
|
+
readonly webp: 75;
|
|
12
|
+
readonly jpeg: 82;
|
|
13
|
+
readonly png: 80;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type StaticImageExtension = (typeof staticImagePipelineConfig.supportedExtensions)[number];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const staticImagePipelineConfig = {
|
|
2
|
+
sourceDirectory: 'static/images',
|
|
3
|
+
outputDirectory: 'static/_image-variants',
|
|
4
|
+
publicSourcePrefix: '/images',
|
|
5
|
+
publicOutputPrefix: '/_image-variants',
|
|
6
|
+
manifestPath: '.stack-utils/image-variants.generated.json',
|
|
7
|
+
supportedExtensions: ['.jpg', '.jpeg', '.png', '.webp'],
|
|
8
|
+
widths: [320, 480, 640, 768, 960, 1280, 1536, 1920],
|
|
9
|
+
quality: {
|
|
10
|
+
avif: 50,
|
|
11
|
+
webp: 75,
|
|
12
|
+
jpeg: 82,
|
|
13
|
+
png: 80
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ImageVariantManifest } from './types.js';
|
|
2
|
+
interface GenerateStaticImageVariantsOptions {
|
|
3
|
+
root: string;
|
|
4
|
+
log?: (message: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function generateStaticImageVariants({ root, log }: GenerateStaticImageVariantsOptions): Promise<ImageVariantManifest>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { staticImagePipelineConfig } from './config.js';
|
|
5
|
+
const SUPPORTED_EXTENSIONS = new Set(staticImagePipelineConfig.supportedExtensions);
|
|
6
|
+
export async function generateStaticImageVariants({ root, log = () => undefined }) {
|
|
7
|
+
const sourceDirectory = path.join(root, staticImagePipelineConfig.sourceDirectory);
|
|
8
|
+
const outputDirectory = path.join(root, staticImagePipelineConfig.outputDirectory);
|
|
9
|
+
const manifestPath = path.join(root, staticImagePipelineConfig.manifestPath);
|
|
10
|
+
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
11
|
+
const sourceFiles = await collectSourceImages(sourceDirectory);
|
|
12
|
+
if (sourceFiles.length === 0) {
|
|
13
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
14
|
+
await fs.writeFile(manifestPath, '{}\n');
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
18
|
+
const manifest = {};
|
|
19
|
+
for (const file of sourceFiles) {
|
|
20
|
+
const entry = await generateManifestEntry({ file, outputDirectory });
|
|
21
|
+
if (entry) {
|
|
22
|
+
manifest[entry.src] = entry;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
26
|
+
log(`[static-image-pipeline] generated ${Object.keys(manifest).length} manifest entries`);
|
|
27
|
+
return manifest;
|
|
28
|
+
}
|
|
29
|
+
async function generateManifestEntry({ file, outputDirectory }) {
|
|
30
|
+
const image = sharp(file.absolutePath, { failOn: 'none' });
|
|
31
|
+
const metadata = await image.metadata();
|
|
32
|
+
if (!metadata.width || !metadata.height) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const relativePath = toPosixPath(file.relativePath);
|
|
36
|
+
const sourcePath = `${staticImagePipelineConfig.publicSourcePrefix}/${relativePath}`;
|
|
37
|
+
const fallbackExtension = getFallbackExtension(path.extname(relativePath));
|
|
38
|
+
const widths = getTargetWidths(metadata.width);
|
|
39
|
+
const outputSubdirectory = path.join(outputDirectory, path.dirname(file.relativePath));
|
|
40
|
+
const basename = path.parse(file.relativePath).name;
|
|
41
|
+
await fs.mkdir(outputSubdirectory, { recursive: true });
|
|
42
|
+
const formats = {
|
|
43
|
+
avif: [],
|
|
44
|
+
webp: [],
|
|
45
|
+
fallback: []
|
|
46
|
+
};
|
|
47
|
+
for (const width of widths) {
|
|
48
|
+
const height = Math.round((metadata.height * width) / metadata.width);
|
|
49
|
+
formats.avif.push(await generateVariant({
|
|
50
|
+
sourceFile: file,
|
|
51
|
+
outputSubdirectory,
|
|
52
|
+
relativePath,
|
|
53
|
+
basename,
|
|
54
|
+
width,
|
|
55
|
+
height,
|
|
56
|
+
format: 'avif',
|
|
57
|
+
fallbackExtension
|
|
58
|
+
}));
|
|
59
|
+
formats.webp.push(await generateVariant({
|
|
60
|
+
sourceFile: file,
|
|
61
|
+
outputSubdirectory,
|
|
62
|
+
relativePath,
|
|
63
|
+
basename,
|
|
64
|
+
width,
|
|
65
|
+
height,
|
|
66
|
+
format: 'webp',
|
|
67
|
+
fallbackExtension
|
|
68
|
+
}));
|
|
69
|
+
formats.fallback.push(await generateVariant({
|
|
70
|
+
sourceFile: file,
|
|
71
|
+
outputSubdirectory,
|
|
72
|
+
relativePath,
|
|
73
|
+
basename,
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
format: 'fallback',
|
|
77
|
+
fallbackExtension
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
src: sourcePath,
|
|
82
|
+
width: metadata.width,
|
|
83
|
+
height: metadata.height,
|
|
84
|
+
fallbackExtension,
|
|
85
|
+
formats
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function generateVariant({ sourceFile, outputSubdirectory, relativePath, basename, width, height, format, fallbackExtension }) {
|
|
89
|
+
const extension = getGeneratedExtension(format, fallbackExtension);
|
|
90
|
+
const outputFilename = `${basename}-${width}.${extension}`;
|
|
91
|
+
const outputAbsolutePath = path.join(outputSubdirectory, outputFilename);
|
|
92
|
+
const outputRelativeDirectory = path.dirname(relativePath);
|
|
93
|
+
const outputRelativePath = outputRelativeDirectory === '.'
|
|
94
|
+
? outputFilename
|
|
95
|
+
: `${toPosixPath(outputRelativeDirectory)}/${outputFilename}`;
|
|
96
|
+
const shouldWriteVariant = await needsWrite(outputAbsolutePath, sourceFile.mtimeMs);
|
|
97
|
+
if (shouldWriteVariant) {
|
|
98
|
+
let pipeline = sharp(sourceFile.absolutePath, { failOn: 'none' }).resize({
|
|
99
|
+
width,
|
|
100
|
+
withoutEnlargement: true
|
|
101
|
+
});
|
|
102
|
+
if (format === 'avif') {
|
|
103
|
+
pipeline = pipeline.avif({ quality: staticImagePipelineConfig.quality.avif });
|
|
104
|
+
}
|
|
105
|
+
else if (format === 'webp') {
|
|
106
|
+
pipeline = pipeline.webp({ quality: staticImagePipelineConfig.quality.webp });
|
|
107
|
+
}
|
|
108
|
+
else if (fallbackExtension === 'png') {
|
|
109
|
+
pipeline = pipeline.png({
|
|
110
|
+
quality: staticImagePipelineConfig.quality.png,
|
|
111
|
+
compressionLevel: 9,
|
|
112
|
+
palette: true
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else if (fallbackExtension === 'webp') {
|
|
116
|
+
pipeline = pipeline.webp({ quality: staticImagePipelineConfig.quality.webp });
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
pipeline = pipeline.jpeg({
|
|
120
|
+
quality: staticImagePipelineConfig.quality.jpeg,
|
|
121
|
+
mozjpeg: true
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
await pipeline.toFile(outputAbsolutePath);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
url: `${staticImagePipelineConfig.publicOutputPrefix}/${outputRelativePath}`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function collectSourceImages(directory) {
|
|
133
|
+
const files = [];
|
|
134
|
+
await walkDirectory(directory, async (absolutePath, relativePath) => {
|
|
135
|
+
const extension = path.extname(absolutePath).toLowerCase();
|
|
136
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const stats = await fs.stat(absolutePath);
|
|
140
|
+
files.push({
|
|
141
|
+
absolutePath,
|
|
142
|
+
relativePath,
|
|
143
|
+
mtimeMs: stats.mtimeMs
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
147
|
+
return files;
|
|
148
|
+
}
|
|
149
|
+
async function walkDirectory(directory, onFile, baseDirectory = directory) {
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code === 'ENOENT') {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const absolutePath = path.join(directory, entry.name);
|
|
162
|
+
if (entry.isDirectory()) {
|
|
163
|
+
await walkDirectory(absolutePath, onFile, baseDirectory);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const relativePath = path.relative(baseDirectory, absolutePath);
|
|
167
|
+
await onFile(absolutePath, relativePath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function needsWrite(outputPath, sourceMtimeMs) {
|
|
171
|
+
try {
|
|
172
|
+
const outputStats = await fs.stat(outputPath);
|
|
173
|
+
return outputStats.mtimeMs < sourceMtimeMs;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.code === 'ENOENT') {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function getTargetWidths(originalWidth) {
|
|
183
|
+
const widths = staticImagePipelineConfig.widths.filter((width) => width < originalWidth).map(Number);
|
|
184
|
+
return Array.from(new Set([...widths, originalWidth])).sort((left, right) => left - right);
|
|
185
|
+
}
|
|
186
|
+
function getFallbackExtension(extension) {
|
|
187
|
+
if (extension === '.png') {
|
|
188
|
+
return 'png';
|
|
189
|
+
}
|
|
190
|
+
if (extension === '.webp') {
|
|
191
|
+
return 'webp';
|
|
192
|
+
}
|
|
193
|
+
return 'jpg';
|
|
194
|
+
}
|
|
195
|
+
function getGeneratedExtension(format, fallbackExtension) {
|
|
196
|
+
if (format === 'fallback') {
|
|
197
|
+
return fallbackExtension;
|
|
198
|
+
}
|
|
199
|
+
return format;
|
|
200
|
+
}
|
|
201
|
+
function toPosixPath(filePath) {
|
|
202
|
+
return filePath.split(path.sep).join('/');
|
|
203
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { staticImagePipeline } from './vite-plugin.js';
|
|
2
|
+
export { generateStaticImageVariants } from './generator.js';
|
|
3
|
+
export { staticImagePipelineConfig } from './config.js';
|
|
4
|
+
export type { GeneratedImageFormat, ImageVariant, ImageVariantManifest, ImageVariantManifestEntry } from './types.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLImgAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
import { joinClassNames } from '../class-split';
|
|
5
|
+
import type { ImageVariant, ImageVariantManifest } from '../types';
|
|
6
|
+
|
|
7
|
+
type StaticImageProps = Omit<HTMLImgAttributes, 'src' | 'class'> & {
|
|
8
|
+
src: string;
|
|
9
|
+
manifest?: ImageVariantManifest;
|
|
10
|
+
optimize?: boolean;
|
|
11
|
+
wrapperClass?: string;
|
|
12
|
+
imgClass?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
src,
|
|
18
|
+
manifest = {},
|
|
19
|
+
alt = '',
|
|
20
|
+
optimize = true,
|
|
21
|
+
sizes,
|
|
22
|
+
srcset,
|
|
23
|
+
width,
|
|
24
|
+
height,
|
|
25
|
+
wrapperClass = '',
|
|
26
|
+
imgClass = '',
|
|
27
|
+
class: className = '',
|
|
28
|
+
...restProps
|
|
29
|
+
}: StaticImageProps = $props();
|
|
30
|
+
|
|
31
|
+
const manifestEntry = $derived(optimize && !srcset ? manifest[src] : undefined);
|
|
32
|
+
const fallbackVariants = $derived(manifestEntry?.formats.fallback ?? []);
|
|
33
|
+
const avifSrcset = $derived(toSrcset(manifestEntry?.formats.avif));
|
|
34
|
+
const webpSrcset = $derived(toSrcset(manifestEntry?.formats.webp));
|
|
35
|
+
const fallbackSrcset = $derived(toSrcset(fallbackVariants));
|
|
36
|
+
const fallbackSrc = $derived(getLargestVariant(fallbackVariants)?.url ?? src);
|
|
37
|
+
const resolvedWidth = $derived(width ?? manifestEntry?.width);
|
|
38
|
+
const resolvedHeight = $derived(height ?? manifestEntry?.height);
|
|
39
|
+
const resolvedImgClass = $derived(joinClassNames(className, imgClass));
|
|
40
|
+
const fallbackClass = $derived(joinClassNames(wrapperClass, resolvedImgClass));
|
|
41
|
+
|
|
42
|
+
function toSrcset(variants: ImageVariant[] | undefined) {
|
|
43
|
+
if (!variants?.length) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return variants.map((variant) => `${variant.url} ${variant.width}w`).join(', ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getLargestVariant(variants: ImageVariant[]) {
|
|
51
|
+
return variants[variants.length - 1];
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if manifestEntry}
|
|
56
|
+
<picture class={wrapperClass || undefined}>
|
|
57
|
+
{#if avifSrcset}
|
|
58
|
+
<source type="image/avif" srcset={avifSrcset} sizes={sizes} />
|
|
59
|
+
{/if}
|
|
60
|
+
{#if webpSrcset}
|
|
61
|
+
<source type="image/webp" srcset={webpSrcset} sizes={sizes} />
|
|
62
|
+
{/if}
|
|
63
|
+
<img
|
|
64
|
+
{...restProps}
|
|
65
|
+
src={fallbackSrc}
|
|
66
|
+
alt={alt}
|
|
67
|
+
srcset={fallbackSrcset}
|
|
68
|
+
sizes={sizes}
|
|
69
|
+
width={resolvedWidth}
|
|
70
|
+
height={resolvedHeight}
|
|
71
|
+
class={resolvedImgClass || undefined}
|
|
72
|
+
/>
|
|
73
|
+
</picture>
|
|
74
|
+
{:else}
|
|
75
|
+
<img {...restProps} {src} {alt} {srcset} {sizes} {width} {height} class={fallbackClass || undefined} />
|
|
76
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type GeneratedImageFormat = 'avif' | 'webp' | 'fallback';
|
|
2
|
+
export interface ImageVariant {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
url: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ImageVariantManifestEntry {
|
|
8
|
+
src: string;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
fallbackExtension: 'jpg' | 'png' | 'webp';
|
|
12
|
+
formats: Record<GeneratedImageFormat, ImageVariant[]>;
|
|
13
|
+
}
|
|
14
|
+
export type ImageVariantManifest = Record<string, ImageVariantManifestEntry>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'svelte/compiler';
|
|
4
|
+
import { joinClassNames, splitStaticImageClasses } from './class-split.js';
|
|
5
|
+
import { staticImagePipelineConfig } from './config.js';
|
|
6
|
+
import { generateStaticImageVariants } from './generator.js';
|
|
7
|
+
const PACKAGE_NAME = '@megabudino/stack-utils';
|
|
8
|
+
const VIRTUAL_MANIFEST_MODULE = 'virtual:stack-utils-image-manifest';
|
|
9
|
+
const RESOLVED_VIRTUAL_MANIFEST_MODULE = '\0virtual:stack-utils-image-manifest';
|
|
10
|
+
const STATIC_IMAGE_IMPORT_BLOCK = [
|
|
11
|
+
`import StaticImage from '${PACKAGE_NAME}/images/internal/StaticImage.svelte';`,
|
|
12
|
+
`import staticImagePipelineManifest from '${VIRTUAL_MANIFEST_MODULE}';`
|
|
13
|
+
].join('\n');
|
|
14
|
+
const OPTIMIZABLE_SOURCE_REGEX = /^\/images\/.+\.(?:png|jpe?g|webp)$/i;
|
|
15
|
+
export function staticImagePipeline() {
|
|
16
|
+
let config;
|
|
17
|
+
let generationPromise = null;
|
|
18
|
+
const ensureGenerated = () => {
|
|
19
|
+
if (generationPromise) {
|
|
20
|
+
return generationPromise;
|
|
21
|
+
}
|
|
22
|
+
generationPromise = generateStaticImageVariants({
|
|
23
|
+
root: config.root,
|
|
24
|
+
log: (message) => config.logger.info(message)
|
|
25
|
+
})
|
|
26
|
+
.then(() => undefined)
|
|
27
|
+
.finally(() => {
|
|
28
|
+
generationPromise = null;
|
|
29
|
+
});
|
|
30
|
+
return generationPromise;
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
name: 'static-image-pipeline',
|
|
34
|
+
enforce: 'pre',
|
|
35
|
+
configResolved(resolvedConfig) {
|
|
36
|
+
config = resolvedConfig;
|
|
37
|
+
},
|
|
38
|
+
resolveId(id) {
|
|
39
|
+
if (id === VIRTUAL_MANIFEST_MODULE) {
|
|
40
|
+
return RESOLVED_VIRTUAL_MANIFEST_MODULE;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
async load(id) {
|
|
45
|
+
if (id !== RESOLVED_VIRTUAL_MANIFEST_MODULE) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const manifest = await readManifest(config.root);
|
|
49
|
+
return `export default ${manifest};`;
|
|
50
|
+
},
|
|
51
|
+
async buildStart() {
|
|
52
|
+
await ensureGenerated();
|
|
53
|
+
},
|
|
54
|
+
configureServer(server) {
|
|
55
|
+
registerStaticImageWatcher(server, ensureGenerated, config.root);
|
|
56
|
+
},
|
|
57
|
+
transform(code, id) {
|
|
58
|
+
const cleanId = id.split('?', 1)[0];
|
|
59
|
+
if (!cleanId.endsWith('.svelte') || isInternalStaticImageFile(cleanId)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return rewriteStaticImages(code);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isInternalStaticImageFile(filePath) {
|
|
67
|
+
return (filePath.includes('/src/images/internal/') ||
|
|
68
|
+
filePath.includes('/dist/images/internal/') ||
|
|
69
|
+
filePath.includes('/node_modules/@megabudino/stack-utils/dist/images/internal/'));
|
|
70
|
+
}
|
|
71
|
+
function registerStaticImageWatcher(server, ensureGenerated, root) {
|
|
72
|
+
const watchedDirectory = path.join(root, staticImagePipelineConfig.sourceDirectory);
|
|
73
|
+
server.watcher.add(watchedDirectory);
|
|
74
|
+
const maybeRegenerate = (changedPath) => {
|
|
75
|
+
const normalizedPath = path.normalize(changedPath);
|
|
76
|
+
const normalizedDirectory = path.normalize(watchedDirectory);
|
|
77
|
+
if (!normalizedPath.startsWith(normalizedDirectory)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!OPTIMIZABLE_SOURCE_REGEX.test(toPublicSourcePath(normalizedDirectory, normalizedPath))) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
void ensureGenerated().then(() => {
|
|
84
|
+
server.ws.send({ type: 'full-reload' });
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
server.watcher.on('add', maybeRegenerate);
|
|
88
|
+
server.watcher.on('change', maybeRegenerate);
|
|
89
|
+
server.watcher.on('unlink', maybeRegenerate);
|
|
90
|
+
}
|
|
91
|
+
function rewriteStaticImages(code) {
|
|
92
|
+
if (!code.includes('<img')) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const ast = parse(code);
|
|
96
|
+
const imgNodes = collectOptimizableImgNodes(ast.html);
|
|
97
|
+
if (imgNodes.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let transformedCode = code;
|
|
101
|
+
for (const node of [...imgNodes].sort((left, right) => right.start - left.start)) {
|
|
102
|
+
const originalMarkup = transformedCode.slice(node.start, node.end);
|
|
103
|
+
const replacement = convertImgToComponentMarkup(node, originalMarkup);
|
|
104
|
+
transformedCode =
|
|
105
|
+
transformedCode.slice(0, node.start) + replacement + transformedCode.slice(node.end);
|
|
106
|
+
}
|
|
107
|
+
if (!transformedCode.includes(STATIC_IMAGE_IMPORT_BLOCK)) {
|
|
108
|
+
transformedCode = injectStaticImageImport(transformedCode, ast.instance);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
code: transformedCode,
|
|
112
|
+
map: null
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function collectOptimizableImgNodes(rootNode) {
|
|
116
|
+
const result = [];
|
|
117
|
+
visitNode(rootNode, false, result);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function visitNode(node, insidePicture, result) {
|
|
121
|
+
if (!node || typeof node !== 'object') {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const nodeIsElement = node.type === 'Element';
|
|
125
|
+
const nextInsidePicture = insidePicture || (nodeIsElement && node.name === 'picture');
|
|
126
|
+
if (nodeIsElement && node.name === 'img' && !insidePicture && isOptimizableImgNode(node)) {
|
|
127
|
+
result.push(node);
|
|
128
|
+
}
|
|
129
|
+
for (const value of Object.values(node)) {
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
for (const child of value) {
|
|
132
|
+
if (child && typeof child === 'object' && 'type' in child) {
|
|
133
|
+
visitNode(child, nextInsidePicture, result);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (value && typeof value === 'object' && 'type' in value) {
|
|
139
|
+
visitNode(value, nextInsidePicture, result);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isOptimizableImgNode(node) {
|
|
144
|
+
const attributes = node.attributes ?? [];
|
|
145
|
+
const sourceAttribute = attributes.find((attribute) => attribute.type === 'Attribute' && attribute.name === 'src');
|
|
146
|
+
if (!sourceAttribute) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const sourceValue = readStaticAttributeValue(sourceAttribute);
|
|
150
|
+
if (!sourceValue || !OPTIMIZABLE_SOURCE_REGEX.test(sourceValue)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (attributes.some((attribute) => attribute.type === 'Attribute' && attribute.name === 'srcset')) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (attributes.some((attribute) => attribute.type === 'Attribute' && attribute.name === 'data-no-optimize')) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (attributes.some((attribute) => attribute.type === 'Binding')) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
function readStaticAttributeValue(attribute) {
|
|
165
|
+
if (attribute.value === true || !Array.isArray(attribute.value) || attribute.value.length !== 1) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
const [value] = attribute.value;
|
|
169
|
+
return value?.type === 'Text' ? value.data : null;
|
|
170
|
+
}
|
|
171
|
+
function convertImgToComponentMarkup(node, markup) {
|
|
172
|
+
const classAttribute = findAttribute(node, 'class');
|
|
173
|
+
const wrapperOverrideAttribute = findAttribute(node, 'data-static-image-wrapper-class');
|
|
174
|
+
const imageOverrideAttribute = findAttribute(node, 'data-static-image-img-class');
|
|
175
|
+
const classValue = classAttribute ? readStaticAttributeValue(classAttribute) : null;
|
|
176
|
+
const wrapperOverride = wrapperOverrideAttribute
|
|
177
|
+
? readStaticAttributeValue(wrapperOverrideAttribute)
|
|
178
|
+
: null;
|
|
179
|
+
const imageOverride = imageOverrideAttribute
|
|
180
|
+
? readStaticAttributeValue(imageOverrideAttribute)
|
|
181
|
+
: null;
|
|
182
|
+
const removedAttributes = [
|
|
183
|
+
...(classValue ? [classAttribute] : []),
|
|
184
|
+
...(wrapperOverride ? [wrapperOverrideAttribute] : []),
|
|
185
|
+
...(imageOverride ? [imageOverrideAttribute] : [])
|
|
186
|
+
].filter(Boolean);
|
|
187
|
+
let rewritten = stripAttributes(markup, node.start, removedAttributes).replace(/^<img\b/, '<StaticImage manifest={staticImagePipelineManifest}');
|
|
188
|
+
const splitClassNames = classValue
|
|
189
|
+
? splitStaticImageClasses(classValue)
|
|
190
|
+
: { wrapperClass: '', imageClass: '' };
|
|
191
|
+
const wrapperClass = joinClassNames(splitClassNames.wrapperClass, wrapperOverride ?? undefined);
|
|
192
|
+
const imageClass = joinClassNames(splitClassNames.imageClass, imageOverride ?? undefined);
|
|
193
|
+
const extraAttributes = [
|
|
194
|
+
wrapperClass ? `wrapperClass="${escapeAttributeValue(wrapperClass)}"` : '',
|
|
195
|
+
imageClass ? `class="${escapeAttributeValue(imageClass)}"` : ''
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
if (extraAttributes.length === 0) {
|
|
198
|
+
return ensureSelfClosingTag(rewritten);
|
|
199
|
+
}
|
|
200
|
+
return appendAttributes(rewritten, extraAttributes);
|
|
201
|
+
}
|
|
202
|
+
function injectStaticImageImport(code, instanceScript) {
|
|
203
|
+
if (!instanceScript) {
|
|
204
|
+
return `<script>\n${STATIC_IMAGE_IMPORT_BLOCK}\n</script>\n\n${code}`;
|
|
205
|
+
}
|
|
206
|
+
const importNodes = instanceScript.content.body.filter((node) => node.type === 'ImportDeclaration');
|
|
207
|
+
const insertPosition = importNodes.length > 0 ? importNodes[importNodes.length - 1].end : instanceScript.content.start;
|
|
208
|
+
return `${code.slice(0, insertPosition)}\n${STATIC_IMAGE_IMPORT_BLOCK}${code.slice(insertPosition)}`;
|
|
209
|
+
}
|
|
210
|
+
function findAttribute(node, name) {
|
|
211
|
+
return node.attributes?.find((attribute) => attribute.type === 'Attribute' && attribute.name === name);
|
|
212
|
+
}
|
|
213
|
+
function stripAttributes(markup, nodeStart, attributes) {
|
|
214
|
+
let rewritten = markup;
|
|
215
|
+
for (const attribute of [...attributes].sort((left, right) => right.start - left.start)) {
|
|
216
|
+
const localStart = attribute.start - nodeStart;
|
|
217
|
+
const localEnd = attribute.end - nodeStart;
|
|
218
|
+
rewritten = rewritten.slice(0, localStart) + rewritten.slice(localEnd);
|
|
219
|
+
}
|
|
220
|
+
return rewritten;
|
|
221
|
+
}
|
|
222
|
+
function appendAttributes(markup, attributes) {
|
|
223
|
+
const normalizedMarkup = ensureSelfClosingTag(markup);
|
|
224
|
+
const attributeString = attributes.join(' ');
|
|
225
|
+
return normalizedMarkup.replace(/\s*\/>$/, ` ${attributeString} />`);
|
|
226
|
+
}
|
|
227
|
+
function ensureSelfClosingTag(markup) {
|
|
228
|
+
if (markup.endsWith('/>')) {
|
|
229
|
+
return markup;
|
|
230
|
+
}
|
|
231
|
+
if (markup.endsWith('>')) {
|
|
232
|
+
return `${markup.slice(0, -1)} />`;
|
|
233
|
+
}
|
|
234
|
+
return markup;
|
|
235
|
+
}
|
|
236
|
+
function escapeAttributeValue(value) {
|
|
237
|
+
return value.replaceAll('&', '&').replaceAll('"', '"');
|
|
238
|
+
}
|
|
239
|
+
function toPublicSourcePath(sourceDirectory, absolutePath) {
|
|
240
|
+
const relativePath = path.relative(sourceDirectory, absolutePath).split(path.sep).join('/');
|
|
241
|
+
return `${staticImagePipelineConfig.publicSourcePrefix}/${relativePath}`;
|
|
242
|
+
}
|
|
243
|
+
async function readManifest(root) {
|
|
244
|
+
const manifestPath = path.join(root, staticImagePipelineConfig.manifestPath);
|
|
245
|
+
try {
|
|
246
|
+
return await fs.readFile(manifestPath, 'utf8');
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (error.code === 'ENOENT') {
|
|
250
|
+
return '{}';
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Handle } from '@sveltejs/kit';
|
|
2
|
+
import type { ProxyConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Create a SvelteKit `Handle` hook that reverse-proxies requests to an origin.
|
|
5
|
+
*
|
|
6
|
+
* Routes matched by SvelteKit's file-based router are resolved locally;
|
|
7
|
+
* everything else is forwarded to the origin.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createProxyHandle(config: ProxyConfig): Handle;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { injectIntoSitemapIndex, rewriteHtml } from './rewrite.js';
|
|
2
|
+
/**
|
|
3
|
+
* Create a SvelteKit `Handle` hook that reverse-proxies requests to an origin.
|
|
4
|
+
*
|
|
5
|
+
* Routes matched by SvelteKit's file-based router are resolved locally;
|
|
6
|
+
* everything else is forwarded to the origin.
|
|
7
|
+
*/
|
|
8
|
+
export function createProxyHandle(config) {
|
|
9
|
+
const { originUrl, siteUrl } = config;
|
|
10
|
+
const sitemaps = config.sitemaps ?? [];
|
|
11
|
+
const sitemapCache = `public, max-age=${config.sitemapCacheSeconds ?? 3600}`;
|
|
12
|
+
const proxyAssets = config.proxyAssets ?? false;
|
|
13
|
+
const additionalOrigins = config.additionalOrigins ?? [];
|
|
14
|
+
return async ({ event, resolve }) => {
|
|
15
|
+
const { pathname, search } = event.url;
|
|
16
|
+
if (event.route.id) {
|
|
17
|
+
return resolve(event);
|
|
18
|
+
}
|
|
19
|
+
const upstream = new URL(pathname + search, originUrl);
|
|
20
|
+
const headers = new Headers(event.request.headers);
|
|
21
|
+
headers.delete('accept-encoding');
|
|
22
|
+
headers.delete('host');
|
|
23
|
+
headers.delete('content-length');
|
|
24
|
+
let upstreamResponse;
|
|
25
|
+
try {
|
|
26
|
+
upstreamResponse = await fetch(upstream, {
|
|
27
|
+
method: event.request.method,
|
|
28
|
+
headers,
|
|
29
|
+
redirect: 'manual',
|
|
30
|
+
body: event.request.method === 'GET' || event.request.method === 'HEAD'
|
|
31
|
+
? undefined
|
|
32
|
+
: event.request.body
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return new Response('Bad Gateway', { status: 502 });
|
|
37
|
+
}
|
|
38
|
+
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
|
39
|
+
const location = upstreamResponse.headers.get('location');
|
|
40
|
+
if (location) {
|
|
41
|
+
try {
|
|
42
|
+
const nextUrl = new URL(location);
|
|
43
|
+
return new Response(null, {
|
|
44
|
+
status: upstreamResponse.status,
|
|
45
|
+
headers: {
|
|
46
|
+
location: `${siteUrl}${nextUrl.pathname}${nextUrl.search}`
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return new Response(null, {
|
|
52
|
+
status: upstreamResponse.status,
|
|
53
|
+
headers: { location }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const cleanHeaders = new Headers(upstreamResponse.headers);
|
|
59
|
+
cleanHeaders.delete('content-encoding');
|
|
60
|
+
cleanHeaders.delete('content-length');
|
|
61
|
+
cleanHeaders.delete('transfer-encoding');
|
|
62
|
+
const contentType = upstreamResponse.headers.get('content-type') ?? '';
|
|
63
|
+
if (contentType.includes('text/xml') || contentType.includes('application/xml')) {
|
|
64
|
+
let xml = await upstreamResponse.text();
|
|
65
|
+
xml = xml.replace(/<\?xml-stylesheet[^?]*\?>\s*/g, '');
|
|
66
|
+
if (sitemaps.length > 0 && xml.includes('</sitemapindex>')) {
|
|
67
|
+
xml = injectIntoSitemapIndex(xml, siteUrl, sitemaps);
|
|
68
|
+
}
|
|
69
|
+
cleanHeaders.set('cache-control', sitemapCache);
|
|
70
|
+
return new Response(xml, {
|
|
71
|
+
status: upstreamResponse.status,
|
|
72
|
+
statusText: upstreamResponse.statusText,
|
|
73
|
+
headers: cleanHeaders
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (contentType.includes('text/html')) {
|
|
77
|
+
const html = await upstreamResponse.text();
|
|
78
|
+
return new Response(rewriteHtml(html, originUrl, siteUrl, proxyAssets, additionalOrigins), {
|
|
79
|
+
status: upstreamResponse.status,
|
|
80
|
+
statusText: upstreamResponse.statusText,
|
|
81
|
+
headers: cleanHeaders
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return new Response(upstreamResponse.body, {
|
|
85
|
+
status: upstreamResponse.status,
|
|
86
|
+
statusText: upstreamResponse.statusText,
|
|
87
|
+
headers: cleanHeaders
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createProxyHandle } from './handle.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML / XML rewriting utilities for the reverse proxy.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Rewrite HTML returned by the origin so it works under the public domain.
|
|
6
|
+
*
|
|
7
|
+
* - Absolute links pointing to the public domain -> relative paths.
|
|
8
|
+
* - When `proxyAssets` is true, absolute origin URLs are stripped to relative paths.
|
|
9
|
+
* - When `proxyAssets` is false, absolute origin URLs are left as-is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function rewriteHtml(html: string, originUrl: string, siteUrl: string, proxyAssets?: boolean, additionalOrigins?: string[]): string;
|
|
12
|
+
/** Inject SvelteKit sitemap references into a `<sitemapindex>`. */
|
|
13
|
+
export declare function injectIntoSitemapIndex(xml: string, siteUrl: string, sitemaps: string[]): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML / XML rewriting utilities for the reverse proxy.
|
|
3
|
+
*/
|
|
4
|
+
/** Build a domain-matching regex from a public site URL. */
|
|
5
|
+
function domainPattern(siteUrl) {
|
|
6
|
+
const { hostname } = new URL(siteUrl);
|
|
7
|
+
// Strip leading "www." so we match both variants
|
|
8
|
+
const bare = hostname.replace(/^www\./, '');
|
|
9
|
+
const escaped = bare.replace(/\./g, '\\.');
|
|
10
|
+
return new RegExp(`href=["']https?://(www\\.)?${escaped}([^"']*)["']`, 'gi');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build a regex that matches absolute URLs for a given origin,
|
|
14
|
+
* capturing the path portion (everything after the origin).
|
|
15
|
+
*/
|
|
16
|
+
function originPattern(origin) {
|
|
17
|
+
const escaped = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
|
+
return new RegExp(escaped, 'gi');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Rewrite HTML returned by the origin so it works under the public domain.
|
|
22
|
+
*
|
|
23
|
+
* - Absolute links pointing to the public domain -> relative paths.
|
|
24
|
+
* - When `proxyAssets` is true, absolute origin URLs are stripped to relative paths.
|
|
25
|
+
* - When `proxyAssets` is false, absolute origin URLs are left as-is.
|
|
26
|
+
*/
|
|
27
|
+
export function rewriteHtml(html, originUrl, siteUrl, proxyAssets = false, additionalOrigins = []) {
|
|
28
|
+
html = html.replace(domainPattern(siteUrl), 'href="$2"');
|
|
29
|
+
html = html.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=["'])([^"']*)["']/gi, (_, prefix, value) => {
|
|
30
|
+
try {
|
|
31
|
+
value = new URL(value).pathname;
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
return `${prefix}${siteUrl}${value}"`;
|
|
35
|
+
});
|
|
36
|
+
if (proxyAssets) {
|
|
37
|
+
const origins = [originUrl, ...additionalOrigins];
|
|
38
|
+
for (const origin of origins) {
|
|
39
|
+
html = html.replace(originPattern(origin), '');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return html;
|
|
43
|
+
}
|
|
44
|
+
/** Inject SvelteKit sitemap references into a `<sitemapindex>`. */
|
|
45
|
+
export function injectIntoSitemapIndex(xml, siteUrl, sitemaps) {
|
|
46
|
+
const entries = sitemaps
|
|
47
|
+
.map((url) => `\t<sitemap>\n\t\t<loc>${siteUrl}${url}</loc>\n\t</sitemap>`)
|
|
48
|
+
.join('\n');
|
|
49
|
+
return xml.replace('</sitemapindex>', `${entries}\n</sitemapindex>`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Configuration for the reverse proxy. */
|
|
2
|
+
export interface ProxyConfig {
|
|
3
|
+
/** The origin URL (not publicly exposed). */
|
|
4
|
+
originUrl: string;
|
|
5
|
+
/** The public site URL (used in sitemaps and link rewriting). */
|
|
6
|
+
siteUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* SvelteKit sitemap URLs to inject into the origin's sitemap index.
|
|
9
|
+
* Defaults to `[]`.
|
|
10
|
+
*/
|
|
11
|
+
sitemaps?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Cache-Control max-age (in seconds) for sitemap responses.
|
|
14
|
+
* Defaults to 3600 (1 hour).
|
|
15
|
+
*/
|
|
16
|
+
sitemapCacheSeconds?: number;
|
|
17
|
+
/**
|
|
18
|
+
* If true, absolute origin URLs in HTML are rewritten to relative paths
|
|
19
|
+
* so assets are served through the SvelteKit proxy and the origin is never exposed.
|
|
20
|
+
* Defaults to false (absolute origin URLs are left as-is).
|
|
21
|
+
*/
|
|
22
|
+
proxyAssets?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Additional origin URLs whose absolute references in HTML should be
|
|
25
|
+
* rewritten the same way as `originUrl` (e.g. legacy/migration domains).
|
|
26
|
+
*/
|
|
27
|
+
additionalOrigins?: string[];
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@megabudino/stack-utils",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Reusable utilities for a SvelteKit stack, including a reverse proxy and static image pipeline",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./proxy": {
|
|
12
|
+
"types": "./dist/proxy/index.d.ts",
|
|
13
|
+
"default": "./dist/proxy/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./images": {
|
|
16
|
+
"types": "./dist/images/index.d.ts",
|
|
17
|
+
"default": "./dist/images/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./images/internal/StaticImage.svelte": {
|
|
20
|
+
"svelte": "./dist/images/internal/StaticImage.svelte",
|
|
21
|
+
"default": "./dist/images/internal/StaticImage.svelte"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node ./scripts/build.mjs",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"sharp": "^0.34.5"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@sveltejs/kit": "^2.0.0",
|
|
38
|
+
"svelte": "^5.0.0",
|
|
39
|
+
"vite": "^7.0.0 || ^8.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@sveltejs/kit": "^2.0.0",
|
|
43
|
+
"@types/node": "^24.12.0",
|
|
44
|
+
"svelte": "^5.54.0",
|
|
45
|
+
"typescript": "^5.0.0",
|
|
46
|
+
"vite": "^8.0.1"
|
|
47
|
+
},
|
|
48
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
49
|
+
}
|