diff --git a/data/paths.json b/data/paths.json index e780328..e618d9a 100644 --- a/data/paths.json +++ b/data/paths.json @@ -8,5 +8,6 @@ "/_image": "astro", "/.netlify/images": "netlify", "/storage/v1/object/public/": "supabase", - "/storage/v1/render/image/public/": "supabase" + "/storage/v1/render/image/public/": "supabase", + "/v1/storage/buckets/": "appwrite" } diff --git a/demo/src/examples.json b/demo/src/examples.json index 3c72a8c..25b43b0 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -91,5 +91,9 @@ "hygraph": [ "Hygraph", "https://us-west-2.graphassets.com/cm2apl1zp07l506n66dmd9xo8/cm2tr64fx7gvu07n85chjmuno" + ], + "appwrite": [ + "Appwrite", + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/view?project=unpic-test" ] } diff --git a/deno.jsonc b/deno.jsonc index 1cc3b97..402b1f3 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -57,4 +57,4 @@ ] }, "license": "MIT" -} \ No newline at end of file +} diff --git a/src/extract.ts b/src/extract.ts index 7c2212e..1e82549 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -6,6 +6,7 @@ import type { } from "./providers/types.ts"; import type { ImageCdn, ParseURLResult, URLExtractor } from "./types.ts"; +import { extract as appwrite } from "./providers/appwrite.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"; @@ -34,6 +35,7 @@ import { extract as vercel } from "./providers/vercel.ts"; import { extract as wordpress } from "./providers/wordpress.ts"; export const parsers: URLExtractorMap = { + appwrite, astro, "builder.io": builder, bunny, diff --git a/src/providers/appwrite.test.ts b/src/providers/appwrite.test.ts new file mode 100644 index 0000000..b234deb --- /dev/null +++ b/src/providers/appwrite.test.ts @@ -0,0 +1,64 @@ +import { extract, generate, transform } from "./appwrite.ts"; +import { assertEqualIgnoringQueryOrder } from "../test-utils.ts"; +import { assertEquals } from "jsr:@std/assert"; + +const imageUrl = + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/view?project=unpic-test"; + +// Tests for generate, extract, and transform + +Deno.test("Appwrite - generate", async (t) => { + await t.step("should generate a URL with transformations", () => { + const result = generate(imageUrl, { width: 800, height: 600 }); + assertEqualIgnoringQueryOrder( + result, + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test&width=800&height=600", + ); + }); + + await t.step("should generate a URL with quality and format", () => { + const result = generate(imageUrl, { + width: 800, + quality: 75, + format: "webp", + }); + assertEqualIgnoringQueryOrder( + result, + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test&width=800&quality=75&output=webp", + ); + }); +}); + +Deno.test("Appwrite - extract", async (t) => { + await t.step( + "should extract transformations from a transformed URL", + () => { + const parsed = extract( + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test&width=800&height=600&quality=75&output=webp", + ); + assertEquals(parsed, { + src: + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test", + operations: { + width: 800, + height: 600, + format: "webp", + quality: 75, + }, + }); + }, + ); +}); + +Deno.test("Appwrite - transform", async (t) => { + await t.step("should transform a URL with new operations", () => { + const result = transform( + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test&width=300&height=400", + { width: 800, height: 600, quality: 80, format: "webp" }, + ); + assertEqualIgnoringQueryOrder( + result, + "https://cloud.appwrite.io/v1/storage/buckets/unpic/files/679d127100131f67b6d8/preview?project=unpic-test&width=800&height=600&quality=80&output=webp", + ); + }); +}); diff --git a/src/providers/appwrite.ts b/src/providers/appwrite.ts new file mode 100644 index 0000000..a93155b --- /dev/null +++ b/src/providers/appwrite.ts @@ -0,0 +1,152 @@ +import { getProviderForUrlByPath } from "../detect.ts"; +import type { + ImageFormat, + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + createOperationsHandlers, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; + +type AppwriteOutputFormats = + | ImageFormat + | "gif"; + +const VIEW_URL_SUFFIX = "/view?"; +const PREVIEW_URL_SUFFIX = "/preview?"; + +/** + * @see https://appwrite.io/docs/products/storage/images + */ +export interface AppwriteOperations extends Operations { + /** + * Set the width of the output image in pixels. + * Output image will be resized keeping the aspect ratio intact. + * @type {number} Range: 0-4000 + */ + width?: number; + + /** + * Set the height of the output image in pixels. + * Output image will be resized keeping the aspect ratio intact. + * @type {number} Range: 0-4000 + */ + height?: number; + + /** + * Set the gravity while cropping the output image, providing either width, height, or both. + */ + gravity?: + | "center" + | "top-left" + | "top" + | "top-right" + | "left" + | "right" + | "bottom-left" + | "bottom" + | "bottom-right"; + + /** + * Set the quality of the output image + * @type {number} Range: 0-100 + */ + quality?: number; + + /** + * Set a border with the given width in pixels for the output image. + * @type {number} Range: 0-100 + */ + borderWidth?: number; + + /** + * Set a border-color for the output image. + * Accepts any valid hex color value without the leading '#'. + */ + borderColor?: string; + + /** + * Set the border-radius in pixels. + * @type {number} Range: 0-4000 + */ + borderRadius?: number; + + /** + * Set opacity for the output image. + * Works only with output formats supporting alpha channels, like 'png'. + * @type {number} Range: 0-1 + */ + opacity?: number; + + /** + * Rotates the output image by a degree. + * @type {number} Range: -360-360 + */ + rotation?: number; + + /** + * Set a background-color for the output image. + * Accepts any valid hex color value without the leading '#'. + * Works only with output formats supporting alpha channels, like 'png'. + */ + background?: string; + + /** + * Set the output image format. + * If not provided, will use the original image's format. + */ + output?: AppwriteOutputFormats; +} + +const { operationsGenerator, operationsParser } = createOperationsHandlers< + AppwriteOperations +>({ + keyMap: { + format: "output", + }, + kvSeparator: "=", + paramSeparator: "&", +}); + +export const generate: URLGenerator<"appwrite"> = (src, modifiers) => { + const url = toUrl( + src.toString().replace(VIEW_URL_SUFFIX, PREVIEW_URL_SUFFIX), + ); + const projectParam = url.searchParams.get("project") ?? ""; + + const operations = operationsGenerator(modifiers); + url.search = operations; + url.searchParams.append("project", projectParam); + + return toCanonicalUrlString(url); +}; + +export const extract: URLExtractor<"appwrite"> = (url) => { + if (getProviderForUrlByPath(url) !== "appwrite") { + return null; + } + const parsedUrl = toUrl(url); + const operations = operationsParser(parsedUrl); + // deno-lint-ignore no-explicit-any + delete (operations as any).project; + + const projectParam = parsedUrl.searchParams.get("project") ?? ""; + parsedUrl.search = ""; + parsedUrl.searchParams.append("project", projectParam); + + const sourceUrl = parsedUrl.href; + + return { + src: sourceUrl, + operations, + }; +}; + +export const transform: URLTransformer< + "appwrite" +> = createExtractAndGenerate(extract, generate); diff --git a/src/providers/types.ts b/src/providers/types.ts index d8c3353..e5aa982 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -4,6 +4,7 @@ import type { URLGenerator, URLTransformer, } from "../types.ts"; +import type { AppwriteOperations } from "./appwrite.ts"; import type { AstroOperations, AstroOptions } from "./astro.ts"; import type { BuilderOperations } from "./builder.io.ts"; import type { BunnyOperations } from "./bunny.ts"; @@ -38,6 +39,7 @@ import type { VercelOperations, VercelOptions } from "./vercel.ts"; import type { WordPressOperations } from "./wordpress.ts"; export interface ProviderOperations { + appwrite: AppwriteOperations; astro: AstroOperations; "builder.io": BuilderOperations; bunny: BunnyOperations; @@ -67,6 +69,7 @@ export interface ProviderOperations { } export interface ProviderOptions { + appwrite: undefined; astro: AstroOptions; "builder.io": undefined; bunny: undefined; diff --git a/src/transform.ts b/src/transform.ts index 38d1c3c..4554fb5 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,4 +1,5 @@ import { getProviderForUrl } from "./detect.ts"; +import { transform as appwrite } from "./providers/appwrite.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"; @@ -37,6 +38,7 @@ import type { } from "./providers/types.ts"; const transformerMap: URLTransformerMap = { + appwrite, astro, "builder.io": builderio, bunny, diff --git a/src/types.ts b/src/types.ts index 0cd82e6..8c414f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,8 @@ export type ImageCdn = | "imagekit" | "uploadcare" | "supabase" - | "hygraph"; + | "hygraph" + | "appwrite"; export const SupportedProviders: Record = { astro: "Astro image service", @@ -87,6 +88,7 @@ export const SupportedProviders: Record = { uploadcare: "Uploadcare", vercel: "Vercel", wordpress: "WordPress", + appwrite: "Appwrite", } as const; export type OperationFormatter = (