diff --git a/.gitignore b/.gitignore
index 1a82eb615..1c7684f9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,5 @@ tsconfig.tsbuildinfo
apps/tests/**/test-results
.solid-start
+
+.image
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 216a70a8b..b29941267 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,5 +4,11 @@
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
- "editor.defaultFormatter": "oxc.oxc-vscode"
+ "editor.defaultFormatter": "oxc.oxc-vscode",
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "oxc.oxc-vscode"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "oxc.oxc-vscode"
+ }
}
diff --git a/apps/tests/src/images/example.jpg b/apps/tests/src/images/example.jpg
new file mode 100644
index 000000000..229e7980c
Binary files /dev/null and b/apps/tests/src/images/example.jpg differ
diff --git a/apps/tests/src/routes/image-local.tsx b/apps/tests/src/routes/image-local.tsx
new file mode 100644
index 000000000..be4865347
--- /dev/null
+++ b/apps/tests/src/routes/image-local.tsx
@@ -0,0 +1,31 @@
+import { StartImage as Image } from "@solidjs/start/image";
+import { type JSX, onMount, Show } from "solid-js";
+import exampleImage from "../images/example.jpg?image";
+
+interface PlaceholderProps {
+ show: () => void;
+}
+
+function Placeholder(props: PlaceholderProps): JSX.Element {
+ onMount(() => {
+ props.show();
+ });
+
+ return
Loading...
;
+}
+
+export default function App(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/tests/src/routes/image-remote.tsx b/apps/tests/src/routes/image-remote.tsx
new file mode 100644
index 000000000..524023c10
--- /dev/null
+++ b/apps/tests/src/routes/image-remote.tsx
@@ -0,0 +1,35 @@
+import { StartImage as Image } from "@solidjs/start/image";
+import { type JSX, onMount, Show } from "solid-js";
+// local
+// import exampleImage from './example.jpg?image';
+
+// remote
+import exampleImage from "image:foobar";
+
+interface PlaceholderProps {
+ show: () => void;
+}
+
+function Placeholder(props: PlaceholderProps): JSX.Element {
+ onMount(() => {
+ props.show();
+ });
+
+ return Loading...
;
+}
+
+export default function App(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/tests/vite.config.ts b/apps/tests/vite.config.ts
index 4149be667..76d17c627 100644
--- a/apps/tests/vite.config.ts
+++ b/apps/tests/vite.config.ts
@@ -1,10 +1,54 @@
import { defineConfig } from "vite";
-import { solidStart } from "../../packages/start/src/config";
import { nitroV2Plugin } from "../../packages/start-nitro-v2-vite-plugin/src";
+import { solidStart } from "../../packages/start/src/config";
export default defineConfig({
server: {
port: 3000,
},
- plugins: [solidStart(), nitroV2Plugin()],
+ plugins: [
+ solidStart({
+ image: {
+ local: {
+ sizes: [480, 600],
+ quality: 80,
+ publicPath: "public",
+ },
+ remote: {
+ transformURL(url) {
+ return {
+ src: {
+ source: `https://picsum.photos/seed/${url}/1200/900.webp`,
+ width: 1080,
+ height: 760,
+ },
+ variants: [
+ {
+ path: `https://picsum.photos/seed/${url}/800/600.jpg`,
+ width: 800,
+ type: "image/jpeg",
+ },
+ {
+ path: `https://picsum.photos/seed/${url}/400/300.jpg`,
+ width: 400,
+ type: "image/jpeg",
+ },
+ {
+ path: `https://picsum.photos/seed/${url}/800/600.png`,
+ width: 800,
+ type: "image/png",
+ },
+ {
+ path: `https://picsum.photos/seed/${url}/400/300.png`,
+ width: 400,
+ type: "image/png",
+ },
+ ],
+ };
+ },
+ },
+ },
+ }),
+ nitroV2Plugin(),
+ ],
});
diff --git a/packages/start/env.d.ts b/packages/start/env.d.ts
index 2c9155d1c..7ef5d9c2c 100644
--- a/packages/start/env.d.ts
+++ b/packages/start/env.d.ts
@@ -7,3 +7,19 @@ declare namespace App {
[key: string | symbol]: any;
}
}
+
+declare module 'image:*' {
+ import type { StartImageProps } from "./src/image.ts";
+
+ const props: Pick, 'src' | 'transformer'>;
+
+ export default props;
+}
+
+declare module '*?image' {
+ import type { StartImageProps } from "./src/image.ts";
+
+ const props: Pick, 'src' | 'transformer'>;
+
+ export default props;
+}
diff --git a/packages/start/package.json b/packages/start/package.json
index 9170468d3..23822e599 100644
--- a/packages/start/package.json
+++ b/packages/start/package.json
@@ -19,7 +19,8 @@
"./client/spa": "./src/client/spa/index.tsx",
"./middleware": "./src/middleware/index.ts",
"./http": "./src/http/index.ts",
- "./env": "./env.d.ts"
+ "./env": "./env.d.ts",
+ "./image": "./src/image/index.tsx"
},
"publishConfig": {
"access": "public",
@@ -33,7 +34,8 @@
"./client/spa": "./dist/client/spa/index.jsx",
"./middleware": "./dist/middleware/index.js",
"./http": "./dist/http/index.js",
- "./env": "./env.d.ts"
+ "./env": "./env.d.ts",
+ "./image": "./dist/image/index.jsx"
}
},
"dependencies": {
@@ -58,6 +60,7 @@
"radix3": "^1.1.2",
"seroval": "^1.4.1",
"seroval-plugins": "^1.4.0",
+ "sharp": "^0.34.5",
"shiki": "^1.26.1",
"solid-js": "^1.9.9",
"source-map-js": "^1.2.1",
diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts
index 4b1c82179..41edef3d8 100644
--- a/packages/start/src/config/index.ts
+++ b/packages/start/src/config/index.ts
@@ -5,7 +5,7 @@ import { extname, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizePath, type PluginOption } from "vite";
import solid, { type Options as SolidOptions } from "vite-plugin-solid";
-
+import { imagePlugin, type StartImageOptions } from "../image/plugin/index.ts";
import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts";
import { devServer } from "./dev-server.ts";
import { SolidStartClientFileRouter, SolidStartServerFileRouter } from "./fs-router.ts";
@@ -21,6 +21,8 @@ export interface SolidStartOptions {
routeDir?: string;
extensions?: string[];
middleware?: string;
+
+ image?: StartImageOptions;
}
const absolute = (path: string, root: string) =>
@@ -176,7 +178,7 @@ export function solidStart(options?: SolidStartOptions): Array {
envName: VITE_ENVIRONMENTS.client,
getRuntimeCode: () =>
`import { createServerReference } from "${normalizePath(
- fileURLToPath(new URL("../server/server-runtime", import.meta.url))
+ fileURLToPath(new URL("../server/server-runtime", import.meta.url)),
)}"`,
replacer: opts => `createServerReference('${opts.functionId}')`,
},
@@ -185,7 +187,7 @@ export function solidStart(options?: SolidStartOptions): Array {
envName: VITE_ENVIRONMENTS.server,
getRuntimeCode: () =>
`import { createServerReference } from '${normalizePath(
- fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url))
+ fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url)),
)}'`,
replacer: opts => `createServerReference(${opts.fn}, '${opts.functionId}')`,
},
@@ -194,11 +196,12 @@ export function solidStart(options?: SolidStartOptions): Array {
envName: VITE_ENVIRONMENTS.server,
getRuntimeCode: () =>
`import { createServerReference } from '${normalizePath(
- fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url))
+ fileURLToPath(new URL("../server/server-fns-runtime", import.meta.url)),
)}'`,
replacer: opts => `createServerReference(${opts.fn}, '${opts.functionId}')`,
},
}),
+ options?.image ? imagePlugin(options.image) : undefined,
{
name: "solid-start:virtual-modules",
async resolveId(id) {
diff --git a/packages/start/src/image/aspect-ratio.ts b/packages/start/src/image/aspect-ratio.ts
new file mode 100644
index 000000000..962464188
--- /dev/null
+++ b/packages/start/src/image/aspect-ratio.ts
@@ -0,0 +1,86 @@
+function gcd(a: number, b: number): number {
+ if (b === 0) {
+ return a;
+ }
+ return gcd(b, a % b);
+}
+
+export interface AspectRatio {
+ width: number;
+ height: number;
+}
+
+const HORIZONTAL_ASPECT_RATIO = [
+ { width: 4, height: 4 }, // Square
+ { width: 4, height: 3 }, // Standard Fullscreen
+ { width: 16, height: 10 }, // Standard LCD
+ { width: 16, height: 9 }, // HD
+ // { width: 37, height: 20 }, // Widescreen
+ { width: 6, height: 3 }, // Univisium
+ { width: 21, height: 9 }, // Anamorphic 2.35:1
+ // { width: 64, height: 27 }, // Anamorphic 2.39:1 or 2.37:1
+ { width: 19, height: 16 }, // Movietone
+ { width: 5, height: 4 }, // 17' LCD CRT
+ // { width: 48, height: 35 }, // 16mm and 35mm
+ { width: 11, height: 8 }, // 35mm full sound
+ // { width: 143, height: 100 }, // IMAX
+ { width: 6, height: 4 }, // 35mm photo
+ { width: 14, height: 9 }, // commercials
+ { width: 5, height: 3 }, // Paramount
+ { width: 7, height: 4 }, // early 35mm
+ { width: 11, height: 5 }, // 70mm
+ { width: 12, height: 5 }, // Bluray
+ { width: 8, height: 3 }, // Super 16
+ { width: 18, height: 5 }, // IMAX
+ { width: 12, height: 3 }, // Polyvision
+];
+
+const VERTICAL_ASPECT_RATIO = HORIZONTAL_ASPECT_RATIO.map(item => ({
+ width: item.height,
+ height: item.width,
+}));
+
+const ASPECT_RATIO = [...HORIZONTAL_ASPECT_RATIO, ...VERTICAL_ASPECT_RATIO];
+
+export function getAspectRatio({ width, height }: AspectRatio): AspectRatio {
+ const denom = gcd(width, height);
+
+ return {
+ width: width / denom,
+ height: height / denom,
+ };
+}
+
+export function getNearestAspectRatio(ratio: AspectRatio): AspectRatio {
+ let nearest = Number.MAX_VALUE;
+ let id = 0;
+
+ const originalRatio = ratio.width / ratio.height;
+
+ for (let i = 0; i < ASPECT_RATIO.length; i += 1) {
+ const target = ASPECT_RATIO[i]!;
+
+ const tRatio = target.width / target.height;
+ const squared = tRatio - originalRatio;
+ const distance = Math.sqrt(squared * squared);
+
+ if (i === 0) {
+ nearest = distance;
+ } else if (distance < nearest) {
+ id = i;
+ nearest = distance;
+ }
+ }
+
+ return ASPECT_RATIO[id]!;
+}
+
+export function getScaledComponentRatio(ratio: AspectRatio): AspectRatio {
+ const xScale = 9 / ratio.width;
+ const yScale = 9 / ratio.height;
+ const scale = Math.min(xScale, yScale);
+ return {
+ width: scale * ratio.width,
+ height: scale * ratio.height,
+ };
+}
diff --git a/packages/start/src/image/client-only.tsx b/packages/start/src/image/client-only.tsx
new file mode 100644
index 000000000..4df147fd9
--- /dev/null
+++ b/packages/start/src/image/client-only.tsx
@@ -0,0 +1,37 @@
+import type { JSX } from "solid-js";
+import { createSignal, onMount, Show } from "solid-js";
+import { isServer } from "solid-js/web";
+
+export const createClientSignal = isServer
+ ? (): (() => boolean) => () => false
+ : (): (() => boolean) => {
+ const [flag, setFlag] = createSignal(false);
+
+ onMount(() => {
+ setFlag(true);
+ });
+
+ return flag;
+ };
+
+export interface ClientOnlyProps {
+ fallback?: JSX.Element;
+ children?: JSX.Element;
+}
+
+export const ClientOnly = (props: ClientOnlyProps): JSX.Element => {
+ const isClient = createClientSignal();
+
+ return Show({
+ keyed: false,
+ get when() {
+ return isClient();
+ },
+ get fallback() {
+ return props.fallback;
+ },
+ get children() {
+ return props.children;
+ },
+ });
+};
diff --git a/packages/start/src/image/create-lazy-render.ts b/packages/start/src/image/create-lazy-render.ts
new file mode 100644
index 000000000..d5b32d370
--- /dev/null
+++ b/packages/start/src/image/create-lazy-render.ts
@@ -0,0 +1,62 @@
+import { createEffect, createSignal, onCleanup } from "solid-js";
+
+export interface LazyRender {
+ ref: (value: T) => void;
+ visible: boolean;
+}
+
+export interface LazyRenderOptions {
+ refresh?: boolean;
+}
+
+export function createLazyRender(
+ options?: LazyRenderOptions,
+): LazyRender {
+ const [visible, setVisible] = createSignal(false);
+
+ // We use a reactive ref here so that the component
+ // re-renders if the host element changes, therefore
+ // re-evaluating our intersection logic
+ const [ref, setRef] = createSignal(null);
+
+ createEffect(() => {
+ // If the host changed, make sure that
+ // visibility is set to false
+ setVisible(false);
+ const shouldRefresh = options?.refresh;
+
+ const current = ref();
+ if (!current) {
+ return;
+ }
+ const observer = new IntersectionObserver(entries => {
+ for (const entry of entries) {
+ if (shouldRefresh) {
+ setVisible(entry.isIntersecting);
+ } else if (entry.isIntersecting) {
+ // Host intersected, set visibility to true
+ setVisible(true);
+
+ // Stop observing
+ observer.disconnect();
+ }
+ }
+ });
+
+ observer.observe(current);
+
+ onCleanup(() => {
+ observer.unobserve(current);
+ observer.disconnect();
+ });
+ });
+
+ return {
+ ref(value) {
+ return setRef(() => value);
+ },
+ get visible() {
+ return visible();
+ },
+ };
+}
diff --git a/packages/start/src/image/index.tsx b/packages/start/src/image/index.tsx
new file mode 100644
index 000000000..064e06faa
--- /dev/null
+++ b/packages/start/src/image/index.tsx
@@ -0,0 +1,118 @@
+import type { JSX } from "solid-js";
+import { createMemo, createSignal, For, Show } from "solid-js";
+import { ClientOnly } from "./client-only.tsx";
+import { createLazyRender } from "./create-lazy-render.ts";
+import {
+ createImageVariants,
+ mergeImageVariantsByType,
+ mergeImageVariantsToSrcSet,
+} from "./transformer.ts";
+import type { StartImageSource, StartImageTransformer, StartImageVariant } from "./types.ts";
+import { getAspectRatioBoxStyle } from "./utils.ts";
+
+import "./styles.css";
+
+export interface StartImageProps {
+ src: StartImageSource;
+ alt: string;
+ transformer?: StartImageTransformer;
+
+ onLoad?: () => void;
+ fallback: (visible: () => boolean, onLoad: () => void) => JSX.Element;
+
+ crossOrigin?: JSX.HTMLCrossorigin | undefined;
+ fetchPriority?: "high" | "low" | "auto" | undefined;
+ decoding?: "sync" | "async" | "auto" | undefined;
+}
+
+interface StartImageSourcesProps extends StartImageProps {
+ variants: StartImageVariant[];
+}
+
+function StartImageSources(props: StartImageSourcesProps): JSX.Element {
+ const mergedVariants = createMemo(() => {
+ const types = mergeImageVariantsByType(props.variants);
+
+ const values: [type: string, srcset: string][] = [];
+
+ for (const [key, variants] of types) {
+ values.push([key, mergeImageVariantsToSrcSet(variants)]);
+ }
+
+ return values;
+ });
+
+ return (
+ {([type, srcset]) => }
+ );
+}
+
+export function StartImage(props: StartImageProps): JSX.Element {
+ const [showPlaceholder, setShowPlaceholder] = createSignal(true);
+ const laze = createLazyRender();
+ const [defer, setDefer] = createSignal(true);
+
+ function onPlaceholderLoad() {
+ setDefer(false);
+ }
+
+ const width = createMemo(() => props.src.width);
+ const height = createMemo(() => props.src.height);
+
+ return (
+
+
+
+ }>
+ {cb => }
+
+
+ }
+ >
+
+
{
+ if (!defer()) {
+ setShowPlaceholder(false);
+ props.onLoad?.();
+ }
+ }}
+ style={{
+ opacity: showPlaceholder() ? 0 : 1,
+ }}
+ crossOrigin={props.crossOrigin}
+ fetchpriority={props.fetchPriority}
+ decoding={props.decoding}
+ />
+
+
+
+
+
+
+ {props.fallback(showPlaceholder, onPlaceholderLoad)}
+
+
+
+ );
+}
diff --git a/packages/start/src/image/plugin/fs.ts b/packages/start/src/image/plugin/fs.ts
new file mode 100644
index 000000000..46eefaeaa
--- /dev/null
+++ b/packages/start/src/image/plugin/fs.ts
@@ -0,0 +1,72 @@
+import type { Abortable } from "node:events";
+import type { Mode, ObjectEncodingOptions, OpenMode } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import type { Stream } from "node:stream";
+
+export async function removeFile(filePath: string): Promise {
+ return await fs.rm(filePath, { recursive: true, force: true });
+}
+
+export async function fileExists(p: string): Promise {
+ try {
+ const stat = await fs.stat(p);
+
+ return stat.isFile();
+ } catch {
+ return false;
+ }
+}
+
+const PATH_FILTER = /[<>:"|?*]/;
+
+export function checkPath(pth: string) {
+ if (process.platform === "win32") {
+ const pathHasInvalidWinCharacters = PATH_FILTER.test(pth.replace(path.parse(pth).root, ""));
+
+ if (pathHasInvalidWinCharacters) {
+ const error = new Error(`Path contains invalid characters: ${pth}`);
+ throw error;
+ }
+ }
+}
+
+export async function makeDir(dir: string, mode = 0o777) {
+ checkPath(dir);
+ return await fs.mkdir(dir, {
+ mode,
+ recursive: true,
+ });
+}
+
+export async function pathExists(filePath: string) {
+ try {
+ await fs.access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function outputFile(
+ file: string,
+ data:
+ | string
+ | NodeJS.ArrayBufferView
+ | Iterable
+ | AsyncIterable
+ | Stream,
+ encoding?:
+ | (ObjectEncodingOptions & {
+ mode?: Mode | undefined;
+ flag?: OpenMode | undefined;
+ } & Abortable)
+ | BufferEncoding
+ | null,
+) {
+ const dir = path.dirname(file);
+ if (!(await pathExists(dir))) {
+ await makeDir(dir);
+ }
+ return fs.writeFile(file, data, encoding);
+}
diff --git a/packages/start/src/image/plugin/index.ts b/packages/start/src/image/plugin/index.ts
new file mode 100644
index 000000000..6f3dd6074
--- /dev/null
+++ b/packages/start/src/image/plugin/index.ts
@@ -0,0 +1,205 @@
+import path from "node:path";
+import { Plugin } from "vite";
+import { getFilesFromFormat, getMIMEFromFormat, getOutputFileFromFormat } from "../transformer.ts";
+import type { StartImageFile, StartImageFormat, StartImageVariant } from "../types.ts";
+import { outputFile } from "./fs.ts";
+import { getImageData, transformImage } from "./transformers.ts";
+import xxHash32 from "./xxhash32.ts";
+
+const DEFAULT_INPUT: StartImageFormat[] = ["png", "jpeg", "webp"];
+const DEFAULT_OUTPUT: StartImageFormat[] = ["png", "jpeg", "webp"];
+const DEFAULT_QUALITY = 0.8;
+
+type MaybePromise = T | Promise;
+
+export interface StartImageOptions {
+ local?: {
+ sizes: number[];
+ input?: StartImageFormat[];
+ output?: StartImageFormat[];
+ quality: number;
+ publicPath?: string;
+ };
+ remote?: {
+ transformURL(url: string): MaybePromise<{
+ src: {
+ source: string;
+ width: number;
+ height: number;
+ };
+ variants: StartImageVariant | StartImageVariant[];
+ }>;
+ };
+}
+
+function getValidFileExtensions(formats: StartImageFormat[]): Set {
+ const result = new Set();
+ for (const format of formats) {
+ for (const file of getFilesFromFormat(format)) {
+ result.add(file);
+ }
+ }
+ return result;
+}
+
+function isValidFileExtension(extensions: Set, target: string): target is StartImageFile {
+ return extensions.has(target);
+}
+
+async function getImageSource(imagePath: string, relativePath: string): Promise {
+ // TODO add format variation
+ const imageData = await getImageData(imagePath);
+ return `
+import source from ${JSON.stringify(relativePath)};
+export default {
+ width: ${JSON.stringify(imageData.width)},
+ height: ${JSON.stringify(imageData.height)},
+ source,
+};
+`;
+}
+
+function getImageTransformer(imagePath: string, outputTypes: string[], sizes: number[]): string {
+ let imported = "";
+ let exported = "";
+
+ for (const format of outputTypes) {
+ for (const size of sizes) {
+ const variantName = "variant_" + format + "_" + size;
+ const importPath = JSON.stringify(imagePath + "?image-" + format + "-" + size);
+ imported += "import " + variantName + " from " + importPath + ";\n";
+ exported += variantName + ",";
+ }
+ }
+
+ return (
+ imported +
+ "const variants = [" +
+ exported +
+ "];\n" +
+ "export default { transform() { return variants; }};"
+ );
+}
+
+function getImageVariant(imagePath: string, target: StartImageFormat, size: number): string {
+ return `import source from ${JSON.stringify(imagePath + "?image-raw-" + target + "-" + size)};
+export default {
+ width: ${size},
+ type: '${getMIMEFromFormat(target)}',
+ path: source,
+};`;
+}
+
+function getImageEntryPoint(imagePath: string): string {
+ return `import src from ${JSON.stringify(imagePath + "?image-source")};
+import transformer from ${JSON.stringify(imagePath + "?image-transformer")};
+
+export default { src, transformer };
+`;
+}
+
+const LOCAL_PATH = /\?image(-[a-z]+(-[0-9]+)?)?/;
+const REMOTE_PATH = "image:";
+
+export const imagePlugin = (options: StartImageOptions) => {
+ const plugins: Plugin[] = [];
+ if (options.remote) {
+ const transformUrl = options.remote.transformURL;
+ plugins.push({
+ name: "solid-start:image/remote",
+ enforce: "pre",
+ resolveId(id) {
+ if (id.startsWith(REMOTE_PATH)) {
+ return id;
+ }
+ return null;
+ },
+ async load(id) {
+ if (id.startsWith(REMOTE_PATH)) {
+ const param = id.substring(REMOTE_PATH.length);
+
+ const result = await transformUrl(param);
+
+ return `const VARIANTS = ${JSON.stringify(result.variants)};
+export default {
+ src: ${JSON.stringify(result.src)},
+ transformer: {
+ transform() {
+ return VARIANTS;
+ },
+ },
+};`;
+ }
+ return null;
+ },
+ });
+ }
+ if (options.local) {
+ const inputFormat = options.local.input ?? DEFAULT_INPUT;
+ const outputFormat = options.local.output ?? DEFAULT_OUTPUT;
+ const quality = options.local.quality ?? DEFAULT_QUALITY;
+ const sizes = options.local.sizes;
+ const publicPath = options.local.publicPath ?? "dist";
+
+ const validInputFileExtensions = getValidFileExtensions(inputFormat);
+
+ plugins.push({
+ name: "solid-start:image/local",
+ enforce: "pre",
+ resolveId(id, importer) {
+ if (LOCAL_PATH.test(id) && importer) {
+ return path.join(path.dirname(importer), id);
+ }
+ return null;
+ },
+ async load(id) {
+ if (id.startsWith("\0")) {
+ return null;
+ }
+ const { dir, name, ext } = path.parse(id);
+ const [actualExtension, condition] = ext.substring(1).split("?");
+ // Check if extension is valid
+ if (!isValidFileExtension(validInputFileExtensions, actualExtension!)) {
+ return null;
+ }
+ if (!condition) {
+ return null;
+ }
+ const originalPath = `${dir}/${name}.${actualExtension}`;
+ const relativePath = `./${name}.${actualExtension}`;
+ // Get the true source
+ if (condition.startsWith("image-source")) {
+ return await getImageSource(originalPath, relativePath);
+ }
+ // Get the transformer file
+ if (condition.startsWith("image-transformer")) {
+ return getImageTransformer(relativePath, outputFormat, sizes);
+ }
+ // Image transformer variant
+ if (condition.startsWith("image-raw")) {
+ const [, , format, size] = condition.split("-");
+ const hash = xxHash32(originalPath).toString(16);
+ const filename = `i-${hash}-${size}.${getOutputFileFromFormat(format as StartImageFormat)}`;
+ const image = transformImage(originalPath, format as StartImageFormat, +size!, quality);
+ const buffer = await image.toBuffer();
+ const basePath = path.join(".image", filename);
+ const targetPath = path.join(publicPath, basePath);
+ await outputFile(targetPath, buffer);
+ return `export default "/${basePath}"`;
+ }
+ // Image transformer variant
+ if (condition.startsWith("image-")) {
+ const [, format, size] = condition.split("-");
+
+ return getImageVariant(relativePath, format as StartImageFormat, +size!);
+ }
+ if (condition.startsWith("image")) {
+ return getImageEntryPoint(relativePath);
+ }
+ return null;
+ },
+ });
+ }
+
+ return plugins;
+};
diff --git a/packages/start/src/image/plugin/transformers.ts b/packages/start/src/image/plugin/transformers.ts
new file mode 100644
index 000000000..3c4748e29
--- /dev/null
+++ b/packages/start/src/image/plugin/transformers.ts
@@ -0,0 +1,46 @@
+import sharp from "sharp";
+import type { StartImageFormat } from "../types.ts";
+
+export function transformImage(
+ originalPath: string,
+ targetFormat: StartImageFormat,
+ size: number,
+ quality: number,
+) {
+ const input = sharp(originalPath);
+ switch (targetFormat) {
+ case "avif":
+ return input.resize(size).avif({
+ quality,
+ });
+ case "jpeg":
+ return input.resize(size).jpeg({
+ quality,
+ });
+ case "png":
+ return input.resize(size).png({
+ quality,
+ });
+ case "webp":
+ return input.resize(size).webp({
+ quality,
+ });
+ case "tiff":
+ return input.resize(size).tiff({
+ quality,
+ });
+ }
+}
+
+interface ImageData {
+ width: number;
+ height: number;
+}
+
+export async function getImageData(originalPath: string): Promise {
+ const result = await sharp(originalPath).metadata();
+ return {
+ width: result.width || 0,
+ height: result.height || 0,
+ };
+}
diff --git a/packages/start/src/image/plugin/xxhash32.ts b/packages/start/src/image/plugin/xxhash32.ts
new file mode 100644
index 000000000..0cae220f3
--- /dev/null
+++ b/packages/start/src/image/plugin/xxhash32.ts
@@ -0,0 +1,202 @@
+// @ts-nocheck
+/**
+ * Copyright (c) 2019 Jason Dent
+ * https://github.com/Jason3S/xxhash
+ */
+const PRIME32_1 = 2654435761;
+const PRIME32_2 = 2246822519;
+const PRIME32_3 = 3266489917;
+const PRIME32_4 = 668265263;
+const PRIME32_5 = 374761393;
+
+function toUtf8(text: string): Uint8Array {
+ const bytes: number[] = [];
+ for (let i = 0, n = text.length; i < n; ++i) {
+ const c = text.charCodeAt(i);
+ if (c < 0x80) {
+ bytes.push(c);
+ } else if (c < 0x800) {
+ bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
+ } else if (c < 0xd800 || c >= 0xe000) {
+ bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
+ } else {
+ const cp = 0x10000 + (((c & 0x3ff) << 10) | (text.charCodeAt(++i) & 0x3ff));
+ bytes.push(
+ 0xf0 | ((cp >> 18) & 0x7),
+ 0x80 | ((cp >> 12) & 0x3f),
+ 0x80 | ((cp >> 6) & 0x3f),
+ 0x80 | (cp & 0x3f),
+ );
+ }
+ }
+ return new Uint8Array(bytes);
+}
+/**
+ *
+ * @param buffer - byte array or string
+ * @param seed - optional seed (32-bit unsigned);
+ */
+export default function xxHash32(buffer: Uint8Array | string, seed = 0): number {
+ buffer = typeof buffer === "string" ? toUtf8(buffer) : buffer;
+ const b = buffer;
+
+ /*
+ Step 1. Initialize internal accumulators
+ Each accumulator gets an initial value based on optional seed input.
+ Since the seed is optional, it can be 0.
+ ```
+ u32 acc1 = seed + PRIME32_1 + PRIME32_2;
+ u32 acc2 = seed + PRIME32_2;
+ u32 acc3 = seed + 0;
+ u32 acc4 = seed - PRIME32_1;
+ ```
+ Special case : input is less than 16 bytes
+ When input is too small (< 16 bytes), the algorithm will not process any stripe.
+ Consequently, it will not make use of parallel accumulators.
+ In which case, a simplified initialization is performed, using a single accumulator :
+ u32 acc = seed + PRIME32_5;
+ The algorithm then proceeds directly to step 4.
+ */
+
+ let acc = (seed + PRIME32_5) & 0xffffffff;
+ let offset = 0;
+
+ if (b.length >= 16) {
+ const accN = [
+ (seed + PRIME32_1 + PRIME32_2) & 0xffffffff,
+ (seed + PRIME32_2) & 0xffffffff,
+ (seed + 0) & 0xffffffff,
+ (seed - PRIME32_1) & 0xffffffff,
+ ];
+
+ /*
+ Step 2. Process stripes
+ A stripe is a contiguous segment of 16 bytes. It is evenly divided into 4 lanes,
+ of 4 bytes each. The first lane is used to update accumulator 1, the second lane
+ is used to update accumulator 2, and so on. Each lane read its associated 32-bit
+ value using little-endian convention. For each {lane, accumulator}, the update
+ process is called a round, and applies the following formula :
+ ```
+ accN = accN + (laneN * PRIME32_2);
+ accN = accN <<< 13;
+ accN = accN * PRIME32_1;
+ ```
+ This shuffles the bits so that any bit from input lane impacts several bits in
+ output accumulator. All operations are performed modulo 2^32.
+ Input is consumed one full stripe at a time. Step 2 is looped as many times as
+ necessary to consume the whole input, except the last remaining bytes which cannot
+ form a stripe (< 16 bytes). When that happens, move to step 3.
+ */
+
+ const b = buffer;
+ const limit = b.length - 16;
+ let lane = 0;
+ for (offset = 0; (offset & 0xfffffff0) <= limit; offset += 4) {
+ const i = offset;
+ const laneN0 = b[i + 0] + (b[i + 1] << 8);
+ const laneN1 = b[i + 2] + (b[i + 3] << 8);
+ const laneNP = laneN0 * PRIME32_2 + ((laneN1 * PRIME32_2) << 16);
+ let acc = (accN[lane] + laneNP) & 0xffffffff;
+ acc = (acc << 13) | (acc >>> 19);
+ const acc0 = acc & 0xffff;
+ const acc1 = acc >>> 16;
+ accN[lane] = (acc0 * PRIME32_1 + ((acc1 * PRIME32_1) << 16)) & 0xffffffff;
+ lane = (lane + 1) & 0x3;
+ }
+
+ /*
+ Step 3. Accumulator convergence
+ All 4 lane accumulators from previous steps are merged to produce a
+ single remaining accumulator
+ of same width (32-bit). The associated formula is as follows :
+ ```
+ acc = (acc1 <<< 1) + (acc2 <<< 7) + (acc3 <<< 12) + (acc4 <<< 18);
+ ```
+ */
+ acc =
+ (((accN[0] << 1) | (accN[0] >>> 31)) +
+ ((accN[1] << 7) | (accN[1] >>> 25)) +
+ ((accN[2] << 12) | (accN[2] >>> 20)) +
+ ((accN[3] << 18) | (accN[3] >>> 14))) &
+ 0xffffffff;
+ }
+
+ /*
+ Step 4. Add input length
+ The input total length is presumed known at this stage.
+ This step is just about adding the length to
+ accumulator, so that it participates to final mixing.
+ ```
+ acc = acc + (u32)inputLength;
+ ```
+ */
+ acc = (acc + buffer.length) & 0xffffffff;
+
+ /*
+ Step 5. Consume remaining input
+ There may be up to 15 bytes remaining to consume from the input.
+ The final stage will digest them according
+ to following pseudo-code :
+ ```
+ while (remainingLength >= 4) {
+ lane = read_32bit_little_endian(input_ptr);
+ acc = acc + lane * PRIME32_3;
+ acc = (acc <<< 17) * PRIME32_4;
+ input_ptr += 4; remainingLength -= 4;
+ }
+ ```
+ This process ensures that all input bytes are present in the final mix.
+ */
+
+ const limit = buffer.length - 4;
+ for (; offset <= limit; offset += 4) {
+ const i = offset;
+ const laneN0 = b[i + 0] + (b[i + 1] << 8);
+ const laneN1 = b[i + 2] + (b[i + 3] << 8);
+ const laneP = laneN0 * PRIME32_3 + ((laneN1 * PRIME32_3) << 16);
+ acc = (acc + laneP) & 0xffffffff;
+ acc = (acc << 17) | (acc >>> 15);
+ acc = ((acc & 0xffff) * PRIME32_4 + (((acc >>> 16) * PRIME32_4) << 16)) & 0xffffffff;
+ }
+
+ /*
+ ```
+ while (remainingLength >= 1) {
+ lane = read_byte(input_ptr);
+ acc = acc + lane * PRIME32_5;
+ acc = (acc <<< 11) * PRIME32_1;
+ input_ptr += 1; remainingLength -= 1;
+ }
+ ```
+ */
+
+ for (; offset < b.length; ++offset) {
+ const lane = b[offset];
+ acc += lane * PRIME32_5;
+ acc = (acc << 11) | (acc >>> 21);
+ acc = ((acc & 0xffff) * PRIME32_1 + (((acc >>> 16) * PRIME32_1) << 16)) & 0xffffffff;
+ }
+
+ /*
+ Step 6. Final mix (avalanche)
+ The final mix ensures that all input bits have a chance to impact any bit in
+ the output digest, resulting in an unbiased distribution. This is also called
+ avalanche effect.
+ ```
+ acc = acc xor (acc >> 15);
+ acc = acc * PRIME32_2;
+ acc = acc xor (acc >> 13);
+ acc = acc * PRIME32_3;
+ acc = acc xor (acc >> 16);
+ ```
+ */
+
+ acc ^= acc >>> 15;
+ acc = (((acc & 0xffff) * PRIME32_2) & 0xffffffff) + (((acc >>> 16) * PRIME32_2) << 16);
+ acc ^= acc >>> 13;
+ acc = (((acc & 0xffff) * PRIME32_3) & 0xffffffff) + (((acc >>> 16) * PRIME32_3) << 16);
+ acc ^= acc >>> 16;
+
+ // turn any negatives back into a positive number;
+ return acc < 0 ? acc + 4294967296 : acc;
+}
diff --git a/packages/start/src/image/styles.css b/packages/start/src/image/styles.css
new file mode 100644
index 000000000..a24b0ae72
--- /dev/null
+++ b/packages/start/src/image/styles.css
@@ -0,0 +1,24 @@
+[data-start-image="blocker"],
+[data-start-image="image"],
+[data-start-image="picture"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+[data-start-image="image"],
+[data-start-image="picture"] {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ pointer-events: none;
+ user-select: none;
+}
+
+[data-start-image="container"] {
+ width: 100%;
+ height: 100%;
+ position: relative;
+}
\ No newline at end of file
diff --git a/packages/start/src/image/transformer.ts b/packages/start/src/image/transformer.ts
new file mode 100644
index 000000000..6acb83e43
--- /dev/null
+++ b/packages/start/src/image/transformer.ts
@@ -0,0 +1,117 @@
+import type {
+ StartImageFile,
+ StartImageFormat,
+ StartImageMIME,
+ StartImageSource,
+ StartImageTransformer,
+ StartImageVariant,
+} from "./types";
+
+const MIME_TO_FORMAT: Record = {
+ "image/avif": "avif",
+ "image/jpeg": "jpeg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/tiff": "tiff",
+};
+
+export function getFormatFromMIME(mime: StartImageMIME): StartImageFormat {
+ return MIME_TO_FORMAT[mime];
+}
+
+const FORMAT_TO_MIME: Record = {
+ avif: "image/avif",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ tiff: "image/tiff",
+};
+
+export function getMIMEFromFormat(format: StartImageFormat): StartImageMIME {
+ return FORMAT_TO_MIME[format];
+}
+
+const FILE_TO_FORMAT: Record = {
+ avif: "avif",
+ jfif: "jpeg",
+ jpeg: "jpeg",
+ jpg: "jpeg",
+ pjp: "jpeg",
+ pjpeg: "jpeg",
+ png: "png",
+ webp: "webp",
+ tif: "tiff",
+ tiff: "tiff",
+};
+
+export function getFormatFromFile(file: StartImageFile): StartImageFormat {
+ return FILE_TO_FORMAT[file];
+}
+
+const FORMAT_TO_FILES: Record = {
+ avif: ["avif"],
+ jpeg: ["jfif", "jpeg", "jpg", "pjp", "pjpeg"],
+ png: ["png"],
+ webp: ["webp"],
+ tiff: ["tif", "tiff"],
+};
+
+export function getFilesFromFormat(format: StartImageFormat): StartImageFile[] {
+ return FORMAT_TO_FILES[format];
+}
+
+const FORMAT_TO_OUTPUT: Record = {
+ avif: "avif",
+ jpeg: "jpg",
+ png: "png",
+ webp: "webp",
+ tiff: "tiff",
+};
+
+export function getOutputFileFromFormat(format: StartImageFormat): StartImageFile {
+ return FORMAT_TO_OUTPUT[format];
+}
+
+function ensureArray(value: T | T[]): T[] {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ return [value];
+}
+
+export function createImageVariants(
+ source: StartImageSource,
+ transformer: StartImageTransformer,
+): StartImageVariant[] {
+ return ensureArray(transformer.transform(source));
+}
+
+function variantToSrcSetPart(variant: StartImageVariant): string {
+ return variant.path + " " + variant.width + "w";
+}
+
+export function mergeImageVariantsToSrcSet(variants: StartImageVariant[]): string {
+ let result = variantToSrcSetPart(variants[0]!);
+
+ for (let i = 1, len = variants.length; i < len; i++) {
+ result += "," + variantToSrcSetPart(variants[i]!);
+ }
+
+ return result;
+}
+
+export function mergeImageVariantsByType(
+ variants: StartImageVariant[],
+): Map {
+ const map = new Map();
+
+ for (let i = 0, len = variants.length; i < len; i++) {
+ const current = variants[i]!;
+
+ const arr = map.get(current.type) || [];
+ arr.push(current);
+ map.set(current.type, arr);
+ }
+
+ return map;
+}
diff --git a/packages/start/src/image/types.ts b/packages/start/src/image/types.ts
new file mode 100644
index 000000000..53aec3977
--- /dev/null
+++ b/packages/start/src/image/types.ts
@@ -0,0 +1,53 @@
+/**
+ * List of supported image types
+ *
+ * Based on https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types
+ */
+export type StartImageMIME =
+ | "image/avif"
+ | "image/jpeg"
+ | "image/png"
+ | "image/webp"
+ | "image/tiff";
+
+export type StartImagePNG = "png";
+export type StartImageAVIF = "avif";
+export type StartImageJPEG = "jpg" | "jpeg" | "jfif" | "pjpeg" | "pjp";
+export type StartImageWebP = "webp";
+export type StartImageTIFF = "tiff" | "tif";
+
+export type StartImageFile =
+ | StartImageAVIF
+ | StartImageJPEG
+ | StartImagePNG
+ | StartImageWebP
+ | StartImageTIFF;
+
+export type StartImageFormat = "avif" | "jpeg" | "png" | "webp" | "tiff";
+
+/**
+ * A variant of an image source. This is used to transform a given source string
+ * into a element
+ */
+export interface StartImageVariant {
+ path: string;
+ width: number;
+ type: StartImageMIME;
+}
+
+/**
+ * An image source
+ */
+export interface StartImageSource {
+ source: string;
+ width: number;
+ height: number;
+ options: T;
+}
+
+/**
+ * Transforms an image source into a set of image variants
+ */
+export interface StartImageTransformer {
+ transform: (source: StartImageSource) => StartImageVariant | StartImageVariant[];
+}
diff --git a/packages/start/src/image/utils.ts b/packages/start/src/image/utils.ts
new file mode 100644
index 000000000..4b2291eb6
--- /dev/null
+++ b/packages/start/src/image/utils.ts
@@ -0,0 +1,48 @@
+import type { JSX } from "solid-js";
+import type { AspectRatio } from "./aspect-ratio";
+
+function kebabify(str: string): string {
+ return str
+ .replace(/([A-Z])([A-Z])/g, "$1-$2")
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .replace(/[\s_]+/g, "-")
+ .toLowerCase();
+}
+
+export function shimStyle(style: JSX.CSSProperties): JSX.CSSProperties {
+ const keys = Object.keys(style) as (keyof JSX.CSSProperties)[];
+ const newStyle: JSX.CSSProperties = {};
+
+ for (let i = 0, len = keys.length; i < len; i += 1) {
+ const key = kebabify(keys[i]!);
+ newStyle[key as any] = style[keys[i]!];
+ }
+ return newStyle;
+}
+
+export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties {
+ return {
+ position: "relative",
+ "padding-top": `${(ratio.height * 100) / ratio.width}%`,
+ width: "100%",
+ height: "0",
+ overflow: "hidden",
+ };
+}
+
+export function getEmptySVGPlaceholder({ width, height }: AspectRatio): string {
+ return ``;
+}
+
+export function getEncodedSVG(svg: string): string {
+ const encodedSVG = encodeURIComponent(svg);
+ return `data:image/svg+xml,${encodedSVG}`;
+}
+
+export function getEncodedOptionalSVG(ratio: AspectRatio, svg?: string): string {
+ return getEncodedSVG(svg || getEmptySVGPlaceholder(ratio));
+}
+
+export function getEmptyImageURL(ratio: AspectRatio): string {
+ return getEncodedOptionalSVG(ratio);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d00c64b91..d7f3e3a99 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -373,6 +373,9 @@ importers:
seroval-plugins:
specifier: ^1.4.0
version: 1.4.0(seroval@1.4.1)
+ sharp:
+ specifier: ^0.34.5
+ version: 0.34.5
shiki:
specifier: ^1.26.1
version: 1.26.1
@@ -1122,6 +1125,143 @@ packages:
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
+ '@img/colour@1.0.0':
+ resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
'@inquirer/ansi@1.0.2':
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'}
@@ -4868,6 +5008,10 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -6563,6 +6707,102 @@ snapshots:
'@floating-ui/utils@0.2.8': {}
+ '@img/colour@1.0.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.7.1
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
'@inquirer/ansi@1.0.2':
optional: true
@@ -10544,6 +10784,37 @@ snapshots:
setprototypeof@1.2.0: {}
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.0.0
+ detect-libc: 2.1.2
+ semver: 7.7.3
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0