diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..764928c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Create a bug report +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Before opening a new issue: + + - Do a search of existing issues. + - Pull the latest updates from this repository's `dev` branch. + + - type: textarea + attributes: + label: To Reproduce + description: A step-by-step description of how to reproduce the issue, or a link to the reproducible repository. + placeholder: | + 1. Start the application in development (npm run dev -- -f ./assets/suns.jpg -u) + 2. Press enter + 3. An error appears: "[ERROR] [CLOUD-IMAGE-UPLOAD] [UPLOAD] ENOENT: no such file or directory, open 'C:\lab\image-transformer\app\assets\suns.jpg'" + validations: + required: true + + - type: textarea + attributes: + label: Current vs. Expected behavior + description: A clear and concise description of what the bug is, and what you expected to happen. + placeholder: 'Following the steps from the previous section, I expected A to happen, but I observed B instead' + validations: + required: true + + - type: textarea + attributes: + label: Provide environment information + description: Please run `npm run info` in the root directory of your project and paste the results. + render: bash + placeholder: | + Node version: v24.11.0 + Platform: win32 + Arch: x64 + V8 version: 13.6.233.10-node.28 + npm version: 11.6.1 + validations: + required: true + + - type: dropdown + attributes: + label: Which area(s) are affected? (Select all that apply) + multiple: true + options: + - 'Not sure' + - 'CLI/NPM scripts usage' + - 'Cloudinary credentials' + - 'Image input/output' + - 'Docker' + - 'Others' + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: | + Any extra information that might help us investigate. + placeholder: | + I tested my reproduction against different `cloudinary` releases, and the first one that introduced the bug was "v2.9.0", since reverting to "v2.10.0" works. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5aa9ac8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest a new feature or improvement to the project +labels: ['enhancement'] +body: + - type: textarea + attributes: + label: What problem will this feature address? + description: A clear and concise description of what the problem is. + placeholder: | + I'm always frustrated when I can't do X + validations: + required: true + + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: Add X to the core + validations: + required: true + + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: | + Maybe use Y as a workaround? + validations: + required: true + + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b14b135 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Summary + + +## Related Issues + + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Refactor +- [ ] Documentation +- [ ] Other (please describe): + +## Checklist +- [ ] I have tested my changes locally +- [ ] I have linked relevant issues +- [ ] I have added screenshots for UI changes (if applicable) + +## Screenshots (if applicable) + + +## Additional Context + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ae43a57 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Deploy Release + +# This workflow will trigger on any tag/release created from the `main` branch +on: + release: + types: [published] + +jobs: + lint-test-app: + name: Lint and Test App + if: github.event.release.target_commitish == 'main' + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + + - name: Use NodeJS v24.11.0 + uses: actions/setup-node@v6 + with: + node-version: 24.11.0 + registry-url: https://registry.npmjs.org/ + + - name: Install Dependencies + run: | + cd app + npm ci + + - name: Lint + run: | + cd app + npm run lint + + - name: Check types + run: | + cd app + npm run types:check + + publish-npm: + name: Publish to NPM registry + if: github.event.release.target_commitish == 'main' + needs: [lint-test-app] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Use NodeJS v24.11.0 + uses: actions/setup-node@v6 + with: + node-version: 24.11.0 + registry-url: https://registry.npmjs.org/ + + - name: Build distribution package + run: | + cd app + npm ci + npm run build + + - name: Publish package + run: | + cp LICENSE app/ + cp docs/README_NPM.md app/README.md + cd app + npm publish --provenance --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c997d8e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Run CI Tests + +on: + push: + branches-ignore: + - main + +jobs: + lint-app: + name: Lint App + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + + - name: Use NodeJS v24.11.0 + uses: actions/setup-node@v6 + with: + node-version: 24.11.0 + registry-url: https://registry.npmjs.org/ + + - name: Install Dependencies + run: | + cd app + npm ci + + - name: Lint + run: | + cd app + npm run lint + + - name: Check types + run: | + cd app + npm run types:check diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..db72c81 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to Docker", + "port": 9229, + "address": "localhost", + "localRoot": "${workspaceFolder}/app", + "remoteRoot": "/opt/app" + }, + ] +} diff --git a/README.md b/README.md index baa78c3..b8b7707 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,27 @@ -## image-cli +## cloudinary-image -CLI that supports Cloudinary image transformations. +Cloudinary image optimization CLI with Node.js wrappers for select [Cloudinary Node.js APIs](https://cloudinary.com/documentation/node_image_manipulation)
+covering image transformation, optimization, and asset management. ### Requirements - Node v24+ - Docker (optional) +- Cloudinary account -### Usage +## Installation -Using Docker +Create a `.env` file in the `/app` directory, replacing the contents of the `.env.example` file with actual values. + +| Variable Name | Description | +| --- | --- | +| CLOUDINARY_NAME | Cloudinary account name | +| CLOUDINARY_API_KEY | Cloudinary API key | +| CLOUDINARY_API_SECRET | Cloudinary API secret | + +## Usage + +#### A. Using Docker 1. Build the image.
`docker compose build` @@ -17,5 +29,221 @@ Using Docker 2. Run the container.
`docker compose up` +3. Run the [Available Scripts](#available-scripts) using Docker. + +4. See the examples under the [Code Samples](#-code-samples) section for more information. + +**Example using the development Docker image** + +(PowerShell - development) + +```sh +docker exec cloudinary-cli-dev npm run docker:debug -- -f /opt/app/assets/sunset.jpg -u -d +``` + +**Example using stand-alone production Docker image** + +(PowerShell - production) + +Build the production image with
+`docker compose -f docker-compose.prod.yml build` + +```sh + docker run --rm --env-file .env ` + -v ${pwd}/assets:/images ` + weaponsforge/cloudinary-cli ` + -f /images/sunset.jpg -u +``` + +#### B. Using Node.js + +1. Install dependencies.
+ + ```sh + cd app + npm install + ``` + +2. Run the [Available Scripts](#available-scripts). + +3. See the examples under the [Code Samples](#-code-samples) section for more information. + +## Available Scripts + +### `npm start` + +Optimizes an input image using the Cloudinary image transformations. +Downloads the optimized image to a `/processed` directory relative to the input file, or to a specified output directory. + +> **NOTE**: this requires transpiling TypeScript into JavaScript first via `npm run build`. + +**Example Usage** + +```sh +npm start -- -f /path/to/file.jpg -u -d +``` + +**CLI Guide** + +```sh +npm start -- \ + -f /path/to/file.jpg # Full input image file path + -o /output/folder/path # (Optional) output folder + -a my-asset-folder # (Optional) Cloudinary asset folder + -t cars,vehicles,tech # (Optional) image tags + -w 600 # (Optional) width to resize the image. Default is 800 + -u # (Optional) flag to upload the input image to Cloudinary. Required on 1st run. + -d # (Optional) flag to delete the uploaded image in Cloudinary +``` + +> **NOTE**: This script is also accessible using `npx optimize` minus the `--` flag. + +### `npm run dev` + +Runs the `npm start` script in development mode with `tsx`. + +Example usage:
+`npm run dev -- -f /assets/sunset.jpg -u` + +### `npm run info` + +Logs the installed Node.js and npm version, environment platform, architecture and V8 version. + +### `npm run build` + +Builds JavaScript, `.d.ts` declaration files, and map files from the TypeScript source files in the `/src` directory to the `/dist` directory. + +### `npm run types:check` + +Runs type-checking without generating the JavaScript or declaration files from the TypeScript files in the `/src` directory. + +### `npm run lint` +Lints TypeScript source codes. + +### `npm run lint:fix` +Fixes lint errors in TypeScript files. + +### `npm run watch` + +Watches file changes in `.ts` files using the `tsc --watch` option. + +### `npm run docker:watch:win` + +Watches file changes in `.ts` files using the `tsc --watch` option with `dynamicPriorityPolling` in Docker containers running in Windows WSL2. + +## ๐Ÿงพ Code Samples + +### A. Optimize an Image + +```typescript +import { join } from 'node:path' +import dotenv from 'dotenv' +import { CloudinaryImage } from '@/lib/image.js' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + await image.upload('sea,travel') + await image.optimize(600) + await image.delete() +} + +main() +``` + +### B. Apply Image Transformations + +```typescript +import dotenv from 'dotenv' +import { CloudinaryImage } from '@/lib/image.js' +import { join } from 'node:path' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + // Upload image to Cloudinary + await image.upload('sea,travel') + + // Generate URL of resized image + const urlResize = await image.transformer + .resize(image.publicId, { + width: 450 + }) + + // Generate URL of cropped image + const urlCropped = await image.transformer + .crop(image.publicId, { + width: 400, + height: 200, + crop: 'scale' + }) + + // Generate URL of image's new format + const urlFormat = await image.transformer + .format(image.publicId, 'webp') + + // Generate URL of image with improved quality + const urlQuality = await image.transformer + .quality(image.publicId, 'auto') + + // Download one of the generated images + const downloadFilePath = join(process.cwd(), image.name) + await image.service.fetch(urlCropped, downloadFilePath) +} + +main() +``` + +### C. Using Classes + +```typescript +import { join } from 'node:path' + +import { AssetManager } from '@/lib/cloudinary/manager.js' +import { AssetService } from '@/lib/cloudinary/service.js' +import { BaseImage } from '@/lib/cloudinary/baseimage.js' +import { Transform } from '@/lib/cloudinary/transform.js' + +// Class for managing Cloudinary assets +const _manager = new AssetManager() + +// Class for uploading and fetching images from Cloudinary +const _service = new AssetService() + +// Class for generating Cloudinary image transformations +const _transformer = new Transform() + +// Initialize a new BaseImage - no Cloudinary libraries +const inputFile = join(process.cwd(), 'boat.jpg') +const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg') + +const _image = new BaseImage({ + localFile: inputFile, + cloudinaryAssetFolder: 'my-folder', + localDestination: outputFile, // optional +}) + +// Note: the CloudinaryImage class is composed of all these components +``` + +## References + +- [Cloudinary NPM Registry](https://www.npmjs.com/package/cloudinary) +- [Cloudinary Node.js Docs](https://cloudinary.com/documentation/node_image_manipulation) + @weaponsforge
20260817 diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..07493cb --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,29 @@ +node_modules/ +dist/ +.git +.github +.vscode +.dockerignore +Dockerfile +*.zip +*.rar +*.tgz +*.txt +*.d.ts +*.map +.env* +*.json +*.blob +*.md +src/**/*.js + +LICENSE +README.md + +!package.json +!package-lock.json +!tsconfig.json +!.env.example + +assets/ + diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..2660520 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,3 @@ +CLOUDINARY_NAME=YOUR_CLOUDINARY_ACCOUNT_NAME +CLOUDINARY_API_KEY=YOUR_CLOUDINARY_API_KEY +CLOUDINARY_API_SECRET=YOUR_CLOUDINARY_API_SECRET \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore index a9e1a12..fd20608 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -7,5 +7,18 @@ dist/ *.txt *.d.ts *.map +*.png +*.jpg +*.jpeg +*.webp .env* src/**/*.js + +!.env.example + +assets/** +!assets/bridge.jpg +!assets/sunset.jpg + +LICENSE +README.md diff --git a/app/Dockerfile b/app/Dockerfile index af0f847..ef04841 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -11,7 +11,7 @@ COPY package*.json ./ FROM base AS build RUN npm ci COPY --chown=node:node . ./ -RUN npm run transpile +RUN npm run build # Development target profile FROM base AS development @@ -29,4 +29,4 @@ RUN npm ci --only=production && npm cache clean --force COPY --chown=node:node --from=build /opt/app/dist /opt/app/dist USER node -CMD ["sh"] +ENTRYPOINT ["node", "/opt/app/dist/scripts/optimize/index.js"] diff --git a/app/assets/bridge.jpg b/app/assets/bridge.jpg new file mode 100644 index 0000000..adad8b4 Binary files /dev/null and b/app/assets/bridge.jpg differ diff --git a/app/assets/sunset.jpg b/app/assets/sunset.jpg new file mode 100644 index 0000000..bcd50a7 Binary files /dev/null and b/app/assets/sunset.jpg differ diff --git a/app/package-lock.json b/app/package-lock.json index 23bd658..effeda4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,13 +1,20 @@ { - "name": "app", + "name": "cloudinary-image", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "app", + "name": "cloudinary-image", "version": "1.0.0", "license": "MIT", + "dependencies": { + "cloudinary": "^2.10.0", + "dotenv": "^17.4.2" + }, + "bin": { + "optimize": "dist/scripts/optimize/index.js" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.2.0", @@ -1097,6 +1104,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/cloudinary": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz", + "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -1160,6 +1179,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1705,6 +1736,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/app/package.json b/app/package.json index 4abe120..fec9372 100644 --- a/app/package.json +++ b/app/package.json @@ -1,23 +1,55 @@ { - "name": "app", + "name": "cloudinary-image", "version": "1.0.0", - "description": "", - "license": "MIT", - "author": "weaponsforge", + "description": "Cloudinary image library wrapper and CLI for optimization and transformations", "type": "module", - "main": "index.js", + "main": "dist/index.js", "engines": { "node": ">=24" }, + "bin": { + "optimize": "./dist/scripts/optimize/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!dist/demo", + "README.md", + "LICENSE" + ], "scripts": { + "start": "node ./dist/scripts/optimize/index.js", + "dev": "tsx ./src/scripts/optimize", "build": "tsc -p tsconfig.json && tsc-alias", "types:check": "tsc -p tsconfig.json --noEmit", "watch": "tsc -p tsconfig.json --watch", "lint": "eslint \"src/**/*.ts\" *.mjs", "lint:fix": "eslint \"src/**/*.ts\" *.mjs --fix", - "docker:debug": "export IS_DOCKER=true && node --inspect=0.0.0.0:9229 --import tsx src/utils/sample.ts", - "docker:watch:win": "tsc -p tsconfig.json --watch --watchFile dynamicPriorityPolling --watchDirectory dynamicPriorityPolling" + "docker:debug": "export IS_DOCKER=true && node --inspect=0.0.0.0:9229 --import tsx src/scripts/optimize/index.ts", + "docker:watch:win": "tsc -p tsconfig.json --watch --watchFile dynamicPriorityPolling --watchDirectory dynamicPriorityPolling", + "info": "tsx ./src/scripts/envinfo" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/weaponsforge/cloudinary-image.git" }, + "keywords": [ + "cloudinary", + "image", + "transformations", + "cli" + ], + "author": "weaponsforge", + "license": "MIT", + "bugs": { + "url": "https://github.com/weaponsforge/cloudinary-image/issues" + }, + "homepage": "https://github.com/weaponsforge/cloudinary-image#readme", "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^26.2.0", @@ -27,5 +59,9 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0" + }, + "dependencies": { + "cloudinary": "^2.10.0", + "dotenv": "^17.4.2" } } diff --git a/app/src/demo/classes.ts b/app/src/demo/classes.ts new file mode 100644 index 0000000..b534c9a --- /dev/null +++ b/app/src/demo/classes.ts @@ -0,0 +1,27 @@ +import { join } from 'node:path' + +import { AssetManager } from '@/lib/cloudinary/manager.js' +import { AssetService } from '@/lib/cloudinary/service.js' +import { BaseImage } from '@/lib/cloudinary/baseimage.js' +import { Transform } from '@/lib/cloudinary/transform.js' + +// Class for managing Cloudinary assets +const _manager = new AssetManager() + +// Class for uploading and fetching images from Cloudinary +const _service = new AssetService() + +// Class for generating Cloudinary image transformations +const _transformer = new Transform() + +// Initialize a new BaseImage - no Cloudinary libraries +const inputFile = join(process.cwd(), 'boat.jpg') +const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg') + +const _image = new BaseImage({ + localFile: inputFile, + cloudinaryAssetFolder: 'my-folder', + localDestination: outputFile, // optional +}) + +// Note: the CloudinaryImage class is composed of all these components diff --git a/app/src/demo/quickstart.ts b/app/src/demo/quickstart.ts new file mode 100644 index 0000000..1a7f740 --- /dev/null +++ b/app/src/demo/quickstart.ts @@ -0,0 +1,20 @@ +import dotenv from 'dotenv' +import { CloudinaryImage } from '@/lib/image.js' +import { join } from 'node:path' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + await image.upload('sea,travel') + await image.optimize(600) + await image.delete() +} + +main() diff --git a/app/src/demo/transformations.ts b/app/src/demo/transformations.ts new file mode 100644 index 0000000..9d715bd --- /dev/null +++ b/app/src/demo/transformations.ts @@ -0,0 +1,44 @@ +import dotenv from 'dotenv' +import { CloudinaryImage } from '@/lib/image.js' +import { join } from 'node:path' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + // await image.upload('sea,travel') + + // Generate URL of resized image + const _urlResize = await image.transformer + .resize(image.publicId, { + width: 450, + }) + + // Generate URL of cropped image + const urlCropped = await image.transformer + .crop(image.publicId, { + width: 400, + height: 200, + crop: 'scale', + }) + + // Generate URL of image's new format + const _urlFormat = await image.transformer + .format(image.publicId, 'webp') + + // Generate URL of image with improved quality + const _urlQuality = await image.transformer + .quality(image.publicId, 'auto') + + // Download one of the generated images + const downloadFilePath = join(process.cwd(), image.name) + await image.service.fetch(urlCropped, downloadFilePath) +} + +main() diff --git a/app/src/index.ts b/app/src/index.ts new file mode 100644 index 0000000..ffad053 --- /dev/null +++ b/app/src/index.ts @@ -0,0 +1,5 @@ +export { AssetManager } from './lib/cloudinary/manager.js' +export { AssetService } from './lib/cloudinary/service.js' +export { BaseImage } from './lib/cloudinary/baseimage.js' +export { CloudinaryImage } from './lib/image.js' +export { Transform } from './lib/cloudinary/transform.js' diff --git a/app/src/lib/cloudinary/baseimage.ts b/app/src/lib/cloudinary/baseimage.ts new file mode 100644 index 0000000..696aa7c --- /dev/null +++ b/app/src/lib/cloudinary/baseimage.ts @@ -0,0 +1,99 @@ +import { join } from 'node:path' +import { v2 as cloudinary } from 'cloudinary' + +import { createDirectory, getFileName } from '@/utils/helpers.js' +import type { ImageLocation } from '@/types/types.js' + +const CLASS_NAME = 'BASE IMAGE' + +/** + * Base image class with local file definitions and metadata. + */ +export class BaseImage { + location: ImageLocation = { + localFile: '', + localDestination: '', + publicId: '', + cloudinaryAssetFolder: '', + } + + name: string = '' + + constructor (options: ImageLocation) { + this.initialize(options) + + // Initialize Cloudinary + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }) + } + + initialize (options: ImageLocation) { + if (!options.localFile) { + throw new Error(`${CLASS_NAME}: missing localFile input`) + } + + const fileName = getFileName(options.localFile) + this.name = getFileName(options.localFile) + this.location = options + + if (!this.location.publicId) { + const endIndex = fileName.indexOf('.') >= 1 + ? fileName.indexOf('.') + : fileName.length + + this.location.publicId = fileName.substring(0, endIndex) + } + + if (!this.location.localDestination) { + // Create a "processed" directory relative to the file + createDirectory(this.location.localFile) + .then(destDir => { + const destFile = join(destDir, fileName) + this.location.localDestination = destFile + }) + .catch(err => { + throw new Error( + `${CLASS_NAME}: Failed to create destination directory`, { + cause: err, + }) + }) + } else { + this.location.localDestination = join(this.location.localDestination, `${fileName}`) + } + } + + log (message: string) { + console.log(`[${this.name}]: ${message}`) + } + + /** + * Sets the internal publicId + */ + set publicId (publicId: string) { + this.location.publicId = publicId + } + + /** + * Returns the internal publicId + */ + get publicId () { + return this.location.publicId ?? '-' + } + + /** + * Sets the cloudinaryAssetFolder + */ + set cloudinaryAssetFolder (assetFolder: string) { + this.location.cloudinaryAssetFolder = assetFolder + } + + /** + * Returns the internal cloudinaryAssetFolder + */ + get cloudinaryAssetFolder () { + return this.location.cloudinaryAssetFolder ?? '-' + } +} diff --git a/app/src/lib/cloudinary/manager.ts b/app/src/lib/cloudinary/manager.ts new file mode 100644 index 0000000..8e4aaad --- /dev/null +++ b/app/src/lib/cloudinary/manager.ts @@ -0,0 +1,62 @@ +import { v2 as cloudinary, type AdminAndPublishOptions } from 'cloudinary' +import { handleThrowError } from '@/utils/helpers.js' + +const METHODS = { + URL: 'GET URL', + RESOURCE: 'GET RESOURCE', + DELETE_RESOURCES: 'DELETE RESOURCES', +} + +/** + * Wrapper around the Cloudinary Admin API for managing assets + * @see https://cloudinary.com/documentation/node_asset_administration + * @see https://cloudinary.com/documentation/admin_api + */ +export class AssetManager { + /** + * Retrieves the Cloudinary URL of an asset by `public_id` + * @param publicId - Cloudinary image public ID + * @returns Cloudinary asset URL + */ + async getUrl (publicId: string) { + try { + return await cloudinary.url(publicId) + } catch (err) { + return handleThrowError(err, METHODS.URL) + } + } + + /** + * Retrieves the metadata of a single Cloudinary resource (asset) by `public_id` + * @see https://cloudinary.com/documentation/admin_api#get_details_of_a_single_resource_by_public_id + * @param publicId - Cloudinary image public ID + * @param options + * @returns + */ + async getResource ( + publicId: string, + options?: AdminAndPublishOptions | undefined, + ) { + try { + return await cloudinary.api.resource(publicId, options) + } catch (err) { + return handleThrowError(err, METHODS.RESOURCE) + } + } + + /** + * Deletes resources by a list of `public_ids` + * @see https://cloudinary.com/documentation/admin_api#delete_resources + * @param publicIds - List of `public_ids` + */ + async deleteResources (publicIds: string[] = []) { + if (publicIds.length === 0) return + + try { + const result = await cloudinary.api.delete_resources(publicIds, { invalidate: true }) + console.log(result) + } catch (err) { + return handleThrowError(err, METHODS.DELETE_RESOURCES) + } + } +} diff --git a/app/src/lib/cloudinary/service.ts b/app/src/lib/cloudinary/service.ts new file mode 100644 index 0000000..500b8b2 --- /dev/null +++ b/app/src/lib/cloudinary/service.ts @@ -0,0 +1,87 @@ +import { v2 as cloudinary } from 'cloudinary' +import { handleThrowError, writeToFileBuffer } from '@/utils/helpers.js' +import type { DeliveryType, ResourceType, UploadApiOptions } from 'cloudinary' + +const METHODS = { + UPLOAD: 'UPLOAD', + FETCH: 'FETCH', + DELETE: 'DELETE', +} + +type DeleteOptions = { + resource_type?: ResourceType + type?: DeliveryType + invalidate?: boolean +} + +type DeleteCallback = Parameters[1] + +/** + * Wrapper around the Cloudinary Upload API for uploading and fetching assets + * @see https://cloudinary.com/documentation/image_upload_api_reference + */ +export class AssetService { + /** + * Uploads an asset + * @param file - Local file path of asset to upload + * @param options - Cloudinary Uploader API `upload()` options + * @returns + */ + async upload (file: string, options: UploadApiOptions) { + try { + return await cloudinary.uploader.upload(file, options) + } catch (err) { + return handleThrowError(err, METHODS.UPLOAD) + } + } + + /** + * Deletes an asset + * @param publicId - Cloudinary image public ID + * @param options - Cloudinary Uploader API `destroy()` options + * @param callback - Function callback + * @returns + */ + async delete ( + publicId: string, + options?: DeleteOptions, + callback?: DeleteCallback, + ) { + try { + return await cloudinary.uploader.destroy(publicId, options, callback) + } catch (err) { + return handleThrowError(err, METHODS.DELETE) + } + } + + /** + * Downloads a Cloudinary asset to local disk + * @param url - Public acessible Cloudinary URL to an asset + * @param filePath - Local file path in which to save the asset + * @param extension - (Optional) file extension for downloaded files + */ + async fetch (url: string, filePath: string, extension?: string) { + if (!url) throw new Error('Undefined URL') + if (!filePath) throw new Error('Undefined filePath') + let downloadFilePath = filePath + + if (extension) { + downloadFilePath = filePath.substring(0, filePath.lastIndexOf('.') + 1) + extension + } + + try { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to fetch asset ${response.status}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + writeToFileBuffer(downloadFilePath, buffer) + + console.log(`[${METHODS.FETCH}]: File downloaded in\n${downloadFilePath}`) + } catch (err: unknown) { + handleThrowError(err, METHODS.FETCH) + } + } +} diff --git a/app/src/lib/cloudinary/transform.ts b/app/src/lib/cloudinary/transform.ts new file mode 100644 index 0000000..c51f3ae --- /dev/null +++ b/app/src/lib/cloudinary/transform.ts @@ -0,0 +1,172 @@ +import { v2 as cloudinary } from 'cloudinary' +import { handleThrowError } from '@/utils/helpers.js' +import type { + ImageTransformationOptions, + TransformationOptions, +} from '@/types/types.js' + +const METHODS = { + GENERAL: 'GENERAL TRANSFORM', + RESIZE: 'RESIZE', + CROP: 'CROP', + FORMAT: 'FORMAT', + QUALITY: 'QUALITY', +} + +/** + * Wrapper around the Cloudinary URL API for image transformations + * @see https://cloudinary.com/documentation/node_image_manipulation + * @see https://cloudinary.com/documentation/image_transformations + */ +export class Transform { + /** + * Cloudinary.url() transformation wrapper + * @param publicId - Cloudinary image public ID + * @param transformation - Cloudinary URL transformation options + * @returns + */ + async transform (publicId: string, transformation: TransformationOptions) { + try { + return await cloudinary.url(publicId, { transformation }) + } catch (err) { + return handleThrowError(err, METHODS.GENERAL) + } + } + + /** + * Changes an asset's size by editing its width and/or height + * @param publicId - Cloudinary image public ID + * @param options - `ImageSize` Cloudinary URL transformation options + * @param options.width - Image width to resize + * @param options.height - Image height to resize + * @returns Cloudinary image URL of the resized image + */ + async resize ( + publicId: string, + options: ImageTransformationOptions, + ) { + const { width, height } = options + const hasWidth = Boolean(width) + const hasHeight = Boolean(height) + + if (!hasWidth && !hasHeight) { + throw new Error('One of width or height is required') + } + + const transformation: ImageTransformationOptions = { + ...(hasWidth && { width }), + ...(hasHeight && { height }), + crop: 'scale', + } + + try { + const result = await this.transform(publicId, transformation) + console.log(`[${METHODS.RESIZE}]: Success`, result) + + return result + } catch (err: unknown) { + return handleThrowError(err, METHODS.RESIZE) + } + } + + /** + * Crops an image + * @param publicId - Cloudinary image public ID + * @param options - `ImageCropOptions` Cloudinary URL transformation options for + * @param options.width - Image width to resize + * @param options.height - Image height to resize + * @param options.crop - Decides how the image will fill the width and height. + * Possible values: scale | pad | thumb | fill + * @param options.gravity - Gravitates focus to the most important part of the picture. + * Possible values: auto | face | south_west | southe_east | north_east, etc + * @returns Cloudinary URL of the cropped image + */ + async crop ( + publicId: string, + options: ImageTransformationOptions, + ) { + if (!('width' in options) || !('height' in options)) { + throw new Error(`${METHODS.CROP}: Required width and height`) + } + + const { + width, + height, + crop = 'pad', + } = options + + try { + const result = await this.transform(publicId, { + width, + height, + crop, + }) + + console.log(`[${METHODS.CROP}]: Success`, result) + + return result + } catch (error) { + return handleThrowError(error, METHODS.CROP) + } + } + + /** + * Convert assets to other formats. + * Specify image and video format, eg., on native mobile based on device capabilities. + * Allows cloudinary to deliver the optimal format for + * web delivery scenarios with f_auto according to device + * @param publicId - Cloudinary image public ID + * @param format - Image format + * @returns + */ + async format ( + publicId: string, + format: string = 'auto', + options?: ImageTransformationOptions, + ) { + try { + const transformation = { + fetch_format: format, + ...(options && { options }), + } + + const result = await this.transform(publicId, transformation) + console.log(`[${METHODS.FORMAT}]: Success`, result) + + return result + } catch (error) { + return handleThrowError(error, METHODS.FORMAT) + } + } + + /** + * Controls the visual quality and compression level of assets. + * Allows Cloudinary to deliver the optimal quality for each viewing device with q_auto + * @param publicId - Cloudinary image public ID + * @param quality - Cloudinary Image quality + * @param options - Cloudinary Image transform options + * @returns + */ + async quality ( + publicId: string, + quality: string = 'auto', + options?: ImageTransformationOptions[], + ) { + try { + let transformation: ImageTransformationOptions[] = [] + + if (Array.isArray(options)) { + transformation = [...options] + } + + transformation.push({ quality }) + + const result = await this.transform(publicId, transformation) + console.log(`[${METHODS.QUALITY}]: Success`, result) + + return result + } catch (error) { + return handleThrowError(error, METHODS.QUALITY) + } + } +} diff --git a/app/src/lib/image.ts b/app/src/lib/image.ts new file mode 100644 index 0000000..dcded19 --- /dev/null +++ b/app/src/lib/image.ts @@ -0,0 +1,109 @@ +import { BaseImage } from './cloudinary/baseimage.js' +import { AssetService } from './cloudinary/service.js' +import { Transform } from './cloudinary/transform.js' +import { AssetManager } from './cloudinary/manager.js' +import { handleThrowError } from '@/utils/helpers.js' + +import type { UploadApiResponse } from 'cloudinary' +import type { ImageTransformationOptions, ImageLocation } from '@/types/types.js' + +const METHODS = { + UPLOAD: 'CLOUD-IMAGE-UPLOAD', + OPTIMIZE: 'CLOUD-IMAGE-OPTIMIZE', + DELETE: 'CLOUD-IMAGE-DELETE', +} + +/** + * Cloudinary image methods with local image file definitions + */ +export class CloudinaryImage extends BaseImage { + service = new AssetService() + manager = new AssetManager() + transformer = new Transform() + + meta: UploadApiResponse | null = null + + constructor (options: ImageLocation) { + super(options) + } + + /** + * Uploads the `localFile` image to the `cloudinaryAssetFolder` + * @param tags - Comma-separated string tags associate with the image + */ + async upload (tags?: string) { + try { + this.log(`Uploading ${this.location.localFile} to\n${this.cloudinaryAssetFolder}`) + + const response = await this.service.upload(this.location.localFile, { + public_id: this.publicId, + asset_folder: this.cloudinaryAssetFolder, + ...(tags && { tags }), + }) + + this.meta = response + + return response + } catch (error) { + return handleThrowError(error, METHODS.UPLOAD) + } + } + + /** + * Optimizes the image uploaded in `localFile` in `cloudinaryAssetFolder` by + * resizing it to max 800px (or retaining size if < 800) and converting to webp. + * Downloads the optimized image into a `"processed"` folder relative to the `localFile` + * @param customWidth - Custom image width + * @returns + */ + async optimize (customWidth: number = 800) { + try { + this.log('Starting optimization...') + this.log('Fetching Cloudinary resource...') + + const maxWidth = 800 + const resource = await this.manager.getResource(this.location.publicId!) + const { public_id, width } = resource + + const transformOptions: ImageTransformationOptions[] = [] + + if (width > maxWidth) { + transformOptions.push({ + width: customWidth, + crop: 'scale', + }) + } + + transformOptions.push({ + fetch_format: 'webp', + }) + + this.log('Generating optimized image URL...') + const cloudinaryURL = await this.transformer.quality( + public_id, + 'auto', + transformOptions, + ) + + this.log('Fetching optimized image...') + + return await this.service.fetch(cloudinaryURL, this.location.localDestination!, 'webp') + } catch (error) { + handleThrowError(error, METHODS.OPTIMIZE) + } + } + + /** + * Deletes this image asset + */ + async delete () { + try { + this.log(`Deleting ${this.publicId}...`) + const result = await this.service.delete(this.publicId, { invalidate: true }) + + console.log(result) + } catch (err) { + handleThrowError(err, METHODS.DELETE) + } + } +} diff --git a/app/src/main.ts b/app/src/main.ts deleted file mode 100644 index 371fdfb..0000000 --- a/app/src/main.ts +++ /dev/null @@ -1 +0,0 @@ -console.log('hello') diff --git a/app/src/scripts/envinfo/envinfo.ts b/app/src/scripts/envinfo/envinfo.ts new file mode 100644 index 0000000..7689ee7 --- /dev/null +++ b/app/src/scripts/envinfo/envinfo.ts @@ -0,0 +1,15 @@ +import { execSync } from 'child_process' + +export const envinfo = () => { + console.log('Node version:', process.version) + console.log('Platform:', process.platform) + console.log('Arch:', process.arch) + console.log('V8 version:', process.versions.v8) + + try { + console.log('npm version:', execSync('npm -v').toString().trim()) + } catch { + console.log('npm version: unavailable') + } +} + diff --git a/app/src/scripts/envinfo/index.ts b/app/src/scripts/envinfo/index.ts new file mode 100644 index 0000000..1c236c9 --- /dev/null +++ b/app/src/scripts/envinfo/index.ts @@ -0,0 +1,3 @@ +import { envinfo } from './envinfo.js' + +envinfo() diff --git a/app/src/scripts/optimize/index.ts b/app/src/scripts/optimize/index.ts new file mode 100644 index 0000000..9bb4e7a --- /dev/null +++ b/app/src/scripts/optimize/index.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import { parseArgs } from 'node:util' +import { optimize } from './optimize.js' + +const { values } = parseArgs({ + args: process.argv.slice(2), + allowPositionals: true, + options: { + file: { // Path to input image file + type: 'string', + short: 'f', + }, + assetfolder: { // Cloudinary asset folder (optional). Defaults to "image-optimizer" + type: 'string', + short: 'a', + }, + outputFolder: { // Local image download folder (optional) + type: 'string', + short: 'o', + }, + tags: { // Comma-separated Cloudinary image tags (optional) + type: 'string', + short: 't', + }, + width: { // Image width to resize the input image (optional), default=800px + type: 'string', + short: 'w', + }, + upload: { // Flag to upload the input inmage to Cloudinary. + type: 'boolean', // Required on first-time, optional on succeeding runs. + short: 'u', + }, + deleteAfter: { // Flag to delete the uploaded image in Cloudinary (optional) + type: 'boolean', + short: 'd', + }, + }, +}) + +if (process.env.IS_DOCKER) { + setTimeout(() => { + optimize(values) + }, 5000) +} else { + optimize(values) +} diff --git a/app/src/scripts/optimize/optimize.ts b/app/src/scripts/optimize/optimize.ts new file mode 100644 index 0000000..a224dfb --- /dev/null +++ b/app/src/scripts/optimize/optimize.ts @@ -0,0 +1,58 @@ +import { resolve } from 'node:path' +import dotenv from 'dotenv' + +import { CloudinaryImage } from '@/lib/image.js' +import { handleLogError } from '@/utils/helpers.js' + +dotenv.config() + +const log = (str: string) => { + console.log(`[OPTIMIZE] ${str}`) +} + +export const optimize = async (args: Record) => { + const { + file = '', + assetfolder = 'image-optimizer', + outputFolder = '', + tags = '', + width = 800, + upload = false, + deleteAfter = false, + } = args + + const filePath = String(file) + let outDir = null + + if (outputFolder) { + outDir = resolve(String(outputFolder)) + } + + log(`Input file:\n${filePath}`) + log(`Output folder: ${outDir}`) + log(`Cloudinary asset folder: ${assetfolder}`) + log(`Tags: ${tags}`) + log(`Image width: ${width}`) + log(` -upload? ${upload}`) + log(` -delete cloud file? ${deleteAfter}\n`) + + try { + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: String(assetfolder), + ...(outDir && { localDestination: outDir }), + }) + + if (upload) { + await image.upload(String(tags)) + } + + await image.optimize(Number(width)) + + if (deleteAfter) { + await image.delete() + } + } catch (err) { + handleLogError(err, 'ERROR') + } +} diff --git a/app/src/types/types.ts b/app/src/types/types.ts new file mode 100644 index 0000000..5187b6d --- /dev/null +++ b/app/src/types/types.ts @@ -0,0 +1,73 @@ +import type { TransformationOptions, ConfigAndUrlOptions } from 'cloudinary' +export type { ImageTransformationOptions } from 'cloudinary' +export type { TransformationOptions, ConfigAndUrlOptions } + +// ---------------------------------------------------- +// Base Image types +// ---------------------------------------------------- + +export interface ImageLocation { + localFile: string; + localDestination?: string; + publicId?: string; + cloudinaryAssetFolder?: string; +} + +// ---------------------------------------------------- +// File types +// ---------------------------------------------------- + +export interface File { + allowed_formats: 'jpg' | 'jpeg' | 'png' | 'webp' | 'bmp' | 'gif' | 'avif'; + fetch_format: 'auto' | string; +} + +export interface Filename { + use_filename?: boolean; + unique_filename?: boolean; +} + +// ---------------------------------------------------- +// Image Transformation types +// ---------------------------------------------------- + +// Custom transformation types + +export interface ImageSize { + width?: number; + height?: number; +} + +export interface TransformOptionalParams { + crop?: string; + gravity?: string; + quality?: string; + fetch_format?: string; +} + +export interface ImageCropOptions extends TransformOptionalParams { + width: number; + height: number; +} + +export interface TransformOptions + extends ImageSize, TransformOptionalParams {} + +export interface TransformationStyles { + width?: number; + height?: number; + radius?: 'max' | number; + border?: string; // eg., 10px_solid_rgb:bde4fb + background?: 'auto' | string; + effect?: string; // eg., tint:40:red, improve:outdoor, art:zorro +} + +export type CropOptions = + 'scale' | 'pad' | 'thumb' | 'fill' + +// Value could also be an object name in the image +export type GravityOptions = + 'auto' | 'face' | 'auto' | + 'north' | 'north_east' | 'nort_west' | + 'south' | 'south_east' | 'south_west' | + 'east' | 'west' diff --git a/app/src/utils/helpers.ts b/app/src/utils/helpers.ts new file mode 100644 index 0000000..71746e3 --- /dev/null +++ b/app/src/utils/helpers.ts @@ -0,0 +1,161 @@ +import { promises as fs } from 'node:fs' +import path, { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'url' + +import dotenv from 'dotenv' + +/** + * Get the full file path of the current directory of a module file equivalent to `"__dirname"` + * @param {string} moduleFile - File URL of the current module being executed: `"import.meta.url"` + * @returns {string} Full file path to the directory of the calling file/module also know as `__dirname` in CommonJS + */ +export const directory = (moduleFile: string) => { + return dirname(fileURLToPath(moduleFile)) +} + +/** + * Copies files to an output directory + * @param outDir File path to the output directory + * @param files Array containing a list of file paths + */ +export const copyFiles = async (outDir: string, files: string[]) => { + await fs.mkdir(outDir, { recursive: true }) + + for (const src of files) { + const dest = path.join(outDir, path.basename(src)) + await fs.copyFile(src, dest) + console.log(`Copied to ${dest}`) + } +} + +/** + * Extracts the filename from a full file path + * @param pathToFile - Full file path to a local file + * @returns filename + */ +export const getFileName = (pathToFile: string) => { + return basename(pathToFile) +} + +export const createDirectory = async (pathToFile: string, newDir: string = 'processed') => { + const assetDir = dirname(pathToFile) + const destDir = join(assetDir, newDir) + + await fs.mkdir(destDir, { recursive: true }) + + return destDir +} + +/** + * Writes a buffer to file on disk + * @param pathToFile - Full file path to a local file + * @param buffer + */ +export const writeToFileBuffer = (pathToFile: string, buffer: Buffer) => { + fs.writeFile(pathToFile, buffer) +} + +/** + * Loads the `.env` environment variable from a path + * @param {string} pathToEnv - Path to a `.env` file + */ +export const loadEnv = (pathToEnv: string | undefined) => { + if (!pathToEnv) return + + dotenv.config({ + path: pathToEnv, + quiet: true, + }) +} + +/** + * Re-throws an Error with log from calling function + * @param error - Error object + * @param prefix - Calling function identifier + */ +export const handleThrowError = (error: unknown, prefix: string = 'LOG'): never => { + // error is a legit Error + if (error instanceof Error) { + const msg = `[${prefix}] ${error.message}` + throw new Error(msg, { cause: error }) + } + + if ( // error is an Object + typeof error === 'object' && + error !== null && + 'message' in error + ) { + throw new Error( + `[${prefix}] ${error.message}`, + { cause: error }, + ) + } + + if ( // error is an Object with an inner "error" object + typeof error === 'object' && + error !== null && + 'error' in error + ) { + const { error: innerError } = error + const { message } = innerError as { message: string; http_code: number } + + throw new Error( + `[${prefix}] ${message}`, + { cause: error }, + ) + } + + const msgUnknown = `[${prefix}] An unknown error occured` + throw new Error(msgUnknown, { cause: error }) +} + +/** + * Logs an Error with log from calling function + * @param error - Error object + * @param prefix - Calling function identifier + */ +export const handleLogError = (error: unknown, prefix: string = 'LOG') => { + if (error instanceof Error) { + console.log(`[${prefix}] ${String(error.message)}`) + } else if ( + typeof error === 'object' && + error !== null && + 'message' in error + ) { + console.log(`[${prefix}] ${String(error.message)}`) + } else if ( + typeof error === 'object' && + error !== null && + 'error' in error + ) { + const { error: innerError } = error + const { message } = innerError as { message: string; http_code: number } + console.log(`[${prefix}] ${message}`) + } else { + console.log(`[${prefix}] An unknown error occured`) + } +} + +/** + * Tiny arg parser for `--key=value` style flags + * @param {string[]} args - Raw args, typically process.argv.slice(2) + * @returns {Object} Parsed key/value pairs + */ +export const parseArgs = (args: string[]): Record => { + const result: Record = {} + + for (const arg of args) { + if (!arg.startsWith('--')) continue + + const [key, ...values] = arg.slice(2).split('=') + + if (key) { + result[key] = values.length > 0 + ? values.join('=') + : true // supports flags with no value, e.g. --verbose + } + } + + return result +} + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a9f387a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,16 @@ +# Docker compose file for building the production image only +services: + cloudinary-cli: + container_name: cloudinary-cli + image: weaponsforge/cloudinary-cli + env_file: + - ./app/.env + build: + context: ./app + dockerfile: Dockerfile + target: production + volumes: + - ./app/assets:/opt/app/assets + - /opt/app/node_modules + environment: + - NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 290aa41..63ae2e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ # Docker compose file for local development mode services: - cloudinary-cli: - container_name: cloudinary-cli + cloudinary-cli-dev: + container_name: cloudinary-cli-dev image: weaponsforge/cloudinary-cli:dev env_file: - - ./.env + - ./app/.env build: - context: . + context: ./app dockerfile: Dockerfile target: development volumes: - - .:/opt/app + - ./app:/opt/app - /opt/app/node_modules ports: - "9229:9229" # VSCode debugger diff --git a/docs/README_NPM.md b/docs/README_NPM.md new file mode 100644 index 0000000..6c31bb4 --- /dev/null +++ b/docs/README_NPM.md @@ -0,0 +1,148 @@ +## cloudinary-image + +Cloudinary image optimization CLI with Node.js wrappers for select [Cloudinary Node.js APIs](https://cloudinary.com/documentation/node_image_manipulation)
+covering image transformation, optimization, and asset management. + +### Requirements + +- Node v24+ +- Cloudinary account + +## ๐Ÿ†• Quickstart + +1. Install the library. + + ```sh + npm i cloudinary-image + ``` + +2. Create a `.env` file in the `/app` directory, replacing the contents of the `.env.example` file with actual values. + + | Variable Name | Description | + | --- | --- | + | CLOUDINARY_NAME | Cloudinary account name | + | CLOUDINARY_API_KEY | Cloudinary API key | + | CLOUDINARY_API_SECRET | Cloudinary API secret | + +3. Optimize images programmatically via code. See the examples under the [Code Samples](#-code-samples) section for more information. + +
+ +## ๐Ÿงพ Code Samples + +### A. Optimize an Image + +```typescript +import { join } from 'node:path' +import dotenv from 'dotenv' +import { CloudinaryImage } from 'cloudinary-image' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + await image.upload('sea,travel') + await image.optimize(600) + await image.delete() +} + +main() +``` + +### B. Apply Image Transformations + +```typescript +import dotenv from 'dotenv' +import { CloudinaryImage } from 'cloudinary-image' +import { join } from 'node:path' + +dotenv.config() + +const main = async () => { + const filePath = join(process.cwd(), 'boat.jpg') + + const image = new CloudinaryImage({ + localFile: filePath, + cloudinaryAssetFolder: 'my-folder', + }) + + // Upload image to Cloudinary + await image.upload('sea,travel') + + // Generate URL of resized image + const urlResize = await image.transformer + .resize(image.publicId, { + width: 450 + }) + + // Generate URL of cropped image + const urlCropped = await image.transformer + .crop(image.publicId, { + width: 400, + height: 200, + crop: 'scale' + }) + + // Generate URL of image's new format + const urlFormat = await image.transformer + .format(image.publicId, 'webp') + + // Generate URL of image with improved quality + const urlQuality = await image.transformer + .quality(image.publicId, 'auto') + + // Download one of the generated images + const downloadFilePath = join(process.cwd(), image.name) + await image.service.fetch(urlCropped, downloadFilePath) +} + +main() +``` + +### C. Using Classes + +```typescript +import { join } from 'node:path' + +import { + AssetManager, + AssetService, + BaseImage, + Transform +} from 'cloudinary-image' + +// Class for managing Cloudinary assets +const _manager = new AssetManager() + +// Class for uploading and fetching images from Cloudinary +const _service = new AssetService() + +// Class for generating Cloudinary image transformations +const _transformer = new Transform() + +// Initialize a new BaseImage - no Cloudinary libraries +const inputFile = join(process.cwd(), 'boat.jpg') +const outputFile = join(process.cwd(), 'images', 'done', 'processed.jpg') + +const _image = new BaseImage({ + localFile: inputFile, + cloudinaryAssetFolder: 'my-folder', + localDestination: outputFile, // optional +}) + +// Note: the CloudinaryImage class is composed of all these components +``` + +## References + +- [Cloudinary NPM Registry](https://www.npmjs.com/package/cloudinary) +- [Cloudinary Node.js Docs](https://cloudinary.com/documentation/node_image_manipulation) + +@weaponsforge
+20260819