Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/content/3.providers/truocloud.md
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 }
}
})
```
1 change: 1 addition & 0 deletions docs/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ orientation: horizontal
- shopify
- storyblok
- strapi
- truocloud
- twicpics
- unsplash
- uploadcare
Expand Down
18 changes: 18 additions & 0 deletions playground/app/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ interface Provider {
}

export const providers: Provider[] = [
{
name: 'truocloud',
samples: [
{
src: '/wikipedia/commons/3/3f/Fronalpstock_big.jpg',
width: 400,
height: 300,
fit: 'cover',
},
{
src: '/wikipedia/commons/3/3f/Fronalpstock_big.jpg',
width: 400,
height: 300,
format: 'auto',
},
],
},

// null provider (for non-node environments)
{
name: 'none',
Expand Down
3 changes: 3 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export default defineNuxtConfig({
imgix: {
baseURL: 'https://assets.imgix.net',
},
truocloud: {
baseURL: 'https://img.truo.cloud/i/demo',
},
imgproxy: {
baseURL: 'http://localhost:8080',
key: 'ee3b0e07dfc9ec20d5d9588a558753547a8a88c48291ae96171330daf4ce2800',
Expand Down
1 change: 1 addition & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const BuiltInProviders = [
'strapi',
'strapi5',
'supabase',
'truocloud',
'twicpics',
'umbraco',
'unsplash',
Expand Down
173 changes: 173 additions & 0 deletions src/runtime/providers/truocloud.ts
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)
Comment on lines +158 to +160

Copy link
Copy Markdown

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 baseURL at 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default defineProvider<TruoCloudOptions>({
getImage: (src, { modifiers, baseURL = 'https://img.truo.cloud' }) => {
const existing = unwrap(src, baseURL)
export default defineProvider<TruoCloudOptions>({
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)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/providers/truocloud.ts` around lines 158 - 160, Update the
getImage baseURL handling in the TruoCloud provider to require a PID-qualified
baseURL at runtime, or replace the current default with a valid /i/<pid>
endpoint. Ensure generated URLs always target the required PID-qualified
delivery path when callers omit configuration.

// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 /assets/a%20b.png does not enter existing. encodePath changes it to assets/a%2520b.png. The generated URL then requests a different asset.

Preserve existing %HH escapes when encoding direct source segments. Add a regression fixture for an encoded path.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/providers/truocloud.ts` around lines 164 - 166, Update the
direct-source path handling around the existing `path` assignment and
`encodePath` so valid `%HH` percent escapes are preserved instead of being
encoded again, while still encoding unescaped path characters and retaining the
verbatim `existing.path` behavior. Add a regression fixture covering a source
path such as `/assets/a%20b.png` and verify the generated URL references the
original encoded asset.

const query = canonicalise(operationsGenerator(merged))

return {
url: joinURL(baseURL, path) + (query ? `?${query}` : ''),
}
},
})
22 changes: 22 additions & 0 deletions test/nuxt/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import storyblok from '../../dist/runtime/providers/storyblok'
import strapi from '../../dist/runtime/providers/strapi'
import strapi5 from '../../dist/runtime/providers/strapi5'
import supabase from '../../dist/runtime/providers/supabase'
import truocloud from '../../dist/runtime/providers/truocloud'
import edgeonePages from '../../dist/runtime/providers/edgeonePages'
import twicpics from '../../dist/runtime/providers/twicpics'
import umbraco from '../../dist/runtime/providers/umbraco'
Expand Down Expand Up @@ -88,6 +89,27 @@ describe('Providers', () => {
url: '/_ipx/_/images/test.png',
})
})
it('truocloud', () => {
const providerOptions = {
baseURL: 'https://img.truo.cloud/i/demo',
}
for (const image of images) {
const [src, modifiers] = image.args
const generated = truocloud().getImage(src, { modifiers, ...providerOptions }, getEmptyContext())
expect(generated).toMatchObject(image.truocloud)
}
})

it('truocloud does not wrap a source that is already one of its own URLs', () => {
// Nuxt re-runs the provider over whatever `src` it is given, and a
// partially migrated site hands it URLs that already point at the CDN.
// Wrapping twice produces a URL that works and costs twice.
const providerOptions = { baseURL: 'https://img.truo.cloud/i/demo' }
const once = truocloud().getImage('/test.png', { modifiers: { width: 200 }, ...providerOptions }, getEmptyContext())
const twice = truocloud().getImage(once.url, { modifiers: { width: 400 }, ...providerOptions }, getEmptyContext())
expect(twice).toMatchObject({ url: 'https://img.truo.cloud/i/demo/test.png?w=400' })
})

it('aliyun', () => {
const providerOptions = {
baseURL: '/',
Expand Down
6 changes: 6 additions & 0 deletions test/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png' },
storyblok: { url: 'https://a.storyblok.com/test.png' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=1536&q=100' },
wagtail: { url: '329944/original|format-webp|webpquality-70' },
Expand Down Expand Up @@ -77,6 +78,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200' },
storyblok: { url: 'https://a.storyblok.com/test.png/m/200x0' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?w=200' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' },
wagtail: { url: '329944/width-200|format-webp|webpquality-70' },
Expand Down Expand Up @@ -123,6 +125,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?height=200' },
storyblok: { url: 'https://a.storyblok.com/test.png/m/0x200' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?height=200' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?h=200' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/x200' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=1536&q=100' },
wagtail: { url: '329944/height-200|format-webp|webpquality-70' },
Expand Down Expand Up @@ -169,6 +172,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200' },
storyblok: { url: 'https://a.storyblok.com/test.png/m/200x200' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?h=200&w=200' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' },
wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
Expand Down Expand Up @@ -215,6 +219,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200&func=fit' },
storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200&resize=contain' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?fit=contain&h=200&w=200' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' },
wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
Expand Down Expand Up @@ -261,6 +266,7 @@ export const images = [
cloudimage: { url: 'https://demo.cloudimg.io/v7/_sl_/test.png?width=200&height=200&func=fit&force_format=jpeg' },
storyblok: { url: 'https://a.storyblok.com/test.png/m/fit-contain/200x200/filters:format(jpeg)' },
supabase: { url: 'https://ovzjdhllnxrizgszqlsi.supabase.co/storage/v1/render/image/public/nuxt/test.png?width=200&height=200&resize=contain&format=jpeg' },
truocloud: { url: 'https://img.truo.cloud/i/demo/test.png?f=jpg&fit=contain&h=200&w=200' },
edgeonePages: { url: 'https://nuxt-mix-template.edgeone.site/test.png?imageMogr2/thumbnail/200x200/format/jpg' },
vercel: { url: '/_vercel/image?url=%2Ftest.png&w=640&q=100' },
wagtail: { url: '329944/fill-200x200-c0|format-jpeg|jpegquality-70' },
Expand Down
Loading