-
Notifications
You must be signed in to change notification settings - Fork 331
feat(provider): add TruoCloud provider #2304
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| --- | ||
| title: TruoCloud | ||
| description: Nuxt Image has first class integration with TruoCloud. | ||
| links: | ||
| - label: Source | ||
| icon: i-simple-icons-github | ||
| to: https://github.com/nuxt/image/blob/main/src/runtime/providers/truocloud.ts | ||
| size: xs | ||
| --- | ||
|
|
||
| Integration between [TruoCloud](https://docs.truo.cloud/images) and the image module. | ||
|
|
||
| To use this provider, set `baseURL` to the delivery endpoint shown in your | ||
| console under **Images → Endpoint**. It ends in your tenant's public id, which | ||
| is not a secret: it appears in every image URL on your site. | ||
|
|
||
| ```ts [nuxt.config.ts] | ||
| export default defineNuxtConfig({ | ||
| image: { | ||
| truocloud: { | ||
| baseURL: 'https://img.truo.cloud/i/<pid>' | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| ## TruoCloud `fit` Values | ||
|
|
||
| TruoCloud supports all the [standard values for the `fit` property](/usage/nuxt-img#fit) of Nuxt image and Nuxt picture, and also accepts the imgix and | ||
| ImageKit vocabularies (`crop`, `clip`, `pad`, `scale`…), which it maps itself. | ||
|
|
||
| One difference worth knowing if you are migrating: `fit=fill` **stretches** the | ||
| image, following sharp's semantics rather than imgix's letterbox. Use | ||
| `fit=contain` for padding. | ||
|
|
||
| ## TruoCloud Modifiers | ||
|
|
||
| Beside the [standard modifiers](/usage/nuxt-img#modifiers), you can pass any | ||
| TruoCloud parameter through the `modifiers` prop — gravity, crops, blur, | ||
| filters and the rest. The full list is in the [TruoCloud image | ||
| documentation](https://docs.truo.cloud/images). | ||
|
|
||
| ## Choosing an output format | ||
|
|
||
| `format: 'auto'` picks avif or webp from the browser's `Accept` header and | ||
| answers `Vary: Accept`. That is correct HTTP, and it is also the fragile part: | ||
| `Accept` has very high cardinality, and some CDNs ignore `Vary` on images | ||
| unless you turn it on explicitly. | ||
|
|
||
| If your images sit behind a third-party CDN you did not configure, pin | ||
| `format: 'webp'` instead. A slightly larger file that is always the right one | ||
| beats an avif served to a browser that cannot decode it. | ||
|
|
||
| ```vue | ||
| <NuxtImg | ||
| provider="truocloud" | ||
| src="/photos/sea.jpg" | ||
| width="300" | ||
| height="500" | ||
| fit="cover" | ||
| :modifiers="{ format: 'auto', gravity: 'attention' }" | ||
| /> | ||
| ``` | ||
|
|
||
| That returns a 300 x 500 image, cropped towards the most interesting region of | ||
| the picture rather than its centre, in the best format the browser accepts. | ||
|
|
||
| ## Sizing the ladder | ||
|
|
||
| TruoCloud caches a transformation on its second identical request, so every | ||
| extra width in a responsive ladder costs two transformations before it starts | ||
| being served from cache. Five breakpoints cover the real range; the default | ||
| `screens` are worth trimming if you are on a free tier. | ||
|
|
||
| ```ts [nuxt.config.ts] | ||
| export default defineNuxtConfig({ | ||
| image: { | ||
| screens: { xs: 640, sm: 828, md: 1200, lg: 1600, xl: 2048 } | ||
| } | ||
| }) | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import { joinURL } from 'ufo' | ||
| import { createOperationsGenerator } from '../utils/index' | ||
| import { defineProvider } from '../utils/provider' | ||
|
|
||
| interface TruoCloudOptions { | ||
| /** The delivery endpoint, `https://img.truo.cloud/i/<pid>`. */ | ||
| baseURL?: string | ||
| } | ||
|
|
||
| /** | ||
| * Standard modifier name to the service's wire name. | ||
| * | ||
| * Declared separately so it can be reversed below: a source that is already a | ||
| * TruoCloud URL carries wire names (`w`), while modifiers arrive with standard | ||
| * names (`width`). Merging them without translating one side emits both, and | ||
| * `?w=200&w=800` means whichever the service reads first. | ||
| */ | ||
| const keyMap = { | ||
| width: 'w', | ||
| height: 'h', | ||
| format: 'f', | ||
| quality: 'q', | ||
| fit: 'fit', | ||
| dpr: 'dpr', | ||
| background: 'bg', | ||
| rotate: 'ro', | ||
| blur: 'blur', | ||
| sharpen: 'sharp', | ||
| brightness: 'bri', | ||
| contrast: 'con', | ||
| saturation: 'sat', | ||
| gamma: 'gam', | ||
| gravity: 'a', | ||
| crop: 'crop', | ||
| trim: 'trim', | ||
| mask: 'mask', | ||
| filter: 'filt', | ||
| withoutEnlargement: 'we', | ||
| lossless: 'll', | ||
| progressive: 'il', | ||
| frames: 'n', | ||
| } as const | ||
|
|
||
| const wireToStandard: Record<string, string> = Object.fromEntries( | ||
| Object.entries(keyMap).map(([standard, wire]) => [wire, standard]), | ||
| ) | ||
|
|
||
| export const operationsGenerator = createOperationsGenerator({ | ||
| keyMap, | ||
| valueMap: { | ||
| // The service answers `jpg`, and silently ignores a format it does not | ||
| // know: an unmapped `jpeg` would return the source format with a 200. | ||
| format: { | ||
| jpeg: 'jpg', | ||
| jpg: 'jpg', | ||
| png: 'png', | ||
| webp: 'webp', | ||
| avif: 'avif', | ||
| gif: 'gif', | ||
| tiff: 'tiff', | ||
| // Negotiates from the `Accept` header, answered with `Vary: Accept`. | ||
| auto: 'auto', | ||
| }, | ||
| }, | ||
| }) | ||
|
|
||
| /** | ||
| * Percent-encodes each path segment per RFC 3986. | ||
| * | ||
| * Not `encodeURI`, and not nothing: this path ends up inside a query parameter | ||
| * upstream, where a raw `+` means a space and the file is not found. The strict | ||
| * form also matches `rawurlencode`, which the CMS-side builders of this | ||
| * contract use, so the same file produces the same URL everywhere. | ||
| */ | ||
| function encodePath(path: string): string { | ||
| return path | ||
| .replace(/^\/+/, '') | ||
| .split('/') | ||
| .map(segment => | ||
| encodeURIComponent(segment).replace( | ||
| /[!'()*]/g, | ||
| c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, | ||
| ), | ||
| ) | ||
| .join('/') | ||
| } | ||
|
|
||
| /** | ||
| * Sorts parameters by name and restores literal commas. | ||
| * | ||
| * Every builder of this contract sorts them, and two orderings of one request | ||
| * are two CDN cache entries for the same image. The comma matters separately: | ||
| * the transformation engine does not decode `%2C`, so an escaped | ||
| * `crop=60,30,0,0` is ignored and the image comes back uncropped, with a 200. | ||
| */ | ||
| function canonicalise(query: string): string { | ||
| if (!query) { | ||
| return '' | ||
| } | ||
| return query | ||
| .split('&') | ||
| .sort((a, b) => (a.split('=')[0]! < b.split('=')[0]! ? -1 : 1)) | ||
| .join('&') | ||
| .replace(/%2C/g, ',') | ||
| } | ||
|
|
||
| /** | ||
| * Splits a source that is already a TruoCloud URL. | ||
| * | ||
| * Without this, a `src` that already points at the CDN is wrapped again into | ||
| * `/i/<pid>/https%3A//img.truo.cloud/i/<pid>/…` — a URL that works, costs twice | ||
| * and is unreadable in a bug report. It is the normal state of a partially | ||
| * migrated site. | ||
| */ | ||
| function unwrap(src: string, baseURL: string) { | ||
| const prefix = baseURL.replace(/\/+$/, '') | ||
| if (!src.toLowerCase().startsWith(`${prefix.toLowerCase()}/`)) { | ||
| return null | ||
| } | ||
| const rest = src.slice(prefix.length + 1) | ||
| const q = rest.indexOf('?') | ||
| if (q === -1) { | ||
| return { path: rest, carried: {} as Record<string, string> } | ||
| } | ||
|
|
||
| const carried: Record<string, string> = {} | ||
| for (const pair of rest.slice(q + 1).split('&')) { | ||
| const [k, v = ''] = pair.split('=') | ||
| // A signature covers one exact path and query and cannot be re-derived | ||
| // here, so carrying it over would produce a URL that 403s. | ||
| if (!k || k === 's' || k === 'exp') { | ||
| continue | ||
| } | ||
| const name = decodeURIComponent(k) | ||
| carried[wireToStandard[name] ?? name] = decodeURIComponent(v) | ||
| } | ||
| return { path: rest.slice(0, q), carried } | ||
| } | ||
|
|
||
| /** | ||
| * Booleans travel as `1`, and `false` drops the parameter. | ||
| * | ||
| * `createOperationsGenerator` stringifies `true` as `'true'`, which the service | ||
| * accepts — but the other builders of this contract emit `1`, and two spellings | ||
| * of one request are two cache entries and two different signatures. | ||
| */ | ||
| function normaliseModifiers(modifiers: Record<string, unknown> = {}) { | ||
| const out: Record<string, unknown> = {} | ||
| for (const [key, value] of Object.entries(modifiers)) { | ||
| if (value === false || value === null || value === undefined || value === '') { | ||
| continue | ||
| } | ||
| out[key] = value === true ? 1 : value | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| export default defineProvider<TruoCloudOptions>({ | ||
| getImage: (src, { modifiers, baseURL = 'https://img.truo.cloud' }) => { | ||
| const existing = unwrap(src, baseURL) | ||
| // The explicit call wins over what was glued to the URL: the caller asking | ||
| // now knows more than the markup did. | ||
| const merged = { ...(existing?.carried ?? {}), ...normaliseModifiers(modifiers) } | ||
| // An already-encoded path is reused verbatim; encoding it again would turn | ||
| // `%20` into `%2520`. | ||
| const path = existing ? existing.path : encodePath(src) | ||
|
Comment on lines
+164
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Preserve valid percent escapes for direct source paths. A direct source such as Preserve existing Proposed fix .map(segment =>
encodeURIComponent(segment).replace(
/[!'()*]/g,
c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
- ),
+ ).replace(/%25([0-9A-Fa-f]{2})/g, (_, hex: string) => `%${hex.toUpperCase()}`),
)🤖 Prompt for AI Agents |
||
| const query = canonicalise(operationsGenerator(merged)) | ||
|
|
||
| return { | ||
| url: joinURL(baseURL, path) + (query ? `?${query}` : ''), | ||
| } | ||
| }, | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Require a PID-qualified
baseURL.The default value produces
https://img.truo.cloud/<path>. It does not produce the required/i/<pid>/<path>endpoint. A caller that selects this provider without configuration gets an invalid delivery URL.Require
baseURLat runtime, or provide a valid PID-qualified default.Proposed fix
export default defineProvider<TruoCloudOptions>({ - getImage: (src, { modifiers, baseURL = 'https://img.truo.cloud' }) => { + getImage: (src, { modifiers, baseURL }) => { + if (!baseURL) { + throw new Error('The TruoCloud provider requires a baseURL such as https://img.truo.cloud/i/<pid>.') + } const existing = unwrap(src, baseURL)📝 Committable suggestion
🤖 Prompt for AI Agents