diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e15e48..08ccc79 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,6 +29,7 @@ "[javascriptreact]": { "editor.defaultFormatter": "denoland.vscode-deno" }, + "deno.config": "deno.jsonc", "prettier.enable": false, "editor.defaultFormatter": "denoland.vscode-deno" } diff --git a/.zed/settings.json b/.zed/settings.json index a58d028..bbfd6c7 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,7 +1,3 @@ -// Folder-specific settings -// -// For a full list of overridable settings, and general information on folder-specific settings, -// see the documentation: https://zed.dev/docs/configuring-zed#settings-files { "lsp": { "deno": { diff --git a/CHANGELOG-old.md b/CHANGELOG-old.md new file mode 100644 index 0000000..14a0528 --- /dev/null +++ b/CHANGELOG-old.md @@ -0,0 +1,275 @@ + +# Changelog + +## [3.19.0](https://github.com/ascorbic/unpic/compare/v3.18.1...v3.19.0) (2024-10-26) + + +### Features + +* add support for cloudflare images without custom domain ([#131](https://github.com/ascorbic/unpic/issues/131)) ([320784b](https://github.com/ascorbic/unpic/commit/320784bcbe8ce91c0c58723cb4446d6f8e535d99)) + + +### Bug Fixes + +* fix publishing ([#138](https://github.com/ascorbic/unpic/issues/138)) ([ae9de25](https://github.com/ascorbic/unpic/commit/ae9de2532b0f3c3fc7e47b69b260c16b106d6f3c)) + +## [3.18.1](https://github.com/ascorbic/unpic/compare/v3.18.0...v3.18.1) (2024-10-26) + + +### Bug Fixes + +* publish via JSR ([#136](https://github.com/ascorbic/unpic/issues/136)) ([6674473](https://github.com/ascorbic/unpic/commit/66744733a58a1e3d1ffed25eff8fe196cf8f6681)) + +## [3.18.0](https://github.com/ascorbic/unpic/compare/v3.17.0...v3.18.0) (2024-03-12) + + +### Features + +* add Supabase support ([#123](https://github.com/ascorbic/unpic/issues/123)) ([0aa185a](https://github.com/ascorbic/unpic/commit/0aa185ac71263e4d487d5ece19f316f65a6c0403)) + +## [3.17.0](https://github.com/ascorbic/unpic/compare/v3.16.1...v3.17.0) (2024-02-08) + + +### Features + +* add Uploadcare support ([#120](https://github.com/ascorbic/unpic/issues/120)) ([68307a4](https://github.com/ascorbic/unpic/commit/68307a492a52b9db1d93f063b6b28b5e30db5c30)) + +## [3.16.1](https://github.com/ascorbic/unpic/compare/v3.16.0...v3.16.1) (2024-02-07) + + +### Bug Fixes + +* **imagekit:** set format param to "f" ([#116](https://github.com/ascorbic/unpic/issues/116)) ([d06651e](https://github.com/ascorbic/unpic/commit/d06651ed2d74e0c6123855d7129e1abab15c023a)) + +## [3.16.0](https://github.com/ascorbic/unpic/compare/v3.15.0...v3.16.0) (2023-12-09) + + +### Features + +* add Cloudimage support ([#107](https://github.com/ascorbic/unpic/issues/107)) ([e8df456](https://github.com/ascorbic/unpic/commit/e8df456f80c6c5acfee23579382bf7334b4f67e5)) + +## [3.15.0](https://github.com/ascorbic/unpic/compare/v3.14.1...v3.15.0) (2023-12-05) + + +### Features + +* astro _image endpoint ([#94](https://github.com/ascorbic/unpic/issues/94)) ([b015190](https://github.com/ascorbic/unpic/commit/b015190d39855232d415c2c101cef2c1d4161ab9)) + + +### Bug Fixes + +* **contentful:** limit dimensions to 4000px ([#101](https://github.com/ascorbic/unpic/issues/101)) ([8d84f4d](https://github.com/ascorbic/unpic/commit/8d84f4d9987f020e09a705209686f21bf0c7806b)) + +## [3.14.1](https://github.com/ascorbic/unpic/compare/v3.14.0...v3.14.1) (2023-11-16) + + +### Bug Fixes + +* **cloudflare:** use optional regex for transformation group [#60](https://github.com/ascorbic/unpic/issues/60) ([2800f41](https://github.com/ascorbic/unpic/commit/2800f41e6226868dd63091881d8750a532932da8)) + +## [3.14.0](https://github.com/ascorbic/unpic/compare/v3.13.0...v3.14.0) (2023-11-15) + + +### Features + +* add subpath exports ([#99](https://github.com/ascorbic/unpic/issues/99)) ([7c9da42](https://github.com/ascorbic/unpic/commit/7c9da427ab3449b2b1c270ad49071ace1cc5ccb1)) + + +### Bug Fixes + +* **netlify:** correct "fit" param ([#97](https://github.com/ascorbic/unpic/issues/97)) ([66a48d4](https://github.com/ascorbic/unpic/commit/66a48d46f87f0938ae0a159dad070c715d7297b6)) + +## [3.13.0](https://github.com/ascorbic/unpic/compare/v3.12.0...v3.13.0) (2023-11-07) + + +### Features + +* add imagekit support ([#91](https://github.com/ascorbic/unpic/issues/91)) ([cb03686](https://github.com/ascorbic/unpic/commit/cb03686afc998cbd106163098397813a114e1cff)) + +## [3.12.0](https://github.com/ascorbic/unpic/compare/v3.11.0...v3.12.0) (2023-10-31) + + +### Features + +* add Netlify support ([#88](https://github.com/ascorbic/unpic/issues/88)) ([4dd047f](https://github.com/ascorbic/unpic/commit/4dd047fe034f61d99432f687c219cb2329628865)) + +## [3.11.0](https://github.com/ascorbic/unpic/compare/v3.10.1...v3.11.0) (2023-10-29) + + +### Features + +* add support for ipx ([#85](https://github.com/ascorbic/unpic/issues/85)) ([0ef9eed](https://github.com/ascorbic/unpic/commit/0ef9eedfe59892e2ad96e129d3f52632dc12c52c)) + +## [3.10.1](https://github.com/ascorbic/unpic/compare/v3.10.0...v3.10.1) (2023-09-29) + + +### Bug Fixes + +* don't include node type refs in declaration ([#83](https://github.com/ascorbic/unpic/issues/83)) ([b960e77](https://github.com/ascorbic/unpic/commit/b960e779b70508ed4b11cce84cd2e3fc2d529213)) + +## [3.10.0](https://github.com/ascorbic/unpic/compare/v3.9.0...v3.10.0) (2023-08-01) + + +### Features + +* add support for Cloudflare Images ([#79](https://github.com/ascorbic/unpic/issues/79)) ([50f5d59](https://github.com/ascorbic/unpic/commit/50f5d5943de15e301d4ccf5afde70988f6c065be)) + +## [3.9.0](https://github.com/ascorbic/unpic/compare/v3.8.1...v3.9.0) (2023-07-20) + + +### Features + +* add Contentstack support ([#77](https://github.com/ascorbic/unpic/issues/77)) ([eaf33b7](https://github.com/ascorbic/unpic/commit/eaf33b7662a408c38aa311a3e917295f306f8c07)) + +## [3.8.1](https://github.com/ascorbic/unpic/compare/v3.8.0...v3.8.1) (2023-07-05) + + +### Bug Fixes + +* **builder.io:** force sharp to ensure correct fit behaviour ([#75](https://github.com/ascorbic/unpic/issues/75)) ([0146f0c](https://github.com/ascorbic/unpic/commit/0146f0c7c6eddab0f7a7a6f46f38b8eba9827ecc)) + +## [3.8.0](https://github.com/ascorbic/unpic/compare/v3.7.0...v3.8.0) (2023-07-04) + + +### Features + +* add support for imageengine cdn ([#66](https://github.com/ascorbic/unpic/issues/66)) ([788633e](https://github.com/ascorbic/unpic/commit/788633ed57f658b1e79f1140ffac67b2bd09f21e)) + +## [3.7.0](https://github.com/ascorbic/unpic/compare/v3.6.4...v3.7.0) (2023-07-03) + + +### Features + +* improve compatibility of URLs in Node ([#72](https://github.com/ascorbic/unpic/issues/72)) ([b91f194](https://github.com/ascorbic/unpic/commit/b91f1948844a1bc96b8aa94014f85046d93fb725)) + +## [3.6.4](https://github.com/ascorbic/unpic/compare/v3.6.3...v3.6.4) (2023-07-03) + + +### Bug Fixes + +* fix npm publish test ([aa927c8](https://github.com/ascorbic/unpic/commit/aa927c83d7662cca554ad6659714cd1a64b8d2ff)) + +## [3.6.3](https://github.com/ascorbic/unpic/compare/v3.6.2...v3.6.3) (2023-07-03) + + +### Bug Fixes + +* correct npm publish test ([b54d6cd](https://github.com/ascorbic/unpic/commit/b54d6cdd28e301177f945c61a6331d675ead8cf6)) + +## [3.6.2](https://github.com/ascorbic/unpic/compare/v3.6.1...v3.6.2) (2023-07-03) + + +### Bug Fixes + +* correctly handle fit mode in Bunny.net, Cloudflare and Kontent.ai ([#68](https://github.com/ascorbic/unpic/issues/68)) ([4b2bf38](https://github.com/ascorbic/unpic/commit/4b2bf38e8621fecd18ff2e6dc5ced3d24bf5b7e5)) + +## [3.6.1](https://github.com/ascorbic/unpic/compare/v3.6.0...v3.6.1) (2023-05-16) + + +### Bug Fixes + +* correctly detect relative next.js URLs ([#64](https://github.com/ascorbic/unpic/issues/64)) ([e2eb2ae](https://github.com/ascorbic/unpic/commit/e2eb2aef43d04c979c8b9041fa12e7b4829cd292)) + +## [3.6.0](https://github.com/ascorbic/unpic/compare/v3.5.0...v3.6.0) (2023-05-15) + + +### Features + +* support relative URLs when detecting by path ([#61](https://github.com/ascorbic/unpic/issues/61)) ([a28ffa9](https://github.com/ascorbic/unpic/commit/a28ffa913340b0419a9f755dd2835e716250903c)) + +## [3.5.0](https://github.com/ascorbic/unpic/compare/v3.4.1...v3.5.0) (2023-04-25) + + +### Features + +* add caisy ([39ec49b](https://github.com/ascorbic/unpic/commit/39ec49b6fae6dfa2f08db5737594dfe2c54e8489)) + +## [3.4.1](https://github.com/ascorbic/unpic/compare/v3.4.0...v3.4.1) (2023-04-23) + + +### Bug Fixes + +* not delete existing format ([#54](https://github.com/ascorbic/unpic/issues/54)) ([9d656a8](https://github.com/ascorbic/unpic/commit/9d656a87876c061892679068dd3aaec5b39ec164)) + +## [3.4.0](https://github.com/ascorbic/unpic/compare/v3.3.0...v3.4.0) (2023-04-22) + + +### Features + +* add Directus support ([#46](https://github.com/ascorbic/unpic/issues/46)) ([3be1fe3](https://github.com/ascorbic/unpic/commit/3be1fe33861b7980ba333fc63b9fd47e4e4cf314)) + +## [3.3.0](https://github.com/ascorbic/unpic/compare/v3.2.0...v3.3.0) (2023-04-22) + + +### Features + +* add support for delegated URLs ([#47](https://github.com/ascorbic/unpic/issues/47)) ([3f29447](https://github.com/ascorbic/unpic/commit/3f294470e012d535e4aeec31c642b6202e0db177)) + +## [3.2.0](https://github.com/ascorbic/unpic/compare/v3.1.0...v3.2.0) (2023-04-04) + + +### Features + +* add KeyCDN support ([#43](https://github.com/ascorbic/unpic/issues/43)) ([0cfde2e](https://github.com/ascorbic/unpic/commit/0cfde2edf76af9e3825c695d68dc113832d4ad89)) + +## [3.1.0](https://github.com/ascorbic/unpic/compare/v3.0.1...v3.1.0) (2023-03-28) + + +### Features + +* add Scene7 transformer ([#39](https://github.com/ascorbic/unpic/issues/39)) ([ad6edf2](https://github.com/ascorbic/unpic/commit/ad6edf2b5a119cf6d6a26db0f569c5305d66e911)) + +## [3.0.1](https://github.com/ascorbic/unpic/compare/v3.0.0...v3.0.1) (2023-03-11) + + +### Bug Fixes + +* **nextjs:** default q to 75 ([#34](https://github.com/ascorbic/unpic/issues/34)) ([0948a17](https://github.com/ascorbic/unpic/commit/0948a171537e335afd5502bd6a31652f68d0c976)) + +## [3.0.0](https://github.com/ascorbic/unpic/compare/v2.2.2...v3.0.0) (2023-03-10) + + +### ⚠ BREAKING CHANGES + +* transformers may return URLs as strings ([#30](https://github.com/ascorbic/unpic/issues/30)) + +### Features + +* add Next.js and Vercel support ([#32](https://github.com/ascorbic/unpic/issues/32)) ([4cff46d](https://github.com/ascorbic/unpic/commit/4cff46d48988a9e9e8b11a2741109f452acdd334)) +* transformers may return URLs as strings ([#30](https://github.com/ascorbic/unpic/issues/30)) ([ce95cfe](https://github.com/ascorbic/unpic/commit/ce95cfe9470fdee43c056f4d861b3477572808df)) + +## [2.2.2](https://github.com/ascorbic/unpic/compare/v2.2.1...v2.2.2) (2023-03-09) + + +### Bug Fixes + +* **cloudinary:** default to fill without upscale ([#27](https://github.com/ascorbic/unpic/issues/27)) ([e7a3de5](https://github.com/ascorbic/unpic/commit/e7a3de584b6e4acefd6ce9259319a24ce617ce1c)) +* handle custom cloudinary domains ([ce2d1e6](https://github.com/ascorbic/unpic/commit/ce2d1e615c6fc3ac3d3a0dff78ace317b572ad4f)) + +## [2.2.1](https://github.com/ascorbic/unpic/compare/v2.2.0...v2.2.1) (2023-03-06) + + +### Bug Fixes + +* export kontent parse function ([#24](https://github.com/ascorbic/unpic/issues/24)) ([9e49772](https://github.com/ascorbic/unpic/commit/9e49772be926fb56991183d3add85f1a35a38d73)) + +## [2.2.0](https://github.com/ascorbic/unpic/compare/v2.1.0...v2.2.0) (2023-03-06) + + +### Features + +* add kontent.ai CDN ([#19](https://github.com/ascorbic/unpic/issues/19)) ([7475b58](https://github.com/ascorbic/unpic/commit/7475b58a258878faf2c9f15c094de477294992b1)) + +## [2.1.0](https://github.com/ascorbic/unpic/compare/2.0.2...v2.1.0) (2023-02-24) + + +### Features + +* **builder:** add Builder.io ([#13](https://github.com/ascorbic/unpic/issues/13)) ([fb31a94](https://github.com/ascorbic/unpic/commit/fb31a94edf9e08a00f8f72258b38123b9c4d27ad)) + + +### Bug Fixes + +* handle workflow ([#10](https://github.com/ascorbic/unpic/issues/10)) ([d59485f](https://github.com/ascorbic/unpic/commit/d59485f22decb0cd7146d5443c438d41f247747e)) +* permissions on rp workflow ([6e1dcdf](https://github.com/ascorbic/unpic/commit/6e1dcdfc1bf490738f5bab292ed48e096f8504a2)) +* setup releasing ([#9](https://github.com/ascorbic/unpic/issues/9)) ([2a609a7](https://github.com/ascorbic/unpic/commit/2a609a7b7f09ed887c0ca2bd8e90dc82b05a787b)) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5db35a..7bf7923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,49 @@ +# CHANGELOG + +## 4.0.0 + +This is a major release with several breaking changes. See +[UPGRADING.md](UPGRADING.md) for a detailed migration guide. + +### New Features + +- Added support for provider-specific operations with type safety +- Added support for provider-specific options (e.g., base URLs, project keys) +- Added `fallback` option to specify a fallback provider if URL isn't recognized + +### Breaking Changes + +- **Removed Features** + - Removed delegated URL system + - Removed support for some CDN-specific params in favor of new provider + operations system + +- **API Changes** + - Removed URL delegation system and canonical URL detection + - Changed `transformUrl` signature to accept operations and provider options + as separate arguments + - Updated `parseUrl` return type structure to use operations and provider + terminology + - Added new URL parsing functions: `getExtractorForUrl`, + `getExtractorForProvider` + +- **Function Renames** + - `getImageCdnForUrl` → `getProviderForUrl` + - `getImageCdnForUrlByDomain` → `getProviderForUrlByDomain` + - `getImageCdnForUrlByPath` → `getProviderForUrlByPath` + - Old functions marked as deprecated but still available + +- **Type System Changes** + - Added generic type support for provider-specific operations + - Updated `UrlTransformerOptions` to support typed provider operations + - Removed `ParsedUrl` type in favor of new `ParseURLResult` + - Added new types for enhanced type safety: + - `Operations` + - `OperationMap` + - `FormatMap` + - `ImageFormat` + - `ProviderConfig` # Changelog ## [3.22.0](https://github.com/ascorbic/unpic/compare/v3.21.0...v3.22.0) (2024-12-07) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32181a2..f7f068c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,67 +1,519 @@ -# Contributing - -:heart: We love contributions of new CDNs, bug fixes, and improvements to the -code. - -To add new domains, subdomains or paths to an existing CDN, add them to one or -more of the JSON files in `data`. - -To add a new CDN, add the following: - -- a new source file in `src/transformers`. This should export a `transform` - function that implements the `UrlTransformer` interface, a `parse` function - that implements the `UrlParser` interface and optionally a `generate` function - that implements the `UrlGenerator` interface. -- if the CDN should delegate remote source images to a different CDN where - possible, implement `delegateUrl` and import it in `src/delegate.ts`. This is - likely to apply to all self-hosted image servers. See the `ipx` transformer - for an example. -- a new test file in `src/transformers`. This should test all of the exported - API functions, -- at least one entry in `domains.json`, `subdomains.json` or `paths.json` to - detect the CDN, unless it is not auto-detected. Do not include paths that are - likely to cause false positives. e.g. `/assets` is too generic, but `/_mycdn` - is ok. -- add the new CDN to the types in `src/types.ts` -- import the new source file in `src/transform.ts` and `src/parse.ts` -- add a sample image to `examples.json` in the demo site. Run the site locally - to see that it works. This step is important! This is used for the e2e tests - to ensure that the transformer works as expected, so it needs a real image to - work with. Ideally this should be a public sample image used in the CDN's own - docs, but if that is not available then any image hosted on the CDN will do. - -## Testing - -This project uses [Deno](https://deno.com/) for development, so there is no -install or build step. To run the tests you need to install Deno, then run: - -```sh -deno test src --allow-net +# Contributing Guide + +This guide will help you add new image CDN providers to the library. It covers +implementation details, utility functions, and best practices. + +## Overview + +Each provider consists of: + +1. A TypeScript file containing the implementation + (`src/providers/[provider].ts`) and types for provider-specific operations + and options. +2. A test file (`src/providers/[provider].test.ts`) +3. Example URLs in `src/demo/examples.json` +4. Detection domains or paths in `data` if appropriate +5. Adding to the types and exports in: + - `src/providers/types.ts` + - `src/types.ts` + - `src/extract.ts` + - `src/transform.ts` + +## Core Concepts and Utilities + +### URL Manipulation + +The library provides several utilities for URL handling: + +```typescript +// Convert strings or URLs to URL objects (handles relative URLs) +const url = toUrl("https://example.com/image.jpg"); + +// Convert back to string, preserving relativeness +const urlString = toCanonicalUrlString(url); + +// Path manipulation +const cleanPath = stripLeadingSlash("/path/to/image.jpg"); +const formattedPath = addTrailingSlash("path/to/image"); +``` + +### Operations Handlers + +The most important utility is `createOperationsHandlers`, which creates +standardized parser and generator functions: + +```typescript +const { operationsGenerator, operationsParser } = createOperationsHandlers< + ExampleCdnOperations +>({ + // Map standard operation names to provider-specific names + keyMap: { + width: "w", + height: "h", + quality: "q", + format: "fmt", + }, + // Set default values + defaults: { + quality: 80, + format: "auto", + }, + // Normalize format names + formatMap: { + jpg: "jpeg", + }, + // Define parameter formatting + kvSeparator: "=", // key=value + paramSeparator: "&", // param1¶m2 +}); +``` + +## Step-by-Step Implementation + +Let's create a complete example provider "example-cdn": + +### 1. Define Operations Interface + +```typescript +// example-cdn.ts +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +// Only add NEW operations specific to your provider +export interface ExampleCdnOperations extends Operations { + // Provider-specific operations + specialCrop?: "smart" | "center"; + blur?: number; + + // DON'T include these - they're in base Operations + // width?: number; ❌ + // height?: number; ❌ + // quality?: number; ❌ + // format?: string; ❌ +} + +// Optional provider-specific options +export interface ExampleCdnOptions { + baseUrl?: string; +} +``` + +### 2. Configure Operations Handlers + +```typescript +// Different parameter formatting styles: + +// Query parameters: ?width=100&height=200 +const queryStyle = createOperationsHandlers({ + keyMap: { + width: "w", + height: "h", + quality: "q", + format: "fmt", + }, + defaults: { + quality: 80, + }, + kvSeparator: "=", + paramSeparator: "&", +}); + +// Path segments: /w_100/h_200/q_80 +const pathStyle = createOperationsHandlers({ + keyMap: { + width: "w", + height: "h", + quality: "q", + format: "fmt", + }, + defaults: { + quality: 80, + }, + kvSeparator: "_", + paramSeparator: "/", +}); + +// You can also disable parameters: +const noHeightStyle = createOperationsHandlers({ + keyMap: { + width: "w", + height: false, // Height parameter will be removed + quality: "q", + }, +}); +``` + +### 3. Implement Core Functions + +```typescript +// Extract operations from existing URL +export const extract: URLExtractor<"example-cdn"> = (url) => { + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + parsedUrl.search = ""; + + return { + src: toCanonicalUrlString(parsedUrl), + operations, + options: { + baseUrl: parsedUrl.origin, + }, + }; +}; + +// Generate new URL with operations +export const generate: URLGenerator<"example-cdn"> = ( + src, + operations, + options = {}, +) => { + const url = toUrl(src, options.baseUrl); + url.search = operationsGenerator(operations); + return toCanonicalUrlString(url); +}; + +// Transform existing URL with new operations +export const transform: URLTransformer<"example-cdn"> = + createExtractAndGenerate(extract, generate); +``` + +### 4. Add Comprehensive Tests + +```typescript +// example-cdn.test.ts +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./example-cdn.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = "https://example-cdn.com/image.jpg"; + +Deno.test("Example CDN", async (t) => { + // Test extraction + await t.step("should extract operations from URL", () => { + const url = `${img}?w=300&h=200&q=80&fmt=webp&specialCrop=smart`; + const result = extract(url); + assertEquals(result, { + src: img, + operations: { + width: 300, + height: 200, + quality: 80, + format: "webp", + specialCrop: "smart", + }, + options: { + baseUrl: "https://example-cdn.com", + }, + }); + }); + + // Test URL generation + await t.step("should generate URL with operations", () => { + const result = generate(img, { + width: 400, + height: 300, + quality: 90, + specialCrop: "center", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=400&h=300&q=90&specialCrop=center`, + ); + }); + + // Test transformation + await t.step("should transform existing URL", () => { + const url = `${img}?w=300&h=200`; + const result = transform(url, { + width: 500, + blur: 5, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=500&h=200&blur=5`, + ); + }); + + // Test error cases + await t.step("should handle invalid URLs", () => { + const result = extract("invalid-url"); + assertEquals(result, null); + }); + + // Test relative URLs + await t.step("should handle relative URLs", () => { + const result = generate("/image.jpg", { width: 300 }); + assertEqualIgnoringQueryOrder( + result, + "/image.jpg?w=300", + ); + }); +}); +``` + +### 5. Update Types and Add Examples + +```typescript +// types.ts +export interface ProviderOperations { + "example-cdn": ExampleCdnOperations; + // ... +} + +export interface ProviderOptions { + "example-cdn": ExampleCdnOptions; + // ... +} +``` + +```json +// examples.json +{ + "example-cdn": [ + "Example CDN", + "https://example-cdn.com/demo-image.jpg" + ] +} +``` + +## Parameter Handling Patterns + +### Query Parameters vs Path Segments + +Providers use different URL patterns for operations: + +```typescript +// Standard query parameters +// https://example.com/image.jpg?width=100&height=200 +{ + kvSeparator: "=", + paramSeparator: "&" +} + +// Path segments +// https://example.com/image/w_100/h_200/image.jpg +{ + kvSeparator: "_", + paramSeparator: "/" +} + +// Custom separators +// https://example.com/image:w=100,h=200/image.jpg +{ + kvSeparator: "=", + paramSeparator: "," +} ``` -This will run all of the unit tests and e2e tests. +## Best Practices -The playground site is in `demo`. To run it locally, run `yarn install` then -`yarn dev` in the demo directory. +### When to Use Utilities -## Image defaults +Use the provided utilities when: + +- You need standard parameter mapping +- Your provider follows common URL patterns +- You want automatic parameter normalization + +### When to Create Custom Solutions + +Create custom handlers when: + +- Your provider has unique URL structures +- Parameters have complex interdependencies +- You need special encoding or encryption +- The provider requires custom protocols + +### Error Handling + +Always handle: + +- Invalid URLs +- Missing parameters +- Malformed parameters +- Unsupported formats +- Edge cases + +### Type Safety + +- Define clear interfaces extending `Operations` +- Only add provider-specific operations +- Use proper TypeScript generics +- Document supported operations + +## Testing Requirements + +1. Basic Operations + - Width/height resizing + - Format conversion + - Quality settings + - Provider-specific features + +2. URL Handling + - Absolute URLs + - Relative URLs + - URL with existing parameters + - Invalid URLs + +3. Parameter Edge Cases + - Missing parameters + - Invalid values + - Parameter combinations + - Default values + +4. Common Scenarios + - Standard transformations + - Format conversion + - Quality adjustment + - Size constraints + +## Final Checklist + +Before submitting: + +- [ ] Implementation complete with proper types +- [ ] Comprehensive tests covering all features +- [ ] Types updated in all files listed above +- [ ] Example added to examples.json +- [ ] Detection domains or paths added if needed +- [ ] All tests passing, including unit tests and E2E tests + +## Development Environment + +### Deno Setup + +This project uses Deno for development. If you haven't already, install Deno +from https://deno.com. + +Basic commands: + +```bash +# Run tests +deno test + +# Run tests with watch mode +deno test --watch + +# Type checking +deno check + +# Format code +deno fmt +``` + +### Running Tests + +Tests are written using Deno's built-in test framework. Run them from the +project root: + +```bash +# Run all tests +deno test + +# Run tests for a specific provider +deno test src/providers/example-cdn.test.ts + +# Run E2E tests. These need network access. +deno test --allow-net e2e.test.ts +``` + +## Image Defaults + +When implementing a provider, follow these default behaviors for consistency +across CDNs. If the provider does not support a feature then it can be omitted, +but these are the defaults to aim for so that users have a consistent +experience. + +### Format Handling + +- Enable auto format detection/content negotiation when supported +- When supported, priority order for formats should be. For services that + generate images locally, it is ok to prefer WebP over AVIF for performance + reasons. + 1. AVIF + 2. WebP + 3. Original format + +### Image Fitting + +Default to `fit=cover` behavior (equivalent to CSS `object-fit: cover`). This +means: + +- Image should fill requested dimensions +- Maintain aspect ratio +- Crop if necessary +- Avoid distortion + +### Size Handling + +- _Never_ upscale images beyond their original dimensions +- Return largest available size when requested size is too large +- Maintain requested aspect ratio even when size is constrained + +### Local Development Server + +The project includes a playground application in the `demo` directory for +testing providers visually: + +1. Start the development server: + +```bash +cd demo +pnpm install +pnpm dev +``` + +2. Open http://localhost:1234 + +The playground is crucial for testing as it: + +- Provides real-world testing with actual CDN endpoints +- Allows visual verification of image operations +- Tests responsive image behavior +- Verifies URL generation patterns + +When adding a new provider: + +1. Add an example URL to `demo/src/examples.json` + - Ideally use a public sample image from the CDN's documentation + - If unavailable, use any publicly-accessible image on that CDN + - **Do not skip this** - no provider can be added without an example URL, + because otherwise it cannot be tested +2. Test comprehensively: + - Verify resizing behavior + - Check that defaults are properly applied + - Test format conversion + - Verify responsive behavior + - Ensure upscaling limits work + - Check aspect ratio handling + +### End-to-End Testing + +The E2E tests in `e2e.test.ts` verify that providers work with real CDN +endpoints. They use the images from `examples.json` to test real operations: + +```bash +deno test --allow-net e2e.test.ts +``` -When generating image URLs, we expect transformers to use the following defaults -if supported, to ensure consistent behaviour across all CDNs: +## Getting Help -- Auto format. If the CDN supports it, then it should deliver the best format - for the browser using content negotiation. If supported, the priority order - should be AVIF, WebP, then the original format. -- Fit = cover. The image should fill the requested dimensions, cropping if - necessary and without distortion. This is the equivalent of the CSS - `object-fit: cover` setting. There is an e2e test for this. -- No upscaling. The image should not be upscaled if it is smaller than the - requested dimensions. Instead it should return the largest available size, but - maintain the requested aspect ratio. +If you need help: -## Publishing +1. Review existing provider implementations +2. Check test files for patterns +3. Open an issue for discussion +4. Ask questions in pull requests -The module is published to both [deno.land](https://deno.land/x/unpic) and -[npm](https://www.npmjs.com/package/unpic), with the npm version generated using -[dnt](https://github.com/denoland/dnt). This is handled automatically by GitHub -Actions. +Remember that clear, well-tested code is more important than clever solutions. +Take time to write comprehensive tests and documentation. diff --git a/README.md b/README.md index 64b33eb..c90f44a 100644 --- a/README.md +++ b/README.md @@ -50,49 +50,46 @@ You can then use the `transformUrl` function to transform a URL: ```ts const url = transformUrl( + "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg", { - url: - "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg", width: 800, height: 600, }, ); -console.log(url.toString()); - +console.log(url); // https://cdn.shopify.com/static/sample-images/bath.jpeg?width=800&height=600&crop=center ``` -You can also use the `parseUrl` function to parse a URL and get the CDN and any -params: +You can also use the `parseUrl` function to parse a URL and get information +about the image: ```ts -const parsedUrl = parseUrl( +const parsed = parseUrl( "https://cdn.shopify.com/static/sample-images/bath_800x600_crop_center.jpeg", ); -console.log(parsedUrl); +console.log(parsed); // { -// cdn: "shopify", -// width: 800, -// height: 600, -// base: "https://cdn.shopify.com/static/sample-images/bath.jpeg", -// params: { -// crop: "center", -// }, +// provider: "shopify", +// src: "https://cdn.shopify.com/static/sample-images/bath.jpeg", +// operations: { +// width: 800, +// height: 600, +// crop: "center" +// } // } ``` -You can bypass auto-detection by specifying the CDN: +You can bypass auto-detection by specifying the provider: ```ts const url = transformUrl( + "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg", { - url: - "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg", width: 800, height: 600, - cdn: "shopify", + provider: "shopify", }, ); ``` @@ -100,36 +97,138 @@ const url = transformUrl( This is particularly useful if you are using the CDN with a custom domain which is not auto-detected. -## Supported CDN APIs - -- Adobe Dynamic Media (Scene7) -- Builder.io -- Bunny.net, including caisy -- Cloudflare -- Contentful -- Contentstack -- Cloudinary -- Directus -- ImageEngine -- Imgix, including Unsplash, DatoCMS, Sanity and Prismic -- Kontent.ai -- Shopify -- Storyblok -- Vercel / Next.js -- WordPress.com and Jetpack Site Accelerator - -## Delegated URLs - -Some transformers support URL delegation. This means that the source image URL -is also checked, and if it matches a CDN then the transform is applied directly -to the source image. For example: consider a `next/image` URL that points to an -image on Shopify. The URL is detected as a `nextjs` URL because it starts with -`/_next/image`. The `nextjs` transformer supports delegation, so the source -image URL is then checked. As it matches a Shopify domain, the transform is -applied directly to the Shopify URL. This means that the image is transformed on -the fly by Shopify, rather than by Next.js. However if the source image is not a -supported CDN, or is a local image then the `nextjs` transformer will return a -`/_next/image` URL. +You can also specify a fallback provider to use if the URL is not recognised as +coming from a known CDN: + +```ts +const url = transformUrl( + "https://example.com/image.jpg", + { + width: 800, + height: 600, + fallback: "netlify", + }, +); +``` + +This is useful if you are using a CDN provider that supports external images, +but you still want to use the original CDN if possible. + +## Custom operations + +Different CDNs support different operations. By default, the transform function +accepts the operations `width`, `height`, `quality` and `format`. You can pass +provider-specific operations as the third argument to the `transformUrl` +function: + +```ts +const url = transformUrl( + "https://cdn.shopify.com/static/sample-images/bath.jpeg", + { + width: 800, + height: 600, + }, + { + shopify: { + crop: "center", + }, + }, +); +``` + +You can pass options for multiple providers, which will be passed to the +provider depending on the detected CDN: + +```ts +const url = transformUrl( + src, + { + width: 800, + height: 600, + }, + { + shopify: { + crop: "left", + }, + imgix: { + position: "left", + }, + }, +); +``` + +These options are type-safe, as we include the types for each provider. + +You can do the same for provider options, such as base URLs project keys. + +```ts +const url = transformUrl( + src, + { + width: 800, + height: 600, + fallback: "cloudinary", + }, + { + shopify: { + crop: "left", + }, + }, + { + cloudinary: { + cloudName: "demo", + }, + }, +); +``` + +## Provider imports + +If you know which providers you will be using, you can import them directly. +This will reduce the bundle size of your application, as only the providers you +use will be included. In this case you can pass provider-specific operations in +the object. + +```ts +import { transform } from "unpic/providers/shopify"; + +const url = transform( + "https://cdn.shopify.com/static/sample-images/bath.jpeg", + { + width: 800, + height: 600, + crop: "center", + }, +); +``` + +## Supported Providers + +- Adobe Dynamic Media (Scene7) `scene7` +- Astro image service `astro` +- Builder.io `builder.io` +- Bunny.net, including caisy `bunny` +- Cloudflare `cloudflare` and `cloudflare_images` +- Cloudimage `cloudimage` +- Cloudinary `cloudinary` +- Contentful `contentful` +- Contentstack `contentstack` +- Directus `directus` +- Hygraph `hygraph` +- ImageEngine `imageengine` +- ImageKit `imagekit` +- Imgix, including Unsplash, DatoCMS, Sanity and Prismic `imgix` +- IPX `ipx` +- KeyCDN `keycdn` +- Kontent.ai `kontent.ai` +- Netlify `netlify` +- Next.js image service `nextjs` +- Shopify `shopify` +- Storyblok `storyblok` +- Supabase `supabase` +- Uploadcare `uploadcare` +- Vercel `vercel` +- WordPress.com and Jetpack Site Accelerator `wordpress` ## FAQs @@ -143,6 +242,7 @@ supported CDN, or is a local image then the `nextjs` transformer will return a CDN to provide the image API, most commonly Imgix. In most cases they support the same API, but in others they may proxy the image through their own CDN, or use a different API. + - **Why would I use this instead of the CDN's own SDK?** If you you know that your images will all come from one CDN, then you probably should use the CDN's own SDK. This library is designed to work with images from multiple CDNs, and @@ -150,29 +250,44 @@ supported CDN, or is a local image then the `nextjs` transformer will return a useful for images that may come from an arbitrary source, such as a CMS. It is also useful for parsing URLs that may already have transforms applied, because most CDN SDKs will not parse these URLs correctly. + - **Can you add support for CDN X?** If it supports a URL API and doesn't require signed URLs then yes, please open an issue or PR. + - **Can you add my domain to CDN X?** If you provide a service where end-users use your URLs then probably. Examples may be image providers such as Unsplash, or CMSs. If it is just your own site then probably not. You can manually - specify the CDN in the arguments to `transformUrl` and `parseUrl`. -- **Can you support more params?** We deliberately just support the most common - params that are shared between all CDNs. If you need more params then you can - use the CDN-specific API directly. + specify the provider in the arguments to `transformUrl` and `parseUrl`. + +- **What params can I use?** The library provides a standard set of operations + (`width`, `height`, `format`, `quality`) that work across all providers. You + can also use provider-specific operations by passing them as the third + argument to `transformUrl`. These are fully type-safe - your IDE will show you + which operations are available for each provider. + - **Why do you set auto format?** If the CDN support is, and no format is specified in `transformUrl`, the library will remove any format set in the source image, changing it to auto-format. In most cases, this is what you want. Almost all browsers now support modern formats such as WebP, and setting auto-format will allow the CDN to serve the best format for the browser. If you want to force a specific format, you can set it in `transformUrl`. + +- **Why do you set fit=cover (or equivalent)?** If the CDN supports it, and no + fit is specified in `transformUrl`, the library will set fit to cover. This is + because in most cases you want the image to fill the space, rather than be + contained within it. Every CDN has its own syntax for this, so it's best if we + set a default that applies to all images. If you want to force a specific fit, + you can set it in `transformUrl`. + - **Do you support SVG, animated GIF etc?** If the CDN supports it, then yes. We don't attempt to check if a format is valid - we will just pass it through to the CDN. If the CDN doesn't support it, then it will return an error or a default. -- **Do you support video, etc** No, this library is only for images. If you pass - a video URL to `transformUrl`, it will return `undefined`, as it will for any - URL that is not recognised as an image CDN URL. It is up to you to handle this - case. + +- **Do you support video, etc?** No, this library is only for images. If you + pass a video URL to `transformUrl`, it will return `undefined`, as it will for + any URL that is not recognised as an image CDN URL. It is up to you to handle + this case. ## Contributing diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..bf38a2f --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,279 @@ +# Migration Guide: Version 3 to Version 4 + +## Overview + +Version 4 introduces several breaking changes to improve type safety and better +support provider-specific features. This guide will help you migrate your code +from version 3 to version 4. + +## Breaking Changes + +### 1. `transformUrl` Changes + +The `transformUrl` function now accepts provider operations and options +separately: + +```ts +// Version 3 +transformUrl({ + url: "https://example.com/image.jpg", + width: 800, + height: 600, + cdn: "shopify", + cdnOptions: { + shopify: { + crop: "center", + }, + }, +}); + +// Version 4 +transformUrl({ + url: "https://example.com/image.jpg", + width: 800, + height: 600, + provider: "shopify", // or use 'cdn' (deprecated) +}, { + shopify: { + crop: "center", + }, +}); +``` + +### 2. Provider-Specific Operations + +Provider operations are now passed as a separate argument: + +```ts +// Version 3 +transformUrl({ + url: "https://example.com/image.jpg", + width: 800, + cdnOptions: { + cloudinary: { + crop: "fill", + gravity: "center", + }, + }, +}); + +// Version 4 +transformUrl({ + url: "https://example.com/image.jpg", + width: 800, +}, { + cloudinary: { + crop: "fill", + gravity: "center", + }, +}); +``` + +### 3. Replacing URL Delegation with Fallback + +The URL delegation system has been replaced with a more explicit fallback +system. Instead of recursively checking source URLs, you can now specify a +fallback provider to use when a URL isn't recognized: + +```ts +// Version 3 (with delegation) +transformUrl({ + url: "/_next/image?url=https://example.com/image.jpg&w=800", + width: 1200, + recursive: true, // would try to use the source image's CDN +}); + +// Version 4 (with fallback) +transformUrl({ + url: "https://example.com/image.jpg", + width: 1200, + fallback: "nextjs", // will use Next.js image optimization if URL isn't recognized +}); + +// Or use a different fallback provider +transformUrl( + { + url: "https://example.com/image.jpg", + width: 1200, + fallback: "cloudinary", // use Cloudinary for unrecognized URLs + }, + {}, + { + cloudinary: { + cloudName: "demo", + }, + }, +); +``` + +### 4. Function Renames + +Several functions have been renamed for clarity. The old names are still +available but deprecated: + +```ts +// Version 3 +import { + getImageCdnForUrl, + getImageCdnForUrlByDomain, + getImageCdnForUrlByPath, +} from "unpic"; + +// Version 4 +import { + getProviderForUrl, // new name + getProviderForUrlByDomain, // new name + getProviderForUrlByPath, // new name +} from "unpic"; +``` + +### 5. ParseURL Changes + +The `parseUrl` function has been updated to return a different structure and +support generic types: + +```ts +// Version 3 +const result = parseUrl(url); +// { +// cdn: "shopify", +// width: 800, +// height: 600, +// base: "https://cdn.shopify.com/image.jpg", +// params: { +// crop: "center" +// } +// } + +// Version 4 +const result = parseUrl(url); +// { +// provider: "shopify", // or cdn +// src: "https://cdn.shopify.com/image.jpg", +// operations: { +// width: 800, +// height: 600, +// crop: "center" +// } +// } + +// New in Version 4: URL extractors +const extractor = getExtractorForUrl(url); +const result = extractor?.(url); +``` + +### 6. Provider Configuration + +Provider-specific options (like base URLs or project keys) are now passed as the +third argument: + +```ts +// Version 3 +transformUrl({ + url: "image.jpg", + width: 800, + cdn: "cloudinary", + cdnOptions: { + cloudinary: { + cloudName: "demo", + }, + }, +}); + +// Version 4 +transformUrl( + { + url: "image.jpg", + width: 800, + provider: "cloudinary", + }, + {}, // operations (empty in this case) + { + cloudinary: { + cloudName: "demo", + }, + }, +); +``` + +### 7. Type System Changes + +If you're using TypeScript, you'll notice improved type safety: + +```ts +// Version 4 adds type safety for provider operations +transformUrl<"shopify">({ + url: url, + width: 800, + provider: "shopify", +}, { + shopify: { + crop: "center", // Type-safe: only valid Shopify options allowed + }, +}); +``` + +## New Features + +### 1. Fallback Provider + +The new fallback system provides a more explicit way to handle unrecognized +URLs: + +```ts +transformUrl({ + url: "https://example.com/image.jpg", + width: 800, + fallback: "netlify", // Use Netlify if URL isn't recognized +}); +``` + +This is particularly useful when: + +- Handling user-provided URLs that might not be from a known CDN +- Migrating from a delegation-based system +- Working with frameworks like Next.js where you want to use the native image + optimization as a fallback + +### 2. Direct Provider Imports + +For better tree-shaking, you can import providers directly: + +```ts +import { transform } from "unpic/providers/shopify"; + +const url = transform( + "https://cdn.shopify.com/image.jpg", + { + width: 800, + crop: "center", // Provider-specific options available directly + }, +); +``` + +## Removed Features + +1. URL delegation system (`recursive` option and automatic source URL detection) +2. `cdnOptions` parameter (replaced with separate operations and options + arguments) +3. `CanonicalCdnUrl` and related types +4. `ShouldDelegateUrl` interface and delegation system + +## Tips for Migrating + +1. Replace URL delegation with explicit fallback providers +2. Update the `transformUrl` function calls to use the new signature +3. Replace `cdn` with `provider` in your code (though `cdn` still works) +4. Move provider-specific operations to the second argument +5. Move provider configuration options to the third argument +6. Update TypeScript types if you're using them in your code +7. Test your image transformations to ensure they still work as expected + +## Getting Help + +If you encounter any issues while migrating, please: + +1. Check the [documentation](https://unpic.pics/lib) +2. Open an issue [on GitHub](https://github.com/ascorbic/unpic) if you think + you've found a bug +3. Ask for help in the GitHub discussions diff --git a/data/paths.json b/data/paths.json index 0b0238e..e780328 100644 --- a/data/paths.json +++ b/data/paths.json @@ -2,7 +2,6 @@ "/cdn-cgi/image/": "cloudflare", "/cdn-cgi/imagedelivery/": "cloudflare_images", "/_next/image": "nextjs", - "/_next/static": "nextjs", "/_vercel/image": "vercel", "/is/image": "scene7", "/_ipx/": "ipx", diff --git a/data/subdomains.json b/data/subdomains.json index 0a3d57c..e4cc576 100644 --- a/data/subdomains.json +++ b/data/subdomains.json @@ -1,5 +1,6 @@ { "imgix.net": "imgix", + "wp.com": "wordpress", "files.wordpress.com": "wordpress", "b-cdn.net": "bunny", "storyblok.com": "storyblok", diff --git a/demo/pnpm-lock.yaml b/demo/pnpm-lock.yaml new file mode 100644 index 0000000..e7ecef9 --- /dev/null +++ b/demo/pnpm-lock.yaml @@ -0,0 +1,3140 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@preact/signals': + specifier: ^1.1.3 + version: 1.3.1(preact@10.25.0) + ipx: + specifier: ^2.0.0 + version: 2.1.0 + preact: + specifier: ^10.1.0 + version: 10.25.0 + preact-render-to-string: + specifier: ^6.5.11 + version: 6.5.11(preact@10.25.0) + devDependencies: + '@netlify/functions': + specifier: ^2.3.0 + version: 2.8.2 + '@types/node': + specifier: ^20.8.9 + version: 20.17.9 + parcel: + specifier: ^2.12.0 + version: 2.13.2(@swc/helpers@0.5.15)(svgo@3.3.2)(typescript@5.7.2) + typescript: + specifier: ^5.6.3 + version: 5.7.2 + +packages: + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@fastify/accept-negotiator@1.1.0': + resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} + engines: {node: '>=14'} + + '@lezer/common@1.2.3': + resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} + + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + + '@lmdb/lmdb-darwin-arm64@2.8.5': + resolution: {integrity: sha512-KPDeVScZgA1oq0CiPBcOa3kHIqU+pTOwRFDIhxvmf8CTNvqdZQYp5cCKW0bUk69VygB2PuTiINFWbY78aR2pQw==} + cpu: [arm64] + os: [darwin] + + '@lmdb/lmdb-darwin-x64@2.8.5': + resolution: {integrity: sha512-w/sLhN4T7MW1nB3R/U8WK5BgQLz904wh+/SmA2jD8NnF7BLLoUgflCNxOeSPOWp8geP6nP/+VjWzZVip7rZ1ug==} + cpu: [x64] + os: [darwin] + + '@lmdb/lmdb-linux-arm64@2.8.5': + resolution: {integrity: sha512-vtbZRHH5UDlL01TT5jB576Zox3+hdyogvpcbvVJlmU5PdL3c5V7cj1EODdh1CHPksRl+cws/58ugEHi8bcj4Ww==} + cpu: [arm64] + os: [linux] + + '@lmdb/lmdb-linux-arm@2.8.5': + resolution: {integrity: sha512-c0TGMbm2M55pwTDIfkDLB6BpIsgxV4PjYck2HiOX+cy/JWiBXz32lYbarPqejKs9Flm7YVAKSILUducU9g2RVg==} + cpu: [arm] + os: [linux] + + '@lmdb/lmdb-linux-x64@2.8.5': + resolution: {integrity: sha512-Xkc8IUx9aEhP0zvgeKy7IQ3ReX2N8N1L0WPcQwnZweWmOuKfwpS3GRIYqLtK5za/w3E60zhFfNdS+3pBZPytqQ==} + cpu: [x64] + os: [linux] + + '@lmdb/lmdb-win32-x64@2.8.5': + resolution: {integrity: sha512-4wvrf5BgnR8RpogHhtpCPJMKBmvyZPhhUtEwMJbXh0ni2BucpfF07jlmyM11zRqQ2XIq6PbC2j7W7UCCcm1rRQ==} + cpu: [x64] + os: [win32] + + '@mischnic/json-sourcemap@0.1.1': + resolution: {integrity: sha512-iA7+tyVqfrATAIsIRWQG+a7ZLLD0VaOCKV2Wd/v4mqIU3J9c4jx9p7S0nw1XH3gJCKNBOOwACOPYYSUu9pgT+w==} + engines: {node: '>=12.0.0'} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@netlify/functions@2.8.2': + resolution: {integrity: sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==} + engines: {node: '>=14.0.0'} + + '@netlify/node-cookies@0.1.0': + resolution: {integrity: sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g==} + engines: {node: ^14.16.0 || >=16.0.0} + + '@netlify/serverless-functions-api@1.26.1': + resolution: {integrity: sha512-q3L9i3HoNfz0SGpTIS4zTcKBbRkxzCRpd169eyiTuk3IwcPC3/85mzLHranlKo2b+HYT0gu37YxGB45aD8A3Tw==} + engines: {node: '>=18.0.0'} + + '@parcel/bundler-default@2.13.2': + resolution: {integrity: sha512-WY0LB1B7H6zIGXBtwssZRmzk788GzHoOGvMSIqgE/mZ0+jPF5V54zkjbhPBXj1fvoKOGlFy8Bm/gd/GnlQDdIg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/cache@2.13.2': + resolution: {integrity: sha512-Y0nWlCMWDSp1lxiPI5zCWTGD0InnVZ+IfqeyLWmROAqValYyd0QZCvnSljKJ144jWTr0jXxDveir+DVF8sAYaA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/codeframe@2.13.2': + resolution: {integrity: sha512-qFMiS14orb6QSQj5/J/QN+gJElUfedVAKBTNkp9QB4i8ObdLHDqHRUzFb55ZQJI3G4vsxOOWAOUXGirtLwrxGQ==} + engines: {node: '>= 16.0.0'} + + '@parcel/compressor-raw@2.13.2': + resolution: {integrity: sha512-HX51w7WlgQY2f30p3Le1B5nFsUrtEA1phvWEwQDm1gEC6OPmDrxNsFLWx18JdGlKWTaPYbAGXRMSOjUWU41N9w==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/config-default@2.13.2': + resolution: {integrity: sha512-oTf69/Ikxb7b8uqdu4SasRnIn7e68dCSNW2PhXuBkHq2GgzTSnpSqCwur70wQwrHKHdNt9RtIjLQgC6oOs5aJQ==} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/core@2.13.2': + resolution: {integrity: sha512-1zC5Au4z9or5XyP6ipfvJqHktuB0jD7WuxMcV1CWAZGARHKylLe+0ccl+Wx7HN5O+xAvfCDtTlKrATY8qyrIyw==} + engines: {node: '>= 16.0.0'} + + '@parcel/diagnostic@2.13.2': + resolution: {integrity: sha512-6Au0JEJ5SY2gYrY0/m0i0sTuqTvK0k2E9azhBJR+zzCREbUxLiDdLZ+vXAfLW7t/kPAcWtdNU0Bj7pnZcMiMXg==} + engines: {node: '>= 16.0.0'} + + '@parcel/events@2.13.2': + resolution: {integrity: sha512-BVB9hW1RGh/tMaDHfpa+uIgz5PMULorCnjmWr/KvrlhdUSUQoaPYfRcTDYrKhoKuNIKsWSnTGvXrxE53L5qo0w==} + engines: {node: '>= 16.0.0'} + + '@parcel/feature-flags@2.13.2': + resolution: {integrity: sha512-cCwDAKD4Er24EkuQ+loVZXSURpM0gAGRsLJVoBtFiCSbB3nmIJJ6FLRwSBI/5OsOUExiUXDvSpfUCA5ldGTzbw==} + engines: {node: '>= 16.0.0'} + + '@parcel/fs@2.13.2': + resolution: {integrity: sha512-bdeIMuAXhMnROvqV55JWRUmjD438/T7h3r3NsFnkq+Mp4z2nuAn0STxbqDNxIgTMJHNunSDzncqRNMT7xJCe8A==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/graph@3.3.2': + resolution: {integrity: sha512-aAysQLRr8SOonSHWqdKHMJzfcrDFXKK8IYZEurlOzosiSgZXrAK7q8b8JcaJ4r84/jlvQYNYneNZeFQxKjHXkA==} + engines: {node: '>= 16.0.0'} + + '@parcel/logger@2.13.2': + resolution: {integrity: sha512-SFVABAMqaT9jIDn4maPgaQQauPDz8fpoKUGEuLF44Q0aQFbBUy7vX7KYs/EvYSWZo4VyJcUDHvIInBlepA0/ZQ==} + engines: {node: '>= 16.0.0'} + + '@parcel/markdown-ansi@2.13.2': + resolution: {integrity: sha512-MIEoetfT/snk1GqWzBI3AhifV257i2xke9dvyQl14PPiMl+TlVhwnbQyA09WJBvDor+MuxZypHL7xoFdW8ff3A==} + engines: {node: '>= 16.0.0'} + + '@parcel/namer-default@2.13.2': + resolution: {integrity: sha512-wHaaJZcZEZUaCylC88PqjN4BybJhnkpP5RYg1xGWBTzdxhZthxvDbeOI+0YZ4jZXrLyVNjPyPRwyx0ETlq8MKA==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/node-resolver-core@3.4.2': + resolution: {integrity: sha512-SwnKLcZRG1VdB5JeM/Ax5VMWWh2QfXufmMQCKKx0/Kk41nUpie+aIZKj3LH6Z/fJsnKig/vXpeWoxGhmG523qg==} + engines: {node: '>= 16.0.0'} + + '@parcel/optimizer-css@2.13.2': + resolution: {integrity: sha512-V9JszWd1Lk3b/9hpfRp6U8lfOIaFPyevGFNTrT+CFMviuipCMWrkUgBa7wtFvqN1i8IJ5NV5FhIlc12qfBBBgA==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/optimizer-htmlnano@2.13.2': + resolution: {integrity: sha512-/ikDOZrnO4tdt99h34OyqnNIhugdeqWgnpfqEQ6Xi7odIn8OIGfwAHBXoyKRyUU8YUTqLhzOhckbSMwFTPRmXg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/optimizer-image@2.13.2': + resolution: {integrity: sha512-1BsQOPoSB0TBJJ40TiN+VS3YK2V4EMDtaOML1Bet2oTLMlL77vJG/xT5QHzhExYK+ZyFh2R0gq7deEKXNScBzg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/optimizer-svgo@2.13.2': + resolution: {integrity: sha512-QbuQzGfk5b/p9Yzc8PaSyjwalZbu/5afrKaLYKkiyG+kAVVOGMsxA2WPqPdb8x551AgdQL4OVODS9dE3zdDQOQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/optimizer-swc@2.13.2': + resolution: {integrity: sha512-tyxXn36UAxZkAh+/cjvWwLCBkY+DL7+4G9NHWl5KeFqErc4diBox81Aiu8hnswNzFIg4ljn6f0rNpnWM3yfoMg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/package-manager@2.13.2': + resolution: {integrity: sha512-6HjfbdJUjHyNKzYB7GSYnOCtLwqCGW7yT95GlnnTKyFffvXYsqvBSyepMuPRlbX0mFUm4S9l2DH3OVZrk108AA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/packager-css@2.13.2': + resolution: {integrity: sha512-agao71rIHU1lR776IMwxKvknl1/Yglhkr2qSY0JQC10PRQXHs7nj0GXd69p568W42A3/rkMWrXjWkGzhbAcPRg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/packager-html@2.13.2': + resolution: {integrity: sha512-RHoYR4sp5VZATQbKE2Rn7DrJKK7HnvUTKB0WPFSppWJbJrqrZgvVCqnBMI2FPkbCoznGdt20rQ1R6vs591fuxQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/packager-js@2.13.2': + resolution: {integrity: sha512-/dx19/vTCb4JIx/556hz6KEmwanasUNLAFsZ1PAm5AYDcoxJtHiNITRilA6JTlO+mdsERxOI5eE7tHCTou1ErQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/packager-raw@2.13.2': + resolution: {integrity: sha512-P+BnMZ3WS4F+Kpd+iv6PCfgyCftPGf8iGSQOCPkWb5fjuNjfvIzsq4WAW41FPbu788JwChev1O4zREYzlQjG2Q==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/packager-svg@2.13.2': + resolution: {integrity: sha512-K99yyQ1IsbQlGWYOLaxv/GGeWXDq0snbgGrCJvXFS8APZZ2CrXRm2634XLFkY3XA1cKqP47wz+KbibMT/+uaPQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/packager-wasm@2.13.2': + resolution: {integrity: sha512-XqFQQcQRgZLPHgLWsQmWHr47ebsu9F7hmpHS+hFNHda4zj7WDtw7r7n6/d8CEXzdI3agpxR3XKVZzx7nB6sQig==} + engines: {node: '>=16.0.0', parcel: ^2.13.2} + + '@parcel/plugin@2.13.2': + resolution: {integrity: sha512-Q+RIENS1B185yLPhrGdzBK1oJrZmh/RXrYMnzJs78Tog8SpihjeNBNR6z4PT85o2F+Gy2y1S9A26fpiGq161qQ==} + engines: {node: '>= 16.0.0'} + + '@parcel/profiler@2.13.2': + resolution: {integrity: sha512-fur6Oq2HkX6AiM8rtqmDvldH5JWz0sqXA1ylz8cE3XOiDZIuvCulZmQ+hH+4odaNH6QocI1MwfV+GDh3HlQoCA==} + engines: {node: '>= 16.0.0'} + + '@parcel/reporter-cli@2.13.2': + resolution: {integrity: sha512-dIx4d/B+P+7n+lPCnjorM3ygHf3E/P3os3g6BjUe7gOlq/acTwtM0TNXNdRLcsw3K+RzA2VkHLnvdgjIJ18F5g==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/reporter-dev-server@2.13.2': + resolution: {integrity: sha512-alWCPZiXEy5a1/mVnxQTJwJhWrnjaR+JOHQSu69eBGuWHqhXt2SCyKpczT08nm37GIxkhsiIaVR8sP4lVriApw==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/reporter-tracer@2.13.2': + resolution: {integrity: sha512-QdnyUHrYcb5iIMqqp6nmR0xi63sPLTALsRYMoLpQPXP/SrO4JQIqGeBSdHi+59esDnlbWDtN2RpBJ3cXlOsjsA==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/resolver-default@2.13.2': + resolution: {integrity: sha512-8bMK04AxUmLF0+rsEl9u2LiboAsTjAemer9N/qMnWfsbxvEDunfTR39fwEEXpGAQV2sFb0ZPYtTxOc8bk5ygcQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/runtime-browser-hmr@2.13.2': + resolution: {integrity: sha512-ByF8Ww1g6XbtwqBxNZrUz/j9EG0O7sqefkW7E2IWhlxFiNJakIrgaN5VKCBRRWaDvyAz0Kn6Md9e6GLmioRXkA==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/runtime-js@2.13.2': + resolution: {integrity: sha512-DxRFW30RWM8noK1+yrqa+GYblMJabx6cg5Q7BI1SmTvVflomYVy2KEBVA161VZoxjHS6o0lToziAeVcUJT5GUQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/runtime-react-refresh@2.13.2': + resolution: {integrity: sha512-anLQUANkU++brMa7PWBmvbGDgaNMA9BP7vg/g22KI7w6nh9D3f4JBi/Vo4N66zHATpex41gAjGmFXcBtotc5bQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/runtime-service-worker@2.13.2': + resolution: {integrity: sha512-EWn3eM5d81uL9+hXqAnuXo/6yq/7p1VEOKn83FEsbO4TAb6Pd25bJ0mPnWpewPcJBQUoPX3Wjx7VzVit7eeuYw==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/rust@2.13.2': + resolution: {integrity: sha512-XFIewSwxkrDYOnnSP/XZ1LDLdXTs7L9CjQUWtl46Vir5Pq/rinemwLJeKGIwKLHy7fhUZQjYxquH6fBL+AY8DA==} + engines: {node: '>= 16.0.0'} + + '@parcel/source-map@2.1.1': + resolution: {integrity: sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew==} + engines: {node: ^12.18.3 || >=14} + + '@parcel/transformer-babel@2.13.2': + resolution: {integrity: sha512-2cHXLQ2+jeae+mImoaKTtkKhCKATaPY2+Pao0g3zh1xwhNu/08xj7upnbD548UEyEChUWn6IjWljDsx4y8Oa3w==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-css@2.13.2': + resolution: {integrity: sha512-QR9I4wYc+Tw7eET5ak3BvXLdsmDJGzq+Gd4KaULa0sNKioDUXCi79E5rGICW8E+BbHGKar7boNzk7HrNZX7PLg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-html@2.13.2': + resolution: {integrity: sha512-LlQHODz/R832ZuRkCGlOQe+TF1BR9nriUcVSc2Z7q5xQ/HblNPrVvvLDBcXG7xRToawS1y6jXG0Tihv47AykfQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-image@2.13.2': + resolution: {integrity: sha512-sHk9UmJIPEGil+8ulK+Mi4BArbSuMPTXrY1z3EP4pKGHPCMABNKIRiricngvxCW1eVZrxSokeHQe2jYWJ4tAtA==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/transformer-js@2.13.2': + resolution: {integrity: sha512-mn5DL+59x0FHeHKWOstZuKcz4rcVnZUAkXMPtERgXa0ggjJ1CgVOc26VD68sszC/aiF6yathz/LJtJpyluniLQ==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@parcel/transformer-json@2.13.2': + resolution: {integrity: sha512-AiLyWPnHaNvO9sGykYF15S3jzyQY0vSw3xQPj/xhDRv7IXQyt3y1zTtJmQsp/ri9vIzf2CruD42UXiaSPpbA8A==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-postcss@2.13.2': + resolution: {integrity: sha512-srcKQcTaaCGutcvpWeTue4/bScPJK3nXyql2QVNneufqxTQsOZcZg8lFaMc3ma6WjQn/m2emQC26eivr3MOhDg==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-posthtml@2.13.2': + resolution: {integrity: sha512-pNvxKp7GWLKSbyV2xRaGWZNV/ut8uetMfbwpcGxboxgq5TV9dqnHxRGzsTvZTo7yHqQ3N6hycoGh+w8L/8sg8Q==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-raw@2.13.2': + resolution: {integrity: sha512-KsTasFp+jwkGjBLrHO6oiqIIwOeiyNPx5NawmIzXUuGvQv6UhTSayk3NnFxteOVXzy5C9GfrQ5W+IBrHe6JWaw==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-react-refresh-wrap@2.13.2': + resolution: {integrity: sha512-2UuuzHzpUx8Z0muoM3cETa7PDRJIG9a5nxPaTBZttT5ucprskITakky5pzsd4gabmNzWfZ5raRG5ixV3zOSL5A==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/transformer-svg@2.13.2': + resolution: {integrity: sha512-ANwWE4/n4rXrlbmY3iT18ndlxlLP1ubapR1wYL9bpp2cKA8ny2tCe5wlzLxBAfwcZx8cd15N/5v/ZwS6xt6BXw==} + engines: {node: '>= 16.0.0', parcel: ^2.13.2} + + '@parcel/types-internal@2.13.2': + resolution: {integrity: sha512-j0zb3WNM8O/+d8CArll7/4w4AyBED3Jbo32/unz89EPVN0VklmgBrRCAI5QXDKuJAGdAZSL5/a8bNYbwl7/Wxw==} + + '@parcel/types@2.13.2': + resolution: {integrity: sha512-6ixqjk2pjKELn4sQ/jdvpbCVTeH6xXQTdotkN8Wzk68F2K2MtSPIRAEocumlexScfffbRQplr2MdIf1JJWLogA==} + + '@parcel/utils@2.13.2': + resolution: {integrity: sha512-BkFtRo5xenmonwnBy+X4sVbHIRrx+ZHMPpS/6hFqyTvoUUFq2yTFQnfRGVVOOvscVUxpGom+kewnrTG3HHbZoA==} + engines: {node: '>= 16.0.0'} + + '@parcel/watcher-android-arm64@2.5.0': + resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.0': + resolution: {integrity: sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.0': + resolution: {integrity: sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.0': + resolution: {integrity: sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.0': + resolution: {integrity: sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.0': + resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.0': + resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.0': + resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.0': + resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.0': + resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-wasm@2.5.0': + resolution: {integrity: sha512-Z4ouuR8Pfggk1EYYbTaIoxc+Yv4o7cGQnH0Xy8+pQ+HbiW+ZnwhcD2LPf/prfq1nIWpAxjOkQ8uSMFWMtBLiVQ==} + engines: {node: '>= 10.0.0'} + bundledDependencies: + - napi-wasm + + '@parcel/watcher-win32-arm64@2.5.0': + resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.0': + resolution: {integrity: sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.0': + resolution: {integrity: sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.0': + resolution: {integrity: sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==} + engines: {node: '>= 10.0.0'} + + '@parcel/workers@2.13.2': + resolution: {integrity: sha512-P78BpH0yTT9KK09wgK4eabtlb5OlcWAmZebOToN5UYuwWEylKt0gWZx1+d+LPQupvK84/iZ+AutDScsATjgUMw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + '@parcel/core': ^2.13.2 + + '@preact/signals-core@1.8.0': + resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} + + '@preact/signals@1.3.1': + resolution: {integrity: sha512-nNvSF2O7RDzxp1Rm7SkA5QhN1a2kN8pGE8J5o6UjgDof0F0Vlg6d6HUUVxxqZ1uJrN9xnH2DpL6rpII3Es0SsQ==} + peerDependencies: + preact: 10.x + + '@swc/core-darwin-arm64@1.9.3': + resolution: {integrity: sha512-hGfl/KTic/QY4tB9DkTbNuxy5cV4IeejpPD4zo+Lzt4iLlDWIeANL4Fkg67FiVceNJboqg48CUX+APhDHO5G1w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.9.3': + resolution: {integrity: sha512-IaRq05ZLdtgF5h9CzlcgaNHyg4VXuiStnOFpfNEMuI5fm5afP2S0FHq8WdakUz5WppsbddTdplL+vpeApt/WCQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.9.3': + resolution: {integrity: sha512-Pbwe7xYprj/nEnZrNBvZfjnTxlBIcfApAGdz2EROhjpPj+FBqBa3wOogqbsuGGBdCphf8S+KPprL1z+oDWkmSQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.9.3': + resolution: {integrity: sha512-AQ5JZiwNGVV/2K2TVulg0mw/3LYfqpjZO6jDPtR2evNbk9Yt57YsVzS+3vHSlUBQDRV9/jqMuZYVU3P13xrk+g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.9.3': + resolution: {integrity: sha512-tzVH480RY6RbMl/QRgh5HK3zn1ZTFsThuxDGo6Iuk1MdwIbdFYUY034heWUTI4u3Db97ArKh0hNL0xhO3+PZdg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.9.3': + resolution: {integrity: sha512-ivXXBRDXDc9k4cdv10R21ccBmGebVOwKXT/UdH1PhxUn9m/h8erAWjz5pcELwjiMf27WokqPgaWVfaclDbgE+w==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.9.3': + resolution: {integrity: sha512-ILsGMgfnOz1HwdDz+ZgEuomIwkP1PHT6maigZxaCIuC6OPEhKE8uYna22uU63XvYcLQvZYDzpR3ms47WQPuNEg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.9.3': + resolution: {integrity: sha512-e+XmltDVIHieUnNJHtspn6B+PCcFOMYXNJB1GqoCcyinkEIQNwC8KtWgMqUucUbEWJkPc35NHy9k8aCXRmw9Kg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.9.3': + resolution: {integrity: sha512-rqpzNfpAooSL4UfQnHhkW8aL+oyjqJniDP0qwZfGnjDoJSbtPysHg2LpcOBEdSnEH+uIZq6J96qf0ZFD8AGfXA==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.9.3': + resolution: {integrity: sha512-3YJJLQ5suIEHEKc1GHtqVq475guiyqisKSoUnoaRtxkDaW5g1yvPt9IoSLOe2mRs7+FFhGGU693RsBUSwOXSdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.9.3': + resolution: {integrity: sha512-oRj0AFePUhtatX+BscVhnzaAmWjpfAeySpM1TCbxA1rtBDeH/JDhi5yYzAKneDYtVtBvA7ApfeuzhMC9ye4xSg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '*' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@swc/types@0.1.17': + resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/node@20.17.9': + resolution: {integrity: sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + b4a@1.6.7: + resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} + + bare-events@2.5.0: + resolution: {integrity: sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==} + + bare-fs@2.3.5: + resolution: {integrity: sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==} + + bare-os@2.4.4: + resolution: {integrity: sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==} + + bare-path@2.1.3: + resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} + + bare-stream@2.4.2: + resolution: {integrity: sha512-XZ4ln/KV4KT+PXdIWTKjsLY+quqCaEtqqtgGJVPw9AoM73By03ij64YjepK0aQvHSWDb6AfAZwqKaFu68qkrdA==} + + base-x@3.0.10: + resolution: {integrity: sha512-7d0s06rR9rYaIWHkpfLIFICM/tkSVdoPC9qYAQRpxn9DdKNWNsKC0uk++akckyLq16Tx2WIinnZ6WRriAt6njQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.2: + resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001684: + resolution: {integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + clipboardy@4.0.0: + resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} + engines: {node: '>=18'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.1: + resolution: {integrity: sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw==} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + electron-to-chromium@1.5.67: + resolution: {integrity: sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@3.0.1: + resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} + engines: {node: '>=0.12'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-port-please@3.1.2: + resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} + + get-port@4.2.0: + resolution: {integrity: sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw==} + engines: {node: '>=6'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + h3@1.13.0: + resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + htmlnano@2.1.1: + resolution: {integrity: sha512-kAERyg/LuNZYmdqgCdYvugyLWNFAm8MWXpQMz1pLpetmCbFwoMxvkSoaAMlFrOC4OKTWI4KlZGT/RsNxg4ghOw==} + peerDependencies: + cssnano: ^7.0.0 + postcss: ^8.3.11 + purgecss: ^6.0.0 + relateurl: ^0.2.7 + srcset: 5.0.1 + svgo: ^3.0.2 + terser: ^5.10.0 + uncss: ^0.17.3 + peerDependenciesMeta: + cssnano: + optional: true + postcss: + optional: true + purgecss: + optional: true + relateurl: + optional: true + srcset: + optional: true + svgo: + optional: true + terser: + optional: true + uncss: + optional: true + + htmlparser2@7.2.0: + resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-shutdown@1.2.2: + resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + image-meta@0.2.1: + resolution: {integrity: sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipx@2.1.0: + resolution: {integrity: sha512-AVnPGXJ8L41vjd11Z4akIF2yd14636Klxul3tBySxHA6PKfCOQPxBDkCFK5zcWh0z/keR6toh1eg8qzdBVUgdA==} + hasBin: true + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-json@2.0.1: + resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.4.1: + resolution: {integrity: sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-darwin-arm64@1.28.2: + resolution: {integrity: sha512-/8cPSqZiusHSS+WQz0W4NuaqFjquys1x+NsdN/XOHb+idGHJSoJ7SoQTVl3DZuAgtPZwFZgRfb/vd1oi8uX6+g==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.28.2: + resolution: {integrity: sha512-R7sFrXlgKjvoEG8umpVt/yutjxOL0z8KWf0bfPT3cYMOW4470xu5qSHpFdIOpRWwl3FKNMUdbKtMUjYt0h2j4g==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.28.2: + resolution: {integrity: sha512-l2qrCT+x7crAY+lMIxtgvV10R8VurzHAoUZJaVFSlHrN8kRLTvEg9ObojIDIexqWJQvJcVVV3vfzsEynpiuvgA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.28.2: + resolution: {integrity: sha512-DKMzpICBEKnL53X14rF7hFDu8KKALUJtcKdFUCW5YOlGSiwRSgVoRjM97wUm/E0NMPkzrTi/rxfvt7ruNK8meg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.28.2: + resolution: {integrity: sha512-nhfjYkfymWZSxdtTNMWyhFk2ImUm0X7NAgJWFwnsYPOfmtWQEapzG/DXZTfEfMjSzERNUNJoQjPAbdqgB+sjiw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.28.2: + resolution: {integrity: sha512-1SPG1ZTNnphWvAv8RVOymlZ8BDtAg69Hbo7n4QxARvkFVCJAt0cgjAw1Fox0WEhf4PwnyoOBaVH0Z5YNgzt4dA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.28.2: + resolution: {integrity: sha512-ZhQy0FcO//INWUdo/iEdbefntTdpPVQ0XJwwtdbBuMQe+uxqZoytm9M+iqR9O5noWFaxK+nbS2iR/I80Q2Ofpg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.28.2: + resolution: {integrity: sha512-alb/j1NMrgQmSFyzTbN1/pvMPM+gdDw7YBuQ5VSgcFDypN3Ah0BzC2dTZbzwzaMdUVDszX6zH5MzjfVN1oGuww==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.28.2: + resolution: {integrity: sha512-WnwcjcBeAt0jGdjlgbT9ANf30pF0C/QMb1XnLnH272DQU8QXh+kmpi24R55wmWBwaTtNAETZ+m35ohyeMiNt+g==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.28.2: + resolution: {integrity: sha512-3piBifyT3avz22o6mDKywQC/OisH2yDK+caHWkiMsF82i3m5wDBadyCjlCQ5VNgzYkxrWZgiaxHDdd5uxsi0/A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.28.2: + resolution: {integrity: sha512-ePLRrbt3fgjXI5VFZOLbvkLD5ZRuxGKm+wJ3ujCqBtL3NanDHPo/5zicR5uEKAPiIjBYF99BM4K4okvMznjkVA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + listhen@1.9.0: + resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} + hasBin: true + + lmdb@2.8.5: + resolution: {integrity: sha512-9bMdFfc80S+vSldBmG3HOuLVHnxRdNTlpzR6QDnzqCQtCzGUEAGTzBKYMeIM+I/sU4oZfgbcbS7X7F65/z/oxQ==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mlly@1.7.3: + resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.2: + resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + node-abi@3.71.0: + resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + + node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + ofetch@1.4.1: + resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + + ohash@1.1.4: + resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + ordered-binary@1.5.3: + resolution: {integrity: sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==} + + parcel@2.13.2: + resolution: {integrity: sha512-ROp1Lf6cihWYzdkieXH+KWVkjlqiUMqW18MBMNZQ3sQitnXWGozTgSYIfpUFLQqaHLgBfm5inOwdqmbzExdpYA==} + engines: {node: '>= 16.0.0'} + hasBin: true + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pkg-types@1.2.1: + resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + posthtml-parser@0.11.0: + resolution: {integrity: sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==} + engines: {node: '>=12'} + + posthtml-parser@0.12.1: + resolution: {integrity: sha512-rYFmsDLfYm+4Ts2Oh4DCDSZPtdC1BLnRXAobypVzX9alj28KGl65dIFtgDY9zB57D0TC4Qxqrawuq/2et1P0GA==} + engines: {node: '>=16'} + + posthtml-render@3.0.0: + resolution: {integrity: sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==} + engines: {node: '>=12'} + + posthtml@0.16.6: + resolution: {integrity: sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ==} + engines: {node: '>=12.0.0'} + + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.25.0: + resolution: {integrity: sha512-6bYnzlLxXV3OSpUxLdaxBmE7PMOu0aR3pG6lryK/0jmvcDFPlcXGQAt5DpK3RITWiDrfYZRI0druyaK/S9kYLg==} + + prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} + engines: {node: '>=10'} + hasBin: true + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-error-overlay@6.0.9: + resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + srcset@4.0.0: + resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} + engines: {node: '>=12'} + + std-env@3.8.0: + resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + + streamx@2.20.2: + resolution: {integrity: sha512-aDGDLU+j9tJcUdPGOaHmVF1u/hhI+CsGkT02V3OKlHDV7IukOI+nTWAGkiZEKCO35rWN1wIr4tS7YFr1f4qSvA==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + + tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + + tar-fs@3.0.6: + resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + text-decoder@1.2.1: + resolution: {integrity: sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==} + + timsort@0.3.0: + resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + unenv@1.10.0: + resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + + unstorage@1.13.1: + resolution: {integrity: sha512-ELexQHUrG05QVIM/iUeQNdl9FXDZhqLJ4yP59fnmn2jGUh0TEulwOgov1ubOb3Gt2ZGK/VMchJwPDNVEGWQpRg==} + peerDependencies: + '@azure/app-configuration': ^1.7.0 + '@azure/cosmos': ^4.1.1 + '@azure/data-tables': ^13.2.2 + '@azure/identity': ^4.5.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.25.0 + '@capacitor/preferences': ^6.0.2 + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/kv': ^1.0.1 + idb-keyval: ^6.2.1 + ioredis: ^5.4.1 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/kv': + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + + untun@0.1.3: + resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} + hasBin: true + + update-browserslist-db@1.1.1: + resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + weak-lru-cache@1.2.2: + resolution: {integrity: sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + +snapshots: + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.25.9': {} + + '@fastify/accept-negotiator@1.1.0': {} + + '@lezer/common@1.2.3': {} + + '@lezer/lr@1.4.2': + dependencies: + '@lezer/common': 1.2.3 + + '@lmdb/lmdb-darwin-arm64@2.8.5': + optional: true + + '@lmdb/lmdb-darwin-x64@2.8.5': + optional: true + + '@lmdb/lmdb-linux-arm64@2.8.5': + optional: true + + '@lmdb/lmdb-linux-arm@2.8.5': + optional: true + + '@lmdb/lmdb-linux-x64@2.8.5': + optional: true + + '@lmdb/lmdb-win32-x64@2.8.5': + optional: true + + '@mischnic/json-sourcemap@0.1.1': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/lr': 1.4.2 + json5: 2.2.3 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@netlify/functions@2.8.2': + dependencies: + '@netlify/serverless-functions-api': 1.26.1 + + '@netlify/node-cookies@0.1.0': {} + + '@netlify/serverless-functions-api@1.26.1': + dependencies: + '@netlify/node-cookies': 0.1.0 + urlpattern-polyfill: 8.0.2 + + '@parcel/bundler-default@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/graph': 3.3.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/cache@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/fs': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/logger': 2.13.2 + '@parcel/utils': 2.13.2 + lmdb: 2.8.5 + + '@parcel/codeframe@2.13.2': + dependencies: + chalk: 4.1.2 + + '@parcel/compressor-raw@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/config-default@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15)(svgo@3.3.2)(typescript@5.7.2)': + dependencies: + '@parcel/bundler-default': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/compressor-raw': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/namer-default': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/optimizer-css': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/optimizer-htmlnano': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(svgo@3.3.2)(typescript@5.7.2) + '@parcel/optimizer-image': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/optimizer-svgo': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/optimizer-swc': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15) + '@parcel/packager-css': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/packager-html': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/packager-js': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/packager-raw': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/packager-svg': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/packager-wasm': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/reporter-dev-server': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/resolver-default': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/runtime-browser-hmr': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/runtime-js': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/runtime-react-refresh': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/runtime-service-worker': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-babel': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-css': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-html': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-image': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-js': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-json': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-postcss': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-posthtml': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-raw': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-react-refresh-wrap': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/transformer-svg': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + + '@parcel/core@2.13.2(@swc/helpers@0.5.15)': + dependencies: + '@mischnic/json-sourcemap': 0.1.1 + '@parcel/cache': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/diagnostic': 2.13.2 + '@parcel/events': 2.13.2 + '@parcel/feature-flags': 2.13.2 + '@parcel/fs': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/graph': 3.3.2 + '@parcel/logger': 2.13.2 + '@parcel/package-manager': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15) + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/profiler': 2.13.2 + '@parcel/rust': 2.13.2 + '@parcel/source-map': 2.1.1 + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + base-x: 3.0.10 + browserslist: 4.24.2 + clone: 2.1.2 + dotenv: 16.4.5 + dotenv-expand: 11.0.7 + json5: 2.2.3 + msgpackr: 1.11.2 + nullthrows: 1.1.1 + semver: 7.6.3 + transitivePeerDependencies: + - '@swc/helpers' + + '@parcel/diagnostic@2.13.2': + dependencies: + '@mischnic/json-sourcemap': 0.1.1 + nullthrows: 1.1.1 + + '@parcel/events@2.13.2': {} + + '@parcel/feature-flags@2.13.2': {} + + '@parcel/fs@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/feature-flags': 2.13.2 + '@parcel/rust': 2.13.2 + '@parcel/types-internal': 2.13.2 + '@parcel/utils': 2.13.2 + '@parcel/watcher': 2.5.0 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + + '@parcel/graph@3.3.2': + dependencies: + '@parcel/feature-flags': 2.13.2 + nullthrows: 1.1.1 + + '@parcel/logger@2.13.2': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/events': 2.13.2 + + '@parcel/markdown-ansi@2.13.2': + dependencies: + chalk: 4.1.2 + + '@parcel/namer-default@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/node-resolver-core@3.4.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@mischnic/json-sourcemap': 0.1.1 + '@parcel/diagnostic': 2.13.2 + '@parcel/fs': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + semver: 7.6.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/optimizer-css@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + browserslist: 4.24.2 + lightningcss: 1.28.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/optimizer-htmlnano@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(svgo@3.3.2)(typescript@5.7.2)': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + htmlnano: 2.1.1(svgo@3.3.2)(typescript@5.7.2) + nullthrows: 1.1.1 + posthtml: 0.16.6 + transitivePeerDependencies: + - '@parcel/core' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + + '@parcel/optimizer-image@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/utils': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + + '@parcel/optimizer-svgo@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/optimizer-swc@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15)': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + '@swc/core': 1.9.3(@swc/helpers@0.5.15) + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + - '@swc/helpers' + + '@parcel/package-manager@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15)': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/diagnostic': 2.13.2 + '@parcel/fs': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/logger': 2.13.2 + '@parcel/node-resolver-core': 3.4.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@swc/core': 1.9.3(@swc/helpers@0.5.15) + semver: 7.6.3 + transitivePeerDependencies: + - '@swc/helpers' + + '@parcel/packager-css@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + lightningcss: 1.28.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/packager-html@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + posthtml: 0.16.6 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/packager-js@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/source-map': 2.1.1 + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + globals: 13.24.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/packager-raw@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/packager-svg@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + posthtml: 0.16.6 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/packager-wasm@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/plugin@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/profiler@2.13.2': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/events': 2.13.2 + '@parcel/types-internal': 2.13.2 + chrome-trace-event: 1.0.4 + + '@parcel/reporter-cli@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/types': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + chalk: 4.1.2 + term-size: 2.2.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/reporter-dev-server@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/reporter-tracer@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + chrome-trace-event: 1.0.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/resolver-default@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/node-resolver-core': 3.4.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/runtime-browser-hmr@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/runtime-js@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/runtime-react-refresh@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + react-error-overlay: 6.0.9 + react-refresh: 0.14.2 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/runtime-service-worker@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/rust@2.13.2': {} + + '@parcel/source-map@2.1.1': + dependencies: + detect-libc: 1.0.3 + + '@parcel/transformer-babel@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + browserslist: 4.24.2 + json5: 2.2.3 + nullthrows: 1.1.1 + semver: 7.6.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-css@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + browserslist: 4.24.2 + lightningcss: 1.28.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-html@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + nullthrows: 1.1.1 + posthtml: 0.16.6 + posthtml-parser: 0.12.1 + posthtml-render: 3.0.0 + semver: 7.6.3 + srcset: 4.0.0 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-image@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + nullthrows: 1.1.1 + + '@parcel/transformer-js@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/source-map': 2.1.1 + '@parcel/utils': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@swc/helpers': 0.5.15 + browserslist: 4.24.2 + nullthrows: 1.1.1 + regenerator-runtime: 0.14.1 + semver: 7.6.3 + + '@parcel/transformer-json@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + json5: 2.2.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-postcss@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + '@parcel/utils': 2.13.2 + clone: 2.1.2 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + semver: 7.6.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-posthtml@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + posthtml: 0.16.6 + posthtml-parser: 0.12.1 + posthtml-render: 3.0.0 + semver: 7.6.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-raw@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-react-refresh-wrap@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + react-refresh: 0.14.2 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/transformer-svg@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/plugin': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/rust': 2.13.2 + nullthrows: 1.1.1 + posthtml: 0.16.6 + posthtml-parser: 0.12.1 + posthtml-render: 3.0.0 + semver: 7.6.3 + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/types-internal@2.13.2': + dependencies: + '@parcel/diagnostic': 2.13.2 + '@parcel/feature-flags': 2.13.2 + '@parcel/source-map': 2.1.1 + utility-types: 3.11.0 + + '@parcel/types@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/types-internal': 2.13.2 + '@parcel/workers': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + transitivePeerDependencies: + - '@parcel/core' + + '@parcel/utils@2.13.2': + dependencies: + '@parcel/codeframe': 2.13.2 + '@parcel/diagnostic': 2.13.2 + '@parcel/logger': 2.13.2 + '@parcel/markdown-ansi': 2.13.2 + '@parcel/rust': 2.13.2 + '@parcel/source-map': 2.1.1 + chalk: 4.1.2 + nullthrows: 1.1.1 + + '@parcel/watcher-android-arm64@2.5.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.0': + optional: true + + '@parcel/watcher-darwin-x64@2.5.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.0': + optional: true + + '@parcel/watcher-wasm@2.5.0': + dependencies: + is-glob: 4.0.3 + micromatch: 4.0.8 + + '@parcel/watcher-win32-arm64@2.5.0': + optional: true + + '@parcel/watcher-win32-ia32@2.5.0': + optional: true + + '@parcel/watcher-win32-x64@2.5.0': + optional: true + + '@parcel/watcher@2.5.0': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.0 + '@parcel/watcher-darwin-arm64': 2.5.0 + '@parcel/watcher-darwin-x64': 2.5.0 + '@parcel/watcher-freebsd-x64': 2.5.0 + '@parcel/watcher-linux-arm-glibc': 2.5.0 + '@parcel/watcher-linux-arm-musl': 2.5.0 + '@parcel/watcher-linux-arm64-glibc': 2.5.0 + '@parcel/watcher-linux-arm64-musl': 2.5.0 + '@parcel/watcher-linux-x64-glibc': 2.5.0 + '@parcel/watcher-linux-x64-musl': 2.5.0 + '@parcel/watcher-win32-arm64': 2.5.0 + '@parcel/watcher-win32-ia32': 2.5.0 + '@parcel/watcher-win32-x64': 2.5.0 + + '@parcel/workers@2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))': + dependencies: + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/diagnostic': 2.13.2 + '@parcel/logger': 2.13.2 + '@parcel/profiler': 2.13.2 + '@parcel/types-internal': 2.13.2 + '@parcel/utils': 2.13.2 + nullthrows: 1.1.1 + + '@preact/signals-core@1.8.0': {} + + '@preact/signals@1.3.1(preact@10.25.0)': + dependencies: + '@preact/signals-core': 1.8.0 + preact: 10.25.0 + + '@swc/core-darwin-arm64@1.9.3': + optional: true + + '@swc/core-darwin-x64@1.9.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.9.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.9.3': + optional: true + + '@swc/core-linux-arm64-musl@1.9.3': + optional: true + + '@swc/core-linux-x64-gnu@1.9.3': + optional: true + + '@swc/core-linux-x64-musl@1.9.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.9.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.9.3': + optional: true + + '@swc/core-win32-x64-msvc@1.9.3': + optional: true + + '@swc/core@1.9.3(@swc/helpers@0.5.15)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.17 + optionalDependencies: + '@swc/core-darwin-arm64': 1.9.3 + '@swc/core-darwin-x64': 1.9.3 + '@swc/core-linux-arm-gnueabihf': 1.9.3 + '@swc/core-linux-arm64-gnu': 1.9.3 + '@swc/core-linux-arm64-musl': 1.9.3 + '@swc/core-linux-x64-gnu': 1.9.3 + '@swc/core-linux-x64-musl': 1.9.3 + '@swc/core-win32-arm64-msvc': 1.9.3 + '@swc/core-win32-ia32-msvc': 1.9.3 + '@swc/core-win32-x64-msvc': 1.9.3 + '@swc/helpers': 0.5.15 + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/types@0.1.17': + dependencies: + '@swc/counter': 0.1.3 + + '@trysound/sax@0.2.0': {} + + '@types/node@20.17.9': + dependencies: + undici-types: 6.19.8 + + acorn@8.14.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@2.0.1: {} + + b4a@1.6.7: {} + + bare-events@2.5.0: + optional: true + + bare-fs@2.3.5: + dependencies: + bare-events: 2.5.0 + bare-path: 2.1.3 + bare-stream: 2.4.2 + optional: true + + bare-os@2.4.4: + optional: true + + bare-path@2.1.3: + dependencies: + bare-os: 2.4.4 + optional: true + + bare-stream@2.4.2: + dependencies: + streamx: 2.20.2 + optional: true + + base-x@3.0.10: + dependencies: + safe-buffer: 5.2.1 + + base64-js@1.5.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.2: + dependencies: + caniuse-lite: 1.0.30001684 + electron-to-chromium: 1.5.67 + node-releases: 2.0.18 + update-browserslist-db: 1.1.1(browserslist@4.24.2) + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001684: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + chrome-trace-event@1.0.4: {} + + citty@0.1.6: + dependencies: + consola: 3.2.3 + + clipboardy@4.0.0: + dependencies: + execa: 8.0.1 + is-wsl: 3.1.0 + is64bit: 2.0.0 + + clone@2.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + confbox@0.1.8: {} + + consola@3.2.3: {} + + cookie-es@1.2.2: {} + + cosmiconfig@9.0.0(typescript@5.7.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.1: + dependencies: + uncrypto: 0.1.3 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssfilter@0.0.10: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + defu@6.1.4: {} + + destr@2.0.3: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.3: {} + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.4.5 + + dotenv@16.4.5: {} + + electron-to-chromium@1.5.67: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + entities@2.2.0: {} + + entities@3.0.1: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + escalade@3.2.0: {} + + etag@1.8.1: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + expand-template@2.0.3: {} + + fast-fifo@1.3.2: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-port-please@3.1.2: {} + + get-port@4.2.0: {} + + get-stream@8.0.1: {} + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + h3@1.13.0: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.1 + defu: 6.1.4 + destr: 2.0.3 + iron-webcrypto: 1.2.1 + ohash: 1.1.4 + radix3: 1.1.2 + ufo: 1.5.4 + uncrypto: 0.1.3 + unenv: 1.10.0 + + has-flag@4.0.0: {} + + htmlnano@2.1.1(svgo@3.3.2)(typescript@5.7.2): + dependencies: + cosmiconfig: 9.0.0(typescript@5.7.2) + posthtml: 0.16.6 + timsort: 0.3.0 + optionalDependencies: + svgo: 3.3.2 + transitivePeerDependencies: + - typescript + + htmlparser2@7.2.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 3.0.1 + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + + http-shutdown@1.2.2: {} + + human-signals@5.0.0: {} + + ieee754@1.2.1: {} + + image-meta@0.2.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipx@2.1.0: + dependencies: + '@fastify/accept-negotiator': 1.1.0 + citty: 0.1.6 + consola: 3.2.3 + defu: 6.1.4 + destr: 2.0.3 + etag: 1.8.1 + h3: 1.13.0 + image-meta: 0.2.1 + listhen: 1.9.0 + ofetch: 1.4.1 + pathe: 1.1.2 + sharp: 0.32.6 + svgo: 3.3.2 + ufo: 1.5.4 + unstorage: 1.13.1 + xss: 1.0.15 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/kv' + - idb-keyval + - ioredis + + iron-webcrypto@1.2.1: {} + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.2: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-json@2.0.1: {} + + is-number@7.0.0: {} + + is-stream@3.0.0: {} + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + is64bit@2.0.0: + dependencies: + system-architecture: 0.1.0 + + isexe@2.0.0: {} + + jiti@2.4.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + lightningcss-darwin-arm64@1.28.2: + optional: true + + lightningcss-darwin-x64@1.28.2: + optional: true + + lightningcss-freebsd-x64@1.28.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.28.2: + optional: true + + lightningcss-linux-arm64-gnu@1.28.2: + optional: true + + lightningcss-linux-arm64-musl@1.28.2: + optional: true + + lightningcss-linux-x64-gnu@1.28.2: + optional: true + + lightningcss-linux-x64-musl@1.28.2: + optional: true + + lightningcss-win32-arm64-msvc@1.28.2: + optional: true + + lightningcss-win32-x64-msvc@1.28.2: + optional: true + + lightningcss@1.28.2: + dependencies: + detect-libc: 1.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.28.2 + lightningcss-darwin-x64: 1.28.2 + lightningcss-freebsd-x64: 1.28.2 + lightningcss-linux-arm-gnueabihf: 1.28.2 + lightningcss-linux-arm64-gnu: 1.28.2 + lightningcss-linux-arm64-musl: 1.28.2 + lightningcss-linux-x64-gnu: 1.28.2 + lightningcss-linux-x64-musl: 1.28.2 + lightningcss-win32-arm64-msvc: 1.28.2 + lightningcss-win32-x64-msvc: 1.28.2 + + lines-and-columns@1.2.4: {} + + listhen@1.9.0: + dependencies: + '@parcel/watcher': 2.5.0 + '@parcel/watcher-wasm': 2.5.0 + citty: 0.1.6 + clipboardy: 4.0.0 + consola: 3.2.3 + crossws: 0.3.1 + defu: 6.1.4 + get-port-please: 3.1.2 + h3: 1.13.0 + http-shutdown: 1.2.2 + jiti: 2.4.1 + mlly: 1.7.3 + node-forge: 1.3.1 + pathe: 1.1.2 + std-env: 3.8.0 + ufo: 1.5.4 + untun: 0.1.3 + uqr: 0.1.2 + + lmdb@2.8.5: + dependencies: + msgpackr: 1.11.2 + node-addon-api: 6.1.0 + node-gyp-build-optional-packages: 5.1.1 + ordered-binary: 1.5.3 + weak-lru-cache: 1.2.2 + optionalDependencies: + '@lmdb/lmdb-darwin-arm64': 2.8.5 + '@lmdb/lmdb-darwin-x64': 2.8.5 + '@lmdb/lmdb-linux-arm': 2.8.5 + '@lmdb/lmdb-linux-arm64': 2.8.5 + '@lmdb/lmdb-linux-x64': 2.8.5 + '@lmdb/lmdb-win32-x64': 2.8.5 + + lru-cache@10.4.3: {} + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime@3.0.0: {} + + mimic-fn@4.0.0: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mlly@1.7.3: + dependencies: + acorn: 8.14.0 + pathe: 1.1.2 + pkg-types: 1.2.1 + ufo: 1.5.4 + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.2: + optionalDependencies: + msgpackr-extract: 3.0.3 + + napi-build-utils@1.0.2: {} + + node-abi@3.71.0: + dependencies: + semver: 7.6.3 + + node-addon-api@6.1.0: {} + + node-addon-api@7.1.1: {} + + node-fetch-native@1.6.4: {} + + node-forge@1.3.1: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.0.3 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + + node-releases@2.0.18: {} + + normalize-path@3.0.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + + ofetch@1.4.1: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.4 + ufo: 1.5.4 + + ohash@1.1.4: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + ordered-binary@1.5.3: {} + + parcel@2.13.2(@swc/helpers@0.5.15)(svgo@3.3.2)(typescript@5.7.2): + dependencies: + '@parcel/config-default': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15)(svgo@3.3.2)(typescript@5.7.2) + '@parcel/core': 2.13.2(@swc/helpers@0.5.15) + '@parcel/diagnostic': 2.13.2 + '@parcel/events': 2.13.2 + '@parcel/feature-flags': 2.13.2 + '@parcel/fs': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/logger': 2.13.2 + '@parcel/package-manager': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15))(@swc/helpers@0.5.15) + '@parcel/reporter-cli': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/reporter-dev-server': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/reporter-tracer': 2.13.2(@parcel/core@2.13.2(@swc/helpers@0.5.15)) + '@parcel/utils': 2.13.2 + chalk: 4.1.2 + commander: 12.1.0 + get-port: 4.2.0 + transitivePeerDependencies: + - '@swc/helpers' + - cssnano + - postcss + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pkg-types@1.2.1: + dependencies: + confbox: 0.1.8 + mlly: 1.7.3 + pathe: 1.1.2 + + postcss-value-parser@4.2.0: {} + + posthtml-parser@0.11.0: + dependencies: + htmlparser2: 7.2.0 + + posthtml-parser@0.12.1: + dependencies: + htmlparser2: 9.1.0 + + posthtml-render@3.0.0: + dependencies: + is-json: 2.0.1 + + posthtml@0.16.6: + dependencies: + posthtml-parser: 0.11.0 + posthtml-render: 3.0.0 + + preact-render-to-string@6.5.11(preact@10.25.0): + dependencies: + preact: 10.25.0 + + preact@10.25.0: {} + + prebuild-install@7.1.2: + dependencies: + detect-libc: 2.0.3 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.71.0 + pump: 3.0.2 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + queue-tick@1.0.1: {} + + radix3@1.1.2: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-error-overlay@6.0.9: {} + + react-refresh@0.14.2: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + regenerator-runtime@0.14.1: {} + + resolve-from@4.0.0: {} + + safe-buffer@5.2.1: {} + + semver@7.6.3: {} + + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + node-addon-api: 6.1.0 + prebuild-install: 7.1.2 + semver: 7.6.3 + simple-get: 4.0.1 + tar-fs: 3.0.6 + tunnel-agent: 0.6.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + + source-map-js@1.2.1: {} + + srcset@4.0.0: {} + + std-env@3.8.0: {} + + streamx@2.20.2: + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.2.1 + optionalDependencies: + bare-events: 2.5.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-final-newline@3.0.0: {} + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + + system-architecture@0.1.0: {} + + tar-fs@2.1.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-fs@3.0.6: + dependencies: + pump: 3.0.2 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 2.3.5 + bare-path: 2.1.3 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.6.7 + fast-fifo: 1.3.2 + streamx: 2.20.2 + + term-size@2.2.1: {} + + text-decoder@1.2.1: {} + + timsort@0.3.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-fest@0.20.2: {} + + typescript@5.7.2: {} + + ufo@1.5.4: {} + + uncrypto@0.1.3: {} + + undici-types@6.19.8: {} + + unenv@1.10.0: + dependencies: + consola: 3.2.3 + defu: 6.1.4 + mime: 3.0.0 + node-fetch-native: 1.6.4 + pathe: 1.1.2 + + unstorage@1.13.1: + dependencies: + anymatch: 3.1.3 + chokidar: 3.6.0 + citty: 0.1.6 + destr: 2.0.3 + h3: 1.13.0 + listhen: 1.9.0 + lru-cache: 10.4.3 + node-fetch-native: 1.6.4 + ofetch: 1.4.1 + ufo: 1.5.4 + + untun@0.1.3: + dependencies: + citty: 0.1.6 + consola: 3.2.3 + pathe: 1.1.2 + + update-browserslist-db@1.1.1(browserslist@4.24.2): + dependencies: + browserslist: 4.24.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uqr@0.1.2: {} + + urlpattern-polyfill@8.0.2: {} + + util-deprecate@1.0.2: {} + + utility-types@3.11.0: {} + + weak-lru-cache@1.2.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 diff --git a/demo/src/App.jsx b/demo/src/App.jsx index 4c8212b..fa537e7 100644 --- a/demo/src/App.jsx +++ b/demo/src/App.jsx @@ -1,148 +1,142 @@ import { h } from "preact"; -import { parseUrl, transformUrl } from "../../mod"; +import { parseUrl, transformUrl } from "../../mod.ts"; import { computed, signal } from "@preact/signals"; import "./style.css"; import examples from "./examples.json"; const inputUrl = signal(examples.shopify[1]); const cdn = signal(""); -const recursive = signal(true); - const width = signal(800); const height = signal(600); const url = computed(() => { - return transformUrl({ - url: inputUrl.value, - width: width.value, - height: height.value, - cdn: cdn.value || undefined, - recursive: recursive.value, - }); + try { + return transformUrl({ + url: inputUrl.value, + width: Number(width.value), + height: Number(height.value), + provider: cdn.value || undefined, + }); + } catch (e) { + console.error(e); + return null; + } }); -const parsedUrl = computed(() => - JSON.stringify(parseUrl(inputUrl.value), null, 2) -); +const parsedUrl = computed(() => { + try { + return JSON.stringify(parseUrl(inputUrl.value), null, 2); + } catch (e) { + return "Invalid URL"; + } +}); const code = computed(() => - /* javascript */ `const url = transformUrl({ + /* javascript */ `const url = transformUrl({ url: ${JSON.stringify(inputUrl.value)}, width: ${width}, - height: ${height}, + height: ${height} });` ); export default function App() { - return ( -
-

🖼 Unpic

-

- Enter an image URL below, or choose from one of the examples -

-
-
- - -
-
- - -
-
- - recursive.value = e.target.checked} - /> -
-
- - inputUrl.value = e.target.value} - /> -
-
- - width.value = e.target.value} - /> -
-
- - height.value = e.target.value} - /> -
-
- - -
-
- Show details -
-
-
Result
-
{parsedUrl}
-
-
-
Code
- -
{code}
-
-
-
-
+ return ( +
+

🖼 Unpic

+

+ Enter an image URL below, or choose from one of the examples +

+
+
+ + +
+
+ + +
+
+ + inputUrl.value = e.target.value} + /> +
+
+ + width.value = e.target.value} + /> +
+
+ + height.value = e.target.value} + /> +
+
+ + +
+
+ Show details +
+
+
Result
+
{parsedUrl}
+
+
+
Code
+
{code}
+
+
+
+
-
- {url.value - ? - :

Invalid URL

} -
-
- ); +
+ {url.value + ? + :

Invalid URL

} +
+
+ ); } diff --git a/demo/src/examples.json b/demo/src/examples.json index fc865e6..3c72a8c 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -41,7 +41,7 @@ ], "nextjs": [ "Next.js", - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto-1674255909399-9bcb2cab6489%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75" + "https://image-component.nextjs.gallery/_next/image?url=%252F_next%252Fstatic%252Fmedia%252Fmountains.a2eb1d50.jpg" ], "scene7": [ "Adobe Dynamic Media (Scene7)", @@ -84,6 +84,10 @@ "Supabase", "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/object/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg" ], + "vercel": [ + "Vercel", + "https://build-output-api-image-optimization.vercel.sh/_vercel/image?url=%2Fimages%2Frio.jpeg" + ], "hygraph": [ "Hygraph", "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/cm2tr64fx7gvu07n85chjmuno" diff --git a/demo/src/index.html b/demo/src/index.html index 919109f..de61f84 100644 --- a/demo/src/index.html +++ b/demo/src/index.html @@ -1,14 +1,14 @@ - - - Unpic - - - - - -
- - + + + Unpic + + + + + +
+ + diff --git a/demo/src/manifest.json b/demo/src/manifest.json index d03c45e..e752f19 100644 --- a/demo/src/manifest.json +++ b/demo/src/manifest.json @@ -1,16 +1,16 @@ { - "name": "demo", - "short_name": "demo", - "start_url": "/", - "display": "standalone", - "orientation": "portrait", - "background_color": "#fff", - "theme_color": "#673ab8", - "icons": [ - { - "src": "/assets/icon.png", - "type": "image/png", - "sizes": "512x512" - } - ] + "name": "demo", + "short_name": "demo", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#fff", + "theme_color": "#673ab8", + "icons": [ + { + "src": "/assets/icon.png", + "type": "image/png", + "sizes": "512x512" + } + ] } diff --git a/demo/src/style.css b/demo/src/style.css index 7173929..9fef57e 100644 --- a/demo/src/style.css +++ b/demo/src/style.css @@ -1,129 +1,129 @@ * { - box-sizing: border-box; + box-sizing: border-box; } html, body { - font: 16px/1.21 "Helvetica Neue", arial, sans-serif; - font-weight: 400; + font: 16px/1.21 "Helvetica Neue", arial, sans-serif; + font-weight: 400; } body { - background: linear-gradient(180deg, white 0%, rgba(216, 216, 237, 1) 81%) - no-repeat center center fixed; - background-size: cover; + background: linear-gradient(180deg, white 0%, rgba(216, 216, 237, 1) 81%) + no-repeat center center fixed; + background-size: cover; } h1, .instructions { - text-align: center; + text-align: center; } .example { - grid-area: 1 / 1 / 1 / 3; + grid-area: 1 / 1 / 1 / 3; } .result { - grid-area: 3 / 1 / 4 / 5; + grid-area: 3 / 1 / 4 / 5; } .result input { - font-size: 14px; - color: #666; + font-size: 14px; + color: #666; } details { - grid-area: 4 / 1 / 4 / 4; + grid-area: 4 / 1 / 4 / 4; } .tools { - background-color: white; - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-column-gap: 10px; - grid-row-gap: 15px; - max-width: 800px; - margin: 2em auto; - padding: 1.5em; - box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); - border-radius: 10px; + background-color: white; + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-column-gap: 10px; + grid-row-gap: 15px; + max-width: 800px; + margin: 2em auto; + padding: 1.5em; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); + border-radius: 10px; } .tools > div, details { - justify-content: stretch; - display: grid; + justify-content: stretch; + display: grid; } label { - display: block; + display: block; } label, summary { - text-transform: uppercase; - font-size: 12px; - font-weight: 700; - margin-bottom: 5px; + text-transform: uppercase; + font-size: 12px; + font-weight: 700; + margin-bottom: 5px; } .url { - grid-area: 1 / 3 / 2 / 5; + grid-area: 1 / 3 / 2 / 5; } .imagePanel { - display: grid; - place-items: center; + display: grid; + place-items: center; } .imagePanel img { - object-fit: cover; + object-fit: cover; } .code { - overflow: auto; + overflow: auto; } input, select { - font-size: 18px; - padding: 5px 10px; - border: 2px #898 solid; - border-radius: 2px; - justify-self: stretch; + font-size: 18px; + padding: 5px 10px; + border: 2px #898 solid; + border-radius: 2px; + justify-self: stretch; } summary { - cursor: pointer; + cursor: pointer; } details { - font-size: 14px; + font-size: 14px; } @media (max-width: 850px) { - .tools { - grid-template-columns: 2fr; - margin: 0; - box-shadow: none; - padding: 5px; - } + .tools { + grid-template-columns: 2fr; + margin: 0; + box-shadow: none; + padding: 5px; + } - .tools > * { - grid-area: auto; - } + .tools > * { + grid-area: auto; + } - .tools > div, - details { - padding: 0; - } + .tools > div, + details { + padding: 0; + } - details { - grid-area: auto; - overflow: auto; - font-size: 12px; - } + details { + grid-area: auto; + overflow: auto; + font-size: 12px; + } } img { - border: 1px solid #ccc; + border: 1px solid #ccc; } diff --git a/demo/src/template.html b/demo/src/template.html index cc3d143..e6a54b6 100644 --- a/demo/src/template.html +++ b/demo/src/template.html @@ -1,18 +1,18 @@ - - - <% preact.title %> - - - - - <% preact.headEnd %> - - - <% preact.bodyEnd %> - + + + <% preact.title %> + + + + + <% preact.headEnd %> + + + <% preact.bodyEnd %> + diff --git a/demo/yarn.lock b/demo/yarn.lock deleted file mode 100644 index 847f7e2..0000000 --- a/demo/yarn.lock +++ /dev/null @@ -1,2632 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@^7.0.0": - version "7.18.6" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/helper-validator-identifier@^7.18.6": - version "7.19.1" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@fastify/accept-negotiator@^1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz" - integrity sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ== - -"@ioredis/commands@^1.1.1": - version "1.2.0" - resolved "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz" - integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== - -"@lezer/common@^0.15.0", "@lezer/common@^0.15.7": - version "0.15.12" - resolved "https://registry.npmjs.org/@lezer/common/-/common-0.15.12.tgz" - integrity sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig== - -"@lezer/lr@^0.15.4": - version "0.15.8" - resolved "https://registry.npmjs.org/@lezer/lr/-/lr-0.15.8.tgz" - integrity sha512-bM6oE6VQZ6hIFxDNKk8bKPa14hqFrV07J/vHGOeiAbJReIaQXmkVb6xQu4MR+JBTLa5arGRyAAjJe1qaQt3Uvg== - dependencies: - "@lezer/common" "^0.15.0" - -"@lmdb/lmdb-darwin-arm64@2.8.5": - version "2.8.5" - resolved "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-2.8.5.tgz" - integrity sha512-KPDeVScZgA1oq0CiPBcOa3kHIqU+pTOwRFDIhxvmf8CTNvqdZQYp5cCKW0bUk69VygB2PuTiINFWbY78aR2pQw== - -"@lmdb/lmdb-darwin-x64@2.8.5": - version "2.8.5" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-2.8.5.tgz#ca243534c8b37d5516c557e4624256d18dd63184" - integrity sha512-w/sLhN4T7MW1nB3R/U8WK5BgQLz904wh+/SmA2jD8NnF7BLLoUgflCNxOeSPOWp8geP6nP/+VjWzZVip7rZ1ug== - -"@lmdb/lmdb-linux-arm64@2.8.5": - version "2.8.5" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-2.8.5.tgz#b44a8023057e21512eefb9f6120096843b531c1e" - integrity sha512-vtbZRHH5UDlL01TT5jB576Zox3+hdyogvpcbvVJlmU5PdL3c5V7cj1EODdh1CHPksRl+cws/58ugEHi8bcj4Ww== - -"@lmdb/lmdb-linux-arm@2.8.5": - version "2.8.5" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-2.8.5.tgz#17bd54740779c3e4324e78e8f747c21416a84b3d" - integrity sha512-c0TGMbm2M55pwTDIfkDLB6BpIsgxV4PjYck2HiOX+cy/JWiBXz32lYbarPqejKs9Flm7YVAKSILUducU9g2RVg== - -"@lmdb/lmdb-linux-x64@2.8.5": - version "2.8.5" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-2.8.5.tgz#6c61835b6cc58efdf79dbd5e8c72a38300a90302" - integrity sha512-Xkc8IUx9aEhP0zvgeKy7IQ3ReX2N8N1L0WPcQwnZweWmOuKfwpS3GRIYqLtK5za/w3E60zhFfNdS+3pBZPytqQ== - -"@lmdb/lmdb-win32-x64@2.8.5": - version "2.8.5" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-2.8.5.tgz#8233e8762440b0f4632c47a09b1b6f23de8b934c" - integrity sha512-4wvrf5BgnR8RpogHhtpCPJMKBmvyZPhhUtEwMJbXh0ni2BucpfF07jlmyM11zRqQ2XIq6PbC2j7W7UCCcm1rRQ== - -"@mischnic/json-sourcemap@^0.1.0": - version "0.1.0" - resolved "https://registry.npmjs.org/@mischnic/json-sourcemap/-/json-sourcemap-0.1.0.tgz" - integrity sha512-dQb3QnfNqmQNYA4nFSN/uLaByIic58gOXq4Y4XqLOWmOrw73KmJPt/HLyG0wvn1bnR6mBKs/Uwvkh+Hns1T0XA== - dependencies: - "@lezer/common" "^0.15.7" - "@lezer/lr" "^0.15.4" - json5 "^2.2.1" - -"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz" - integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== - -"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" - integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== - -"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" - integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== - -"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" - integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== - -"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" - integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== - -"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" - integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== - -"@netlify/functions@^2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@netlify/functions/-/functions-2.3.0.tgz" - integrity sha512-E3kzXPWMP/r1rAWhjTaXcaOT47dhEvg/eQUJjRLhD9Zzp0WqkdynHr+bqff4rFNv6tuXrtFZrpbPJFKHH0c0zw== - dependencies: - "@netlify/serverless-functions-api" "1.9.0" - is-promise "^4.0.0" - -"@netlify/node-cookies@^0.1.0": - version "0.1.0" - resolved "https://registry.npmjs.org/@netlify/node-cookies/-/node-cookies-0.1.0.tgz" - integrity sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g== - -"@netlify/serverless-functions-api@1.9.0": - version "1.9.0" - resolved "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.9.0.tgz" - integrity sha512-Jq4uk1Mwa5vyxImupJYXPP+I5yYcp3PtguvXtJRutKdm9DPALXfZVtCQzBWMNdZiqVWCM3La9hvaBsPjSMfeug== - dependencies: - "@netlify/node-cookies" "^0.1.0" - urlpattern-polyfill "8.0.2" - -"@parcel/bundler-default@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/bundler-default/-/bundler-default-2.12.0.tgz" - integrity sha512-3ybN74oYNMKyjD6V20c9Gerdbh7teeNvVMwIoHIQMzuIFT6IGX53PyOLlOKRLbjxMc0TMimQQxIt2eQqxR5LsA== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/graph" "3.2.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - -"@parcel/cache@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/cache/-/cache-2.12.0.tgz" - integrity sha512-FX5ZpTEkxvq/yvWklRHDESVRz+c7sLTXgFuzz6uEnBcXV38j6dMSikflNpHA6q/L4GKkCqRywm9R6XQwhwIMyw== - dependencies: - "@parcel/fs" "2.12.0" - "@parcel/logger" "2.12.0" - "@parcel/utils" "2.12.0" - lmdb "2.8.5" - -"@parcel/codeframe@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/codeframe/-/codeframe-2.12.0.tgz" - integrity sha512-v2VmneILFiHZJTxPiR7GEF1wey1/IXPdZMcUlNXBiPZyWDfcuNgGGVQkx/xW561rULLIvDPharOMdxz5oHOKQg== - dependencies: - chalk "^4.1.0" - -"@parcel/compressor-raw@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/compressor-raw/-/compressor-raw-2.12.0.tgz" - integrity sha512-h41Q3X7ZAQ9wbQ2csP8QGrwepasLZdXiuEdpUryDce6rF9ZiHoJ97MRpdLxOhOPyASTw/xDgE1xyaPQr0Q3f5A== - dependencies: - "@parcel/plugin" "2.12.0" - -"@parcel/config-default@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/config-default/-/config-default-2.12.0.tgz" - integrity sha512-dPNe2n9eEsKRc1soWIY0yToMUPirPIa2QhxcCB3Z5RjpDGIXm0pds+BaiqY6uGLEEzsjhRO0ujd4v2Rmm0vuFg== - dependencies: - "@parcel/bundler-default" "2.12.0" - "@parcel/compressor-raw" "2.12.0" - "@parcel/namer-default" "2.12.0" - "@parcel/optimizer-css" "2.12.0" - "@parcel/optimizer-htmlnano" "2.12.0" - "@parcel/optimizer-image" "2.12.0" - "@parcel/optimizer-svgo" "2.12.0" - "@parcel/optimizer-swc" "2.12.0" - "@parcel/packager-css" "2.12.0" - "@parcel/packager-html" "2.12.0" - "@parcel/packager-js" "2.12.0" - "@parcel/packager-raw" "2.12.0" - "@parcel/packager-svg" "2.12.0" - "@parcel/packager-wasm" "2.12.0" - "@parcel/reporter-dev-server" "2.12.0" - "@parcel/resolver-default" "2.12.0" - "@parcel/runtime-browser-hmr" "2.12.0" - "@parcel/runtime-js" "2.12.0" - "@parcel/runtime-react-refresh" "2.12.0" - "@parcel/runtime-service-worker" "2.12.0" - "@parcel/transformer-babel" "2.12.0" - "@parcel/transformer-css" "2.12.0" - "@parcel/transformer-html" "2.12.0" - "@parcel/transformer-image" "2.12.0" - "@parcel/transformer-js" "2.12.0" - "@parcel/transformer-json" "2.12.0" - "@parcel/transformer-postcss" "2.12.0" - "@parcel/transformer-posthtml" "2.12.0" - "@parcel/transformer-raw" "2.12.0" - "@parcel/transformer-react-refresh-wrap" "2.12.0" - "@parcel/transformer-svg" "2.12.0" - -"@parcel/core@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/core/-/core-2.12.0.tgz" - integrity sha512-s+6pwEj+GfKf7vqGUzN9iSEPueUssCCQrCBUlcAfKrJe0a22hTUCjewpB0I7lNrCIULt8dkndD+sMdOrXsRl6Q== - dependencies: - "@mischnic/json-sourcemap" "^0.1.0" - "@parcel/cache" "2.12.0" - "@parcel/diagnostic" "2.12.0" - "@parcel/events" "2.12.0" - "@parcel/fs" "2.12.0" - "@parcel/graph" "3.2.0" - "@parcel/logger" "2.12.0" - "@parcel/package-manager" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/profiler" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - "@parcel/workers" "2.12.0" - abortcontroller-polyfill "^1.1.9" - base-x "^3.0.8" - browserslist "^4.6.6" - clone "^2.1.1" - dotenv "^7.0.0" - dotenv-expand "^5.1.0" - json5 "^2.2.0" - msgpackr "^1.9.9" - nullthrows "^1.1.1" - semver "^7.5.2" - -"@parcel/diagnostic@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/diagnostic/-/diagnostic-2.12.0.tgz" - integrity sha512-8f1NOsSFK+F4AwFCKynyIu9Kr/uWHC+SywAv4oS6Bv3Acig0gtwUjugk0C9UaB8ztBZiW5TQZhw+uPZn9T/lJA== - dependencies: - "@mischnic/json-sourcemap" "^0.1.0" - nullthrows "^1.1.1" - -"@parcel/events@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/events/-/events-2.12.0.tgz" - integrity sha512-nmAAEIKLjW1kB2cUbCYSmZOGbnGj8wCzhqnK727zCCWaA25ogzAtt657GPOeFyqW77KyosU728Tl63Fc8hphIA== - -"@parcel/fs@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/fs/-/fs-2.12.0.tgz" - integrity sha512-NnFkuvou1YBtPOhTdZr44WN7I60cGyly2wpHzqRl62yhObyi1KvW0SjwOMa0QGNcBOIzp4G0CapoZ93hD0RG5Q== - dependencies: - "@parcel/rust" "2.12.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - "@parcel/watcher" "^2.0.7" - "@parcel/workers" "2.12.0" - -"@parcel/graph@3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@parcel/graph/-/graph-3.2.0.tgz" - integrity sha512-xlrmCPqy58D4Fg5umV7bpwDx5Vyt7MlnQPxW68vae5+BA4GSWetfZt+Cs5dtotMG2oCHzZxhIPt7YZ7NRyQzLA== - dependencies: - nullthrows "^1.1.1" - -"@parcel/logger@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/logger/-/logger-2.12.0.tgz" - integrity sha512-cJ7Paqa7/9VJ7C+KwgJlwMqTQBOjjn71FbKk0G07hydUEBISU2aDfmc/52o60ErL9l+vXB26zTrIBanbxS8rVg== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/events" "2.12.0" - -"@parcel/markdown-ansi@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/markdown-ansi/-/markdown-ansi-2.12.0.tgz" - integrity sha512-WZz3rzL8k0H3WR4qTHX6Ic8DlEs17keO9gtD4MNGyMNQbqQEvQ61lWJaIH0nAtgEetu0SOITiVqdZrb8zx/M7w== - dependencies: - chalk "^4.1.0" - -"@parcel/namer-default@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/namer-default/-/namer-default-2.12.0.tgz" - integrity sha512-9DNKPDHWgMnMtqqZIMiEj/R9PNWW16lpnlHjwK3ciRlMPgjPJ8+UNc255teZODhX0T17GOzPdGbU/O/xbxVPzA== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - nullthrows "^1.1.1" - -"@parcel/node-resolver-core@3.3.0": - version "3.3.0" - resolved "https://registry.npmjs.org/@parcel/node-resolver-core/-/node-resolver-core-3.3.0.tgz" - integrity sha512-rhPW9DYPEIqQBSlYzz3S0AjXxjN6Ub2yS6tzzsW/4S3Gpsgk/uEq4ZfxPvoPf/6TgZndVxmKwpmxaKtGMmf3cA== - dependencies: - "@mischnic/json-sourcemap" "^0.1.0" - "@parcel/diagnostic" "2.12.0" - "@parcel/fs" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - semver "^7.5.2" - -"@parcel/optimizer-css@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/optimizer-css/-/optimizer-css-2.12.0.tgz" - integrity sha512-ifbcC97fRzpruTjaa8axIFeX4MjjSIlQfem3EJug3L2AVqQUXnM1XO8L0NaXGNLTW2qnh1ZjIJ7vXT/QhsphsA== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - browserslist "^4.6.6" - lightningcss "^1.22.1" - nullthrows "^1.1.1" - -"@parcel/optimizer-htmlnano@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/optimizer-htmlnano/-/optimizer-htmlnano-2.12.0.tgz" - integrity sha512-MfPMeCrT8FYiOrpFHVR+NcZQlXAptK2r4nGJjfT+ndPBhEEZp4yyL7n1y7HfX9geg5altc4WTb4Gug7rCoW8VQ== - dependencies: - "@parcel/plugin" "2.12.0" - htmlnano "^2.0.0" - nullthrows "^1.1.1" - posthtml "^0.16.5" - svgo "^2.4.0" - -"@parcel/optimizer-image@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/optimizer-image/-/optimizer-image-2.12.0.tgz" - integrity sha512-bo1O7raeAIbRU5nmNVtx8divLW9Xqn0c57GVNGeAK4mygnQoqHqRZ0mR9uboh64pxv6ijXZHPhKvU9HEpjPjBQ== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/utils" "2.12.0" - "@parcel/workers" "2.12.0" - -"@parcel/optimizer-svgo@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/optimizer-svgo/-/optimizer-svgo-2.12.0.tgz" - integrity sha512-Kyli+ZZXnoonnbeRQdoWwee9Bk2jm/49xvnfb+2OO8NN0d41lblBoRhOyFiScRnJrw7eVl1Xrz7NTkXCIO7XFQ== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - svgo "^2.4.0" - -"@parcel/optimizer-swc@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/optimizer-swc/-/optimizer-swc-2.12.0.tgz" - integrity sha512-iBi6LZB3lm6WmbXfzi8J3DCVPmn4FN2lw7DGXxUXu7MouDPVWfTsM6U/5TkSHJRNRogZ2gqy5q9g34NPxHbJcw== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - "@swc/core" "^1.3.36" - nullthrows "^1.1.1" - -"@parcel/package-manager@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/package-manager/-/package-manager-2.12.0.tgz" - integrity sha512-0nvAezcjPx9FT+hIL+LS1jb0aohwLZXct7jAh7i0MLMtehOi0z1Sau+QpgMlA9rfEZZ1LIeFdnZZwqSy7Ccspw== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/fs" "2.12.0" - "@parcel/logger" "2.12.0" - "@parcel/node-resolver-core" "3.3.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - "@parcel/workers" "2.12.0" - "@swc/core" "^1.3.36" - semver "^7.5.2" - -"@parcel/packager-css@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-css/-/packager-css-2.12.0.tgz" - integrity sha512-j3a/ODciaNKD19IYdWJT+TP+tnhhn5koBGBWWtrKSu0UxWpnezIGZetit3eE+Y9+NTePalMkvpIlit2eDhvfJA== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - lightningcss "^1.22.1" - nullthrows "^1.1.1" - -"@parcel/packager-html@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-html/-/packager-html-2.12.0.tgz" - integrity sha512-PpvGB9hFFe+19NXGz2ApvPrkA9GwEqaDAninT+3pJD57OVBaxB8U+HN4a5LICKxjUppPPqmrLb6YPbD65IX4RA== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - posthtml "^0.16.5" - -"@parcel/packager-js@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-js/-/packager-js-2.12.0.tgz" - integrity sha512-viMF+FszITRRr8+2iJyk+4ruGiL27Y6AF7hQ3xbJfzqnmbOhGFtLTQwuwhOLqN/mWR2VKdgbLpZSarWaO3yAMg== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - globals "^13.2.0" - nullthrows "^1.1.1" - -"@parcel/packager-raw@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-raw/-/packager-raw-2.12.0.tgz" - integrity sha512-tJZqFbHqP24aq1F+OojFbQIc09P/u8HAW5xfndCrFnXpW4wTgM3p03P0xfw3gnNq+TtxHJ8c3UFE5LnXNNKhYA== - dependencies: - "@parcel/plugin" "2.12.0" - -"@parcel/packager-svg@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-svg/-/packager-svg-2.12.0.tgz" - integrity sha512-ldaGiacGb2lLqcXas97k8JiZRbAnNREmcvoY2W2dvW4loVuDT9B9fU777mbV6zODpcgcHWsLL3lYbJ5Lt3y9cg== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - posthtml "^0.16.4" - -"@parcel/packager-wasm@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/packager-wasm/-/packager-wasm-2.12.0.tgz" - integrity sha512-fYqZzIqO9fGYveeImzF8ll6KRo2LrOXfD+2Y5U3BiX/wp9wv17dz50QLDQm9hmTcKGWxK4yWqKQh+Evp/fae7A== - dependencies: - "@parcel/plugin" "2.12.0" - -"@parcel/plugin@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/plugin/-/plugin-2.12.0.tgz" - integrity sha512-nc/uRA8DiMoe4neBbzV6kDndh/58a4wQuGKw5oEoIwBCHUvE2W8ZFSu7ollSXUGRzfacTt4NdY8TwS73ScWZ+g== - dependencies: - "@parcel/types" "2.12.0" - -"@parcel/profiler@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/profiler/-/profiler-2.12.0.tgz" - integrity sha512-q53fvl5LDcFYzMUtSusUBZSjQrKjMlLEBgKeQHFwkimwR1mgoseaDBDuNz0XvmzDzF1UelJ02TUKCGacU8W2qA== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/events" "2.12.0" - chrome-trace-event "^1.0.2" - -"@parcel/reporter-cli@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/reporter-cli/-/reporter-cli-2.12.0.tgz" - integrity sha512-TqKsH4GVOLPSCanZ6tcTPj+rdVHERnt5y4bwTM82cajM21bCX1Ruwp8xOKU+03091oV2pv5ieB18pJyRF7IpIw== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - chalk "^4.1.0" - term-size "^2.2.1" - -"@parcel/reporter-dev-server@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/reporter-dev-server/-/reporter-dev-server-2.12.0.tgz" - integrity sha512-tIcDqRvAPAttRlTV28dHcbWT5K2r/MBFks7nM4nrEDHWtnrCwimkDmZTc1kD8QOCCjGVwRHcQybpHvxfwol6GA== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - -"@parcel/reporter-tracer@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/reporter-tracer/-/reporter-tracer-2.12.0.tgz" - integrity sha512-g8rlu9GxB8Ut/F8WGx4zidIPQ4pcYFjU9bZO+fyRIPrSUFH2bKijCnbZcr4ntqzDGx74hwD6cCG4DBoleq2UlQ== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - chrome-trace-event "^1.0.3" - nullthrows "^1.1.1" - -"@parcel/resolver-default@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/resolver-default/-/resolver-default-2.12.0.tgz" - integrity sha512-uuhbajTax37TwCxu7V98JtRLiT6hzE4VYSu5B7Qkauy14/WFt2dz6GOUXPgVsED569/hkxebPx3KCMtZW6cHHA== - dependencies: - "@parcel/node-resolver-core" "3.3.0" - "@parcel/plugin" "2.12.0" - -"@parcel/runtime-browser-hmr@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/runtime-browser-hmr/-/runtime-browser-hmr-2.12.0.tgz" - integrity sha512-4ZLp2FWyD32r0GlTulO3+jxgsA3oO1P1b5oO2IWuWilfhcJH5LTiazpL5YdusUjtNn9PGN6QLAWfxmzRIfM+Ow== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - -"@parcel/runtime-js@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/runtime-js/-/runtime-js-2.12.0.tgz" - integrity sha512-sBerP32Z1crX5PfLNGDSXSdqzlllM++GVnVQVeM7DgMKS8JIFG3VLi28YkX+dYYGtPypm01JoIHCkvwiZEcQJg== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - -"@parcel/runtime-react-refresh@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/runtime-react-refresh/-/runtime-react-refresh-2.12.0.tgz" - integrity sha512-SCHkcczJIDFTFdLTzrHTkQ0aTrX3xH6jrA4UsCBL6ji61+w+ohy4jEEe9qCgJVXhnJfGLE43HNXek+0MStX+Mw== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - react-error-overlay "6.0.9" - react-refresh "^0.9.0" - -"@parcel/runtime-service-worker@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/runtime-service-worker/-/runtime-service-worker-2.12.0.tgz" - integrity sha512-BXuMBsfiwpIEnssn+jqfC3jkgbS8oxeo3C7xhSQsuSv+AF2FwY3O3AO1c1RBskEW3XrBLNINOJujroNw80VTKA== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - -"@parcel/rust@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/rust/-/rust-2.12.0.tgz" - integrity sha512-005cldMdFZFDPOjbDVEXcINQ3wT4vrxvSavRWI3Az0e3E18exO/x/mW9f648KtXugOXMAqCEqhFHcXECL9nmMw== - -"@parcel/source-map@^2.1.1": - version "2.1.1" - resolved "https://registry.npmjs.org/@parcel/source-map/-/source-map-2.1.1.tgz" - integrity sha512-Ejx1P/mj+kMjQb8/y5XxDUn4reGdr+WyKYloBljpppUy8gs42T+BNoEOuRYqDVdgPc6NxduzIDoJS9pOFfV5Ew== - dependencies: - detect-libc "^1.0.3" - -"@parcel/transformer-babel@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-babel/-/transformer-babel-2.12.0.tgz" - integrity sha512-zQaBfOnf/l8rPxYGnsk/ufh/0EuqvmnxafjBIpKZ//j6rGylw5JCqXSb1QvvAqRYruKeccxGv7+HrxpqKU6V4A== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - browserslist "^4.6.6" - json5 "^2.2.0" - nullthrows "^1.1.1" - semver "^7.5.2" - -"@parcel/transformer-css@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-css/-/transformer-css-2.12.0.tgz" - integrity sha512-vXhOqoAlQGATYyQ433Z1DXKmiKmzOAUmKysbYH3FD+LKEKLMEl/pA14goqp00TW+A/EjtSKKyeMyHlMIIUqj4Q== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - browserslist "^4.6.6" - lightningcss "^1.22.1" - nullthrows "^1.1.1" - -"@parcel/transformer-html@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-html/-/transformer-html-2.12.0.tgz" - integrity sha512-5jW4dFFBlYBvIQk4nrH62rfA/G/KzVzEDa6S+Nne0xXhglLjkm64Ci9b/d4tKZfuGWUbpm2ASAq8skti/nfpXw== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - nullthrows "^1.1.1" - posthtml "^0.16.5" - posthtml-parser "^0.10.1" - posthtml-render "^3.0.0" - semver "^7.5.2" - srcset "4" - -"@parcel/transformer-image@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-image/-/transformer-image-2.12.0.tgz" - integrity sha512-8hXrGm2IRII49R7lZ0RpmNk27EhcsH+uNKsvxuMpXPuEnWgC/ha/IrjaI29xCng1uGur74bJF43NUSQhR4aTdw== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - "@parcel/workers" "2.12.0" - nullthrows "^1.1.1" - -"@parcel/transformer-js@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-js/-/transformer-js-2.12.0.tgz" - integrity sha512-OSZpOu+FGDbC/xivu24v092D9w6EGytB3vidwbdiJ2FaPgfV7rxS0WIUjH4I0OcvHAcitArRXL0a3+HrNTdQQw== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/utils" "2.12.0" - "@parcel/workers" "2.12.0" - "@swc/helpers" "^0.5.0" - browserslist "^4.6.6" - nullthrows "^1.1.1" - regenerator-runtime "^0.13.7" - semver "^7.5.2" - -"@parcel/transformer-json@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-json/-/transformer-json-2.12.0.tgz" - integrity sha512-Utv64GLRCQILK5r0KFs4o7I41ixMPllwOLOhkdjJKvf1hZmN6WqfOmB1YLbWS/y5Zb/iB52DU2pWZm96vLFQZQ== - dependencies: - "@parcel/plugin" "2.12.0" - json5 "^2.2.0" - -"@parcel/transformer-postcss@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-postcss/-/transformer-postcss-2.12.0.tgz" - integrity sha512-FZqn+oUtiLfPOn67EZxPpBkfdFiTnF4iwiXPqvst3XI8H+iC+yNgzmtJkunOOuylpYY6NOU5jT8d7saqWSDv2Q== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/utils" "2.12.0" - clone "^2.1.1" - nullthrows "^1.1.1" - postcss-value-parser "^4.2.0" - semver "^7.5.2" - -"@parcel/transformer-posthtml@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-posthtml/-/transformer-posthtml-2.12.0.tgz" - integrity sha512-z6Z7rav/pcaWdeD+2sDUcd0mmNZRUvtHaUGa50Y2mr+poxrKilpsnFMSiWBT+oOqPt7j71jzDvrdnAF4XkCljg== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - posthtml "^0.16.5" - posthtml-parser "^0.10.1" - posthtml-render "^3.0.0" - semver "^7.5.2" - -"@parcel/transformer-raw@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-raw/-/transformer-raw-2.12.0.tgz" - integrity sha512-Ht1fQvXxix0NncdnmnXZsa6hra20RXYh1VqhBYZLsDfkvGGFnXIgO03Jqn4Z8MkKoa0tiNbDhpKIeTjyclbBxQ== - dependencies: - "@parcel/plugin" "2.12.0" - -"@parcel/transformer-react-refresh-wrap@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-react-refresh-wrap/-/transformer-react-refresh-wrap-2.12.0.tgz" - integrity sha512-GE8gmP2AZtkpBIV5vSCVhewgOFRhqwdM5Q9jNPOY5PKcM3/Ff0qCqDiTzzGLhk0/VMBrdjssrfZkVx6S/lHdJw== - dependencies: - "@parcel/plugin" "2.12.0" - "@parcel/utils" "2.12.0" - react-refresh "^0.9.0" - -"@parcel/transformer-svg@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/transformer-svg/-/transformer-svg-2.12.0.tgz" - integrity sha512-cZJqGRJ4JNdYcb+vj94J7PdOuTnwyy45dM9xqbIMH+HSiiIkfrMsdEwYft0GTyFTdsnf+hdHn3tau7Qa5hhX+A== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/plugin" "2.12.0" - "@parcel/rust" "2.12.0" - nullthrows "^1.1.1" - posthtml "^0.16.5" - posthtml-parser "^0.10.1" - posthtml-render "^3.0.0" - semver "^7.5.2" - -"@parcel/types@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/types/-/types-2.12.0.tgz" - integrity sha512-8zAFiYNCwNTQcglIObyNwKfRYQK5ELlL13GuBOrSMxueUiI5ylgsGbTS1N7J3dAGZixHO8KhHGv5a71FILn9rQ== - dependencies: - "@parcel/cache" "2.12.0" - "@parcel/diagnostic" "2.12.0" - "@parcel/fs" "2.12.0" - "@parcel/package-manager" "2.12.0" - "@parcel/source-map" "^2.1.1" - "@parcel/workers" "2.12.0" - utility-types "^3.10.0" - -"@parcel/utils@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/utils/-/utils-2.12.0.tgz" - integrity sha512-z1JhLuZ8QmDaYoEIuUCVZlhcFrS7LMfHrb2OCRui5SQFntRWBH2fNM6H/fXXUkT9SkxcuFP2DUA6/m4+Gkz72g== - dependencies: - "@parcel/codeframe" "2.12.0" - "@parcel/diagnostic" "2.12.0" - "@parcel/logger" "2.12.0" - "@parcel/markdown-ansi" "2.12.0" - "@parcel/rust" "2.12.0" - "@parcel/source-map" "^2.1.1" - chalk "^4.1.0" - nullthrows "^1.1.1" - -"@parcel/watcher-android-arm64@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.3.0.tgz#d82e74bb564ebd4d8a88791d273a3d2bd61e27ab" - integrity sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA== - -"@parcel/watcher-darwin-arm64@2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.3.0.tgz" - integrity sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw== - -"@parcel/watcher-darwin-x64@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.3.0.tgz#83c902994a2a49b9e1ab5050dba24876fdc2c219" - integrity sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow== - -"@parcel/watcher-freebsd-x64@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.3.0.tgz#7a0f4593a887e2752b706aff2dae509aef430cf6" - integrity sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw== - -"@parcel/watcher-linux-arm-glibc@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.3.0.tgz#3fc90c3ebe67de3648ed2f138068722f9b1d47da" - integrity sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ== - -"@parcel/watcher-linux-arm64-glibc@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.3.0.tgz#f7bbbf2497d85fd11e4c9e9c26ace8f10ea9bcbc" - integrity sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA== - -"@parcel/watcher-linux-arm64-musl@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.3.0.tgz#de131a9fcbe1fa0854e9cbf4c55bed3b35bcff43" - integrity sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw== - -"@parcel/watcher-linux-x64-glibc@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.3.0.tgz#193dd1c798003cdb5a1e59470ff26300f418a943" - integrity sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow== - -"@parcel/watcher-linux-x64-musl@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.3.0.tgz#6dbdb86d96e955ab0fe4a4b60734ec0025a689dd" - integrity sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g== - -"@parcel/watcher-wasm@2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@parcel/watcher-wasm/-/watcher-wasm-2.3.0.tgz" - integrity sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA== - dependencies: - is-glob "^4.0.3" - micromatch "^4.0.5" - napi-wasm "^1.1.0" - -"@parcel/watcher-win32-arm64@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.3.0.tgz#59da26a431da946e6c74fa6b0f30b120ea6650b6" - integrity sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw== - -"@parcel/watcher-win32-ia32@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.3.0.tgz#3ee6a18b08929cd3b788e8cc9547fd9a540c013a" - integrity sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow== - -"@parcel/watcher-win32-x64@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.3.0.tgz#14e7246289861acc589fd608de39fe5d8b4bb0a7" - integrity sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA== - -"@parcel/watcher@^2.0.7": - version "2.1.0" - resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.1.0.tgz" - integrity sha512-8s8yYjd19pDSsBpbkOHnT6Z2+UJSuLQx61pCFM0s5wSRvKCEMDjd/cHY3/GI1szHIWbpXpsJdg3V6ISGGx9xDw== - dependencies: - is-glob "^4.0.3" - micromatch "^4.0.5" - node-addon-api "^3.2.1" - node-gyp-build "^4.3.0" - -"@parcel/watcher@^2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.3.0.tgz" - integrity sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ== - dependencies: - detect-libc "^1.0.3" - is-glob "^4.0.3" - micromatch "^4.0.5" - node-addon-api "^7.0.0" - optionalDependencies: - "@parcel/watcher-android-arm64" "2.3.0" - "@parcel/watcher-darwin-arm64" "2.3.0" - "@parcel/watcher-darwin-x64" "2.3.0" - "@parcel/watcher-freebsd-x64" "2.3.0" - "@parcel/watcher-linux-arm-glibc" "2.3.0" - "@parcel/watcher-linux-arm64-glibc" "2.3.0" - "@parcel/watcher-linux-arm64-musl" "2.3.0" - "@parcel/watcher-linux-x64-glibc" "2.3.0" - "@parcel/watcher-linux-x64-musl" "2.3.0" - "@parcel/watcher-win32-arm64" "2.3.0" - "@parcel/watcher-win32-ia32" "2.3.0" - "@parcel/watcher-win32-x64" "2.3.0" - -"@parcel/workers@2.12.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@parcel/workers/-/workers-2.12.0.tgz" - integrity sha512-zv5We5Jmb+ZWXlU6A+AufyjY4oZckkxsZ8J4dvyWL0W8IQvGO1JB4FGeryyttzQv3RM3OxcN/BpTGPiDG6keBw== - dependencies: - "@parcel/diagnostic" "2.12.0" - "@parcel/logger" "2.12.0" - "@parcel/profiler" "2.12.0" - "@parcel/types" "2.12.0" - "@parcel/utils" "2.12.0" - nullthrows "^1.1.1" - -"@preact/signals-core@^1.2.3": - version "1.2.3" - resolved "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.3.tgz" - integrity sha512-Kui4p7PMcEQevBgsTO0JBo3gyQ88Q3qzEvsVCuSp11t0JcN4DmGCTJcGRVSCq7Bn7lGxJBO+57jNSzDoDJ+QmA== - -"@preact/signals@^1.1.3": - version "1.1.3" - resolved "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz" - integrity sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og== - dependencies: - "@preact/signals-core" "^1.2.3" - -"@swc/core-darwin-arm64@1.7.35": - version "1.7.35" - resolved "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.35.tgz" - integrity sha512-BQSSozVxjxS+SVQz6e3GC/+OBWGIK3jfe52pWdANmycdjF3ch7lrCKTHTU7eHwyoJ96mofszPf5AsiVJF34Fwg== - -"@swc/core-darwin-x64@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.7.35.tgz#c15c0fb11fb44e748d86d949911a6e416c7d36c9" - integrity sha512-44TYdKN/EWtkU88foXR7IGki9JzhEJzaFOoPevfi9Xe7hjAD/x2+AJOWWqQNzDPMz9+QewLdUVLyR6s5okRgtg== - -"@swc/core-linux-arm-gnueabihf@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.35.tgz#e40a31cbbef31b4ec0fa294eae4c51bf28f1622b" - integrity sha512-ccfA5h3zxwioD+/z/AmYtkwtKz9m4rWTV7RoHq6Jfsb0cXHrd6tbcvgqRWXra1kASlE+cDWsMtEZygs9dJRtUQ== - -"@swc/core-linux-arm64-gnu@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.35.tgz#538d367d06d7f2cbee05b9e1357583574eb57601" - integrity sha512-hx65Qz+G4iG/IVtxJKewC5SJdki8PAPFGl6gC/57Jb0+jA4BIoGLD/J3Q3rCPeoHfdqpkCYpahtyUq8CKx41Jg== - -"@swc/core-linux-arm64-musl@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.35.tgz#752e6b74c10113e1759e418906f48138dfd01a9f" - integrity sha512-kL6tQL9No7UEoEvDRuPxzPTpxrvbwYteNRbdChSSP74j13/55G2/2hLmult5yFFaWuyoyU/2lvzjRL/i8OLZxg== - -"@swc/core-linux-x64-gnu@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.35.tgz#0cbade76e5abce7d13802d6c55dc0c21213f5d3d" - integrity sha512-Ke4rcLQSwCQ2LHdJX1FtnqmYNQ3IX6BddKlUtS7mcK13IHkQzZWp0Dcu6MgNA3twzb/dBpKX5GLy07XdGgfmyw== - -"@swc/core-linux-x64-musl@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.35.tgz#ac60b88972cdbd2856cbe7b5b5ef1a1f54d906f6" - integrity sha512-T30tlLnz0kYyDFyO5RQF5EQ4ENjW9+b56hEGgFUYmfhFhGA4E4V67iEx7KIG4u0whdPG7oy3qjyyIeTb7nElEw== - -"@swc/core-win32-arm64-msvc@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.35.tgz#366f3e8cc387c539579e91a4ccf8a2b9a0083889" - integrity sha512-CfM/k8mvtuMyX+okRhemfLt784PLS0KF7Q9djA8/Dtavk0L5Ghnq+XsGltO3d8B8+XZ7YOITsB14CrjehzeHsg== - -"@swc/core-win32-ia32-msvc@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.35.tgz#07fbda3ca8ac58f28cd441aa9ba082a3ea6d84fb" - integrity sha512-ATB3uuH8j/RmS64EXQZJSbo2WXfRNpTnQszHME/sGaexsuxeijrp3DTYSFAA3R2Bu6HbIIX6jempe1Au8I3j+A== - -"@swc/core-win32-x64-msvc@1.7.35": - version "1.7.35" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.35.tgz#043a54aaeb9b24f4f2405ac2cfa48891508ff1a6" - integrity sha512-iDGfQO1571NqWUXtLYDhwIELA/wadH42ioGn+J9R336nWx40YICzy9UQyslWRhqzhQ5kT+QXAW/MoCWc058N6Q== - -"@swc/core@^1.3.36": - version "1.7.35" - resolved "https://registry.npmjs.org/@swc/core/-/core-1.7.35.tgz" - integrity sha512-3cUteCTbr2r5jqfgx0r091sfq5Mgh6F1SQh8XAOnSvtKzwv2bC31mvBHVAieD1uPa2kHJhLav20DQgXOhpEitw== - dependencies: - "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.13" - optionalDependencies: - "@swc/core-darwin-arm64" "1.7.35" - "@swc/core-darwin-x64" "1.7.35" - "@swc/core-linux-arm-gnueabihf" "1.7.35" - "@swc/core-linux-arm64-gnu" "1.7.35" - "@swc/core-linux-arm64-musl" "1.7.35" - "@swc/core-linux-x64-gnu" "1.7.35" - "@swc/core-linux-x64-musl" "1.7.35" - "@swc/core-win32-arm64-msvc" "1.7.35" - "@swc/core-win32-ia32-msvc" "1.7.35" - "@swc/core-win32-x64-msvc" "1.7.35" - -"@swc/counter@^0.1.3": - version "0.1.3" - resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz" - integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== - -"@swc/helpers@^0.5.0": - version "0.5.13" - resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz" - integrity sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w== - dependencies: - tslib "^2.4.0" - -"@swc/types@^0.1.13": - version "0.1.13" - resolved "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz" - integrity sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q== - dependencies: - "@swc/counter" "^0.1.3" - -"@trysound/sax@0.2.0": - version "0.2.0" - resolved "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz" - integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== - -"@types/node@^20.8.9": - version "20.8.9" - resolved "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz" - integrity sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg== - dependencies: - undici-types "~5.26.4" - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== - -abortcontroller-polyfill@^1.1.9: - version "1.7.5" - resolved "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz" - integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ== - -acorn@^8.10.0: - version "8.11.2" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz" - integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -anymatch@^3.1.3, anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -arch@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz" - integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== - -b4a@^1.6.4: - version "1.6.4" - resolved "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz" - integrity sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw== - -base-x@^3.0.8: - version "3.0.9" - resolved "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz" - integrity sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ== - dependencies: - safe-buffer "^5.0.1" - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -bl@^4.0.3: - version "4.1.0" - resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browserslist@^4.6.6: - version "4.21.4" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz" - integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw== - dependencies: - caniuse-lite "^1.0.30001400" - electron-to-chromium "^1.4.251" - node-releases "^2.0.6" - update-browserslist-db "^1.0.9" - -buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -caniuse-lite@^1.0.30001400: - version "1.0.30001446" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz" - integrity sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw== - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - -chrome-trace-event@^1.0.2: - version "1.0.3" - resolved "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz" - integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== - -chrome-trace-event@^1.0.3: - version "1.0.4" - resolved "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz" - integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== - -citty@^0.1.3, citty@^0.1.4: - version "0.1.4" - resolved "https://registry.npmjs.org/citty/-/citty-0.1.4.tgz" - integrity sha512-Q3bK1huLxzQrvj7hImJ7Z1vKYJRPQCDnd0EjXfHMidcjecGOMuLrmuQmtWmFkuKLcMThlGh1yCKG8IEc6VeNXQ== - dependencies: - consola "^3.2.3" - -clipboardy@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz" - integrity sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg== - dependencies: - arch "^2.2.0" - execa "^5.1.1" - is-wsl "^2.2.0" - -clone@^2.1.1: - version "2.1.2" - resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" - integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== - -cluster-key-slot@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz" - integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@^1.0.0, color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-string@^1.9.0: - version "1.9.1" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/color/-/color-4.2.3.tgz" - integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== - dependencies: - color-convert "^2.0.1" - color-string "^1.9.0" - -commander@^2.20.3: - version "2.20.3" - resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - -commander@^7.0.0, commander@^7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - -consola@^3.2.3: - version "3.2.3" - resolved "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz" - integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ== - -cookie-es@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/cookie-es/-/cookie-es-1.0.0.tgz" - integrity sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ== - -cosmiconfig@^7.0.1: - version "7.1.0" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz" - integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.2.1" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.10.0" - -cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - -css-select@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz" - integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== - dependencies: - boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" - -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - -css-tree@^2.2.1: - version "2.3.1" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz" - integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== - dependencies: - mdn-data "2.0.30" - source-map-js "^1.0.1" - -css-tree@~2.2.0: - version "2.2.1" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz" - integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== - dependencies: - mdn-data "2.0.28" - source-map-js "^1.0.1" - -css-what@^6.0.1, css-what@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== - -cssfilter@0.0.10: - version "0.0.10" - resolved "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz" - integrity sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw== - -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - -csso@^5.0.5: - version "5.0.5" - resolved "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz" - integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== - dependencies: - css-tree "~2.2.0" - -debug@^4.3.4: - version "4.3.4" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decompress-response@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz" - integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== - dependencies: - mimic-response "^3.1.0" - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -defu@^6.1.2: - version "6.1.3" - resolved "https://registry.npmjs.org/defu/-/defu-6.1.3.tgz" - integrity sha512-Vy2wmG3NTkmHNg/kzpuvHhkqeIx3ODWqasgCRbKtbXEN0G+HpEEv9BtJLp7ZG1CZloFaC41Ah3ZFbq7aqCqMeQ== - -denque@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz" - integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== - -destr@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/destr/-/destr-2.0.2.tgz" - integrity sha512-65AlobnZMiCET00KaFFjUefxDX0khFA/E4myqZ7a6Sq1yZtR8+FVIvilVX66vF2uobSumxooYZChiRPCKNqhmg== - -detect-libc@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz" - integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== - -detect-libc@^2.0.0, detect-libc@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz" - integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw== - -detect-libc@^2.0.1: - version "2.0.3" - resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz" - integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== - -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - -dom-serializer@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz" - integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - entities "^4.2.0" - -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^4.2.0, domhandler@^4.2.2, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - -domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - -domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -domutils@^3.0.1: - version "3.1.0" - resolved "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz" - integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== - dependencies: - dom-serializer "^2.0.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - -dotenv-expand@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz" - integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== - -dotenv@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/dotenv/-/dotenv-7.0.0.tgz" - integrity sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g== - -electron-to-chromium@^1.4.251: - version "1.4.284" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz" - integrity sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA== - -end-of-stream@^1.1.0, end-of-stream@^1.4.1: - version "1.4.4" - resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz" - integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== - -entities@^4.2.0: - version "4.5.0" - resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" - integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -etag@^1.8.1: - version "1.8.1" - resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -execa@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -expand-template@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz" - integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== - -fast-fifo@^1.1.0, fast-fifo@^1.2.0: - version "1.3.2" - resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" - integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - -fsevents@~2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -get-port-please@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.1.tgz" - integrity sha512-3UBAyM3u4ZBVYDsxOQfJDxEa6XTbpBDrOjp4mf7ExFRt5BKs/QywQQiJsh2B+hxcZLSapWqCRvElUe8DnKcFHA== - -get-port@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/get-port/-/get-port-4.2.0.tgz" - integrity sha512-/b3jarXkH8KJoOMQc3uVGHASwGLPq3gSFJ7tgJm2diza+bydJPTGOibin2steecKeOylE8oY2JERlVWkAJO6yw== - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -github-from-package@0.0.0: - version "0.0.0" - resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz" - integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== - -glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -globals@^13.2.0: - version "13.19.0" - resolved "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz" - integrity sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ== - dependencies: - type-fest "^0.20.2" - -h3@^1.7.1, h3@^1.8.1, h3@^1.8.2: - version "1.8.2" - resolved "https://registry.npmjs.org/h3/-/h3-1.8.2.tgz" - integrity sha512-1Ca0orJJlCaiFY68BvzQtP2lKLk46kcLAxVM8JgYbtm2cUg6IY7pjpYgWMwUvDO9QI30N5JAukOKoT8KD3Q0PQ== - dependencies: - cookie-es "^1.0.0" - defu "^6.1.2" - destr "^2.0.1" - iron-webcrypto "^0.10.1" - radix3 "^1.1.0" - ufo "^1.3.0" - uncrypto "^0.1.3" - unenv "^1.7.4" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -htmlnano@^2.0.0: - version "2.0.3" - resolved "https://registry.npmjs.org/htmlnano/-/htmlnano-2.0.3.tgz" - integrity sha512-S4PGGj9RbdgW8LhbILNK7W9JhmYP8zmDY7KDV/8eCiJBQJlbmltp5I0gv8c5ntLljfdxxfmJ+UJVSqyH4mb41A== - dependencies: - cosmiconfig "^7.0.1" - posthtml "^0.16.5" - timsort "^0.3.0" - -htmlparser2@^7.1.1: - version "7.2.0" - resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz" - integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.2" - domutils "^2.8.0" - entities "^3.0.1" - -http-shutdown@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz" - integrity sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw== - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -image-meta@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/image-meta/-/image-meta-0.2.0.tgz" - integrity sha512-ZBGjl0ZMEMeOC3Ns0wUF/5UdUmr3qQhBSCniT0LxOgGGIRHiNFOkMtIHB7EOznRU47V2AxPgiVP+s+0/UCU0Hg== - -import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -inherits@^2.0.3, inherits@^2.0.4: - version "2.0.4" - resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@~1.3.0: - version "1.3.8" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -ioredis@^5.3.2: - version "5.3.2" - resolved "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz" - integrity sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA== - dependencies: - "@ioredis/commands" "^1.1.1" - cluster-key-slot "^1.1.0" - debug "^4.3.4" - denque "^2.1.0" - lodash.defaults "^4.2.0" - lodash.isarguments "^3.1.0" - redis-errors "^1.2.0" - redis-parser "^3.0.0" - standard-as-callback "^2.1.0" - -ipx@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ipx/-/ipx-2.0.0.tgz" - integrity sha512-JUD8CRIzBlGRKJxTinOR6QIVhp+bIUBrZajDIJwUC+ndoIxURrx834Ju6uxSnGvbw7WoMsmF0jnCLsaRHDaXDg== - dependencies: - "@fastify/accept-negotiator" "^1.1.0" - citty "^0.1.4" - consola "^3.2.3" - defu "^6.1.2" - destr "^2.0.1" - etag "^1.8.1" - h3 "^1.8.2" - image-meta "^0.2.0" - listhen "^1.5.5" - ofetch "^1.3.3" - pathe "^1.1.1" - sharp "^0.32.6" - svgo "^3.0.2" - ufo "^1.3.1" - unstorage "^1.9.0" - xss "^1.0.14" - -iron-webcrypto@^0.10.1: - version "0.10.1" - resolved "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-0.10.1.tgz" - integrity sha512-QGOS8MRMnj/UiOa+aMIgfyHcvkhqNUsUxb1XzskENvbo+rEfp6TOwqd1KPuDzXC4OnGHcMSVxDGRoilqB8ViqA== - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-docker@^2.0.0: - version "2.2.1" - resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-json@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz" - integrity sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-promise@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz" - integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -jiti@^1.20.0: - version "1.21.0" - resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz" - integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q== - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json5@^2.2.0, json5@^2.2.1: - version "2.2.3" - resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsonc-parser@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz" - integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== - -lightningcss-darwin-arm64@1.27.0: - version "1.27.0" - resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz" - integrity sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ== - -lightningcss-darwin-x64@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz#c906a267237b1c7fe08bff6c5ac032c099bc9482" - integrity sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg== - -lightningcss-freebsd-x64@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz#a7c3c4d6ee18dffeb8fa69f14f8f9267f7dc0c34" - integrity sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA== - -lightningcss-linux-arm-gnueabihf@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz#c7c16432a571ec877bf734fe500e4a43d48c2814" - integrity sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA== - -lightningcss-linux-arm64-gnu@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz#cfd9e18df1cd65131da286ddacfa3aee6862a752" - integrity sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A== - -lightningcss-linux-arm64-musl@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz#6682ff6b9165acef9a6796bd9127a8e1247bb0ed" - integrity sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg== - -lightningcss-linux-x64-gnu@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz#714221212ad184ddfe974bbb7dbe9300dfde4bc0" - integrity sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A== - -lightningcss-linux-x64-musl@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz#247958daf622a030a6dc2285afa16b7184bdf21e" - integrity sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA== - -lightningcss-win32-arm64-msvc@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz#64cfe473c264ef5dc275a4d57a516d77fcac6bc9" - integrity sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ== - -lightningcss-win32-x64-msvc@1.27.0: - version "1.27.0" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz#237d0dc87d9cdc9cf82536bcbc07426fa9f3f422" - integrity sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw== - -lightningcss@^1.22.1: - version "1.27.0" - resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz" - integrity sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ== - dependencies: - detect-libc "^1.0.3" - optionalDependencies: - lightningcss-darwin-arm64 "1.27.0" - lightningcss-darwin-x64 "1.27.0" - lightningcss-freebsd-x64 "1.27.0" - lightningcss-linux-arm-gnueabihf "1.27.0" - lightningcss-linux-arm64-gnu "1.27.0" - lightningcss-linux-arm64-musl "1.27.0" - lightningcss-linux-x64-gnu "1.27.0" - lightningcss-linux-x64-musl "1.27.0" - lightningcss-win32-arm64-msvc "1.27.0" - lightningcss-win32-x64-msvc "1.27.0" - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -listhen@^1.2.2, listhen@^1.5.5: - version "1.5.5" - resolved "https://registry.npmjs.org/listhen/-/listhen-1.5.5.tgz" - integrity sha512-LXe8Xlyh3gnxdv4tSjTjscD1vpr/2PRpzq8YIaMJgyKzRG8wdISlWVWnGThJfHnlJ6hmLt2wq1yeeix0TEbuoA== - dependencies: - "@parcel/watcher" "^2.3.0" - "@parcel/watcher-wasm" "2.3.0" - citty "^0.1.4" - clipboardy "^3.0.0" - consola "^3.2.3" - defu "^6.1.2" - get-port-please "^3.1.1" - h3 "^1.8.1" - http-shutdown "^1.2.2" - jiti "^1.20.0" - mlly "^1.4.2" - node-forge "^1.3.1" - pathe "^1.1.1" - std-env "^3.4.3" - ufo "^1.3.0" - untun "^0.1.2" - uqr "^0.1.2" - -lmdb@2.8.5: - version "2.8.5" - resolved "https://registry.npmjs.org/lmdb/-/lmdb-2.8.5.tgz" - integrity sha512-9bMdFfc80S+vSldBmG3HOuLVHnxRdNTlpzR6QDnzqCQtCzGUEAGTzBKYMeIM+I/sU4oZfgbcbS7X7F65/z/oxQ== - dependencies: - msgpackr "^1.9.5" - node-addon-api "^6.1.0" - node-gyp-build-optional-packages "5.1.1" - ordered-binary "^1.4.1" - weak-lru-cache "^1.2.2" - optionalDependencies: - "@lmdb/lmdb-darwin-arm64" "2.8.5" - "@lmdb/lmdb-darwin-x64" "2.8.5" - "@lmdb/lmdb-linux-arm" "2.8.5" - "@lmdb/lmdb-linux-arm64" "2.8.5" - "@lmdb/lmdb-linux-x64" "2.8.5" - "@lmdb/lmdb-win32-x64" "2.8.5" - -lodash.defaults@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz" - integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== - -lodash.isarguments@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz" - integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== - -lru-cache@^10.0.0: - version "10.0.1" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz" - integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - -mdn-data@2.0.28: - version "2.0.28" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz" - integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== - -mdn-data@2.0.30: - version "2.0.30" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz" - integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -micromatch@^4.0.5: - version "4.0.5" - resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -mimic-response@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz" - integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== - -minimist@^1.2.0, minimist@^1.2.3: - version "1.2.8" - resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: - version "0.5.3" - resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz" - integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== - -mlly@^1.2.0, mlly@^1.4.2: - version "1.4.2" - resolved "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz" - integrity sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg== - dependencies: - acorn "^8.10.0" - pathe "^1.1.1" - pkg-types "^1.0.3" - ufo "^1.3.0" - -mri@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz" - integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -msgpackr-extract@^3.0.2: - version "3.0.3" - resolved "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz" - integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== - dependencies: - node-gyp-build-optional-packages "5.2.2" - optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" - -msgpackr@^1.9.5, msgpackr@^1.9.9: - version "1.11.0" - resolved "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz" - integrity sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw== - optionalDependencies: - msgpackr-extract "^3.0.2" - -napi-build-utils@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz" - integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== - -napi-wasm@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/napi-wasm/-/napi-wasm-1.1.0.tgz" - integrity sha512-lHwIAJbmLSjF9VDRm9GoVOy9AGp3aIvkjv+Kvz9h16QR3uSVYH78PNQUnT2U4X53mhlnV2M7wrhibQ3GHicDmg== - -node-abi@^3.3.0: - version "3.51.0" - resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz" - integrity sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA== - dependencies: - semver "^7.3.5" - -node-addon-api@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz" - integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== - -node-addon-api@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz" - integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== - -node-addon-api@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.0.0.tgz" - integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== - -node-fetch-native@^1.2.0, node-fetch-native@^1.4.0: - version "1.4.1" - resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.4.1.tgz" - integrity sha512-NsXBU0UgBxo2rQLOeWNZqS3fvflWePMECr8CoSWoSTqCqGbVVsvl9vZu1HfQicYN0g5piV9Gh8RTEvo/uP752w== - -node-forge@^1.3.1: - version "1.3.1" - resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz" - integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== - -node-gyp-build-optional-packages@5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz" - integrity sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw== - dependencies: - detect-libc "^2.0.1" - -node-gyp-build-optional-packages@5.2.2: - version "5.2.2" - resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz" - integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== - dependencies: - detect-libc "^2.0.1" - -node-gyp-build@^4.3.0: - version "4.6.0" - resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz" - integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== - -node-releases@^2.0.6: - version "2.0.8" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz" - integrity sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A== - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - -nullthrows@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz" - integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== - -ofetch@^1.1.1, ofetch@^1.3.3: - version "1.3.3" - resolved "https://registry.npmjs.org/ofetch/-/ofetch-1.3.3.tgz" - integrity sha512-s1ZCMmQWXy4b5K/TW9i/DtiN8Ku+xCiHcjQ6/J/nDdssirrQNOoB165Zu8EqLMA2lln1JUth9a0aW9Ap2ctrUg== - dependencies: - destr "^2.0.1" - node-fetch-native "^1.4.0" - ufo "^1.3.0" - -once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -ordered-binary@^1.4.1: - version "1.5.2" - resolved "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.2.tgz" - integrity sha512-JTo+4+4Fw7FreyAvlSLjb1BBVaxEQAacmjD3jjuyPZclpbEghTvQZbXBb2qPd2LeIMxiHwXBZUcpmG2Gl/mDEA== - -parcel@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/parcel/-/parcel-2.12.0.tgz#60529c268c2ce0754b225af835f1519da1364298" - integrity sha512-W+gxAq7aQ9dJIg/XLKGcRT0cvnStFAQHPaI0pvD0U2l6IVLueUAm3nwN7lkY62zZNmlvNx6jNtE4wlbS+CyqSg== - dependencies: - "@parcel/config-default" "2.12.0" - "@parcel/core" "2.12.0" - "@parcel/diagnostic" "2.12.0" - "@parcel/events" "2.12.0" - "@parcel/fs" "2.12.0" - "@parcel/logger" "2.12.0" - "@parcel/package-manager" "2.12.0" - "@parcel/reporter-cli" "2.12.0" - "@parcel/reporter-dev-server" "2.12.0" - "@parcel/reporter-tracer" "2.12.0" - "@parcel/utils" "2.12.0" - chalk "^4.1.0" - commander "^7.0.0" - get-port "^4.2.0" - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -pathe@^1.1.0, pathe@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz" - integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pkg-types@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz" - integrity sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A== - dependencies: - jsonc-parser "^3.2.0" - mlly "^1.2.0" - pathe "^1.1.0" - -postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -posthtml-parser@^0.10.1: - version "0.10.2" - resolved "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.10.2.tgz" - integrity sha512-PId6zZ/2lyJi9LiKfe+i2xv57oEjJgWbsHGGANwos5AvdQp98i6AtamAl8gzSVFGfQ43Glb5D614cvZf012VKg== - dependencies: - htmlparser2 "^7.1.1" - -posthtml-parser@^0.11.0: - version "0.11.0" - resolved "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz" - integrity sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw== - dependencies: - htmlparser2 "^7.1.1" - -posthtml-render@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz" - integrity sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA== - dependencies: - is-json "^2.0.1" - -posthtml@^0.16.4, posthtml@^0.16.5: - version "0.16.6" - resolved "https://registry.npmjs.org/posthtml/-/posthtml-0.16.6.tgz" - integrity sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ== - dependencies: - posthtml-parser "^0.11.0" - posthtml-render "^3.0.0" - -preact-render-to-string@^6.5.11: - version "6.5.11" - resolved "https://registry.yarnpkg.com/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz#467e69908a453497bb93d4d1fc35fb749a78e027" - integrity sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw== - -preact@^10.1.0: - version "10.11.3" - resolved "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz" - integrity sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg== - -prebuild-install@^7.1.1: - version "7.1.1" - resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz" - integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw== - dependencies: - detect-libc "^2.0.0" - expand-template "^2.0.3" - github-from-package "0.0.0" - minimist "^1.2.3" - mkdirp-classic "^0.5.3" - napi-build-utils "^1.0.1" - node-abi "^3.3.0" - pump "^3.0.0" - rc "^1.2.7" - simple-get "^4.0.0" - tar-fs "^2.0.0" - tunnel-agent "^0.6.0" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -queue-tick@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz" - integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== - -radix3@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/radix3/-/radix3-1.1.0.tgz" - integrity sha512-pNsHDxbGORSvuSScqNJ+3Km6QAVqk8CfsCBIEoDgpqLrkD2f3QM4I7d1ozJJ172OmIcoUcerZaNWqtLkRXTV3A== - -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -react-error-overlay@6.0.9: - version "6.0.9" - resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz" - integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== - -react-refresh@^0.9.0: - version "0.9.0" - resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.9.0.tgz" - integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ== - -readable-stream@^3.1.1, readable-stream@^3.4.0: - version "3.6.2" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -redis-errors@^1.0.0, redis-errors@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz" - integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== - -redis-parser@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz" - integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== - dependencies: - redis-errors "^1.0.0" - -regenerator-runtime@^0.13.7: - version "0.13.11" - resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -safe-buffer@^5.0.1, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -semver@^7.3.5, semver@^7.5.4: - version "7.5.4" - resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - -semver@^7.5.2: - version "7.6.3" - resolved "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz" - integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== - -sharp@^0.32.6: - version "0.32.6" - resolved "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz" - integrity sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w== - dependencies: - color "^4.2.3" - detect-libc "^2.0.2" - node-addon-api "^6.1.0" - prebuild-install "^7.1.1" - semver "^7.5.4" - simple-get "^4.0.1" - tar-fs "^3.0.4" - tunnel-agent "^0.6.0" - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -simple-concat@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz" - integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== - -simple-get@^4.0.0, simple-get@^4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz" - integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== - dependencies: - decompress-response "^6.0.0" - once "^1.3.1" - simple-concat "^1.0.0" - -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz" - integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== - dependencies: - is-arrayish "^0.3.1" - -source-map-js@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map@^0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -srcset@4: - version "4.0.0" - resolved "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz" - integrity sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw== - -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - -standard-as-callback@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz" - integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== - -std-env@^3.4.3: - version "3.4.3" - resolved "https://registry.npmjs.org/std-env/-/std-env-3.4.3.tgz" - integrity sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q== - -streamx@^2.15.0: - version "2.15.2" - resolved "https://registry.npmjs.org/streamx/-/streamx-2.15.2.tgz" - integrity sha512-b62pAV/aeMjUoRN2C/9F0n+G8AfcJjNC0zw/ZmOHeFsIe4m4GzjVW9m6VHXVjk536NbdU9JRwKMJRfkc+zUFTg== - dependencies: - fast-fifo "^1.1.0" - queue-tick "^1.0.1" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -svgo@^2.4.0: - version "2.8.0" - resolved "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz" - integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" - picocolors "^1.0.0" - stable "^0.1.8" - -svgo@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/svgo/-/svgo-3.0.2.tgz" - integrity sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^5.1.0" - css-tree "^2.2.1" - csso "^5.0.5" - picocolors "^1.0.0" - -tar-fs@^2.0.0: - version "2.1.1" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz" - integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.1.4" - -tar-fs@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz" - integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w== - dependencies: - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^3.1.5" - -tar-stream@^2.1.4: - version "2.2.0" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz" - integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== - dependencies: - bl "^4.0.3" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar-stream@^3.1.5: - version "3.1.6" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz" - integrity sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg== - dependencies: - b4a "^1.6.4" - fast-fifo "^1.2.0" - streamx "^2.15.0" - -term-size@^2.2.1: - version "2.2.1" - resolved "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz" - integrity sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg== - -timsort@^0.3.0: - version "0.3.0" - resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz" - integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -tslib@^2.4.0: - version "2.4.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz" - integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -typescript@^5.6.3: - version "5.6.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" - integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== - -ufo@^1.2.0, ufo@^1.3.0, ufo@^1.3.1: - version "1.3.1" - resolved "https://registry.npmjs.org/ufo/-/ufo-1.3.1.tgz" - integrity sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw== - -uncrypto@^0.1.3: - version "0.1.3" - resolved "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz" - integrity sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q== - -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - -unenv@^1.7.4: - version "1.7.4" - resolved "https://registry.npmjs.org/unenv/-/unenv-1.7.4.tgz" - integrity sha512-fjYsXYi30It0YCQYqLOcT6fHfMXsBr2hw9XC7ycf8rTG7Xxpe3ZssiqUnD0khrjiZEmkBXWLwm42yCSCH46fMw== - dependencies: - consola "^3.2.3" - defu "^6.1.2" - mime "^3.0.0" - node-fetch-native "^1.4.0" - pathe "^1.1.1" - -unstorage@^1.9.0: - version "1.9.0" - resolved "https://registry.npmjs.org/unstorage/-/unstorage-1.9.0.tgz" - integrity sha512-VpD8ZEYc/le8DZCrny3bnqKE4ZjioQxBRnWE+j5sGNvziPjeDlaS1NaFFHzl/kkXaO3r7UaF8MGQrs14+1B4pQ== - dependencies: - anymatch "^3.1.3" - chokidar "^3.5.3" - destr "^2.0.1" - h3 "^1.7.1" - ioredis "^5.3.2" - listhen "^1.2.2" - lru-cache "^10.0.0" - mri "^1.2.0" - node-fetch-native "^1.2.0" - ofetch "^1.1.1" - ufo "^1.2.0" - -untun@^0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/untun/-/untun-0.1.2.tgz" - integrity sha512-wLAMWvxfqyTiBODA1lg3IXHQtjggYLeTK7RnSfqtOXixWJ3bAa2kK/HHmOOg19upteqO3muLvN6O/icbyQY33Q== - dependencies: - citty "^0.1.3" - consola "^3.2.3" - pathe "^1.1.1" - -update-browserslist-db@^1.0.9: - version "1.0.10" - resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz" - integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -uqr@^0.1.2: - version "0.1.2" - resolved "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz" - integrity sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA== - -urlpattern-polyfill@8.0.2: - version "8.0.2" - resolved "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz" - integrity sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ== - -util-deprecate@^1.0.1: - version "1.0.2" - resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -utility-types@^3.10.0: - version "3.10.0" - resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz" - integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== - -weak-lru-cache@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz" - integrity sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw== - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -xss@^1.0.14: - version "1.0.14" - resolved "https://registry.npmjs.org/xss/-/xss-1.0.14.tgz" - integrity sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw== - dependencies: - commander "^2.20.3" - cssfilter "0.0.10" - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== diff --git a/deno.json b/deno.json deleted file mode 100644 index a39b695..0000000 --- a/deno.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@unpic/lib", - "version": "3.22.0", - "exports": "./mod.ts", - "tasks": { - "build:npm": "deno run --allow-all scripts/build_npm.ts" - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "https://esm.sh/preact@10.11.2" - }, - "fmt": { - "useTabs": true - }, - "imports": { - "@deno/dnt": "jsr:@deno/dnt@0.37.0", - "@std/path": "jsr:@std/path@0.206.0", - "@std/fs": "jsr:@std/fs@0.206.0", - "@std/testing": "jsr:@std/testing@0.172.0" - }, - "publish": { - "include": [ - "src", - "mod.ts", - "README.md", - "data" - ], - "exclude": [ - "**/*.test.ts" - ] - }, - "license": "MIT" -} diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..cd91873 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,60 @@ +{ + "name": "@unpic/lib", + "version": "3.20.0", + "exports": { + ".": "./mod.ts", + "./async": "./src/async.ts", + "./providers/astro": "./src/providers/astro.ts", + "./providers/builder.io": "./src/providers/builder.io.ts", + "./providers/bunny": "./src/providers/bunny.ts", + "./providers/cloudflare_images": "./src/providers/cloudflare_images.ts", + "./providers/cloudflare": "./src/providers/cloudflare.ts", + "./providers/cloudimage": "./src/providers/cloudimage.ts", + "./providers/cloudinary": "./src/providers/cloudinary.ts", + "./providers/contentful": "./src/providers/contentful.ts", + "./providers/contentstack": "./src/providers/contentstack.ts", + "./providers/directus": "./src/providers/directus.ts", + "./providers/imageengine": "./src/providers/imageengine.ts", + "./providers/imagekit": "./src/providers/keycdn.ts", + "./providers/imgix": "./src/providers/imgix.ts", + "./providers/keycdn": "./src/providers/keycdn.ts", + "./providers/kontent.ai": "./src/providers/kontent.ai.ts", + "./providers/netlify": "./src/providers/netlify.ts", + "./providers/nextjs": "./src/providers/nextjs.ts", + "./providers/scene7": "./src/providers/scene7.ts", + "./providers/shopify": "./src/providers/shopify.ts", + "./providers/storyblok": "./src/providers/storyblok.ts", + "./providers/supabase": "./src/providers/supabase.ts", + "./providers/uploadcare": "./src/providers/uploadcare.ts", + "./providers/vercel": "./src/providers/vercel.ts", + "./providers/wordpress": "./src/providers/wordpress.ts" + }, + "tasks": { + "build:npm": "deno run --allow-all scripts/build_npm.ts" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "https://esm.sh/preact@10.11.2" + }, + "fmt": { + "useTabs": true + }, + "imports": { + "@deno/dnt": "jsr:@deno/dnt@0.37.0", + "@std/path": "jsr:@std/path@0.206.0", + "@std/fs": "jsr:@std/fs@0.206.0", + "@std/testing": "jsr:@std/testing@0.172.0" + }, + "publish": { + "include": [ + "src", + "mod.ts", + "README.md", + "data" + ], + "exclude": [ + "**/*.test.ts" + ] + }, + "license": "MIT" +} diff --git a/deno.lock b/deno.lock index b2677b8..cdbae24 100644 --- a/deno.lock +++ b/deno.lock @@ -9,6 +9,7 @@ "jsr:@std/assert@0.223": "0.223.0", "jsr:@std/assert@0.226": "0.226.0", "jsr:@std/bytes@0.223": "0.223.0", + "jsr:@std/fmt@0.206": "0.206.0", "jsr:@std/fmt@0.223": "0.223.0", "jsr:@std/fmt@1": "1.0.2", "jsr:@std/fs@*": "0.206.0", @@ -55,7 +56,10 @@ ] }, "@std/assert@0.206.0": { - "integrity": "9e3e4e1ae03bd154d5732afe7da88897f3b6a1f2b919b5bbea579c905d1a7054" + "integrity": "9e3e4e1ae03bd154d5732afe7da88897f3b6a1f2b919b5bbea579c905d1a7054", + "dependencies": [ + "jsr:@std/fmt@0.206" + ] }, "@std/assert@0.223.0": { "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" @@ -69,6 +73,9 @@ "@std/bytes@0.223.0": { "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" }, + "@std/fmt@0.206.0": { + "integrity": "e068d8327cf477f7d67f5a04d2ff8ef0fa314b4d06f98395b4e802963f18f436" + }, "@std/fmt@0.223.0": { "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" }, @@ -149,283 +156,10 @@ } }, "remote": { - "https://deno.land/std@0.111.0/_util/assert.ts": "2f868145a042a11d5ad0a3c748dcf580add8a0dbc0e876eaa0026303a5488f58", - "https://deno.land/std@0.111.0/_util/os.ts": "dfb186cc4e968c770ab6cc3288bd65f4871be03b93beecae57d657232ecffcac", - "https://deno.land/std@0.111.0/bytes/bytes_list.ts": "3bff6a09c72b2e0b1e92e29bd3b135053894196cca07a2bba842901073efe5cb", - "https://deno.land/std@0.111.0/bytes/equals.ts": "69f55fdbd45c71f920c1a621e6c0865dc780cd8ae34e0f5e55a9497b70c31c1b", - "https://deno.land/std@0.111.0/bytes/mod.ts": "fedb80b8da2e7ad8dd251148e65f92a04c73d6c5a430b7d197dc39588c8dda6f", - "https://deno.land/std@0.111.0/fmt/colors.ts": "8368ddf2d48dfe413ffd04cdbb7ae6a1009cf0dccc9c7ff1d76259d9c61a0621", - "https://deno.land/std@0.111.0/fs/_util.ts": "f2ce811350236ea8c28450ed822a5f42a0892316515b1cd61321dec13569c56b", - "https://deno.land/std@0.111.0/fs/ensure_dir.ts": "b7c103dc41a3d1dbbb522bf183c519c37065fdc234831a4a0f7d671b1ed5fea7", - "https://deno.land/std@0.111.0/hash/sha256.ts": "bd85257c68d1fdd9da8457284c4fbb04efa9f4f2229b5f41a638d5b71a3a8d5c", - "https://deno.land/std@0.111.0/io/buffer.ts": "fdf93ba9e5d20ff3369e2c42443efd89131f8a73066f7f59c033cc588a0e2cfe", - "https://deno.land/std@0.111.0/io/types.d.ts": "89a27569399d380246ca7cdd9e14d5e68459f11fb6110790cc5ecbd4ee7f3215", - "https://deno.land/std@0.111.0/path/_constants.ts": "1247fee4a79b70c89f23499691ef169b41b6ccf01887a0abd131009c5581b853", - "https://deno.land/std@0.111.0/path/_interface.ts": "1fa73b02aaa24867e481a48492b44f2598cd9dfa513c7b34001437007d3642e4", - "https://deno.land/std@0.111.0/path/_util.ts": "2e06a3b9e79beaf62687196bd4b60a4c391d862cfa007a20fc3a39f778ba073b", - "https://deno.land/std@0.111.0/path/common.ts": "f41a38a0719a1e85aa11c6ba3bea5e37c15dd009d705bd8873f94c833568cbc4", - "https://deno.land/std@0.111.0/path/glob.ts": "ea87985765b977cc284b92771003b2070c440e0807c90e1eb0ff3e095911a820", - "https://deno.land/std@0.111.0/path/mod.ts": "4465dc494f271b02569edbb4a18d727063b5dbd6ed84283ff906260970a15d12", - "https://deno.land/std@0.111.0/path/posix.ts": "34349174b9cd121625a2810837a82dd8b986bbaaad5ade690d1de75bbb4555b2", - "https://deno.land/std@0.111.0/path/separator.ts": "8fdcf289b1b76fd726a508f57d3370ca029ae6976fcde5044007f062e643ff1c", - "https://deno.land/std@0.111.0/path/win32.ts": "11549e8c6df8307a8efcfa47ad7b2a75da743eac7d4c89c9723a944661c8bd2e", - "https://deno.land/std@0.111.0/streams/conversion.ts": "fe0059ed9d3c53eda4ba44eb71a6a9acb98c5fdb5ba1b6c6ab28004724c7641b", - "https://deno.land/std@0.128.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.128.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617", - "https://deno.land/std@0.128.0/fmt/colors.ts": "4575bb20edc666d3ae75fa9fac75f20e4cd423b280008094b05e423cc85047bb", - "https://deno.land/std@0.128.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", - "https://deno.land/std@0.128.0/fs/empty_dir.ts": "7274d87160de34cbed0531e284df383045cf43543bbeadeb97feac598bd8f3c5", - "https://deno.land/std@0.128.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.128.0/fs/expand_glob.ts": "0c10130d67c9b02164b03df8e43c6d6defbf8e395cb69d09e84a8586e6d72ac3", - "https://deno.land/std@0.128.0/fs/walk.ts": "117403ccd21fd322febe56ba06053b1ad5064c802170f19b1ea43214088fe95f", - "https://deno.land/std@0.128.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.128.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.128.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", - "https://deno.land/std@0.128.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.128.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", - "https://deno.land/std@0.128.0/path/mod.ts": "4275129bb766f0e475ecc5246aa35689eeade419d72a48355203f31802640be7", - "https://deno.land/std@0.128.0/path/posix.ts": "663e4a6fe30a145f56aa41a22d95114c4c5582d8b57d2d7c9ed27ad2c47636bb", - "https://deno.land/std@0.128.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.128.0/path/win32.ts": "e7bdf63e8d9982b4d8a01ef5689425c93310ece950e517476e22af10f41a136e", - "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", - "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", - "https://deno.land/std@0.140.0/bytes/bytes_list.ts": "67eb118e0b7891d2f389dad4add35856f4ad5faab46318ff99653456c23b025d", - "https://deno.land/std@0.140.0/bytes/equals.ts": "fc16dff2090cced02497f16483de123dfa91e591029f985029193dfaa9d894c9", - "https://deno.land/std@0.140.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf", - "https://deno.land/std@0.140.0/fmt/colors.ts": "30455035d6d728394781c10755351742dd731e3db6771b1843f9b9e490104d37", - "https://deno.land/std@0.140.0/fs/_util.ts": "0fb24eb4bfebc2c194fb1afdb42b9c3dda12e368f43e8f2321f84fc77d42cb0f", - "https://deno.land/std@0.140.0/fs/ensure_dir.ts": "9dc109c27df4098b9fc12d949612ae5c9c7169507660dcf9ad90631833209d9d", - "https://deno.land/std@0.140.0/hash/sha256.ts": "803846c7a5a8a5a97f31defeb37d72f519086c880837129934f5d6f72102a8e8", - "https://deno.land/std@0.140.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b", - "https://deno.land/std@0.140.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", - "https://deno.land/std@0.140.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", - "https://deno.land/std@0.140.0/path/_util.ts": "c1e9686d0164e29f7d880b2158971d805b6e0efc3110d0b3e24e4b8af2190d2b", - "https://deno.land/std@0.140.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", - "https://deno.land/std@0.140.0/path/glob.ts": "cb5255638de1048973c3e69e420c77dc04f75755524cb3b2e160fe9277d939ee", - "https://deno.land/std@0.140.0/path/mod.ts": "d3e68d0abb393fb0bf94a6d07c46ec31dc755b544b13144dee931d8d5f06a52d", - "https://deno.land/std@0.140.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", - "https://deno.land/std@0.140.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", - "https://deno.land/std@0.140.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", - "https://deno.land/std@0.140.0/streams/conversion.ts": "712585bfa0172a97fb68dd46e784ae8ad59d11b88079d6a4ab098ff42e697d21", - "https://deno.land/std@0.172.0/fmt/colors.ts": "938c5d44d889fb82eff6c358bea8baa7e85950a16c9f6dae3ec3a7a729164471", - "https://deno.land/std@0.172.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.172.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.172.0/testing/asserts.ts": "984ab0bfb3faeed92ffaa3a6b06536c66811185328c5dd146257c702c41b01ab", - "https://deno.land/std@0.181.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.181.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.181.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.181.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", - "https://deno.land/std@0.181.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.181.0/fs/walk.ts": "ea95ffa6500c1eda6b365be488c056edc7c883a1db41ef46ec3bf057b1c0fe32", - "https://deno.land/std@0.181.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.181.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.181.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.181.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.181.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.181.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.181.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.181.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.181.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.182.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", - "https://deno.land/std@0.182.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", - "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", - "https://deno.land/std@0.182.0/fs/_util.ts": "65381f341af1ff7f40198cee15c20f59951ac26e51ddc651c5293e24f9ce6f32", - "https://deno.land/std@0.182.0/fs/empty_dir.ts": "c3d2da4c7352fab1cf144a1ecfef58090769e8af633678e0f3fabaef98594688", - "https://deno.land/std@0.182.0/fs/expand_glob.ts": "e4f56259a0a70fe23f05215b00de3ac5e6ba46646ab2a06ebbe9b010f81c972a", - "https://deno.land/std@0.182.0/fs/walk.ts": "920be35a7376db6c0b5b1caf1486fb962925e38c9825f90367f8f26b5e5d0897", - "https://deno.land/std@0.182.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.182.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.182.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", - "https://deno.land/std@0.182.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", - "https://deno.land/std@0.182.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", - "https://deno.land/std@0.182.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", - "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", - "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", - "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.206.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.206.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.206.0/fs/_util.ts": "fbf57dcdc9f7bc8128d60301eece608246971a7836a3bb1e78da75314f08b978", - "https://deno.land/std@0.206.0/fs/copy.ts": "ca19e4837965914471df38fbd61e16f9e8adfe89f9cffb0c83615c83ea3fc2bf", - "https://deno.land/std@0.206.0/fs/empty_dir.ts": "0b4a2508232446eed232ad1243dd4b0f07ac503a281633ae1324d1528df70964", - "https://deno.land/std@0.206.0/fs/ensure_dir.ts": "dc64c4c75c64721d4e3fb681f1382f803ff3d2868f08563ff923fdd20d071c40", - "https://deno.land/std@0.206.0/fs/ensure_file.ts": "39ac83cc283a20ec2735e956adf5de3e8a3334e0b6820547b5772f71c49ae083", - "https://deno.land/std@0.206.0/fs/ensure_link.ts": "c15e69c48556d78aae31b83e0c0ece04b7b8bc0951412f5b759aceb6fde7f0ac", - "https://deno.land/std@0.206.0/fs/ensure_symlink.ts": "b389c8568f0656d145ac7ece472afe710815cccbb2ebfd19da7978379ae143fe", - "https://deno.land/std@0.206.0/fs/eol.ts": "f1f2eb348a750c34500741987b21d65607f352cf7205f48f4319d417fff42842", - "https://deno.land/std@0.206.0/fs/exists.ts": "cb59a853d84871d87acab0e7936a4dac11282957f8e195102c5a7acb42546bb8", - "https://deno.land/std@0.206.0/fs/expand_glob.ts": "4f98c508fc9e40d6311d2f7fd88aaad05235cc506388c22dda315e095305811d", - "https://deno.land/std@0.206.0/fs/mod.ts": "bc3d0acd488cc7b42627044caf47d72019846d459279544e1934418955ba4898", - "https://deno.land/std@0.206.0/fs/move.ts": "b4f8f46730b40c32ea3c0bc8eb0fd0e8139249a698883c7b3756424cf19785c9", - "https://deno.land/std@0.206.0/fs/walk.ts": "c1e6b43f72a46e89b630140308bd51a4795d416a416b4cfb7cd4bd1e25946723", - "https://deno.land/std@0.206.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946", - "https://deno.land/std@0.206.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a", - "https://deno.land/std@0.206.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143", - "https://deno.land/std@0.206.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.206.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", - "https://deno.land/std@0.206.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96", - "https://deno.land/std@0.206.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22", - "https://deno.land/std@0.206.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156", - "https://deno.land/std@0.206.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", - "https://deno.land/std@0.206.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589", - "https://deno.land/std@0.206.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4", - "https://deno.land/std@0.206.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65", - "https://deno.land/std@0.206.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae", - "https://deno.land/std@0.206.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.206.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2", - "https://deno.land/std@0.206.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3", - "https://deno.land/std@0.206.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5", - "https://deno.land/std@0.206.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361", - "https://deno.land/std@0.206.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2", - "https://deno.land/std@0.206.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb", - "https://deno.land/std@0.206.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8", - "https://deno.land/std@0.206.0/path/glob.ts": "b8333cbb4aaaeb54ca6d6c43e0b69fb13c9481c69ed7a3c64a3d0d9daf2af769", - "https://deno.land/std@0.206.0/path/glob_to_regexp.ts": "74d7448c471e293d03f05ccb968df4365fed6aaa508506b6325a8efdc01d8271", - "https://deno.land/std@0.206.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13", - "https://deno.land/std@0.206.0/path/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad", - "https://deno.land/std@0.206.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09", - "https://deno.land/std@0.206.0/path/join_globs.ts": "9b84d5103b63d3dbed4b2cf8b12477b2ad415c7d343f1488505162dc0e5f4db8", - "https://deno.land/std@0.206.0/path/mod.ts": "51c48d6da76cad6029b134951732025bc81910ef83f854d9e9c4581a1cc0155a", - "https://deno.land/std@0.206.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66", - "https://deno.land/std@0.206.0/path/normalize_glob.ts": "674baa82e1c00b6cb153bbca36e06f8e0337cb8062db6d905ab5de16076ca46b", - "https://deno.land/std@0.206.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece", - "https://deno.land/std@0.206.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5", - "https://deno.land/std@0.206.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a", - "https://deno.land/std@0.206.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", - "https://deno.land/std@0.206.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472", - "https://deno.land/std@0.206.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0", - "https://deno.land/std@0.206.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968", - "https://deno.land/std@0.206.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379", - "https://deno.land/std@0.206.0/path/posix/glob_to_regexp.ts": "6ed00c71fbfe0ccc35977c35444f94e82200b721905a60bd1278b1b768d68b1a", - "https://deno.land/std@0.206.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8", - "https://deno.land/std@0.206.0/path/posix/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", - "https://deno.land/std@0.206.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076", - "https://deno.land/std@0.206.0/path/posix/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", - "https://deno.land/std@0.206.0/path/posix/mod.ts": "f1b08a7f64294b7de87fc37190d63b6ce5b02889af9290c9703afe01951360ae", - "https://deno.land/std@0.206.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b", - "https://deno.land/std@0.206.0/path/posix/normalize_glob.ts": "10a1840c628ebbab679254d5fa1c20e59106102354fb648a1765aed72eb9f3f9", - "https://deno.land/std@0.206.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e", - "https://deno.land/std@0.206.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c", - "https://deno.land/std@0.206.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285", - "https://deno.land/std@0.206.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1", - "https://deno.land/std@0.206.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6", - "https://deno.land/std@0.206.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc", - "https://deno.land/std@0.206.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c", - "https://deno.land/std@0.206.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867", - "https://deno.land/std@0.206.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f", - "https://deno.land/std@0.206.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864", - "https://deno.land/std@0.206.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a", - "https://deno.land/std@0.206.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54", - "https://deno.land/std@0.206.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3", - "https://deno.land/std@0.206.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", - "https://deno.land/std@0.206.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94", - "https://deno.land/std@0.206.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614", - "https://deno.land/std@0.206.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf", - "https://deno.land/std@0.206.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199", - "https://deno.land/std@0.206.0/path/windows/glob_to_regexp.ts": "290755e18ec6c1a4f4d711c3390537358e8e3179581e66261a0cf348b1a13395", - "https://deno.land/std@0.206.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c", - "https://deno.land/std@0.206.0/path/windows/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", - "https://deno.land/std@0.206.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16", - "https://deno.land/std@0.206.0/path/windows/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", - "https://deno.land/std@0.206.0/path/windows/mod.ts": "d7040f461465c2c21c1c68fc988ef0bdddd499912138cde3abf6ad60c7fb3814", - "https://deno.land/std@0.206.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69", - "https://deno.land/std@0.206.0/path/windows/normalize_glob.ts": "344ff5ed45430495b9a3d695567291e50e00b1b3b04ea56712a2acf07ab5c128", - "https://deno.land/std@0.206.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5", - "https://deno.land/std@0.206.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649", - "https://deno.land/std@0.206.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b", - "https://deno.land/std@0.206.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d", - "https://deno.land/std@0.206.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", - "https://deno.land/std@0.206.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", - "https://deno.land/x/code_block_writer@11.0.0/comment_char.ts": "22b66890bbdf7a2d59777ffd8231710c1fda1c11fadada67632a596937a1a314", - "https://deno.land/x/code_block_writer@11.0.0/mod.ts": "dc43d56c3487bae02886a09754fb09c607da4ea866817e80f3e60632f3391d70", - "https://deno.land/x/code_block_writer@11.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", - "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", - "https://deno.land/x/deno_cache@0.2.1/auth_tokens.ts": "01b94d25abd974153a3111653998b9a43c66d84a0e4b362fc5f4bbbf40a6e0f7", - "https://deno.land/x/deno_cache@0.2.1/cache.ts": "67e301c20161546fea45405316314f4c3d85cc7a367b2fb72042903f308f55b7", - "https://deno.land/x/deno_cache@0.2.1/deno_dir.ts": "e4dc68da5641aa337bcc06fb1df28fcb086b366dcbea7d8aaed7ac7c853fedb1", - "https://deno.land/x/deno_cache@0.2.1/deps.ts": "2ebaba0ad86fff8b9027c6afd4c3909a17cd8bf8c9e263151c980c15c56a18ee", - "https://deno.land/x/deno_cache@0.2.1/dirs.ts": "e07003fabed7112375d4a50040297aae768f9d06bb6c2655ca46880653b576b4", - "https://deno.land/x/deno_cache@0.2.1/disk_cache.ts": "d7a361f0683a032bcca28513a7bbedc28c77cfcc6719e6f6cea156c0ff1108df", - "https://deno.land/x/deno_cache@0.2.1/file_fetcher.ts": "352702994c190c45215f3b8086621e117e88bc2174b020faefb5eca653d71d6a", - "https://deno.land/x/deno_cache@0.2.1/http_cache.ts": "af1500149496e2d0acadec24569e2a9c86a3f600cceef045dcf6f5ce8de72b3a", - "https://deno.land/x/deno_cache@0.2.1/mod.ts": "709ab9d1068be5fd77b020b33e7a9394f1e9b453553b1e2336b72c90283cf3c0", - "https://deno.land/x/deno_cache@0.2.1/util.ts": "652479928551259731686686ff2df6f26bc04e8e4d311137b2bf3bc10f779f48", - "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", - "https://deno.land/x/deno_cache@0.4.1/cache.ts": "51f72f4299411193d780faac8c09d4e8cbee951f541121ef75fcc0e94e64c195", - "https://deno.land/x/deno_cache@0.4.1/deno_dir.ts": "f2a9044ce8c7fe1109004cda6be96bf98b08f478ce77e7a07f866eff1bdd933f", - "https://deno.land/x/deno_cache@0.4.1/deps.ts": "8974097d6c17e65d9a82d39377ae8af7d94d74c25c0cbb5855d2920e063f2343", - "https://deno.land/x/deno_cache@0.4.1/dirs.ts": "d2fa473ef490a74f2dcb5abb4b9ab92a48d2b5b6320875df2dee64851fa64aa9", - "https://deno.land/x/deno_cache@0.4.1/disk_cache.ts": "1f3f5232cba4c56412d93bdb324c624e95d5dd179d0578d2121e3ccdf55539f9", - "https://deno.land/x/deno_cache@0.4.1/file_fetcher.ts": "07a6c5f8fd94bf50a116278cc6012b4921c70d2251d98ce1c9f3c352135c39f7", - "https://deno.land/x/deno_cache@0.4.1/http_cache.ts": "f632e0d6ec4a5d61ae3987737a72caf5fcdb93670d21032ddb78df41131360cd", - "https://deno.land/x/deno_cache@0.4.1/mod.ts": "ef1cda9235a93b89cb175fe648372fc0f785add2a43aa29126567a05e3e36195", - "https://deno.land/x/deno_cache@0.4.1/util.ts": "8cb686526f4be5205b92c819ca2ce82220aa0a8dd3613ef0913f6dc269dbbcfe", - "https://deno.land/x/deno_graph@0.6.0/lib/deno_graph.generated.js": "3e1cccd6376d4ad0ea789d66aa0f6b19f737fa8da37b5e6185ef5c269c974f54", - "https://deno.land/x/deno_graph@0.6.0/lib/loader.ts": "13a11c1dea0d85e0ad211be77217b8c06138bbb916afef6f50a04cca415084a9", - "https://deno.land/x/deno_graph@0.6.0/lib/media_type.ts": "36be751aa63d6ae36475b90dca5fae8fd7c3a77cf13684c48cf23a85ee607b31", - "https://deno.land/x/deno_graph@0.6.0/lib/snippets/deno_graph-1c138d6136337537/src/deno_apis.js": "f13f2678d875372cf8489ceb7124623a39fa5bf8de8ee1ec722dbb2ec5ec7845", - "https://deno.land/x/deno_graph@0.6.0/lib/types.d.ts": "68cb232e02a984658b40ffaf6cafb979a06fbfdce7f5bd4c7a83ed1a32a07687", - "https://deno.land/x/deno_graph@0.6.0/mod.ts": "8fe3d39bdcb273adfb41a0bafbbaabec4c6fe6c611b47fed8f46f218edb37e8e", - "https://deno.land/x/dir@1.5.1/data_local_dir/mod.ts": "91eb1c4bfadfbeda30171007bac6d85aadacd43224a5ed721bbe56bc64e9eb66", - "https://deno.land/x/dnt@0.22.0/lib/compiler.ts": "9e82c4eebf06e0f948488f148cb5da3ac05480796d5567ef9120800903a8c7f4", - "https://deno.land/x/dnt@0.22.0/lib/compiler_transforms.ts": "316c24175fe6a5d7ac6bb1dd44d14ef8010ea5773a3ac918db4d64f986402d8b", - "https://deno.land/x/dnt@0.22.0/lib/mod.deps.ts": "e499a8363a3d8f909a2334feaf5835dd4ea7bfafd5a7f770ba239e6c0927e7c9", - "https://deno.land/x/dnt@0.22.0/lib/npm_ignore.ts": "36fe32008cd71e995bc08569d2b43e8fba816cbada82fa37d1fe52358d5a2e17", - "https://deno.land/x/dnt@0.22.0/lib/package_json.ts": "ad4a7255ff1f97777a31b163549c2ed90a59c2eaaf19e44c0b3023054bdae8ed", - "https://deno.land/x/dnt@0.22.0/lib/pkg/dnt_wasm.js": "a6a460a97647ab30cff2b22f81bb3701a320cb7d7d18d0fb8c048f7a2db1ac7b", - "https://deno.land/x/dnt@0.22.0/lib/pkg/dnt_wasm_bg.ts": "cbb34c17fd0da4e0e60ab140e72d7faed6ebd240a802b3079e8b7db681d237d4", - "https://deno.land/x/dnt@0.22.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "2f623f83602d4fbb30caa63444b10e35b45e9c2b267e49585ec9bb790a4888d8", - "https://deno.land/x/dnt@0.22.0/lib/shims.ts": "4c6b4b1bb48b58d1071e74f0daab29160e02716fe8886f8b5b069291214777d7", - "https://deno.land/x/dnt@0.22.0/lib/test_runner/get_test_runner_code.ts": "5fe5543c8479b5f17c58db4d994de3f3d573e3ca7e4c32c7cf8e338e8e900ba7", - "https://deno.land/x/dnt@0.22.0/lib/test_runner/test_runner.ts": "976920b8c69f26f5316942a66f8957fbc53c105e0cefd9cdef3d2f7c385ec5a2", - "https://deno.land/x/dnt@0.22.0/lib/transform.deps.ts": "2fce3a37e40d40f06faeae5f93322cccdcc8a4a0ee68b27c7022677d882df5be", - "https://deno.land/x/dnt@0.22.0/lib/types.ts": "8506b5ced3921a6ac2a1d5a2bb381bfdbf818c68207f14a1a1fffbf48ee95886", - "https://deno.land/x/dnt@0.22.0/lib/utils.ts": "d2681d634dfa6bd4ad2a32ad15bd419f6f1f895e06c0bf479455fbf1c5f49cd9", - "https://deno.land/x/dnt@0.22.0/mod.ts": "1038999528085c2def6b53a83d50973cf7946598d6d5b64e29457dde4bafcf76", - "https://deno.land/x/dnt@0.22.0/transform.ts": "5829d1b0b03026a07a5630c19a1676defbce8cd09a01f442d362b30176097f7b", - "https://deno.land/x/dnt@0.37.0/lib/compiler.ts": "209ad2e1b294f93f87ec02ade9a0821f942d2e524104552d0aa8ff87021050a5", - "https://deno.land/x/dnt@0.37.0/lib/compiler_transforms.ts": "cbb1fd5948f5ced1aa5c5aed9e45134e2357ce1e7220924c1d7bded30dcd0dd0", - "https://deno.land/x/dnt@0.37.0/lib/mod.deps.ts": "30367fc68bcd2acf3b7020cf5cdd26f817f7ac9ac35c4bfb6c4551475f91bc3e", - "https://deno.land/x/dnt@0.37.0/lib/npm_ignore.ts": "b430caa1905b65ae89b119d84857b3ccc3cb783a53fc083d1970e442f791721d", - "https://deno.land/x/dnt@0.37.0/lib/package_json.ts": "61f35b06e374ed39ca776d29d67df4be7ee809d0bca29a8239687556c6d027c2", - "https://deno.land/x/dnt@0.37.0/lib/pkg/dnt_wasm.generated.js": "65514d733c044bb394e4765321e33b73c490b20f86563293b5665d7a7b185153", - "https://deno.land/x/dnt@0.37.0/lib/pkg/snippets/dnt-wasm-a15ef721fa5290c5/helpers.js": "a6b95adc943a68d513fe8ed9ec7d260ac466b7a4bced4e942f733e494bb9f1be", - "https://deno.land/x/dnt@0.37.0/lib/shims.ts": "df1bd4d9a196dca4b2d512b1564fff64ac6c945189a273d706391f87f210d7e6", - "https://deno.land/x/dnt@0.37.0/lib/test_runner/get_test_runner_code.ts": "4dc7a73a13b027341c0688df2b29a4ef102f287c126f134c33f69f0339b46968", - "https://deno.land/x/dnt@0.37.0/lib/test_runner/test_runner.ts": "4d0da0500ec427d5f390d9a8d42fb882fbeccc92c92d66b6f2e758606dbd40e6", - "https://deno.land/x/dnt@0.37.0/lib/transform.deps.ts": "e42f2bdef46d098453bdba19261a67cf90b583f5d868f7fe83113c1380d9b85c", - "https://deno.land/x/dnt@0.37.0/lib/types.ts": "b8e228b2fac44c2ae902fbb73b1689f6ab889915bd66486c8a85c0c24255f5fb", - "https://deno.land/x/dnt@0.37.0/lib/utils.ts": "878b7ac7003a10c16e6061aa49dbef9b42bd43174853ebffc9b67ea47eeb11d8", - "https://deno.land/x/dnt@0.37.0/mod.ts": "37d0c784371cf1750f30203a95de2555ba4c1aa89d826024f14c038f87e0f344", - "https://deno.land/x/dnt@0.37.0/transform.ts": "1b127c5f22699c8ab2545b98aeca38c4e5c21405b0f5342ea17e9c46280ed277", - "https://deno.land/x/get_pixels@v1.2.1/mod.ts": "5710900b4ef304edb0f8621d63419b927340ebdcafe67263cffd76669cfa0db8", - "https://deno.land/x/get_pixels@v1.2.1/src/get-pixels.ts": "d2a4ec97dfd2966a50120a3c41c64f8538b2f73433622bf0a25b8cd6480e5c54", - "https://deno.land/x/jpegts@1.1/lib/decoder.ts": "b8823ee917fc99a1e085c353b2cd4ca1fbaacaaf1f80be94175e42487666c822", - "https://deno.land/x/jpegts@1.1/lib/encoder.ts": "d75fc5ae88f77e1fa349af08d7a46937add31a03b6d3605ff4d2e3303eb32b3b", - "https://deno.land/x/jpegts@1.1/lib/image.ts": "32255e99b6c1bf4e72e13367516a0d878ba9d195d81fac1f145ce4c11e951962", - "https://deno.land/x/jpegts@1.1/lib/pixel.ts": "19f7f28f09514157be87d01d1d12915cc17c787d8c1707899d2f5570ac6a12fe", - "https://deno.land/x/jpegts@1.1/mod.ts": "2014257f7269bcc822a4d6eb871e5002b347f13a641c12d45c1a61586374f127", - "https://deno.land/x/lz4@v0.1.2/mod.ts": "4decfc1a3569d03fd1813bd39128b71c8f082850fe98ecfdde20025772916582", - "https://deno.land/x/lz4@v0.1.2/wasm.js": "b9c65605327ba273f0c76a6dc596ec534d4cda0f0225d7a94ebc606782319e46", - "https://deno.land/x/pngs@0.1.1/mod.ts": "9dc8a7daed1497b94a77b68c954164a9f0b2a6f40866481bdfdbbaf015b5f764", - "https://deno.land/x/pngs@0.1.1/wasm.js": "e3d4a8f293b267c9859a2164ca7b4603869bc92fe0d5ad4f109925858bce0c4c", - "https://deno.land/x/ts_morph@14.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@14.0.0/bootstrap/ts_morph_bootstrap.d.ts": "2be47f54ceb6ef524ed0e2e9f80776d93276a1edadfa2191680927dadd3ccd76", - "https://deno.land/x/ts_morph@14.0.0/bootstrap/ts_morph_bootstrap.js": "7038365181fb388668289e35142ee43881aa053386ca5f86f276edacf42859c7", - "https://deno.land/x/ts_morph@14.0.0/common/DenoRuntime.ts": "9499b723d5e06dc609c170f6ebe239f70535c91ba422720adddc28ef9bd03905", - "https://deno.land/x/ts_morph@14.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@14.0.0/common/ts_morph_common.d.ts": "aacba92e65115e95113ad3c6e652f349434488712a65e18a6642076cfc234235", - "https://deno.land/x/ts_morph@14.0.0/common/ts_morph_common.js": "9f616b75e0decd08f699b9721e886766275ab5e17d766afe55319fb6e6d7037b", - "https://deno.land/x/ts_morph@14.0.0/common/typescript.d.ts": "ba00bb2ada9a5b7e0ab18c7282c0161f5af809112e4439b35c8b3853f7d436a7", - "https://deno.land/x/ts_morph@14.0.0/common/typescript.js": "d6b532a181b94359894da7559663d7430396c8b4a5d8ed436601dc46ba542ee9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/mod.ts": "b53aad517f106c4079971fcd4a81ab79fadc40b50061a3ab2b741a09119d51e9", - "https://deno.land/x/ts_morph@18.0.0/bootstrap/ts_morph_bootstrap.js": "6645ac03c5e6687dfa8c78109dc5df0250b811ecb3aea2d97c504c35e8401c06", - "https://deno.land/x/ts_morph@18.0.0/common/DenoRuntime.ts": "6a7180f0c6e90dcf23ccffc86aa8271c20b1c4f34c570588d08a45880b7e172d", - "https://deno.land/x/ts_morph@18.0.0/common/mod.ts": "01985d2ee7da8d1caee318a9d07664774fbee4e31602bc2bb6bb62c3489555ed", - "https://deno.land/x/ts_morph@18.0.0/common/ts_morph_common.js": "845671ca951073400ce142f8acefa2d39ea9a51e29ca80928642f3f8cf2b7700", - "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59", - "https://deno.land/x/wasmbuild@0.13.0/cache.ts": "89eea5f3ce6035a1164b3e655c95f21300498920575ade23161421f5b01967f4", - "https://deno.land/x/wasmbuild@0.13.0/loader.ts": "d98d195a715f823151cbc8baa3f32127337628379a02d9eb2a3c5902dbccfc02", + "https://deno.land/std@0.186.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.186.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.186.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.186.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", "https://esm.sh/preact@10.11.2/jsx-runtime": "a5aaf30a5718f778318c62218b57b8ffd74a240e8a2406ffa81f7895c6f7dbb6", "https://esm.sh/stable/preact@10.11.2/denonext/jsx-runtime.js": "ed453ca93891a111997d4a0039569103025ebff9a0447f90578f5c0f4e8ae4c7", "https://esm.sh/stable/preact@10.11.2/denonext/preact.mjs": "deeb0873c83971be55cddc2f90bf0099a4c86adb3797884300677a3daf1b70b7" diff --git a/e2e.test.ts b/e2e.test.ts index 30fc2bb..56cd72f 100644 --- a/e2e.test.ts +++ b/e2e.test.ts @@ -1,4 +1,4 @@ -import { assertAlmostEquals, assertExists } from "jsr:@std/assert"; +import { assert, assertAlmostEquals, assertExists } from "jsr:@std/assert"; import examples from "./demo/src/examples.json" with { type: "json" }; import { getPixels } from "jsr:@unpic/pixels"; import { transformUrl } from "./src/transform.ts"; @@ -10,21 +10,27 @@ Deno.test("E2E tests", async (t) => { // ImageEngine is really flaky, so ignore it, and the supabase example is // broken const ignore = ["imageengine", "supabase"].includes(cdn); + const ignoreAspectRatio = [ + "imageengine", + "supabase", + "vercel", + "nextjs", + ] + .includes(cdn); await t.step({ name: `${name} resizes an image`, fn: async () => { + const size = cdn === "vercel" ? 256 : 96; const image = transformUrl({ url, - width: 100, + width: size, cdn: cdn as ImageCdn, format: "jpg", }); - assertExists(image, `Failed to resize ${name} with ${cdn}`); const { width } = await getPixels(image); - - assertAlmostEquals(width, 100, 1); + assertAlmostEquals(width, size, 1); }, ignore, }); @@ -43,11 +49,10 @@ Deno.test("E2E tests", async (t) => { assertExists(image, `Failed to resize ${name} with ${cdn}`); const { width, height } = await getPixels(image); - assertAlmostEquals(width, 100, 1); assertAlmostEquals(height, 50, 1); }, - ignore, + ignore: ignoreAspectRatio, }); } }); diff --git a/mod.ts b/mod.ts index 2ac690b..aca81bc 100644 --- a/mod.ts +++ b/mod.ts @@ -1,5 +1,4 @@ export * from "./src/types.ts"; export * from "./src/transform.ts"; export * from "./src/detect.ts"; -export * from "./src/parse.ts"; -export * from "./src/canonical.ts"; +export * from "./src/extract.ts"; diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 08e40cb..0b2b1e2 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -4,22 +4,38 @@ import { walk } from "jsr:@std/fs"; await emptyDir("./npm"); -const transformers = await Array.fromAsync(walk("./src/transformers", { +const providers = await Array.fromAsync(walk("./src/providers", { match: [/^(?!.*test\.ts$).*\.ts$/], })); -const entry = transformers.map((entry) => ({ - path: entry.path, - name: `./transformers/${basename(entry.path, ".ts")}`, +const entry = providers.map((t) => ({ + path: t.path, + name: `./providers/${basename(t.path, ".ts")}`, })); await build({ entryPoints: [ "./mod.ts", + { + path: "./src/async.ts", + name: "./async", + }, { path: "./src/detect.ts", name: "./detect", }, + { + path: "./src/extract.ts", + name: "./extract", + }, + { + path: "./src/types.ts", + name: "./types", + }, + { + path: "./src/transform.ts", + name: "./transform", + }, ...entry, ], outDir: "./npm", @@ -55,3 +71,4 @@ await build({ // post build steps Deno.copyFileSync("README.md", "npm/README.md"); +Deno.copyFileSync("CONTRIBUTING.md", "npm/CONTRIBUTING.md"); diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 0000000..0a0c8f0 --- /dev/null +++ b/src/async.ts @@ -0,0 +1,127 @@ +import { getProviderForUrl } from "./detect.ts"; +import type { ProviderOperations, ProviderOptions } from "./providers/types.ts"; +import type { ProviderModule } from "./providers/types.ts"; +import type { + ImageCdn, + URLExtractor, + URLGenerator, + URLTransformer, + UrlTransformerOptions, +} from "./types.ts"; + +type AsyncProviderMap = { + [T in ImageCdn]: () => Promise>; +}; +const asyncProviderMap: AsyncProviderMap = { + astro: () => import("./providers/astro.ts"), + "builder.io": () => import("./providers/builder.io.ts"), + bunny: () => import("./providers/bunny.ts"), + cloudflare: () => import("./providers/cloudflare.ts"), + cloudflare_images: () => import("./providers/cloudflare_images.ts"), + cloudimage: () => import("./providers/cloudimage.ts"), + cloudinary: () => import("./providers/cloudinary.ts"), + contentful: () => import("./providers/contentful.ts"), + contentstack: () => import("./providers/contentstack.ts"), + directus: () => import("./providers/directus.ts"), + hygraph: () => import("./providers/hygraph.ts"), + imageengine: () => import("./providers/imageengine.ts"), + imagekit: () => import("./providers/imagekit.ts"), + imgix: () => import("./providers/imgix.ts"), + ipx: () => import("./providers/ipx.ts"), + keycdn: () => import("./providers/keycdn.ts"), + "kontent.ai": () => import("./providers/kontent.ai.ts"), + netlify: () => import("./providers/netlify.ts"), + nextjs: () => import("./providers/nextjs.ts"), + scene7: () => import("./providers/scene7.ts"), + shopify: () => import("./providers/shopify.ts"), + storyblok: () => import("./providers/storyblok.ts"), + supabase: () => import("./providers/supabase.ts"), + uploadcare: () => import("./providers/uploadcare.ts"), + vercel: () => import("./providers/vercel.ts"), + wordpress: () => import("./providers/wordpress.ts"), +}; + +/** + * Returns a parser function if the given URL is from a known image CDN + */ +export const getExtractorForUrl = < + TCDN extends ImageCdn = ImageCdn, +>( + url: string | URL, +): Promise | undefined> => + getExtractorForProvider(getProviderForUrl(url) as TCDN); + +/** + * Dynamically loads the module for the given provider + */ +export function getModuleForProvider< + TCDN extends ImageCdn, +>( + cdn: TCDN | false | undefined, +): Promise> | undefined { + if (!cdn) { + return undefined; + } + + return asyncProviderMap[cdn]?.() as Promise>; +} + +/** + * Dynamically loads the extract function for the given provider + */ +export const getExtractorForProvider = async < + TCDN extends ImageCdn, +>( + cdn: TCDN | false | undefined, +): Promise | undefined> => + (await getModuleForProvider(cdn))?.extract; + +/** + * Dynamically loads the generate function for the given provider + */ +export const getGeneratorForProvider = async < + TCDN extends ImageCdn, +>( + cdn: TCDN | false | undefined, +): Promise | undefined> => + (await getModuleForProvider(cdn))?.generate; + +/** + * Dynamically loads the transform function for the given provider + */ +export const getTransformerForProvider = async < + TCDN extends ImageCdn, +>( + cdn: TCDN | false | undefined, +): Promise | undefined> => + (await getModuleForProvider(cdn))?.transform; + +/** + * Transforms an image URL to a new URL with the given options. + * If the URL is not from a known image CDN it returns undefined. + * + * This function is async because it dynamically loads the module for the provider. + * If you need a synchronous version, import from the root module instead. + */ +export async function transformUrl( + url: string | URL, + { provider, cdn: cdnOption, fallback, ...operations }: UrlTransformerOptions< + TCDN + >, + providerOperations?: Partial, + providerOptions?: Partial, +): Promise { + const cdn = provider || cdnOption || + getProviderForUrl(url) as TCDN || fallback; + + if (!cdn) { + return undefined; + } + + const transformer = await getTransformerForProvider(cdn); + + return transformer?.(url, { + ...operations as ProviderOperations[TCDN], + ...providerOperations?.[cdn], + }, providerOptions?.[cdn] ?? {} as ProviderOptions[TCDN]); +} diff --git a/src/canonical.test.ts b/src/canonical.test.ts deleted file mode 100644 index 22176a6..0000000 --- a/src/canonical.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { assertEquals, assertExists } from "jsr:@std/assert"; -import { getCanonicalCdnForUrl } from "./canonical.ts"; - -const nextImgLocal = - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=3840&q=75"; - -const nextImgRemote = - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75"; - -Deno.test("Canonical", async (t) => { - await t.step("should detect a local image server", () => { - const result = getCanonicalCdnForUrl(nextImgLocal) || undefined; - assertExists(result); - assertEquals( - result?.url, - nextImgLocal, - ); - assertEquals( - result?.cdn, - "nextjs", - ); - }); - - await t.step( - "should detect a remote image source with a local image server", - () => { - const result = getCanonicalCdnForUrl(nextImgRemote) || undefined; - assertExists(result); - assertEquals( - result?.url, - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", - ); - assertEquals( - result?.cdn, - "imgix", - ); - }, - ); - - await t.step( - "should fall back to the default CDN for unrecognized image domains - vercel", - () => { - const result = getCanonicalCdnForUrl( - "https://placekitten.com/100", - "vercel", - ) || - undefined; - assertExists(result); - assertEquals( - result?.url, - "https://placekitten.com/100", - ); - assertEquals( - result?.cdn, - "vercel", - ); - }, - ); - - await t.step( - "should fall back to the default CDN for unrecognized image domains - ipx", - () => { - const result = - getCanonicalCdnForUrl("https://placekitten.com/100", "ipx") || - undefined; - assertExists(result); - assertEquals( - result?.url, - "https://placekitten.com/100", - ); - assertEquals( - result?.cdn, - "ipx", - ); - }, - ); - - await t.step( - "should fall back to the detected local CDN for unrecognized source image domains", - () => { - const unknownDomain = - "https://example.com/_next/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75"; - const result = getCanonicalCdnForUrl( - unknownDomain, - ) || - undefined; - assertExists(result); - assertEquals( - result?.url, - unknownDomain, - ); - assertEquals( - result?.cdn, - "nextjs", - ); - }, - ); -}); diff --git a/src/canonical.ts b/src/canonical.ts deleted file mode 100644 index 9bcd4a8..0000000 --- a/src/canonical.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getImageCdnForUrl } from "./detect.ts"; -import { CanonicalCdnUrl, ImageCdn, ShouldDelegateUrl } from "./types.ts"; -import { delegateUrl as vercel } from "./transformers/vercel.ts"; -import { delegateUrl as nextjs } from "./transformers/nextjs.ts"; - -// Image servers that might delegate to another CDN -const delegators: Partial> = { - vercel, - nextjs, -}; - -export function getDelegatedCdn( - url: string | URL, - cdn: ImageCdn, -): CanonicalCdnUrl | false { - // Most CDNs are authoritative for their own URLs - if (!(cdn in delegators)) { - return false; - } - const maybeDelegate = delegators[cdn]; - if (!maybeDelegate) { - return false; - } - return maybeDelegate(url); -} - -/** - * Gets the canonical URL and CDN for a given image URL, recursing into - * the source image if it is hosted on another CDN. - */ -export function getCanonicalCdnForUrl( - url: string | URL, - defaultCdn?: ImageCdn | false, -): CanonicalCdnUrl | false { - const cdn = getImageCdnForUrl(url) || defaultCdn; - if (!cdn) { - return false; - } - const maybeDelegated = getDelegatedCdn(url, cdn); - if (maybeDelegated) { - return maybeDelegated; - } - return { cdn, url }; -} diff --git a/src/detect.ts b/src/detect.ts index 98f6eba..5cf5be3 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,45 +1,62 @@ import domains from "../data/domains.json" with { type: "json" }; import subdomains from "../data/subdomains.json" with { type: "json" }; import paths from "../data/paths.json" with { type: "json" }; -import { ImageCdn } from "./types.ts"; +import type { ImageCdn } from "./types.ts"; import { toUrl } from "./utils.ts"; -const cdnDomains = new Map(Object.entries(domains)); -const cdnSubdomains = Object.entries(subdomains); +const cdnDomains = new Map(Object.entries(domains)) as Map; +const cdnSubdomains = Object.entries(subdomains) as [string, ImageCdn][]; +const cdnPaths = Object.entries(paths) as [string, ImageCdn][]; -export function getImageCdnForUrl( +/** + * Detects the image CDN provider for a given URL. + */ +export function getProviderForUrl( url: string | URL, ): ImageCdn | false { - return getImageCdnForUrlByDomain(url) || getImageCdnForUrlByPath(url); + return getProviderForUrlByDomain(url) || getProviderForUrlByPath(url); } -export function getImageCdnForUrlByDomain( +/** + * @deprecated Use `getProviderForUrl` instead. + */ +export const getImageCdnForUrl = getProviderForUrl; + +export function getProviderForUrlByDomain( url: string | URL, ): ImageCdn | false { if (typeof url === "string" && !url.startsWith("https://")) { return false; } const { hostname } = toUrl(url); - if (cdnDomains.has(hostname)) { - return cdnDomains.get(hostname) as ImageCdn; - } - for (const [subdomain, cdn] of cdnSubdomains) { - if (hostname.endsWith(`.${subdomain}`)) { - return cdn as ImageCdn; - } + const cdn = cdnDomains.get(hostname); + if (cdn) { + return cdn; } - return false; + return cdnSubdomains.find(([subdomain]) => hostname.endsWith(subdomain)) + ?.[1] || false; } -export function getImageCdnForUrlByPath( +/** + * @deprecated Use `getProviderForUrlByDomain` instead. + */ + +export const getImageCdnForUrlByDomain = getProviderForUrlByDomain; + +/** + * Gets the image CDN provider for a given URL by its path. + */ + +export function getProviderForUrlByPath( url: string | URL, ): ImageCdn | false { // Allow relative URLs const { pathname } = toUrl(url); - for (const [prefix, cdn] of Object.entries(paths)) { - if (pathname.startsWith(prefix)) { - return cdn as ImageCdn; - } - } - return false; + return cdnPaths.find(([path]) => pathname.startsWith(path))?.[1] || false; } + +/** + * @deprecated Use `getProviderForUrlByPath` instead. + */ + +export const getImageCdnForUrlByPath = getProviderForUrlByPath; diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..7c2212e --- /dev/null +++ b/src/extract.ts @@ -0,0 +1,116 @@ +import { getProviderForUrl } from "./detect.ts"; +import type { + ProviderOperations, + ProviderOptions, + URLExtractorMap, +} from "./providers/types.ts"; +import type { ImageCdn, ParseURLResult, URLExtractor } from "./types.ts"; + +import { extract as astro } from "./providers/astro.ts"; +import { extract as builder } from "./providers/builder.io.ts"; +import { extract as bunny } from "./providers/bunny.ts"; +import { extract as cloudflare } from "./providers/cloudflare.ts"; +import { extract as cloudflareImages } from "./providers/cloudflare_images.ts"; +import { extract as cloudimage } from "./providers/cloudimage.ts"; +import { extract as cloudinary } from "./providers/cloudinary.ts"; +import { extract as contentful } from "./providers/contentful.ts"; +import { extract as contentstack } from "./providers/contentstack.ts"; +import { extract as directus } from "./providers/directus.ts"; +import { extract as hygraph } from "./providers/hygraph.ts"; +import { extract as imageengine } from "./providers/imageengine.ts"; +import { extract as imagekit } from "./providers/imagekit.ts"; +import { extract as imgix } from "./providers/imgix.ts"; +import { extract as ipx } from "./providers/ipx.ts"; +import { extract as keycdn } from "./providers/keycdn.ts"; +import { extract as kontentai } from "./providers/kontent.ai.ts"; +import { extract as netlify } from "./providers/netlify.ts"; +import { extract as nextjs } from "./providers/nextjs.ts"; +import { extract as scene7 } from "./providers/scene7.ts"; +import { extract as shopify } from "./providers/shopify.ts"; +import { extract as storyblok } from "./providers/storyblok.ts"; +import { extract as supabase } from "./providers/supabase.ts"; +import { extract as uploadcare } from "./providers/uploadcare.ts"; +import { extract as vercel } from "./providers/vercel.ts"; +import { extract as wordpress } from "./providers/wordpress.ts"; + +export const parsers: URLExtractorMap = { + astro, + "builder.io": builder, + bunny, + cloudflare, + cloudflare_images: cloudflareImages, + cloudimage, + cloudinary, + contentful, + contentstack, + directus, + hygraph, + imageengine, + imagekit, + imgix, + ipx, + keycdn, + "kontent.ai": kontentai, + netlify, + nextjs, + scene7, + shopify, + storyblok, + supabase, + uploadcare, + vercel, + wordpress, +} as const; + +/** + * Returns a parser function if the given URL is from a known image CDN + */ +export const getExtractorForUrl = < + TCDN extends ImageCdn = ImageCdn, +>( + url: string | URL, +): URLExtractor | undefined => + getExtractorForProvider(getProviderForUrl(url) as TCDN); + +export const getExtractorForProvider = < + TCDN extends ImageCdn, +>( + cdn: TCDN | false | undefined, +): URLExtractor | undefined => { + if (!cdn) { + return undefined; + } + return parsers[cdn]; +}; + +/** + * Parses an image URL into its components. + * If the URL is not from a known image CDN it returns undefined. + */ +export const parseUrl = < + TCDN extends ImageCdn = ImageCdn, +>( + url: string | URL, + cdn?: TCDN, + options?: ProviderOptions[TCDN], +): ParseURLResult => { + const detectedCdn = cdn || getProviderForUrl(url) as TCDN; + if (!detectedCdn) { + return undefined; + } + + const parser = getExtractorForProvider(detectedCdn); + + if (!parser) { + return { + src: url.toString(), + operations: {} as ProviderOperations[TCDN], + options: {} as ProviderOptions[TCDN], + cdn: detectedCdn, + }; + } + + return { ...parser(url, options), cdn: detectedCdn } as ParseURLResult< + TCDN + >; +}; diff --git a/src/parse.ts b/src/parse.ts deleted file mode 100644 index 4e8892c..0000000 --- a/src/parse.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { getImageCdnForUrl } from "./detect.ts"; -import { parse as contentful } from "./transformers/contentful.ts"; -import { parse as builder } from "./transformers/builder.io.ts"; -import { parse as imgix } from "./transformers/imgix.ts"; -import { parse as shopify } from "./transformers/shopify.ts"; -import { parse as wordpress } from "./transformers/wordpress.ts"; -import { parse as cloudimage } from "./transformers/cloudimage.ts"; -import { parse as cloudinary } from "./transformers/cloudinary.ts"; -import { parse as cloudflare } from "./transformers/cloudflare.ts"; -import { parse as bunny } from "./transformers/bunny.ts"; -import { parse as storyblok } from "./transformers/storyblok.ts"; -import { parse as kontentai } from "./transformers/kontent.ai.ts"; -import { parse as vercel } from "./transformers/vercel.ts"; -import { parse as nextjs } from "./transformers/nextjs.ts"; -import { parse as scene7 } from "./transformers/scene7.ts"; -import { parse as keycdn } from "./transformers/keycdn.ts"; -import { parse as directus } from "./transformers/directus.ts"; -import { parse as imageengine } from "./transformers/imageengine.ts"; -import { parse as contentstack } from "./transformers/contentstack.ts"; -import { parse as cloudflareImages } from "./transformers/cloudflare_images.ts"; -import { parse as ipx } from "./transformers/ipx.ts"; -import { parse as astro } from "./transformers/astro.ts"; -import { parse as netlify } from "./transformers/netlify.ts"; -import { parse as imagekit } from "./transformers/imagekit.ts"; -import { parse as uploadcare } from "./transformers/uploadcare.ts"; -import { parse as supabase } from "./transformers/supabase.ts"; -import { parse as hygraph } from "./transformers/hygraph.ts"; -import { ImageCdn, ParsedUrl, SupportedImageCdn, UrlParser } from "./types.ts"; - -export const parsers = { - imgix, - contentful, - "builder.io": builder, - shopify, - wordpress, - cloudimage, - cloudinary, - cloudflare, - bunny, - storyblok, - "kontent.ai": kontentai, - vercel, - nextjs, - scene7, - keycdn, - directus, - imageengine, - contentstack, - "cloudflare_images": cloudflareImages, - ipx, - astro, - netlify, - imagekit, - uploadcare, - supabase, - hygraph, -}; - -export const cdnIsSupportedForParse = ( - cdn: ImageCdn | false, -): cdn is SupportedImageCdn => cdn && cdn in parsers; - -/** - * Returns a parser function if the given URL is from a known image CDN - * @param url - */ -export const getParserForUrl = >( - url: string | URL, -): UrlParser | undefined => - getParserForCdn(getImageCdnForUrl(url)); - -export const getParserForCdn = >( - cdn: ImageCdn | false | undefined, -): UrlParser | undefined => { - if (!cdn || !cdnIsSupportedForParse(cdn)) { - return undefined; - } - return parsers[cdn] as UrlParser; -}; - -/** - * Parses an image URL into its components. - * If the URL is not from a known image CDN it returns undefined. - * @param url - */ -export const parseUrl = >( - url: string | URL, - cdn?: ImageCdn, -): ParsedUrl | undefined => { - if (cdn) { - return getParserForCdn(cdn)?.(url) as ParsedUrl; - } - const detectedCdn = getImageCdnForUrl(url); - if (!detectedCdn) { - return undefined; - } - if (!cdnIsSupportedForParse(detectedCdn)) { - return { cdn: detectedCdn, base: url.toString() } as ParsedUrl; - } - return getParserForCdn(detectedCdn)?.(url) as ParsedUrl; -}; diff --git a/src/providers/astro.test.ts b/src/providers/astro.test.ts new file mode 100644 index 0000000..14c98c6 --- /dev/null +++ b/src/providers/astro.test.ts @@ -0,0 +1,130 @@ +import { assertEquals } from "jsr:@std/assert"; + +import { extract, generate, transform } from "./astro.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg"; + +const astroUrl = + "http://example.com/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg"; + +Deno.test("astro parser", async (t) => { + await t.step("should parse a URL", () => { + const { operations, src, options } = extract(astroUrl) ?? {}; + assertEquals(src, img); + assertEquals(operations, {}); + assertEquals(options, { baseUrl: "http://example.com" }); + }); + + await t.step("should parse a URL with operations", () => { + const { operations, src, options } = extract( + `${astroUrl}&w=200&h=300&q=80&f=webp`, + ) ?? {}; + assertEquals(src, img); + assertEquals(operations, { + width: 200, + height: 300, + quality: 80, + format: "webp", + }); + assertEquals(options, { baseUrl: "http://example.com" }); + }); +}); + +Deno.test("astro generate", async (t) => { + await t.step("should format a URL", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=cover", + ); + }); + + await t.step("should format a URL with a custom endpoint", () => { + const result = generate(img, { + width: 200, + height: 100, + }, { + endpoint: "/custom", + }); + assertEqualIgnoringQueryOrder( + result, + "/custom?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=cover", + ); + }); + + await t.step("should format a URL with a baseURL", () => { + const result = generate(img, { + width: 200, + height: 100, + }, { + baseUrl: "http://example.com", + }); + assertEqualIgnoringQueryOrder( + result, + "http://example.com/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=cover", + ); + }); + + await t.step("should not set height if not provided", () => { + const result = generate(img, { width: 200 }, { + baseUrl: "http://example.com", + }); + assertEqualIgnoringQueryOrder( + result, + "http://example.com/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&fit=cover", + ); + }); + + await t.step("should override the default fit", () => { + const result = generate(img, { + width: 200, + height: 100, + fit: "contain", + }); + assertEqualIgnoringQueryOrder( + result, + "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=contain", + ); + }); + + await t.step("should round non-integer params", () => { + const result = generate(img, { + width: 200.6, + height: 100.2, + }, {}); + assertEqualIgnoringQueryOrder( + result?.toString(), + "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=201&h=100&fit=cover", + ); + }); + + await t.step("should generate a local image with a relative base", () => { + const result = generate("/static/moose.png", { + width: 100, + height: 200, + format: "webp", + }, {}); + assertEqualIgnoringQueryOrder( + result?.toString(), + "/_image?href=%2Fstatic%2Fmoose.png&w=100&h=200&f=webp&fit=cover", + ); + }); +}); + +Deno.test("astro transform", async (t) => { + await t.step("should transform an Astro URL", () => { + const url = `${astroUrl}&w=200&h=300&q=80&f=webp`; + const transformed = transform(url, { + width: 400, + }, {}); + assertEqualIgnoringQueryOrder( + transformed, + "http://example.com/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=400&h=300&q=80&f=webp&fit=cover", + ); + }); +}); diff --git a/src/providers/astro.ts b/src/providers/astro.ts new file mode 100644 index 0000000..56a492d --- /dev/null +++ b/src/providers/astro.ts @@ -0,0 +1,95 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createOperationsHandlers, + stripTrailingSlash, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const DEFAULT_ENDPOINT = "/_image"; + +export interface AstroOperations extends Operations { + w?: number; + h?: number; + f?: ImageFormat; + q?: number; + fit?: "contain" | "cover" | "fill" | "none" | "scale-down"; +} + +export interface AstroOptions { + baseUrl?: string; + endpoint?: string; +} + +const { operationsParser, operationsGenerator } = createOperationsHandlers< + AstroOperations +>({ + keyMap: { + format: "f", + width: "w", + height: "h", + quality: "q", + }, + defaults: { + fit: "cover", + }, +}); + +export const generate: URLGenerator<"astro"> = ( + src, + modifiers, + options, +) => { + const url = toUrl( + `${stripTrailingSlash(options?.baseUrl ?? "")}${ + options?.endpoint ?? DEFAULT_ENDPOINT + }`, + ); + const operations = operationsGenerator(modifiers); + url.search = operations; + url.searchParams.set("href", src.toString()); + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"astro"> = ( + url, +) => { + const parsedUrl = toUrl(url); + const src = parsedUrl.searchParams.get("href"); + if (!src) { + return null; + } + parsedUrl.searchParams.delete("href"); + const operations = operationsParser(parsedUrl); + return { + src, + operations, + options: { baseUrl: parsedUrl.origin }, + }; +}; + +export const transform: URLTransformer<"astro"> = ( + src, + operations, + options = {}, +) => { + const url = toUrl(src); + if (url.pathname !== (options?.endpoint ?? DEFAULT_ENDPOINT)) { + return generate(src, operations, options); + } + const base = extract(src); + if (!base) { + return generate(src, operations, options); + } + options.baseUrl ??= base.options.baseUrl; + return generate(base.src, { + ...base.operations, + ...operations, + }, options); +}; diff --git a/src/providers/builder.io.test.ts b/src/providers/builder.io.test.ts new file mode 100644 index 0000000..ab6a040 --- /dev/null +++ b/src/providers/builder.io.test.ts @@ -0,0 +1,120 @@ +import { generate, transform } from "./builder.io.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f"; + +Deno.test("builder.io transform", async (t) => { + await t.step("should format a URL", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200&height=100&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should not set height if not provided", () => { + const result = transform(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should delete height if not set", () => { + const url = new URL(img); + url.searchParams.set("height", "100"); + const result = transform(img, { + width: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should round non-integer params", () => { + const result = transform(img, { + width: 200.6, + height: 100.2, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=201&height=100&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should not set fit=cover if another value exists", () => { + const url = new URL(img); + url.searchParams.set("fit", "inside"); + const result = transform(url.toString(), { + width: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?fit=inside&width=200&sharp=true&format=webp", + ); + }); +}); + +Deno.test("builder.io generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200&height=100&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should format a URL with fit type", () => { + const result = generate(img, { + width: 300, + height: 150, + fit: "contain", + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?fit=contain&width=300&height=150&sharp=true&format=webp", + ); + }); + + await t.step("should format a URL with position", () => { + const result = generate(img, { + width: 400, + height: 300, + position: "bottom", + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?position=bottom&width=400&height=300&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should format a URL with quality", () => { + const result = generate(img, { + width: 600, + quality: 80, + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=600&quality=80&fit=cover&sharp=true&format=webp", + ); + }); + + await t.step("should format a URL with format conversion", () => { + const result = generate(img, { + width: 400, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?format=webp&width=400&fit=cover&sharp=true", + ); + }); +}); diff --git a/src/providers/builder.io.ts b/src/providers/builder.io.ts new file mode 100644 index 0000000..88d3df9 --- /dev/null +++ b/src/providers/builder.io.ts @@ -0,0 +1,73 @@ +import { Operations, URLExtractor, type URLTransformer } from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsGenerator, + extractFromURL, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +import { URLGenerator } from "../types.ts"; + +export type FitType = "cover" | "contain" | "fill" | "inside" | "outside"; + +export type Position = + | "center" + | "top" + | "right top" + | "right" + | "right bottom" + | "bottom" + | "left bottom" + | "left" + | "left top"; + +export interface BuilderOperations extends Operations { + /** + * Defines how the image fits into the specified dimensions. + * Possible values: + * - `cover`: Scales the image to cover the target dimensions while maintaining aspect ratio. + * - `contain`: Scales the image to fit within the target dimensions without cropping. + * - `fill`: Stretches the image to fill both dimensions, potentially distorting the aspect ratio. + * - `inside`: Scales the image to fit within the target dimensions, with both sides being within the limits. + * - `outside`: Scales the image to be fully outside the target dimensions, while maintaining aspect ratio. + */ + fit?: FitType; + + /** + * Defines the cropping anchor point when resizing the image. + * Possible values: + * - `center`, `top`, `right top`, `right`, `right bottom`, `bottom`, `left bottom`, `left`, `left top`. + */ + position?: Position; + /** + * Undocumented option to enable use of sharp library. ENabled automatically when using `format: "webp"`. + * Required for crop support, so is enabled by default with Unpic. + */ + sharp?: boolean; +} + +const operationsGenerator = createOperationsGenerator({ + defaults: { + fit: "cover", + format: "webp", + sharp: true, + }, +}); + +export const extract: URLExtractor<"builder.io"> = extractFromURL; + +export const generate: URLGenerator<"builder.io"> = ( + src, + modifiers, +) => { + const operations = operationsGenerator(modifiers); + const url = toUrl(src); + url.search = operations; + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"builder.io"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/bunny.test.ts b/src/providers/bunny.test.ts new file mode 100644 index 0000000..e2f6c3e --- /dev/null +++ b/src/providers/bunny.test.ts @@ -0,0 +1,84 @@ +import { generate, transform } from "./bunny.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg"; + +Deno.test("bunny.net transform", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=200&height=100&aspect_ratio=200:100", + ); + }); + + await t.step("should not set height if not provided", () => { + const result = transform(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=200", + ); + }); + + await t.step("should delete height if not set", () => { + const url = new URL(img); + url.searchParams.set("height", "100"); + const result = transform(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=200", + ); + }); + + await t.step("should round non-integer params", () => { + const result = transform(img, { + width: 200.6, + height: 100.2, + }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=201&height=100&aspect_ratio=201:100", + ); + }); +}); + +Deno.test("bunny.net generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { width: 200, height: 100 }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=200&height=100", + ); + }); + + await t.step("should format a URL with crop gravity", () => { + const result = generate(img, { + width: 400, + height: 300, + crop_gravity: "bottom", + }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?crop_gravity=bottom&width=400&height=300", + ); + }); + + await t.step("should format a URL with quality", () => { + const result = generate(img, { width: 600, quality: 80 }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?width=600&quality=80", + ); + }); + + await t.step("should format a URL with format conversion", () => { + const result = generate(img, { width: 400, format: "webp" }); + assertEqualIgnoringQueryOrder( + result, + "https://bunnyoptimizerdemo.b-cdn.net/bunny7.jpg?output=webp&width=400", + ); + }); +}); diff --git a/src/providers/bunny.ts b/src/providers/bunny.ts new file mode 100644 index 0000000..9b76c91 --- /dev/null +++ b/src/providers/bunny.ts @@ -0,0 +1,149 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsGenerator, + extractFromURL, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +import { URLGenerator } from "../types.ts"; + +export type FocusArea = + | "center" + | "top" + | "right" + | "left" + | "bottom" + | "top_right" + | "top_left" + | "bottom_right" + | "bottom_left" + | "north" + | "south" + | "east" + | "west" + | "northeast" + | "northwest" + | "southeast" + | "southwest"; + +export interface BunnyOperations extends Operations { + /** + * Crops the image to the specified dimensions. + * Supports two formats: `width,height` or `width,height,x,y`. + */ + crop?: `${number},${number}` | `${number},${number},${number},${number}`; + + /** + * Sets the gravity for the cropping operation. + * This defines where the crop should focus. + */ + crop_gravity?: FocusArea; + + /** + * Crops the image to a specific aspect ratio. + * The gravity defaults to center. + */ + aspect_ratio?: `${number}:${number}`; + + /** + * Automatically optimizes the image with varying levels of optimization. + */ + auto_optimize?: "low" | "medium" | "high"; + + /** + * Sharpens the image. + */ + sharpen?: boolean; + + /** + * Blurs the image. Range: 0 to 100. + */ + blur?: number; + + /** + * Flips the image vertically. + */ + flip?: boolean; + + /** + * Flops (mirrors) the image horizontally. + */ + flop?: boolean; + + /** + * Adjusts the brightness of the image. Range: -100 to 100. + */ + brightness?: number; + + /** + * Adjusts the contrast of the image. Range: -100 to 100. + */ + contrast?: number; + + /** + * Adjusts the saturation of the image. Range: -100 to 100. + * -100 for grayscale. + */ + saturation?: number; + + /** + * Adjusts the hue of the image. Range: 0 to 100. + */ + hue?: number; + + /** + * Adjusts the gamma of the image. Range: -100 to 100. + */ + gamma?: number; + + /** + * Forces Bunny.net to recognize and optimize the image if file detection fails. + */ + optimizer?: string; + + /** + * The format of the output image. + * @deprecated Use `format` instead + */ + output?: ImageFormat; +} + +const operationsGenerator = createOperationsGenerator({ + keyMap: { + format: "output", + }, +}); + +export const extract: URLExtractor<"bunny"> = extractFromURL; + +export const generate: URLGenerator<"bunny"> = ( + src, + modifiers, +) => { + const operations = operationsGenerator(modifiers); + const url = toUrl(src); + url.search = operations; + return toCanonicalUrlString(url); +}; + +const extractAndGenerate = createExtractAndGenerate(extract, generate); + +export const transform: URLTransformer<"bunny"> = ( + src, + operations, +) => { + const { width, height } = operations; + if (width && height) { + operations.aspect_ratio ??= `${Math.round(Number(width))}:${ + Math.round(Number(height)) + }`; + } + return extractAndGenerate(src, operations); +}; diff --git a/src/providers/cloudflare.test.ts b/src/providers/cloudflare.test.ts new file mode 100644 index 0000000..5f70747 --- /dev/null +++ b/src/providers/cloudflare.test.ts @@ -0,0 +1,103 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./cloudflare.ts"; + +const img = + "https://assets.brevity.io/cdn-cgi/image/background=red,width=128,height=128,f=auto/uploads/generic/avatar-sample.jpeg"; + +Deno.test("cloudflare parser", async (t) => { + await t.step("should parse a Cloudflare URL", () => { + const parsed = extract(img); + assertEquals(parsed, { + src: "/uploads/generic/avatar-sample.jpeg", + operations: { + background: "red", + width: 128, + height: 128, + format: "auto", + }, + options: { + domain: "assets.brevity.io", + }, + }); + }); + await t.step("should parse a Cloudflare URL without a domain", () => { + const path = new URL(img).pathname; + const parsed = extract(path); + + assertEquals(parsed, { + src: "/uploads/generic/avatar-sample.jpeg", + operations: { + background: "red", + width: 128, + height: 128, + format: "auto", + }, + options: { + domain: undefined, + }, + }); + }); +}); + +Deno.test("cloudflare transformer", async (t) => { + await t.step("transforms a URL", () => { + const result = transform(img, { + width: 100, + height: 200, + }); + assertEquals( + result?.toString(), + "https://assets.brevity.io/cdn-cgi/image/background=red,width=100,height=200,f=auto,fit=cover/uploads/generic/avatar-sample.jpeg", + ); + }); +}); + +const base = "uploads/generic/avatar-sample.jpeg"; + +Deno.test("cloudflare generator", async (t) => { + await t.step("should generate a Cloudflare URL", () => { + const result = generate(base, { + width: 200, + height: 100, + format: "webp", + }, { + domain: "assets.brevity.io", + }); + assertEquals( + result, + "https://assets.brevity.io/cdn-cgi/image/width=200,height=100,f=webp,fit=cover/uploads/generic/avatar-sample.jpeg", + ); + }); + await t.step("should generate a Cloudflare URL without a domain", () => { + const result = generate(base, { + width: 200, + height: 100, + format: "webp", + }); + assertEquals( + result, + "/cdn-cgi/image/width=200,height=100,f=webp,fit=cover/uploads/generic/avatar-sample.jpeg", + ); + }); + + await t.step( + "should generate a Cloudflare URL with an absolute src", + () => { + const result = generate( + "https://example.com/uploads/generic/avatar-sample.jpeg", + { + width: 200, + height: 100, + format: "webp", + }, + { + domain: "assets.brevity.io", + }, + ); + assertEquals( + result, + "https://assets.brevity.io/cdn-cgi/image/width=200,height=100,f=webp,fit=cover/https://example.com/uploads/generic/avatar-sample.jpeg", + ); + }, + ); +}); diff --git a/src/providers/cloudflare.ts b/src/providers/cloudflare.ts new file mode 100644 index 0000000..d70a31e --- /dev/null +++ b/src/providers/cloudflare.ts @@ -0,0 +1,145 @@ +import { getProviderForUrlByPath } from "../detect.ts"; +import { + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { ImageFormat } from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + stripLeadingSlash, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * Image transform options for Cloudflare URL-based image processing. + */ +export interface CloudflareOperations extends Operations<"auto" | "json"> { + /** Preserve animation frames from GIFs, default true. */ + anim?: boolean; + + /** Background color for transparent images. Accepts CSS color values. */ + background?: string; + + /** Blur radius (1 to 250). */ + blur?: number; + + /** Border options, including color and width for each side. */ + border?: { + color: string; + width?: number; + top?: number; + right?: number; + bottom?: number; + left?: number; + }; + + /** Brightness factor, 1.0 means no change. */ + brightness?: number; + + /** Choose a faster but lower-quality compression method. */ + compression?: "fast"; + + /** Contrast factor, 1.0 means no change. */ + contrast?: number; + + /** Device Pixel Ratio multiplier, default is 1. */ + dpr?: number; + + /** Resizing mode, preserving aspect ratio. */ + fit?: "scale-down" | "contain" | "cover" | "crop" | "pad"; + + /** Output image format, or "auto" to choose based on browser support. */ + format?: ImageFormat | "auto" | "json"; + f?: ImageFormat | "auto" | "json"; + + /** Gamma correction factor. */ + gamma?: number; + + /** Cropping gravity (focal point) or alignment. */ + gravity?: "auto" | "left" | "right" | "top" | "bottom" | string; + + /** Control the preservation of metadata. */ + metadata?: "keep" | "copyright" | "none"; + + /** Redirect to original image if transformation fails. */ + onerror?: "redirect"; + + /** Rotate the image by 90, 180, or 270 degrees. */ + rotate?: 90 | 180 | 270; + + /** Strength of sharpening filter (0-10). */ + sharpen?: number; + + /** Trim options to remove pixels from edges. */ + trim?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + width?: number; + height?: number; + }; +} + +export interface CloudflareOptions { + /** The Cloudflare domain */ + domain?: string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + CloudflareOperations +>({ + keyMap: { + "format": "f", + }, + defaults: { + format: "auto", + fit: "cover", + }, + formatMap: { + jpg: "jpeg", + }, + kvSeparator: "=", + paramSeparator: ",", +}); + +export const generate: URLGenerator<"cloudflare"> = ( + src, + operations, + options, +) => { + const modifiers = operationsGenerator(operations); + const url = toUrl(options?.domain ? `https://${options.domain}` : "/"); + url.pathname = `/cdn-cgi/image/${modifiers}/${ + stripLeadingSlash(src.toString()) + }`; + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor< + "cloudflare" +> = (url, options) => { + if (getProviderForUrlByPath(url) !== "cloudflare") { + return null; + } + const parsedUrl = toUrl(url); + + const [, , , modifiers, ...src] = parsedUrl.pathname.split("/"); + const operations = operationsParser(modifiers); + return { + src: toCanonicalUrlString(toUrl(src.join("/"))), + operations, + options: { + domain: options?.domain ?? + (parsedUrl.hostname === "n" ? undefined : parsedUrl.hostname), + }, + }; +}; + +export const transform: URLTransformer< + "cloudflare" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/cloudflare_images.test.ts b/src/providers/cloudflare_images.test.ts new file mode 100644 index 0000000..61abc49 --- /dev/null +++ b/src/providers/cloudflare_images.test.ts @@ -0,0 +1,157 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./cloudflare_images.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const sampleUrl = + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/w=128,h=128,rotate=90,f=auto"; +const urlWithoutDomain = + "https://imagedelivery.net/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/w=128,h=128,rotate=90,f=auto"; + +Deno.test("Cloudflare Images CDN - extract", async (t) => { + await t.step( + "should extract operations from a Cloudflare Images URL", + () => { + const result = extract(sampleUrl); + assertEquals(result, { + src: + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200", + operations: { + width: 128, + height: 128, + rotate: "90", + format: "auto", + }, + options: { + host: "100francisco.com", + accountHash: "1aS6NlIe-Sc1o3NhVvy8Qw", + imageId: "2ba36ba9-69f6-41b6-8ff0-2779b41df200", + }, + }); + }, + ); + + await t.step( + "should extract operations from a Cloudflare Images URL without custom domain", + () => { + const result = extract(urlWithoutDomain); + assertEquals(result, { + src: + "https://imagedelivery.net/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200", + operations: { + width: 128, + height: 128, + rotate: "90", + format: "auto", + }, + options: { + host: "imagedelivery.net", + accountHash: "1aS6NlIe-Sc1o3NhVvy8Qw", + imageId: "2ba36ba9-69f6-41b6-8ff0-2779b41df200", + }, + }); + }, + ); + + await t.step("should return null for non-Cloudflare Images URLs", () => { + const result = extract("https://example.com/image.jpg"); + assertEquals(result, null); + }); +}); + +Deno.test("Cloudflare Images CDN - generate", async (t) => { + await t.step( + "should generate a Cloudflare Images URL with operations", + () => { + const result = generate( + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200", + { width: 256, height: 256, format: "webp" }, + { + host: "100francisco.com", + accountHash: "1aS6NlIe-Sc1o3NhVvy8Qw", + imageId: "2ba36ba9-69f6-41b6-8ff0-2779b41df200", + }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/w=256,h=256,f=webp,fit=cover", + ); + }, + ); + + await t.step( + "should throw an error if required options are missing", + () => { + try { + generate("https://example.com/image.jpg", {}, {}); + throw new Error("Should have thrown"); + // deno-lint-ignore no-explicit-any + } catch (error: any) { + assertEquals( + error.message, + "Missing required Cloudflare Images options", + ); + } + }, + ); +}); + +Deno.test("Cloudflare Images CDN - transform", async (t) => { + await t.step("should transform an existing Cloudflare Images URL", () => { + const result = transform(sampleUrl, { + width: 256, + height: 256, + format: "webp", + }, {}); + assertEqualIgnoringQueryOrder( + result, + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/rotate=90,w=256,h=256,f=webp,fit=cover", + ); + }); + + await t.step( + "should transform a Cloudflare Images URL without custom domain", + () => { + const result = transform(urlWithoutDomain, { + width: 256, + height: 256, + format: "webp", + }, {}); + assertEqualIgnoringQueryOrder( + result, + "https://imagedelivery.net/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/rotate=90,w=256,h=256,f=webp,fit=cover", + ); + }, + ); + + await t.step( + "should handle additional Cloudflare-specific operations", + () => { + const result = transform(sampleUrl, { + width: 256, + height: 256, + format: "webp", + gravity: "auto", + }, {}); + assertEqualIgnoringQueryOrder( + result, + "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/rotate=90,gravity=auto,w=256,h=256,f=webp,fit=cover", + ); + }, + ); + + await t.step("should throw an error for non-Cloudflare Images URLs", () => { + try { + transform("https://example.com/image.jpg", { + width: 256, + height: 256, + }, {}); + throw new Error("Should have thrown"); + // deno-lint-ignore no-explicit-any + } catch (error: any) { + assertEquals( + error.message, + "Invalid Cloudflare Images URL", + ); + } + }); +}); diff --git a/src/providers/cloudflare_images.ts b/src/providers/cloudflare_images.ts new file mode 100644 index 0000000..0d72091 --- /dev/null +++ b/src/providers/cloudflare_images.ts @@ -0,0 +1,129 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const cloudflareImagesRegex = + /https?:\/\/(?[^\/]+)\/cdn-cgi\/imagedelivery\/(?[^\/]+)\/(?[^\/]+)\/*(?[^\/]+)*$/g; +const imagedeliveryRegex = + /https?:\/\/(?imagedelivery.net)\/(?[^\/]+)\/(?[^\/]+)\/*(?[^\/]+)*$/g; + +export interface CloudflareImagesOperations extends Operations { + /** + * Fit mode for the image. + */ + fit?: "scale-down" | "contain" | "cover" | "crop" | "pad"; + + /** + * Gravity for the image when using fit modes that crop. + */ + gravity?: "auto" | "side" | "top" | "bottom" | "left" | "right"; + + /** + * Additional Cloudflare-specific operations. + */ + [key: string]: string | number | undefined; +} + +export interface CloudflareImagesOptions { + host?: string; + accountHash?: string; + imageId?: string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + CloudflareImagesOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "f", + }, + defaults: { + fit: "cover", + }, + kvSeparator: "=", + paramSeparator: ",", +}); + +function formatUrl( + options: CloudflareImagesOptions, + transformations?: string, +): string { + const { host, accountHash, imageId } = options; + if (!host || !accountHash || !imageId) { + throw new Error("Missing required Cloudflare Images options"); + } + const pathSegments = [ + "https:/", + ...(host === "imagedelivery.net" + ? [host] + : [host, "cdn-cgi", "imagedelivery"]), + accountHash, + imageId, + transformations, + ].filter(Boolean); + return pathSegments.join("/"); +} + +export const generate: URLGenerator< + "cloudflare_images" +> = ( + _src, + operations, + options = {}, +) => { + const transformations = operationsGenerator(operations); + const url = formatUrl(options, transformations); + return toCanonicalUrlString(toUrl(url)); +}; + +export const extract: URLExtractor< + "cloudflare_images" +> = (url) => { + const parsedUrl = toUrl(url); + const matches = [ + ...parsedUrl.toString().matchAll(cloudflareImagesRegex), + ...parsedUrl.toString().matchAll(imagedeliveryRegex), + ]; + if (!matches[0]?.groups) { + return null; + } + + const { host, accountHash, imageId, transformations } = matches[0].groups; + const operations = operationsParser(transformations || ""); + + const options = { host, accountHash, imageId }; + + return { + src: formatUrl(options), + operations, + options: options, + }; +}; + +export const transform: URLTransformer< + "cloudflare_images" +> = ( + src, + operations, + options = {}, +) => { + const extracted = extract(src); + if (!extracted) { + throw new Error("Invalid Cloudflare Images URL"); + } + + const newOperations = { ...extracted.operations, ...operations }; + return generate(extracted.src, newOperations, { + ...extracted.options, + ...options, + }); +}; diff --git a/src/providers/cloudimage.test.ts b/src/providers/cloudimage.test.ts new file mode 100644 index 0000000..486b070 --- /dev/null +++ b/src/providers/cloudimage.test.ts @@ -0,0 +1,116 @@ +import { extract, generate, transform } from "./cloudimage.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = "https://doc.cloudimg.io/sample.li/flat1.jpg"; +const remote = "https://sample.li/flat1.jpg"; + +Deno.test("cloudimage extract", async (t) => { + await t.step("should extract operations from a URL", () => { + const url = `${img}?w=300&h=200&force_format=webp&ci_url_encoded=1`; + const result = extract(url); + assertEquals(result?.src, remote); + assertEquals(result?.operations, { + width: 300, + height: 200, + format: "webp", + }); + assertEquals(result?.options, { token: "doc" }); + }); + + await t.step("should handle URLs without transformations", () => { + const result = extract(img); + assertEquals(result?.src, remote); + assertEquals(result?.operations, {}); + assertEquals(result?.options, { token: "doc" }); + }); +}); + +Deno.test("cloudimage generate", async (t) => { + await t.step("should generate a URL with width and height", () => { + const result = generate(remote, { + width: 300, + height: 200, + }, { token: "doc" }); + assertEquals( + result, + `${img}?w=300&h=200&org_if_sml=1`, + ); + }); + + await t.step("should generate a URL with format conversion", () => { + const result = generate(remote, { + width: 300, + format: "webp", + }, { token: "doc" }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=300&force_format=webp&org_if_sml=1`, + ); + }); + + await t.step("should URL-encode src if it has a query string", () => { + const result = generate( + `${remote}?query=string`, + { + width: 300, + height: 200, + }, + { token: "doc" }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://doc.cloudimg.io/sample.li%2Fflat1.jpg%3Fquery%3Dstring?w=300&h=200&ci_url_encoded=1&org_if_sml=1", + ); + }); + + await t.step("should handle complex operations", () => { + const result = generate(remote, { + width: 400, + height: 300, + quality: 85, + func: "cropfit", + }, { token: "doc" }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=400&h=300&q=85&func=cropfit&org_if_sml=1`, + ); + }); +}); + +Deno.test("cloudimage transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform(`${img}?w=400&h=300`, { + width: 500, + format: "webp", + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=500&h=300&force_format=webp&org_if_sml=1`, + ); + }); + + await t.step("should handle URLs with existing transformations", () => { + const result = transform(`${img}?w=400&h=300&q=80`, { + width: 600, + format: "gif", + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=600&h=300&q=80&force_format=gif&org_if_sml=1`, + ); + }); + + await t.step("should add new operations to an existing URL", () => { + const result = transform(img, { + width: 800, + height: 600, + quality: 90, + func: "fit", + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=800&h=600&q=90&func=fit&org_if_sml=1`, + ); + }); +}); diff --git a/src/providers/cloudimage.ts b/src/providers/cloudimage.ts new file mode 100644 index 0000000..f4a6494 --- /dev/null +++ b/src/providers/cloudimage.ts @@ -0,0 +1,175 @@ +import { getProviderForUrl } from "../detect.ts"; +import { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toUrl, +} from "../utils.ts"; + +export interface CloudimageOperations extends Operations { + /** + * Width of the image in pixels. + * @example "w=500" + */ + w?: number; + + /** + * Height of the image in pixels. + * @example "h=300" + */ + h?: number; + + q?: number; + + force_format?: ImageFormat; + + /** + * Prevents resizing if the target size is larger than the original image. + * @example "org_if_sml=1" + */ + org_if_sml?: 1; + + /** + * Crop mode. Available options: crop, fit, cropfit, bound, cover. + * @example "func=crop" + */ + func?: "crop" | "fit" | "cropfit" | "bound" | "cover" | "face"; + + /** + * Gravity for cropping, defines the part of the image to be retained. + * @example "gravity=center" + */ + gravity?: + | "north" + | "south" + | "east" + | "west" + | "center" + | "auto" + | "face" + | "smart" + | `${number},${number}` // focal point coordinates + | `${number}p,${number}p`; // percentage focal point + + /** + * Top-left corner of the crop area. + * @example "tl_px=100,100" + */ + tl_px?: string; + + /** + * Bottom-right corner of the crop area. + * @example "br_px=200,200" + */ + br_px?: string; + + /** + * Rotates the image in degrees counterclockwise. + * @example "r=90" + */ + r?: number; + + /** + * Flips the image horizontally and/or vertically. + * @example "flip=h" + */ + flip?: "h" | "v"; + + /** + * Trims any solid-color border. + * @example "trim=10" + */ + trim?: number; + + /** + * Applies rounded corners and optionally fills the background with a color. + * @example "radius=15" + */ + radius?: number; + + /** + * Sets the margin around a detected face during face crop. + * @example "face_margin=60" + */ + face_margin?: string; + + /** + * Background color for the image, accepts color name or hex code. + * @example "bg_color=FFFFFF" + */ + bg_color?: string; + + ci_url_encoded?: 1 | "1"; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + CloudimageOperations +>({ + keyMap: { + format: "force_format", + width: "w", + height: "h", + quality: "q", + }, + defaults: { + org_if_sml: 1, + }, +}); + +export interface CloudimageOptions { + token?: string; +} + +export const generate: URLGenerator<"cloudimage"> = ( + src, + modifiers = {}, + { token } = {}, +) => { + if (!token) { + throw new Error("Token is required for Cloudimage URLs" + src); + } + let srcString = src.toString(); + srcString = srcString.replace(/^https?:\/\//, ""); + if (srcString.includes("?")) { + modifiers.ci_url_encoded = 1; + srcString = encodeURIComponent(srcString); + } + const operations = operationsGenerator(modifiers); + const url = new URL(`https://${token}.cloudimg.io/`); + url.pathname = srcString; + url.search = operations; + return url.toString(); +}; + +export const extract: URLExtractor< + "cloudimage" +> = (src, options = {}) => { + const url = toUrl(src); + if (getProviderForUrl(url) !== "cloudimage") { + return null; + } + const operations = operationsParser(url); + + let originalSrc = url.pathname; + if (operations.ci_url_encoded) { + originalSrc = decodeURIComponent(originalSrc); + delete operations.ci_url_encoded; + } + + options.token ??= url.hostname.replace(".cloudimg.io", ""); + return { + src: `${url.protocol}/${originalSrc}`, + operations, + options, + }; +}; + +export const transform: URLTransformer< + "cloudimage" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/cloudinary.test.ts b/src/providers/cloudinary.test.ts new file mode 100644 index 0000000..baeea95 --- /dev/null +++ b/src/providers/cloudinary.test.ts @@ -0,0 +1,107 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, transform } from "./cloudinary.ts"; + +const sampleImg = + "https://res.cloudinary.com/demo/image/upload/v1/samples/animals/three-dogs.jpg"; +const privateCdnImg = + "https://demo-res.cloudinary.com/image/upload/v1/samples/animals/three-dogs.jpg"; +const customDomainImg = + "https://assets.custom.com/image/upload/v1/samples/animals/three-dogs.jpg"; + +// Test cases for `extract` +Deno.test("cloudinary extract", async (t) => { + await t.step("should extract from standard Cloudinary URL", () => { + const parsed = extract(sampleImg); + assertEquals(parsed, { + src: sampleImg, + operations: {}, + options: { + cloudName: "demo", + domain: "res.cloudinary.com", + privateCdn: false, + }, + }); + }); + + await t.step("should extract from private CDN URL", () => { + const parsed = extract(privateCdnImg); + assertEquals(parsed, { + src: privateCdnImg, + operations: {}, + options: { + cloudName: "demo", + domain: "demo-res.cloudinary.com", + privateCdn: true, + }, + }); + }); + + await t.step("should extract from custom domain URL", () => { + const parsed = extract(customDomainImg); + assertEquals(parsed, { + src: customDomainImg, + operations: {}, + options: { + cloudName: undefined, + domain: "assets.custom.com", + privateCdn: true, + }, + }); + }); + + await t.step("should extract from a URL with operations", () => { + const parsed = extract( + "https://res.cloudinary.com/demo/image/upload/w_300,h_200,c_fill/v1/folder/samples/animals/three-dogs.jpg", + ); + assertEquals(parsed, { + src: + "https://res.cloudinary.com/demo/image/upload/v1/folder/samples/animals/three-dogs.jpg", + operations: { + width: 300, + height: 200, + c: "fill", + }, + options: { + cloudName: "demo", + domain: "res.cloudinary.com", + privateCdn: false, + }, + }); + }); +}); + +// Test cases for `transform` +Deno.test("cloudinary transform", async (t) => { + await t.step("should transform a URL with additional operations", () => { + const transformed = transform(sampleImg, { quality: 80 }); + assertEquals( + transformed, + "https://res.cloudinary.com/demo/image/upload/q_80,f_auto,c_lfill/v1/samples/animals/three-dogs.jpg", + ); + }); + + await t.step( + "should transform a private CDN URL with additional operations", + () => { + const transformed = transform(privateCdnImg, { + width: 100, + height: 100, + }); + assertEquals( + transformed, + "https://demo-res.cloudinary.com/image/upload/w_100,h_100,f_auto,c_lfill/v1/samples/animals/three-dogs.jpg", + ); + }, + ); + + await t.step( + "should transform a custom domain URL with additional operations", + () => { + const transformed = transform(customDomainImg, { c: "fit" }); + assertEquals( + transformed, + "https://assets.custom.com/image/upload/c_fit,f_auto/v1/samples/animals/three-dogs.jpg", + ); + }, + ); +}); diff --git a/src/providers/cloudinary.ts b/src/providers/cloudinary.ts new file mode 100644 index 0000000..6614ea2 --- /dev/null +++ b/src/providers/cloudinary.ts @@ -0,0 +1,353 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import type { ImageFormat } from "../types.ts"; +import { createOperationsHandlers } from "../utils.ts"; + +const publicRegex = + /https?:\/\/(?res\.cloudinary\.com)\/(?[a-zA-Z0-9-]+)\/(?image|video|raw)\/(?upload|fetch|private|authenticated|sprite|facebook|twitter|youtube|vimeo)\/?(?s\-\-[a-zA-Z0-9]+\-\-)?\/?(?(?:[^_\/]+_[^,\/]+,?)*)?\/(?:(?v\d+)\/)?(?(?:[^\s\/]+\/)*[^\s\/]+(?:\.[a-zA-Z0-9]+)?)$/; + +const privateRegex = + /https?:\/\/(?(?[a-zA-Z0-9-]+)-res\.cloudinary\.com|[a-zA-Z0-9.-]+)\/(?image|video|raw)\/(?upload|fetch|private|authenticated|sprite|facebook|twitter|youtube|vimeo)\/?(?s\-\-[a-zA-Z0-9]+\-\-)?\/?(?(?:[^_\/]+_[^,\/]+,?)*)?\/(?:(?v\d+)\/)?(?(?:[^\s\/]+\/)*[^\s\/]+(?:\.[a-zA-Z0-9]+)?)$/; +type CloudinaryFormats = + | ImageFormat + | "gif" + | "bmp" + | "ico" + | "tiff" + | "pdf" + | "heif" + | "heic" + | "mp4" + | "webm" + | "ogv" + | "auto" + // deno-lint-ignore ban-types + | (string & {}); + +/** + * Image transform options for Cloudinary URL-based image processing. + */ +export interface CloudinaryOperations extends Operations { + /** Rotates the image by a specified degree. */ + a?: "auto" | number; + + /** Specifies the audio codec for video/audio assets. */ + ac?: "aac" | "mp3" | "opus"; + + /** Sets the audio frequency in Hz. */ + af?: number; + + /** Adjusts the aspect ratio of the image or video. */ + ar?: string; + + /** Sets the background color. */ + b?: string; + + /** Adds a border to the image with the format `width_px_solid_color`. */ + bo?: string; + + /** Specifies the video bitrate. */ + br?: number | string; + + /** Defines the crop mode. */ + c?: + | "fill" + | "lfill" + | "fill_pad" + | "crop" + | "thumb" + | "auto" + | "scale" + | "fit" + | "limit" + | "mfit" + | "pad" + | "lpad" + | "mpad" + | "imagga_scale" + | "imagga_crop"; + + /** Specifies the color for overlays, text, etc. */ + co?: string; + + /** Adjusts the color space of the image (e.g., `srgb`, `cmyk`). */ + cs?: "srgb" | "cmyk" | "no_cmyk"; + + /** Specifies the default image if the original is missing. */ + d?: string; + + /** Sets the delay between frames in animated images. */ + dl?: number; + + /** Controls the image density (DPI). */ + dn?: number; + + /** Adjusts for device pixel ratio. */ + dpr?: number; + + /** Defines the video duration in seconds. */ + du?: number; + + /** Applies an effect. */ + e?: + | "grayscale" + | "sepia" + | "blur" + | "shadow" + | "red" + | "blue" + | "green" + | "negate" + | "oil_paint" + | "cartoonify" + | "vectorize" + | "vignette" + | "auto_color" + | "auto_contrast" + | "auto_brightness" + | "hue" + | "saturation" + | "desaturation" + | "fade" + | "pixelate" + | "unsharp_mask" + // deno-lint-ignore ban-types + | (string & {}); + + /** Sets the end offset for videos. */ + eo?: number; + + /** Specifies the output format. */ + f?: CloudinaryFormats; + + /** Adds transformation flags. */ + fl?: + | "progressive" + | "lossy" + | "attachment" + | "streaming_attachment" + | "keep_iptc" + | "clip" + | "region_relative" + | "relative" + | "no_overflow" + | "layer_apply" + | "splice" + | "force_strip" + // deno-lint-ignore ban-types + | (string & {}); + /** Adds custom functionality, such as external image overlays. */ + fn?: string; + + /** Sets the frames per second for video. */ + fps?: string | number; + + /** Adjusts the gravity for cropping */ + g?: + | "auto" + | "center" + | "north" + | "south" + | "east" + | "west" + | "north_east" + | "north_west" + | "south_east" + | "south_west" + | "face" + | "faces" + | "body" + | "liquid" + // deno-lint-ignore ban-types + | (string & {}); + + /** Defines the height of the image/video. */ + h?: number; + + /** Allows for conditional transformations. */ + if?: string; + + /** Sets the keyframe interval for videos. */ + ki?: number; + + /** Adds an overlay image or text layer. */ + l?: string; + + /** Controls the opacity of overlays. */ + o?: number; + + /** Prefixes the public ID of the asset. */ + p?: string; + + /** Selects a specific page/layer in multi-page assets (e.g., PDFs). */ + pg?: number; + + /** Sets the quality level of the asset (1-100 or `auto`). */ + q?: number | "auto"; + + /** Rounds the corners of the image/video. */ + r?: number | "max"; + + /** Sets the start offset for video. */ + so?: number; + + /** Specifies the streaming profile for video. */ + sp?: string; + + /** Applies named transformations. */ + t?: string; + + /** Adds an underlay image or text layer. */ + u?: string; + + /** Specifies the video codec. */ + vc?: string; + + /** Controls video frame sampling (used for animated images). */ + vs?: number; + + /** Sets the width of the image/video. */ + w?: number; + + /** Sets the X-coordinate for overlays or cropping. */ + x?: number; + + /** Sets the Y-coordinate for overlays or cropping. */ + y?: number; + + /** Zooms into the image. */ + z?: number; + + /** Defines custom variables for transformation. */ + // deno-lint-ignore no-explicit-any + $?: Record; +} + +export interface CloudinaryOptions { + cloudName?: string; + privateCdn?: boolean; + domain?: string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + CloudinaryOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "f", + quality: "q", + }, + defaults: { + format: "auto", + c: "lfill", + }, + kvSeparator: "_", + paramSeparator: ",", +}); + +export const generate: URLGenerator<"cloudinary"> = ( + src, + operations, +) => { + const group = parseCloudinaryUrl(src.toString()); + if (!group) { + return src.toString(); + } + group.transformations = operationsGenerator(operations); + return formatCloudinaryUrl(group); +}; + +interface CloudinaryParts { + host?: string; + cloudName?: string; + assetType?: string; + deliveryType?: string; + signature?: string; + transformations?: string; + version?: string; + id?: string; +} + +function formatCloudinaryUrl({ + host, + cloudName, + assetType, + deliveryType, + signature, + transformations, + version, + id, +}: CloudinaryParts) { + const isPublic = host === "res.cloudinary.com"; + return [ + "https:/", + host, + isPublic ? cloudName : undefined, + assetType, + deliveryType, + signature, + transformations, + version, + id, + ].filter(Boolean).join("/"); +} + +function parseCloudinaryUrl(url: string): CloudinaryParts | null { + let matches = url.toString().match(publicRegex); + if (!matches?.length) { + matches = url.toString().match(privateRegex); + } + if (!matches?.length) { + return null; + } + return matches.groups || {}; +} + +export const extract: URLExtractor< + "cloudinary" +> = (url) => { + const group = parseCloudinaryUrl(url.toString()); + if (!group) { + return null; + } + const { + transformations: transformString = "", + ...params + } = group; + + const src = formatCloudinaryUrl(params); + + const operations = operationsParser(transformString) || {}; + return { + src, + operations, + options: { + cloudName: params.cloudName, + domain: params.host, + privateCdn: params.host !== "res.cloudinary.com", + } as CloudinaryOptions, + }; +}; + +export const transform: URLTransformer< + "cloudinary" +> = ( + src, + operations, +) => { + const group = parseCloudinaryUrl(src.toString()); + if (!group) { + return src.toString(); + } + const existing = operationsParser(group.transformations || ""); + group.transformations = operationsGenerator({ + ...existing, + ...operations, + }); + return formatCloudinaryUrl(group); +}; diff --git a/src/providers/contentful.test.ts b/src/providers/contentful.test.ts new file mode 100644 index 0000000..a2b532e --- /dev/null +++ b/src/providers/contentful.test.ts @@ -0,0 +1,206 @@ +import { generate, transform } from "./contentful.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; + +const img = + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg"; + +Deno.test("contentful transform", async (t) => { + await t.step("should format a URL", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&h=100&fit=fill", + ); + }); + await t.step("should not set height if not provided", () => { + const result = transform(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&fit=fill", + ); + }); + await t.step("should delete height if not set", () => { + const url = new URL(img); + url.searchParams.set("h", "100"); + const result = transform(img, { + width: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&fit=fill", + ); + }); + + await t.step("should round non-integer params", () => { + const result = transform(img, { + width: 200.6, + height: 100.2, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=201&h=100&fit=fill", + ); + }); + + await t.step("should not set fit=fill if another value exists", () => { + const url = new URL(img); + url.searchParams.set("fit", "crop"); + const result = transform(url.toString(), { + width: 200, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fit=crop&w=200", + ); + }); + + await t.step("should bracket width if > 4000", () => { + const result = transform(img, { + width: 5000, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&fit=fill", + ); + }); + + await t.step("should adjust height proportionally if width > 4000", () => { + const result = transform(img, { + width: 5000, + height: 2000, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&h=1600&fit=fill", + ); + }); + + await t.step("should bracket height if > 4000", () => { + const result = transform(img, { + height: 5000, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?h=4000&fit=fill", + ); + }); + + await t.step("should adjust width proportionally if height > 4000", () => { + const result = transform(img, { + width: 2000, + height: 5000, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=1600&h=4000&fit=fill", + ); + }); + + await t.step("it should adjust width and height if both are > 4000", () => { + const result = transform(img, { + width: 6000, + height: 4500, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&h=3000&fit=fill", + ); + }); +}); + +Deno.test("contentful generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&h=100&fit=fill", + ); + }); + + await t.step("should format a URL with fit type", () => { + const result = generate(img, { + width: 300, + height: 150, + fit: "pad", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fit=pad&w=300&h=150", + ); + }); + + await t.step("should format a URL with focus area", () => { + const result = generate(img, { + width: 400, + height: 300, + f: "top_right", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?f=top_right&w=400&h=300&fit=fill", + ); + }); + + await t.step("should format a URL with background color", () => { + const result = generate(img, { + width: 500, + height: 250, + fit: "pad", + bg: "rgb:ffffff", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fit=pad&bg=rgb:ffffff&w=500&h=250", + ); + }); + + await t.step("should format a URL with quality", () => { + const result = generate(img, { + width: 600, + quality: 80, + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=600&q=80&fit=fill", + ); + }); + + await t.step("should format a URL with corner radius", () => { + const result = generate(img, { + width: 400, + r: "max", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?r=max&w=400&fit=fill", + ); + }); + + await t.step("should format a URL with format conversion", () => { + const result = generate(img, { + width: 400, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fm=webp&w=400&fit=fill", + ); + }); + + await t.step("should format a URL with compression flag", () => { + const result = generate(img, { + width: 800, + fl: "progressive", + }); + assertEqualIgnoringQueryOrder( + result, + "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fl=progressive&w=800&fit=fill", + ); + }); +}); diff --git a/src/providers/contentful.ts b/src/providers/contentful.ts new file mode 100644 index 0000000..6eb877d --- /dev/null +++ b/src/providers/contentful.ts @@ -0,0 +1,142 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLTransformer, +} from "../types.ts"; +import { + clampDimensions, + createExtractAndGenerate, + createOperationsGenerator, + extractFromURL, + toCanonicalUrlString, +} from "../utils.ts"; + +import { URLGenerator } from "../types.ts"; + +export type FitType = "pad" | "fill" | "scale" | "crop" | "thumb"; + +export type FocusArea = + | "center" + | "top" + | "right" + | "left" + | "bottom" + | "top_right" + | "top_left" + | "bottom_right" + | "bottom_left" + | "face" + | "faces"; + +export interface ContentfulOperations extends Operations { + /** + * Specifies the width of the image in pixels. + * Maximum allowed value is 4000 pixels. + * @example `?w=300` + */ + w?: number; + + /** + * Specifies the height of the image in pixels. + * Maximum allowed value is 4000 pixels. + */ + h?: number; + + /** + * Defines how the image fits into the specified dimensions. + * Possible values: + * - `pad`: Adds padding if necessary to fit the dimensions. + * - `fill`: Crops the image to fit exactly in the specified dimensions. + * - `scale`: Resizes the image while changing the aspect ratio. + * - `crop`: Crops a portion of the image to fit the dimensions. + * - `thumb`: Creates a thumbnail of the image. + */ + fit?: FitType; + + /** + * Defines the focal area for cropping or padding. + * Works with `fit` values like `crop`, `pad`, etc. + * Possible values: + * - `center`, `top`, `right`, `left`, `bottom` + * - `top_right`, `top_left`, `bottom_right`, `bottom_left` + * - `face`: For the largest detected face. + * - `faces`: For all detected faces. + */ + f?: FocusArea; + + /** + * Sets the background color when padding is applied or with rounded corners. + * It accepts RGB or hex values. + */ + bg?: string; + + /** + * Adjusts the image quality as a percentage from 1 to 100. + * Ignored for 8-bit PNG images. + */ + q?: number; + + /** + * Rounds the corners of the image. You can specify the corner radius in pixels or use "max" to create a full circle/ellipse. + */ + r?: number | "max"; + + /** + * Specifies the output format for the image. + * Possible values: + * - `jpg` + * - `png` + * - `webp` + * - `avif` + */ + fm?: ImageFormat; + + /** + * Sets the compression method for the image. + * Possible values: + * - `progressive`: For progressive JPEG images. + * - `lossless`: For lossless PNG/WebP images. + */ + fl?: "progressive" | "lossless"; +} + +const operationsGenerator = createOperationsGenerator< + ContentfulOperations +>({ + keyMap: { + format: "fm", + width: "w", + height: "h", + quality: "q", + }, + defaults: { + fit: "fill", + }, +}); + +export const generate: URLGenerator<"contentful"> = ( + src, + modifiers, +) => { + const operations = operationsGenerator(modifiers); + const url = new URL(src); + url.search = operations; + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"contentful"> = extractFromURL; + +const extractAndGenerate = createExtractAndGenerate(extract, generate); + +export const transform: URLTransformer<"contentful"> = ( + src, + operations, +) => { + const { width, height } = clampDimensions(operations, 4000, 4000); + return extractAndGenerate(src, { + ...operations, + width, + height, + }); +}; diff --git a/src/providers/contentstack.test.ts b/src/providers/contentstack.test.ts new file mode 100644 index 0000000..0204f14 --- /dev/null +++ b/src/providers/contentstack.test.ts @@ -0,0 +1,128 @@ +import { extract, generate, transform } from "./contentstack.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = + "https://images.contentstack.io/v3/assets/example-asset-uid/example-image.jpg"; + +Deno.test("contentstack extract", async (t) => { + await t.step("should extract operations and baseURL from a URL", () => { + const url = `${img}?width=200&height=100&format=webp&quality=80&fit=bounds`; + const result = extract(url); + assertEqualIgnoringQueryOrder( + result?.src ?? "", + img, + ); + assertEquals( + result?.operations ?? {}, + { + width: "200", + height: "100", + format: "webp", + quality: "80", + fit: "bounds", + }, + ); + assertEquals( + result?.options.baseURL, + "https://images.contentstack.io", + ); + }); +}); + +Deno.test("contentstack transform", async (t) => { + await t.step( + "should apply defaults when no operations are provided", + () => { + const result = transform(img, {}, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?auto=webp&disable=upscale`, + ); + }, + ); + + await t.step( + "should override defaults when operations are provided", + () => { + const result = transform(img, { + width: 200, + height: 100, + fit: "bounds", + auto: "avif", + disable: false, + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=200&height=100&fit=bounds&auto=avif&disable=false`, + ); + }, + ); + + await t.step("should handle edge case: pjpg format", () => { + const result = transform(img, { + format: "pjpg", + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?auto=webp&format=pjpg&disable=upscale`, + ); + }); + + await t.step("should handle edge case: overlay with alignment", () => { + const result = transform(img, { + overlay: "overlay-image.png", + "overlay-align": "top,left", + }, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?overlay=overlay-image.png&overlay-align=top,left&auto=webp&disable=upscale`, + ); + }); +}); + +Deno.test("contentstack generate", async (t) => { + await t.step("should generate a URL with default options", () => { + const result = generate(img, {}); + assertEqualIgnoringQueryOrder( + result, + `${img}?auto=webp&disable=upscale`, + ); + }); + + await t.step("should generate a URL with custom baseURL", () => { + const result = generate( + "/v3/assets/example-asset-uid/example-image.jpg", + {}, + { + baseURL: "https://eu-images.contentstack.com/", + }, + ); + assertEqualIgnoringQueryOrder( + result, + `https://eu-images.contentstack.com/v3/assets/example-asset-uid/example-image.jpg?auto=webp&disable=upscale`, + ); + }); + + await t.step("should handle edge case: all transformation options", () => { + const result = generate(img, { + width: 300, + height: 200, + quality: 85, + format: "webpll", + fit: "bounds", + trim: "10,20,30,40", + orient: 3, + blur: 5, + saturation: 120, + contrast: 110, + brightness: 90, + filter: "lanczos3", + canvas: "300x200", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?auto=webp&width=300&height=200&quality=85&format=webpll&fit=bounds&trim=10,20,30,40&orient=3&blur=5&saturation=120&contrast=110&brightness=90&filter=lanczos3&canvas=300x200&disable=upscale`, + ); + }); +}); diff --git a/src/providers/contentstack.ts b/src/providers/contentstack.ts new file mode 100644 index 0000000..566a306 --- /dev/null +++ b/src/providers/contentstack.ts @@ -0,0 +1,186 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsGenerator, + extractFromURL, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export type OverlayAlignment = + | "top" + | "bottom" + | "left" + | "right" + | "middle" + | "center"; + +export type EdgeValues = + | number + | `${number},${number}` + | `${number},${number},${number}` + | `${number},${number},${number},${number}`; + +export interface ContentstackOperations + extends Operations<"pjpg" | "webpll" | "webply"> { + /** + * Defines how the image fits into the specified dimensions. + */ + fit?: "crop" | "bounds"; + + /** + * Enables automatic format selection (usually WebP). + */ + auto?: "webp" | "avif"; + + format?: ImageFormat | "pjpg" | "webpll" | "webply"; + + /** + * Disables upscaling of images. True by default. + */ + disable?: "upscale" | false; + + /** + * Trim an image from the edges. The value for this parameter can be given in pixels or percentage. + */ + trim?: EdgeValues; + + /** + * Control the cardinal orientation of the given image + */ + orient?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; + + /** + * Overlay an image on top of another image. + */ + overlay?: string; + /** + * Define the position of the overlay image + */ + "overlay-align"?: + | OverlayAlignment + | `${OverlayAlignment},${OverlayAlignment}`; + + /** + * Pad the image edges + */ + padding?: EdgeValues; + + /** + * Pad the overlay edges + */ + "overlay-padding"?: EdgeValues; + + /** + * Set the background color for padding + */ + bgcolor?: string; + + /** + * Device pixel ratio + */ + dpr?: number; + + /** + * Blur the image + */ + blur?: number; + + /** + * Extract a frame from an animated gif. Only the first frame is supported. + */ + frame?: 1; + /** + * Sharpen the image + */ + sharpen?: `a${number},r${number},t${number}`; + /** + * Saturation of the image + */ + saturation?: number; + /** + * Contrast of the image + */ + contrast?: number; + /** + * Brightness of the image + */ + brightness?: number; + /** + * Sets the resize filter + */ + filter?: "nearest" | "bilinear" | "bicubic" | "lanczos3" | "lanczos2"; + /** + * Increase the size of the canvas that surrounds an image + */ + canvas?: string; +} + +export interface ContentstackOptions { + /** + * The base URL for the images. + */ + baseURL?: + | "https://images.contentstack.io/" + | "https://eu-images.contentstack.com/" + | "https://azure-na-images.contentstack.com/" + | "https://azure-eu-images.contentstack.com/" + // deno-lint-ignore ban-types + | (string & {}); +} + +const operationsGenerator = createOperationsGenerator({ + defaults: { + auto: "webp", + disable: "upscale", + }, +}); + +export const generate: URLGenerator< + "contentstack" +> = ( + src, + operations, + { baseURL = "https://images.contentstack.io/" }: ContentstackOptions = {}, +) => { + if (operations.width && operations.height) { + operations.fit ??= "crop"; + } + const modifiers = operationsGenerator(operations); + const url = toUrl(src); + if (url.hostname === "n") { + url.protocol = "https:"; + url.hostname = new URL(baseURL).hostname; + } + url.search = modifiers; + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor< + "contentstack" +> = (url) => { + const { src, operations } = extractFromURL(url) ?? {}; + + if (!operations || !src) { + return null; + } + const { origin } = toUrl(url); + + return { + src, + operations: operations as ContentstackOperations, + options: { + baseURL: origin, + }, + }; +}; + +export const transform: URLTransformer< + "contentstack" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/directus.test.ts b/src/providers/directus.test.ts new file mode 100644 index 0000000..6448a1a --- /dev/null +++ b/src/providers/directus.test.ts @@ -0,0 +1,99 @@ +import { extract, generate, transform } from "./directus.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "https://deno.land/std@0.186.0/testing/asserts.ts"; + +const img = "https://example.com/assets/directus-image.jpg"; + +Deno.test("directus extract", async (t) => { + await t.step("should extract operations from a URL with transforms", () => { + const url = + `${img}?width=200&height=100&fit=contain&quality=80&transforms=[["blur",45],["tint","rgb(255, 0, 0)"]]`; + const result = extract(url); + assertEquals(result?.src, img); + assertEquals(result?.operations, { + width: "200", + height: "100", + fit: "contain", + quality: "80", + transforms: [["blur", 45], ["tint", "rgb(255, 0, 0)"]], + }); + }); +}); + +Deno.test("directus transform", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=200&height=100&fit=cover&withoutEnlargement=true`, + ); + }); + + await t.step("should handle transforms array", () => { + const result = transform(img, { + width: 200, + transforms: [ + ["blur", 45], + ["tint", "rgb(255, 0, 0)"], + ], + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=200&fit=cover&withoutEnlargement=true&transforms=%5B%5B%22blur%22%2C45%5D%2C%5B%22tint%22%2C%22rgb(255%2C%200%2C%200)%22%5D%5D`, + ); + }); +}); + +Deno.test("directus generate", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=200&height=100&fit=cover&withoutEnlargement=true`, + ); + }); + + await t.step("should format a URL with fit type", () => { + const result = generate(img, { + width: 300, + height: 150, + fit: "contain", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=300&height=150&fit=contain&withoutEnlargement=true`, + ); + }); + + await t.step("should handle complex transforms", () => { + const result = generate(img, { + width: 800, + transforms: [ + ["grayscale"], + ["blur", 5], + ["rotate", 45], + ], + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=800&fit=cover&withoutEnlargement=true&transforms=%5B%5B%22grayscale%22%5D%2C%5B%22blur%22%2C5%5D%2C%5B%22rotate%22%2C45%5D%5D`, + ); + }); + + await t.step("should format a URL with format conversion", () => { + const result = generate(img, { + width: 400, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?width=400&format=webp&fit=cover&withoutEnlargement=true`, + ); + }); +}); diff --git a/src/providers/directus.ts b/src/providers/directus.ts new file mode 100644 index 0000000..5406270 --- /dev/null +++ b/src/providers/directus.ts @@ -0,0 +1,75 @@ +import { + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsGenerator, + extractFromURL, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * @see https://docs.directus.io/reference/files.html#custom-transformations + */ +export interface DirectusOperations extends Operations<"auto"> { + fit?: "cover" | "contain" | "inside" | "outside"; + withoutEnlargement?: boolean; + /** + * For advanced control over the file generation, Directus exposes the + * [full `sharp` API](https://sharp.pixelplumbing.com/api-operation). + * Pass an array of operations to apply to the image, or a JSON string. + */ + transforms?: + | Array< + [operation: string, ...Array] + > + | string; +} + +const operationsGenerator = createOperationsGenerator< + DirectusOperations +>({ + defaults: { + withoutEnlargement: true, + fit: "cover", + }, +}); + +export const generate: URLGenerator<"directus"> = ( + src, + operations, +) => { + if (Array.isArray(operations.transforms)) { + operations.transforms = JSON.stringify(operations.transforms); + } + const modifiers = operationsGenerator(operations); + const url = toUrl(src); + url.search = modifiers; + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"directus"> = (url) => { + const base = extractFromURL( + url, + ); + if ( + base?.operations?.transforms && + typeof base.operations.transforms === "string" + ) { + try { + base.operations.transforms = JSON.parse(base.operations.transforms); + } catch { + return null; + } + } + return base; +}; + +export const transform: URLTransformer<"directus"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/hygraph.test.ts b/src/providers/hygraph.test.ts new file mode 100644 index 0000000..63f44cf --- /dev/null +++ b/src/providers/hygraph.test.ts @@ -0,0 +1,113 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./hygraph.ts"; + +const imgBase = + "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/cm2tr64fx7gvu07n85chjmuno"; +const imgWithAutoFormat = + "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/resize=fit:crop,width:400,height:400/auto_image/cm2tr64fx7gvu07n85chjmuno"; +const imgWithExplicitFormat = + "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/resize=fit:crop,width:400,height:400/output=format:jpg/cm2tr64fx7gvu07n85chjmuno"; + +Deno.test("Hygraph provider", async (t) => { + await t.step( + "extract - should extract operations from URL with auto format", + () => { + const result = extract(imgWithAutoFormat); + assertEquals(result, { + src: imgBase, + operations: { + width: 400, + height: 400, + format: "auto", + fit: "crop", + }, + options: { + region: "us-west-2", + envId: "cm2apl1zp07l506n66dmd9xo8", + handle: "cm2tr64fx7gvu07n85chjmuno", + }, + }); + }, + ); + + await t.step( + "extract - should extract operations from URL with explicit format", + () => { + const result = extract(imgWithExplicitFormat); + assertEquals(result, { + src: imgBase, + operations: { + width: 400, + height: 400, + format: "jpg", + fit: "crop", + }, + options: { + region: "us-west-2", + envId: "cm2apl1zp07l506n66dmd9xo8", + handle: "cm2tr64fx7gvu07n85chjmuno", + }, + }); + }, + ); + + await t.step("extract - should handle URL without transformations", () => { + const result = extract(imgBase); + assertEquals(result, { + src: imgBase, + operations: {}, + options: { + region: "us-west-2", + envId: "cm2apl1zp07l506n66dmd9xo8", + handle: "cm2tr64fx7gvu07n85chjmuno", + }, + }); + }); + + await t.step("generate - should generate URL with auto format", () => { + const result = generate(imgBase, { + width: 400, + height: 400, + }); + assertEquals(result, imgWithAutoFormat); + }); + + await t.step("generate - should generate URL with explicit format", () => { + const result = generate(imgBase, { + width: 400, + height: 400, + format: "jpg", + }); + assertEquals(result, imgWithExplicitFormat); + }); + + await t.step("transform - should transform URL with auto format", () => { + const result = transform(imgBase, { + width: 400, + height: 400, + }); + assertEquals(result, imgWithAutoFormat); + }); + + await t.step("transform - should transform URL with explicit format", () => { + const result = transform(imgBase, { + width: 400, + height: 400, + format: "jpg", + }); + assertEquals(result, imgWithExplicitFormat); + }); + + await t.step( + "transform - should preserve existing transformations when adding new ones", + () => { + const result = transform(imgWithAutoFormat, { + width: 800, + }); + assertEquals( + result, + "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/resize=fit:crop,width:800,height:400/auto_image/cm2tr64fx7gvu07n85chjmuno", + ); + }, + ); +}); diff --git a/src/providers/hygraph.ts b/src/providers/hygraph.ts new file mode 100644 index 0000000..462d239 --- /dev/null +++ b/src/providers/hygraph.ts @@ -0,0 +1,147 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export interface HygraphOperations extends Operations { + /** + * Fit mode for resizing. + */ + fit?: "crop" | "clip" | "scale" | "max"; +} + +export interface HygraphOptions { + region?: string; + envId?: string; + handle?: string; +} + +const hygraphRegex = + /https:\/\/(?[a-z0-9-]+)\.graphassets\.com\/(?[a-zA-Z0-9]+)(?:\/(?.*?))?\/(?[a-zA-Z0-9]+)$/; + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + HygraphOperations +>({ + keyMap: { + width: "width", + height: "height", + format: "format", + }, + defaults: { + format: "auto", + fit: "crop", + }, +}); + +export const extract: URLExtractor<"hygraph"> = (url) => { + const parsedUrl = toUrl(url); + const matches = parsedUrl.toString().match(hygraphRegex); + + if (!matches?.groups) { + return null; + } + + const { region, envId, handle, transformations } = matches.groups; + + // Parse any existing transformations from the URL + const operations: HygraphOperations = {}; + if (transformations) { + const parts = transformations.split("/"); + parts.forEach((part) => { + const [operation, params] = part.split("="); + if (operation === "resize" && params) { + params.split(",").forEach((param) => { + const [key, value] = param.split(":"); + if (key === "width" || key === "height") { + operations[key] = Number(value); + } else if (key === "fit") { + operations.fit = value as HygraphOperations["fit"]; + } + }); + } else if (operation === "output" && params) { + params.split(",").forEach((param) => { + const [key, value] = param.split(":"); + if (key === "format") { + operations.format = value; + } + }); + } else if (operation === "auto_image") { + operations.format = "auto"; + } + }); + } + + return { + src: `https://${region}.graphassets.com/${envId}/${handle}`, + operations, + options: { + region, + envId, + handle, + }, + }; +}; + +export const generate: URLGenerator<"hygraph"> = ( + src, + operations, + options = {}, +) => { + // First extract the components from the source URL + const extracted = extract(src); + if (!extracted) { + throw new Error("Invalid Hygraph URL"); + } + + // Merge options + const { region, envId, handle } = { + ...extracted.options, + ...options, + }; + + const transforms: string[] = []; + + // Add resize transformation if width or height is specified + if (operations.width || operations.height) { + const resize = []; + // Always include fit:crop when both dimensions are specified to maintain aspect ratio + if (operations.width && operations.height) { + resize.push("fit:crop"); + } else if (operations.fit) { + resize.push(`fit:${operations.fit}`); + } + if (operations.width) resize.push(`width:${operations.width}`); + if (operations.height) resize.push(`height:${operations.height}`); + if (resize.length) transforms.push(`resize=${resize.join(",")}`); + } + + // Add format transformation + if ( + operations.format === "auto" || + (!operations.format && !extracted.operations.format) + ) { + transforms.push("auto_image"); + } else if (operations.format) { + transforms.push(`output=format:${operations.format}`); + } + + // Construct the URL in parts + const baseUrl = `https://${region}.graphassets.com/${envId}`; + const transformPart = transforms.length > 0 ? "/" + transforms.join("/") : ""; + const finalUrl = toUrl(`${baseUrl}${transformPart}/${handle}`); + + return toCanonicalUrlString(finalUrl); +}; + +export const transform: URLTransformer<"hygraph"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/imageengine.test.ts b/src/providers/imageengine.test.ts new file mode 100644 index 0000000..bc97f55 --- /dev/null +++ b/src/providers/imageengine.test.ts @@ -0,0 +1,98 @@ +import { extract, generate, transform } from "./imageengine.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg"; + +Deno.test("imageengine extract", async (t) => { + await t.step("should extract operations from a URL", () => { + const url = `${img}?imgeng=w_300/h_200/f_webp/m_cropbox`; + const result = extract(url); + assertEquals(result?.src, img); + assertEquals(result?.operations, { + width: 300, + height: 200, + format: "webp", + m: "cropbox", + }); + }); +}); + +Deno.test("imageengine transform", async (t) => { + await t.step("should format a URL with width and height", () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=w_200/h_100/m_cropbox`, + ); + }); + + await t.step("should handle advanced operations", () => { + const result = transform(img, { + width: 300, + height: 200, + cmpr: 80, + s: 10, + r: 90, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=cmpr_80%2Fs_10%2Fr_90%2Fw_300%2Fh_200%2Fm_cropbox`, + ); + }); + + await t.step("should transform an already transformed URL", () => { + const initialUrl = `${img}?imgeng=w_300/h_200/f_webp`; + const result = transform(initialUrl, { + width: 400, + cmpr: 75, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=cmpr_75%2Fw_400%2Fh_200%2Ff_webp%2Fm_cropbox`, + ); + }); +}); + +Deno.test("imageengine generate", async (t) => { + await t.step("should generate a URL with width and height", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=w_200/h_100/m_cropbox`, + ); + }); + + await t.step("should generate a URL with format conversion", () => { + const result = generate(img, { + width: 300, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=w_300/f_webp/m_cropbox`, + ); + }); + + await t.step("should handle complex operations", () => { + const result = generate(img, { + width: 400, + height: 300, + m: "letterbox", + cmpr: 85, + s: 15, + meta: true, + }); + console.log(result); + assertEqualIgnoringQueryOrder( + result, + `${img}?imgeng=m_letterbox%2Fcmpr_85%2Fs_15%2Fmeta_true%2Fw_400%2Fh_300`, + ); + }); +}); diff --git a/src/providers/imageengine.ts b/src/providers/imageengine.ts new file mode 100644 index 0000000..77680d2 --- /dev/null +++ b/src/providers/imageengine.ts @@ -0,0 +1,131 @@ +import { + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { ImageFormat } from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * @see https://support.imageengine.io/hc/en-us/articles/360058880672-Directives + */ + +export interface ImageEngineOperations + extends Operations<"gif" | "jp2" | "bmp" | "jxl"> { + w?: number; + + h?: number; + + f?: ImageFormat | "gif" | "jp2" | "bmp" | "jxl"; + + /** + * Compression level of the image (0-99). Higher values reduce quality and size. + * Example: `cmpr=50` for 50% compression. + */ + cmpr?: number; + + /** + * Method used to fit the image into the specified dimensions. + * Supported values: 'stretch', 'box', 'letterbox', 'cropbox', 'outside'. + * Example: `m=cropbox` to crop the image inside the bounding box. + */ + m?: "stretch" | "box" | "letterbox" | "cropbox" | "outside"; + + /** + * Whether to pass through the original image unmodified. + * Example: `pass=true` to bypass transformations. + */ + pass?: boolean; + + /** + * Sharpness level of the image (0-100). Higher values increase sharpness. + * Example: `s=20` for moderate sharpening. + */ + s?: number; + + /** + * Rotate the image by a specific number of degrees (-360 to 360). + * Example: `r=90` to rotate the image 90 degrees clockwise. + */ + r?: number; + + /** + * Scale the image as a percentage of the screen (1-100). + * Example: `pc=50` to scale the image to 50% of the screen size. + */ + pc?: number; + + /** + * Crop the image using four values: width, height, left, and top. + * Example: `cr=200,200,50,50` to crop a 200x200px image starting 50px from the left and 50px from the top. + */ + cr?: [number, number, number, number]; + + /** + * Retain EXIF metadata in the image. + * Example: `meta=true` to keep EXIF data. + */ + meta?: boolean; + + /** + * Forces the image to be downloaded rather than displayed. + * Example: `dl=true` to trigger download behavior. + */ + dl?: boolean; + + /** + * Maximum device pixel ratio to consider when resizing and optimizing the image. + * Example: `maxdpr=2.1` to limit DPR considerations to 2.1. + */ + maxdpr?: number; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + ImageEngineOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "f", + }, + defaults: { + m: "cropbox", + }, + kvSeparator: "_", + paramSeparator: "/", +}); + +export const generate: URLGenerator<"imageengine"> = ( + src, + operations, +) => { + const modifiers = operationsGenerator(operations); + const url = toUrl(src); + url.searchParams.set("imgeng", modifiers); + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor< + "imageengine" +> = (url) => { + const parsedUrl = toUrl(url); + const imgeng = parsedUrl.searchParams.get("imgeng"); + if (!imgeng) { + return null; + } + const operations = operationsParser(imgeng); + parsedUrl.searchParams.delete("imgeng"); + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; +}; + +export const transform: URLTransformer<"imageengine"> = + createExtractAndGenerate(extract, generate); diff --git a/src/providers/imagekit.test.ts b/src/providers/imagekit.test.ts new file mode 100644 index 0000000..1ddb834 --- /dev/null +++ b/src/providers/imagekit.test.ts @@ -0,0 +1,161 @@ +import { extract, generate, transform } from "./imagekit.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = + "https://ik.imagekit.io/ikmedia/docs_images/examples/example_food_3.jpg"; + +Deno.test("imagekit extract", async (t) => { + await t.step("should extract operations from a query parameter URL", () => { + const url = `${img}?tr=w-300,h-300`; + const result = extract(url); + assertEquals(result?.src, img); + assertEquals(result?.operations, { + width: 300, + height: 300, + }); + }); + + await t.step("should extract operations from a path-based URL", () => { + const url = + `https://ik.imagekit.io/ikmedia/tr:w-300,h-300/docs_images/examples/example_food_3.jpg`; + const result = extract(url); + assertEquals( + result?.src, + "https://ik.imagekit.io/ikmedia/docs_images/examples/example_food_3.jpg", + ); + assertEquals(result?.operations, { + width: 300, + height: 300, + }); + }); +}); + +Deno.test("imagekit transform", async (t) => { + await t.step( + "should format a URL with width and height using query parameters and include defaults", + () => { + const result = transform(img, { + width: 300, + height: 300, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=w-300,h-300,c-maintain_ratio,fo-auto`, + ); + }, + ); + + await t.step( + "should handle advanced operations and override defaults", + () => { + const result = transform(img, { + width: 300, + height: 200, + c: "force", + fo: "center", + q: 80, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=c-force%2Cfo-center%2Cq-80%2Cw-300%2Ch-200`, + ); + }, + ); + + await t.step( + "should transform an already transformed URL (query params) and include defaults", + () => { + const initialUrl = `${img}?tr=w-300,h-200`; + const result = transform(initialUrl, { + quality: 75, + blur: 5, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=blur-5%2Cw-300%2Ch-200%2Cq-75%2Cc-maintain_ratio%2Cfo-auto`, + ); + }, + ); + + await t.step( + "should transform an already transformed URL (path-based) and include defaults", + () => { + const initialUrl = + `https://ik.imagekit.io/ikmedia/tr:w-300,h-200/docs_images/examples/example_food_3.jpg`; + const result = transform(initialUrl, { + q: 75, + blur: 5, + }); + assertEqualIgnoringQueryOrder( + result, + `https://ik.imagekit.io/ikmedia/docs_images/examples/example_food_3.jpg?tr=q-75%2Cblur-5%2Cw-300%2Ch-200%2Cc-maintain_ratio%2Cfo-auto`, + ); + }, + ); +}); + +Deno.test("imagekit generate", async (t) => { + await t.step( + "should generate a URL with width and height using query parameters and include defaults", + () => { + const result = generate(img, { + w: 200, + h: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=w-200,h-100,c-maintain_ratio,fo-auto`, + ); + }, + ); + + await t.step( + "should generate a URL with format conversion and include defaults", + () => { + const result = generate(img, { + w: 300, + f: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=w-300,f-webp,c-maintain_ratio,fo-auto`, + ); + }, + ); + + await t.step( + "should handle complex operations and override defaults", + () => { + const result = generate(img, { + w: 400, + h: 300, + c: "pad_resize", + fo: "bottom", + bg: "FFFFFF", + r: 90, + q: 85, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=w-400,h-300,c-pad_resize,fo-bottom,bg-FFFFFF,r-90,q-85`, + ); + }, + ); + + await t.step( + "should not include defaults if they are explicitly set to different values", + () => { + const result = generate(img, { + w: 300, + h: 200, + c: "force", + fo: "center", + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?tr=w-300,h-200,c-force,fo-center`, + ); + }, + ); +}); diff --git a/src/providers/imagekit.ts b/src/providers/imagekit.ts new file mode 100644 index 0000000..fd044d9 --- /dev/null +++ b/src/providers/imagekit.ts @@ -0,0 +1,193 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export interface ImageKitOperations extends Operations { + /** + * Resize image width in pixels or percentage. + * Example: `w=300` or `w=0.5` (50% of the original width) + */ + w?: number | string; + + /** + * Resize image height in pixels or percentage. + * Example: `h=200` or `h=0.5` (50% of the original height) + */ + h?: number | string; + + /** + * Aspect ratio of the output image. + * Example: `ar=16:9` + */ + ar?: string; + + /** + * Crop strategy for the image. + * Options: 'maintain_ratio', 'extract', 'pad_resize', 'force', 'at_max', 'at_least' + */ + c?: + | "maintain_ratio" + | "extract" + | "pad_resize" + | "force" + | "at_max" + | "at_least"; + + /** + * Focal point for cropping. Can also pass object types for smart cropping. + * @see https://imagekit.io/docs/image-resize-and-crop#supported-object-list + */ + fo?: + | "center" + | "top" + | "left" + | "bottom" + | "right" + | "top_left" + | "top_right" + | "bottom_left" + | "bottom_right" + | "auto" + // deno-lint-ignore ban-types + | (string & {}); + + /** + * Set the background color for padding. + * Example: `bg=FFFFFF` for white background. + */ + bg?: string; + + /** + * Rotate the image by a specified degree. + * Example: `r=90` for a 90-degree rotation. + */ + r?: number; + + /** + * Adjust sharpness of the image. Value between 1-100. + * Example: `s=50` for moderate sharpness. + */ + s?: number; + + /** + * Adjust the blur level of the image. Value between 1-100. + * Example: `blur=5` for light blur. + */ + blur?: number; + + /** + * Quality of the image, represented as a percentage between 1-100. + * Example: `q=80` for 80% quality. + */ + q?: number; + + /** + * Device pixel ratio for high-resolution displays. + * Example: `dpr=2` for retina display. + */ + dpr?: number; + + /** + * Chained transformations, separated by a colon. + * Example: `w-300,h-300:fo-center` to resize and center the image. + */ + chain?: string; + + /** + * Format of the output image. + * Options: 'jpg', 'png', 'gif', 'webp', 'avif' + */ + f?: ImageFormat; + + /** + * Add a default image if the requested image is not found. + * Example: `di=default.jpg` + */ + di?: string; + + /** + * Add a border around the image. Specify thickness in pixels. + * Example: `bo=5_000000` for a 5px black border. + */ + bo?: string; + + /** + * Apply round corners to the image. + * Example: `rt=20` for 20px radius. + */ + rt?: number; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + ImageKitOperations +>({ + keyMap: { + width: "w", + height: "h", + format: "f", + quality: "q", + }, + defaults: { + c: "maintain_ratio", + fo: "auto", + }, + kvSeparator: "-", + paramSeparator: ",", +}); + +export const generate: URLGenerator<"imagekit"> = ( + src, + operations, +) => { + const modifiers = operationsGenerator(operations); + const url = toUrl(src); + url.searchParams.set("tr", modifiers); + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"imagekit"> = (url) => { + const parsedUrl = toUrl(url); + let trPart: string | null = null; + let path = parsedUrl.pathname; + + // Check for query parameter format + if (parsedUrl.searchParams.has("tr")) { + trPart = parsedUrl.searchParams.get("tr"); + parsedUrl.searchParams.delete("tr"); + } else { + // Check for path-based format + const pathParts = parsedUrl.pathname.split("/"); + const trIndex = pathParts.findIndex((part) => part.startsWith("tr:")); + + if (trIndex !== -1) { + trPart = pathParts[trIndex].slice(3); // Remove 'tr:' prefix + path = pathParts.slice(0, trIndex).concat( + pathParts.slice(trIndex + 1), + ).join("/"); + } + } + + if (!trPart) { + return null; + } + + parsedUrl.pathname = path; + + const operations = operationsParser(trPart); + + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; +}; + +export const transform = createExtractAndGenerate(extract, generate); diff --git a/src/providers/imgix.test.ts b/src/providers/imgix.test.ts new file mode 100644 index 0000000..aad063a --- /dev/null +++ b/src/providers/imgix.test.ts @@ -0,0 +1,104 @@ +import { extract, generate, transform } from "./imgix.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = "https://images.unsplash.com/photo-1674255909399-9bcb2cab6489"; + +Deno.test("imgix extract", async (t) => { + await t.step("should parse a URL", () => { + const { operations, src } = extract( + img, + ) ?? {}; + assertEquals(src, img); + assertEquals(operations, {}); + }); + await t.step("should parse a URL with operations", () => { + const { operations, src } = extract( + `${img}?w=200&h=300&q=80&fit=crop`, + ) ?? {}; + assertEquals(src, img); + assertEquals(operations, { + width: 200, + height: 300, + quality: 80, + fit: "crop", + }); + }); +}); + +Deno.test("imgix generate", async (t) => { + await t.step("should format a URL", () => { + const result = generate(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=200&h=100&fit=min&auto=format`, + ); + }); + await t.step("should not set height if not provided", () => { + const result = generate(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=200&fit=min&auto=format`, + ); + }); + await t.step("should round non-integer dimensions", () => { + const result = generate(img, { + width: 200.6, + height: 100.2, + }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=201&h=100&fit=min&auto=format`, + ); + }); + await t.step("should set auto=format if no format is provided", () => { + const result = generate(img, { width: 200 }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=200&fit=min&auto=format`, + ); + }); + await t.step("should not set auto=format if format is provided", () => { + const result = generate(img, { width: 200, format: "webp" }); + assertEqualIgnoringQueryOrder( + result, + `${img}?w=200&fit=min&fm=webp`, + ); + }); +}); + +Deno.test("imgix transform", async (t) => { + await t.step("should apply defaults to a URL", () => { + const result = transform(`${img}?w=200&h=300`, {}); + assertEqualIgnoringQueryOrder( + result?.toString(), + `${img}?w=200&h=300&fit=min&auto=format`, + ); + }); + await t.step("should apply defaults to a URL with no operations", () => { + const result = transform(img, {}); + assertEqualIgnoringQueryOrder( + result?.toString(), + `${img}?fit=min&auto=format`, + ); + }); + + await t.step("should not apply auto if format is in URL", () => { + const result = transform(`${img}?w=200&fm=webp`, {}); + assertEqualIgnoringQueryOrder( + result?.toString(), + `${img}?w=200&fm=webp&fit=min`, + ); + }); + + await t.step("should not apply auto if format is in operations", () => { + const result = transform(`${img}?w=200`, { format: "webp" }); + assertEqualIgnoringQueryOrder( + result?.toString(), + `${img}?w=200&fm=webp&fit=min`, + ); + }); +}); diff --git a/src/providers/imgix.ts b/src/providers/imgix.ts new file mode 100644 index 0000000..45a3d39 --- /dev/null +++ b/src/providers/imgix.ts @@ -0,0 +1,248 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + type URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export type ImixFormats = + | ImageFormat + | "gif" + | "jp2" + | "json" + | "jxr" + | "pjpg" + | "mp4" + | "png8" + | "png32" + | "webm" + | "blurhash"; + +export interface ImgixOperations extends Operations { + w?: number; + + h?: number; + + /** + * Aspect ratio, defined as width/height + * @example "16:9" + */ + ar?: string; + + /** + * Fit mode to use when resizing. + */ + fit?: + | "clamp" + | "clip" + | "crop" + | "facearea" + | "fill" + | "fillmax" + | "max" + | "min" + | "scale"; + + /** + * Crop mode to use when resizing. + * Can be a combination of "top", "bottom", "left", "right", "faces", etc. + * @example "faces,top" + */ + crop?: string; + + /** + * Device pixel ratio (useful for responsive images). + * @example 2 + */ + dpr?: number; + + /** + * Quality level (1-100) for lossy image formats. + * @example 75 + */ + q?: number; + + /** + * Output format for the image. + */ + fm?: ImixFormats; + + /** + * Automatic optimizations to apply. + * Can be a combination of "format", "compress", "enhance", "redeye". + * @example "format,compress" + */ + auto?: "format" | "compress" | "enhance" | "redeye"; + + /** + * Contrast adjustment (-100 to 100). + * @example 50 + */ + con?: number; + + /** + * Exposure adjustment (-100 to 100). + * @example 20 + */ + exp?: number; + + /** + * Saturation adjustment (-100 to 100). + * @example -20 + */ + sat?: number; + + /** + * Blur radius to apply. + * @example 5 + */ + blur?: number; + + /** + * Sharpening amount to apply. + * @example 10 + */ + sharp?: number; + + /** + * Sepia tone effect (0-100). + * @example 80 + */ + sepia?: number; + + /** + * Background color in RGB/hex. + * @example "ff0000" + */ + bg?: string; + + /** + * Border size and color (e.g., 10px solid red). + * @example "10,ff0000" + */ + border?: string; + + /** + * Text overlay string. + * @example "Hello, World!" + */ + txt?: string; + + /** + * Font to use for text overlay. + * @example "Arial" + */ + txtFont?: string; + + /** + * Color of the text overlay. + * @example "ffffff" + */ + txtColor?: string; + + /** + * Font size for text overlay. + * @example 48 + */ + txtSize?: number; + + /** + * Alignment for the text overlay. + * One of "left", "center", "right". + * @example "center" + */ + txtAlign?: "left" | "center" | "right"; + + /** + * Watermark image URL. + * @example "https://example.com/watermark.png" + */ + mark?: string; + + /** + * Watermark transparency level (0-100). + * @example 50 + */ + markAlpha?: number; + + /** + * Rotation angle (degrees). + * @example 90 + */ + rot?: number; + + /** + * Flip mode. + * One of "h" for horizontal, "v" for vertical. + * @example "h" + */ + flip?: "h" | "v"; + + /** + * Gaussian blur radius to apply. + * @example 5 + */ + gaussblur?: number; + + /** + * Noise reduction amount to apply. + * @example 10 + */ + noise?: number; + + /** + * Strip image metadata (EXIF, etc.) + * @example true + */ + strip?: boolean; + /** + * For all options, see: https://unpkg.com/browse/typescript-imgix-url-params/dist/index.d.ts + */ + [key: string]: string | number | boolean | undefined; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + ImgixOperations +>({ + keyMap: { + format: "fm", + width: "w", + height: "h", + quality: "q", + }, + defaults: { + fit: "min", + auto: "format", + }, +}); + +export const extract: URLExtractor<"imgix"> = (url) => { + const src = toUrl(url); + const operations = operationsParser(url); + src.search = ""; + return { src: toCanonicalUrlString(src), operations }; +}; + +export const generate: URLGenerator<"imgix"> = (src, operations) => { + const modifiers = operationsGenerator(operations); + const url = toUrl(src); + url.search = modifiers; + if ( + url.searchParams.has("fm") && url.searchParams.get("auto") === "format" + ) { + url.searchParams.delete("auto"); + } + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"imgix"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/ipx.test.ts b/src/providers/ipx.test.ts new file mode 100644 index 0000000..c1f6ed8 --- /dev/null +++ b/src/providers/ipx.test.ts @@ -0,0 +1,125 @@ +import { extract, generate, transform } from "./ipx.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const absoluteImg = "https://example.com/images/test.jpg"; +const img = "/images/test.jpg"; +const baseURL = "https://example.com/_ipx"; + +Deno.test("ipx extract", async (t) => { + await t.step("should extract operations from a URL", () => { + const url = `${baseURL}/w_300,h_200,q_75,f_webp/images/test.jpg`; + const result = extract(url); + assertEquals(result?.src, "/images/test.jpg"); + assertEquals(result?.operations, { + width: 300, + height: 200, + quality: 75, + format: "webp", + }); + assertEquals(result?.options, { baseURL: "https://example.com/_ipx" }); + }); + + await t.step("should extract operations with 's' parameter", () => { + const url = `${baseURL}/s_300x200,q_75,f_webp/images/test.jpg`; + const result = extract(url); + assertEquals(result?.src, "/images/test.jpg"); + assertEquals(result?.operations, { + width: 300, + height: 200, + quality: 75, + format: "webp", + }); + }); +}); + +Deno.test("ipx generate", async (t) => { + await t.step("should generate a URL with width and height", () => { + const result = generate( + img, + { + width: 300, + height: 200, + }, + { baseURL }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseURL}/s_300x200,f_auto/images/test.jpg`, + ); + }); + + await t.step("should generate a URL with only width", () => { + const result = generate( + img, + { + width: 300, + }, + { baseURL }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseURL}/w_300,f_auto/images/test.jpg`, + ); + }); + + await t.step("should generate a URL with format and quality", () => { + const result = generate( + img, + { + width: 300, + height: 200, + format: "webp", + quality: 75, + }, + { baseURL }, + ); + assertEquals(result, `${baseURL}/s_300x200,q_75,f_webp/images/test.jpg`); + }); +}); + +Deno.test("ipx transform", async (t) => { + await t.step("should transform an existing IPX URL", () => { + const url = `${baseURL}/w_300,h_200,f_auto/images/test.jpg`; + const result = transform( + url, + { + width: 400, + format: "webp", + }, + { baseURL }, + ); + assertEquals(result, `${baseURL}/s_400x200,f_webp/images/test.jpg`); + }); + + await t.step("should transform a non-IPX URL", () => { + const result = transform( + absoluteImg, + { + width: 300, + height: 200, + quality: 80, + }, + { baseURL }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseURL}/s_300x200,q_80,f_auto/https://example.com/images/test.jpg`, + ); + }); + + await t.step("should use default baseURL if not provided", () => { + const result = transform( + absoluteImg, + { + width: 300, + height: 200, + }, + {}, + ); + assertEqualIgnoringQueryOrder( + result, + `/_ipx/s_300x200,f_auto/https://example.com/images/test.jpg`, + ); + }); +}); diff --git a/src/providers/ipx.ts b/src/providers/ipx.ts new file mode 100644 index 0000000..d1fce56 --- /dev/null +++ b/src/providers/ipx.ts @@ -0,0 +1,135 @@ +import { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createOperationsHandlers, + stripLeadingSlash, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export interface IPXOperations extends Operations { + /** + * Width of the image in pixels. + */ + w?: number; + + /** + * Height of the image in pixels. + */ + h?: number; + + /** + * Combined size parameter. Example: "300x200" + */ + s?: string; + + /** + * Quality of the image (1-100). + */ + q?: number; + + /** + * Output format of the image. + */ + f?: ImageFormat | "auto"; +} + +export interface IPXOptions { + baseURL?: string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + IPXOperations +>({ + keyMap: { + width: "w", + height: "h", + quality: "q", + format: "f", + }, + defaults: { + f: "auto", + }, + kvSeparator: "_", + paramSeparator: ",", +}); + +export const generate: URLGenerator<"ipx"> = ( + src, + operations, + options, +) => { + if (operations.width && operations.height) { + operations.s = `${operations.width}x${operations.height}`; + delete operations.width; + delete operations.height; + } + + const modifiers = operationsGenerator(operations); + const baseURL = options?.baseURL ?? "/_ipx"; + const url = toUrl(baseURL); + + url.pathname = `${url.pathname}/${modifiers}/${ + stripLeadingSlash(src.toString()) + }`; + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"ipx"> = (url) => { + const parsedUrl = toUrl(url); + const [, baseUrlPart, modifiers, ...srcParts] = parsedUrl.pathname.split( + "/", + ); + + if (!modifiers || !srcParts.length) { + return null; + } + + const operations = operationsParser(modifiers); + + // Handle the 's' parameter + if (operations.s) { + const [width, height] = operations.s.split("x").map(Number); + operations.width = width; + operations.height = height; + delete operations.s; + } + + return { + src: "/" + srcParts.join("/"), + operations, + options: { + baseURL: `${parsedUrl.origin}/${baseUrlPart}`, + }, + }; +}; + +export const transform: URLTransformer<"ipx"> = ( + src, + operations, + options, +) => { + const url = toUrl(src); + const baseURL = options?.baseURL; + + if ( + (baseURL && url.toString().startsWith(baseURL)) || + url.pathname.startsWith("/_ipx") + ) { + const extracted = extract(src); + if (extracted) { + return generate( + extracted.src, + { ...extracted.operations, ...operations }, + { baseURL: extracted.options.baseURL }, + ); + } + } + + return generate(src, operations, { baseURL }); +}; diff --git a/src/providers/keycdn.test.ts b/src/providers/keycdn.test.ts new file mode 100644 index 0000000..b5b0229 --- /dev/null +++ b/src/providers/keycdn.test.ts @@ -0,0 +1,174 @@ +import { extract, generate, transform } from "./keycdn.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = "https://ip.keycdn.com/example.jpg"; + +Deno.test("keycdn transform", async (t) => { + await t.step( + "should format a URL with width, height, and default fit", + () => { + const result = transform(img, { + width: 200, + height: 100, + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=200&height=100&fit=cover", + ); + }, + ); + + await t.step("should set fit to another value", () => { + const result = transform(img, { + width: 200, + height: 100, + fit: "contain", + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=200&height=100&fit=contain", + ); + }); + + await t.step("should convert boolean to 0/1 and format with flip", () => { + const result = transform(img, { + width: 200, + flip: true, + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=200&flip=1&fit=cover", + ); + }); + + await t.step( + "should handle boolean conversions from 0/1 back to true/false", + () => { + const result = transform(img, { + width: 200, + flip: 0, + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=200&flip=0&fit=cover", + ); + }, + ); + + await t.step("should round non-integer params", () => { + const result = transform(img, { + width: 200.6, + height: 100.2, + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=201&height=100&fit=cover", + ); + }); +}); + +Deno.test("keycdn generate", async (t) => { + await t.step( + "should format a URL with width, height, and default fit", + () => { + const result = generate(img, { width: 200, height: 100 }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=200&height=100&fit=cover", + ); + }, + ); + + await t.step("should format a URL with crop and default fit", () => { + const result = generate(img, { + width: 400, + height: 300, + crop: "smart", + }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?crop=smart&width=400&height=300&fit=cover", + ); + }); + + await t.step("should format a URL with quality and default fit", () => { + const result = generate(img, { width: 600, quality: 80 }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=600&quality=80&fit=cover", + ); + }); + + await t.step( + "should format a URL with format conversion and default fit", + () => { + const result = generate(img, { width: 400, format: "webp" }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?format=webp&width=400&fit=cover", + ); + }, + ); + + await t.step("should set fit to another value in generated URL", () => { + const result = generate(img, { width: 300, height: 200, fit: "fill" }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?width=300&height=200&fit=fill", + ); + }); + + await t.step( + "should convert boolean to 0/1 and format with negate and default fit", + () => { + const result = generate(img, { negate: true }); + assertEqualIgnoringQueryOrder( + result, + "https://ip.keycdn.com/example.jpg?negate=1&fit=cover", + ); + }, + ); +}); + +Deno.test("keycdn extract", async (t) => { + await t.step( + "should extract from regular CDN URL with no operations", + () => { + const parsed = extract(img); + assertEquals(parsed, { + src: img, + operations: {}, + }); + }, + ); + + await t.step("should extract with transformations", () => { + const parsed = extract( + "https://ip.keycdn.com/example.jpg?width=200&height=100&format=webp&fit=fill", + ); + assertEquals(parsed, { + src: "https://ip.keycdn.com/example.jpg", + operations: { + width: 200, + height: 100, + format: "webp", + fit: "fill", + }, + }); + }); + + await t.step("should extract and convert booleans from 0/1 in URL", () => { + const parsed = extract( + "https://ip.keycdn.com/example.jpg?flip=1&negate=0&fit=contain", + ); + assertEquals(parsed, { + src: "https://ip.keycdn.com/example.jpg", + operations: { + flip: true, + negate: false, + fit: "contain", + }, + }); + }); +}); diff --git a/src/providers/keycdn.ts b/src/providers/keycdn.ts new file mode 100644 index 0000000..3c4a16a --- /dev/null +++ b/src/providers/keycdn.ts @@ -0,0 +1,305 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + paramToBoolean, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * @see https://www.keycdn.com/support/image-processing + */ +export interface KeyCDNOperations extends Operations { + /** + * Trim similar pixels from the edges. + * @type {number} Range: 0-100 + */ + trim?: number; + + /** + * Crop options: "smart" for automatic cropping, or pixel-based. + * @type {string | {width: number, height: number, x?: number, y?: number} | `fp,${number},${number},${number}` | `fpd,${number},${number}`} + */ + crop?: + | "smart" + | `${number},${number}` + | `${number},${number},${number},${number}` + | `fp,${number},${number},${number}` + | `fpd,${number},${number}`; + + /** + * Resize fit method. + * @type {('cover' | 'contain' | 'fill' | 'inside' | 'outside')} + */ + fit?: "cover" | "contain" | "fill" | "inside" | "outside"; + + /** + * Image position for fit: 'cover' or 'contain'. + * @type {('top' | 'right' | 'bottom' | 'left')} + */ + position?: "top" | "right" | "bottom" | "left"; + + /** + * Whether to enlarge the image beyond original size. + */ + enlarge?: 0 | 1 | boolean; + + /** + * Background color, either as a hex string or rgba values. + * @type {string | `${number},${number},${number}` | `${number},${number},${number},${number}`} + */ + bg?: + | string + | `${number},${number},${number}` + | `${number},${number},${number},${number}`; + + /** + * Extend the image with padding. + * @type {number | {top: number, right: number, bottom: number, left: number}} + */ + extend?: number | { + top: number; + right: number; + bottom: number; + left: number; + }; + + /** + * Rotate the image. + * @type {number} Range: -359 to 359 degrees + */ + rotate?: number; + + /** + * Flip image vertically. + */ + flip?: 0 | 1 | boolean; + + /** + * Flop image horizontally. + */ + flop?: 0 | 1 | boolean; + + /** + * Sharpen the image. + * @type {number} Range: 0-100 + */ + sharpen?: number; + + /** + * Blur the image. + * @type {number} Range: 0.3-100 + */ + blur?: number; + + /** + * Apply gamma correction. + * @type {number} Range: 0-3 + */ + gamma?: number; + + /** + * Invert image colors. + */ + negate?: 0 | 1 | boolean; + + /** + * Normalize image contrast. + */ + normalize?: 0 | 1 | boolean; + + /** + * Apply threshold. + * @type {number} Range: 0-255 + */ + threshold?: number; + + /** + * Apply tint using hex color. + */ + tint?: string; + + /** + * Convert the image to grayscale. + */ + grayscale?: 0 | 1 | boolean; + + /** + * Remove alpha channel from the image. + */ + removealpha?: 0 | 1 | boolean; + + /** + * URL for an overlay image. + */ + olurl?: string; + + /** + * Overlay alignment. + */ + olalign?: + | "center" + | "top" + | "topright" + | "right" + | "bottomright" + | "bottom" + | "bottomleft" + | "topleft"; + + /** + * Overlay X-axis coordinate. + * @type {number} Range: 0-2000 pixels + */ + olx?: number; + + /** + * Overlay Y-axis coordinate. + * @type {number} Range: 0-2000 pixels + */ + oly?: number; + + /** + * Overlay width in pixels or percentage. + * @type {number | `${number}%`} + */ + olwidth?: number | `${number}%`; + + /** + * Overlay height in pixels or percentage. + * @type {number | `${number}%`} + */ + olheight?: number | `${number}%`; + + /** + * Overlay transparency. + * @type {number} Range: 1-99 + */ + olalpha?: number; + + /** + * Repeat overlay. + */ + olrepeat?: 0 | 1 | boolean; + + /** + * Output image format. + */ + format?: ImageFormat | "tiff"; + + /** + * Image quality. + * @type {number} Range: 0-100 + */ + quality?: number; + + /** + * Progressive scan for JPEG/PNG. + */ + progressive?: 0 | 1 | boolean; + + /** + * PNG compression level. + * @type {number} Range: 0-9 + */ + compression?: number; + + /** + * Adaptive row filtering for PNG. + */ + adaptive?: 0 | 1 | boolean; + + /** + * Quality of WebP alpha layer. + * @type {number} Range: 0-100 + */ + alphaquality?: number; + + /** + * Lossless encoding for WebP. + */ + lossless?: 0 | 1 | boolean; + + /** + * Near-lossless compression for WebP. + */ + nearlossless?: 0 | 1 | boolean; + + /** + * Preserve image metadata (EXIF, IPTC, XMP). + */ + metadata?: 0 | 1 | boolean; +} + +const BOOLEAN_PARAMS = [ + "enlarge", + "flip", + "flop", + "negate", + "normalize", + "grayscale", + "removealpha", + "olrepeat", + "progressive", + "adaptive", + "lossless", + "nearlossless", + "metadata", +] as const; + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + KeyCDNOperations +>({ + defaults: { + fit: "cover", + }, + formatMap: { + jpg: "jpeg", + }, +}); + +export const generate: URLGenerator<"keycdn"> = ( + src, + operations, +) => { + const url = toUrl(src); + + for (const key of BOOLEAN_PARAMS) { + if (operations[key] !== undefined) { + operations[key] = operations[key] ? 1 : 0; + } + } + + url.search = operationsGenerator(operations); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"keycdn"> = (url) => { + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + + for (const key of BOOLEAN_PARAMS) { + if (operations[key] !== undefined) { + operations[key] = paramToBoolean(operations[key]); + } + } + parsedUrl.search = ""; + + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; +}; + +export const transform: URLTransformer<"keycdn"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/kontent.ai.test.ts b/src/providers/kontent.ai.test.ts new file mode 100644 index 0000000..fe49e5a --- /dev/null +++ b/src/providers/kontent.ai.test.ts @@ -0,0 +1,122 @@ +import { extract, generate, transform } from "./kontent.ai.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const img = + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg"; + +Deno.test("kontent.ai generate", async (t) => { + await t.step("should generate URL with width and height", () => { + const result = generate(img, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&h=600&fit=crop", + ); + }); + + await t.step("should generate URL with custom fit", () => { + const result = generate(img, { width: 800, height: 600, fit: "clip" }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&h=600&fit=crop", + ); + }); + + await t.step("should generate URL with quality", () => { + const result = generate(img, { width: 800, quality: 80 }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&q=80", + ); + }); + + await t.step("should generate URL with format conversion", () => { + const result = generate(img, { width: 400, format: "webp" }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=400&fm=webp", + ); + }); + + await t.step("should generate URL with lossless compression", () => { + const result = generate(img, { format: "webp", lossless: true }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?fm=webp&lossless=1", + ); + }); +}); + +Deno.test("kontent.ai extract", async (t) => { + await t.step("should extract basic URL without transformations", () => { + const parsed = extract(img); + assertEquals(parsed, { + src: img, + operations: {}, + }); + }); + + await t.step("should extract width, height, and fit", () => { + const parsed = extract( + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&h=600&fit=clip", + ); + assertEquals(parsed, { + src: + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg", + operations: { + width: 800, + height: 600, + fit: "clip", + }, + }); + }); + + await t.step("should extract format and quality", () => { + const parsed = extract( + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?fm=webp&q=90", + ); + assertEquals(parsed, { + src: + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg", + operations: { + format: "webp", + quality: 90, + }, + }); + }); + + await t.step("should extract and convert lossless to boolean", () => { + const parsed = extract( + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?fm=webp&lossless=1", + ); + assertEquals(parsed, { + src: + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg", + operations: { + format: "webp", + lossless: true, + }, + }); + }); +}); + +Deno.test("kontent.ai transform", async (t) => { + await t.step("should transform URL by adding new operations", () => { + const result = transform(img, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&h=600&fit=crop", + ); + }); + + await t.step("should overwrite existing operations", () => { + const result = transform( + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=400&h=300", + { width: 800, height: 600 }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=800&h=600&fit=crop", + ); + }); +}); diff --git a/src/providers/kontent.ai.ts b/src/providers/kontent.ai.ts new file mode 100644 index 0000000..293d749 --- /dev/null +++ b/src/providers/kontent.ai.ts @@ -0,0 +1,145 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + paramToBoolean, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * @see https://kontent.ai/learn/docs/apis/image-transformation-api + */ +export interface KontentAiOperations + extends Operations<"gif" | "png8" | "pjpg"> { + /** + * Resize the image to a specified width in pixels. + * @type {number} Range: 1-8192 + */ + w?: number; + + /** + * Resize the image to a specified height in pixels. + * @type {number} Range: 1-8192 + */ + h?: number; + + /** + * Defines the fit mode to apply to the image. + * @type {('clip' | 'scale' | 'crop')} + */ + fit?: "clip" | "scale" | "crop"; + + /** + * Select a rectangular region of the image to process. + * Format: x,y,width,height as either pixels or percentages. + * @type {string} Example: "0,0,100,100" or "0.1,0.1,0.5,0.5" + */ + rect?: string; + + /** + * Horizontal focal point for cropping. + * @type {number} Range: 0.0-1.0, Default is 0.5 + */ + "fp-x"?: number; + + /** + * Vertical focal point for cropping. + * @type {number} Range: 0.0-1.0, Default is 0.5 + */ + "fp-y"?: number; + + /** + * Focal point zoom level, where 1 is the original size. + * @type {number} Range: 1-100 + */ + "fp-z"?: number; + + /** + * Background color for transparent areas. + * Accepts RGB or ARGB hex values. + * @type {string} Examples: "FFFFFF" (RGB), "FF00FF00" (ARGB) + */ + bg?: string; + + /** + * Image format conversion. + */ + fm?: ImageFormat | "gif" | "png8" | "pjpg"; + + /** + * Quality of the output image for lossy formats (jpg, pjpg, webp). + * @type {number} Range: 0-100 + */ + q?: number; + + /** + * Lossless compression for WebP format. + * @type {0 | 1 | boolean} + */ + lossless?: 0 | 1 | boolean; + + /** + * Automatically select the best format for the browser. + * @type {'format'} + */ + auto?: "format"; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + KontentAiOperations +>({ + formatMap: { + jpg: "jpeg", + }, + keyMap: { + format: "fm", + width: "w", + height: "h", + quality: "q", + }, +}); + +export const generate: URLGenerator<"kontent.ai"> = ( + src, + operations, +) => { + const url = toUrl(src); + if (operations.lossless !== undefined) { + operations.lossless = operations.lossless ? 1 : 0; + } + + if (operations.width && operations.height) { + operations.fit = "crop"; + } + + url.search = operationsGenerator(operations); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"kontent.ai"> = (url) => { + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + + if (operations.lossless !== undefined) { + operations.lossless = paramToBoolean(operations.lossless); + } + parsedUrl.search = ""; + + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; +}; + +export const transform: URLTransformer<"kontent.ai"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/netlify.test.ts b/src/providers/netlify.test.ts new file mode 100644 index 0000000..2a26ea7 --- /dev/null +++ b/src/providers/netlify.test.ts @@ -0,0 +1,113 @@ +import { extract, generate, transform } from "./netlify.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const relativeUrl = "/cappadocia.jpg"; +const baseUrl = "https://unpic-playground.netlify.app"; +const transformedUrl = `${baseUrl}/.netlify/images?url=${relativeUrl}`; + +// Tests for generate, extract, and transform + +Deno.test("Netlify Image CDN - generate", async (t) => { + await t.step("should generate a relative URL with transformations", () => { + const result = generate(relativeUrl, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + "/.netlify/images?url=/cappadocia.jpg&w=800&h=600&fit=cover", // Short params: w, h + ); + }); + + await t.step("should generate an absolute URL with transformations", () => { + const result = generate(relativeUrl, { width: 800, height: 600 }, { + baseUrl, + }); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-playground.netlify.app/.netlify/images?url=/cappadocia.jpg&w=800&h=600&fit=cover", // Short params: w, h + ); + }); + + await t.step("should generate a URL with quality and format", () => { + const result = generate(relativeUrl, { + width: 800, + quality: 75, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + "/.netlify/images?url=/cappadocia.jpg&w=800&q=75&fm=webp&fit=cover", // Short params: w, q, fm + ); + }); + + await t.step( + "should generate an absolute URL with quality and format", + () => { + const result = generate( + relativeUrl, + { width: 800, quality: 75, format: "webp" }, + { baseUrl }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-playground.netlify.app/.netlify/images?url=/cappadocia.jpg&w=800&q=75&fm=webp&fit=cover", // Short params: w, q, fm + ); + }, + ); +}); + +Deno.test("Netlify Image CDN - extract", async (t) => { + await t.step( + "should extract transformations from a transformed URL", + () => { + const parsed = extract( + "https://unpic-playground.netlify.app/.netlify/images?url=/cappadocia.jpg&w=800&h=600&fm=webp&q=75", + ); + assertEquals(parsed, { + src: "/cappadocia.jpg", + operations: { + width: 800, + height: 600, + format: "webp", + quality: 75, + }, + options: { + baseUrl, + }, + }); + }, + ); +}); + +Deno.test("Netlify Image CDN - transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform( + "/.netlify/images?url=/cappadocia.jpg&w=400&h=300", + { width: 800, height: 600 }, + {}, + ); + assertEqualIgnoringQueryOrder( + result, + "/.netlify/images?url=/cappadocia.jpg&w=800&h=600&fit=cover", // Short params: w, h + ); + }); + + await t.step("should transform a relative URL with new operations", () => { + const result = transform(relativeUrl, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + "/.netlify/images?url=/cappadocia.jpg&w=800&h=600&fit=cover", // Short params: w, h + ); + }); + + await t.step("should transform an absolute URL with new operations", () => { + const result = transform( + transformedUrl, + { width: 1200, quality: 80 }, + { baseUrl }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-playground.netlify.app/.netlify/images?url=/cappadocia.jpg&w=1200&q=80&fit=cover", // Short params: w, q + ); + }); +}); diff --git a/src/providers/netlify.ts b/src/providers/netlify.ts new file mode 100644 index 0000000..bf4cff9 --- /dev/null +++ b/src/providers/netlify.ts @@ -0,0 +1,122 @@ +import { getProviderForUrlByPath } from "../detect.ts"; +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * @see https://docs.netlify.com/image-cdn/overview/ + */ +export interface NetlifyOperations extends Operations<"blurhash"> { + /** + * Resize the image to a specified width in pixels. + * @type {number} Range: 1-8192 + */ + w?: number; + + /** + * Resize the image to a specified height in pixels. + * @type {number} Range: 1-8192 + */ + h?: number; + + /** + * Fit the image within the specified dimensions. + */ + fit?: "contain" | "cover" | "fill"; + + /** + * Image quality for lossy formats like JPEG and WebP. + * Shorthand for `quality`. + * @type {number} Range: 1-100 + */ + q?: number; + + /** + * Image format conversion. + * Shorthand for `format`. + */ + fm?: ImageFormat | "blurhash"; + + /** + * Position of the image when using fit=cover. + * Shorthand for `position`. + */ + position?: "center" | "top" | "bottom" | "left" | "right"; +} + +export interface NetlifyOptions { + /** + * Base URL for the Netlify Image CDN. Defaults to relative URLs. + */ + baseUrl?: string; + /** + * Always use the Netlify Image CDN, even if the source URL matches another provider. + */ + force?: boolean; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + NetlifyOperations +>({ + defaults: { + fit: "cover", + }, + keyMap: { + format: "fm", + width: "w", + height: "h", + quality: "q", + }, +}); + +export const generate: URLGenerator< + "netlify" +> = ( + src, + operations, + options = {}, +) => { + const url = toUrl(`${options.baseUrl || ""}/.netlify/images`); + + url.search = operationsGenerator(operations); + url.searchParams.set("url", src.toString()); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor< + "netlify" +> = (url) => { + if (getProviderForUrlByPath(url) !== "netlify") { + return null; + } + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + // deno-lint-ignore no-explicit-any + delete (operations as any).url; + const sourceUrl = parsedUrl.searchParams.get("url") || ""; + + parsedUrl.search = ""; + + return { + src: sourceUrl, + operations, + options: { + baseUrl: parsedUrl.hostname === "n" ? undefined : parsedUrl.origin, + }, + }; +}; + +export const transform: URLTransformer< + "netlify" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/nextjs.test.ts b/src/providers/nextjs.test.ts new file mode 100644 index 0000000..538019c --- /dev/null +++ b/src/providers/nextjs.test.ts @@ -0,0 +1,110 @@ +import { extract, generate, transform } from "./nextjs.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const relativeUrl = "/_next/static/media/bunny.0e498116.jpg"; +const baseUrl = "https://unpic-next.netlify.app"; +const transformedUrl = `${baseUrl}/_next/image?url=${ + encodeURIComponent(relativeUrl) +}&w=828&q=75`; + +// Tests for generate, extract, and transform + +Deno.test("Next.js Image CDN - generate", async (t) => { + await t.step("should generate a relative URL with transformations", () => { + const result = generate(relativeUrl, { width: 828 }); + assertEqualIgnoringQueryOrder( + result, + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + }); + + await t.step("should generate an absolute URL with transformations", () => { + const result = generate(relativeUrl, { width: 828 }, { baseUrl }); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-next.netlify.app/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + }); + + await t.step("should generate a URL with quality", () => { + const result = generate(relativeUrl, { width: 828, quality: 80 }); + assertEqualIgnoringQueryOrder( + result, + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=80", + ); + }); + + await t.step("should generate an absolute URL with quality", () => { + const result = generate(relativeUrl, { width: 828, quality: 80 }, { + baseUrl, + }); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-next.netlify.app/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=80", + ); + }); +}); + +Deno.test("Next.js Image CDN - extract", async (t) => { + await t.step( + "should extract transformations from a transformed URL", + () => { + const parsed = extract( + "https://unpic-next.netlify.app/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + assertEquals(parsed, { + src: "/_next/static/media/bunny.0e498116.jpg", + operations: { + width: 828, + quality: 75, + }, + options: { + baseUrl: "https://unpic-next.netlify.app", + }, + }); + }, + ); +}); + +Deno.test("Next.js Image CDN - transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform( + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=400", + { width: 828 }, + {}, + ); + assertEqualIgnoringQueryOrder( + result, + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + }); + + await t.step("should transform a relative URL with new operations", () => { + const result = transform(relativeUrl, { width: 828 }); + assertEqualIgnoringQueryOrder( + result, + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + }); + + await t.step("should ignore height operations", () => { + const result = transform(relativeUrl, { width: 828, height: 400 }); + assertEqualIgnoringQueryOrder( + result, + "/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=828&q=75", + ); + }); + + await t.step("should transform an absolute URL with new operations", () => { + const result = transform( + transformedUrl, + { width: 1200, quality: 80 }, + { baseUrl }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://unpic-next.netlify.app/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fbunny.0e498116.jpg&w=1200&q=80", + ); + }); +}); diff --git a/src/providers/nextjs.ts b/src/providers/nextjs.ts new file mode 100644 index 0000000..20d5d4e --- /dev/null +++ b/src/providers/nextjs.ts @@ -0,0 +1,29 @@ +import { + extract as vercelExtract, + generate as vercelGenerate, + type VercelOperations as NextjsOperations, +} from "./vercel.ts"; +import type { URLExtractor, URLGenerator, URLTransformer } from "../types.ts"; +import { createExtractAndGenerate } from "../utils.ts"; + +export type { NextjsOperations }; + +export interface NextjsOptions { + baseUrl?: string; +} + +export const generate: URLGenerator<"nextjs"> = ( + src, + operations, + options = {}, +) => vercelGenerate(src, operations, { ...options, prefix: "_next" }); + +export const extract: URLExtractor<"nextjs"> = ( + url, + options, +) => vercelExtract(url, options); + +export const transform: URLTransformer<"nextjs"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/scene7.test.ts b/src/providers/scene7.test.ts new file mode 100644 index 0000000..7a758e3 --- /dev/null +++ b/src/providers/scene7.test.ts @@ -0,0 +1,103 @@ +import { extract, generate, transform } from "./scene7.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const baseImageUrl = "https://s7d1.scene7.com/is/image/sample/s9"; +const transformedUrl = + `${baseImageUrl}?wid=800&hei=600&qlt=75&fmt=jpg&op_blur=5`; + +// Tests for generate, extract, and transform + +Deno.test("Scene7 Image CDN - generate", async (t) => { + await t.step("should generate a URL with basic transformations", () => { + const result = generate(baseImageUrl, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=800&hei=600&fit=crop,0`, + ); + }); + + await t.step("should generate a URL with quality and format", () => { + const result = generate(baseImageUrl, { + width: 800, + height: 600, + quality: 75, + format: "jpg", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=800&hei=600&qlt=75&fmt=jpg&fit=crop,0`, + ); + }); + + await t.step("should generate a URL with additional operations", () => { + const result = generate(baseImageUrl, { + width: 800, + height: 600, + quality: 75, + format: "jpg", + op_blur: 5, + }); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=800&hei=600&qlt=75&fmt=jpg&op_blur=5&fit=crop,0`, + ); + }); +}); + +Deno.test("Scene7 Image CDN - extract", async (t) => { + await t.step( + "should extract transformations from a transformed URL", + () => { + const parsed = extract(transformedUrl); + assertEquals(parsed, { + src: baseImageUrl, + operations: { + width: 800, + height: 600, + quality: 75, + format: "jpg", + op_blur: "5", + }, + }); + }, + ); +}); + +Deno.test("Scene7 Image CDN - transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform( + `${baseImageUrl}?wid=400&hei=300`, + { width: 800, height: 600 }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=800&hei=600&fit=crop,0`, + ); + }); + + await t.step("should add new operations to an existing URL", () => { + const result = transform( + `${baseImageUrl}?wid=400&hei=300`, + { quality: 75, format: "jpg" }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=400&hei=300&qlt=75&fmt=jpg&fit=crop,0`, + ); + }); + + await t.step( + "should update existing operations in a transformed URL", + () => { + const result = transform(transformedUrl, { + width: 1200, + op_blur: 10, + }); + assertEqualIgnoringQueryOrder( + result, + `${baseImageUrl}?wid=1200&hei=600&qlt=75&fmt=jpg&op_blur=10&fit=crop,0`, + ); + }, + ); +}); diff --git a/src/providers/scene7.ts b/src/providers/scene7.ts new file mode 100644 index 0000000..cbe9a2e --- /dev/null +++ b/src/providers/scene7.ts @@ -0,0 +1,241 @@ +import { getProviderForUrl } from "../detect.ts"; +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, +} from "../utils.ts"; + +export type Scene7Formats = + | "avif-alpha" + | "avif" + | "eps" + | "f4m" + | "gif-alpha" + | "gif" + | "heic" + | "jpeg" + | "jpeg2000-alpha" + | "jpeg2000" + | "jpegxr-alpha" + | "jpegxr" + | "jpg" + | "m3u8" + | "pdf" + | "pjpeg" + | "png-alpha" + | "png" + | "png8-alpha" + | "png8" + | "swf-alpha" + | "swf" + | "swf3-alpha" + | "swf3" + | "tif-alpha" + | "tif" + | "web-alpha" + | "webp"; + +export type Scene7Fit = + | "fit" + | "constrain" + | "crop" + | "wrap" + | "stretch" + | "hfit" + | "vfit"; + +/** + * Adobe Dynamic Media Image Rendering API operations + * @see https://experienceleague.adobe.com/en/docs/dynamic-media-developer-resources/image-serving-api/image-serving-api/http-protocol-reference/command-reference/c-command-reference + */ +export interface Scene7Operations extends Operations { + /** + * Request type to perform on the image. + */ + req?: "saveToFile" | "xmp" | "targets" | "mbrset"; + + /** + * Specifies how the scale factor is calculated + */ + fit?: Scene7Fit | `${Scene7Fit},${1 | 0}`; + + /** + * Width of the output image in pixels. + */ + wid?: number | string; + + /** + * Height of the output image in pixels. + */ + hei?: number | string; + + /** + * Device Pixel Ratio, used to scale the image according to device resolution. + */ + dpr?: number | string; + + /** + * Output format of the image. + */ + fmt?: Scene7Formats; + + /** + * Quality of the image (used for JPEG/WebP). + */ + qlt?: number | string; + + /** + * Background color of the image in hexadecimal format (e.g., 'FFFFFF'). + */ + bgColor?: string; + + /** + * Whether the image should be cached or not. + * @type {'on' | 'off'} + */ + cache?: "on" | "off"; + + /** + * Scaling factor for the image. + */ + scale?: number | string; + + /** + * Rotation angle of the image in degrees. + */ + rotate?: number | string; + + /** + * Flip the image horizontally ('h') or vertically ('v'). + */ + flip?: "h" | "v"; + + /** + * Cropping dimensions for the image in the format 'x,y,width,height'. + */ + crop?: string; + + /** + * Image mask to be applied. + */ + mask?: string; + + /** + * Blending mode to be applied to the image. + */ + blendMode?: "multiply" | "screen" | "overlay"; + + /** + * Add an image layer. + */ + layer?: string; + + /** + * Opacity of a layer. + * @type {number} Range: 0-100 + */ + opac?: number | string; + + /** + * Position of a layer in the format 'x,y'. + */ + pos?: `${number},${number}`; + + /** + * Text to add to the image. + */ + text?: string; + + /** + * Angle at which to rotate the text. + */ + textAngle?: number | string; + + /** + * Attributes of the text (e.g., 'Arial,20,bold'). + * @type {string} + */ + textAttr?: string; + + /** + * ICC color profile to use for color correction. + */ + icc?: string; + + /** + * Whether to embed the ICC profile in the image. + */ + iccEmbed?: boolean; + + /** + * Apply unsharp mask to the image. Format: 'amount,radius,threshold'. + */ + op_usm?: string; + + /** + * Apply blur to the image. + */ + op_blur?: number | string; + + /** + * Resolution of the output image in DPI. + */ + res?: number | string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + Scene7Operations +>({ + keyMap: { + width: "wid", + height: "hei", + quality: "qlt", + format: "fmt", + }, + defaults: { + fit: "crop,0", + }, +}); + +const BASE = "https://s7d1.scene7.com/is/image/"; + +export const generate: URLGenerator< + "scene7" +> = ( + src, + operations, +) => { + const url = new URL(src, BASE); + + url.search = operationsGenerator(operations); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"scene7"> = ( + url, +) => { + if (getProviderForUrl(url) !== "scene7") { + return null; + } + const parsedUrl = new URL(url, BASE); + const operations = operationsParser(parsedUrl); + + parsedUrl.search = ""; + + return { + src: parsedUrl.toString(), + operations, + }; +}; + +export const transform: URLTransformer<"scene7"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/transformers/shopify.fixtures.json b/src/providers/shopify.fixtures.json similarity index 67% rename from src/transformers/shopify.fixtures.json rename to src/providers/shopify.fixtures.json index 49630a9..9b07a92 100644 --- a/src/transformers/shopify.fixtures.json +++ b/src/providers/shopify.fixtures.json @@ -1,83 +1,46 @@ [ { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage_icon.png?v=1", - "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.png?v=1", - "width": null, - "height": null, - "size": "icon", - "crop": null, - "format": null + "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.png?v=1" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage_200x300.jpg?v=2", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.jpg?v=2", "width": 200, - "height": 300, - "size": null, - "crop": null, - "format": null + "height": 300 }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage_medium_crop_top.webp?v=3", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.webp?v=3", - "width": null, - "height": null, - "size": "medium", - "crop": "top", - "format": null + "crop": "top" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage_large_crop_bottom.avif?v=4", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.avif?v=4", - "width": null, - "height": null, - "size": "large", - "crop": "bottom", - "format": null + "crop": "bottom" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.jpg?v=5", - "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.jpg?v=5", - "width": null, - "height": null, - "size": null, - "crop": null, - "format": null + "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.jpg?v=5" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2_icon.png.jpg?v=6", - "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2.png?v=6", - "width": null, - "height": null, - "size": "icon", - "crop": null, - "format": "jpg" + "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2.png?v=6" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2_200x300.jpg.webp?v=7", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2.jpg?v=7", "width": 200, - "height": 300, - "size": null, - "crop": null, - "format": "webp" + "height": 300 }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2_medium_crop_top.webp.avif?v=8", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2.webp?v=8", - "width": null, - "height": null, - "size": "medium", - "crop": "top", - "format": "avif" + "crop": "top" }, { "original": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2_large_crop_bottom.avif.png?v=9", "base": "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage2.avif?v=9", - "width": null, - "height": null, - "size": "large", - "crop": "bottom", - "format": "png" + "crop": "bottom" } ] diff --git a/src/providers/shopify.test.ts b/src/providers/shopify.test.ts new file mode 100644 index 0000000..f1c0403 --- /dev/null +++ b/src/providers/shopify.test.ts @@ -0,0 +1,148 @@ +import { extract, generate, transform } from "./shopify.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +import examples from "./shopify.fixtures.json" with { + type: "json", +}; + +const baseUrl = "https://cdn.shopify.com/static/sample-images"; + +// Sample URLs to test +const pathWithSize = `${baseUrl}/garnished_800x600.jpeg`; +const pathWithCrop = `${baseUrl}/garnished_800x600_crop_center.jpeg`; +const transformedUrl = + `${baseUrl}/garnished_800x600_crop_center.jpeg?width=800&height=600&crop=center`; + +// Tests for generate, extract, and transform + +Deno.test("Shopify Image CDN - generate", async (t) => { + await t.step("should generate a clean URL with query parameters", () => { + const result = generate(`${baseUrl}/garnished_800x600.jpeg`, { + width: 800, + height: 600, + format: "jpeg", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=800&height=600`, + ); + }); + + await t.step( + "should generate a URL with crop and size query parameters", + () => { + const result = generate( + `${baseUrl}/garnished_800x600_crop_center.jpeg`, + { + width: 800, + height: 600, + crop: "center", + format: "jpeg", + }, + ); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=800&height=600&crop=center`, + ); + }, + ); + + await t.step("should generate a URL with padding color", () => { + const result = generate(`${baseUrl}/garnished.jpeg`, { + width: 400, + height: 300, + pad_color: "FFFFFF", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=400&height=300&pad_color=FFFFFF`, + ); + }); +}); + +Deno.test("Shopify Image CDN - extract", async (t) => { + await t.step("should extract size and crop from path", () => { + const parsed = extract(pathWithCrop); + assertEquals(parsed, { + src: `${baseUrl}/garnished.jpeg`, + operations: { + width: 800, + height: 600, + crop: "center", + }, + }); + }); + + await t.step("should extract size from path without crop", () => { + const parsed = extract(pathWithSize); + assertEquals(parsed, { + src: `${baseUrl}/garnished.jpeg`, + operations: { + width: 800, + height: 600, + }, + }); + }); + + await t.step("should extract transformations from query parameters", () => { + const parsed = extract(transformedUrl); + assertEquals(parsed, { + src: `${baseUrl}/garnished.jpeg`, + operations: { + width: 800, + height: 600, + crop: "center", + }, + }); + }); + for (const { original, ...example } of examples) { + await t.step(`Parse ${original}`, () => { + const { operations = {}, src } = extract(original) || {}; + // Convert null from JSON into undefined for assertEquals + const { base, ...expected } = Object.fromEntries( + Object.entries(example).map(([k, v]) => [k, v ?? undefined]), + ); + assertEquals(src, base); + + // deno-lint-ignore no-explicit-any + delete (operations as any).v; + assertEquals(operations, expected); + }); + } +}); + +Deno.test("Shopify Image CDN - transform", async (t) => { + await t.step("should transform a URL by adding new operations", () => { + const result = transform(pathWithSize, { + crop: "center", + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=800&height=600&crop=center`, + ); + }); + + await t.step("should override existing query parameters", () => { + const result = transform(transformedUrl, { + width: 400, + height: 300, + format: "png", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=400&height=300&crop=center`, + ); + }); + + await t.step("should add padding color to a transformed URL", () => { + const result = transform(transformedUrl, { + pad_color: "000000", + }); + assertEqualIgnoringQueryOrder( + result, + `${baseUrl}/garnished.jpeg?width=800&height=600&crop=center&pad_color=000000`, + ); + }); +}); diff --git a/src/providers/shopify.ts b/src/providers/shopify.ts new file mode 100644 index 0000000..383221a --- /dev/null +++ b/src/providers/shopify.ts @@ -0,0 +1,92 @@ +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const shopifyRegex = + /(.+?)(?:_(?:(pico|icon|thumb|small|compact|medium|large|grande|original|master)|(\d*)x(\d*)))?(?:_crop_([a-z]+))?(\.[a-zA-Z]+)(\.png|\.jpg|\.webp|\.avif)?$/; + +/** + * Shopify Image API operations + */ +export interface ShopifyOperations extends Operations { + /** + * Crop option, such as top, bottom, or center. + */ + crop?: "center" | "top" | "bottom" | "left" | "right"; + + /** + * Background color for padding. + */ + pad_color?: string; + + /** + * @deprecated Format is not supported by Shopify. + */ + format?: ImageFormat; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + ShopifyOperations +>({ + keyMap: { + format: false, + }, +}); + +export const generate: URLGenerator<"shopify"> = (src, operations) => { + const url = toUrl(src); + const basePath = url.pathname.replace(shopifyRegex, "$1$6"); + + // Update pathname with the clean version (remove size details) + url.pathname = basePath; + + // Add query parameters for size, format, etc. + url.search = operationsGenerator(operations); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"shopify"> = (url) => { + const parsedUrl = toUrl(url); + const match = shopifyRegex.exec(parsedUrl.pathname); + const operations: ShopifyOperations = operationsParser(parsedUrl); + + if (match) { + const [, , , width, height, crop] = match; + + if (width && height && !operations.width && !operations.height) { + operations.width = parseInt(width, 10); + operations.height = parseInt(height, 10); + } + + if (crop) { + operations.crop ??= crop as ShopifyOperations["crop"]; + } + } + + const basePath = parsedUrl.pathname.replace(shopifyRegex, "$1$6"); + parsedUrl.pathname = basePath; + + for (const key of ["width", "height", "crop", "pad_color", "format"]) { + parsedUrl.searchParams.delete(key); + } + + return { + src: parsedUrl.toString(), + operations, + }; +}; +export const transform: URLTransformer<"shopify"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/storyblok.test.ts b/src/providers/storyblok.test.ts new file mode 100644 index 0000000..edc67d1 --- /dev/null +++ b/src/providers/storyblok.test.ts @@ -0,0 +1,126 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./storyblok.ts"; + +const NEW_BASE_URL = + "https://a.storyblok.com/f/39898/1000x600/d962430746/demo-image-human.jpeg"; +const OLD_BASE_URL = + "https://img2.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg"; + +Deno.test("Storyblok Image CDN - extract", async (t) => { + await t.step("should extract operations from new URL format", () => { + const url = `${NEW_BASE_URL}/m/400x300/filters:format(webp)`; + const result = extract(url); + assertEquals(result, { + src: NEW_BASE_URL, + operations: { + width: 400, + height: 300, + format: "webp", + filters: {}, + }, + }); + }); + + await t.step("should extract operations from old URL format", () => { + const url = + "https://img2.storyblok.com/200x0/filters:rotate(90):format(png)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg"; + const result = extract(url); + assertEquals(result, { + src: + "https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg", + operations: { + width: 200, + format: "png", + filters: { + rotate: "90", + }, + }, + }); + }); + + await t.step("should handle URL without operations", () => { + const result = extract(NEW_BASE_URL); + assertEquals(result, { + src: NEW_BASE_URL, + operations: { + filters: {}, + }, + }); + }); +}); + +Deno.test("Storyblok Image CDN - generate", async (t) => { + await t.step("should generate URL with width and height", () => { + const result = generate(NEW_BASE_URL, { width: 400, height: 300 }); + assertEquals(result, `${NEW_BASE_URL}/m/400x300`); + }); + + await t.step("should generate URL with format", () => { + const result = generate(NEW_BASE_URL, { + width: 400, + height: 300, + format: "webp", + }); + assertEquals( + result, + `${NEW_BASE_URL}/m/400x300/filters:format(webp)`, + ); + }); + + await t.step("should generate URL with crop", () => { + const result = generate(NEW_BASE_URL, { + width: 400, + height: 300, + crop: "fit-in", + }); + assertEquals( + result, + `${NEW_BASE_URL}/m/fit-in/400x300`, + ); + }); + + await t.step("should generate URL with flip", () => { + const result = generate(NEW_BASE_URL, { + width: 400, + height: 300, + flipx: "-", + flipy: "-", + }); + assertEquals(result, `${NEW_BASE_URL}/m/-400x-300`); + }); +}); + +Deno.test("Storyblok Image CDN - transform", async (t) => { + await t.step("should transform new URL by adding operations", () => { + const result = transform(NEW_BASE_URL, { + width: 500, + height: 400, + format: "webp", + }); + assertEquals( + result, + `${NEW_BASE_URL}/m/500x400/filters:format(webp)`, + ); + }); + + await t.step("should transform old URL and update to new format", () => { + const result = transform(OLD_BASE_URL, { + width: 600, + height: 500, + format: "png", + }); + assertEquals( + result, + `https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg/m/600x500/filters:format(png)`, + ); + }); + + await t.step("should transform URL with existing operations", () => { + const url = `${NEW_BASE_URL}/m/300x200/filters:format(jpg)`; + const result = transform(url, { width: 400, format: "webp" }); + assertEquals( + result, + `${NEW_BASE_URL}/m/400x200/filters:format(webp)`, + ); + }); +}); diff --git a/src/providers/storyblok.ts b/src/providers/storyblok.ts new file mode 100644 index 0000000..22b3db8 --- /dev/null +++ b/src/providers/storyblok.ts @@ -0,0 +1,128 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const storyBlokAssets = + /(?\/f\/\d+\/\d+x\d+\/\w+\/[^\/]+)\/?(?m\/?(?\d+x\d+:\d+x\d+)?\/?(?(?\-)?(?\d+)x(?\-)?(?\d+))?\/?(filters\:(?[^\/]+))?)?$/; + +const storyBlokImg2 = + /^(?\/(?\d+x\d+:\d+x\d+)?\/?(?(?\-)?(?\d+)x(?\-)?(?\d+))?\/?(filters\:(?[^\/]+))?\/?)?(?\/f\/.+)$/; + +export interface StoryblokOperations extends Operations { + crop?: string; + filters?: Record; + flipx?: "-"; + flipy?: "-"; +} + +const splitFilters = (filters: string): Record => { + if (!filters) { + return {}; + } + return Object.fromEntries( + filters.split(":").map((filter) => { + if (!filter) return []; + const [key, value] = filter.split("("); + return [key, value.replace(")", "")]; + }), + ); +}; + +const generateFilters = (filters?: Record) => { + if (!filters) { + return undefined; + } + const filterItems = Object.entries(filters).map(([key, value]) => + `${key}(${value ?? ""})` + ); + if (filterItems.length === 0) { + return undefined; + } + return `filters:${filterItems.join(":")}`; +}; + +export const extract: URLExtractor<"storyblok"> = (url) => { + const parsedUrl = toUrl(url); + + const regex = parsedUrl.hostname === "img2.storyblok.com" + ? storyBlokImg2 + : storyBlokAssets; + + const matches = regex.exec(parsedUrl.pathname); + if (!matches || !matches.groups) { + return null; + } + + const { id, crop, width, height, filters, flipx, flipy } = matches.groups; + + const { format, ...filterMap } = splitFilters(filters ?? ""); + + // We update old img2.storyblok.com URLs to use the new syntax and domain + if (parsedUrl.hostname === "img2.storyblok.com") { + parsedUrl.hostname = "a.storyblok.com"; + } + + const operations: StoryblokOperations = Object.fromEntries( + [ + ["width", Number(width) || undefined], + ["height", Number(height) || undefined], + ["format", format], + ["crop", crop], + ["filters", filterMap], + ["flipx", flipx], + ["flipy", flipy], + ].filter(([_, value]) => value !== undefined), + ); + + return { + src: `${parsedUrl.origin}${id}`, + operations, + }; +}; + +export const generate: URLGenerator<"storyblok"> = ( + src, + operations, +) => { + const url = toUrl(src); + const { + width = 0, + height = 0, + format, + crop, + filters = {}, + flipx = "", + flipy = "", + } = operations; + + const size = `${flipx}${width}x${flipy}${height}`; + + if (format) { + filters.format = format; + } + + const parts = [ + url.pathname, + "m", + crop, + size, + generateFilters(filters), + ].filter(Boolean); + + url.pathname = parts.join("/"); + + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"storyblok"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/supabase.ts b/src/providers/supabase.ts new file mode 100644 index 0000000..30f4026 --- /dev/null +++ b/src/providers/supabase.ts @@ -0,0 +1,95 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const STORAGE_URL_PREFIX = "/storage/v1/object/public/"; +const RENDER_URL_PREFIX = "/storage/v1/render/image/public/"; + +const isRenderUrl = (url: URL) => url.pathname.startsWith(RENDER_URL_PREFIX); + +/** + * Supabase Image Transformation API operations + */ +export interface SupabaseOperations extends Operations<"origin"> { + /** + * You can use different resizing modes: + * - `cover`: resizes the image while keeping the aspect ratio to fill a given size and crops projecting parts. + * - `contain`: resizes the image while keeping the aspect ratio to fit a given size. + * - `fill`: resizes the image without keeping the aspect ratio. + */ + resize?: "cover" | "contain" | "fill"; + + /** + * When using the image transformation API, Storage will automatically find the best format supported + * by the client and return that to the client. + * In case you'd like to return the original format of the image and opt-out from the automatic image + * optimization detection, you can pass the format=origin parameter when requesting a transformed image + */ + format?: "origin"; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + SupabaseOperations +>({}); + +export const generate: URLGenerator<"supabase"> = (src, operations) => { + const url = toUrl(src); + const basePath = url.pathname.replace( + RENDER_URL_PREFIX, + STORAGE_URL_PREFIX, + ); + + // Update the pathname with the cleaned version + url.pathname = basePath; + + // Supabase uses auto-format unless set to origin. Specific formats are not supported + if (operations.format && operations.format !== "origin") { + delete operations.format; + } + + // Add query parameters for image transformation + url.search = operationsGenerator(operations); + + // Replace with the render prefix for rendering + return toCanonicalUrlString(url).replace( + STORAGE_URL_PREFIX, + RENDER_URL_PREFIX, + ); +}; + +export const extract: URLExtractor<"supabase"> = (url) => { + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + const isRender = isRenderUrl(parsedUrl); + + const imagePath = parsedUrl.pathname.replace(RENDER_URL_PREFIX, "").replace( + STORAGE_URL_PREFIX, + "", + ); + + if (!isRender) { + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; + } + + return { + src: `${parsedUrl.origin}${STORAGE_URL_PREFIX}${imagePath}`, + operations, + }; +}; + +export const transform: URLTransformer<"supabase"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..d8c3353 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,116 @@ +import type { + ImageCdn, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import type { AstroOperations, AstroOptions } from "./astro.ts"; +import type { BuilderOperations } from "./builder.io.ts"; +import type { BunnyOperations } from "./bunny.ts"; +import type { CloudflareOperations, CloudflareOptions } from "./cloudflare.ts"; +import type { + CloudflareImagesOperations, + CloudflareImagesOptions, +} from "./cloudflare_images.ts"; +import type { CloudimageOperations, CloudimageOptions } from "./cloudimage.ts"; +import type { CloudinaryOperations, CloudinaryOptions } from "./cloudinary.ts"; +import type { ContentfulOperations } from "./contentful.ts"; +import type { + ContentstackOperations, + ContentstackOptions, +} from "./contentstack.ts"; +import type { DirectusOperations } from "./directus.ts"; +import type { HygraphOperations, HygraphOptions } from "./hygraph.ts"; +import type { ImageEngineOperations } from "./imageengine.ts"; +import type { ImageKitOperations } from "./imagekit.ts"; +import type { ImgixOperations } from "./imgix.ts"; +import type { IPXOperations, IPXOptions } from "./ipx.ts"; +import type { KeyCDNOperations } from "./keycdn.ts"; +import type { KontentAiOperations } from "./kontent.ai.ts"; +import type { NetlifyOperations, NetlifyOptions } from "./netlify.ts"; +import type { NextjsOperations, NextjsOptions } from "./nextjs.ts"; +import type { Scene7Operations } from "./scene7.ts"; +import type { ShopifyOperations } from "./shopify.ts"; +import type { StoryblokOperations } from "./storyblok.ts"; +import type { SupabaseOperations } from "./supabase.ts"; +import type { UploadcareOperations, UploadcareOptions } from "./uploadcare.ts"; +import type { VercelOperations, VercelOptions } from "./vercel.ts"; +import type { WordPressOperations } from "./wordpress.ts"; + +export interface ProviderOperations { + astro: AstroOperations; + "builder.io": BuilderOperations; + bunny: BunnyOperations; + cloudflare: CloudflareOperations; + cloudflare_images: CloudflareImagesOperations; + cloudimage: CloudimageOperations; + cloudinary: CloudinaryOperations; + contentful: ContentfulOperations; + contentstack: ContentstackOperations; + directus: DirectusOperations; + hygraph: HygraphOperations; + imageengine: ImageEngineOperations; + imagekit: ImageKitOperations; + imgix: ImgixOperations; + ipx: IPXOperations; + keycdn: KeyCDNOperations; + "kontent.ai": KontentAiOperations; + netlify: NetlifyOperations; + nextjs: NextjsOperations; + scene7: Scene7Operations; + shopify: ShopifyOperations; + storyblok: StoryblokOperations; + supabase: SupabaseOperations; + uploadcare: UploadcareOperations; + vercel: VercelOperations; + wordpress: WordPressOperations; +} + +export interface ProviderOptions { + astro: AstroOptions; + "builder.io": undefined; + bunny: undefined; + cloudflare: CloudflareOptions; + cloudflare_images: CloudflareImagesOptions; + cloudimage: CloudimageOptions; + cloudinary: CloudinaryOptions; + contentful: undefined; + contentstack: ContentstackOptions; + directus: undefined; + hygraph: HygraphOptions; + imageengine: undefined; + imagekit: undefined; + imgix: undefined; + ipx: IPXOptions; + keycdn: undefined; + "kontent.ai": undefined; + netlify: NetlifyOptions; + nextjs: NextjsOptions; + scene7: undefined; + shopify: undefined; + storyblok: undefined; + supabase: undefined; + uploadcare: UploadcareOptions; + vercel: VercelOptions; + wordpress: undefined; +} + +export type URLExtractorMap = { + [K in ImageCdn]: URLExtractor; +}; + +export type URLGeneratorMap = { + [K in ImageCdn]: URLGenerator; +}; + +export type URLTransformerMap = { + [K in ImageCdn]: URLTransformer; +}; + +export type ProviderModule< + TCDN extends ImageCdn, +> = { + generate: URLGenerator; + extract: URLExtractor; + transform?: URLTransformer; +}; diff --git a/src/providers/uploadcare.test.ts b/src/providers/uploadcare.test.ts new file mode 100644 index 0000000..275ceac --- /dev/null +++ b/src/providers/uploadcare.test.ts @@ -0,0 +1,151 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./uploadcare.ts"; + +const baseImageUrl = + "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/"; + +Deno.test("Uploadcare provider - extract", async (t) => { + await t.step("should extract operations from a basic URL", () => { + const result = extract(baseImageUrl); + assertEquals(result, { + src: "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/", + operations: {}, + options: { host: "ucarecdn.com" }, + }); + }); + + await t.step( + "should extract operations from a basic URL with filename", + () => { + const result = extract(`${baseImageUrl}tshirt1.jpg`); + assertEquals(result, { + src: "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/", + operations: {}, + options: { host: "ucarecdn.com" }, + }); + }, + ); + + await t.step( + "should extract operations from a URL with transformations", + () => { + const url = `${baseImageUrl}-/preview/1000x500/-/quality/lighter/`; + const result = extract(url); + assertEquals(result, { + src: "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/", + operations: { + preview: "1000x500", + quality: "lighter", + }, + options: { host: "ucarecdn.com" }, + }); + }, + ); + + await t.step( + "should extract resize operation and convert to width/height", + () => { + const url = `${baseImageUrl}-/resize/800x600/-/format/auto/`; + const result = extract(url); + assertEquals(result, { + src: "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/", + operations: { + width: 800, + height: 600, + format: "auto", + }, + options: { host: "ucarecdn.com" }, + }); + }, + ); +}); + +Deno.test("Uploadcare provider - generate", async (t) => { + await t.step( + "should generate a URL with basic operations and default format", + () => { + const result = generate(baseImageUrl, { + width: 800, + height: 600, + }); + assertEquals( + result, + `${baseImageUrl}-/resize/800x600/-/format/auto/`, + ); + }, + ); + + await t.step( + "should generate a URL with basic operations and a filename", + () => { + const result = generate(`${baseImageUrl}tshirt.jpg`, { + width: 800, + height: 600, + }); + assertEquals( + result, + `${baseImageUrl}-/resize/800x600/-/format/auto/tshirt.jpg`, + ); + }, + ); + + await t.step("should generate a URL with multiple operations", () => { + const result = generate(baseImageUrl, { + width: 800, + height: 600, + quality: "best", + format: "webp", + }); + assertEquals( + result, + `${baseImageUrl}-/quality/best/-/format/webp/-/resize/800x600/`, + ); + }); + + await t.step( + "should generate a URL with custom host and default format", + () => { + const result = generate(baseImageUrl, { width: 800 }, { + host: "custom-cdn.com", + }); + assertEquals( + result, + `https://custom-cdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x/-/format/auto/`, + ); + }, + ); +}); + +Deno.test("Uploadcare provider - transform", async (t) => { + await t.step("should transform a basic URL with default format", () => { + const result = transform(baseImageUrl, { width: 800, height: 600 }); + assertEquals(result, `${baseImageUrl}-/resize/800x600/-/format/auto/`); + }); + + await t.step("should transform a URL with existing operations", () => { + const url = + `${baseImageUrl}-/preview/500x300/-/quality/normal/-/format/auto/`; + const result = transform(url, { width: 800, format: "webp" }); + assertEquals( + result, + `${baseImageUrl}-/preview/500x300/-/quality/normal/-/format/webp/-/resize/800x/`, + ); + }); + + await t.step( + "should override existing operations and keep default format if not specified", + () => { + const url = + `${baseImageUrl}-/preview/500x300/-/quality/normal/-/format/auto/`; + const result = transform(url, { + width: 800, + height: 600, + quality: "best", + }); + assertEquals( + result, + `${baseImageUrl}-/preview/500x300/-/quality/best/-/format/auto/-/resize/800x600/`, + ); + }, + ); +}); diff --git a/src/providers/uploadcare.ts b/src/providers/uploadcare.ts new file mode 100644 index 0000000..27d9bcd --- /dev/null +++ b/src/providers/uploadcare.ts @@ -0,0 +1,134 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { stripTrailingSlash } from "../utils.ts"; +import { + addTrailingSlash, + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +const uploadcareRegex = + /^https?:\/\/(?[^\/]+)\/(?[^\/]+)(?:\/(?[^\/]+)?)?/; + +type Dimension = number | string; +type Dimensions = `${Dimension}x${Dimension}`; + +export interface UploadcareOperations extends Operations { + /** Resize the image to fit within the specified dimensions while maintaining aspect ratio */ + preview?: Dimensions; + /** Resize the image to specified dimensions */ + resize?: Dimensions | `${number | string}x` | `x${number | string}`; + /** Control how the image fits into the specified dimensions */ + stretch?: "on" | "off" | "fill"; + /** Resize the image intelligently to fit the specified dimensions */ + smart_resize?: Dimensions; + /** Crop the image to specified dimensions */ + crop?: string; + /** Scale and crop the image to specified dimensions */ + scale_crop?: string; + /** Apply border radius to the image */ + border_radius?: string; + /** Set the background color for transparent images */ + setfill?: string; + /** Zoom in on detected objects in the image */ + zoom_objects?: number; + /** Automatically rotate the image based on EXIF data */ + autorotate?: "yes" | "no"; + /** Rotate the image by a specified number of degrees */ + rotate?: number; + /** Flip the image vertically */ + flip?: boolean; + /** Mirror the image horizontally */ + mirror?: boolean; + /** Set the quality of the output image */ + quality?: "normal" | "better" | "best" | "lighter" | "lightest"; + /** Enable or disable progressive image loading */ + progressive?: "yes" | "no"; + /** Control the removal of metadata from the image */ + strip_meta?: "all" | "none" | "sensitive"; +} + +export interface UploadcareOptions { + /** The hostname for the Uploadcare CDN */ + host?: string; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + UploadcareOperations +>({ + keyMap: { + width: false, + height: false, + }, + defaults: { + format: "auto", + }, + kvSeparator: "/", + paramSeparator: "/-/", +}); + +export const extract: URLExtractor< + "uploadcare" +> = (url) => { + const parsedUrl = toUrl(url); + const match = uploadcareRegex.exec(parsedUrl.toString()); + if (!match || !match.groups) { + return null; + } + + const { host, uuid } = match.groups; + const [, ...operationsString] = parsedUrl.pathname.split("/-/"); + const operations = operationsParser(operationsString.join("/-/") || ""); + + if (operations.resize) { + const [width, height] = operations.resize.split("x"); + if (width) operations.width = parseInt(width); + if (height) operations.height = parseInt(height); + delete operations.resize; + } + + return { + src: `https://${host}/${uuid}/`, + operations, + options: { host }, + }; +}; + +export const generate: URLGenerator<"uploadcare"> = ( + src, + operations, + options = {}, +) => { + const url = toUrl(src); + const host = options.host || url.hostname; + + // Strip filename from the URL + const match = uploadcareRegex.exec(url.toString()); + if (match?.groups) { + url.pathname = `/${match.groups.uuid}/`; + } + + operations.resize = operations.resize || + `${operations.width ?? ""}x${operations.height ?? ""}`; + delete operations.width; + delete operations.height; + + const modifiers = addTrailingSlash(operationsGenerator(operations)); + + url.hostname = host; + url.pathname = stripTrailingSlash(url.pathname) + + (modifiers ? `/-/${modifiers}` : "") + (match?.groups?.filename ?? ""); + + return toCanonicalUrlString(url); +}; + +export const transform: URLTransformer<"uploadcare"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/vercel.test.ts b/src/providers/vercel.test.ts new file mode 100644 index 0000000..07e89e3 --- /dev/null +++ b/src/providers/vercel.test.ts @@ -0,0 +1,127 @@ +import { extract, generate, transform } from "./vercel.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const relativeUrl = "/image.jpg"; +const baseUrl = "https://example.com"; +const transformedUrl = `${baseUrl}/_vercel/image?url=${relativeUrl}`; + +// Tests for generate, extract, and transform + +Deno.test("Vercel Image CDN - generate", async (t) => { + await t.step("should generate a relative URL with transformations", () => { + const result = generate(relativeUrl, { w: 800 }); + assertEqualIgnoringQueryOrder( + result, + "/_vercel/image?url=/image.jpg&w=800&q=75", + ); + }); + + await t.step("should generate an absolute URL with transformations", () => { + const result = generate(relativeUrl, { w: 800 }, { baseUrl }); + assertEqualIgnoringQueryOrder( + result, + "https://example.com/_vercel/image?url=/image.jpg&w=800&q=75", + ); + }); + + await t.step("should generate a URL with quality", () => { + const result = generate(relativeUrl, { w: 800, q: 80 }); + assertEqualIgnoringQueryOrder( + result, + "/_vercel/image?url=/image.jpg&w=800&q=80", + ); + }); + + await t.step("should generate an absolute URL with quality", () => { + const result = generate(relativeUrl, { w: 800, q: 80 }, { baseUrl }); + assertEqualIgnoringQueryOrder( + result, + "https://example.com/_vercel/image?url=/image.jpg&w=800&q=80", + ); + }); + + await t.step("should generate a URL with a remote image", () => { + const result = generate( + "https://example.net/image.jpg", + { w: 800, q: 80 }, + { + baseUrl, + }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://example.com/_vercel/image?url=https%3A%2F%2Fexample.net%2Fimage.jpg&w=800&q=80", + ); + }); + + await t.step( + "should generate a relative path when transforming a remote URL with no base URL", + () => { + const result = generate( + "https://example.net/image.jpg", + { w: 800, q: 80 }, + {}, + ); + assertEqualIgnoringQueryOrder( + result, + "/_vercel/image?url=https%3A%2F%2Fexample.net%2Fimage.jpg&w=800&q=80", + ); + }, + ); +}); + +Deno.test("Vercel Image CDN - extract", async (t) => { + await t.step( + "should extract transformations from a transformed URL", + () => { + const parsed = extract( + "https://example.com/_vercel/image?url=/image.jpg&w=800&q=75", + ); + assertEquals(parsed, { + src: "/image.jpg", + operations: { + width: 800, + quality: 75, + }, + options: { + baseUrl: "https://example.com", + }, + }); + }, + ); +}); + +Deno.test("Vercel Image CDN - transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform( + "/_vercel/image?url=/image.jpg&w=400&q=75", + { width: 800 }, + {}, + ); + assertEqualIgnoringQueryOrder( + result, + "/_vercel/image?url=/image.jpg&w=800&q=75", + ); + }); + + await t.step("should transform a relative URL with new operations", () => { + const result = transform(relativeUrl, { w: 800 }); + assertEqualIgnoringQueryOrder( + result, + "/_vercel/image?url=/image.jpg&w=800&q=75", + ); + }); + + await t.step("should transform an absolute URL with new operations", () => { + const result = transform( + transformedUrl, + { w: 1200, q: 80 }, + { baseUrl }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://example.com/_vercel/image?url=/image.jpg&w=1200&q=80", + ); + }); +}); diff --git a/src/providers/vercel.ts b/src/providers/vercel.ts new file mode 100644 index 0000000..66c5bf7 --- /dev/null +++ b/src/providers/vercel.ts @@ -0,0 +1,103 @@ +import { getProviderForUrlByPath } from "../detect.ts"; +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +/** + * Vercel Image Optimization provider. + * @see https://vercel.com/docs/image-optimization + */ +export interface VercelOperations extends Operations { + /** + * Resize the image to a specified width in pixels. + * Shorthand for `width`. + * @type {number} Range: 1-8192 + */ + w?: number; + + /** + * Image quality for lossy formats like JPEG and WebP. + * Shorthand for `quality`. + * @type {number} Range: 1-100 + */ + q?: number; +} + +export interface VercelOptions { + baseUrl?: string; + /** + * Either "_vercel" or "_next". Defaults to "_vercel". + */ + prefix?: string; + /** + * Always use the Vercel CDN, even if the source URL matches another provider. + */ + force?: boolean; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + VercelOperations +>({ + keyMap: { + width: "w", + quality: "q", + height: false, + format: false, + }, + defaults: { + q: 75, + }, +}); + +export const generate: URLGenerator<"vercel"> = ( + src, + operations, + options = {}, +) => { + const url = toUrl( + `${options.baseUrl || ""}/${options.prefix || "_vercel"}/image`, + ); + + url.search = operationsGenerator(operations); + url.searchParams.append("url", src.toString()); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"vercel"> = ( + url, + options = {}, +) => { + if ( + !["vercel", "nextjs"].includes(getProviderForUrlByPath(url) || "") + ) { + return null; + } + const parsedUrl = toUrl(url); + const sourceUrl = parsedUrl.searchParams.get("url") || ""; + parsedUrl.searchParams.delete("url"); + const operations = operationsParser(parsedUrl); + + parsedUrl.search = ""; + + return { + src: sourceUrl, + operations, + options: { + baseUrl: options.baseUrl ?? parsedUrl.origin, + }, + }; +}; + +export const transform: URLTransformer< + "vercel" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/wordpress.test.ts b/src/providers/wordpress.test.ts new file mode 100644 index 0000000..676e86f --- /dev/null +++ b/src/providers/wordpress.test.ts @@ -0,0 +1,94 @@ +import { assertEquals } from "jsr:@std/assert"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { extract, generate, transform } from "./wordpress.ts"; + +const BASE_URL = + "https://wordpress.com/wp-content/uploads/2024/09/lohp-i3-hero-2x.png"; + +Deno.test("WordPress Image CDN - extract", async (t) => { + await t.step("should extract width and height", () => { + const url = `${BASE_URL}?w=300&h=200`; + const result = extract(url); + assertEquals(result, { + src: BASE_URL, + operations: { + width: 300, + height: 200, + }, + }); + }); + + await t.step("should handle crop parameter", () => { + const url = `${BASE_URL}?w=300&h=200&crop=0`; + const result = extract(url); + assertEquals(result, { + src: BASE_URL, + operations: { + width: 300, + height: 200, + crop: false, + }, + }); + }); + + await t.step("should handle URL without parameters", () => { + const result = extract(BASE_URL); + assertEquals(result, { + src: BASE_URL, + operations: {}, + }); + }); +}); + +Deno.test("WordPress Image CDN - generate", async (t) => { + await t.step("should generate URL with width and height", () => { + const result = generate(BASE_URL, { width: 400, height: 300 }); + assertEqualIgnoringQueryOrder(result, `${BASE_URL}?w=400&h=300&crop=1`); + }); + + await t.step("should generate URL with crop=false", () => { + const result = generate(BASE_URL, { + width: 400, + height: 300, + crop: false, + }); + assertEqualIgnoringQueryOrder(result, `${BASE_URL}?w=400&h=300&crop=0`); + }); + + await t.step("should generate URL without parameters", () => { + const result = generate(BASE_URL, {}); + assertEqualIgnoringQueryOrder(result, `${BASE_URL}?crop=1`); + }); +}); + +Deno.test("WordPress Image CDN - transform", async (t) => { + await t.step("should transform URL by adding new operations", () => { + const result = transform(BASE_URL, { width: 500, height: 400 }); + assertEqualIgnoringQueryOrder(result, `${BASE_URL}?w=500&h=400&crop=1`); + }); + + await t.step( + "should transform URL by modifying existing operations", + () => { + const url = `${BASE_URL}?w=300&h=200&crop=1`; + const result = transform(url, { width: 600, crop: false }); + console.log(result); + assertEqualIgnoringQueryOrder( + result, + `${BASE_URL}?w=600&h=200&crop=0`, + ); + }, + ); + + await t.step( + "should transform URL without changing unspecified operations", + () => { + const url = `${BASE_URL}?w=300&h=200&crop=1`; + const result = transform(url, { height: 400 }); + assertEqualIgnoringQueryOrder( + result, + `${BASE_URL}?w=300&h=400&crop=1`, + ); + }, + ); +}); diff --git a/src/providers/wordpress.ts b/src/providers/wordpress.ts new file mode 100644 index 0000000..3f449ea --- /dev/null +++ b/src/providers/wordpress.ts @@ -0,0 +1,64 @@ +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +export interface WordPressOperations extends Operations { + w?: number; + h?: number; + crop?: boolean | "1" | "0"; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + WordPressOperations +>({ + keyMap: { + width: "w", + height: "h", + }, + defaults: { + crop: "1", + }, +}); + +export const generate: URLGenerator<"wordpress"> = ( + src, + operations, +) => { + const url = toUrl(src); + const { crop } = operations; + if (typeof crop !== "undefined" && crop !== "0") { + operations.crop = crop ? "1" : "0"; + } + url.search = operationsGenerator(operations); + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"wordpress"> = (url) => { + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + + if (operations.crop !== undefined) { + operations.crop = operations.crop === "1"; + } + + parsedUrl.search = ""; + + return { + src: toCanonicalUrlString(parsedUrl), + operations, + }; +}; + +export const transform: URLTransformer<"wordpress"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..7035b02 --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,26 @@ +import { assertEquals } from "jsr:@std/assert"; +import { toUrl } from "./utils.ts"; + +function sortQueryParams(params: URLSearchParams) { + const sorted = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b) + ); + params = new URLSearchParams(sorted); + return params; +} + +export function assertEqualIgnoringQueryOrder( + a: URL | string, + b: URL | string, + message?: string, +) { + a = toUrl(a); + b = toUrl(b); + assertEquals(a.origin, b.origin, message); + assertEquals(a.pathname, b.pathname, message); + assertEquals( + sortQueryParams(a.searchParams).toString(), + sortQueryParams(b.searchParams).toString(), + message, + ); +} diff --git a/src/transform.test.ts b/src/transform.test.ts index 0fef695..cc2a6f9 100644 --- a/src/transform.test.ts +++ b/src/transform.test.ts @@ -1,20 +1,19 @@ import { assertEquals } from "jsr:@std/assert"; -import { getImageCdnForUrl } from "./detect.ts"; +import { getProviderForUrl } from "./detect.ts"; import { transformUrl } from "./transform.ts"; - -const imgRemote = - "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75"; +import { assertEqualIgnoringQueryOrder } from "./test-utils.ts"; Deno.test("transformer", async (t) => { await t.step("should format a remote URL", () => { const result = transformUrl({ - url: imgRemote, + url: + "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75", width: 200, height: 100, }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", + assertEqualIgnoringQueryOrder( + result!, + "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image?w=200&q=75&url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100", ); }); @@ -24,35 +23,59 @@ Deno.test("transformer", async (t) => { width: 200, height: 100, }); - assertEquals( - result?.toString(), + assertEqualIgnoringQueryOrder( + result!, "https://images.unsplash.com/photo?w=200&h=100&fit=min&auto=format", ); }); - await t.step("should format a remote, non-CDN image next/image", () => { + await t.step("should use a fallback if not a supported CDN", () => { const result = transformUrl({ url: "https://placekitten.com/100", width: 200, height: 100, - cdn: "nextjs", + fallback: "nextjs", }); - assertEquals( - result?.toString(), + assertEqualIgnoringQueryOrder( + result!, "/_next/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75", ); }); + await t.step("should pass CDN-specific options", () => { + const result = transformUrl({ + url: "https://images.unsplash.com/photo", + width: 200, + height: 100, + quality: 80, + }, { + imgix: { + auto: "redeye", + }, + shopify: { + crop: "center", + }, + }, { + cloudinary: { + cloudName: "demo", + }, + }); + assertEqualIgnoringQueryOrder( + result!, + "https://images.unsplash.com/photo?w=200&h=100&fit=min&auto=redeye&q=80", + ); + }); + await t.step("should format a remote, no-CDN ipx image", () => { const result = transformUrl({ url: "https://placekitten.com/100", width: 200, height: 100, - cdn: "ipx", + fallback: "ipx", }); assertEquals( - result?.toString(), - "/_ipx/s_200x100/https://placekitten.com/100", + result!, + "/_ipx/s_200x100,f_auto/https://placekitten.com/100", ); }); @@ -62,8 +85,8 @@ Deno.test("transformer", async (t) => { width: 200, height: 100, }); - assertEquals( - result?.toString(), + assertEqualIgnoringQueryOrder( + result!, "https://example.com/_ipx/s_200x100,f_auto/https://placekitten.com/100", ); }); @@ -73,76 +96,52 @@ Deno.test("transformer", async (t) => { url: "/image.png", width: 200, height: 100, - cdn: "ipx", + fallback: "ipx", }); - assertEquals( - result?.toString(), - "/_ipx/s_200x100/image.png", + assertEqualIgnoringQueryOrder( + result!, + "/_ipx/s_200x100,f_auto/image.png", ); }); }); -Deno.test("delegation", async (t) => { - await t.step("should delegate an image CDN URL and nextjs", () => { - const result = transformUrl({ - url: "https://images.unsplash.com/photo?auto=format&fit=crop&w=2089&q=80", - width: 200, - height: 100, - cdn: "nextjs", - }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", - ); - }); +Deno.test("fallback", async (t) => { + await t.step( + "should not use the fallback if the URL matches a known CDN", + () => { + const result = transformUrl( + { + url: + "https://images.unsplash.com/photo?auto=format&fit=crop&w=2089&q=80", + width: 200, + height: 100, + fallback: "nextjs", + }, + ); + assertEqualIgnoringQueryOrder( + result!, + "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", + ); + }, + ); await t.step("should delegate an image CDN URL and ipx", () => { const result = transformUrl({ url: "https://images.unsplash.com/photo?auto=format&fit=crop&w=2089&q=80", width: 200, height: 100, - cdn: "ipx", + fallback: "ipx", }); - assertEquals( - result?.toString(), + assertEqualIgnoringQueryOrder( + result!, "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", ); }); - - await t.step("should not delegate a local URL", () => { - const result = transformUrl({ - url: "/_next/static/image.png", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_next/image?url=%2F_next%2Fstatic%2Fimage.png&w=200&q=75", - ); - }); - - await t.step( - "should not delegate an image CDN URL if recursion is disabled", - () => { - const result = transformUrl({ - url: - "https://images.unsplash.com/photo?auto=format&fit=crop&w=2089&q=80", - width: 200, - height: 100, - recursive: false, - cdn: "nextjs", - }); - assertEquals( - result?.toString(), - "/_next/image?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D2089%26q%3D80&w=200&q=75", - ); - }, - ); }); Deno.test("detection", async (t) => { await t.step("should detect by path with a relative URL", () => { - const cdn = getImageCdnForUrl( + const cdn = getProviderForUrl( "/_next/image?url=%2Fprofile.png&w=200&q=75", ); assertEquals(cdn, "nextjs"); diff --git a/src/transform.ts b/src/transform.ts index bc3d387..38d1c3c 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,102 +1,108 @@ -import { getImageCdnForUrl } from "./detect.ts"; -import { transform as contentful } from "./transformers/contentful.ts"; -import { transform as builderio } from "./transformers/builder.io.ts"; -import { transform as imgix } from "./transformers/imgix.ts"; -import { transform as shopify } from "./transformers/shopify.ts"; -import { transform as wordpress } from "./transformers/wordpress.ts"; -import { transform as cloudimage } from "./transformers/cloudimage.ts"; -import { transform as cloudinary } from "./transformers/cloudinary.ts"; -import { transform as cloudflare } from "./transformers/cloudflare.ts"; -import { transform as bunny } from "./transformers/bunny.ts"; -import { transform as storyblok } from "./transformers/storyblok.ts"; -import { transform as kontentai } from "./transformers/kontent.ai.ts"; -import { transform as vercel } from "./transformers/vercel.ts"; -import { transform as nextjs } from "./transformers/nextjs.ts"; -import { transform as scene7 } from "./transformers/scene7.ts"; -import { transform as keycdn } from "./transformers/keycdn.ts"; -import { transform as directus } from "./transformers/directus.ts"; -import { transform as imageengine } from "./transformers/imageengine.ts"; -import { transform as contentstack } from "./transformers/contentstack.ts"; -import { transform as cloudflareImages } from "./transformers/cloudflare_images.ts"; -import { transform as ipx } from "./transformers/ipx.ts"; -import { transform as astro } from "./transformers/astro.ts"; -import { transform as netlify } from "./transformers/netlify.ts"; -import { transform as imagekit } from "./transformers/imagekit.ts"; -import { transform as uploadcare } from "./transformers/uploadcare.ts"; -import { transform as supabase } from "./transformers/supabase.ts"; -import { transform as hygraph } from "./transformers/hygraph.ts"; -import { ImageCdn, UrlTransformer } from "./types.ts"; -import { getCanonicalCdnForUrl } from "./canonical.ts"; +import { getProviderForUrl } from "./detect.ts"; +import { transform as astro } from "./providers/astro.ts"; +import { transform as builderio } from "./providers/builder.io.ts"; +import { transform as bunny } from "./providers/bunny.ts"; +import { transform as cloudflare } from "./providers/cloudflare.ts"; +import { transform as cloudflare_images } from "./providers/cloudflare_images.ts"; +import { transform as cloudimage } from "./providers/cloudimage.ts"; +import { transform as cloudinary } from "./providers/cloudinary.ts"; +import { transform as contentful } from "./providers/contentful.ts"; +import { transform as contentstack } from "./providers/contentstack.ts"; +import { transform as directus } from "./providers/directus.ts"; +import { transform as hygraph } from "./providers/hygraph.ts"; +import { transform as imageengine } from "./providers/imageengine.ts"; +import { transform as imagekit } from "./providers/imagekit.ts"; +import { transform as imgix } from "./providers/imgix.ts"; +import { transform as ipx } from "./providers/ipx.ts"; +import { transform as keycdn } from "./providers/keycdn.ts"; +import { transform as kontentai } from "./providers/kontent.ai.ts"; +import { transform as netlify } from "./providers/netlify.ts"; +import { transform as nextjs } from "./providers/nextjs.ts"; +import { transform as scene7 } from "./providers/scene7.ts"; +import { transform as shopify } from "./providers/shopify.ts"; +import { transform as storyblok } from "./providers/storyblok.ts"; +import { transform as supabase } from "./providers/supabase.ts"; +import { transform as uploadcare } from "./providers/uploadcare.ts"; +import { transform as vercel } from "./providers/vercel.ts"; +import { transform as wordpress } from "./providers/wordpress.ts"; +import type { + ImageCdn, + URLTransformer, + UrlTransformerOptions, +} from "./types.ts"; +import type { + ProviderOperations, + ProviderOptions, + URLTransformerMap, +} from "./providers/types.ts"; -export const getTransformer = (cdn: ImageCdn) => ({ - imgix, - contentful, +const transformerMap: URLTransformerMap = { + astro, "builder.io": builderio, - shopify, - wordpress, - cloudimage, - cloudinary, bunny, - storyblok, cloudflare, - vercel, - nextjs, - scene7, - "kontent.ai": kontentai, - keycdn, + cloudflare_images, + cloudimage, + cloudinary, + contentful, + contentstack, directus, + hygraph, imageengine, - contentstack, - "cloudflare_images": cloudflareImages, + imagekit, + imgix, ipx, - astro, + keycdn, + "kontent.ai": kontentai, netlify, - imagekit, - uploadcare, + nextjs, + scene7, + shopify, + storyblok, supabase, - hygraph, -}[cdn]); - + uploadcare, + vercel, + wordpress, +} as const; /** * Returns a transformer function if the given CDN is supported */ -export const getTransformerForCdn = ( - cdn: ImageCdn | false | undefined, -): UrlTransformer | undefined => { + +export function getTransformerForCdn( + cdn: TCDN | false | undefined, +): URLTransformer | undefined { if (!cdn) { return undefined; } - return getTransformer(cdn); -}; + return transformerMap[cdn]; +} /** * Transforms an image URL to a new URL with the given options. * If the URL is not from a known image CDN it returns undefined. */ -export const transformUrl: UrlTransformer = (options) => { - const cdn = options?.cdn ?? getImageCdnForUrl(options.url); - // Default to recursive - if (!(options.recursive ?? true)) { - return getTransformerForCdn(cdn)?.(options); - } - const canonical = getCanonicalCdnForUrl( - options.url, - cdn, - ); - if (!canonical || !canonical.cdn) { +export function transformUrl( + { + url, + provider, + cdn: cdnOption, + fallback, + width, + height, + format, + quality, + }: UrlTransformerOptions, + providerOperations?: Partial, + providerOptions?: Partial, +): string | undefined { + const cdn = provider || cdnOption || + getProviderForUrl(url) as TCDN || fallback; + + if (!cdn) { return undefined; } - return getTransformer(canonical.cdn)?.({ - ...options, - url: canonical.url, - }); -}; - -/** - * Returns a transformer function if the given URL is from a known image CDN - * - * @deprecated Use `getCanonicalCdnForUrl` and `getTransformerForCdn` instead - */ -export const getTransformerForUrl = ( - url: string | URL, -): UrlTransformer | undefined => getTransformerForCdn(getImageCdnForUrl(url)); + return getTransformerForCdn(cdn)?.(url, { + ...{ width, height, format, quality } as ProviderOperations[TCDN], + ...providerOperations?.[cdn], + }, providerOptions?.[cdn] ?? {} as ProviderOptions[TCDN]); +} diff --git a/src/transformers/astro.test.ts b/src/transformers/astro.test.ts deleted file mode 100644 index d07dc0f..0000000 --- a/src/transformers/astro.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { ParsedUrl } from "../types.ts"; -import { AstroParams, parse, transform } from "./astro.ts"; - -const img = - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg"; - -Deno.test("astro parser", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg", - params: { - "href": - "https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg", - }, - cdn: "astro", - }; - assertEquals(JSON.stringify(parsed), JSON.stringify(expected)); -}); - -Deno.test("astro parser endpoint", () => { - const parsed = parse( - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg", - ); - const expected: ParsedUrl = { - base: - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg", - params: { - "href": - "https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg", - }, - cdn: "astro", - }; - assertEquals(JSON.stringify(parsed), JSON.stringify(expected)); -}); - -Deno.test("astro", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=cover", - ); - }); - - await t.step("should format a URL with custom endpoint", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - cdnOptions: { astro: { endpoint: "/_image/" } }, - }); - assertEquals( - result?.toString(), - "/_image/?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&h=100&fit=cover", - ); - }); - - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&fit=cover", - ); - }); - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("h", "100&fit=cover"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=200&fit=cover", - ); - }); - - await t.step("should round non-integer params", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "/_image?href=https%3A%2F%2Fimages.ctfassets.net%2Faaaa%2Fxxxx%2Fyyyy%2Fhow-to-wow-a-customer.jpg&w=201&h=100&fit=cover", - ); - }); - - await t.step("should transform a local image with a relative base", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - }); - assertEquals( - result?.toString(), - "/_image?href=%2Fstatic%2Fmoose.png&w=100&h=200&f=webp&fit=cover", - ); - }); -}); diff --git a/src/transformers/astro.ts b/src/transformers/astro.ts deleted file mode 100644 index 3f79d6d..0000000 --- a/src/transformers/astro.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ShouldDelegateUrl, UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toCanonicalUrlString, - toUrl, -} from "../utils.ts"; -import { getImageCdnForUrlByDomain } from "../detect.ts"; - -export interface AstroParams { - href: string; - quality?: string | number; -} - -export const delegateUrl: ShouldDelegateUrl = (url) => { - const parsedUrl = toUrl(url); - const searchParamHref = parsedUrl.searchParams.get("href"); - const decodedHref = typeof searchParamHref === "string" - ? decodeURIComponent(searchParamHref) - : new URL(parsedUrl.pathname, parsedUrl.origin).toString(); - const source = toCanonicalUrlString(toUrl(decodedHref)); - - if (!source || !source.startsWith("http")) { - return false; - } - const cdn = getImageCdnForUrlByDomain(source); - if (!cdn) { - return false; - } - return { - cdn, - url: source, - }; -}; - -export const parse: UrlParser = (url) => { - const parsedUrl = toUrl(url); - const searchParamHref = parsedUrl.searchParams.get("href"); - const decodedHref = typeof searchParamHref === "string" - ? decodeURIComponent(searchParamHref) - : new URL(parsedUrl.pathname, parsedUrl.origin).toString(); - const encodedHref = encodeURIComponent( - toCanonicalUrlString(toUrl(decodedHref)), - ); - const width = getNumericParam(parsedUrl, "w") || undefined; - const height = getNumericParam(parsedUrl, "h") || undefined; - const format = parsedUrl.searchParams.get("f") || undefined; - const quality = parsedUrl.searchParams.get("q") || undefined; - - return { - width, - height, - format, - base: `/_image?href=${encodedHref}`, - params: { quality, href: encodedHref }, - cdn: "astro", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format, cdnOptions }, -) => { - const parsedUrl = toUrl(originalUrl); - const href = toCanonicalUrlString( - new URL(parsedUrl.pathname, parsedUrl.origin), - ); - const url = { searchParams: new URLSearchParams() } as URL; - - setParamIfDefined(url, "href", href, true, true); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfDefined(url, "f", format); - setParamIfUndefined(url, "fit", "cover"); - - const endpoint = cdnOptions?.astro?.endpoint ?? "/_image"; - - return `${endpoint}?${url.searchParams.toString()}`; -}; diff --git a/src/transformers/builder.io.test.ts b/src/transformers/builder.io.test.ts deleted file mode 100644 index a811500..0000000 --- a/src/transformers/builder.io.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { transform } from "./builder.io.ts"; - -const img = - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f"; - -Deno.test("builder.io", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200&height=100&fit=cover&sharp=true", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200", - ); - }); - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("height", "100"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=200", - ); - }); - - await t.step("should round non-integer params", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?width=201&height=100&fit=cover&sharp=true", - ); - }); - - await t.step("should not set fit=cover if another value exists", () => { - const url = new URL(img); - url.searchParams.set("fit", "inside"); - const result = transform({ url, width: 200, height: 100 }); - assertEquals( - result?.toString(), - "https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F462d29d57dda42cb9e26441501db535f?fit=inside&width=200&height=100&sharp=true", - ); - }); -}); diff --git a/src/transformers/builder.io.ts b/src/transformers/builder.io.ts deleted file mode 100644 index ad5772f..0000000 --- a/src/transformers/builder.io.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const parse: UrlParser<{ fit?: string; quality?: number }> = (url) => { - const parsedUrl = toUrl(url); - - const width = getNumericParam(parsedUrl, "width"); - const height = getNumericParam(parsedUrl, "height"); - const quality = getNumericParam(parsedUrl, "quality"); - const format = parsedUrl.searchParams.get("format") || undefined; - const fit = parsedUrl.searchParams.get("fit") || undefined; - parsedUrl.search = ""; - - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { quality, fit }, - cdn: "builder.io", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "width", width, true, true); - setParamIfDefined(url, "height", height, true, true); - setParamIfDefined(url, "format", format); - if (width && height) { - setParamIfUndefined(url, "fit", "cover"); - setParamIfUndefined(url, "sharp", "true"); - } - return url; -}; diff --git a/src/transformers/bunny.ts b/src/transformers/bunny.ts deleted file mode 100644 index a19389c..0000000 --- a/src/transformers/bunny.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const parse: UrlParser<{ fit?: string }> = (url) => { - const parsedUrl = toUrl(url); - - const width = getNumericParam(parsedUrl, "width"); - const height = getNumericParam(parsedUrl, "height"); - const params: Record = {}; - parsedUrl.searchParams.forEach((value, key) => { - params[key] = value; - }); - parsedUrl.search = ""; - return { - width, - height, - base: parsedUrl.toString(), - params, - cdn: "bunny", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "width", width, true, true); - if (width && height) { - setParamIfUndefined(url, "aspect_ratio", `${width}:${height}`); - } - return url; -}; diff --git a/src/transformers/cloudflare.test.ts b/src/transformers/cloudflare.test.ts deleted file mode 100644 index 63a891c..0000000 --- a/src/transformers/cloudflare.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { CloudflareParams, parse, transform } from "./cloudflare.ts"; - -const img = - "https://assets.brevity.io/cdn-cgi/image/background=red,width=128,height=128,f=auto/uploads/generic/avatar-sample.jpeg"; - -Deno.test("cloudflare parser", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: img, - cdn: "cloudflare", - format: "auto", - width: 128, - height: 128, - params: { - host: "assets.brevity.io", - transformations: { - background: "red", - }, - path: "uploads/generic/avatar-sample.jpeg", - }, - }; - assertEquals(parsed, expected); -}); - -Deno.test("cloudflare transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://assets.brevity.io/cdn-cgi/image/background=red,width=100,height=200,f=auto,fit=cover/uploads/generic/avatar-sample.jpeg", - ); - }); -}); diff --git a/src/transformers/cloudflare.ts b/src/transformers/cloudflare.ts deleted file mode 100644 index 3abd0dd..0000000 --- a/src/transformers/cloudflare.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { toUrl } from "../utils.ts"; - -const cloudflareRegex = - /https?:\/\/(?[^\/]+)\/cdn-cgi\/image\/(?[^\/]+)?\/(?.*)$/g; - -const parseTransforms = (transformations: string) => - Object.fromEntries(transformations.split(",").map((t) => t.split("="))); - -const formatUrl = ( - { - host, - transformations = {}, - path, - }: CloudflareParams, -): string => { - const transformString = Object.entries(transformations).map( - ([key, value]) => `${key}=${value}`, - ).join(","); - - const pathSegments = [ - host, - "cdn-cgi", - "image", - transformString, - path, - ].join("/"); - return `https://${pathSegments}`; -}; - -export interface CloudflareParams { - host?: string; - transformations: Record; - path?: string; -} -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const matches = [...url.toString().matchAll(cloudflareRegex)]; - if (!matches.length) { - throw new Error("Invalid Cloudflare URL"); - } - - const group = matches[0].groups || {}; - const { - transformations: transformString, - ...baseParams - } = group; - - const { width, height, f, ...transformations } = parseTransforms( - transformString, - ); - - const base = formatUrl({ ...baseParams, transformations }); - return { - base: url.toString(), - width: Number(width) || undefined, - height: Number(height) || undefined, - format: f, - cdn: "cloudflare", - params: { ...group, transformations }, - }; -}; - -export const generate: UrlGenerator = ( - { base, width, height, format, params }, -) => { - const parsed = parse(base.toString()); - - const props: CloudflareParams = { - transformations: {}, - ...parsed.params, - ...params, - }; - - if (width) { - props.transformations.width = width?.toString(); - } - if (height) { - props.transformations.height = height?.toString(); - } - if (format) { - props.transformations.f = format === "jpg" ? "jpeg" : format; - } - - props.transformations.fit ||= "cover"; - - return new URL(formatUrl(props)); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format = "auto" }, -) => { - const parsed = parse(originalUrl); - if (!parsed) { - throw new Error("Invalid Cloudflare URL"); - } - - const props: UrlGeneratorOptions = { - ...parsed, - width, - height, - format, - }; - - return generate(props); -}; diff --git a/src/transformers/cloudflare_images.test.ts b/src/transformers/cloudflare_images.test.ts deleted file mode 100644 index 2aad52c..0000000 --- a/src/transformers/cloudflare_images.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { - CloudflareImagesParams, - parse, - transform, -} from "./cloudflare_images.ts"; - -const img = - "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/w=128,h=128,rotate=90,f=auto"; - -Deno.test("cloudflare images parser", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: img, - cdn: "cloudflare_images", - format: "auto", - width: 128, - height: 128, - params: { - host: "100francisco.com", - accountHash: "1aS6NlIe-Sc1o3NhVvy8Qw", - imageId: "2ba36ba9-69f6-41b6-8ff0-2779b41df200", - transformations: { - rotate: "90", - }, - }, - }; - assertEquals(parsed, expected); -}); - -Deno.test("cloudflare images transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://100francisco.com/cdn-cgi/imagedelivery/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/rotate=90,w=100,h=200,f=auto,fit=cover", - ); - }); -}); - -const img2 = - "https://imagedelivery.net/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/w=128,h=128,rotate=90,f=auto"; - -Deno.test("imagedelivery.net images parser", () => { - const parsed = parse(img2); - const expected: ParsedUrl = { - base: img2, - cdn: "cloudflare_images", - format: "auto", - width: 128, - height: 128, - params: { - host: "imagedelivery.net", - accountHash: "1aS6NlIe-Sc1o3NhVvy8Qw", - imageId: "2ba36ba9-69f6-41b6-8ff0-2779b41df200", - transformations: { - rotate: "90", - }, - }, - }; - assertEquals(parsed, expected); -}); - -Deno.test("imagedelivery.net images transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img2, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://imagedelivery.net/1aS6NlIe-Sc1o3NhVvy8Qw/2ba36ba9-69f6-41b6-8ff0-2779b41df200/rotate=90,w=100,h=200,f=auto,fit=cover", - ); - }); -}); diff --git a/src/transformers/cloudflare_images.ts b/src/transformers/cloudflare_images.ts deleted file mode 100644 index ab69852..0000000 --- a/src/transformers/cloudflare_images.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { toUrl } from "../utils.ts"; - -const cloudflareImagesRegex = - /https?:\/\/(?[^\/]+)\/cdn-cgi\/imagedelivery\/(?[^\/]+)\/(?[^\/]+)\/*(?[^\/]+)*$/g; -const imagedeliveryRegex = - /https?:\/\/(?imagedelivery.net)\/(?[^\/]+)\/(?[^\/]+)\/*(?[^\/]+)*$/g; - -const parseTransforms = (transformations: string) => - Object.fromEntries( - transformations?.split(",")?.map((t) => t.split("=")) ?? [], - ); - -const formatUrl = ( - { - host, - accountHash, - transformations = {}, - imageId, - }: CloudflareImagesParams, -): string => { - const transformString = Object.entries(transformations) - .filter(([key, value]) => Boolean(key) && value !== undefined) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - const pathSegments = [ - ...(host === "imagedelivery.net" - ? [host] - : [host, "cdn-cgi", "imagedelivery"]), - accountHash, - imageId, - transformString, - ].join("/"); - return `https://${pathSegments}`; -}; - -export interface CloudflareImagesParams { - host?: string; - accountHash?: string; - imageId?: string; - transformations: Record; -} -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const matches = [ - ...url.toString().matchAll(cloudflareImagesRegex), - ...url.toString().matchAll(imagedeliveryRegex), - ]; - if (!matches.length) { - throw new Error("Invalid Cloudflare Images URL"); - } - - const group = matches[0].groups || {}; - const { - transformations: transformString, - ...baseParams - } = group; - - const { w, h, f, ...transformations } = parseTransforms( - transformString, - ); - - return { - base: url.toString(), - width: Number(w) || undefined, - height: Number(h) || undefined, - format: f, - cdn: "cloudflare_images", - params: { ...baseParams, transformations }, - }; -}; - -export const generate: UrlGenerator = ( - { base, width, height, format, params }, -) => { - const parsed = parse(base.toString()); - - const props: CloudflareImagesParams = { - transformations: {}, - ...parsed.params, - ...params, - }; - - if (width) { - props.transformations.w = width?.toString(); - } - if (height) { - props.transformations.h = height?.toString(); - } - if (format) { - props.transformations.f = format; - } - - props.transformations.fit ||= "cover"; - - return new URL(formatUrl(props)); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format = "auto" }, -) => { - const parsed = parse(originalUrl); - - if (!parsed) { - throw new Error("Invalid Cloudflare Images URL"); - } - - const props: UrlGeneratorOptions = { - ...parsed, - width, - height, - format, - }; - - return generate(props); -}; diff --git a/src/transformers/cloudimage.test.ts b/src/transformers/cloudimage.test.ts deleted file mode 100644 index 2fee86a..0000000 --- a/src/transformers/cloudimage.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { CloudimageParams, parse, transform } from "./cloudimage.ts"; - -Deno.test("Cloudimage parser", async (t) => { - await t.step("parses a URL", () => { - const parsed = parse( - "https://doc.cloudimg.io/sample.li/flat1.jpg?w=450&h=200&q=90", - ); - const expected: ParsedUrl = { - base: "https://doc.cloudimg.io/sample.li/flat1.jpg", - cdn: "cloudimage", - width: 450, - height: 200, - params: { - quality: 90, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL without transforms", () => { - const parsed = parse("https://doc.cloudimg.io/sample.li/flat1.jpg"); - const expected: ParsedUrl = { - base: "https://doc.cloudimg.io/sample.li/flat1.jpg", - cdn: "cloudimage", - width: undefined, - height: undefined, - params: { - quality: undefined, - }, - }; - assertEquals(parsed, expected); - }); -}); - -Deno.test("Cloudimage transformer", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: "https://doc.cloudimg.io/sample.li/flat1.jpg?q=90", - width: 450, - height: 200, - }); - assertEquals( - result?.toString(), - "https://doc.cloudimg.io/sample.li/flat1.jpg?q=90&w=450&h=200", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ - url: "https://doc.cloudimg.io/sample.li/flat1.jpg?q=90", - width: 450, - }); - assertEquals( - result?.toString(), - "https://doc.cloudimg.io/sample.li/flat1.jpg?q=90&w=450", - ); - }); -}); diff --git a/src/transformers/cloudimage.ts b/src/transformers/cloudimage.ts deleted file mode 100644 index f3fb99d..0000000 --- a/src/transformers/cloudimage.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { getNumericParam, setParamIfDefined, toUrl } from "../utils.ts"; - -export interface CloudimageParams { - quality?: number; -} - -export const parse: UrlParser = (url) => { - const parsedUrl = toUrl(url); - - const width = getNumericParam(parsedUrl, "w") || undefined; - const height = getNumericParam(parsedUrl, "h") || undefined; - const quality = getNumericParam(parsedUrl, "q") || undefined; - parsedUrl.search = ""; - return { - width, - height, - base: parsedUrl.toString(), - params: { - quality, - }, - cdn: "cloudimage", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfDefined(url, "q", getNumericParam(url, "q"), true); - return url; -}; diff --git a/src/transformers/cloudinary.test.ts b/src/transformers/cloudinary.test.ts deleted file mode 100644 index fcfbd53..0000000 --- a/src/transformers/cloudinary.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { CloudinaryParams, parse, transform } from "./cloudinary.ts"; - -const img = - "https://res.cloudinary.com/demo/image/upload/c_lfill,w_800,h_550,f_auto/dog.webp"; - -const imgNoTransforms = "https://res.cloudinary.com/demo/image/upload/dog.jpg"; - -const imgFetchNoTransforms = - "https://res.cloudinary.com/demo/image/fetch/https://mydomain.com/images/logo.jpg"; - -const imgWithPath = - "https://res.cloudinary.com/demo/image/upload/b_rgb:FFFFFF,c_fill,dpr_2.0,f_auto,g_auto,h_600,q_auto,w_600/v1/Product%20gallery%20demo/New%20Demo%20Pages/Tshirt/tshirt1"; - -const imgSubdomain = - "https://private-name.cloudinary.com/demo/image/upload/c_lfill/dog"; - -const imgCustom = "https://media.codingcat.dev/demo/image/upload/c_lfill/dog"; - -Deno.test("cloudinary parser", async (t) => { - await t.step("parses a URL", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: "https://res.cloudinary.com/demo/image/upload/c_lfill/dog", - cdn: "cloudinary", - format: "webp", - width: 800, - height: 550, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "upload", - format: "webp", - host: "res.cloudinary.com", - id: "dog", - signature: undefined, - transformations: { - c: "lfill", - }, - version: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL without transforms", () => { - const parsed = parse(imgNoTransforms); - const expected: ParsedUrl = { - base: "https://res.cloudinary.com/demo/image/upload/dog", - cdn: "cloudinary", - format: "jpg", - width: undefined, - height: undefined, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "upload", - format: "jpg", - host: "res.cloudinary.com", - id: "dog", - signature: undefined, - transformations: {}, - version: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a fetch URL without transforms", () => { - const parsed = parse(imgFetchNoTransforms); - const expected: ParsedUrl = { - base: - "https://res.cloudinary.com/demo/image/fetch/https://mydomain.com/images/logo", - cdn: "cloudinary", - format: "jpg", - width: undefined, - height: undefined, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "fetch", - format: "jpg", - host: "res.cloudinary.com", - id: "https://mydomain.com/images/logo.jpg", - signature: undefined, - transformations: {}, - version: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with custom subdomain", () => { - const parsed = parse(imgSubdomain); - const expected: ParsedUrl = { - base: "https://private-name.cloudinary.com/demo/image/upload/c_lfill/dog", - cdn: "cloudinary", - format: undefined, - width: undefined, - height: undefined, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "upload", - format: undefined, - host: "private-name.cloudinary.com", - id: "dog", - signature: undefined, - transformations: { - c: "lfill", - }, - version: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with custom domain", () => { - const parsed = parse(imgCustom); - const expected: ParsedUrl = { - base: "https://media.codingcat.dev/demo/image/upload/c_lfill/dog", - cdn: "cloudinary", - format: undefined, - width: undefined, - height: undefined, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "upload", - format: undefined, - host: "media.codingcat.dev", - id: "dog", - signature: undefined, - transformations: { - c: "lfill", - }, - version: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with version and folder path", () => { - const parsed = parse(imgWithPath); - const expected: ParsedUrl = { - base: - "https://res.cloudinary.com/demo/image/upload/b_rgb:FFFFFF,c_fill,dpr_2.0,g_auto,q_auto/v1/Product%20gallery%20demo/New%20Demo%20Pages/Tshirt/tshirt1", - cdn: "cloudinary", - format: undefined, - width: 600, - height: 600, - params: { - assetType: "image", - cloudName: "demo", - deliveryType: "upload", - format: undefined, - host: "res.cloudinary.com", - id: "Product%20gallery%20demo/New%20Demo%20Pages/Tshirt/tshirt1", - signature: undefined, - transformations: { - b: "rgb:FFFFFF", - c: "fill", - dpr: "2.0", - g: "auto", - q: "auto", - }, - version: "v1", - }, - }; - assertEquals(parsed, expected); - }); -}); - -Deno.test("cloudinary transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://res.cloudinary.com/demo/image/upload/c_lfill,w_100,h_200,f_auto/dog", - ); - }); - - await t.step("rounds non-integer values", () => { - const result = transform({ - url: img, - width: 100.6, - height: 200.2, - }); - assertEquals( - result?.toString(), - "https://res.cloudinary.com/demo/image/upload/c_lfill,w_101,h_200,f_auto/dog", - ); - }); - - await t.step("transforms a URL without parsed transforms", () => { - const result = transform({ - url: imgNoTransforms, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://res.cloudinary.com/demo/image/upload/w_100,h_200,c_lfill,f_auto/dog", - ); - }); - - await t.step("transforms a fetch URL without parsed transforms", () => { - const result = transform({ - url: imgFetchNoTransforms, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://res.cloudinary.com/demo/image/fetch/w_100,h_200,c_lfill,f_auto/https://mydomain.com/images/logo.jpg", - ); - }); - - await t.step("transforms a URL with path and version", () => { - const result = transform({ - url: imgWithPath, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://res.cloudinary.com/demo/image/upload/b_rgb:FFFFFF,c_fill,dpr_2.0,g_auto,q_auto,w_100,h_200,f_auto/v1/Product%20gallery%20demo/New%20Demo%20Pages/Tshirt/tshirt1", - ); - }); -}); diff --git a/src/transformers/cloudinary.ts b/src/transformers/cloudinary.ts deleted file mode 100644 index c18118e..0000000 --- a/src/transformers/cloudinary.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { roundIfNumeric, toUrl } from "../utils.ts"; - -// Thanks Colby! -const cloudinaryRegex = - /https?:\/\/(?[^\/]+)\/(?[^\/]+)\/(?image|video|raw)\/(?upload|fetch|private|authenticated|sprite|facebook|twitter|youtube|vimeo)\/?(?s\-\-[a-zA-Z0-9]+\-\-)?\/?(?(?:[^_\/]+_[^,\/]+,?)*)?\/(?:(?v\d+)\/)?(?[^\s]+)$/g; - -const parseTransforms = (transformations: string) => { - return transformations - ? Object.fromEntries(transformations.split(",").map((t) => t.split("_"))) - : {}; -}; - -const formatUrl = ( - { - host, - cloudName, - assetType, - deliveryType, - signature, - transformations = {}, - version, - id, - format, - }: CloudinaryParams, -): string => { - if (format) { - transformations.f = format; - } - const transformString = Object.entries(transformations).map( - ([key, value]) => `${key}_${value}`, - ).join(","); - - const pathSegments = [ - host, - cloudName, - assetType, - deliveryType, - signature, - transformString, - version, - id, - ].filter(Boolean).join("/"); - return `https://${pathSegments}`; -}; - -export interface CloudinaryParams { - host?: string; - cloudName?: string; - assetType?: string; - deliveryType?: string; - signature?: string; - transformations: Record; - version?: string; - id?: string; - format?: string; -} -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const matches = [...url.toString().matchAll(cloudinaryRegex)]; - if (!matches.length) { - throw new Error("Invalid Cloudinary URL"); - } - - const group = matches[0].groups || {}; - const { - transformations: transformString = "", - idAndFormat, - ...baseParams - } = group; - delete group.idAndFormat; - const lastDotIndex = idAndFormat.lastIndexOf("."); - const id = lastDotIndex < 0 - ? idAndFormat - : idAndFormat.slice(0, lastDotIndex); - const originalFormat = lastDotIndex < 0 - ? undefined - : idAndFormat.slice(lastDotIndex + 1); - - const { w, h, f, ...transformations } = parseTransforms( - transformString, - ); - - const format = (f && f !== "auto") ? f : originalFormat; - - const base = formatUrl({ ...baseParams, id, transformations }); - return { - base, - width: Number(w) || undefined, - height: Number(h) || undefined, - format, - cdn: "cloudinary", - params: { - ...group, - id: group.deliveryType === "fetch" ? idAndFormat : id, - format, - transformations, - }, - }; -}; - -export const generate: UrlGenerator = ( - { base, width, height, format, params }, -) => { - const parsed = parse(base.toString()); - - const props: CloudinaryParams = { - transformations: {}, - ...parsed.params, - ...params, - format: format || "auto", - }; - if (width) { - props.transformations.w = roundIfNumeric(width).toString(); - } - if (height) { - props.transformations.h = roundIfNumeric(height).toString(); - } - - // Default crop to fill without upscaling - props.transformations.c ||= "lfill"; - return formatUrl(props); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format = "auto" }, -) => { - const parsed = parse(originalUrl); - if (!parsed) { - throw new Error("Invalid Cloudinary URL"); - } - - if (parsed.params?.assetType !== "image") { - throw new Error("Cloudinary transformer only supports images"); - } - - if (parsed.params?.signature) { - throw new Error( - "Cloudinary transformer does not support signed URLs", - ); - } - - const props: UrlGeneratorOptions = { - ...parsed, - width, - height, - format, - }; - - return generate(props); -}; diff --git a/src/transformers/contentful.test.ts b/src/transformers/contentful.test.ts deleted file mode 100644 index 1e88d29..0000000 --- a/src/transformers/contentful.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { transform } from "./contentful.ts"; - -const img = - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg"; - -Deno.test("contentful", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&h=100&fit=fill", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&fit=fill", - ); - }); - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("h", "100"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=200&fit=fill", - ); - }); - - await t.step("should round non-integer params", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=201&h=100&fit=fill", - ); - }); - - await t.step("should not set fit=fill if another value exists", () => { - const url = new URL(img); - url.searchParams.set("fit", "crop"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?fit=crop&w=200", - ); - }); - - await t.step("should bracket width if > 4000", () => { - const result = transform({ - url: img, - width: 5000, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&fit=fill", - ); - }); - - await t.step("should adjust height proportionally if width > 4000", () => { - const result = transform({ - url: img, - width: 5000, - height: 2000, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&h=1600&fit=fill", - ); - }); - - await t.step("should bracket height if > 4000", () => { - const result = transform({ - url: img, - height: 5000, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?h=4000&fit=fill", - ); - }); - - await t.step("should adjust width proportionally if height > 4000", () => { - const result = transform({ - url: img, - width: 2000, - height: 5000, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=1600&h=4000&fit=fill", - ); - }); - - await t.step("it should adjust width and height if both are > 4000", () => { - const result = transform({ - url: img, - width: 6000, - height: 4500, - }); - assertEquals( - result?.toString(), - "https://images.ctfassets.net/aaaa/xxxx/yyyy/how-to-wow-a-customer.jpg?w=4000&h=3000&fit=fill", - ); - }); -}); diff --git a/src/transformers/contentful.ts b/src/transformers/contentful.ts deleted file mode 100644 index 5c1b60d..0000000 --- a/src/transformers/contentful.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const parse: UrlParser<{ fit?: string }> = (url) => { - const parsedUrl = toUrl(url); - - const fit = parsedUrl.searchParams.get("fit") || undefined; - const width = getNumericParam(parsedUrl, "w"); - const height = getNumericParam(parsedUrl, "h"); - const quality = getNumericParam(parsedUrl, "q"); - const format = parsedUrl.searchParams.get("fm") || undefined; - parsedUrl.search = ""; - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { fit, quality }, - cdn: "contentful", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - if (width && width > 4000) { - if (height) { - height = Math.round(height * 4000 / width); - } - width = 4000; - } - - if (height && height > 4000) { - if (width) { - width = Math.round(width * 4000 / height); - } - height = 4000; - } - - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfDefined(url, "fm", format); - setParamIfUndefined(url, "fit", "fill"); - return url; -}; diff --git a/src/transformers/contentstack.test.ts b/src/transformers/contentstack.test.ts deleted file mode 100644 index 19371f0..0000000 --- a/src/transformers/contentstack.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { transform } from "./contentstack.ts"; - -const img = - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg"; - -Deno.test("contentstack", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?width=200&height=100&auto=webp&fit=crop", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?width=200&auto=webp", - ); - }); - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("height", "100"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?width=200&auto=webp", - ); - }); - - await t.step("should round non-integer params", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?width=201&height=100&auto=webp&fit=crop", - ); - }); - - await t.step("should not set fit=crop if another value exists", () => { - const url = new URL(img); - url.searchParams.set("fit", "fill"); - const result = transform({ url, width: 200, height: 100 }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?fit=fill&width=200&height=100&auto=webp", - ); - }); - - await t.step("should not set auto=webp if format is set", () => { - const url = new URL(img); - url.searchParams.set("format", "png"); - const result = transform({ url, width: 200, height: 100 }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?format=png&width=200&height=100&fit=crop", - ); - }); - - await t.step( - "should not set fit if width and height are not both set", - () => { - const url = new URL(img); - const result = transform({ url, width: 100 }); - assertEquals( - result?.toString(), - "https://images.contentstack.io/v3/assets/blteae40eb499811073/bltc5064f36b5855343/59e0c41ac0eddd140d5a8e3e/owl.jpg?width=100&auto=webp", - ); - }, - ); -}); diff --git a/src/transformers/contentstack.ts b/src/transformers/contentstack.ts deleted file mode 100644 index 0e0f1f3..0000000 --- a/src/transformers/contentstack.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const parse: UrlParser<{ fit?: string }> = (url) => { - const parsedUrl = toUrl(url); - - const fit = parsedUrl.searchParams.get("fit") || undefined; - const width = getNumericParam(parsedUrl, "width"); - const height = getNumericParam(parsedUrl, "height"); - const quality = getNumericParam(parsedUrl, "quality"); - const format = parsedUrl.searchParams.get("format") || undefined; - parsedUrl.search = ""; - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { fit, quality }, - cdn: "contentstack", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "width", width, true, true); - setParamIfDefined(url, "height", height, true, true); - setParamIfDefined(url, "format", format); - if (!url.searchParams.has("format")) { - setParamIfUndefined(url, "auto", "webp"); - } - if (width && height) { - setParamIfUndefined(url, "fit", "crop"); - } - return url; -}; diff --git a/src/transformers/directus.test.ts b/src/transformers/directus.test.ts deleted file mode 100644 index 7a6effd..0000000 --- a/src/transformers/directus.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { DirectusParams, parse, transform } from "./directus.ts"; -import { ParsedUrl } from "../types.ts"; - -const img = - "https://apollo.kazel.academy/assets/6d910d38-0659-49bf-80b8-fa6e0b257975"; -const imgNoTransforms = img; -const imgWithQuality = `${img}?quality=30`; -const imgOverrideEnlargement = `${img}?withoutEnlargement=true`; - -Deno.test("directus", async (t) => { - await t.step("should overwrite format", () => { - const result = transform({ - url: img, - width: 200, - height: 200, - format: "png", - cdn: "directus", - }); - assertEquals( - result?.toString(), - `${img}?width=200&height=200&format=png`, - ); - }); - - await t.step("should return quality", () => { - const result = transform({ - url: imgWithQuality, - width: 200, - height: 200, - format: "png", - cdn: "directus", - }); - assertEquals( - result?.toString(), - `${img}?quality=30&width=200&height=200&format=png`, - ); - }); - - await t.step( - "should not override and match any domain that continue with /assets subpath", - () => { - const result = transform({ - url: imgOverrideEnlargement, - width: 400, - height: 600, - }); - assertEquals( - result?.toString(), - `${img}?withoutEnlargement=true&width=400&height=600`, - ); - }, - ); - - await t.step("parses image with base transforms", () => { - const parsed = parse(imgNoTransforms); - const expected: ParsedUrl = { - base: imgNoTransforms, - cdn: "directus", - format: undefined, - width: 0, - height: 0, - params: { - fit: undefined, - quality: undefined, - withoutEnlargement: undefined, - transforms: undefined, - }, - }; - assertEquals(parsed, expected); - }); -}); diff --git a/src/transformers/directus.ts b/src/transformers/directus.ts deleted file mode 100644 index 82e23d1..0000000 --- a/src/transformers/directus.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { getNumericParam, setParamIfDefined, toUrl } from "../utils.ts"; - -type Fit = "cover" | "contain" | "inside" | "outside"; - -export interface DirectusParams { - fit?: "cover" | "contain" | "inside" | "outside"; - quality?: number; - withoutEnlargement?: boolean; - /** - * For even more advanced control over the file generation, Directus exposes the [full `sharp` API](https://sharp.pixelplumbing.com/api-operation). - * - * @example - * - * ?transforms=[["blur",45],["resize",{"width":200,"height":200}]] - */ - transforms?: Array< - Array> - >; -} - -export const parse: UrlParser = (imageUrl) => { - const parsedUrl = toUrl(imageUrl); - - const width = getNumericParam(parsedUrl, "width"); - const height = getNumericParam(parsedUrl, "height"); - const format = parsedUrl.searchParams.get("format") || undefined; - const quality = getNumericParam(parsedUrl, "quality") || undefined; - let fit: Fit | undefined = parsedUrl.searchParams.get("fit") as Fit || - undefined; - const withoutEnlargement = - parsedUrl.searchParams.get("withoutEnlargement") === "true" || undefined; - const transforms = parsedUrl.searchParams.get("transforms") || undefined; - - // if fit doesn't satisfy the type, it will be undefined - if (fit && !["cover", "contain", "inside", "outside"].includes(fit)) { - fit = undefined; - } - - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { - fit, - quality, - withoutEnlargement, - transforms: transforms ? JSON.parse(transforms) : undefined, - }, - cdn: "directus", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "width", width, true, true); - setParamIfDefined(url, "height", height, true, true); - setParamIfDefined(url, "format", format); - setParamIfDefined(url, "quality", getNumericParam(url, "quality"), true); - return url; -}; diff --git a/src/transformers/hygraph.test.ts b/src/transformers/hygraph.test.ts deleted file mode 100644 index 583d510..0000000 --- a/src/transformers/hygraph.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { HygraphParams, parse, transform } from "./hygraph.ts"; -import { ParsedUrl } from "../types.ts"; - -const imageBase = - "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/cm2tr64fx7gvu07n85chjmuno"; - -const imageWithAutoFormat = - "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/resize=fit:crop,width:400,height:400/auto_image/cm2tr64fx7gvu07n85chjmuno"; - -const imageWithExplicitFormat = - "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/resize=fit:crop,width:400,height:400/output=format:jpg/cm2tr64fx7gvu07n85chjmuno"; - -Deno.test("hygraph", async (t) => { - await t.step("parses a URL with auto format", () => { - const parsed = parse(imageWithAutoFormat); - const expected: ParsedUrl = { - base: imageWithAutoFormat, - cdn: "hygraph", - format: "auto", - width: 400, - height: 400, - params: { - transformations: { - resize: { - width: 400, - height: 400, - fit: "crop", - }, - auto_image: {}, - }, - region: "us-west-2", - envId: "cm2apl1zp07l506n66dmd9xo8", - handle: "cm2tr64fx7gvu07n85chjmuno", - }, - }; - - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with explicit format", () => { - const parsed = parse(imageWithExplicitFormat); - const expected: ParsedUrl = { - base: imageWithExplicitFormat, - cdn: "hygraph", - format: "jpg", - width: 400, - height: 400, - params: { - transformations: { - resize: { - width: 400, - height: 400, - fit: "crop", - }, - output: { - format: "jpg", - }, - }, - region: "us-west-2", - envId: "cm2apl1zp07l506n66dmd9xo8", - handle: "cm2tr64fx7gvu07n85chjmuno", - }, - }; - - assertEquals(parsed, expected); - }); - - await t.step("transforms a URL with auto format", () => { - const result = transform({ - url: imageBase, - width: 400, - height: 400, - }); - - assertEquals(result?.toString(), imageWithAutoFormat); - }); - - await t.step("transforms a URL with explicit format", () => { - const result = transform({ - url: imageBase, - width: 400, - height: 400, - format: "jpg", - }); - - assertEquals(result?.toString(), imageWithExplicitFormat); - }); -}); diff --git a/src/transformers/hygraph.ts b/src/transformers/hygraph.ts deleted file mode 100644 index 323b70b..0000000 --- a/src/transformers/hygraph.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; - -const hygraphRegex = - /https:\/\/(?[a-z0-9-]+)\.graphassets\.com\/(?[a-zA-Z0-9]+)(?:\/(?.*?))?\/(?[a-zA-Z0-9]+)$/; - -export interface HygraphParams { - region?: string; - envId?: string; - transformations: Record>; - handle?: string; -} - -export const parse: UrlParser = (url) => { - const base = url.toString(); - const matches = base.match(hygraphRegex); - - if (!matches?.length) { - throw new Error("Invalid Hygraph URL"); - } - - const group = matches.groups || {}; - const { transformations: unparsedTransformations, ...params } = group; - const transformations = parseTransformations(unparsedTransformations || ""); - - return { - base, - width: Number(transformations.resize?.width) || undefined, - height: Number(transformations.resize?.height) || undefined, - format: transformations.auto_image - ? "auto" - : transformations.output?.format?.toString() || undefined, - params: { transformations, ...params }, - cdn: "hygraph", - }; -}; - -export const generate: UrlGenerator = ( - { base, width, height, format, params }, -) => { - const parsed = parse(base.toString()); - const props: HygraphParams = { - transformations: {}, - ...parsed.params, - ...params, - }; - - if (width || height) { - props.transformations.resize ||= {}; - } - - if (width && height) { - props.transformations.resize.fit ||= "crop"; - } - - if (width) { - props.transformations.resize.width = width; - } - - if (height) { - props.transformations.resize.height = height; - } - - if (format === "auto") { - props.transformations.auto_image = {}; - } else if (format) { - props.transformations.output ||= {}; - props.transformations.output.format = format; - } - - const url = new URL(base); - url.pathname = `/${props.envId}/${ - formatTransformations(props.transformations) - }/${props.handle}`; - return url.toString(); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format = "auto" }, -) => { - const parsed = parse(originalUrl); - - if (!parsed) { - throw new Error("Invalid Hygraph URL"); - } - - const props: UrlGeneratorOptions = { - ...parsed, - width, - height, - format, - }; - - return generate(props); -}; - -const parseTransformations = ( - transformations: string, -): Record> => { - if (!transformations) { - return {}; - } - - return transformations.split("/").reduce( - (result: Record>, part) => { - const [operation, params] = part.split("="); - - if (params) { - result[operation] = params.split(",").reduce( - (obj: Record, param) => { - const [key, value] = param.split(":"); - obj[key] = isNaN(Number(value)) ? value : Number(value); - return obj; - }, - {}, - ); - } else { - result[operation] = {}; - } - - return result; - }, - {}, - ); -}; - -const formatTransformations = ( - transformations: Record>, -): string => { - return Object.entries(transformations) - .filter(([key, value]) => Boolean(key) && value !== undefined) - .map(([key, value]) => - Object.keys(value).length === 0 ? key : `${key}=${ - Object.entries(value) - .map(([key, value]) => `${key}:${value}`) - .join(",") - }` - ) - .join("/"); -}; diff --git a/src/transformers/imageengine.test.ts b/src/transformers/imageengine.test.ts deleted file mode 100644 index 1b731b4..0000000 --- a/src/transformers/imageengine.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { ImageEngineParams, parse, transform } from "./imageengine.ts"; - -const img = "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg"; -const parseImg = - "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg?imgeng=/w_200/h_100/f_webp/m_box"; -const transformImage = - "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg?imgeng=/m_outside/f_png"; - -Deno.test("ImageEngine parser", async (t) => { - await t.step("parses a URL", () => { - const parsed = parse(parseImg); - const expected: ParsedUrl = { - base: "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg", - cdn: "imageengine", - format: "webp", - width: 200, - height: 100, - params: { - fit: "box", - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL without transforms", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg", - cdn: "imageengine", - format: undefined, - width: undefined, - height: undefined, - params: {}, - }; - assertEquals(parsed, expected); - }); -}); - -Deno.test("ImageEngine transformer", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - format: "webp", - }); - assertEquals( - result?.toString(), - "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg?imgeng=/w_200/h_100/f_webp/m_cropbox", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200, format: "jpg" }); - assertEquals( - result?.toString(), - "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg?imgeng=/w_200/f_jpg/m_cropbox", - ); - }); - await t.step("should not set fit=cropbox if another value exists", () => { - const url = new URL(transformImage); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://blazing-fast-pics.cdn.imgeng.in/images/pic_1.jpg?imgeng=/m_outside/f_png/w_200", - ); - }); -}); diff --git a/src/transformers/imageengine.ts b/src/transformers/imageengine.ts deleted file mode 100644 index 6950d3d..0000000 --- a/src/transformers/imageengine.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { toUrl } from "../utils.ts"; - -export interface ImageEngineParams { - host?: number; - width?: number; - height?: number; - autoWidthWithFallback?: number; - auto_width_fallback?: number; - scaleToScreenWidth?: number; - scale_to_screen_width?: number; - crop?: number; - outputFormat?: string; - format?: string; - fitMethod?: string; - fit?: string; - compression?: number; - sharpness?: number; - rotate?: number; - keepMeta?: boolean; - keep_meta?: boolean; - noOptimization?: boolean; - no_optimization?: boolean; - force_download?: boolean; - max_device_pixel_ratio?: number; - maxDevicePixelRatio?: number; -} - -export const OBJECT_TO_DIRECTIVES_MAP: { [key: string]: string } = { - width: "w", - height: "h", - autoWidthWithFallback: "w_auto", - auto_width_fallback: "w_auto", - scaleToScreenWidth: "pc", - scale_to_screen_width: "pc", - crop: "cr", - outputFormat: "f", - format: "f", - fit: "m", - fitMethod: "m", - compression: "cmpr", - sharpness: "s", - rotate: "r", - inline: "in", - keepMeta: "meta", - keep_meta: "meta", - noOptimization: "pass", - no_optimization: "pass", - force_download: "dl", - max_device_pixel_ratio: "maxdpr", - maxDevicePixelRatio: "maxdpr", -}; - -export const parse: UrlParser = ( - imageUrl, -) => { - const parsedUrl = toUrl(imageUrl); - const paramArray = getParameterArray(parsedUrl); - const baseUrl = getBaseUrl(parsedUrl); - let width = undefined, height = undefined, format = undefined; - const params: Record = {}; - if (paramArray.length > 0) { - paramArray.forEach((para: string) => { - let key_value = para.split("_"); - if (key_value.length > 1) { - switch (key_value[0]) { - case "w": - width = Number(key_value[1]); - break; - case "h": - height = Number(key_value[1]); - break; - case "f": - format = key_value[1]; - break; - default: - if ( - Object.values(OBJECT_TO_DIRECTIVES_MAP).includes(key_value[0]) - ) { - let directive: string = getDirective(key_value[0]); - params[directive] = key_value[1]; - } - } - } - }); - } - return { - base: baseUrl, - width, - height, - format, - params, - cdn: "imageengine", - }; -}; - -export function getDirective(key: string): string { - let keyArray = Object.keys(OBJECT_TO_DIRECTIVES_MAP); - let directive = keyArray.find((k) => OBJECT_TO_DIRECTIVES_MAP[k] === key) || - ""; - return directive; -} - -export function getParameterArray(url: URL) { - let url_string = url.toString(); - let paramArray: any = []; - if (url_string) { - let splitURL: string[] = url_string.split("imgeng="); - if (splitURL.length > 1) { - paramArray = splitURL[1].split("/"); - } - } - return paramArray; -} - -export function getBaseUrl(url: URL) { - let url_string = url.toString(); - let baseUrl: string = ""; - if (url_string) { - let splitURL: string[] = url_string.split("imgeng="); - if (splitURL.length > 1) { - baseUrl = splitURL[0].slice(0, -1); - } else { - baseUrl = url_string; - } - } - return baseUrl; -} - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - const src = getBaseUrl(url); - let directives: Record = {}; - const param: [] = url.toString() === src ? [] : getParameterArray(url); - if (param.length) { - directives = getDirectives(param); - } - if (width) { - directives["width"] = width; - } - if (height) { - directives["height"] = height; - } - if (format) { - directives["format"] = format; - } - if (!directives.hasOwnProperty("fit")) { - directives = { ...directives, "fit": "cropbox" }; - } - let directives_string = build_IE_directives(directives); - let query_string = build_IE_query_string(directives_string); - let query_prefix = query_string === "" ? "" : (src.includes("?") ? "&" : "?"); - return `${src}${query_prefix}${query_string}`; -}; - -export function build_IE_directives(directives: any): string { - return Object.entries(directives).reduce((acc, [k, v]) => { - return acc + maybe_create_directive(k, v); - }, ""); -} - -export function build_IE_query_string(directives_string: string): string { - if (directives_string && directives_string !== "") { - return `imgeng=${directives_string}`; - } - return ""; -} - -export function maybe_create_directive(directive: string, value: any): string { - let translated_directive = OBJECT_TO_DIRECTIVES_MAP[directive]; - - if (translated_directive && (value || value === 0)) { - return `/${translated_directive}_${value}`; - } - return ""; -} - -export function getDirectives(paramArray: []): {} { - let directives: Record = {}; - paramArray.forEach((para: string) => { - let keyValue = para.split("_"); - if (keyValue.length > 1) { - let key = keyValue[0]; - let value = keyValue[1]; - let directiveKey = getDirective(key); - if (directiveKey) { - directives[directiveKey] = value; - } - } - }); - return directives; -} diff --git a/src/transformers/imagekit.test.ts b/src/transformers/imagekit.test.ts deleted file mode 100644 index f4a795e..0000000 --- a/src/transformers/imagekit.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { parse, transform } from "./imagekit.ts"; - -const img = - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-500,h-300,f-png,q-80"; - -Deno.test("imagekit", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-200%2Ch-100%2Cf-png%2Cq-80", - ); - }); - - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-200%2Cf-png%2Cq-80", - ); - }); - - await t.step("should delete height if not set", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-200%2Cf-png%2Cq-80", - ); - }); - - await t.step("should round non-integer dimensions", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-201%2Ch-100%2Cf-png%2Cq-80", - ); - }); - - await t.step("should set f-auto if no format is provided", () => { - const imgWithoutFormat = - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-500,h-300,q-80"; - - const result = transform({ url: imgWithoutFormat, width: 200 }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-200%2Cq-80%2Cf-auto", - ); - }); - - await t.step( - "should not set f-auto if format is provided and use provided format", - () => { - const result = transform({ url: img, width: 200, format: "jpg" }); - assertEquals( - result?.toString(), - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png?tr=w-200%2Cf-jpg%2Cq-80", - ); - }, - ); - - await t.step("should parse url", () => { - const result = parse(img); - - assertEquals( - result.base, - "https://ik.imagekit.io/subman/v2/asset-3d/icon-standard.png", - ); - assertEquals(result.width, 500); - assertEquals(result.height, 300); - assertEquals(result.format, "png"); - }); -}); diff --git a/src/transformers/imagekit.ts b/src/transformers/imagekit.ts deleted file mode 100644 index 1b7431c..0000000 --- a/src/transformers/imagekit.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { toUrl } from "../utils.ts"; - -const getTransformParams = (url: URL) => { - const transforms = url.searchParams.get("tr") || ""; - - return transforms.split(",").reduce((acc: any, transform: any) => { - const [key, value] = transform.split("-"); - acc[key] = value; - return acc; - }, {}); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - const transformParams = getTransformParams(url); - - transformParams.w = width ? Math.round(width) : width; - transformParams.h = height ? Math.round(height) : height; - - if (!transformParams.f) { - transformParams.f = "auto"; - } - - if (format) { - transformParams.f = format; - } - - const tr = Object.keys(transformParams).map((key) => { - const value = transformParams[key]; - - if (value) { - return `${key}-${value}`; - } - }) - .filter((x) => x) - .join(","); - - url.searchParams.set("tr", tr); - - return url; -}; - -export const parse: UrlParser = ( - url, -) => { - const parsed = toUrl(url); - const transformParams = getTransformParams(parsed); - - const width = Number(transformParams.w) || undefined; - const height = Number(transformParams.h) || undefined; - const format = transformParams.f || undefined; - - parsed.search = ""; - - return { - base: parsed.toString(), - width, - height, - format, - params: transformParams, - cdn: "imagekit", - }; -}; diff --git a/src/transformers/imgix.test.ts b/src/transformers/imgix.test.ts deleted file mode 100644 index f4133ce..0000000 --- a/src/transformers/imgix.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { transform } from "./imgix.ts"; - -const img = - "https://images.unsplash.com/photo?auto=format&fit=crop&w=2089&q=80"; - -Deno.test("imgix", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100", - ); - }); - - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80", - ); - }); - - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("h", "100"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80", - ); - }); - - await t.step("should round non-integer dimensions", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=201&q=80&h=100", - ); - }); - - await t.step("should set auto=format if no format is provided", () => { - const url = new URL(img); - url.searchParams.delete("auto"); - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80", - ); - }); - - await t.step("should not set auto=format if format is provided", () => { - const url = new URL(img); - url.searchParams.delete("auto"); - const result = transform({ url, width: 200, format: "jpg" }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?fit=crop&w=200&q=80&fm=jpg", - ); - }); - - await t.step("should delete auto=format if format is provided", () => { - const result = transform({ url: img, width: 200, format: "jpg" }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?fit=crop&w=200&q=80&fm=jpg", - ); - }); - - await t.step( - "should remove format from existing auto value if format is provided", - () => { - const url = new URL(img); - url.searchParams.set("auto", "compress,format"); - const result = transform({ url, width: 200, format: "jpg" }); - assertEquals( - result?.toString(), - "https://images.unsplash.com/photo?auto=compress&fit=crop&w=200&q=80&fm=jpg", - ); - }, - ); -}); diff --git a/src/transformers/imgix.ts b/src/transformers/imgix.ts deleted file mode 100644 index 53aff0b..0000000 --- a/src/transformers/imgix.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { setParamIfDefined, setParamIfUndefined, toUrl } from "../utils.ts"; - -export const parse: UrlParser = ( - url, -) => { - const parsed = toUrl(url); - const width = Number(parsed.searchParams.get("w")) || undefined; - const height = Number(parsed.searchParams.get("h")) || undefined; - const quality = Number(parsed.searchParams.get("q")) || undefined; - const format = parsed.searchParams.get("fm") || undefined; - const params: Record = {}; - parsed.searchParams.forEach((value, key) => { - params[key] = value; - }); - parsed.search = ""; - return { - base: parsed.toString(), - width, - height, - quality, - format, - params, - cdn: "imgix", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfUndefined(url, "fit", "min"); - - if (format) { - url.searchParams.set("fm", format); - const fm = url.searchParams.get("auto"); - if (fm === "format") { - url.searchParams.delete("auto"); - } else if (fm?.includes("format")) { - url.searchParams.set( - "auto", - fm.split(",").filter((s) => s !== "format").join(","), - ); - } - } else { - url.searchParams.delete("fm"); - if (!url.searchParams.get("auto")?.includes("format")) { - url.searchParams.append("auto", "format"); - } - } - return url; -}; diff --git a/src/transformers/ipx.test.ts b/src/transformers/ipx.test.ts deleted file mode 100644 index d20efff..0000000 --- a/src/transformers/ipx.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { parse, transform } from "./ipx.ts"; - -const img = - "https://example.com/_ipx/embed,f_webp,s_200x300/static/buffalo.png"; - -const remoteImage = "https://example.org/static/moose.png"; - -const cdnOptions = { - ipx: { - base: "https://example.com/_ipx", - }, -}; - -Deno.test("ipx", async (t) => { - await t.step("should parse a URL", () => { - const result = parse(img.replace("https://example.com/_ipx/", "")); - assertEquals( - result.width, - 200, - ); - assertEquals( - result.height, - 300, - ); - assertEquals( - result.format, - "webp", - ); - assertEquals( - result.params, - { s: "200x300", f: "webp", embed: undefined, w: "200", h: "300" }, - ); - assertEquals( - result.base, - "static/buffalo.png", - ); - }); - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_200x100,f_webp/static/buffalo.png", - ); - }); - - await t.step("should format a remote URL", () => { - const result = transform({ - url: - "https://example.com/_ipx/embed,f_webp,s_200x300/https://example.org/static/buffalo.png", - width: 200, - height: 100, - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_200x100,f_webp/https://example.org/static/buffalo.png", - ); - }); - - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 100, cdnOptions }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_100x300,f_webp/static/buffalo.png", - ); - }); - - await t.step("should not set width if not provided", () => { - const result = transform({ url: img, height: 100, cdnOptions }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_200x100,f_webp/static/buffalo.png", - ); - }); - - await t.step("should set s if w and height are provided", () => { - const result = transform({ - url: "https://example.com/_ipx/embed,f_webp,w_100/static/buffalo.png", - height: 200, - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_100x200,f_webp/static/buffalo.png", - ); - }); - - await t.step("should set s if width and h are provided", () => { - const result = transform({ - url: "https://example.com/_ipx/embed,f_webp,h_100/static/buffalo.png", - width: 200, - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_200x100,f_webp/static/buffalo.png", - ); - }); - - await t.step("should transform a remote image", () => { - const result = transform({ - url: remoteImage, - width: 100, - height: 200, - format: "webp", - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_100x200,f_webp/https://example.org/static/moose.png", - ); - }); - - await t.step("should transform a local image", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - cdnOptions, - }); - assertEquals( - result?.toString(), - "https://example.com/_ipx/s_100x200,f_webp/static/moose.png", - ); - }); - - await t.step("should transform a local image with a default base", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - }); - assertEquals( - result?.toString(), - "/_ipx/s_100x200,f_webp/static/moose.png", - ); - }); - - await t.step("should transform a remote image with a relative base", () => { - const result = transform({ - url: remoteImage, - width: 100, - height: 200, - format: "webp", - cdnOptions: { - ipx: { - base: "/_images", - }, - }, - }); - assertEquals( - result?.toString(), - "/_images/s_100x200,f_webp/https://example.org/static/moose.png", - ); - }); - - await t.step("should transform a local image with a relative base", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - cdnOptions: { - ipx: { - base: "/_images", - }, - }, - }); - assertEquals( - result?.toString(), - "/_images/s_100x200,f_webp/static/moose.png", - ); - }); - - await t.step("should transform a local image with an empty base", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - cdnOptions: { - ipx: { - base: "", - }, - }, - }); - assertEquals( - result?.toString(), - "/s_100x200,f_webp/static/moose.png", - ); - }); - - await t.step("should transform a local image with a '/' base", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - cdnOptions: { - ipx: { - base: "/", - }, - }, - }); - assertEquals( - result?.toString(), - "/s_100x200,f_webp/static/moose.png", - ); - }); -}); diff --git a/src/transformers/ipx.ts b/src/transformers/ipx.ts deleted file mode 100644 index 00b14e6..0000000 --- a/src/transformers/ipx.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { UrlGenerator, UrlParser, UrlTransformer } from "../types.ts"; -import { toUrl } from "../utils.ts"; - -/** - * Parses the CDN/server's native URL format - */ -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const [modifiers, ...id] = url.pathname.split("/").slice(1); - const params = Object.fromEntries( - modifiers.split(",").map((modifier) => { - const [key, value] = modifier.split("_"); - return [key, value]; - }), - ); - if (params.s) { - const [width, height] = params.s.split("x"); - params.w ||= width; - params.h ||= height; - } - return { - base: id.join("/"), - width: Number(params.w) || undefined, - height: Number(params.h) || undefined, - quality: Number(params.q) || undefined, - format: params.f || "auto", - params, - cdn: "ipx", - }; -}; - -export interface IpxParams { - base: string; - modifiers?: Record; -} -export const generate: UrlGenerator = ( - { base: id, width, height, format, params }, -) => { - const modifiers = params?.modifiers ?? {}; - if (width && height) { - modifiers.s = `${width}x${height}`; - } else if (width) { - modifiers.w = `${width}`; - } else if (height) { - modifiers.h = `${height}`; - } - if (format) { - modifiers.f = format; - } - - const base = params?.base.endsWith("/") ? params?.base : `${params?.base}/`; - - const modifiersString = Object.entries(modifiers).map( - ([key, value]) => `${key}_${value}`, - ).join(","); - - const stringId = id.toString(); - const image = stringId.startsWith("/") ? stringId.slice(1) : stringId; - - return `${base}${modifiersString}/${image}`; -}; - -export const transform: UrlTransformer = ( - options, -) => { - const url = String(options.url); - const parsedUrl = toUrl(url); - - const defaultBase = - (parsedUrl.pathname.startsWith("/_ipx") && parsedUrl.hostname !== "n") - ? `${parsedUrl.origin}/_ipx` - : "/_ipx"; - const base = (options.cdnOptions?.ipx?.base as string) ?? defaultBase; - const isIpxUrl = base && base !== "/" && url.startsWith(base); - if (isIpxUrl) { - const parsed = parse(url.replace(base, "")); - - return generate({ - ...parsed, - ...options, - params: { - ...options.cdnOptions?.ipx, - base, - }, - }); - } - - return generate({ - ...options, - base: url, - params: { - ...options.cdnOptions?.ipx, - base, - }, - }); -}; diff --git a/src/transformers/keycdn.test.ts b/src/transformers/keycdn.test.ts deleted file mode 100644 index 1e18b63..0000000 --- a/src/transformers/keycdn.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { KeyCDNParams, parse, transform } from "./keycdn.ts"; -import { ParsedUrl } from "../types.ts"; -import { getImageCdnForUrl } from "../detect.ts"; - -const img = "https://ip.keycdn.com/example.jpg"; -const imgNoTransforms = "https://ip.keycdn.com/example.jpg"; -const imgWithHeightWidthFormat = - "https://ip.keycdn.com/example.jpg?width=500&height=700&format=png"; -const imgWithQuality = "https://ip.keycdn.com/example.jpg?quality=30"; -const imgOverrideEnlarge = "https://abc.kxcdn.com/example.jpg?enlarge=1"; - -Deno.test("keycdn", async (t) => { - await t.step("should overwrite format", () => { - const result = transform({ - url: imgWithHeightWidthFormat, - width: 200, - height: 200, - cdn: "keycdn", - }); - assertEquals( - result?.toString(), - "https://ip.keycdn.com/example.jpg?width=200&height=200&enlarge=0", - ); - }); - - await t.step("should add format", () => { - const result = transform({ - url: img, - width: 200, - height: 200, - format: "png", - cdn: "keycdn", - }); - assertEquals( - result?.toString(), - "https://ip.keycdn.com/example.jpg?width=200&height=200&format=png&enlarge=0", - ); - }); - - await t.step( - "should not override and match keycdn for kxcdn domain", - () => { - const result = transform({ - url: imgOverrideEnlarge, - width: 400, - height: 600, - }); - assertEquals( - result?.toString(), - "https://abc.kxcdn.com/example.jpg?enlarge=1&width=400&height=600", - ); - }, - ); - - await t.step("parses image with base transforms", () => { - const parsed = parse(imgNoTransforms); - const expected: ParsedUrl = { - base: imgNoTransforms, - cdn: "keycdn", - format: undefined, - width: 0, - height: 0, - params: { - quality: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses image with transforms", () => { - const parsed = parse(imgWithHeightWidthFormat); - const expected: ParsedUrl = { - base: imgWithHeightWidthFormat, - cdn: "keycdn", - format: "png", - width: 500, - height: 700, - params: { - quality: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses image with transforms", () => { - const parsed = parse(imgWithQuality); - const expected: ParsedUrl = { - base: imgWithQuality, - cdn: "keycdn", - format: undefined, - width: 0, - height: 0, - params: { - quality: 30, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("url detection example image", () => { - const detected = getImageCdnForUrl("https://ip.keycdn.com/example.jpg"); - const expected = "keycdn"; - assertEquals(detected, expected); - }); - - await t.step("url detection kxcdn", () => { - const detected = getImageCdnForUrl( - "https://dodeka-1e294.kxcdn.com/nieuws-3d7ac29c.jpg", - ); - const expected = "keycdn"; - assertEquals(detected, expected); - }); -}); diff --git a/src/transformers/keycdn.ts b/src/transformers/keycdn.ts deleted file mode 100644 index 836f741..0000000 --- a/src/transformers/keycdn.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export interface KeyCDNParams { - quality?: number; -} - -export const parse: UrlParser = (url) => { - const parsedUrl = toUrl(url); - - const width = getNumericParam(parsedUrl, "width"); - const height = getNumericParam(parsedUrl, "height"); - const format = parsedUrl.searchParams.get("format") || undefined; - const quality = getNumericParam(parsedUrl, "quality") || undefined; - - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { quality }, - cdn: "keycdn", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "width", width, true, true); - setParamIfDefined(url, "height", height, true, true); - setParamIfDefined(url, "format", format, true); - setParamIfDefined(url, "quality", getNumericParam(url, "quality"), true); - setParamIfUndefined(url, "enlarge", 0); - return url; -}; diff --git a/src/transformers/kontent.ai.test.ts b/src/transformers/kontent.ai.test.ts deleted file mode 100644 index f1d0f1d..0000000 --- a/src/transformers/kontent.ai.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; - -import { transform } from "./kontent.ai.ts"; - -const img = - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg"; - -Deno.test("kontent.ai", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - format: "webp", - }); - assertEquals( - result?.toString(), - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=200&h=100&fm=webp&fit=crop", - ); - }); - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=200", - ); - }); - - await t.step("should round non-integer params", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=201&h=100&fit=crop", - ); - }); - - await t.step( - "should add fit=scale when height or width (or both) provided and no other fit setting", - () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?w=200&h=100&fit=crop", - ); - }, - ); - await t.step("should not set fit=scale if another value exists", () => { - const url = new URL(img); - url.searchParams.set("fit", "scale"); - const result = transform({ - url: url, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://assets-us-01.kc-usercontent.com/b744f382-bfc7-434d-93e7-a65d51249bc7/cc0afdc7-23d7-4fde-be2c-f58ad54d2934/daylight.jpg?fit=scale&w=200&h=100", - ); - }); -}); diff --git a/src/transformers/kontent.ai.ts b/src/transformers/kontent.ai.ts deleted file mode 100644 index 2277076..0000000 --- a/src/transformers/kontent.ai.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const parse: UrlParser<{ fit?: string }> = (url) => { - const parsedUrl = toUrl(url); - - const fit = parsedUrl.searchParams.get("fit") || undefined; - const width = getNumericParam(parsedUrl, "w"); - const height = getNumericParam(parsedUrl, "h"); - const quality = getNumericParam(parsedUrl, "q"); - const format = parsedUrl.searchParams.get("fm") || undefined; - - parsedUrl.search = ""; - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { fit, quality }, - cdn: "kontent.ai", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfDefined(url, "fm", format, true); - if (width && height) { - setParamIfUndefined(url, "fit", "crop"); - } - return url; -}; diff --git a/src/transformers/netlify.test.ts b/src/transformers/netlify.test.ts deleted file mode 100644 index 49fe4ff..0000000 --- a/src/transformers/netlify.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { parse, transform } from "./netlify.ts"; - -const img = "https://example.netlify.app/.netlify/images?url=/cappadocia.jpg"; - -const remoteImage = "https://example.org/static/moose.png"; - -Deno.test("netlify", async (t) => { - await t.step("should parse a URL", () => { - const result = parse( - "https://example.netlify.app/.netlify/images?url=/cappadocia.jpg&w=200&h=300&fit=cover&q=80&fm=webp", - ); - assertEquals( - result.width, - 200, - ); - assertEquals( - result.height, - 300, - ); - assertEquals( - result.format, - "webp", - ); - assertEquals( - result.base, - "/cappadocia.jpg", - ); - }); - await t.step("should format a URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://example.netlify.app/.netlify/images?w=200&h=100&fit=cover&url=%2Fcappadocia.jpg", - ); - }); - - await t.step("should format a remote URL", () => { - const result = transform({ - url: - "/.netlify/images?w=800&h=600&&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/.netlify/images?w=200&h=100&fit=cover&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - ); - }); - - await t.step("should not set height if not provided", () => { - const result = transform({ url: img, width: 200 }); - assertEquals( - result?.toString(), - "https://example.netlify.app/.netlify/images?w=200&fit=cover&url=%2Fcappadocia.jpg", - ); - }); - - await t.step("should delete height if not set", () => { - const url = new URL(img); - url.searchParams.set("h", "100"); - const result = transform({ url, width: 200 }); - assertEquals( - result?.toString(), - "https://example.netlify.app/.netlify/images?w=200&fit=cover&url=%2Fcappadocia.jpg", - ); - }); - - await t.step("should round non-integer dimensions", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://example.netlify.app/.netlify/images?w=201&h=100&fit=cover&url=%2Fcappadocia.jpg", - ); - }); - - await t.step("should transform a remote image", () => { - const result = transform({ - url: remoteImage, - width: 100, - height: 200, - format: "webp", - }); - assertEquals( - result?.toString(), - "/.netlify/images?w=100&h=200&fm=webp&fit=cover&url=https%3A%2F%2Fexample.org%2Fstatic%2Fmoose.png", - ); - }); - - await t.step("should transform a local image", () => { - const result = transform({ - url: "/static/moose.png", - width: 100, - height: 200, - format: "webp", - }); - assertEquals( - result?.toString(), - "/.netlify/images?w=100&h=200&fm=webp&fit=cover&url=%2Fstatic%2Fmoose.png", - ); - }); - - await t.step("should transform a remote image with a relative base", () => { - const result = transform({ - url: remoteImage, - width: 100, - height: 200, - format: "webp", - cdnOptions: { - netlify: { - site: "https://petsofnetlify.com", - }, - }, - }); - assertEquals( - result?.toString(), - "https://petsofnetlify.com/.netlify/images?w=100&h=200&fm=webp&fit=cover&url=https%3A%2F%2Fexample.org%2Fstatic%2Fmoose.png", - ); - }); - - await t.step("should rename aliased params", () => { - const result = transform({ - url: - "/.netlify/images?width=800&h=600&quality=10&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/.netlify/images?q=10&w=200&h=100&fit=cover&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - ); - }); - - await t.step("should preserve other params", () => { - const result = transform({ - url: - "/.netlify/images?width=800&h=600&quality=10&fit=scale&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/.netlify/images?fit=scale&q=10&w=200&h=100&url=https%3A%2F%2Fexample.org%2Fstatic%2Fbuffalo.png", - ); - }); -}); diff --git a/src/transformers/netlify.ts b/src/transformers/netlify.ts deleted file mode 100644 index b743011..0000000 --- a/src/transformers/netlify.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { UrlGenerator, UrlParser, UrlTransformer } from "../types.ts"; -import { - setParamIfDefined, - setParamIfUndefined, - toCanonicalUrlString, - toUrl, -} from "../utils.ts"; - -const skippedParams = new Set([ - "w", - "h", - "q", - "fm", - "url", - "width", - "height", - "quality", -]); -export const parse: UrlParser = ( - url, -) => { - const parsed = toUrl(url); - const width = - Number(parsed.searchParams.get("w") ?? parsed.searchParams.get("width")) ?? - undefined; - const height = - Number(parsed.searchParams.get("h") ?? parsed.searchParams.get("height")) ?? - undefined; - const quality = Number( - parsed.searchParams.get("q") ?? parsed.searchParams.get("quality"), - ) || undefined; - const format = parsed.searchParams.get("fm") || undefined; - const base = parsed.searchParams.get("url") || ""; - const params: Record = { - quality, - }; - parsed.searchParams.forEach((value, key) => { - if (skippedParams.has(key)) { - return; - } - params[key] = value; - }); - parsed.search = ""; - return { - base, - width, - height, - format, - params, - cdn: "netlify", - }; -}; - -export interface NetlifyParams { - /** If set, use this site as the base for absolute image URLs. Otherwise, generate relative URLs */ - site?: string; - fit?: string; - quality?: number; -} -export const generate: UrlGenerator = ( - { base, width, height, format, params: { site, quality, ...params } = {} }, -) => { - const url = toUrl("/.netlify/images", site); - Object.entries(params).forEach(([key, value]) => - setParamIfDefined(url, key, value) - ); - setParamIfDefined(url, "q", quality, true, true); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfDefined(url, "fm", format); - setParamIfUndefined(url, "fit", "cover"); - url.searchParams.set("url", base.toString()); - return toCanonicalUrlString(url); -}; - -export const transform: UrlTransformer = ( - options, -) => { - const url = toUrl(options.url); - - // If this is a Netlify image URL, we'll manipulate it rather than using it as the source image - if (url.pathname.startsWith("/.netlify/images")) { - const { params, base, format } = parse(url); - return generate({ - base, - format, - ...options, - params: { - ...params, - // If hostname is "n", we're dealing with a relative URL, so we don't need to set the site param - site: url.hostname === "n" ? undefined : url.origin, - }, - }); - } - - return generate({ - ...options, - base: options.url, - params: { - site: options.cdnOptions?.netlify?.site as string, - }, - }); -}; diff --git a/src/transformers/nextjs.test.ts b/src/transformers/nextjs.test.ts deleted file mode 100644 index 111ceff..0000000 --- a/src/transformers/nextjs.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { transform } from "./nextjs.ts"; - -const nextImgLocal = - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=3840&q=75"; - -const nextLocal = "/_next/static/image.jpg"; - -Deno.test("Next.js", async (t) => { - await t.step("should format a local next/image URL", () => { - const result = transform({ - url: nextImgLocal, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=200&q=75", - ); - }); - - await t.step("should format a local file with next/image", () => { - const result = transform({ - url: nextLocal, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_next/image?url=%2F_next%2Fstatic%2Fimage.jpg&w=200&q=75", - ); - }); - - await t.step("should format a local, arbitrary file", () => { - const result = transform({ - url: "/profile.png", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_next/image?url=%2Fprofile.png&w=200&q=75", - ); - }); - - await t.step("should format a remote image", () => { - const result = transform({ - url: "https://placekitten.com/100", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_next/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75", - ); - }); - - await t.step("should round non-integer dimensions", () => { - const result = transform({ - url: nextImgLocal, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=201&q=75", - ); - }); -}); diff --git a/src/transformers/nextjs.ts b/src/transformers/nextjs.ts deleted file mode 100644 index 952a5c3..0000000 --- a/src/transformers/nextjs.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -export { delegateUrl } from "./vercel.ts"; - -import { - parse as vercelParse, - transform as vercelTransform, -} from "./vercel.ts"; -export const parse: UrlParser = ( - url, -) => ({ ...vercelParse(url), cdn: "nextjs" }); - -export const transform: UrlTransformer = ( - params, -) => vercelTransform({ ...params, cdn: "nextjs" }); diff --git a/src/transformers/scene7.test.ts b/src/transformers/scene7.test.ts deleted file mode 100644 index a228a1d..0000000 --- a/src/transformers/scene7.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl, UrlParser } from "../types.ts"; -import { parse, SceneParams, transform } from "./scene7.ts"; - -const imgNoTransofrms = "https://s7d1.scene7.com/is/image/sample/s9"; -const imgWithHeightWidthFormat = - "https://s7d1.scene7.com/is/image/sample/s9?wid=500&hei=700&fmt=webp"; -const imgWithScale = "https://s7d1.scene7.com/is/image/sample/s9?scl=1"; - -Deno.test("Scene 7", async (t) => { - await t.step("parses image with base transforms", () => { - const parsed = parse(imgNoTransofrms); - const expected: ParsedUrl = { - base: imgNoTransofrms, - cdn: "scene7", - format: undefined, - width: 0, - height: 0, - params: { - fit: undefined, - quality: undefined, - scale: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses image with transforms", () => { - const parsed = parse(imgWithHeightWidthFormat); - const expected: ParsedUrl = { - base: imgWithHeightWidthFormat, - cdn: "scene7", - format: "webp", - width: 500, - height: 700, - params: { - fit: undefined, - quality: undefined, - scale: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses image with transforms", () => { - const parsed = parse(imgWithScale); - const expected: ParsedUrl = { - base: imgWithScale, - cdn: "scene7", - format: undefined, - width: 0, - height: 0, - params: { - fit: undefined, - quality: undefined, - scale: 1, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("transforms a URL", () => { - const result = transform({ - url: imgNoTransofrms, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://s7d1.scene7.com/is/image/sample/s9?wid=100&hei=200&fit=crop", - ); - }); -}); diff --git a/src/transformers/scene7.ts b/src/transformers/scene7.ts deleted file mode 100644 index dc751e3..0000000 --- a/src/transformers/scene7.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export interface SceneParams { - fit?: string; - quality?: number; - scale?: number; -} - -export const parse: UrlParser< - { - fit?: string | undefined; - scale?: number | undefined; - quality?: number | undefined; - } -> = (url) => { - const parsedUrl = toUrl(url); - - const fit = parsedUrl.searchParams.get("fit") || undefined; - const width = getNumericParam(parsedUrl, "wid"); - const height = getNumericParam(parsedUrl, "hei"); - const quality = getNumericParam(parsedUrl, "qlt") || undefined; - const format = parsedUrl.searchParams.get("fmt") || undefined; - const scale = getNumericParam(parsedUrl, "scl") || undefined; - - return { - width, - height, - format, - base: parsedUrl.toString(), - params: { fit, quality, scale }, - cdn: "scene7", - }; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const url = toUrl(originalUrl); - - setParamIfDefined(url, "wid", width, true, true); - setParamIfDefined(url, "hei", height, true, true); - setParamIfDefined(url, "fmt", format, true); - setParamIfDefined(url, "qlt", getNumericParam(url, "qlt"), true); - setParamIfDefined(url, "scl", getNumericParam(url, "scl"), true); - setParamIfUndefined(url, "fit", "crop"); - - if (!width && !height) { - setParamIfUndefined(url, "scl", 1); - } - return url; -}; diff --git a/src/transformers/shopify.test.ts b/src/transformers/shopify.test.ts deleted file mode 100644 index 7670292..0000000 --- a/src/transformers/shopify.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals } from "jsr:@std/assert"; -import { parse, transform } from "./shopify.ts"; -import examples from "./shopify.fixtures.json" with { type: "json" }; - -const img = - "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage_medium_crop_top.webp?v=3"; - -Deno.test("shopify parser", async (t) => { - for (const { original, ...example } of examples) { - await t.step(original, () => { - const { params, ...parsed } = parse(original) as any; - // Convert null from JSON into undefined for assertEquals - const expected = Object.fromEntries( - Object.entries(example).map(([k, v]) => [k, v ?? undefined]), - ); - expected.cdn = "shopify"; - const { crop, size } = params || {}; - assertEquals({ crop, size, ...parsed }, expected); - }); - } -}); - -Deno.test("shopify transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.webp?v=3&width=100&height=200&crop=top", - ); - }); - - await t.step("rounds non-numeric params", () => { - const result = transform({ - url: img, - width: 100.2, - height: 200.6, - }); - assertEquals( - result?.toString(), - "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.webp?v=3&width=100&height=201&crop=top", - ); - }); -}); diff --git a/src/transformers/shopify.ts b/src/transformers/shopify.ts deleted file mode 100644 index 5b9e332..0000000 --- a/src/transformers/shopify.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { setParamIfDefined, toUrl } from "../utils.ts"; - -const shopifyRegex = - /(.+?)(?:_(?:(pico|icon|thumb|small|compact|medium|large|grande|original|master)|(\d*)x(\d*)))?(?:_crop_([a-z]+))?(\.[a-zA-Z]+)(\.png|\.jpg|\.webp|\.avif)?$/; - -export const parse: UrlParser<{ crop?: string; size?: string }> = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const match = url.pathname.match(shopifyRegex); - if (!match) { - throw new Error("Invalid Shopify URL"); - } - const [, path, size, width, height, crop, extension, format] = match; - - url.pathname = `${path}${extension}`; - - const widthString = width ? width : url.searchParams.get("width"); - const heightString = height ? height : url.searchParams.get("height"); - url.searchParams.delete("width"); - url.searchParams.delete("height"); - return { - base: url.toString(), - width: Number(widthString) || undefined, - height: Number(heightString) || undefined, - format: format ? format.slice(1) : undefined, - params: { crop, size }, - cdn: "shopify", - }; -}; - -export const generate: UrlGenerator<{ crop?: string }> = ( - { base, width, height, format, params }, -) => { - const url = toUrl(base); - setParamIfDefined(url, "width", width, true, true); - setParamIfDefined(url, "height", height, true, true); - setParamIfDefined(url, "crop", params?.crop); - setParamIfDefined(url, "format", format); - return url; -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height }, -) => { - const parsed = parse(originalUrl); - if (!parsed) { - return; - } - - const props: UrlGeneratorOptions<{ crop?: string }> = { - ...parsed, - width, - height, - }; - - return generate(props); -}; diff --git a/src/transformers/storyblok.test.ts b/src/transformers/storyblok.test.ts deleted file mode 100644 index 73600f4..0000000 --- a/src/transformers/storyblok.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { assertEquals } from "jsr:@std/assert"; -import { parse } from "./storyblok.ts"; - -const images = [ - "https://a.storyblok.com/f/39898/1000x600/d962430746/demo-image-human.jpeg/m/100x100:450x350/200x200/filters:grayscale()", - "https://a.storyblok.com/f/39898/2250x1500/c15735a73c/demo-image-human.jpeg/m/600x130", - "https://a.storyblok.com/f/39898/1000x600/d962430746/demo-image-human.jpeg/m/-230x230/filters:rotate(90)", - "https://a.storyblok.com/f/39898/1000x600/d962430746/demo-image-human.jpeg/m/-230x230/filters:format(webp):rotate(90)", - "https://a.storyblok.com/f/39898/1000x600/d962430746/demo-image-human.jpeg", - "https://img2.storyblok.com/100x100:450x350/200x200/filters:grayscale()/f/39898/1000x600/d962430746/demo-image-human.jpeg", - "https://img2.storyblok.com/600x-130/f/39898/2250x1500/c15735a73c/demo-image-human.jpeg", - "https://img2.storyblok.com/-230x230/filters:rotate(90)/f/39898/1000x600/d962430746/demo-image-human.jpeg", - "https://img2.storyblok.com/200x0/filters:format(png)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg", - "https://img2.storyblok.com/200x0/filters:rotate(90):format(png)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg", - "https://img2.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg", -]; - -Deno.test("storyblok parser", async (t) => { - for (const image of images) { - await t.step(image, () => { - const res = parse(image); - // console.log(res); - }); - // await t.step(original, () => { - // const { params, ...parsed } = parse(original ) as any; - // // Convert null from JSON into undefined for assertEquals - // const expected = Object.fromEntries( - // Object.entries(example).map(([k, v]) => [k, v ?? undefined]), - // ); - // expected.cdn = "shopify"; - // const { crop, size } = params || {}; - // assertEquals({ crop, size, ...parsed }, expected); - // }); - } -}); - -// Deno.test("shopify transformer", async (t) => { -// await t.step("transforms a URL", () => { -// const result = transform({ -// url: img, -// width: 100, -// height: 200, -// }); -// assertEquals( -// result?.toString(), -// "https://cdn.shopify.com/s/files/1/2345/6789/products/myimage.webp?v=3&width=100&height=200&crop=top", -// ); -// }); -// }); diff --git a/src/transformers/storyblok.ts b/src/transformers/storyblok.ts deleted file mode 100644 index 8b7085f..0000000 --- a/src/transformers/storyblok.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { UrlGenerator, UrlParser, UrlTransformer } from "../types.ts"; -import { toUrl } from "../utils.ts"; - -const storyBlokAssets = - /(?\/f\/\d+\/\d+x\d+\/\w+\/[^\/]+)\/?(?m\/?(?\d+x\d+:\d+x\d+)?\/?(?(?\-)?(?\d+)x(?\-)?(?\d+))?\/?(filters\:(?[^\/]+))?)?$/g; - -const storyBlokImg2 = - /^(?\/(?\d+x\d+:\d+x\d+)?\/?(?(?\-)?(?\d+)x(?\-)?(?\d+))?\/?(filters\:(?[^\/]+))?\/?)?(?\/f\/.+)$/g; - -export interface StoryblokParams { - crop?: string; - filters?: Record; - flipx?: "-"; - flipy?: "-"; -} - -export const splitFilters = (filters: string): Record => { - if (!filters) { - return {}; - } - return Object.fromEntries( - filters.split(":").map((filter) => { - if (!filter) return []; - const [key, value] = filter.split("("); - return [key, value.replace(")", "")]; - }), - ); -}; - -export const generateFilters = (filters?: Record) => { - if (!filters) { - return undefined; - } - const filterItems = Object.entries(filters).map(([key, value]) => - `${key}(${value ?? ""})` - ); - if (filterItems.length === 0) { - return undefined; - } - return `filters:${filterItems.join(":")}`; -}; - -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - - // img2.storyblok.com is the old domain for Storyblok images, which used a - // different path format. We'll assume custom domains are using the new format. - const regex = url.hostname === "img2.storyblok.com" - ? storyBlokImg2 - : storyBlokAssets; - - const [matches] = url.pathname.matchAll(regex); - if (!matches || !matches.groups) { - throw new Error("Invalid Storyblok URL"); - } - - const { id, crop, width, height, filters, flipx, flipy } = matches.groups; - - const { format, ...filterMap } = splitFilters(filters); - - // We update old img2.storyblok.com URLs to use the new syntax and domain - if (url.hostname === "img2.storyblok.com") { - url.hostname = "a.storyblok.com"; - } - - return { - base: url.origin + id, - width: Number(width) || undefined, - height: Number(height) || undefined, - format, - params: { - crop, - filters: filterMap, - flipx: flipx as "-" | undefined, - flipy: flipy as "-" | undefined, - }, - cdn: "storyblok", - }; -}; - -export const generate: UrlGenerator = ( - { base, width = 0, height = 0, format, params = {} }, -) => { - const { crop, filters, flipx = "", flipy = "" } = params; - - const size = `${flipx}${width}x${flipy}${height}`; - - return new URL( - [base, "m", crop, size, generateFilters(filters), format].filter( - Boolean, - ).join("/"), - ); -}; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height, format }, -) => { - const parsed = parse(originalUrl); - if (!parsed) { - return; - } - - if (format) { - if (!parsed.params) { - parsed.params = { filters: {} }; - } - if (!parsed.params.filters) { - parsed.params.filters = {}; - } - parsed.params.filters.format = format; - } - - return generate({ - ...parsed, - width, - height, - }); -}; diff --git a/src/transformers/supabase.test.ts b/src/transformers/supabase.test.ts deleted file mode 100644 index 850ffe1..0000000 --- a/src/transformers/supabase.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { generate, parse, SupabaseParams, transform } from "./supabase.ts"; - -const img = - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=600&height=500&quality=50&resize=contain&format=origin"; - -const imgNoTransforms = - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/object/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg"; - -const imgCustom = - "https://api.some-custom-domain.com/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=600&height=500&quality=50&resize=contain&format=origin"; - -Deno.test("supabase parser", async (t) => { - await t.step("parses a URL", () => { - const parsed = parse(img); - - const expected: ParsedUrl = { - base: imgNoTransforms, - cdn: "supabase", - width: 600, - height: 500, - format: "origin", - params: { - quality: 50, - resize: "contain", - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL without transforms", () => { - const parsed = parse(imgNoTransforms); - const expected: ParsedUrl = { - base: imgNoTransforms, - cdn: "supabase", - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with custom domain", () => { - const parsed = parse(imgCustom); - const expected: ParsedUrl = { - base: - "https://api.some-custom-domain.com/storage/v1/object/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg", - cdn: "supabase", - width: 600, - height: 500, - format: "origin", - params: { - quality: 50, - resize: "contain", - }, - }; - assertEquals(parsed, expected); - }); -}); - -Deno.test("supabase transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=100&height=200&format=origin&quality=50&resize=contain", - ); - }); - - await t.step("rounds non-integer values", () => { - const result = transform({ - url: img, - width: 100.6, - height: 200.2, - }); - assertEquals( - result?.toString(), - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=101&height=200&format=origin&quality=50&resize=contain", - ); - }); - - await t.step("transforms a URL without parsed transforms", () => { - const result = transform({ - url: imgNoTransforms, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=100&height=200", - ); - }); -}); - -Deno.test("supabase generator", async (t) => { - await t.step("generates a URL", () => { - const result = generate({ - base: img, - width: 100, - height: 200, - params: { - quality: 80, - resize: "cover", - }, - }); - assertEquals( - result?.toString(), - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=100&height=200&format=origin&quality=80&resize=cover", - ); - }); - - await t.step("generates a URL without parsed transforms", () => { - const result = generate({ - base: imgNoTransforms, - width: 100, - height: 200, - format: "origin", - params: { - quality: 80, - resize: "cover", - }, - }); - assertEquals( - result?.toString(), - "https://enlyjtqaeutqbhqgkadn.supabase.co/storage/v1/render/image/public/sample-public-bucket/alexander-shatov-PHH_0uw9-Qw-unsplash.jpg?width=100&height=200&format=origin&quality=80&resize=cover", - ); - }); -}); diff --git a/src/transformers/supabase.ts b/src/transformers/supabase.ts deleted file mode 100644 index 45c9b8f..0000000 --- a/src/transformers/supabase.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { roundIfNumeric, toUrl } from "../utils.ts"; - -const ALLOWED_FORMATS = ["origin"]; - -const STORAGE_URL_PREFIX = "/storage/v1/object/public/"; -const RENDER_URL_PREFIX = "/storage/v1/render/image/public/"; - -const isRenderUrl = (url: URL) => url.pathname.startsWith(RENDER_URL_PREFIX); - -export interface SupabaseParams { - /** - * The quality of the returned image - a value from 20 to 100 (with 100 being the highest quality). - * - * @type {number} - * @default 80 - * @see https://supabase.com/docs/guides/storage/serving/image-transformations#optimizing - */ - quality?: number; - /** - * You can use different resizing modes to fit your needs, each of them uses a different approach to resize the image. - * - `cover`: resizes the image while keeping the aspect ratio to fill a given size and crops projecting parts. (default) - * - `contain`: resizes the image while keeping the aspect ratio to fit a given size. - * - `fill`: resizes the image without keeping the aspect ratio. - * - * @type {"cover" | "contain" | "fill"} - * @default "cover" - * @see https://supabase.com/docs/guides/storage/serving/image-transformations#modes - */ - resize?: "cover" | "contain" | "fill"; -} - -export const parse: UrlParser = ( - imageUrl, -) => { - const url = toUrl(imageUrl); - const isRender = isRenderUrl(url); - - if (!isRender) { - return { - cdn: "supabase", - base: url.origin + url.pathname, - }; - } - - const imagePath = url.pathname.replace(RENDER_URL_PREFIX, ""); - - const quality = url.searchParams.has("quality") - ? Number(url.searchParams.get("quality")) - : undefined; - const width = url.searchParams.has("width") - ? Number(url.searchParams.get("width")) - : undefined; - const height = url.searchParams.has("height") - ? Number(url.searchParams.get("height")!) - : undefined; - const format = url.searchParams.has("format") - ? url.searchParams.get("format")! - : undefined; - const resize = url.searchParams.has("resize") - ? url.searchParams.get("resize") as "cover" | "contain" | "fill" - : undefined; - - return { - cdn: "supabase", - base: url.origin + STORAGE_URL_PREFIX + imagePath, - width, - height, - format, - params: { - quality, - resize, - }, - }; -}; - -export const generate: UrlGenerator = ( - { base, width, height, format, params }, -) => { - const parsed = parse(base.toString()); - - base = parsed.base; - width = width || parsed.width; - height = height || parsed.height; - format = format || parsed.format; - params = { ...parsed.params, ...params }; - - const searchParams = new URLSearchParams(); - - if (width) searchParams.set("width", roundIfNumeric(width).toString()); - - if (height) searchParams.set("height", roundIfNumeric(height).toString()); - - if (format && ALLOWED_FORMATS.includes(format)) { - searchParams.set("format", format); - } - - if (params?.quality) { - searchParams.set("quality", roundIfNumeric(params.quality).toString()); - } - - if (params?.resize) searchParams.set("resize", params.resize); - - if (searchParams.toString() === "") return base; - - return parsed.base.replace(STORAGE_URL_PREFIX, RENDER_URL_PREFIX) + "?" + - searchParams.toString(); -}; - -export const transform: UrlTransformer = ( - { url, width, height, format, cdnOptions }, -) => { - const parsed = parse(url); - - return generate({ - base: parsed.base, - width: width || parsed.width, - height: height || parsed.height, - format: format || parsed.format, - params: cdnOptions?.supabase || parsed.params, - }); -}; diff --git a/src/transformers/uploadcare.test.ts b/src/transformers/uploadcare.test.ts deleted file mode 100644 index 19c1c94..0000000 --- a/src/transformers/uploadcare.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { ParsedUrl } from "../types.ts"; -import { parse, transform, UploadcareParams } from "./uploadcare.ts"; - -const baseImage = "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/"; -const img = - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/"; - -const imgNoOperations = baseImage; - -const imgSubdomain = - "https://private-name.example.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/"; - -const imgWithFilename = - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/auto/tshirt1.jpg"; - -Deno.test("uploadcare parser", async (t) => { - await t.step("parses a URL", () => { - const parsed = parse(img); - const expected: ParsedUrl = { - base: - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/-/format/auto/", - cdn: "uploadcare", - params: { - host: "ucarecdn.com", - uuid: "661bd414-064c-477a-b50f-8ffd8f66aa49", - operations: { - resize: "800x550", - format: "auto", - }, - filename: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL without operations", () => { - const parsed = parse(imgNoOperations); - const expected: ParsedUrl = { - base: `${baseImage}-/format/auto/`, - cdn: "uploadcare", - params: { - host: "ucarecdn.com", - uuid: "661bd414-064c-477a-b50f-8ffd8f66aa49", - operations: { - format: "auto", - }, - filename: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with custom domain", () => { - const parsed = parse(imgSubdomain); - const expected: ParsedUrl = { - base: - "https://private-name.example.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/-/format/auto/", - cdn: "uploadcare", - params: { - host: "private-name.example.com", - uuid: "661bd414-064c-477a-b50f-8ffd8f66aa49", - operations: { - resize: "800x550", - format: "auto", - }, - filename: undefined, - }, - }; - assertEquals(parsed, expected); - }); - - await t.step("parses a URL with filename", () => { - const parsed = parse(imgWithFilename); - const expected: ParsedUrl = { - base: - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/800x550/-/format/auto/tshirt1.jpg", - cdn: "uploadcare", - params: { - host: "ucarecdn.com", - uuid: "661bd414-064c-477a-b50f-8ffd8f66aa49", - operations: { - resize: "800x550", - format: "auto", - }, - filename: "tshirt1.jpg", - }, - }; - assertEquals(parsed, expected); - }); -}); - -Deno.test("uploadcare transformer", async (t) => { - await t.step("transforms a URL", () => { - const result = transform({ - url: img, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/100x200/-/format/auto/", - ); - }); - - await t.step("rounds non-integer values", () => { - const result = transform({ - url: img, - width: 100.6, - height: 200.2, - }); - assertEquals( - result?.toString(), - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/100.6x200.2/-/format/auto/", - ); - }); - - await t.step("transforms a URL without parsed transforms", () => { - const result = transform({ - url: imgNoOperations, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/format/auto/-/resize/100x200/", - ); - }); - - await t.step("transforms a URL with path and version", () => { - const result = transform({ - url: imgWithFilename, - width: 100, - height: 200, - }); - assertEquals( - result?.toString(), - "https://ucarecdn.com/661bd414-064c-477a-b50f-8ffd8f66aa49/-/resize/100x200/-/format/auto/tshirt1.jpg", - ); - }); -}); diff --git a/src/transformers/uploadcare.ts b/src/transformers/uploadcare.ts deleted file mode 100644 index a5b04de..0000000 --- a/src/transformers/uploadcare.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { - UrlGenerator, - UrlGeneratorOptions, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { toUrl } from "../utils.ts"; - -const uploadcareRegex = /^https?:\/\/(?[^\/]+)\/(?[^\/]+)/g; - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L57 - */ -export function extractFilename(cdnUrl: string) { - const url = new URL(cdnUrl); - const noOrigin = url.pathname + url.search + url.hash; - const urlFilenameIdx = noOrigin.lastIndexOf("http"); - const plainFilenameIdx = noOrigin.lastIndexOf("/"); - let filename = ""; - - if (urlFilenameIdx >= 0) { - filename = noOrigin.slice(urlFilenameIdx); - } else if (plainFilenameIdx >= 0) { - filename = noOrigin.slice(plainFilenameIdx + 1); - } - - return filename; -} - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L131 - */ -export function isFileUrl(filename: string) { - return filename.startsWith("http"); -} - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L141 - */ -export function splitFileUrl(fileUrl: string) { - const url = new URL(fileUrl); - return { - pathname: url.origin + url.pathname || "", - search: url.search || "", - hash: url.hash || "", - }; -} - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L114 - */ -export function trimFilename(cdnUrl: string) { - const url = new URL(cdnUrl); - const filename = extractFilename(cdnUrl); - const filenamePathPart = isFileUrl(filename) - ? splitFileUrl(filename).pathname - : filename; - - url.pathname = url.pathname.replace(filenamePathPart, ""); - url.search = ""; - url.hash = ""; - return url.toString(); -} - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L9C1-L24C3 - */ -export const normalizeCdnOperation = (operation: string) => { - if (typeof operation !== "string" || !operation) { - return ""; - } - let str = operation.trim(); - if (str.startsWith("-/")) { - str = str.slice(2); - } else if (str.startsWith("/")) { - str = str.slice(1); - } - - if (str.endsWith("/")) { - str = str.slice(0, str.length - 1); - } - return str; -}; - -/** - * Taken from uploadcare/blocks - * - * @see https://github.com/uploadcare/blocks/blob/87d1048e94f05f99e1da988c86c6362522e9a3c8/utils/cdn-utils.js#L93C1-L106C2 - */ -export function extractOperations(cdnUrl: string) { - const withoutFilename = trimFilename(cdnUrl); - const url = new URL(withoutFilename); - const operationsMarker = url.pathname.indexOf("/-/"); - if (operationsMarker === -1) { - return []; - } - const operationsStr = url.pathname.substring(operationsMarker); - - return operationsStr - .split("/-/") - .filter(Boolean) - .map((operation) => normalizeCdnOperation(operation)); -} - -const parseOperations = (operations: Array): UploadcareOperations => { - return operations.length - ? operations.reduce((acc, operation) => { - const [key, value] = operation.split("/"); - return { - ...acc, - [key]: value, - }; - }, {}) - : {}; -}; - -type NumericRange< - START extends number, - END extends number, - ARR extends unknown[] = [], - ACC extends number = never, -> = ARR["length"] extends END ? ACC | START | END - : NumericRange< - START, - END, - [...ARR, 1], - ARR[START] extends undefined ? ACC : ACC | ARR["length"] - >; - -interface UploadcareOperations { - /** - * @see https://uploadcare.com/docs/transformations/image/compression/#operation-format - * - * @default "auto" - */ - format?: "jpeg" | "png" | "webp" | "auto" | "preserve"; - /** - * @see https://uploadcare.com/docs/transformations/image/compression/#operation-quality - * - * @default "normal" - */ - quality?: "normal" | "better" | "best" | "lighter" | "lightest"; - /** - * @see https://uploadcare.com/docs/transformations/image/compression/#operation-progressive - * - * @default "no" - */ - progressive?: "yes" | "no"; - /** - * @see https://uploadcare.com/docs/transformations/image/compression/#meta-information-control - * - * @default "all" - */ - strip_meta?: "all" | "none" | "sensitive"; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-preview - * - * @example "320x240" - * @returns `-/preview/:dimensions/` - */ - preview?: `${number}x${number}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-resize - * - * @example "320x240", "320x" or "x240" - * @returns `-/resize/:one_or_two_dimensions/` - */ - resize?: `${number}x${number}` | `${number}x` | `x${number}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-stretch - * - * @returns `-/stretch/:mode/` - */ - stretch?: "on" | "off" | "fill"; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-smart-resize - * - * @returns `-/smart_resize/:dimensions/` - */ - smart_resize?: `${number}x${number}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-crop - * - * @returns `-/crop/:dimensions/` or `-/crop/:dimensions/:alignment/` - */ - crop?: - | `${number}${"x" | ","}${number}` - | `${number}${"x" | ","}${number}${"p" | "%"}` - | `${number}${"p" | "%"}${"x" | ","}${number}` - | `${number}${"p" | "%"}${"x" | ","}${number}${"p" | "%"}` - | `${number}${"x" | ","}${number}/${number}${"x" | ","}${number}` - | `${number}${"x" | ","}${number}/${number}${"x" | ","}${number}${ - | "p" - | "%"}` - | `${number}${"x" | ","}${number}/${number}${"p" | "%"}${ - | "x" - | ","}${number}` - | `${number}${"x" | ","}${number}/${number}${"p" | "%"}${ - | "x" - | ","}${number}${"p" | "%"}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-scale-crop - * - * @returns `-/scale_crop/:dimensions/` or `-/scale_crop/:dimensions/:alignment/ - */ - scale_crop?: - | `${number}${"x" | ","}${number}` - | `${number}${"x" | ","}${number}${"p" | "%"}` - | `${number}${"p" | "%"}${"x" | ","}${number}` - | `${number}${"p" | "%"}${"x" | ","}${number}${"p" | "%"}` - | `${number}${"x" | ","}${number}/${number}${"x" | ","}${number}` - | `${number}${"x" | ","}${number}/${number}${"x" | ","}${number}${ - | "p" - | "%"}` - | `${number}${"x" | ","}${number}/${number}${"p" | "%"}${ - | "x" - | ","}${number}` - | `${number}${"x" | ","}${number}/${number}${"p" | "%"}${ - | "x" - | ","}${number}${"p" | "%"}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-border-radius - * - * @returns `-/border_radius/:radii/ or `-/border_radius/:radii/:vertical_radii/` - */ - border_radius?: - | `${number}${"p" | "%" | ""}${"," | ""}${number}${"p" | "%" | ""}` - | `${number}${"p" | "%" | ""}${"," | ""}${number}${ - | "p" - | "%" - | ""}/${number}${ - | "p" - | "%"}${"," | ""}${number}${"p" | "%" | ""}` - | `${number}${"p" | "%" | ""}${"," | ""}${number}${ - | "p" - | "%" - | ""}/${number}${ - | "p" - | "%"}${"," | ""}${number}${"p" | "%" | ""}`; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-setfill - * - * @returns `-/setfill/:color/` - */ - setfill?: string; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-zoom-objects - * - * @returns `-/zoom_objects/:zoom/` - */ - zoom_objects?: NumericRange<1, 100>; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-autorotate - * - * @returns `-/autorotate/yes/` or `-/autorotate/no/` - */ - autorotate?: "yes" | "no"; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-autorotate - * - * @returns `-/rotate/:angle/` - */ - rotate?: NumericRange<0, 359>; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-flip - * - * @returns `-/flip/` - */ - flip?: true; - /** - * @see https://uploadcare.com/docs/transformations/image/resize-crop/#operation-mirror - * - * @returns `-/mirror/` - */ - mirror?: true; -} - -export interface UploadcareParams { - host?: string; - uuid?: string; - operations?: UploadcareOperations; - filename?: string; -} - -const formatUrl = ( - { - host, - uuid, - operations = {}, - filename, - }: UploadcareParams, -): string => { - const operationString = Object.entries(operations).map( - ([key, value]) => `${key}/${value}`, - ).join("/-/"); - - const pathSegments = [ - host, - uuid, - operationString ? `-/${operationString}` : "", - filename, - ].join("/"); - - return `https://${pathSegments}`; -}; - -export const parse: UrlParser = (imageUrl) => { - const url = toUrl(imageUrl); - const matchers = [...url.toString().matchAll(uploadcareRegex)]; - if (!matchers.length) { - throw new Error("Invalid Uploadcare URL"); - } - - const group = matchers[0].groups || {}; - const { ...baseParams } = group; - - const filename = extractFilename(url.toString()); - const { format: f, ...operations } = parseOperations( - extractOperations(url.toString()), - ); - const format = (f && f !== "auto") ? f : "auto"; - - const base = formatUrl({ - ...baseParams, - filename: filename || undefined, - operations: { - ...operations, - format, - }, - }); - - return { - base, - cdn: "uploadcare", - params: { - ...group, - filename: filename || undefined, - operations: { - ...operations, - format, - }, - }, - }; -}; - -export const generate: UrlGenerator = ({ - base, - width, - height, - params, -}) => { - const baseUrl = base.toString(); - const parsed = parse(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); - - const props: UploadcareParams = { - operations: {}, - ...parsed.params, - ...params, - }; - - if (width && height) { - props.operations = { - ...props.operations, - resize: `${width}x${height}`, - }; - } else { - if (width) { - props.operations = { - ...props.operations, - resize: `${width}x`, - }; - } - - if (height) { - props.operations = { - ...props.operations, - resize: `x${height}`, - }; - } - } - - return formatUrl(props); -}; - -export const transform: UrlTransformer = ({ - url: originalUrl, - width, - height, -}) => { - const parsed = parse(originalUrl); - if (!parsed) { - throw new Error("Invalid Uploadcare URL"); - } - - const props: UrlGeneratorOptions = { - ...parsed, - width, - height, - }; - - return generate(props); -}; diff --git a/src/transformers/vercel.test.ts b/src/transformers/vercel.test.ts deleted file mode 100644 index dc96f3c..0000000 --- a/src/transformers/vercel.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { transform } from "./vercel.ts"; - -const img = - "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=3840&q=75"; - -Deno.test("vercel", async (t) => { - await t.step("should format a local URL", () => { - const result = transform({ - url: img, - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=200&q=75", - ); - }); - - await t.step("should format a remote URL", () => { - const result = transform({ - url: "https://placekitten.com/100", - width: 200, - height: 100, - }); - assertEquals( - result?.toString(), - "/_vercel/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75", - ); - }); - - await t.step("should round non-integer dimensions", () => { - const result = transform({ - url: img, - width: 200.6, - height: 100.2, - }); - assertEquals( - result?.toString(), - "https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=201&q=75", - ); - }); -}); diff --git a/src/transformers/vercel.ts b/src/transformers/vercel.ts deleted file mode 100644 index 325732c..0000000 --- a/src/transformers/vercel.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - ShouldDelegateUrl, - UrlGenerator, - UrlParser, - UrlTransformer, -} from "../types.ts"; -import { - setParamIfDefined, - setParamIfUndefined, - toCanonicalUrlString, - toRelativeUrl, - toUrl, -} from "../utils.ts"; -import { getImageCdnForUrlByDomain } from "../detect.ts"; - -export const parse: UrlParser = ( - url, -) => { - const parsed = toUrl(url); - const width = Number(parsed.searchParams.get("w")) || undefined; - const quality = Number(parsed.searchParams.get("q")) || undefined; - - return { - base: parsed.toString(), - width, - quality, - cdn: "vercel", - }; -}; - -export const delegateUrl: ShouldDelegateUrl = (url) => { - const parsed = toUrl(url); - const source = parsed.searchParams.get("url"); - if (!source || !source.startsWith("http")) { - return false; - } - const cdn = getImageCdnForUrlByDomain(source); - if (!cdn) { - return false; - } - return { - cdn, - url: source, - }; -}; - -export interface VercelParams { - quality?: number; - root?: "_vercel" | "_next"; - src?: string; -} -export const generate: UrlGenerator = ( - { base, width, params: { quality = 75, root = "_vercel" } = {} }, -) => { - // If the base is a relative URL, we need to add a dummy host to the URL - const url = new URL("http://n"); - url.pathname = `/${root}/image`; - url.searchParams.set("url", base.toString()); - setParamIfDefined(url, "w", width, false, true); - setParamIfUndefined(url, "q", quality); - return toRelativeUrl(url); -}; - -export const transform: UrlTransformer = ( - { url, width, cdn }, -) => { - // the URL might be relative, so we need to add a dummy host to it - const parsedUrl = toUrl(url); - - const isNextImage = parsedUrl.pathname.startsWith("/_next/image") || - parsedUrl.pathname.startsWith("/_vercel/image"); - - const src = isNextImage ? parsedUrl.searchParams.get("url") : url.toString(); - if (!src) { - return undefined; - } - - setParamIfDefined(parsedUrl, "w", width, true, true); - - if (isNextImage) { - return toCanonicalUrlString(parsedUrl); - } - - return generate({ - base: src, - width, - params: { root: cdn === "nextjs" ? "_next" : "_vercel" }, - }); -}; diff --git a/src/transformers/wordpress.test.ts b/src/transformers/wordpress.test.ts deleted file mode 100644 index ca41953..0000000 --- a/src/transformers/wordpress.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { assertEquals } from "jsr:@std/assert"; -import { transform } from "./wordpress.ts"; - -const img = "https://jetpackme.files.wordpress.com/2020/01/jetpack-cdn.png"; - -Deno.test("wordpress", async (t) => { - await t.step("should format a URL", () => { - const result = transform({ url: img, width: 200, height: 100 }); - assertEquals( - result?.toString(), - "https://jetpackme.files.wordpress.com/2020/01/jetpack-cdn.png?w=200&h=100&crop=1", - ); - }); - - await t.step("should round non-numeric values", () => { - const result = transform({ url: img, width: 200.6, height: 100.2 }); - assertEquals( - result?.toString(), - "https://jetpackme.files.wordpress.com/2020/01/jetpack-cdn.png?w=201&h=100&crop=1", - ); - }); - - await t.step("should not change crop if set", () => { - const url = new URL(img); - url.searchParams.set("crop", "0"); - const result = transform({ url, width: 200, height: 100 }); - assertEquals( - result?.toString(), - "https://jetpackme.files.wordpress.com/2020/01/jetpack-cdn.png?crop=0&w=200&h=100", - ); - }); -}); diff --git a/src/transformers/wordpress.ts b/src/transformers/wordpress.ts deleted file mode 100644 index cbf1d94..0000000 --- a/src/transformers/wordpress.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UrlParser, UrlTransformer } from "../types.ts"; -import { - getNumericParam, - setParamIfDefined, - setParamIfUndefined, - toUrl, -} from "../utils.ts"; - -export const transform: UrlTransformer = ( - { url: originalUrl, width, height }, -) => { - const url = toUrl(originalUrl); - setParamIfDefined(url, "w", width, true, true); - setParamIfDefined(url, "h", height, true, true); - setParamIfUndefined(url, "crop", "1"); - return url; -}; - -export const parse: UrlParser<{ crop?: boolean }> = ( - url, -) => { - const parsed = toUrl(url); - const width = getNumericParam(parsed, "w"); - const height = getNumericParam(parsed, "h"); - const crop = parsed.searchParams.get("crop") === "1"; - parsed.search = ""; - return { - base: parsed.toString(), - width, - height, - params: { crop }, - cdn: "wordpress", - }; -}; diff --git a/src/types.ts b/src/types.ts index 8fc70bc..0d894c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,42 +1,29 @@ +import type { ProviderOperations, ProviderOptions } from "./providers/types.ts"; +export type { ProviderOperations, ProviderOptions }; /** * Options to transform an image URL */ -export interface UrlTransformerOptions { - /** The original URL of the image */ +export interface UrlTransformerOptions + extends + Pick< + ProviderOperations[TCDN], + "width" | "height" | "format" | "quality" + > { + /** The image URL to transform */ url: string | URL; - /** The desired width of the image */ - width?: number; - /** The desired height of the image */ - height?: number; - /** The desired format of the image. Default is auto-detect */ - format?: string; - /** Recursively find the the canonical CDN for a source image. Default is true */ - recursive?: boolean; - /** Specify a CDN rather than auto-detecting */ - cdn?: ImageCdn; - /** CDN-specific options. */ - cdnOptions?: CdnOptions; -} - -export interface CanonicalCdnUrl { - /** The source image URL */ - url: string | URL; - /** The CDN to use */ - cdn: ImageCdn; + /** Specify a provider rather than auto-detecting */ + provider?: TCDN; + /** @deprecated Use `provider` */ + cdn?: TCDN; + /** Provider to use if none matches */ + fallback?: TCDN; } /** - * Asks a CDN if there is a different canonical CDN for the given URL - * @param url The URL to check - * @returns The canonical CDN URL, or false if the given CDN will handle it itself + * @deprecated Use `ProviderOptions` instead */ -export interface ShouldDelegateUrl { - (url: string | URL): CanonicalCdnUrl | false; -} +export type CdnOptions = ProviderOptions; -export type CdnOptions = { - [key in ImageCdn]?: Record; -}; export interface UrlGeneratorOptions> { base: string | URL; width?: number; @@ -45,36 +32,6 @@ export interface UrlGeneratorOptions> { params?: TParams; } -export interface UrlGenerator> { - (options: UrlGeneratorOptions): URL | string; -} - -export interface ParsedUrl> { - /** The URL of the image with no transforms */ - base: string; - /** The width of the image */ - width?: number; - /** The height of the image */ - height?: number; - /** The format of the image */ - format?: string; - /** Other CDN-specific parameters */ - params?: TParams; - cdn: ImageCdn; -} -/** - * Parse an image URL into its components - */ -export interface UrlTransformer { - (options: UrlTransformerOptions): string | URL | undefined; -} - -export interface UrlParser< - TParams = Record, -> { - (url: string | URL): ParsedUrl; -} - export type ImageCdn = | "contentful" | "builder.io" @@ -103,4 +60,145 @@ export type ImageCdn = | "supabase" | "hygraph"; -export type SupportedImageCdn = ImageCdn; +export const SupportedProviders = { + astro: "Astro image service", + "builder.io": "Builder.io", + bunny: "Bunny.net", + cloudflare: "Cloudflare", + cloudflare_images: "Cloudflare Images", + cloudimage: "Cloudimage", + cloudinary: "Cloudinary", + contentful: "Contentful", + contentstack: "Contentstack", + directus: "Directus", + hygraph: "Hygraph", + imageengine: "ImageEngine", + imagekit: "ImageKit", + imgix: "Imgix", + ipx: "IPX", + keycdn: "KeyCDN", + "kontent.ai": "Kontent.ai", + netlify: "Netlify Image CDN", + nextjs: "Next.js image service", + scene7: "Adobe Dynamic Media / Scene7", + shopify: "Shopify", + storyblok: "Storyblok", + supabase: "Supabase", + uploadcare: "Uploadcare", + vercel: "Vercel", + wordpress: "WordPress", +} as const satisfies Record; + +export type OperationFormatter = ( + operations: T, +) => string; + +export type OperationParser = ( + url: string | URL, +) => T; + +export interface OperationMap { + width?: keyof TOperations | false; + height?: keyof TOperations | false; + format?: keyof TOperations | false; + quality?: keyof TOperations | false; +} + +export interface FormatMap { + // deno-lint-ignore ban-types + [key: string]: ImageFormat | (string & {}); +} + +export type ImageFormat = "jpeg" | "jpg" | "png" | "webp" | "avif"; + +// deno-lint-ignore ban-types +export interface Operations { + width?: number | string; + height?: number | string; + format?: ImageFormat | TImageFormat; + quality?: number | string; +} + +export interface ProviderConfig< + TOperations extends Operations = Operations, +> { + /** + * Maps standard operation names to their equivalent with this provider. + * Keys are any of width, height, format, quality. Only include those + * that are different from the standard. + */ + keyMap?: OperationMap; + /** + * Defaults that should always be applied to operations unless overridden. + */ + defaults?: Partial; + /** + * Maps standard format names to their equivalent with this provider. + * Only include those that are different from the standard. + */ + formatMap?: FormatMap; + /** + * Separator between keys and values in the URL. Defaults to "=". + */ + kvSeparator?: string; + /** + * Parameter separator in the URL. Defaults to "&". + */ + paramSeparator?: string; + /** + * If provided, the src URL will be extracted from this parameter. + */ + srcParam?: string; +} + +export type URLGenerator< + TCDN extends ImageCdn = ImageCdn, +> = ProviderOptions[TCDN] extends undefined + ? (src: string | URL, operations: ProviderOperations[TCDN]) => string + : ( + src: string | URL, + operations: ProviderOperations[TCDN], + options?: ProviderOptions[TCDN], + ) => string; + +export type URLTransformer< + TCDN extends ImageCdn = ImageCdn, +> = ProviderOptions[TCDN] extends undefined + ? (src: string | URL, operations: ProviderOperations[TCDN]) => string + : ( + src: string | URL, + operations: ProviderOperations[TCDN], + options?: ProviderOptions[TCDN], + ) => string; +export type TransformerFunction< + TOperations extends Operations, + TOptions, +> = TOptions extends undefined + ? (src: string | URL, operations: TOperations) => string + : (src: string | URL, operations: TOperations, options?: TOptions) => string; +export type URLExtractor< + TCDN extends ImageCdn = ImageCdn, +> = ( + url: string | URL, + options?: ProviderOptions[TCDN], +) => + | (ProviderOptions[TCDN] extends undefined ? { + operations: ProviderOperations[TCDN]; + src: string; + } + : { + operations: ProviderOperations[TCDN]; + src: string; + options: ProviderOptions[TCDN]; + }) + | null; + +export type ExtractedURL< + TCDN extends ImageCdn = ImageCdn, +> = ReturnType>; + +export type ParseURLResult = + | (ExtractedURL & { + cdn?: TCDN; + }) + | undefined; diff --git a/src/utils.test.ts b/src/utils.test.ts index 47e67a9..3ad3b2f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,6 +1,6 @@ // deno-lint-ignore-file no-explicit-any import { assertEquals } from "jsr:@std/assert"; -import { roundIfNumeric, toUrl } from "./utils.ts"; +import { createFormatter, escapeChar, roundIfNumeric, toUrl } from "./utils.ts"; Deno.test("roundIfNumeric", () => { assertEquals(roundIfNumeric(1), 1); @@ -14,7 +14,7 @@ Deno.test("roundIfNumeric", () => { assertEquals(roundIfNumeric("0"), 0); assertEquals(roundIfNumeric(null as any), null); assertEquals(roundIfNumeric(0), 0); - assertEquals(roundIfNumeric(undefined as any), undefined); + assertEquals(roundIfNumeric(undefined), undefined); }); Deno.test("toUrl", () => { @@ -25,3 +25,57 @@ Deno.test("toUrl", () => { assertEquals(toUrl("/foo").toString(), "http://n/foo"); assertEquals(toUrl("foo").toString(), "http://n/foo"); }); + +const testOperations = { + width: 200, + withSlash: "value/with/slash", + boolean: true, + // Should include false values + isFalse: false, + // Should exclude undefined values + isUndefined: undefined, + // Should exclude null values + isNull: null, + withAmpersand: "value&with&ersand", + withEqual: "value=with=equal", + withComma: "value,with,comma", + withAnArray: ["value", "with", "array"], + withUserscore: "value_with_userscore", +}; + +Deno.test("query formatter", () => { + const formatter = createFormatter("=", "&"); + const result = formatter(testOperations); + assertEquals( + result, + "width=200&withSlash=value%2Fwith%2Fslash&boolean=true&isFalse=false&withAmpersand=value%26with%26ampersand&withEqual=value%3Dwith%3Dequal&withComma=value%2Cwith%2Ccomma&withAnArray=value&withAnArray=with&withAnArray=array&withUserscore=value_with_userscore", + ); +}); + +Deno.test("comma-separated formatter", () => { + const formatter = createFormatter("=", ","); + const result = formatter(testOperations); + assertEquals( + result, + "width=200,withSlash=value%2Fwith%2Fslash,boolean=true,isFalse=false,withAmpersand=value%26with%26ampersand,withEqual=value%3Dwith%3Dequal,withComma=value%2Cwith%2Ccomma,withAnArray=value,withAnArray=with,withAnArray=array,withUserscore=value_with_userscore", + ); +}); + +Deno.test("userscore formatter", () => { + const formatter = createFormatter("_", "/"); + const result = formatter(testOperations); + assertEquals( + result, + "width_200/withSlash_value%2Fwith%2Fslash/boolean_true/isFalse_false/withAmpersand_value%26with%26ampersand/withEqual_value%3Dwith%3Dequal/withComma_value%2Cwith%2Ccomma/withAnArray_value/withAnArray_with/withAnArray_array/withUserscore_value%5Fwith%5Fuserscore", + ); +}); + +Deno.test("escape char", () => { + assertEquals(escapeChar("x"), "%78"); + assertEquals(escapeChar(" "), "+"); + assertEquals(escapeChar("+"), "%2B"); + assertEquals(escapeChar("%"), "%25"); + assertEquals(escapeChar("a"), "%61"); + assertEquals(escapeChar("A"), "%41"); + assertEquals(escapeChar("0"), "%30"); +}); diff --git a/src/utils.ts b/src/utils.ts index 0d66ac1..9f71d73 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,34 @@ -export const roundIfNumeric = (value: string | number) => { +import type { ProviderOperations, ProviderOptions } from "./providers/types.ts"; +import { + type ImageCdn, + OperationFormatter, + OperationMap, + OperationParser, + Operations, + ProviderConfig, + URLExtractor, + URLGenerator, + URLTransformer, +} from "./types.ts"; + +export function roundIfNumeric( + value: T, +): T extends undefined ? undefined + : T extends number ? number + : T extends string ? string | number + : never { if (!value) { - return value; + // deno-lint-ignore no-explicit-any + return value as any; } const num = Number(value); - return isNaN(num) ? value : Math.round(num); -}; - + if (isNaN(num)) { + // deno-lint-ignore no-explicit-any + return value as any; + } + // deno-lint-ignore no-explicit-any + return Math.round(num) as any; +} export const setParamIfDefined = ( url: URL, key: string, @@ -60,3 +83,319 @@ export const toCanonicalUrlString = (url: URL) => { export const toUrl = (url: string | URL, base?: string | URL | undefined) => { return typeof url === "string" ? new URL(url, base ?? "http://n/") : url; }; + +/** + * Escapes a string, even if it's URL-safe + */ +export const escapeChar = (text: string) => + text === " " ? "+" : ("%" + + text.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")); + +export const stripLeadingSlash = (str?: string) => + str?.startsWith("/") ? str.slice(1) : str; + +export const stripTrailingSlash = (str?: string) => + str?.endsWith("/") ? str.slice(0, -1) : str; + +export const addLeadingSlash = (str?: string) => + str?.startsWith("/") ? str : `/${str}`; + +export const addTrailingSlash = (str?: string) => + str?.endsWith("/") ? str : `${str}/`; + +/** + * Creates a formatter given an operation joiner and key/value joiner + */ +export const createFormatter = ( + kvSeparator: string, + paramSeparator: string, +): OperationFormatter => { + const encodedValueJoiner = escapeChar(kvSeparator); + const encodedOperationJoiner = escapeChar(paramSeparator); + + function escape(value: string) { + return encodeURIComponent(value).replaceAll( + kvSeparator, + encodedValueJoiner, + ) + .replaceAll(paramSeparator, encodedOperationJoiner); + } + + function format(key: string, value: unknown) { + return `${escape(key)}${kvSeparator}${escape(String(value))}`; + } + + return (operations) => { + const ops = Array.isArray(operations) + ? operations + : Object.entries(operations); + return ops.flatMap(([key, value]) => { + if (value === undefined || value === null) { + return []; + } + if (Array.isArray(value)) { + return value.map((v) => format(key, v)); + } + return format(key, value); + }).join(paramSeparator); + }; +}; + +/** + * Creates a parser given an operation joiner and key/value joiner + */ +export const createParser = ( + kvSeparator: string, + paramSeparator: string, +): OperationParser => { + if (kvSeparator === "=" && paramSeparator === "&") { + return queryParser as OperationParser; + } + return (url) => { + const urlString = url.toString(); + return Object.fromEntries( + urlString.split(paramSeparator).map((pair) => { + const [key, value] = pair.split(kvSeparator); + return [decodeURI(key), decodeURI(value)]; + }), + ) as unknown as T; + }; +}; + +/** + * Clamp width and height, maintaining aspect ratio + */ + +export function clampDimensions( + operations: Operations, + maxWidth = 4000, + maxHeight = 4000, +): { + width: number | undefined; + height: number | undefined; +} { + let { width, height } = operations; + width = Number(width) || undefined; + height = Number(height) || undefined; + + if (width && width > maxWidth) { + if (height) { + height = Math.round(height * maxWidth / width); + } + width = maxWidth; + } + + if (height && height > maxHeight) { + if (width) { + width = Math.round(width * maxHeight / height); + } + height = maxHeight; + } + + return { width, height }; +} + +export function extractFromURL< + T extends Operations = Operations, +>(url: string | URL): { + operations: T; + src: string; +} { + const parsedUrl = toUrl(url); + const operations = Object.fromEntries( + parsedUrl.searchParams.entries(), + ); + for (const key in ["width", "height", "quality"]) { + const value = operations[key]; + if (value) { + const newVal = Number(value); + if (!isNaN(newVal)) { + // deno-lint-ignore no-explicit-any + operations[key] = newVal as any; + } + } + } + parsedUrl.search = ""; + + return { + operations: operations as unknown as T, + src: toCanonicalUrlString(parsedUrl), + }; +} + +export function normaliseOperations( + { keyMap = {}, formatMap = {}, defaults = {} }: Omit< + ProviderConfig, + "formatter" + >, + operations: T, +): T { + if (operations.format && operations.format in formatMap) { + operations.format = formatMap[operations.format]; + } + if (operations.width) { + operations.width = roundIfNumeric(operations.width); + } + if (operations.height) { + operations.height = roundIfNumeric(operations.height); + } + + for (const k in keyMap) { + if (!Object.prototype.hasOwnProperty.call(keyMap, k)) { + continue; + } + const key = k as keyof OperationMap; + if (keyMap[key] === false) { + delete operations[key]; + continue; + } + if (keyMap[key] && operations[key]) { + operations[keyMap[key]] = operations[key as keyof T]; + delete operations[key]; + } + } + + for (const k in defaults) { + if (!Object.prototype.hasOwnProperty.call(defaults, k)) { + continue; + } + const key = k as keyof OperationMap; + + const value = defaults[key as keyof T]; + if (!operations[key] && value !== undefined) { + if (keyMap[key] === false) { + continue; + } + const resolvedKey = keyMap[key] ?? key; + if (resolvedKey in operations) { + continue; + } + // deno-lint-ignore no-explicit-any + operations[resolvedKey] = value as any; + } + } + + return operations; +} + +const invertMap = ( + // deno-lint-ignore no-explicit-any + map: Record, +) => Object.fromEntries(Object.entries(map).map(([k, v]) => [v, k])); + +export function denormaliseOperations( + { keyMap = {}, formatMap = {}, defaults = {} }: Omit< + ProviderConfig, + "formatter" + >, + operations: T, +): T { + const invertedKeyMap = invertMap(keyMap); + const invertedFormatMap = invertMap(formatMap); + const ops = normaliseOperations({ + keyMap: invertedKeyMap, + formatMap: invertedFormatMap, + defaults, + }, operations); + if (ops.width) { + ops.width = roundIfNumeric(ops.width); + } + if (ops.height) { + ops.height = roundIfNumeric(ops.height); + } + const q = Number(ops.quality); + if (!isNaN(q)) { + ops.quality = q; + } + return ops; +} + +// Parses a query string +const queryParser: OperationParser = (url) => { + const parsedUrl = toUrl(url); + return Object.fromEntries( + parsedUrl.searchParams.entries(), + ); +}; + +export function createOperationsGenerator( + { kvSeparator = "=", paramSeparator = "&", ...options }: ProviderConfig = + {}, +): OperationFormatter { + const formatter = createFormatter(kvSeparator, paramSeparator); + return (operations: T) => { + const normalisedOperations = normaliseOperations(options, operations); + return formatter(normalisedOperations); + }; +} + +export function createOperationsParser( + { kvSeparator = "=", paramSeparator = "&", defaults: _, ...options }: + ProviderConfig = {}, +): OperationParser { + const parser = createParser(kvSeparator, paramSeparator); + return (url: string | URL) => { + const operations = url ? parser(url) : {} as T; + return denormaliseOperations( + options, + operations, + ); + }; +} + +export function createOperationsHandlers( + config: ProviderConfig, +): { + operationsGenerator: OperationFormatter; + operationsParser: OperationParser; +} { + const operationsGenerator = createOperationsGenerator(config); + const operationsParser = createOperationsParser(config); + return { operationsGenerator, operationsParser }; +} + +export function paramToBoolean( + value: boolean | string | number, +): boolean | undefined { + if (value === undefined || value === null) { + return undefined; + } + try { + return Boolean(JSON.parse(value?.toString())); + } catch { + return Boolean(value); + } +} + +const removeUndefined = ( + obj: T, +): Partial => + Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), + ) as Partial; + +export function createExtractAndGenerate< + TCDN extends ImageCdn, +>( + extract: URLExtractor, + generate: URLGenerator, +): URLTransformer { + return (( + src: string | URL, + operations: ProviderOperations[TCDN], + options?: ProviderOptions[TCDN], + ) => { + const base = extract(src, options); + if (!base) { + return generate(src, operations, options); + } + return generate(base.src, { + ...base.operations, + ...removeUndefined(operations), + }, { + // deno-lint-ignore no-explicit-any + ...(base as any).options, + ...options, + }); + }) as URLTransformer; +}