From ced843368b21137f6e9cdc42ba6d67f2d20d19a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Sun, 4 Oct 2026 21:14:49 +0000 Subject: [PATCH 1/2] feat(dynjandi): add provider and tests Adds extract, generate and transform for Dynjandi image URLs (https://cdn.dynjandi.dev). Supports resize, crop, format and quality, and drops anything the service would reject. Wire-up follows separately. --- src/providers/dynjandi.test.ts | 405 +++++++++++++++++++++++++++++++++ src/providers/dynjandi.ts | 265 +++++++++++++++++++++ 2 files changed, 670 insertions(+) create mode 100644 src/providers/dynjandi.test.ts create mode 100644 src/providers/dynjandi.ts diff --git a/src/providers/dynjandi.test.ts b/src/providers/dynjandi.test.ts new file mode 100644 index 0000000..03644d7 --- /dev/null +++ b/src/providers/dynjandi.test.ts @@ -0,0 +1,405 @@ +import { assertEquals } from "jsr:@std/assert"; +import { extract, generate, transform } from "./dynjandi.ts"; + +const id = "85c5cb7a-9c24-4673-b40d-8214a789e0a2"; +const baseImageUrl = `https://cdn.dynjandi.dev/${id}/`; + +Deno.test("Dynjandi provider - extract", async (t) => { + await t.step("should extract a basic URL", () => { + assertEquals(extract(baseImageUrl), { src: baseImageUrl, operations: {} }); + }); + + await t.step("should extract a URL without a trailing slash", () => { + assertEquals(extract(`https://cdn.dynjandi.dev/${id}`), { + src: baseImageUrl, + operations: {}, + }); + }); + + await t.step("should extract resize into width and height", () => { + assertEquals(extract(`${baseImageUrl}-/resize/800x600/`)?.operations, { + width: 800, + height: 600, + }); + assertEquals(extract(`${baseImageUrl}-/resize/800x/`)?.operations, { + width: 800, + }); + assertEquals(extract(`${baseImageUrl}-/resize/x600/`)?.operations, { + height: 600, + }); + }); + + await t.step("should extract format and read jpg as jpeg", () => { + assertEquals(extract(`${baseImageUrl}-/format/webp/`)?.operations, { + format: "webp", + }); + assertEquals(extract(`${baseImageUrl}-/format/jpg/`)?.operations, { + format: "jpeg", + }); + }); + + await t.step("should extract numeric and named quality", () => { + assertEquals(extract(`${baseImageUrl}-/quality/80/`)?.operations, { + quality: 80, + }); + assertEquals(extract(`${baseImageUrl}-/quality/best/`)?.operations, { + quality: "best", + }); + }); + + await t.step("should extract crop and read scale_crop as crop", () => { + assertEquals( + extract(`${baseImageUrl}-/crop/1280x400/focal/0.42x0.18/`)?.operations, + { crop: "1280x400/focal/0.42x0.18" }, + ); + assertEquals( + extract(`${baseImageUrl}-/crop/600x600/face,attention/`)?.operations, + { crop: "600x600/face,attention" }, + ); + assertEquals( + extract(`${baseImageUrl}-/scale_crop/400x400/`)?.operations, + { crop: "400x400/center" }, + ); + assertEquals( + extract(`${baseImageUrl}-/scale_crop/400x400/north/`)?.operations, + { crop: "400x400/north" }, + ); + assertEquals( + extract(`${baseImageUrl}-/scale_crop/400x400/focal/0.5x0.5/`) + ?.operations, + { crop: "400x400/focal/0.5x0.5" }, + ); + }); + + await t.step( + "should read scale_crop before a trailing filename as centred", + () => { + assertEquals( + extract(`${baseImageUrl}-/scale_crop/100x100/photo.jpg`)?.operations, + { crop: "100x100/center" }, + ); + }, + ); + + await t.step("should extract a chain of operations", () => { + assertEquals( + extract( + `${baseImageUrl}-/crop/400x400/center/-/resize/200x/-/format/avif/-/quality/lighter/`, + )?.operations, + { + crop: "400x400/center", + width: 200, + format: "avif", + quality: "lighter", + }, + ); + }); + + await t.step("should ignore a trailing filename and empty segments", () => { + assertEquals(extract(`${baseImageUrl}photo.jpg`), { + src: baseImageUrl, + operations: {}, + }); + assertEquals( + extract(`${baseImageUrl}-/-/resize/800x//-/format/png/photo.jpg`), + { + src: baseImageUrl, + operations: { width: 800, format: "png" }, + }, + ); + }); + + await t.step("should ignore unknown and invalid operations", () => { + assertEquals( + extract( + `${baseImageUrl}-/rotate/90/-/format/auto/-/resize/0x20000/-/quality/101/-/crop/400x400/nowhere/`, + )?.operations, + {}, + ); + }); + + await t.step("should keep a relative src relative", () => { + assertEquals(extract(`/${id}/-/resize/800x/`), { + src: `/${id}/`, + operations: { width: 800 }, + }); + }); + + await t.step("should return null for a URL that is not a file", () => { + assertEquals(extract("https://cdn.dynjandi.dev/"), null); + assertEquals(extract("https://cdn.dynjandi.dev"), null); + assertEquals(extract("http://"), null); + }); +}); + +Deno.test("Dynjandi provider - generate", async (t) => { + await t.step( + "should return the file URL when there are no operations", + () => { + assertEquals(generate(baseImageUrl, {}), baseImageUrl); + }, + ); + + await t.step("should generate a resize", () => { + assertEquals( + generate(baseImageUrl, { width: 800, height: 600 }), + `${baseImageUrl}-/resize/800x600/`, + ); + assertEquals( + generate(baseImageUrl, { width: 800 }), + `${baseImageUrl}-/resize/800x/`, + ); + assertEquals( + generate(baseImageUrl, { height: 600 }), + `${baseImageUrl}-/resize/x600/`, + ); + }); + + await t.step("should not default the format", () => { + assertEquals( + generate(baseImageUrl, { width: 800 }).includes("format"), + false, + ); + }); + + await t.step("should generate format, mapping jpg to jpeg", () => { + assertEquals( + generate(baseImageUrl, { format: "webp" }), + `${baseImageUrl}-/format/webp/`, + ); + assertEquals( + generate(baseImageUrl, { format: "jpg" }), + `${baseImageUrl}-/format/jpeg/`, + ); + }); + + await t.step("should drop a format the service does not accept", () => { + assertEquals(generate(baseImageUrl, { format: "auto" }), baseImageUrl); + assertEquals(generate(baseImageUrl, { format: "gif" }), baseImageUrl); + assertEquals( + generate(baseImageUrl, { width: 800, format: "auto" }), + `${baseImageUrl}-/resize/800x/`, + ); + }); + + await t.step("should generate numeric and named quality", () => { + assertEquals( + generate(baseImageUrl, { quality: 80 }), + `${baseImageUrl}-/quality/80/`, + ); + assertEquals( + generate(baseImageUrl, { quality: "best" }), + `${baseImageUrl}-/quality/best/`, + ); + assertEquals( + generate(baseImageUrl, { quality: "75" }), + `${baseImageUrl}-/quality/75/`, + ); + }); + + await t.step("should drop a quality that is not valid", () => { + for (const quality of [0, 101, 80.5, "ultra", "-1", "80/-/format/png"]) { + assertEquals(generate(baseImageUrl, { quality }), baseImageUrl); + } + }); + + await t.step("should generate a crop", () => { + assertEquals( + generate(baseImageUrl, { crop: "400x400/center" }), + `${baseImageUrl}-/crop/400x400/center/`, + ); + assertEquals( + generate(baseImageUrl, { crop: "1280x400/focal/0.42x0.18" }), + `${baseImageUrl}-/crop/1280x400/focal/0.42x0.18/`, + ); + assertEquals( + generate(baseImageUrl, { crop: "600x600/face,attention" }), + `${baseImageUrl}-/crop/600x600/face,attention/`, + ); + }); + + await t.step("should drop a crop that would not parse", () => { + for ( + const crop of [ + "400x400", + "400x/center", + "0x400/center", + "10001x400/center", + "400x400/nowhere", + "400x400/focal/2x0.5", + "400x400/center/-/format/png", + "400x400/face,face", + ] + ) { + assertEquals(generate(baseImageUrl, { crop }), baseImageUrl); + } + }); + + await t.step( + "should emit operations in the order crop, resize, format, quality", + () => { + assertEquals( + generate(baseImageUrl, { + quality: 80, + format: "avif", + height: 300, + width: 400, + crop: "1280x400/center", + }), + `${baseImageUrl}-/crop/1280x400/center/-/resize/400x300/-/format/avif/-/quality/80/`, + ); + }, + ); + + await t.step( + "should drop sizes outside 1 to 10000 or not whole numbers", + () => { + for (const width of [0, -5, 10001, 800.5, NaN, Infinity]) { + assertEquals(generate(baseImageUrl, { width }), baseImageUrl); + } + assertEquals( + generate(baseImageUrl, { width: 10000, height: 1 }), + `${baseImageUrl}-/resize/10000x1/`, + ); + assertEquals( + generate(baseImageUrl, { width: 800, height: 0 }), + `${baseImageUrl}-/resize/800x/`, + ); + }, + ); + + await t.step("should accept a size given as a string of digits", () => { + assertEquals( + generate(baseImageUrl, { width: "800", height: "600" }), + `${baseImageUrl}-/resize/800x600/`, + ); + }); + + await t.step("should not let a string size inject another operation", () => { + assertEquals( + generate(baseImageUrl, { width: "800/-/format/png" }), + baseImageUrl, + ); + assertEquals( + generate(baseImageUrl, { width: "800/-/format/png", height: 600 }), + `${baseImageUrl}-/resize/x600/`, + ); + assertEquals(generate(baseImageUrl, { width: "../x" }), baseImageUrl); + assertEquals(generate(baseImageUrl, { width: " 800" }), baseImageUrl); + assertEquals(generate(baseImageUrl, { width: "1e3" }), baseImageUrl); + }); + + await t.step( + "should replace existing operations and a trailing filename", + () => { + assertEquals( + generate(`${baseImageUrl}-/resize/100x/-/format/png/photo.jpg`, { + width: 800, + }), + `${baseImageUrl}-/resize/800x/`, + ); + assertEquals( + generate(`${baseImageUrl}photo.jpg`, { width: 800 }), + `${baseImageUrl}-/resize/800x/`, + ); + }, + ); + + await t.step("should keep a relative src relative", () => { + assertEquals( + generate(`/${id}/`, { width: 800 }), + `/${id}/-/resize/800x/`, + ); + }); + + await t.step("should accept a URL object", () => { + assertEquals( + generate(new URL(baseImageUrl), { width: 800 }), + `${baseImageUrl}-/resize/800x/`, + ); + }); + + await t.step("should return a URL that is not a file unchanged", () => { + assertEquals( + generate("https://cdn.dynjandi.dev/", { width: 800 }), + "https://cdn.dynjandi.dev/", + ); + }); +}); + +Deno.test("Dynjandi provider - transform", async (t) => { + await t.step("should transform a basic URL", () => { + assertEquals( + transform(baseImageUrl, { width: 800, height: 600 }), + `${baseImageUrl}-/resize/800x600/`, + ); + }); + + await t.step("should keep the existing operations it is not given", () => { + assertEquals( + transform(`${baseImageUrl}-/resize/500x300/-/quality/best/`, { + width: 800, + format: "webp", + }), + `${baseImageUrl}-/resize/800x300/-/format/webp/-/quality/best/`, + ); + }); + + await t.step( + "should not let an undefined operation erase an existing one", + () => { + assertEquals( + transform(`${baseImageUrl}-/resize/500x300/`, { + width: 800, + height: undefined, + }), + `${baseImageUrl}-/resize/800x300/`, + ); + }, + ); + + await t.step("should keep a crop already in the URL", () => { + assertEquals( + transform(`${baseImageUrl}-/crop/1280x400/focal/0.42x0.18/`, { + width: 640, + }), + `${baseImageUrl}-/crop/1280x400/focal/0.42x0.18/-/resize/640x/`, + ); + assertEquals( + transform(`${baseImageUrl}-/scale_crop/400x400/`, { width: 200 }), + `${baseImageUrl}-/crop/400x400/center/-/resize/200x/`, + ); + }); + + await t.step( + "should keep a scale_crop that precedes a trailing filename", + () => { + assertEquals( + transform(`${baseImageUrl}-/scale_crop/100x100/photo.jpg`, { + width: 50, + }), + `${baseImageUrl}-/crop/100x100/center/-/resize/50x/`, + ); + }, + ); + + await t.step("should reorder the chain into the canonical order", () => { + assertEquals( + transform( + `${baseImageUrl}-/quality/80/-/resize/100x/-/crop/50x50/north/`, + {}, + ), + `${baseImageUrl}-/crop/50x50/north/-/resize/100x/-/quality/80/`, + ); + }); + + await t.step( + "should fall back to generate for a URL that is not a file", + () => { + assertEquals( + transform("https://cdn.dynjandi.dev/", { width: 800 }), + "https://cdn.dynjandi.dev/", + ); + }, + ); +}); diff --git a/src/providers/dynjandi.ts b/src/providers/dynjandi.ts new file mode 100644 index 0000000..50bc3d3 --- /dev/null +++ b/src/providers/dynjandi.ts @@ -0,0 +1,265 @@ +/** + * Dynjandi image provider. + * + * Variant URLs look like `https:////-/resize/800x600/-/format/webp/`. The grammar is + * specified at + * https://github.com/dynjandi-dev/dynjandi/blob/main/packages/spec/openapi.yaml ("Variant URL + * grammar"). Operations apply in the order given, so `generate` always emits crop, resize, format, + * quality. + * + * Supported: width and height (`resize`, either side optional), `format` (`jpeg`, `png`, `webp`, + * `avif`; `jpg` is read as `jpeg`), `quality` (1 to 100 or a named level) and a provider-specific + * `crop`. Anything else is dropped rather than sent, because the service answers an unknown + * operation or value with 400. + * + * `format` is never defaulted: `auto` is not a Dynjandi format and a URL carrying it is a 400, and + * the service does not negotiate a format from `Accept`, so without a `format` the original is served. + * + * Departs from CONTRIBUTING.md "Size Handling" ("Never upscale images beyond their original + * dimensions"): the service upscales with both `resize` and `crop` (measured against the live + * service: a 400x200 source gives 800x400 for `resize/800x` and 800x800 for `resize/800x800/`), the + * grammar has no operation that prevents it, and a provider only sees the URL, not the source size. + * Callers that need a cap must pass a width no larger than the original. + * + * Two-sided sizes cover, as "Image Fitting" asks: `resize/WxH` scales to fill the box and + * centre-crops, so no `crop` is emitted for `width` plus `height`. + */ +import type { Operations } from "../types.ts"; +import { toCanonicalUrlString, toUrl } from "../utils.ts"; + +export interface DynjandiOperations extends Operations { + /** + * Grammar text for a crop: `x/`, for example `400x400/center` or + * `1280x400/focal/0.42x0.18`. Applied before resize. An invalid value is dropped. + */ + crop?: string; +} + +const MAX_DIMENSION = 10000; +const FORMATS = ["jpeg", "png", "webp", "avif"]; +const QUALITY_LEVELS = [ + "lightest", + "lighter", + "normal", + "smart", + "better", + "best", +]; +const POSITIONS = [ + "center", + "north", + "south", + "east", + "west", + "northeast", + "northwest", + "southeast", + "southwest", + "attention", + "entropy", +]; +// Both patterns are anchored and have no nested quantifiers, so matching is linear. +const SIZE_PATTERN = /^(\d{1,5})x(\d{1,5})$/; +const RESIZE_PATTERN = /^(\d{0,5})x(\d{0,5})$/; +const FOCAL_PATTERN = /^(?:0(?:\.\d+)?|1(?:\.0+)?)x(?:0(?:\.\d+)?|1(?:\.0+)?)$/; +const INTEGER_PATTERN = /^\d{1,5}$/; + +/** An integer from 1 to `max`, from a number or a string of digits; anything else is `undefined`. */ +function toInteger(value: unknown, max: number): number | undefined { + const number = typeof value === "string" && INTEGER_PATTERN.test(value) + ? Number(value) + : value; + return typeof number === "number" && Number.isInteger(number) && + number >= 1 && number <= max + ? number + : undefined; +} + +function toDimension(value: unknown): number | undefined { + return toInteger(value, MAX_DIMENSION); +} + +function toFormat(value: unknown): string | undefined { + const format = value === "jpg" ? "jpeg" : value; + return typeof format === "string" && FORMATS.includes(format) + ? format + : undefined; +} + +function toQuality(value: unknown): number | string | undefined { + if (typeof value === "string" && QUALITY_LEVELS.includes(value)) { + return value; + } + return toInteger(value, 100); +} + +function isPosition(value: string): boolean { + if (POSITIONS.includes(value) || value === "face" || value === "smart") { + return true; + } + return value.startsWith("face,") && POSITIONS.includes(value.slice(5)); +} + +/** A crop in the grammar's `x/` form, or `undefined` if it would not parse. */ +function toCrop(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const crop = value.endsWith("/") ? value.slice(0, -1) : value; + const slash = crop.indexOf("/"); + if (slash < 0) { + return undefined; + } + const size = SIZE_PATTERN.exec(crop.slice(0, slash)); + if (!size || !toDimension(size[1]) || !toDimension(size[2])) { + return undefined; + } + const position = crop.slice(slash + 1); + if (position.startsWith("focal/")) { + return FOCAL_PATTERN.test(position.slice(6)) ? crop : undefined; + } + return isPosition(position) ? crop : undefined; +} + +interface ParsedPath { + /** The URL object, relative URLs resolved against a placeholder host by `toUrl`. */ + url: URL; + /** The file id: the first path segment. */ + id: string; + /** The operation groups after the first `-` segment, each as `[name, ...arguments]`. */ + groups: string[][]; +} + +/** Splits a variant URL into its file id and operation groups, or `null` when it has no file id. */ +function parsePath(src: string | URL): ParsedPath | null { + let url: URL; + try { + url = toUrl(src); + } catch { + return null; + } + const [id, ...rest] = url.pathname.split("/").filter(Boolean); + if (id === undefined) { + return null; + } + // Anything before the first `-` is a filename, not an operation. + const firstDash = rest.indexOf("-"); + const groups: string[][] = []; + let group: string[] | undefined; + for (const segment of firstDash < 0 ? [] : rest.slice(firstDash)) { + if (segment === "-") { + group = undefined; + } else if (group) { + group.push(segment); + } else { + group = [segment]; + groups.push(group); + } + } + return { url, id, groups }; +} + +function extractGroup( + operations: DynjandiOperations, + [name, ...args]: string[], +) { + switch (name) { + case "resize": { + const match = RESIZE_PATTERN.exec(args[0] ?? ""); + const width = toDimension(match?.[1]); + const height = toDimension(match?.[2]); + if (width) operations.width = width; + if (height) operations.height = height; + return; + } + case "format": { + const format = toFormat(args[0]); + if (format) operations.format = format; + return; + } + case "quality": { + const quality = toQuality(args[0]); + if (quality) operations.quality = quality; + return; + } + case "crop": + case "scale_crop": { + // `scale_crop` uses a position only when it is one, else it is `center` and what follows + // is a trailing filename. `crop` requires a position, so an invalid one drops the crop. + const [size, ...position] = args; + const usesPosition = name === "crop" || position[0] === "focal" || + (position[0] !== undefined && isPosition(position[0])); + const text = usesPosition + ? `${size}/${ + position[0] === "focal" ? position.slice(0, 2).join("/") : position[0] + }` + : `${size}/center`; + const crop = toCrop(text); + if (crop) operations.crop = crop; + return; + } + } +} + +export function extract( + url: string | URL, +): { src: string; operations: DynjandiOperations } | null { + const parsed = parsePath(url); + if (!parsed) { + return null; + } + const operations: DynjandiOperations = {}; + for (const group of parsed.groups) { + extractGroup(operations, group); + } + return { src: srcOf(parsed), operations }; +} + +function srcOf({ url, id }: ParsedPath): string { + const src = new URL(url); + src.pathname = `/${id}/`; + return toCanonicalUrlString(src); +} + +export function generate( + src: string | URL, + operations: DynjandiOperations, +): string { + const parsed = parsePath(src); + if (!parsed) { + return toCanonicalUrlString(toUrl(src)); + } + const chain: string[] = []; + const crop = toCrop(operations.crop); + if (crop) chain.push(`crop/${crop}/`); + const width = toDimension(operations.width); + const height = toDimension(operations.height); + if (width || height) chain.push(`resize/${width ?? ""}x${height ?? ""}/`); + const format = toFormat(operations.format); + if (format) chain.push(`format/${format}/`); + const quality = toQuality(operations.quality); + if (quality) chain.push(`quality/${quality}/`); + + const result = new URL(parsed.url); + result.pathname = `/${parsed.id}/${chain.map((op) => `-/${op}`).join("")}`; + return toCanonicalUrlString(result); +} + +/** + * Same behaviour as `createExtractAndGenerate(extract, generate)`: keep the operations already in the + * URL, let defined ones in `operations` override them. Written out because that helper is typed by + * `ImageCdn`, which does not list `dynjandi` until the provider is wired in. + */ +export function transform( + src: string | URL, + operations: DynjandiOperations, +): string { + const base = extract(src); + if (!base) { + return generate(src, operations); + } + const defined: DynjandiOperations = Object.fromEntries( + Object.entries(operations).filter(([, value]) => value !== undefined), + ); + return generate(base.src, { ...base.operations, ...defined }); +} From 1ac8d20f65b6a30cdef81385fc0628ff4fd2d035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Sun, 4 Oct 2026 21:29:39 +0000 Subject: [PATCH 2/2] feat(dynjandi): wire up provider, detection and example --- .changeset/calm-falls-resize.md | 5 ++++ README.md | 1 + data/domains.json | 1 + demo/src/examples.json | 4 +++ deno.jsonc | 1 + src/async.ts | 1 + src/extract.ts | 2 ++ src/providers/dynjandi.ts | 48 +++++++++++++-------------------- src/providers/types.ts | 3 +++ src/transform.ts | 2 ++ src/types.ts | 4 ++- 11 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 .changeset/calm-falls-resize.md diff --git a/.changeset/calm-falls-resize.md b/.changeset/calm-falls-resize.md new file mode 100644 index 0000000..2327209 --- /dev/null +++ b/.changeset/calm-falls-resize.md @@ -0,0 +1,5 @@ +--- +"unpic": minor +--- + +feat(dynjandi): add Dynjandi provider with resize, format, quality, crop diff --git a/README.md b/README.md index c90f44a..274d491 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ const url = transform( - Contentful `contentful` - Contentstack `contentstack` - Directus `directus` +- Dynjandi `dynjandi` - Hygraph `hygraph` - ImageEngine `imageengine` - ImageKit `imagekit` diff --git a/data/domains.json b/data/domains.json index f13205f..897e215 100644 --- a/data/domains.json +++ b/data/domains.json @@ -1,4 +1,5 @@ { + "cdn.dynjandi.dev": "dynjandi", "images.ctfassets.net": "contentful", "cdn.builder.io": "builder.io", "images.prismic.io": "imgix", diff --git a/demo/src/examples.json b/demo/src/examples.json index bffd3d8..6c8e072 100644 --- a/demo/src/examples.json +++ b/demo/src/examples.json @@ -1,4 +1,8 @@ { + "dynjandi": [ + "Dynjandi", + "https://cdn.dynjandi.dev/556eef1b-9ce4-4c64-b7a2-fa723772334e" + ], "shopify": [ "Shopify", "https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg" diff --git a/deno.jsonc b/deno.jsonc index 88c341a..5bc2405 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -16,6 +16,7 @@ "./providers/contentful": "./src/providers/contentful.ts", "./providers/contentstack": "./src/providers/contentstack.ts", "./providers/directus": "./src/providers/directus.ts", + "./providers/dynjandi": "./src/providers/dynjandi.ts", "./providers/imageengine": "./src/providers/imageengine.ts", "./providers/imagekit": "./src/providers/keycdn.ts", "./providers/imgix": "./src/providers/imgix.ts", diff --git a/src/async.ts b/src/async.ts index bb77294..959c026 100644 --- a/src/async.ts +++ b/src/async.ts @@ -24,6 +24,7 @@ const asyncProviderMap: AsyncProviderMap = { contentful: () => import("./providers/contentful.ts"), contentstack: () => import("./providers/contentstack.ts"), directus: () => import("./providers/directus.ts"), + dynjandi: () => import("./providers/dynjandi.ts"), hygraph: () => import("./providers/hygraph.ts"), imageengine: () => import("./providers/imageengine.ts"), imagekit: () => import("./providers/imagekit.ts"), diff --git a/src/extract.ts b/src/extract.ts index 2ec907a..4f55b7d 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -17,6 +17,7 @@ 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 dynjandi } from "./providers/dynjandi.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"; @@ -47,6 +48,7 @@ export const parsers: URLExtractorMap = { contentful, contentstack, directus, + dynjandi, hygraph, imageengine, imagekit, diff --git a/src/providers/dynjandi.ts b/src/providers/dynjandi.ts index 50bc3d3..2d403f2 100644 --- a/src/providers/dynjandi.ts +++ b/src/providers/dynjandi.ts @@ -24,8 +24,17 @@ * Two-sided sizes cover, as "Image Fitting" asks: `resize/WxH` scales to fill the box and * centre-crops, so no `crop` is emitted for `width` plus `height`. */ -import type { Operations } from "../types.ts"; -import { toCanonicalUrlString, toUrl } from "../utils.ts"; +import type { + Operations, + URLExtractor, + URLGenerator, + URLTransformer, +} from "../types.ts"; +import { + createExtractAndGenerate, + toCanonicalUrlString, + toUrl, +} from "../utils.ts"; export interface DynjandiOperations extends Operations { /** @@ -201,9 +210,7 @@ function extractGroup( } } -export function extract( - url: string | URL, -): { src: string; operations: DynjandiOperations } | null { +export const extract: URLExtractor<"dynjandi"> = (url) => { const parsed = parsePath(url); if (!parsed) { return null; @@ -213,7 +220,7 @@ export function extract( extractGroup(operations, group); } return { src: srcOf(parsed), operations }; -} +}; function srcOf({ url, id }: ParsedPath): string { const src = new URL(url); @@ -221,10 +228,7 @@ function srcOf({ url, id }: ParsedPath): string { return toCanonicalUrlString(src); } -export function generate( - src: string | URL, - operations: DynjandiOperations, -): string { +export const generate: URLGenerator<"dynjandi"> = (src, operations) => { const parsed = parsePath(src); if (!parsed) { return toCanonicalUrlString(toUrl(src)); @@ -243,23 +247,9 @@ export function generate( const result = new URL(parsed.url); result.pathname = `/${parsed.id}/${chain.map((op) => `-/${op}`).join("")}`; return toCanonicalUrlString(result); -} +}; -/** - * Same behaviour as `createExtractAndGenerate(extract, generate)`: keep the operations already in the - * URL, let defined ones in `operations` override them. Written out because that helper is typed by - * `ImageCdn`, which does not list `dynjandi` until the provider is wired in. - */ -export function transform( - src: string | URL, - operations: DynjandiOperations, -): string { - const base = extract(src); - if (!base) { - return generate(src, operations); - } - const defined: DynjandiOperations = Object.fromEntries( - Object.entries(operations).filter(([, value]) => value !== undefined), - ); - return generate(base.src, { ...base.operations, ...defined }); -} +export const transform: URLTransformer<"dynjandi"> = createExtractAndGenerate( + extract, + generate, +); diff --git a/src/providers/types.ts b/src/providers/types.ts index 3166818..2453100 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -21,6 +21,7 @@ import type { ContentstackOptions, } from "./contentstack.ts"; import type { DirectusOperations } from "./directus.ts"; +import type { DynjandiOperations } from "./dynjandi.ts"; import type { HygraphOperations, HygraphOptions } from "./hygraph.ts"; import type { ImageEngineOperations } from "./imageengine.ts"; import type { ImageKitOperations } from "./imagekit.ts"; @@ -51,6 +52,7 @@ export interface ProviderOperations { contentful: ContentfulOperations; contentstack: ContentstackOperations; directus: DirectusOperations; + dynjandi: DynjandiOperations; hygraph: HygraphOperations; imageengine: ImageEngineOperations; imagekit: ImageKitOperations; @@ -82,6 +84,7 @@ export interface ProviderOptions { contentful: undefined; contentstack: ContentstackOptions; directus: undefined; + dynjandi: undefined; hygraph: HygraphOptions; imageengine: undefined; imagekit: undefined; diff --git a/src/transform.ts b/src/transform.ts index d9bc03b..90dea49 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -10,6 +10,7 @@ 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 dynjandi } from "./providers/dynjandi.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"; @@ -50,6 +51,7 @@ const transformerMap: URLTransformerMap = { contentful, contentstack, directus, + dynjandi, hygraph, imageengine, imagekit, diff --git a/src/types.ts b/src/types.ts index 3492606..236a887 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,7 +60,8 @@ export type ImageCdn = | "supabase" | "hygraph" | "appwrite" - | "wsrv"; + | "wsrv" + | "dynjandi"; export const SupportedProviders: Record = { appwrite: "Appwrite", @@ -74,6 +75,7 @@ export const SupportedProviders: Record = { contentful: "Contentful", contentstack: "Contentstack", directus: "Directus", + dynjandi: "Dynjandi", hygraph: "Hygraph", imageengine: "ImageEngine", imagekit: "ImageKit",