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 ( +
+ example ( + + + + )} + /> +
+ ); +} 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 ( +
+ example ( + + + + )} + /> +
+ ); +} 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 => } + + + } + > + + {props.alt} { + 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