diff --git a/cli.js b/cli.js
index 23597f1..ed3ce0e 100755
--- a/cli.js
+++ b/cli.js
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { fileURLToPath } from 'url';
-import { dirname, join } from 'path';
+import { dirname, join, relative } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
-import { spawnSync } from 'child_process';
+import { spawnSync, spawn } from 'child_process';
import inquirer from 'inquirer';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -208,12 +208,14 @@ async function main() {
name: 'aiTools',
message:
'Which AI coding assistant(s) are you using? (Select all that apply)\n' +
- chalk.gray(' Weβll add local instruction files so your assistant knows Cloudinary patterns.\n'),
+ chalk.gray(' We\'ll install Cloudinary skills in the right location for each tool.\n'),
choices: [
{ name: 'Cursor', value: 'cursor' },
- { name: 'GitHub Copilot', value: 'copilot' },
{ name: 'Claude Code', value: 'claude' },
- { name: 'Other / Generic AI tools', value: 'generic' },
+ { name: 'GitHub Copilot', value: 'copilot' },
+ { name: 'OpenAI Codex', value: 'codex' },
+ { name: 'Gemini CLI', value: 'gemini' },
+ { name: 'Other', value: 'generic' },
],
default: ['cursor'],
},
@@ -316,35 +318,69 @@ async function main() {
copyTemplate(file);
});
- // Create AI rules based on user's tool selection
- const aiRulesTemplatePath = join(TEMPLATES_DIR, '.cursorrules.template');
- if (existsSync(aiRulesTemplatePath) && aiTools && aiTools.length > 0) {
- const aiRulesContent = replaceTemplate(
- readFileSync(aiRulesTemplatePath, 'utf-8'),
- templateVars
- );
+ // Install Cloudinary skills into the directories each selected tool actually reads:
+ // .cursor/skills/ = Cursor
+ // .claude/skills/ = Claude Code
+ // .agents/skills/ = Copilot, Codex, Gemini, Generic (and any unrecognised tool)
+ console.log(chalk.blue('\nπ€ Installing Cloudinary AI skills...\n'));
+
+ const skillTargetDirs = new Set();
+ if (aiTools && aiTools.includes('cursor')) skillTargetDirs.add(join(projectPath, '.cursor', 'skills'));
+ if (aiTools && aiTools.includes('claude')) skillTargetDirs.add(join(projectPath, '.claude', 'skills'));
+ if (!aiTools || aiTools.some(t => !['cursor', 'claude'].includes(t))) {
+ skillTargetDirs.add(join(projectPath, '.agents', 'skills'));
+ }
- // Generate files based on selected tools
- if (aiTools.includes('cursor')) {
- writeFileSync(join(projectPath, '.cursorrules'), aiRulesContent);
- }
+ const skills = ['cloudinary-docs', 'cloudinary-react', 'cloudinary-transformations'];
+ // Download each skill once then copy to any additional targets
+ const [primaryDir, ...additionalDirs] = skillTargetDirs;
+ mkdirSync(primaryDir, { recursive: true });
+
+ // Print all skill names upfront so the user sees activity immediately
+ for (const skill of skills) {
+ console.log(chalk.gray(` Fetching ${skill}...`));
+ }
- if (aiTools.includes('copilot')) {
- const githubDir = join(projectPath, '.github');
- mkdirSync(githubDir, { recursive: true });
- writeFileSync(join(githubDir, 'copilot-instructions.md'), aiRulesContent);
+ // Download all 3 skills in parallel
+ const results = await Promise.all(
+ skills.map(skill => new Promise(resolve => {
+ const proc = spawn(
+ 'npx',
+ ['--yes', 'degit', `cloudinary-devs/skills/skills/${skill}`, join(primaryDir, skill)],
+ { stdio: 'pipe', shell: false }
+ );
+ proc.on('close', code => resolve({ skill, ok: code === 0 }));
+ }))
+ );
+
+ let skillsInstalled = 0;
+ const failedSkills = [];
+ for (const { skill, ok } of results) {
+ if (ok) {
+ console.log(chalk.gray(` β ${skill}`));
+ skillsInstalled++;
+ } else {
+ console.warn(chalk.yellow(` β Could not install ${skill}`));
+ failedSkills.push(skill);
}
+ }
- if (aiTools.includes('claude')) {
- writeFileSync(join(projectPath, 'CLAUDE.md'), aiRulesContent);
+ if (failedSkills.length > 0) {
+ const relDir = relative(projectPath, primaryDir);
+ console.log(chalk.yellow('\n Some skills could not be installed. To install manually, run from your project directory:'));
+ for (const skill of failedSkills) {
+ console.log(chalk.cyan(` npx degit cloudinary-devs/skills/skills/${skill} ${relDir}/${skill}`));
}
+ }
- if (aiTools.includes('generic')) {
- writeFileSync(join(projectPath, 'AI_INSTRUCTIONS.md'), aiRulesContent);
- writeFileSync(join(projectPath, 'PROMPT.md'), aiRulesContent);
+ if (skillsInstalled > 0) {
+ for (const target of additionalDirs) {
+ fs.copySync(primaryDir, target);
}
+ }
- // Generate MCP configuration: Cursor uses .cursor/mcp.json, Claude Code uses .mcp.json in project root
+ // Generate MCP configuration: Cursor uses .cursor/mcp.json, Claude Code uses .mcp.json in project root
+ if (aiTools && aiTools.length > 0) {
const mcpTemplatePath = join(TEMPLATES_DIR, '.cursor/mcp.json.template');
if (existsSync(mcpTemplatePath)) {
const mcpContent = replaceTemplate(
@@ -370,25 +406,15 @@ async function main() {
console.log(chalk.green('β
Project created successfully!\n'));
- if (aiTools && aiTools.length > 0) {
- // Count actual files created
- let fileCount = 0;
- if (aiTools.includes('cursor')) fileCount += 2; // .cursorrules + mcp.json
- if (aiTools.includes('copilot')) fileCount += 1;
- if (aiTools.includes('claude')) fileCount += 2; // CLAUDE.md + .mcp.json
- if (aiTools.includes('generic')) fileCount += 2; // AI_INSTRUCTIONS.md + PROMPT.md
-
- const filesText = fileCount === 1 ? 'file' : 'files';
- console.log(chalk.cyan(`π AI assistant configuration ${filesText} created:`));
- if (aiTools.includes('cursor')) console.log(chalk.gray(' β’ Cursor: .cursorrules'));
- if (aiTools.includes('copilot')) console.log(chalk.gray(' β’ GitHub Copilot: .github/copilot-instructions.md'));
- if (aiTools.includes('claude')) console.log(chalk.gray(' β’ Claude: CLAUDE.md'));
- if (aiTools.includes('generic')) console.log(chalk.gray(' β’ Generic: AI_INSTRUCTIONS.md, PROMPT.md'));
- if (aiTools.includes('cursor')) console.log(chalk.gray(' β’ MCP (Cursor): .cursor/mcp.json'));
- if (aiTools.includes('claude')) console.log(chalk.gray(' β’ MCP (Claude Code): .mcp.json'));
- console.log(chalk.gray(`\n ${fileCount === 1 ? 'This file teaches' : 'These files teach'} your AI assistant about Cloudinary patterns and best practices.`));
- console.log(chalk.gray(`\n π‘ How to use ${fileCount === 1 ? 'this file' : 'these files'}:`));
- console.log(chalk.gray(' β’ Simply open your project in your AI assistant - the configuration is already loaded'));
+ if (skillsInstalled > 0) {
+ console.log(chalk.cyan(`\nπ AI skills installed β .agents/skills/ (works with Cursor, Claude Code, Copilot, and more):`));
+ console.log(chalk.gray(' β’ cloudinary-docs β looks up live Cloudinary documentation'));
+ console.log(chalk.gray(' β’ cloudinary-react β React SDK patterns and best practices'));
+ console.log(chalk.gray(' β’ cloudinary-transformations β describe image & video edits in plain English'));
+ if (aiTools && aiTools.includes('cursor')) console.log(chalk.gray(' β’ MCP (Cursor): .cursor/mcp.json'));
+ if (aiTools && aiTools.includes('claude')) console.log(chalk.gray(' β’ MCP (Claude Code): .mcp.json'));
+ console.log(chalk.gray('\n π‘ How to use:'));
+ console.log(chalk.gray(' β’ Open your project in your AI assistant β skills are picked up automatically'));
console.log(chalk.gray(' β’ Ask your AI to help build Cloudinary features, and it will follow these patterns'));
console.log(chalk.gray(' β’ Example prompts: "Add image upload", "Create a transformation gallery"\n'));
}
diff --git a/templates/.cursorrules.template b/templates/.cursorrules.template
deleted file mode 100644
index c95f311..0000000
--- a/templates/.cursorrules.template
+++ /dev/null
@@ -1,874 +0,0 @@
-# Cloudinary React SDK Patterns & Common Errors
-
-**Scope**: These rules apply to **React (web)** with the browser Upload Widget. The **default** is **Vite** (create-cloudinary-react uses Vite). They also work with **other bundlers** (Create React App, Next.js, Parcel, etc.): only **how you read env vars** changes; see **"Other bundlers (non-Vite)"** below. Rules-only users: see **"Project setup (rules-only / without CLI)"** for the reusable Cloudinary instance, env, Upload Widget (unsigned/signed), and video player. For **React Native** uploads (including signed upload), see: https://cloudinary.com/documentation/react_native_image_and_video_upload#signed_upload β same βnever expose secret, generate signature on backendβ principle, but React Native uses the `upload()` method and backend SDKs differently.
-
-## Official Documentation
-- **Transformation Rules**: https://cloudinary.com/documentation/cloudinary_transformation_rules.md
-- **Transformation Reference**: https://cloudinary.com/documentation/transformation_reference.md
-- **React Image Transformations & Plugins**: https://cloudinary.com/documentation/react_image_transformations.md#plugins
-- **React Video Transformations**: https://cloudinary.com/documentation/react_video_transformations.md
-- **Cloudinary Video Player** (standalone player): https://cloudinary.com/documentation/cloudinary_video_player.md
-- **Video Player React Tutorial**: https://cloudinary.com/documentation/video_player_react_tutorial.md
-- **Upload Widget (signed uploads)**: https://cloudinary.com/documentation/upload_widget.md#signed_uploads
-- **Upload assets in Next.js (backend signature)**: https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md
-- **Cloudinary Node.js SDK (server-side signing)** β use **v2**: `import { v2 as cloudinary } from 'cloudinary'`; do not use v1 (e.g. 1.47.0). https://cloudinary.com/documentation/node_integration.md
-- **React Native image and video upload (signed)**: https://cloudinary.com/documentation/react_native_image_and_video_upload.md#signed_upload
-- Always consult the official transformation rules when creating transformations
-- Use only officially supported parameters from the transformation reference
-
-**Golden rule for @cloudinary/url-gen:** When generating overlay or transformation code, use **only** the "Import reference" table and "Canonical overlay block" in these rules. Copy those import paths and usage patterns exactly; do not guess or derive paths (e.g. `text` and `image` are from `qualifiers/source`, not `actions/overlay`; `Position` is from `qualifiers/position`, not `positioning/Position`).
-
----
-
-# π PATTERNS (How to Do It Right)
-
-## Project setup (rules-only / without CLI)
-
-If the user is **not** using the create-cloudinary-react CLI and only has these rules, generate the following so they get correct config, env, and widget setup.
-
-**1. Environment (.env)**
-Create a `.env` file in the project root with **Vite prefix** (required for client access):
-- `VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name` (required)
-- `VITE_CLOUDINARY_UPLOAD_PRESET=your_unsigned_preset_name` (optional; required for unsigned upload widget)
-- Save the `.env` file after editing it, then restart the dev server so changes load correctly. Use `import.meta.env.VITE_*` in code, not `process.env`.
-
-**2. Reusable Cloudinary instance (config)**
-Create a config file (e.g. `src/cloudinary/config.ts`) so the rest of the app can use a single `cld` instance:
-```ts
-import { Cloudinary } from '@cloudinary/url-gen';
-
-const cloudName = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME;
-if (!cloudName) {
- throw new Error('VITE_CLOUDINARY_CLOUD_NAME is not set. Add it to .env with the VITE_ prefix.');
-}
-
-export const cld = new Cloudinary({ cloud: { cloudName } });
-export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
-```
-- Use **this** pattern for the reusable instance. Everywhere else: `import { cld } from './cloudinary/config'` (or the path the user chose) and call `cld.image(publicId)` / `cld.video(publicId)`.
-
-**3. Upload Widget (unsigned, from scratch)**
-
-**Strict pattern (always follow this exactly):**
-1. **Script in `index.html`** (required): Add `` to `index.html`. Do **not** rely only on dynamic script injection from React β it's fragile.
-2. **Poll in useEffect** (required): In `useEffect`, poll with `setInterval` (e.g. every 100ms) until `typeof window.cloudinary?.createUploadWidget === 'function'`. Only then create the widget. A single check (even in `onload`) is **not** reliable because `window.cloudinary` can exist before `createUploadWidget` is attached.
-3. **Add a timeout**: Set a timeout (e.g. 10 seconds) to stop polling and show an error if the script never loads. Clear both interval and timeout in cleanup.
-4. **Create widget once**: When `createUploadWidget` is available, create the widget and store it in a **ref**. Clear the interval and timeout. Pass options: `{ cloudName, uploadPreset, sources: ['local', 'camera', 'url'], multiple: false }`.
-5. **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup.
-
-β **Do NOT**: Check only `window.cloudinary` (not enough); do a single check in `onload` (unreliable); skip the script in `index.html`; poll forever without a timeout.
-- **Signed uploads**: Do not use only `uploadPreset`; use the pattern under "Secure (Signed) Uploads" (uploadSignature as function, fetch api_key, server includes upload_preset in signature).
-
-**4. Video player**
-- Use imperative video element only (create with document.createElement, append to container ref, pass to videoPlayer). See "Cloudinary Video Player (The Player)" for the full pattern.
-
-**5. Summary for rules-only users**
-- **Env**: Use your bundler's client env prefix and access (Vite: `VITE_` + `import.meta.env.VITE_*`; see "Other bundlers" if not Vite).
-- **Reusable instance**: One config file that creates and exports `cld` (and optionally `uploadPreset`) from `@cloudinary/url-gen`; use it everywhere.
-- **Upload widget**: Script in index.html (required); in useEffect, **poll** until `createUploadWidget` is a function, then create widget once and store in ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
-- **Video player**: Imperative video element (createElement, append to container ref, pass to videoPlayer); dispose + removeChild in cleanup; fall back to AdvancedVideo if init fails.
-
-**If the user is not using Vite:** Use their bundler's client env prefix and access in the config file and everywhere you read env. Examples: Create React App β `REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`; Next.js (client) β `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`. The rest (cld instance, widget options, video player) is the same.
-
-## Environment Variables
-- **Default: Vite** β Vite requires `VITE_` prefix; use `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME` (not `process.env`). Restart dev server after changing `.env`.
-- β
CORRECT (Vite): `VITE_CLOUDINARY_CLOUD_NAME=mycloud` in `.env`; `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME`
-
-## Other bundlers (non-Vite)
-- **Only the env access changes.** All other patterns (reusable `cld`, Upload Widget, Video Player, overlays, signed uploads) are bundler-agnostic.
-- **Create React App**: Prefix `REACT_APP_`; access `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_UPLOAD_PRESET`. Restart dev server after `.env` changes.
-- **Next.js (client)**: Prefix `NEXT_PUBLIC_` for client; access `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, etc. Server-side can use `process.env.CLOUDINARY_*` without `NEXT_PUBLIC_`.
-- **Parcel / other**: Check the bundler's docs for "exposing environment variables to the client" (often a prefix or allowlist). Use that prefix and the documented access (e.g. `process.env.*`).
-- **Config file**: In `src/cloudinary/config.ts` (or equivalent), read cloud name and upload preset using the **user's bundler** env API (e.g. for CRA: `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`). Same `new Cloudinary({ cloud: { cloudName } })` and exports; only the env read line changes.
-
-## Upload Presets
-- **Unsigned** = client-only uploads (no backend). **Signed** = backend required, more secure. See **"Signed vs unsigned uploads"** below for when to use which.
-- β
Create unsigned upload preset (for simple client uploads): https://console.cloudinary.com/app/settings/upload/presets
-- β
Set preset in `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your-preset-name`
-- β
Use in code: `import { uploadPreset } from './cloudinary/config'`
-- β οΈ If upload preset is missing, the Upload Widget will show an error message
-- β οΈ Upload presets must be set to "Unsigned" mode for client-side usage (no API key/secret needed)
-- **When unsigned upload fails**: First check that the user configured their upload preset:
- 1. Is `VITE_CLOUDINARY_UPLOAD_PRESET` set in `.env`? (must match preset name exactly)
- 2. Does the preset exist in the dashboard under Settings β Upload β Upload presets?
- 3. Is the preset set to **Unsigned** (not Signed)?
- 4. Was the dev server restarted after adding/updating `.env`?
-
-## Installing Cloudinary packages
-- β
**Install the latest**: When adding Cloudinary packages, use `npm install ` **with no version** so npm installs the latest compatible version (e.g. `npm install cloudinary-video-player`). In package.json use a **caret range** (e.g. `"cloudinary-video-player": "^1.0.0"`) so future installs get the latest compatible. Do not pin to an exact version unless you have verified it exists on npm.
-- β
**Package names only**: Use **only** these names: `@cloudinary/react`, `@cloudinary/url-gen`, `cloudinary-video-player` (standalone player), `cloudinary` (Node server-side only). Do not invent names (e.g. no `@cloudinary/video-player`).
-- β **WRONG**: `npm install cloudinary-video-player@1.2.3` or `"cloudinary-video-player": "1.2.3"` (exact pin) β versions may not exist and break installs.
-- β
**Correct**: `npm install cloudinary-video-player` (no version) or in package.json: `"cloudinary-video-player": "^1.0.0"` (caret = latest compatible).
-
-## Import Patterns
-- β
Import Cloudinary instance: `import { cld } from './cloudinary/config'`
-- β
Import components: `import { AdvancedImage, AdvancedVideo } from '@cloudinary/react'`
-- β
Import plugins: `import { responsive, lazyload, placeholder } from '@cloudinary/react'`
-- β
**For transformations and overlays**, use **only** the exact paths in "Import reference: @cloudinary/url-gen" and the "Canonical overlay block" below. Do **not** guess subpaths (e.g. `text` and `image` are from `qualifiers/source`, not `actions/overlay`).
-
-## Import reference: @cloudinary/url-gen (use these exact paths only)
-
-**Rule:** Do not invent or guess import paths for `@cloudinary/url-gen`. Use **only** the paths in the table and canonical block below. Copy the import statements exactly; do not derive paths (e.g. `@cloudinary/url-gen/overlay` exports only `source` β `text` and `image` are from **`qualifiers/source`**; `Position` is from **`qualifiers/position`**, not `positioning/Position`). Wrong paths cause "module not found" or "does not exist".
-
-| Purpose | Exact import |
-|--------|----------------|
-| Cloudinary instance (config) | `import { Cloudinary } from '@cloudinary/url-gen';` |
-| Resize (fill) | `import { fill } from '@cloudinary/url-gen/actions/resize';` |
-| Resize (scale, for overlays) | `import { scale } from '@cloudinary/url-gen/actions/resize';` |
-| Delivery format/quality | `import { format, quality } from '@cloudinary/url-gen/actions/delivery';` |
-| Format qualifier (auto) | `import { auto } from '@cloudinary/url-gen/qualifiers/format';` |
-| Quality qualifier (auto) | `import { auto as autoQuality } from '@cloudinary/url-gen/qualifiers/quality';` |
-| Effects (e.g. blur) | `import { blur } from '@cloudinary/url-gen/actions/effect';` |
-| Overlay source | `import { source } from '@cloudinary/url-gen/actions/overlay';` |
-| Overlay text / image (source types) | `import { text, image } from '@cloudinary/url-gen/qualifiers/source';` |
-| Overlay image transformation | `import { Transformation } from '@cloudinary/url-gen/transformation/Transformation';` |
-| Position (overlay) | `import { Position } from '@cloudinary/url-gen/qualifiers/position';` |
-| Gravity/compass | `import { compass } from '@cloudinary/url-gen/qualifiers/gravity';` |
-| Text style (overlay) | `import { TextStyle } from '@cloudinary/url-gen/qualifiers/textStyle';` |
-| Types | `import type { CloudinaryImage, CloudinaryVideo } from '@cloudinary/url-gen';` |
-
-**Canonical overlay block (copy these imports and patterns exactly):**
-```ts
-// Overlay imports β text/image from qualifiers/source, NOT actions/overlay
-import { source } from '@cloudinary/url-gen/actions/overlay';
-import { text, image } from '@cloudinary/url-gen/qualifiers/source';
-import { Position } from '@cloudinary/url-gen/qualifiers/position';
-import { TextStyle } from '@cloudinary/url-gen/qualifiers/textStyle';
-import { compass } from '@cloudinary/url-gen/qualifiers/gravity';
-import { Transformation } from '@cloudinary/url-gen/transformation/Transformation';
-import { scale } from '@cloudinary/url-gen/actions/resize';
-
-// Text overlay (compass with underscores: 'south_east', 'center')
-cld.image('id').overlay(
- source(text('Hello', new TextStyle('Arial', 60).fontWeight('bold')).textColor('white'))
- .position(new Position().gravity(compass('center')))
-);
-
-// Image overlay (logo/image with resize)
-cld.image('id').overlay(
- source(image('logo').transformation(new Transformation().resize(scale().width(100))))
- .position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))
-);
-```
-
-- **Components** (AdvancedImage, AdvancedVideo, plugins) come from **`@cloudinary/react`**, not from `@cloudinary/url-gen`.
-- **Transformation actions and qualifiers** (resize, delivery, effect, overlay, etc.) come from **`@cloudinary/url-gen/actions/*`** and **`@cloudinary/url-gen/qualifiers/*`** with the exact subpaths above.
-- If an import fails, verify the package version (`@cloudinary/url-gen` in package.json) and the [Cloudinary URL-Gen SDK docs](https://cloudinary.com/documentation/sdks/js/url-gen/index.html) or [Transformation Builder reference](https://cloudinary.com/documentation/sdks/js/transformation_builder_reference).
-
-## Creating Image & Video Instances
-- β
Create image instance: `const img = cld.image(publicId)`
-- β
Create video instance: `const video = cld.video(publicId)` (same pattern as images)
-- β
Public ID format: Use forward slashes for folders (e.g., `'folder/subfolder/image'`)
-- β
Public IDs are case-sensitive and should not include file extensions
-- β
**Sample assets**: Cloudinary may provide sample assets under `samples/`. **Assume they might not exist** (users can delete them); always handle load errors and provide fallbacks (see Image gallery). When they exist, use them for examples and demos instead of requiring uploads first.
-- β
**Sample public IDs that may be available** (use for galleries, demos; handle onError if missing):
- - Images: `samples/cloudinary-icon`, `samples/two-ladies`, `samples/food/spices`, `samples/landscapes/beach-boat`, `samples/bike`, `samples/landscapes/girl-urban-view`, `samples/animals/reindeer`, `samples/food/pot-mussels`
- - Video: `samples/elephants`
-- β
**Default / most reliable**: Start with `samples/cloudinary-icon` for a single image; use the list above for galleries or variety. Prefer uploaded assets when the user has them.
-- β
Examples:
- ```tsx
- const displayImage = cld.image('samples/cloudinary-icon');
- const displayVideo = cld.video('samples/elephants');
- // Gallery: e.g. ['samples/bike', 'samples/landscapes/beach-boat', 'samples/food/spices', ...]
- ```
-
-## Transformation Patterns
-
-### Image Transformations
-- β
Chain transformations on image instance:
- ```tsx
- const img = cld.image('id')
- .resize(fill().width(800).height(600))
- .effect(blur(800))
- .delivery(format(auto()))
- .delivery(quality(autoQuality()));
- ```
-- β
Pass to component: ``
-
-### Video Transformations
-- β
Chain transformations on video instance (same pattern as images):
- ```tsx
- const video = cld.video('id')
- .resize(fill().width(800).height(600))
- .delivery(format(auto()));
- ```
-- β
Pass to component: ``
-- β
Video transformations work the same way as image transformations
-
-### Transformation Best Practices
-- β
Format and quality must use separate `.delivery()` calls
-- β
Always end with auto format/quality: `.delivery(format(auto())).delivery(quality(autoQuality()))` unless user specifies a particular format or quality
-- β
Use `gravity(auto())` unless user specifies a focal point
-- β
Same transformation syntax works for both images and videos
-
-## Plugin Patterns
-- β
**When the user asks for lazy loading or responsive images**: Use the **Cloudinary plugins** from `@cloudinary/react` β `responsive()`, `lazyload()`, `placeholder()` β with `AdvancedImage`. Do not use only native `loading="lazy"` or CSS-only responsive; the Cloudinary plugins handle breakpoints, lazy loading, and placeholders for Cloudinary URLs.
-- β
Import plugins from `@cloudinary/react`
-- β
Pass plugins as array: `plugins={[responsive(), lazyload(), placeholder()]}`
-- β
Recommended plugin order:
- 1. `responsive()` - First (handles breakpoints)
- 2. `placeholder()` - Second (shows placeholder while loading)
- 3. `lazyload()` - Third (delays loading until in viewport)
- 4. `accessibility()` - Last (if needed)
-- β
Always add `width` and `height` attributes to prevent layout shift
-- β
Example:
- ```tsx
-
- ```
-
-## Responsive Images Pattern
-- β
**Responsive images**: Use the Cloudinary `responsive()` plugin with `fill()` resize (not only CSS). **Lazy loading**: Use the Cloudinary `lazyload()` plugin with `AdvancedImage` (not only `loading="lazy"`).
-- β
Use `responsive()` plugin with `fill()` resize
-- β
Combine with `placeholder()` and `lazyload()` plugins
-- β
Example:
- ```tsx
- const img = cld.image('id').resize(fill().width(800));
-
- ```
-
-## Image gallery with lazy loading and responsive
-- β
**When the user asks for an image gallery with lazy loading and responsive**: Use Cloudinary **plugins** with `AdvancedImage`: `responsive()`, `lazyload()`, `placeholder()` (see Plugin Patterns). Use `fill()` resize with the responsive plugin. Add `width` and `height` to prevent layout shift.
-- β
**Sample assets in galleries**: Use the sample public IDs from "Creating Image & Video Instances" (e.g. `samples/bike`, `samples/landscapes/beach-boat`, `samples/food/spices`, `samples/two-ladies`, `samples/landscapes/girl-urban-view`, `samples/animals/reindeer`, `samples/food/pot-mussels`, `samples/cloudinary-icon`). **Assume any sample might not exist** β users can delete them. Start with one reliable sample (e.g. `samples/cloudinary-icon`) or a short list; add **onError** handling and remove/hide failed images. Prefer **uploaded** assets when available (e.g. from UploadWidget) over samples.
-- β
**Handle load errors**: Use `onError` on `AdvancedImage` to hide or remove failed images (e.g. set state to filter out the publicId, or hide the parent). Provide user feedback (e.g. "Some images could not be loaded. Try uploading your own!") and upload functionality so users can add their own images.
-- β
**Fallback**: Default gallery list can be a subset of the sample list (e.g. `['samples/cloudinary-icon', 'samples/bike', 'samples/landscapes/beach-boat']`); when user uploads, append `result.public_id`. If an image fails to load, remove it from the list or hide it so the UI doesn't show broken images.
-
-## Image Overlays (text or logos)
-- β
**When the user asks for image overlays with text or logos**: Use `@cloudinary/url-gen` overlay APIs. Copy imports and patterns from the **"Import reference"** table and **"Canonical overlay block"** in these rules. Do not import `text` or `image` from `actions/overlay` β they are from **`qualifiers/source`**; only `source` is from `actions/overlay`.
-- β
**Import** `source` from `actions/overlay`; **`text` and `image` from `qualifiers/source`**. Also: `Position` from `qualifiers/position`, `TextStyle` from `qualifiers/textStyle`, `compass` from `qualifiers/gravity`, `Transformation` from `transformation/Transformation`, `scale` from `actions/resize`.
-- β
**compass()** takes **string** values, with **underscores**: `compass('center')`, `compass('south_east')`, `compass('north_west')`. β WRONG: `compass(southEast)` or `'southEast'` (no camelCase).
-- β
**Overlay image**: Use `new Transformation()` **inside** `.transformation()`: `image('logo').transformation(new Transformation().resize(scale().width(100)))`. β WRONG: `image('logo').transformation().resize(...)` (`.transformation()` does not return a chainable object).
-- β
**Text overlay**: `fontWeight` goes on **TextStyle**: `new TextStyle('Arial', 60).fontWeight('bold')`. `textColor` goes on the **text source** (chained after `text(...)`): `text('Hello', new TextStyle('Arial', 60)).textColor('white')`.
-- β
**Position** is chained **after** `source(...)`, not inside: `source(image('logo').transformation(...)).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))`.
-- β
**Image overlay pattern**: `baseImage.overlay(source(image('id').transformation(new Transformation().resize(scale().width(100)))).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20)))`. (Import `scale` from `@cloudinary/url-gen/actions/resize` if needed.)
-- β
**Text overlay pattern**: `baseImage.overlay(source(text('Your Text', new TextStyle('Arial', 60).fontWeight('bold')).textColor('white')).position(new Position().gravity(compass('center'))))`.
-- β
Docs: React Image Transformations and transformation reference for overlay syntax.
-
-## Upload Widget Pattern
-- β
Use component: `import { UploadWidget } from './cloudinary/UploadWidget'`
-
-**Strict initialization pattern (always follow this exactly):**
-1. β
**Script in `index.html`** (required):
- ```html
-
- ```
-2. β
**Poll in useEffect until `createUploadWidget` is available** (required): Use `setInterval` (e.g. every 100ms) to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
-3. β
**Add a timeout** (e.g. 10 seconds) to stop polling and show an error state if the script never loads. Clear both interval and timeout in cleanup and when ready.
-4. β
**Create widget once**, store in a ref. Cleanup: clear interval, clear timeout, remove click listener.
-
-β **Do NOT**: Check only `window.cloudinary` (the function may not be attached yet); do a single check in `onload` (unreliable timing); skip `index.html` and rely only on dynamic injection; poll forever without a timeout.
-
-- β
Create unsigned upload preset in dashboard at `settings/upload/presets`
-- β
Add to `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your_preset_name`
-- β
Handle callbacks:
- ```tsx
- {
- console.log('Public ID:', result.public_id);
- }}
- onUploadError={(error) => {
- console.error('Upload failed:', error);
- }}
- />
- ```
-- β
Upload result contains: `public_id`, `secure_url`, `width`, `height`, etc.
-
-## Signed vs unsigned uploads (when to use which)
-
-**Unsigned uploads** (simpler, no backend required):
-- Use when: Quick prototypes, low-risk apps, or when anyone with the preset name may upload.
-- Preset: Create an **Unsigned** upload preset in Cloudinary dashboard (Settings β Upload β Upload presets). Put preset name in `.env` as `VITE_CLOUDINARY_UPLOAD_PRESET`.
-- Client: Widget needs only `cloudName` and `uploadPreset`. No API key or secret; no backend.
-- Trade-off: Anyone who knows the preset name can upload. Use only when that is acceptable.
-
-**Signed uploads** (more secure, backend required):
-- Use when: Production apps, authenticated users, or when you need to control who can upload.
-- Preset: Create a **Signed** upload preset in the dashboard. The backend generates a signature using your API secret; the client never sees the secret.
-- Client: Widget gets `api_key` (from your backend), `uploadPreset`, and an `uploadSignature` **function** that calls your backend for each upload. API secret stays on server only.
-- Trade-off: Requires a backend (Node/Express, Next.js API route, etc.) to sign requests. More secure; signature validates each upload.
-
-**Rule of thumb**: **Default to unsigned uploads** unless the user explicitly asks for "secure" or "signed" uploads. Do not default to signed β it requires a running backend and will fail out of the box. Use **signed** only when the user explicitly requests secure/signed uploads or needs to restrict who can upload.
-
-## Secure (Signed) Uploads
-
-**Golden rules**: (1) **Never expose or commit the API secret** β it must live only in server env and server code. (2) **Never commit the API key or secret** β use `server/.env` (or equivalent) and ensure it is in `.gitignore`. (3) The **api_key** is not secret and may be sent to the client (e.g. in the signature response); only **api_secret** must stay server-only.
-
-**When the user asks for secure uploads**: Use a signed upload preset and generate the signature on the server. The client may receive `uploadSignature`, `uploadSignatureTimestamp`, `api_key`, and `cloudName` from your backend; it must **never** receive or contain the API secret.
-
-### Where to put API key and secret (server-only, never committed)
-
-- **Do not put them in the root `.env`** used by Vite. Keep root `.env` for `VITE_CLOUDINARY_CLOUD_NAME` and `VITE_CLOUDINARY_UPLOAD_PRESET` only.
-- **Create `server/.env`** (in a `server/` folder) and put there: `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`. No `VITE_` prefix. Load this file only in the server process (e.g. `dotenv.config({ path: 'server/.env' })`).
-- **Never commit API key or secret**: Add `server/.env` to `.gitignore`. Use env vars for all credentials; never hardcode or commit them.
-- **In code**: Read `process.env.CLOUDINARY_API_SECRET` and `process.env.CLOUDINARY_API_KEY` only in server/API code. Never in React components or any file Vite bundles.
-- **Next.js**: `CLOUDINARY_*` in root `.env.local` is server-only (browser only sees `NEXT_PUBLIC_*`). For Vite + Node in same repo, prefer `server/.env` and load it only in the server.
-- **Server SDK**: Use the **Cloudinary Node.js SDK v2** for server-side signing: `import { v2 as cloudinary } from 'cloudinary'` (package name: `cloudinary`). Do **not** use v1 (e.g. 1.47.0) β v1 does not expose `cloudinary.utils.api_sign_request` the same way. Install: `npm install cloudinary` (v2).
-
-### How the client gets credentials (working pattern β use this)
-
-Use **`uploadSignature` as a function** (not `signatureEndpoint`). The widget calls the function with `params_to_sign`; your function calls your backend and passes the signature back. This pattern is reliable across widget versions.
-
-1. **Fetch `api_key` from server first** (before creating the widget). API key is not secret; safe to use in client. Your backend returns it from the sign endpoint (e.g. `/api/sign-image`).
-
-2. **Set `uploadSignature` to a function** that receives `(callback, params_to_sign)` from the widget. Inside the function, add `upload_preset` to `params_to_sign` (use your signed preset name, e.g. from env or a constant), POST to your backend with `{ params_to_sign: paramsWithPreset }`, then call `callback(data.signature)` with the response.
-
-3. **Include `uploadPreset` in the widget config** (your signed preset name). The widget needs it so it includes it in `params_to_sign`. **Default:** Cloudinary accounts have a built-in signed preset `ml_default` (users can delete it). If the user has not created their own signed preset, use `ml_default`; otherwise use the preset name from their dashboard.
-
-4. **Server endpoint**: Accept `params_to_sign` from the request body. Always include `upload_preset` in the object you sign (add it if the client did not send it). Use `cloudinary.utils.api_sign_request(paramsToSign, process.env.CLOUDINARY_API_SECRET)` to generate the signature. Return `{ signature, timestamp, api_key, cloud_name }`. Never return the API secret.
-
-**Preset name:** Use `ml_default` when the user has not specified a signed preset (Cloudinary provides it by default; users can delete it β then they must create one in the dashboard). Otherwise use the user's preset name.
-
-**Generic client pattern** (preset: use `ml_default` if it exists / user hasn't specified one; endpoint is up to the user):
-```tsx
-// Fetch api_key from server first, then:
-widgetConfig.api_key = data.api_key; // from your sign endpoint
-widgetConfig.uploadPreset = 'ml_default'; // default signed preset (or user's preset if they created one)
-widgetConfig.uploadSignature = function(callback, params_to_sign) {
- const paramsWithPreset = { ...params_to_sign, upload_preset: 'ml_default' };
- fetch('/api/sign-image', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ params_to_sign: paramsWithPreset }),
- })
- .then(r => r.json())
- .then(data => data.signature ? callback(data.signature) : callback(''))
- .catch(() => callback(''));
-};
-```
-
-**Generic server pattern** (Node/Express with SDK v2):
-```ts
-// import { v2 as cloudinary } from 'cloudinary';
-const params = req.body.params_to_sign || {};
-const paramsToSign = { ...params, upload_preset: params.upload_preset || 'ml_default' };
-const signature = cloudinary.utils.api_sign_request(paramsToSign, process.env.CLOUDINARY_API_SECRET);
-res.json({ signature, timestamp: paramsToSign.timestamp, api_key: process.env.CLOUDINARY_API_KEY, cloud_name: process.env.CLOUDINARY_CLOUD_NAME });
-```
-
-- β **Avoid `signatureEndpoint`** β it may not be called reliably by all widget versions. Prefer the `uploadSignature` function.
-- β
Docs: [Upload widget β signed uploads](https://cloudinary.com/documentation/upload_widget.md#signed_uploads), [Upload assets in Next.js](https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md).
-
-### Rules for secure uploads
-- β
Use a **signed** upload preset (dashboard β Upload presets β Signed). Do not use an unsigned preset when the user wants secure uploads. **Default:** Accounts have a built-in signed preset `ml_default` β use it if the user hasn't created their own (they can delete `ml_default`, in which case they must create a signed preset in the dashboard).
-- β
Generate the signature **on the server only** using Cloudinary Node.js SDK **v2** (`cloudinary.utils.api_sign_request`). Never put `CLOUDINARY_API_SECRET` in a `VITE_` variable or in client-side code.
-- β
Keep `server/.env` in `.gitignore`; never commit API key or secret.
-- β
Use **`uploadSignature` as a function** (not `signatureEndpoint`) for reliable signed uploads.
-- β
Include **`uploadPreset` in the widget config** so the widget includes it in `params_to_sign`.
-- β
**Server must include `upload_preset` in the signed params** (add it if the client did not send it).
-
-### What not to do
-- β **Never** put the API secret in a `VITE_` (or `NEXT_PUBLIC_`) variable or in any file sent to the browser.
-- β **Never** commit the API key or secret; use env vars and ignore `server/.env` in git.
-- β **Do not** generate the signature in client-side JavaScript (it would require the secret in the client).
-- β **Do not** use an unsigned preset when the user explicitly wants secure/signed uploads.
-- β **Do not** omit `uploadPreset` from the widget config when using signed uploads (widget needs it in `params_to_sign`).
-- β **Do not** use Cloudinary Node SDK v1 (e.g. 1.47.0) for signing β use v2 (`import { v2 as cloudinary } from 'cloudinary'`).
-- β **Do not** rely on `signatureEndpoint` alone; use the `uploadSignature` function for reliability.
-
-## Video Patterns
-
-- β
**Display a video** β Use **AdvancedVideo** (`@cloudinary/react`). It just displays a video (with optional transformations). Not a full player.
-- β
**A video player** β Use **Cloudinary Video Player** (`cloudinary-video-player`). That is the actual player (styled UI, controls, playlists, etc.).
-
-### β οΈ IMPORTANT: Two Different Approaches
-
-**1. AdvancedVideo** (`@cloudinary/react`) β For **displaying** a video
-- React component similar to `AdvancedImage`; just displays a video with Cloudinary transformations
-- Not a full "player" β it's video display (native HTML5 video with optional controls)
-- Use when: user wants to show/display a video. Works with `cld.video()` like images with `cld.image()`
-
-**2. Cloudinary Video Player** (`cloudinary-video-player`) β The **player**
-- Full-featured video player (styled UI, controls, playlists). Use when the user asks for a "video player."
-- **Use imperative video element only** (create with document.createElement, append to container ref); do not pass a React-managed `