├── .all-contributorsrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── test_release.yml ├── .gitignore ├── .nvmrc ├── .releaserc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── README.md ├── app │ ├── app │ │ └── page.tsx │ └── layout.tsx ├── data │ └── images.json ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ └── index.tsx ├── public │ └── images │ │ ├── beach.jpeg │ │ ├── city.jpeg │ │ ├── earth.jpeg │ │ ├── galaxy.jpeg │ │ ├── iceberg.jpeg │ │ ├── jungle.jpeg │ │ ├── mountain.jpeg │ │ ├── test │ │ └── beach.jpeg │ │ └── waterfall.jpeg ├── styles │ ├── Home.module.css │ └── globals.css └── tsconfig.json ├── docs ├── .github │ └── screenshot.png ├── .gitignore ├── LICENSE ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-1024x1024.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── images │ │ ├── cloudinary-logo-dark.svg │ │ ├── cloudinary-logo-light.svg │ │ ├── netlify-cloudinary-logos.svg │ │ ├── netlify-logo-dark.svg │ │ └── netlify-logo-light.svg │ ├── screenshots │ │ └── netlify-cloudinary-install-website.png │ └── site.webmanifest ├── src │ ├── components │ │ ├── Button │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ └── OgImage │ │ │ ├── OgImage.tsx │ │ │ └── index.ts │ ├── pages │ │ ├── _app.mdx │ │ ├── _meta.json │ │ ├── configuration.mdx │ │ ├── guides │ │ │ ├── _meta.json │ │ │ ├── delivery-type.mdx │ │ │ └── images-path.mdx │ │ ├── index.mdx │ │ ├── installation.mdx │ │ └── troubleshooting │ │ │ └── 401-error.mdx │ └── styles │ │ └── globals.css ├── tailwind.config.js ├── theme.config.tsx └── tsconfig.json ├── netlify-plugin-cloudinary ├── .eslintignore ├── .eslintrc.cjs ├── .npmignore ├── .prettierignore ├── .prettierrc ├── manifest.yml ├── package.json ├── src │ ├── data │ │ ├── analytics.ts │ │ ├── cloudinary.ts │ │ └── errors.ts │ ├── index.ts │ ├── lib │ │ ├── cloudinary.ts │ │ └── util.ts │ └── types │ │ └── integration.ts ├── tests │ ├── assets │ │ └── stranger-things-lucas.jpeg │ ├── images │ │ ├── stranger-things-dustin.jpeg │ │ └── stranger-things-eleven.jpeg │ ├── lib │ │ ├── cloudinary-cname.test.js │ │ ├── cloudinary-privatecdn.test.js │ │ ├── cloudinary.test.js │ │ └── util.test.js │ ├── mocks │ │ ├── demo.json │ │ └── html │ │ │ ├── test-0.html │ │ │ └── test-1.html │ ├── on-build.test.js │ └── on-post-build.test.js └── tsconfig.json ├── netlify.toml ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "colbyfayock", 11 | "name": "Colby Fayock", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/1045274?v=4", 13 | "profile": "https://colbyfayock.com/newsletter", 14 | "contributions": [ 15 | "code", 16 | "doc", 17 | "example" 18 | ] 19 | }, 20 | { 21 | "login": "Chuloo", 22 | "name": "William", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/22301208?v=4", 24 | "profile": "https://github.com/Chuloo", 25 | "contributions": [ 26 | "code" 27 | ] 28 | }, 29 | { 30 | "login": "ascorbic", 31 | "name": "Matt Kane", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/213306?v=4", 33 | "profile": "http://mk.gg", 34 | "contributions": [ 35 | "review" 36 | ] 37 | }, 38 | { 39 | "login": "donno2048", 40 | "name": "Elisha Hollander", 41 | "avatar_url": "https://avatars.githubusercontent.com/u/61805754?v=4", 42 | "profile": "https://donno2048.github.io/Portfolio/", 43 | "contributions": [ 44 | "code" 45 | ] 46 | }, 47 | { 48 | "login": "Kunal-8789", 49 | "name": "Kunal Kaushik", 50 | "avatar_url": "https://avatars.githubusercontent.com/u/76679262?v=4", 51 | "profile": "https://github.com/Kunal-8789", 52 | "contributions": [ 53 | "tool" 54 | ] 55 | }, 56 | { 57 | "login": "shikhar13012001", 58 | "name": "Shikhar", 59 | "avatar_url": "https://avatars.githubusercontent.com/u/75368010?v=4", 60 | "profile": "https://portfolio-shikhar13012001.vercel.app/", 61 | "contributions": [ 62 | "code", 63 | "test" 64 | ] 65 | }, 66 | { 67 | "login": "developer-diganta", 68 | "name": "Diganta Kr Banik", 69 | "avatar_url": "https://avatars.githubusercontent.com/u/65999534?v=4", 70 | "profile": "https://digantakrbanik.codes/", 71 | "contributions": [ 72 | "code", 73 | "doc", 74 | "test" 75 | ] 76 | }, 77 | { 78 | "login": "3t8", 79 | "name": "3t8", 80 | "avatar_url": "https://avatars.githubusercontent.com/u/62209650?v=4", 81 | "profile": "https://github.com/3t8", 82 | "contributions": [ 83 | "doc" 84 | ] 85 | }, 86 | { 87 | "login": "Abubakrce19", 88 | "name": "Abubakrce19", 89 | "avatar_url": "https://avatars.githubusercontent.com/u/104122959?v=4", 90 | "profile": "https://github.com/Abubakrce19", 91 | "contributions": [ 92 | "doc" 93 | ] 94 | }, 95 | { 96 | "login": "ericapisani", 97 | "name": "Erica Pisani", 98 | "avatar_url": "https://avatars.githubusercontent.com/u/5655473?v=4", 99 | "profile": "http://ericapisani.dev", 100 | "contributions": [ 101 | "code" 102 | ] 103 | }, 104 | { 105 | "login": "matiasfha", 106 | "name": "Matías Hernández Arellano", 107 | "avatar_url": "https://avatars.githubusercontent.com/u/282006?v=4", 108 | "profile": "https://matiashernandez.dev", 109 | "contributions": [ 110 | "code" 111 | ] 112 | }, 113 | { 114 | "login": "kcoderhtml", 115 | "name": "Kieran Klukas", 116 | "avatar_url": "https://avatars.githubusercontent.com/u/92754843?v=4", 117 | "profile": "https://github.com/kcoderhtml", 118 | "contributions": [ 119 | "code" 120 | ] 121 | }, 122 | { 123 | "login": "gshel", 124 | "name": "Gretchen Shelby-Dormer", 125 | "avatar_url": "https://avatars.githubusercontent.com/u/35184207?v=4", 126 | "profile": "https://gshel.org", 127 | "contributions": [ 128 | "code" 129 | ] 130 | } 131 | ], 132 | "contributorsPerLine": 7, 133 | "skipCi": true, 134 | "repoType": "github", 135 | "repoHost": "https://github.com", 136 | "projectName": "netlify-plugin-cloudinary", 137 | "projectOwner": "cloudinary-community", 138 | "commitType": "docs" 139 | } 140 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[Bug] ' 5 | labels: 'Type: Bug' 6 | --- 7 | 8 | # **Bug Report** 9 | 10 | ## **Describe the bug** 11 | 12 | 13 | 14 | ## **Is this a regression?** 15 | 16 | 17 | 18 | 19 | ## **Steps To Reproduce the error** 20 | 21 | 22 | 23 | 1. 24 | 2. 25 | 3. 26 | 4. 27 | 28 | ## **Expected behaviour** 29 | 30 | 31 | 32 | ## **CodeSandbox or Live Example of Bug** 33 | 34 | 35 | 36 | ## **Screenshot or Video Recording** 37 | 38 | 39 | 40 | 41 | ### **Your environment** 42 | 43 | 45 | 46 | - OS: 47 | - Node version: 48 | - Npm version: 49 | - Browser name and version: 50 | 51 | 52 | ## **Additional context** 53 | 54 | 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature Request" 3 | about: "Suggest an idea or possible new feature for this project." 4 | title: "[Feature] " 5 | labels: "Type: Feature" 6 | --- 7 | 8 | # **Feature Request** 9 | 10 | ## **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | 14 | ## **Describe the solution you'd like** 15 | 16 | 17 | 18 | 19 | ## **Describe alternatives you've considered** 20 | 21 | 22 | 23 | ## **Additional context** 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | 4 | 5 | ## Issue Ticket Number 6 | 7 | 8 | 9 | 10 | Fixes 11 | 12 | ## Type of change 13 | 14 | 15 | 16 | - [ ] Bug fix (non-breaking change which fixes an issue) 17 | - [ ] New feature (non-breaking change which adds functionality) 18 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 19 | - [ ] This change requires a documentation update 20 | 21 | 22 | # Checklist 23 | 24 | 25 | 26 | - [ ] I have followed the contributing guidelines of this project as mentioned in [CONTRIBUTING.md](/CONTRIBUTING.md) 27 | - [ ] I have created an [issue](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues) ticket for this PR 28 | - [ ] I have checked to ensure there aren't other open [Pull Requests](https://github.com/colbyfayock/netlify-plugin-cloudinary/pulls) for the same update/change? 29 | - [ ] I have performed a self-review of my own code 30 | - [ ] I have run tests locally to ensure they all pass 31 | - [ ] I have commented my code, particularly in hard-to-understand areas 32 | - [ ] I have made corresponding changes needed to the documentation 33 | -------------------------------------------------------------------------------- /.github/workflows/test_release.yml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | on: [push, pull_request] 3 | env: 4 | CI: false 5 | jobs: 6 | tests: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node: [ '16', '18', '20' ] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: pnpm/action-setup@v2 15 | with: 16 | version: 8 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node }} 20 | cache: 'pnpm' 21 | - run: pnpm install --frozen-lockfile 22 | - run: pnpm test 23 | env: 24 | CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} 25 | CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }} 26 | CLOUDINARY_API_SECRET: ${{ secrets.CLOUDINARY_API_SECRET }} 27 | NETLIFY_HOST: ${{ secrets.NETLIFY_HOST }} # Used to test functionality outside of the Netlify environment 28 | release: 29 | name: Release 30 | if: github.event_name == 'push' && github.ref_name == 'main' 31 | needs: tests 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: pnpm/action-setup@v2 36 | with: 37 | version: 8 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: '20' 41 | cache: 'pnpm' 42 | # https://github.com/pnpm/pnpm/issues/3141 43 | registry-url: 'https://registry.npmjs.org' 44 | - run: pnpm install --frozen-lockfile 45 | - run: pnpm --filter netlify-plugin-cloudinary build 46 | # Do not store semantic release dependencies in package.json to avoid lower node versions from failing tests 47 | - run: pnpm install -w @semantic-release/changelog @semantic-release/git semantic-release 48 | - run: npx semantic-release 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | # Local Netlify folder 4 | .netlify 5 | _next 6 | dist 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main", 4 | "next", 5 | "next-major", 6 | { 7 | "name": "beta", 8 | "prerelease": true 9 | }, 10 | { 11 | "name": "alpha", 12 | "prerelease": true 13 | } 14 | ], 15 | "plugins": [ 16 | [ 17 | "@semantic-release/commit-analyzer", 18 | { 19 | "preset": "angular", 20 | "releaseRules": [ 21 | { 22 | "type": "docs", 23 | "scope": "README", 24 | "release": "patch" 25 | } 26 | ], 27 | "parserOpts": { 28 | "noteKeywords": [ 29 | "BREAKING CHANGE", 30 | "BREAKING CHANGES" 31 | ] 32 | } 33 | } 34 | ], 35 | "@semantic-release/release-notes-generator", 36 | [ 37 | "@semantic-release/changelog", 38 | { 39 | "changelogFile": "CHANGELOG.md" 40 | } 41 | ], 42 | [ 43 | "@semantic-release/npm", 44 | { 45 | "pkgRoot": "netlify-plugin-cloudinary" 46 | } 47 | ], 48 | [ 49 | "@semantic-release/git", 50 | { 51 | "assets": [ 52 | "netlify-plugin-cloudinary/package.json", 53 | "CHANGELOG.md" 54 | ] 55 | } 56 | ], 57 | "@semantic-release/github" 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.17.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.16.0...v1.17.0) (2024-02-13) 2 | 3 | 4 | ### Features 5 | 6 | * Adds Analytics Code to URLs ([#87](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/87)) ([4940fb0](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/4940fb03386b63aa1e9a44bc9db5d04a183983bf)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#70](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/70) 7 | 8 | # [1.16.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.15.0...v1.16.0) (2023-12-04) 9 | 10 | 11 | ### Features 12 | 13 | * Consolidate onBuild tests + verify imagesPath for multiple operating systems ([#101](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/101)) ([90b20bd](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/90b20bde822c8190fe760f86dd9fee8591a2e611)), closes [#100](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/100) [#100](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/100) 14 | 15 | # [1.15.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.14.0...v1.15.0) (2023-11-13) 16 | 17 | 18 | ### Features 19 | 20 | * Get loadingStrategy value from netlify.toml inputs, default to 'lazy' if not provided ([#98](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/98)) ([8d68b05](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/8d68b05b35edaf641860822a6dad13989a582854)) 21 | 22 | # [1.14.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.13.1...v1.14.0) (2023-11-10) 23 | 24 | 25 | ### Features 26 | 27 | * Retry failed requests ([bda0cd9](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/bda0cd94da2bc870a943562d43cc47fc17a3ad15)), closes [#82](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/82) 28 | 29 | ## [1.13.1](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.13.0...v1.13.1) (2023-11-10) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * Fixes uncaught errors in concurrency loop ([#95](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/95)) ([2c6a33b](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/2c6a33b45da29a4b6a01e3b1630905989df69177)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#94](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/94) 35 | 36 | # [1.13.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.12.0...v1.13.0) (2023-11-07) 37 | 38 | 39 | ### Features 40 | 41 | * Concurrency ([#89](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/89)) ([6a8d70f](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/6a8d70f923384f4a2ddae66967b8010cff551b01)), closes [#57](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/57) 42 | 43 | # [1.12.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.11.0...v1.12.0) (2023-10-04) 44 | 45 | 46 | ### Features 47 | 48 | * throw and catch error when calls to cloudinary upload fails ([#72](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/72)) ([c90c46f](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/c90c46ffc9a7ed4bb3c00fa87606f2130cef1596)), closes [#58](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/58) 49 | 50 | # [1.11.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.10.2...v1.11.0) (2023-09-20) 51 | 52 | 53 | ### Features 54 | 55 | * Max Size ([#78](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/78)) ([57cb8fb](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/57cb8fba2d82ecc234f950dab459574d23dfafae)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#62](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/62) 56 | 57 | ## [1.10.2](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.10.1...v1.10.2) (2023-09-19) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * Fixes Netlify URL ([#84](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/84)) ([3a83908](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/3a83908fc921bfec4b2125ed2abd75c243c8d479)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#83](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/83) 63 | 64 | ## [1.10.1](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.10.0...v1.10.1) (2023-09-19) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * Fixes missing API Key and Secret failing Fetch builds ([#81](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/81)) ([cdd5981](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/cdd598177824c5a3f753cb045a7fe4d6a29d9207)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#80](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/80) 70 | 71 | # [1.10.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.9.0...v1.10.0) (2023-09-15) 72 | 73 | 74 | ### Features 75 | 76 | * Configure Multiple Image Paths ([#75](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/75)) ([e10cffa](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/e10cffa3ff269275a2a6ef3ad39cbd5756711ebc)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#61](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/61) 77 | 78 | # [1.9.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.8.0...v1.9.0) (2023-09-15) 79 | 80 | 81 | ### Features 82 | 83 | * CNAME & Private CDN ([#71](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/71)) ([17fff6b](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/17fff6b87cf69d1e2d37c1c7ddd211afd8aeba33)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#51](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/51) 84 | 85 | # [1.8.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.7.1...v1.8.0) (2023-09-06) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * cleaning readme, forcing deploy ([d9558d4](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/d9558d4c6d633490517bde2f798f3977e82d16d8)) 91 | * release ([1ce13df](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/1ce13df6402c73308257918013a72a942cd84753)) 92 | * workspace root for release ([589d257](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/589d2575fea20425a942a9ea30a78d06d2a5a2e8)) 93 | 94 | 95 | ### Features 96 | 97 | * pnpm Workspace ([#64](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/64)) ([02f92ac](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/02f92acf001149c9cf229bfb93e455ebd9a68b72)) 98 | 99 | ## [1.7.1](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.7.0...v1.7.1) (2023-09-06) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * removing postinstall to avoid breaking package installations ([a5a71cb](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/a5a71cbf99b9f0e085a91ec4d63877c27d2d4dd6)) 105 | 106 | # [1.7.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.6.0...v1.7.0) (2023-09-06) 107 | 108 | 109 | ### Features 110 | 111 | * migrate to typescript ([#63](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/63)) ([f9b960f](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/f9b960f45cce8d54b0369a53af4613cb65025d03)) 112 | 113 | # [1.6.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.5.1...v1.6.0) (2023-08-31) 114 | 115 | 116 | ### Features 117 | 118 | * Replace preload tags for related image source if exists ([#55](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/55)) ([aaa030f](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/aaa030f2ee71225b1a4b833e9a573ff42d940461)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#54](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/54) 119 | 120 | ## [1.5.1](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.5.0...v1.5.1) (2023-08-29) 121 | 122 | 123 | ### Bug Fixes 124 | 125 | * Fixes missing Netlify Host check ([#50](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/50)) ([646e1f3](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/646e1f3355530fc5a6b1458a5ef24e409f83c1be)), closes [#47](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/47) [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#45](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/45) 126 | 127 | # [1.5.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.4.0...v1.5.0) (2023-08-29) 128 | 129 | 130 | ### Features 131 | 132 | * updating package-lock ([2a89820](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/2a89820a35a6a2e72395640085c4bf83da11256c)) 133 | * Use Netlify Host with Production Context ([#47](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/47)) ([896bcda](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/896bcda6f8ceeca2d33bb9463d6a0078db729a6c)), closes [#11](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/11) [#45](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/45) 134 | 135 | # [1.4.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.3.0...v1.4.0) (2023-08-29) 136 | 137 | 138 | ### Features 139 | 140 | * Yarn to npm ([#49](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/49)) ([c7d5126](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/c7d5126e2adbb7924b99e95d5fa8a83e83e1c6b5)) 141 | 142 | # [1.3.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.2.0...v1.3.0) (2023-08-29) 143 | 144 | 145 | ### Features 146 | 147 | * Package updates, Remove fs-extra, Fix Jest ([#48](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/48)) ([65f8600](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/65f8600e65cbb8dd19feaa597cb8ba32a5d6e57e)) 148 | 149 | # [1.2.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.1.0...v1.2.0) (2022-10-14) 150 | 151 | 152 | ### Bug Fixes 153 | 154 | * Fixes asset not defined error when using upload delivery type ([#36](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/36)) ([86edaab](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/86edaab087eb9d185231e83c67cffa72db2db3d1)) 155 | 156 | 157 | ### Features 158 | 159 | * added lazy loading to images ([#33](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/33)) ([af01791](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/af01791786ce8db42435e50e2b1f223e4db4a924)) 160 | 161 | # [1.1.0](https://github.com/colbyfayock/netlify-plugin-cloudinary/compare/v1.0.3...v1.1.0) (2022-10-13) 162 | 163 | 164 | ### Features 165 | 166 | * Add srcset support ([#30](https://github.com/colbyfayock/netlify-plugin-cloudinary/issues/30)) ([e51e298](https://github.com/colbyfayock/netlify-plugin-cloudinary/commit/e51e2981d274f7281d3de848668be65e6777a56e)) 167 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@colbyfayock.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 3 | 4 | ## Issues 5 | 6 | ### Creating an Issue 7 | If you find a bug, problem, or maybe the documentation just doesn't make sense, please create an Issue to document the concern. 8 | 9 | ### Description 10 | Please be descriptive in your Issue. The more info you provide, the more likely someone will be able to help. 11 | 12 | ### Code Examples 13 | If you're experiencing an issue with the code, the most helpful thing you can do is create an example where you can reproduce the problem. This can be an open source Github repo, a private repo you can share with the maintainers, a [CodeSandbox](https://codesandbox.io/), or really anything to show the issue live with code along side of it. 14 | 15 | ## Pull Requests 16 | 17 | ### Creating a Pull Request 18 | If you're able to fix an active Issue, feel free to create a new Pull Request addressing the problem. There are no gaurantees that the code will be merged in "as is", but chances are, if you're willing to work with the maintainers, everyone will be able to come up with a solution everyone can be happy with. 19 | 20 | ### Description 21 | Please be descriptive in your Pull Request. Whether big or small, it's important to be able to see the context of a change throughout the history of a project. 22 | 23 | ### Linking Fixed Issues 24 | If the Pull Request is addressing an Issue, please link that issue by specifying the `Fixes [Issue #]` syntax within the Pull Request. 25 | 26 | ### Getting Added to All Contributors in the README.md 27 | Once your Pull Request is successfully merged, feel free to tag yourself using the [All Conributors syntax](https://allcontributors.org/docs/en/bot/usage), which will create a new Pull Request requesting to add you in. 28 | 29 | ``` 30 | @all-contributors please add for 31 | ``` 32 | 33 | If your Pull Request is merged in and you're not added, please let someone know if you don't want to tag yourself, as we want to recognize everyone for their help. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Colby Fayock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cloudinary 5 | 6 |    7 | 8 | 9 | 10 | Netlify 11 | 12 | 13 | ###### 14 | 15 | GitHub Workflow Status npm GitHub 16 | 17 | # Cloudinary Netlify Plugin 18 | 19 | Optimize and serve all images served in your Netlify site deploy with [Cloudinary](https://cloudinary.com/). 20 | 21 | The Cloudinary plugin hooks into your Netlify build process and sets up images for optimization and delivery. First, the plugin replaces all your on-page, post-compilation images with a Cloudinary-sourced URL, greatly accelerating your initial page load. Next, for comprehensive coverage, Cloudinary redirects assets requested from your images directory to a Cloudinary URL with the default fetch feature or the upload delivery type. 22 | 23 | tl;dr automatically serves smaller images in modern formats 24 | 25 | - [Getting Started](#%EF%B8%8F-getting-started) 26 | - [Configuration](#-configuration) 27 | - [Common Questions & Issues](#%EF%B8%8F%EF%B8%8F-common-questions--issues) 28 | - [How It Works](#%EF%B8%8F-how-it-works) 29 | - [Development](#-development) 30 | 31 | **This is a community library supported by the Cloudinary Developer Experience team.** 32 | 33 | ## ⚡️ Getting Started 34 | 35 | Before installing, make sure you're set up with a free [Cloudinary](https://cloudinary.com/) account. 36 | 37 | ### Installing via Netlify UI 38 | 39 | - [Install the plugin](https://app.netlify.com/plugins/netlify-plugin-cloudinary/install) using the [Netlify Build Plugins Directory](https://app.netlify.com/plugins) 40 | 41 | 42 | 43 | - Add your Cloudinary Cloud Name as a [build environment variable](https://docs.netlify.com/configure-builds/environment-variables) 44 | 45 | ``` 46 | Name: CLOUDINARY_CLOUD_NAME 47 | Value: 48 | ``` 49 | 50 | - Trigger a new deployment! 51 | 52 | By default, your images will be served via the [fetch delivery type](https://cloudinary.com/documentation/fetch_remote_images). 53 | 54 | ### File-based configuration 55 | 56 | - Add the plugin to your Netlify config: 57 | 58 | ```toml 59 | [[plugins]] 60 | package = "netlify-plugin-cloudinary" 61 | ``` 62 | 63 | - Configure your Cloudinary Cloud Name: 64 | 65 | ```toml 66 | [[plugins]] 67 | package = "netlify-plugin-cloudinary" 68 | 69 | [plugins.inputs] 70 | cloudName = "" 71 | ``` 72 | 73 | By default, your images will be served via the [fetch delivery type](https://cloudinary.com/documentation/fetch_remote_images). 74 | 75 | ### Installing locally 76 | 77 | - Install the plugin: 78 | 79 | ```shell 80 | npm install netlify-plugin-cloudinary 81 | ``` 82 | 83 | - Use a [file-based configuration](#file-based-configuration) to add the plugin to your builds 84 | 85 | ## 🛠 Configuration 86 | 87 | ### Plugin Inputs 88 | 89 | | Name | Type |Required | Example | Description | 90 | |-----------------|---------|---------|-----------| ------------| 91 | | cloudName | string | No* | mycloud | Cloudinary Cloud Name | 92 | | cname | string | No | domain.com | The custom domain name (CNAME) to use for building URLs (Advanced Plan Users) | 93 | | deliveryType | string | No | fetch | The method by which Cloudinary stores and delivers images (Ex: fetch, upload) | 94 | | folder | string | No | myfolder | Folder all media will be stored in. Defaults to Netlify site name | 95 | | imagesPath | string/Array | No | /assets | Local path application serves image assets from | 96 | | loadingStrategy | string | No | eager | The method in which in which images are loaded (Ex: lazy, eager) | 97 | | maxSize | object | No | eager | See Below. | 98 | | privateCdn | boolean | No | true | Enables Private CDN Distribution (Advanced Plan Users) | 99 | | uploadPreset | string | No | my-preset | Defined set of asset upload defaults in Cloudinary | 100 | | uploadConcurrency | number | No | 10 | Maximum value of concurrent uploads | 101 | *Cloud Name must be set as an environment variable if not as an input 102 | 103 | #### Max Size 104 | 105 | The Max Size option gives you the ability to configure a maximum width and height that images will scale down to, helping to avoid serving unnecessarily large images. 106 | 107 | By default, the aspect ratio of the images are preserved, so by specifying both a maximum width and height, you're telling Cloudinary to scale the image down so that neither the width or height are beyond that value. 108 | 109 | Additionally, the plugin uses a crop method of `limit` which avoids upscaling images if the images are already smaller than the given size, which reduces unnecessary upscaling as the browser will typically automatically handle. 110 | 111 | The options available are: 112 | 113 | | Name | Type | Example | Description | 114 | |-----------------|---------|-----------| ------------| 115 | | dpr | string | 2.0 | Device Pixel Ratio which essentially multiplies the width and height for pixel density. | 116 | | height | number | 600 | Maximum height an image can be delivered as. | 117 | | width | number | 800 | Maximum width an image can be delivered as. | 118 | 119 | It's important to note that this will not change the width or height attribute of the image within the DOM, this will only be the image that is being delivered by Cloudinary. 120 | 121 | ### Environment Variables 122 | 123 | | Name | Required | Example | Description | 124 | |------------------------|----------|----------|-------------| 125 | | CLOUDINARY_CLOUD_NAME | No* | mycloud | Cloudinary Cloud Name | 126 | | CLOUDINARY_API_KEY | No | 1234 | Cloudinary API Key | 127 | | CLOUDINARY_API_SECRET | No | abcd1234 | Cloudinary API Secret | 128 | 129 | *Cloud Name must be set as an input variable if not as an environment variable 130 | 131 | ### Setting your Cloud Name 132 | 133 | You have two options for setting your Cloud Name: plugin input or environment variable. 134 | 135 | **Input** 136 | 137 | Inside your Netlify config: 138 | 139 | ```toml 140 | [plugins.inputs] 141 | cloudName = "" 142 | ``` 143 | 144 | **Environment Variable** 145 | 146 | Inside your environment variable configuration: 147 | 148 | ``` 149 | CLOUDINARY_CLOUD_NAME="" 150 | ``` 151 | 152 | Learn how to [set environment variables with Netlify](https://docs.netlify.com/configure-builds/environment-variables/). 153 | 154 | ### Changing your Delivery Type 155 | 156 | **fetch** 157 | 158 | Default - no additional configuration needed. 159 | 160 | The fetch method allows you to use Cloudinary delivery by providing a remote URL. Learn more about using delivering remote images with [fetch](https://cloudinary.com/documentation/fetch_remote_images). 161 | 162 | > Note: if you are currently restricting Fetched URLs, you need to ensure your Netlify URL is listed under allowed fetch domains. Older accounts may restrict fetched images by default. Read more about [restricting the allowed fetch domains](https://cloudinary.com/documentation/fetch_remote_images#restricting_the_allowed_fetch_domains). 163 | 164 | **upload - Unsigned** 165 | 166 | Unsigned uploads require an additional [Upload Preset](https://cloudinary.com/documentation/upload_presets) set up and configured in your Cloudinary account. 167 | 168 | Inside your Netlify config: 169 | 170 | ```toml 171 | [[plugins]] 172 | package = "netlify-plugin-cloudinary" 173 | 174 | [plugins.inputs] 175 | cloudName = "[Your Cloudinary Cloud Name]" 176 | deliveryType = "upload" 177 | uploadPreset = "[Your Cloudinary Upload Preset]" 178 | ``` 179 | 180 | Uploading media to Cloudinary gives you more flexibility with your media upon delivery. Learn more about [unsigned uploads](https://cloudinary.com/documentation/upload_images#unsigned_upload). 181 | 182 | **upload - Signed** 183 | 184 | Signed uploads require you to set your API Key and API Secret as environment variables. 185 | 186 | Inside your Netlify config: 187 | 188 | ```toml 189 | [[plugins]] 190 | package = "netlify-plugin-cloudinary" 191 | 192 | [plugins.inputs] 193 | cloudName = "[Your Cloudinary Cloud Name]" 194 | deliveryType = "upload" 195 | ``` 196 | 197 | Inside your environment variable configuration: 198 | 199 | ``` 200 | CLOUDINARY_API_KEY="[Your Cloudinary API Key]" 201 | CLOUDINARY_API_SECRET="[Your Cloudinary API Secret]" 202 | ``` 203 | 204 | Uploading media to Cloudinary gives you more flexibility with your media upon delivery. Learn more about [signed uploads](https://cloudinary.com/documentation/upload_images#uploading_assets_to_the_cloud). 205 | 206 | ### Customizing where your images are served from 207 | 208 | By default, the plugin will attempt to serve any thing served from /images as Cloudinary paths. This can be customized by passing in the `imagesPath` input. 209 | 210 | Inside your Netlify config: 211 | 212 | ```toml 213 | [[plugins]] 214 | package = "netlify-plugin-cloudinary" 215 | 216 | [plugins.inputs] 217 | cloudName = "[Your Cloudinary Cloud Name]" 218 | imagesPath = "/my-path" 219 | # or imagesPath = [ "/my-path", "/my-other-path" ] 220 | ``` 221 | 222 | ## 🕵️‍♀️ Common Questions & Issues 223 | 224 | ### I'm using the default settings but my images 404 225 | 226 | The plugin uses the fetch method by default and if you're receiving a 404 with a valid URL and valid Cloudinary account, you may be currently restricting fetched URLs. 227 | 228 | You have two options to resolve this: adding your Netlify domain to the list of "allowed fetch domains" and removing the fetched URL restriction. 229 | 230 | Adding your domain to the "allowed fetch domains" list is more secure by not allowing others to use your Cloudinary account with their own images. You can do this under Settings > Security > Allowed fetch domains. 231 | 232 | Alternatively, you can remove the restriction and allow all fetched images to work by going to Settings > Security > Restricted media types and unchecking the box for Fetched URL. 233 | 234 | ## ⚙️ How It Works 235 | 236 | ### Delivery Part 1: Replacing all static, on-page images with Cloudinary URLs 237 | 238 | During the Netlify build process, the plugin is able to tap into the `onPostBuild` hook where we use [jsdom](https://github.com/jsdom/jsdom) to create a node-based representation of the DOM for each output HTML file, then walk through each node, and if it's an image, we replace the source with a Cloudinary URL. 239 | 240 | Depending on the configuration, we'll either use the full URL for that image with the [Cloudinary fetch API](https://cloudinary.com/documentation/fetch_remote_images) or alternatively that image will be [uploaded](https://cloudinary.com/documentation/upload_images), where then it will be served by public ID from the Cloudinary account. 241 | 242 | While this works great for a lot of cases and in particular the first load of that page, using a framework with clientside routing or features that mutate the DOM may prevent that Cloudinary URL from persisting, making all of that hard work disappear, meaning it will be served from the Netlify CDN or original remote source (which is fine, but that leads us to Part 2). 243 | 244 | ### Delivery Part 2: Serving all assets from the /images directory from Cloudinary 245 | 246 | To provide comprehensive coverage of images being served from Cloudinary, we take advantage of Netlify's dynamic redirects and serverless functions to map any image being served from the /images directory (or the configured `imagesPath`), redirect it to a serverless function, which then gets redirected to a Cloudinary URL. 247 | 248 | Through this process, we're still able to afford the same option of using either the fetch or upload API depending on preference, where the latter would be uploaded if it's a new asset within the serverless function. 249 | 250 | ## 🧰 Development 251 | 252 | ### Plugin 253 | 254 | To actively develop with the plugin, you need to be able to run a Netlify build and deploy. 255 | 256 | You can do this by pushing a site that uses this plugin to Netlify or you can use the [Netlify CLI](https://docs.netlify.com/cli/get-started/) locally (recommended). 257 | 258 | You can reference the plugin locally in your `netlify.toml` by specifying the directory the plugin is in relative to your project, for example: 259 | ``` 260 | [[plugins]] 261 | package = "../netlify-plugin-cloudinary" 262 | ``` 263 | 264 | On the locally linked Netlify project, you can then run: 265 | 266 | ``` 267 | netlify deploy --build 268 | ``` 269 | 270 | Which will combine the build and deploy contexts and run through the full process, generating a preview URL. 271 | 272 | #### Caveats 273 | * The Netlify CLI doesn't support all input and environment variables for build plugins, primarily `process.env.DEPLOY_PRIME_URL` meaning the `onPostBuild` image replacement will not occur locally. 274 | 275 | ### Demo 276 | 277 | The repository additionally includes a demo that uses the plugin. The demo is a simple Next.js application that lows a few images statically and those same images in a separate list once the page loads. This helps us test both the on-page image replacement and the redirecting of the images directory. 278 | 279 | You can link this project to your Netlify account for testing purposes by creating a new Netlify site at the root of this project and linking it to that new site. 280 | 281 | Once linked, you can then run the build and deploy process with: 282 | 283 | ``` 284 | netlify deploy --build 285 | ``` 286 | 287 | ### Tests 288 | 289 | Tests require all environment variables to be actively set pass. See [configuration](#-configuration) above to see which variables need to be set. 290 | 291 | Once set, tests can be run with: 292 | 293 | ``` 294 | npm run test 295 | ``` 296 | 297 | ## Contributors 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 |
Colby Fayock
Colby Fayock

💻 📖 💡
William
William

💻
Matt Kane
Matt Kane

👀
Elisha Hollander
Elisha Hollander

💻
Kunal Kaushik
Kunal Kaushik

🔧
Shikhar
Shikhar

💻 ⚠️
Diganta Kr Banik
Diganta Kr Banik

💻 📖 ⚠️
3t8
3t8

📖
Abubakrce19
Abubakrce19

📖
Erica Pisani
Erica Pisani

💻
Matías Hernández Arellano
Matías Hernández Arellano

💻
Kieran Klukas
Kieran Klukas

💻
Gretchen Shelby-Dormer
Gretchen Shelby-Dormer

💻
323 | 324 | 325 | 326 | 327 | 328 | -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /demo/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | ``` 10 | 11 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 12 | 13 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 14 | 15 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 16 | 17 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 18 | 19 | ## Learn More 20 | 21 | To learn more about Next.js, take a look at the following resources: 22 | 23 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 24 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 25 | 26 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 27 | 28 | ## Deploy on Vercel 29 | 30 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 31 | 32 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 33 | -------------------------------------------------------------------------------- /demo/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import '../../styles/globals.css'; 2 | import Image from 'next/image'; 3 | import styles from '../../styles/Home.module.css'; 4 | 5 | import images from '../../data/images.json'; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 |

Cloudinary Netlify Plugin

12 | 13 |
    14 | {images.map((image) => { 15 | return ( 16 |
  • 17 | {image.title} 23 |
  • 24 | ); 25 | })} 26 |
27 | 28 |
    29 | {images.map((image) => { 30 | return ( 31 |
  • 32 | {image.title} 38 |
  • 39 | ); 40 | })} 41 |
42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /demo/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | }; 5 | 6 | export default function RootLayout({ children }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /demo/data/images.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "width": 2376, 4 | "height": 1574, 5 | "src": "/images/beach.jpeg", 6 | "title": "Beach" 7 | }, 8 | { 9 | "width": 1364, 10 | "height": 1705, 11 | "src": "/images/city.jpeg", 12 | "title": "City" 13 | }, 14 | { 15 | "width": 2344, 16 | "height": 1560, 17 | "src": "/images/earth.jpeg", 18 | "title": "Earth" 19 | }, 20 | { 21 | "width": 2340, 22 | "height": 1560, 23 | "src": "/images/galaxy.jpeg", 24 | "title": "Galaxy" 25 | }, 26 | { 27 | "width": 2148, 28 | "height": 1611, 29 | "src": "/images/iceberg.jpeg", 30 | "title": "Iceberg" 31 | }, 32 | { 33 | "width": 1364, 34 | "height": 1705, 35 | "src": "/images/jungle.jpeg", 36 | "title": "Jungle" 37 | }, 38 | { 39 | "width": 2340, 40 | "height": 1561, 41 | "src": "/images/mountain.jpeg", 42 | "title": "Mountain" 43 | }, 44 | { 45 | "width": 2340, 46 | "height": 1560, 47 | "src": "/images/waterfall.jpeg", 48 | "title": "Waterfall" 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /demo/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /demo/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "format": "prettier --write --ignore-path .gitignore ." 10 | }, 11 | "dependencies": { 12 | "next": "13.4.19", 13 | "react": "18.2.0", 14 | "react-dom": "18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "18.2.21", 18 | "eslint": "8.48.0", 19 | "eslint-config-next": "13.4.19", 20 | "eslint-config-prettier": "9.0.0", 21 | "netlify-plugin-cloudinary": "workspace:*", 22 | "prettier": "3.0.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /demo/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return ; 5 | } 6 | 7 | export default MyApp; 8 | -------------------------------------------------------------------------------- /demo/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Head from 'next/head'; 3 | import Image from 'next/image'; 4 | import styles from '../styles/Home.module.css'; 5 | 6 | import dataImages from '../data/images.json'; 7 | 8 | export default function Home({ images }) { 9 | const [isLoaded, setIsLoaded] = useState(false); 10 | 11 | useEffect(() => { 12 | setTimeout(() => { 13 | setIsLoaded(true); 14 | }, 1000); 15 | }, []); 16 | 17 | return ( 18 |
19 | 20 | Cloudinary Netlify Plugin 21 | 22 | 23 | 24 |
25 |

Cloudinary Netlify Plugin

26 | 27 |

28 | Supercharge images on your Netlify site with Cloudinary! 29 |

30 | 31 |

Getting Started

32 | 33 |
    34 |
  • 35 | ✅{' '} 36 | 37 | Install the plugin on Netlify 38 | {' '} 39 | (or search for "cloudinary") 40 |
  • 41 |
  • 42 | ✅ Add CLOUDINARY_CLOUD_NAME as a Build Variable 43 |
  • 44 |
  • ✅ Trigger a new Netlify build
  • 45 |
46 | 47 |

48 | 49 | More details and advanced configuration on GitHub 50 | 51 |

52 | 53 |

Images Transformed to Use Cloudinary

54 | 55 |
    56 | {images.map((image) => { 57 | return ( 58 |
  • 59 | {image.title} 65 |
  • 66 | ); 67 | })} 68 |
69 | 70 |
    71 | {images.map((image) => { 72 | return ( 73 |
  • 74 | {image.title} 80 |
  • 81 | ); 82 | })} 83 |
84 | 85 | {isLoaded && ( 86 |
    87 | {images.map((image) => { 88 | return ( 89 |
  • 90 | {image.title} 96 |
  • 97 | ); 98 | })} 99 |
100 | )} 101 |
102 |
103 | ); 104 | } 105 | 106 | export async function getStaticProps() { 107 | return { 108 | props: { 109 | images: dataImages, 110 | }, 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /demo/public/images/beach.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/beach.jpeg -------------------------------------------------------------------------------- /demo/public/images/city.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/city.jpeg -------------------------------------------------------------------------------- /demo/public/images/earth.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/earth.jpeg -------------------------------------------------------------------------------- /demo/public/images/galaxy.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/galaxy.jpeg -------------------------------------------------------------------------------- /demo/public/images/iceberg.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/iceberg.jpeg -------------------------------------------------------------------------------- /demo/public/images/jungle.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/jungle.jpeg -------------------------------------------------------------------------------- /demo/public/images/mountain.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/mountain.jpeg -------------------------------------------------------------------------------- /demo/public/images/test/beach.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/test/beach.jpeg -------------------------------------------------------------------------------- /demo/public/images/waterfall.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/demo/public/images/waterfall.jpeg -------------------------------------------------------------------------------- /demo/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | padding: 2rem 0; 7 | } 8 | 9 | .title { 10 | margin: 0; 11 | line-height: 1.15; 12 | font-size: 4rem; 13 | } 14 | 15 | h2.title { 16 | font-size: 2rem; 17 | } 18 | 19 | .title, 20 | .description { 21 | text-align: center; 22 | } 23 | 24 | .description { 25 | margin: 4rem 0; 26 | line-height: 1.5; 27 | font-size: 1.5rem; 28 | } 29 | 30 | ul.description { 31 | list-style: none; 32 | padding-left: 0; 33 | } 34 | 35 | .grid { 36 | display: grid; 37 | grid-gap: 1rem; 38 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 39 | list-style: none; 40 | padding: 0; 41 | margin: 4rem 0; 42 | } 43 | -------------------------------------------------------------------------------- /demo/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: 6 | -apple-system, 7 | BlinkMacSystemFont, 8 | Segoe UI, 9 | Roboto, 10 | Oxygen, 11 | Ubuntu, 12 | Cantarell, 13 | Fira Sans, 14 | Droid Sans, 15 | Helvetica Neue, 16 | sans-serif; 17 | } 18 | 19 | a { 20 | color: #0070f3; 21 | text-decoration: none; 22 | } 23 | 24 | a:hover, 25 | a:focus, 26 | a:active { 27 | text-decoration: underline; 28 | } 29 | 30 | * { 31 | box-sizing: border-box; 32 | } 33 | 34 | img { 35 | max-width: 100%; 36 | height: auto; 37 | } 38 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "strictNullChecks": true, 25 | "forceConsistentCasingInFileNames": true 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | ".next/types/**/*.ts", 30 | "**/*.ts", 31 | "**/*.tsx", 32 | "**/*.json" 33 | ], 34 | "exclude": [ 35 | "node_modules" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /docs/.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/.github/screenshot.png -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | .env.local -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shu Ding 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Docs -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require('nextra')({ 2 | theme: 'nextra-theme-docs', 3 | themeConfig: './theme.config.tsx', 4 | }) 5 | 6 | module.exports = withNextra() 7 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start" 8 | }, 9 | "dependencies": { 10 | "autoprefixer": "^10.4.15", 11 | "next": "^13.0.6", 12 | "next-cloudinary": "^4.20.0", 13 | "nextjs-google-analytics": "^2.3.3", 14 | "nextra": "latest", 15 | "nextra-theme-docs": "latest", 16 | "postcss": "^8.4.29", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "tailwindcss": "^3.3.3" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "18.11.10", 23 | "typescript": "^4.9.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/public/favicon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/favicon-1024x1024.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/images/cloudinary-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/public/images/cloudinary-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/public/images/netlify-cloudinary-logos.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/public/images/netlify-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/public/images/netlify-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/public/screenshots/netlify-cloudinary-install-website.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/docs/public/screenshots/netlify-cloudinary-install-website.png -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Netlify Cloudinary", 3 | "short_name": "Netlify Cloudinary", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | interface ButtonProps { 5 | children?: ReactNode; 6 | className?: string; 7 | color?: string; 8 | href?: string; 9 | } 10 | 11 | const Button = ({ children, className = '', href }: ButtonProps) => { 12 | 13 | const buttonColor = 'text-white bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-slate-400'; 14 | const buttonStyles = `inline-block rounded py-2.5 px-6 text-sm font-bold uppercase ${buttonColor} ${className}` 15 | 16 | if ( href ) { 17 | return ( 18 | 19 | { children } 20 | 21 | ) 22 | } 23 | 24 | return ( 25 | 28 | ) 29 | } 30 | 31 | export default Button; -------------------------------------------------------------------------------- /docs/src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; -------------------------------------------------------------------------------- /docs/src/components/OgImage/OgImage.tsx: -------------------------------------------------------------------------------- 1 | import { CldOgImage } from 'next-cloudinary'; 2 | 3 | interface OgImageProps { 4 | title: string; 5 | twitterTitle: string; 6 | } 7 | 8 | const OgImage = ({ title, twitterTitle }: OgImageProps) => { 9 | return ( 10 | 59 | ) 60 | } 61 | 62 | export default OgImage; -------------------------------------------------------------------------------- /docs/src/components/OgImage/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './OgImage'; -------------------------------------------------------------------------------- /docs/src/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import { GoogleAnalytics } from 'nextjs-google-analytics'; 2 | import '../styles/globals.css'; 3 | 4 | export default function App({ Component, pageProps }) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } -------------------------------------------------------------------------------- /docs/src/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Introduction", 4 | "display": "hidden", 5 | "type": "page", 6 | "theme": { 7 | "layout": "raw", 8 | "timestamp": false 9 | } 10 | }, 11 | "installation": { 12 | "title": "Installation", 13 | "theme": { 14 | "breadcrumb": false, 15 | "toc": false 16 | } 17 | }, 18 | "configuration": { 19 | "title": "Configuration", 20 | "theme": { 21 | "breadcrumb": false 22 | } 23 | }, 24 | "Resources": { 25 | "type": "separator", 26 | "title": "Resources" 27 | }, 28 | "guides": "Guides", 29 | "troubleshooting": "Troubleshooting", 30 | 31 | "menubar-installation": { 32 | "title": "Installation", 33 | "href": "/installation", 34 | "type": "page" 35 | }, 36 | "menubar-configuration": { 37 | "title": "Configuration", 38 | "href": "/configuration", 39 | "type": "page" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/src/pages/configuration.mdx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Callout } from 'nextra-theme-docs'; 3 | 4 | import OgImage from '../components/OgImage'; 5 | 6 | 7 | Configuration - Netlify Cloudinary 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | # Configuring Cloudinary Build Plugin 19 | 20 | ## Plugin Inputs 21 | 22 | | Name | Type |Required | Example | Description | 23 | |-----------------|---------|---------|-----------| ------------| 24 | | cloudName | string | No* | mycloud | Cloudinary Cloud Name | 25 | | cname | string | No | domain.com | The custom domain name (CNAME) to use for building URLs (Advanced Plan Users) | 26 | | deliveryType | string | No | fetch | The method by which Cloudinary stores and delivers images (Ex: fetch, upload) | 27 | | folder | string | No | myfolder | Folder all media will be stored in. Defaults to Netlify site name | 28 | | imagesPath | string/Array | No | /assets | Local path application serves image assets from | 29 | | loadingStrategy | string | No | eager | The method in which in which images are loaded (Ex: lazy, eager) | 30 | | maxSize | object | No | See Below | See Below. | 31 | | privateCdn | boolean | No | true | Enables Private CDN Distribution (Advanced Plan Users) | 32 | | uploadPreset | string | No | my-preset | Defined set of asset upload defaults in Cloudinary | 33 | 34 | 35 | Cloud Name must be set as an environment variable if not as an input 36 | 37 | 38 | ### Max Size 39 | 40 | The Max Size option gives you the ability to configure a maximum width and height that images will scale down to, helping to avoid serving unnecessarily large images. 41 | 42 | By default, the aspect ratio of the images are preserved, so by specifying both a maximum width and height, you're telling Cloudinary to scale the image down so that neither the width or height are beyond that value. 43 | 44 | Additionally, the plugin uses a crop method of `limit` which avoids upscaling images if the images are already smaller than the given size, which reduces unnecessary upscaling as the browser will typically automatically handle. 45 | 46 | The options available are: 47 | 48 | | Name | Type | Example | Description | 49 | |-----------------|---------|-----------| ------------| 50 | | dpr | string | 2.0 | Device Pixel Ratio which essentially multiplies the width and height for pixel density. | 51 | | height | number | 600 | Maximum height an image can be delivered as. | 52 | | width | number | 800 | Maximum width an image can be delivered as. | 53 | 54 | It's important to note that this will not change the width or height attribute of the image within the DOM, this will only be the image that is being delivered by Cloudinary. 55 | 56 | ## Environment Variables 57 | 58 | | Name | Required | Example | Description | 59 | |------------------------|----------|----------|-------------| 60 | | CLOUDINARY_CLOUD_NAME | No* | mycloud | Cloudinary Cloud Name | 61 | | CLOUDINARY_API_KEY | No | 1234 | Cloudinary API Key | 62 | | CLOUDINARY_API_SECRET | No | abcd1234 | Cloudinary API Secret | 63 | 64 | 65 | Cloud Name must be set as an input variable if not as an environment variable 66 | 67 | 68 | ## Setting your Cloud Name 69 | 70 | You have two options for setting your Cloud Name: plugin input or environment variable. 71 | 72 | ### Netlify UI 73 | 74 | Inside of your build settings, configure [build environment variable](https://docs.netlify.com/configure-builds/environment-variables) for the following: 75 | 76 | ``` 77 | Name: CLOUDINARY_CLOUD_NAME 78 | Value: 79 | ``` 80 | 81 | ### File-based Configuration 82 | 83 | Inside your Netlify config, add the following input configurations under your netlify-plugin-cloudinary plugin: 84 | 85 | ```toml 86 | [plugins.inputs] 87 | cloudName = "" 88 | ``` -------------------------------------------------------------------------------- /docs/src/pages/guides/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "delivery-type": "Delivery Type", 3 | "images-path": "Images Path" 4 | } -------------------------------------------------------------------------------- /docs/src/pages/guides/delivery-type.mdx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Callout } from 'nextra-theme-docs'; 3 | 4 | import OgImage from '../../components/OgImage'; 5 | 6 | 7 | Delivery Type - Netlify Cloudinary 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | # Delivery Type (Fetch, Upload) 19 | 20 | ## Fetch 21 | 22 | **Default** - no additional configuration needed. 23 | 24 | The fetch method allows you to use Cloudinary delivery by providing a remote URL. 25 | 26 | 27 | If you are currently restricting Fetched URLs, you need to ensure your Netlify URL is listed under allowed fetch domains. Older accounts may restrict fetched images by default. Read more about [restricting the allowed fetch domains](https://cloudinary.com/documentation/fetch_remote_images#restricting_the_allowed_fetch_domains). 28 | 29 | 30 | Learn more about using delivering remote images with [fetch](https://cloudinary.com/documentation/fetch_remote_images). 31 | 32 | ## Unsigned Uploads 33 | 34 | Unsigned uploads require an additional [Upload Preset](https://cloudinary.com/documentation/upload_presets) set up and configured in your Cloudinary account. 35 | 36 | Inside your Netlify config, add the following input configurations under your netlify-plugin-cloudinary plugin: 37 | 38 | ```toml 39 | [[plugins]] 40 | package = "netlify-plugin-cloudinary" 41 | 42 | [plugins.inputs] 43 | cloudName = "[Your Cloudinary Cloud Name]" 44 | deliveryType = "upload" 45 | uploadPreset = "[Your Cloudinary Upload Preset]" 46 | ``` 47 | 48 | Uploading media to Cloudinary gives you more flexibility with your media upon delivery. 49 | 50 | Learn more about [unsigned uploads](https://cloudinary.com/documentation/upload_images#unsigned_upload). 51 | 52 | ## Signed Uploads 53 | 54 | Signed uploads require you to set your API Key and API Secret as environment variables. 55 | 56 | Inside your Netlify config, add the following input configurations under your netlify-plugin-cloudinary plugin: 57 | 58 | ```toml 59 | [[plugins]] 60 | package = "netlify-plugin-cloudinary" 61 | 62 | [plugins.inputs] 63 | cloudName = "[Your Cloudinary Cloud Name]" 64 | deliveryType = "upload" 65 | ``` 66 | 67 | Inside your environment variable configuration: 68 | 69 | ``` 70 | CLOUDINARY_API_KEY="[Your Cloudinary API Key]" 71 | CLOUDINARY_API_SECRET="[Your Cloudinary API Secret]" 72 | ``` 73 | 74 | 75 | Environment variables need to be configured in any environment that you're building your Netlify site. 76 | 77 | 78 | Uploading media to Cloudinary gives you more flexibility with your media upon delivery. 79 | 80 | Learn more about [signed uploads](https://cloudinary.com/documentation/upload_images#uploading_assets_to_the_cloud). -------------------------------------------------------------------------------- /docs/src/pages/guides/images-path.mdx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import OgImage from '../../components/OgImage'; 4 | 5 | 6 | Images Path - Netlify Cloudinary 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | # Changing the Location of Images 18 | 19 | By default, the plugin will attempt to serve any image asset located in the `/images` directory as a Cloudinary asset. 20 | 21 | This can be customized by passing in the `imagesPath` input. 22 | 23 | Inside your Netlify config, add the following input configurations under your netlify-plugin-cloudinary plugin: 24 | 25 | ```toml 26 | [[plugins]] 27 | package = "netlify-plugin-cloudinary" 28 | 29 | [plugins.inputs] 30 | cloudName = "[Your Cloudinary Cloud Name]" 31 | imagesPath = "/my-path" 32 | ``` 33 | 34 | If you'd like to specify multiple locations for your images, you can configure `imagesPath` as an array: 35 | 36 | ```toml 37 | imagesPath = [ "/my-path", "/my-other-path" ] 38 | ``` -------------------------------------------------------------------------------- /docs/src/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Netlify Cloudinary - Automatic Optimization at Scale 3 | --- 4 | 5 | import Head from 'next/head'; 6 | 7 | import Button from '../components/Button'; 8 | import OgImage from '../components/OgImage'; 9 | 10 | 14 | 15 |
16 |
17 |
18 |

Netlify Cloudinary

19 |

Optimize images at scale on Netlify with Cloudinary

20 |

21 | 22 |

23 |
24 |
25 |
26 | 27 | 33 | Netlify Logo 39 | 40 | 41 | 47 | Cloudinary Logo 53 | 54 |
55 |
56 |
57 |
-------------------------------------------------------------------------------- /docs/src/pages/installation.mdx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Callout, Steps, Tab, Tabs } from 'nextra-theme-docs'; 3 | 4 | import OgImage from '../components/OgImage'; 5 | 6 | 7 | Installation - Netlify Cloudinary 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | # Installing Netlify Cloudinary 19 | 20 | 21 | 22 | 23 | ### Enable the Integration 24 | 25 | The Cloudinary Build Plugin for Netlify is easily available as an integration right inside of Netlify. 26 | 27 | You can enable the plugin from the Integration page: https://www.netlify.com/integrations/cloudinary/ 28 | 29 |

30 | Netlify Logo 36 |

37 | 38 | Or you can find it in the Netlify dashboard by navigating to your site, selecting Integrations, searching for Cloudinary, then enabling the integration. 39 | 40 | ### Configure Your Cloudinary Account 41 | 42 | Add your Cloudinary Cloud Name as a [build environment variable](https://docs.netlify.com/configure-builds/environment-variables) 43 | 44 | ``` 45 | Name: CLOUDINARY_CLOUD_NAME 46 | Value: 47 | ``` 48 | 49 | ### Deploy! 50 | 51 | The Cloudinary Build Plugin runs during your Netlify build, meaning, you need to trigger a new deploy to see it in action. 52 |
53 |
54 | 55 | 56 | 57 | ### Add the Plugin 58 | 59 | Add the plugin to your Netlify config: 60 | 61 | ```toml 62 | [[plugins]] 63 | package = "netlify-plugin-cloudinary" 64 | ``` 65 | 66 | ### Configure Your Cloudinary Account 67 | 68 | Configure your Cloudinary Cloud Name in your Netlify config file: 69 | 70 | ```toml 71 | [[plugins]] 72 | package = "netlify-plugin-cloudinary" 73 | 74 | [plugins.inputs] 75 | cloudName = "" 76 | ``` 77 | 78 | ### Deploy! 79 | 80 | The Cloudinary Build Plugin runs during your Netlify build, meaning, you need to trigger a new deploy to see it in action. 81 | 82 | 83 |
84 | 85 | ## How it Works 86 | 87 | ### Delivery Part 1: Replacing all static, on-page images with Cloudinary URLs 88 | 89 | During the Netlify build process, the plugin is able to tap into the `onPostBuild` hook where we use [jsdom](https://github.com/jsdom/jsdom) to create a node-based representation of the DOM for each output HTML file, then walk through each node, and if it's an image, we replace the source with a Cloudinary URL. 90 | 91 | Depending on the configuration, we'll either use the full URL for that image with the [Cloudinary fetch API](https://cloudinary.com/documentation/fetch_remote_images) or alternatively that image will be [uploaded](https://cloudinary.com/documentation/upload_images), where then it will be served by public ID from the Cloudinary account. 92 | 93 | While this works great for a lot of cases and in particular the first load of that page, using a framework with clientside routing or features that mutate the DOM may prevent that Cloudinary URL from persisting, making all of that hard work disappear, meaning it will be served from the Netlify CDN or original remote source (which is fine, but that leads us to Part 2). 94 | 95 | ### Delivery Part 2: Serving all assets from the /images directory from Cloudinary 96 | 97 | To provide comprehensive coverage of images being served from Cloudinary, we take advantage of Netlify's dynamic redirects and serverless functions to map any image being served from the /images directory (or the configured `imagesPath`), redirect it to a serverless function, which then gets redirected to a Cloudinary URL. 98 | 99 | Through this process, we're still able to afford the same option of using either the fetch or upload API depending on preference, where the latter would be uploaded if it's a new asset within the serverless function. 100 | 101 | 102 | ## Using Netlify Cloudinary 103 | 104 | * [Configuration](/configuration): Learn how to configure the Cloudinary Build Plugin for Netlify -------------------------------------------------------------------------------- /docs/src/pages/troubleshooting/401-error.mdx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import OgImage from '../../components/OgImage'; 4 | 5 | 6 | Troubleshooting 401 Errors - Netlify Cloudinary 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | # Troubleshooting 401 Errors 18 | 19 | ## I'm using the default settings, but my images 401 20 | 21 | The plugin uses the fetch method by default and if you're receiving a 401 with a valid URL and valid Cloudinary account, you may be currently restricting fetched URLs. 22 | 23 | You have two options to resolve this: adding your Netlify domain to the list of "allowed fetch domains" and removing the fetched URL restriction. 24 | 25 | Adding your domain to the "allowed fetch domains" list is more secure by not allowing others to use your Cloudinary account with their own images. You can do this under Settings > Security > Allowed fetch domains. 26 | 27 | Alternatively, you can remove the restriction and allow all fetched images to work by going to Settings > Security > Restricted media types and unchecking the box for Fetched URL. -------------------------------------------------------------------------------- /docs/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx,md,mdx}", 5 | "./pages/**/*.{js,ts,jsx,tsx,md,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,md,mdx}", 7 | // Or if using `src` directory: 8 | "./src/**/*.{js,ts,jsx,tsx,md,mdx}", 9 | ], 10 | theme: { 11 | extend: {}, 12 | }, 13 | plugins: [], 14 | } 15 | 16 | -------------------------------------------------------------------------------- /docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { DocsThemeConfig } from 'nextra-theme-docs' 4 | 5 | const config: DocsThemeConfig = { 6 | logo: Netlify Cloudinary, 7 | project: { 8 | link: 'https://github.com/cloudinary-community/netlify-plugin-cloudinary', 9 | }, 10 | docsRepositoryBase: 'https://github.com/cloudinary-community/netlify-plugin-cloudinary', 11 | useNextSeoProps() { 12 | const { route } = useRouter() 13 | if (route !== '/') { 14 | return { 15 | titleTemplate: '%s – Netlify Cloudinary' 16 | } 17 | } 18 | }, 19 | head: ( 20 | <> 21 | 22 | 23 | Netlify Cloudinary - Automatic Optimization at Scale 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ), 36 | footer: { 37 | text: `MIT ${new Date().getFullYear()} © Colby Fayock`, 38 | }, 39 | sidebar: { 40 | autoCollapse: true, 41 | defaultMenuCollapseLevel: 1 42 | } 43 | } 44 | 45 | export default config 46 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/.eslintignore: -------------------------------------------------------------------------------- 1 | demo 2 | node_modules 3 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2022: true, 5 | }, 6 | parser: '@typescript-eslint/parser', 7 | parserOptions: { 8 | ecmaVersion: 'latest', 9 | sourceType: 'module', 10 | }, 11 | plugins: ['@typescript-eslint'], 12 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 13 | overrides: [ 14 | { 15 | env: { 16 | node: true, 17 | }, 18 | files: ['.eslintrc.{js,cjs}'], 19 | }, 20 | ], 21 | rules: { 22 | '@typescript-eslint/no-unused-vars': 'error', 23 | // to enforce using type for object type definitions, can be type or interface 24 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/.npmignore: -------------------------------------------------------------------------------- 1 | .env 2 | .eslintignore 3 | .eslintrc.cjs 4 | .prettierignore 5 | .prettierrc 6 | .setTestEnvVars.js 7 | node_modules 8 | src 9 | tsconfig.json -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/.prettierignore: -------------------------------------------------------------------------------- 1 | demo 2 | node_modules 3 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/manifest.yml: -------------------------------------------------------------------------------- 1 | name: netlify-plugin-cloudinary 2 | inputs: 3 | - name: cloudName 4 | required: false 5 | description: "Cloudinary Cloud Name - can be used as an input or environment variable via CLOUDINARY_CLOUD_NAME" 6 | - name: cname 7 | required: false 8 | description: "The custom domain name (CNAME) to use for building URLs (Advanced Plan Users)" 9 | - name: deliveryType 10 | required: false 11 | description: "The method in which Cloudinary stores and delivers images (Ex: fetch, upload)" 12 | default: "fetch" 13 | - name: folder 14 | required: false 15 | description: "Folder all media will be stored in. Defaults to Netlify site name" 16 | - name: imagesPath 17 | required: false 18 | description: "Local path(s) application serves image assets from" 19 | default: "/images" 20 | - name: loadingStrategy 21 | required: false 22 | description: "The method in which in which images are loaded (Ex: lazy, eager)" 23 | default: "lazy" 24 | - name: maxSize 25 | required: false 26 | description: "Maximum dimensions (width and height) for an image to be delivered" 27 | - name: privateCdn 28 | required: false 29 | description: "Enable Private CDN Distribution (Advanced Plan Users)" 30 | - name: uploadPreset 31 | required: false 32 | description: "Defined set of asset upload defaults in Cloudinary" 33 | - name: uploadConcurrency 34 | required: false 35 | description: "Maximum value of concurrent uploads" 36 | default: 10 37 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-plugin-cloudinary", 3 | "version": "1.17.0", 4 | "description": "Supercharge images on your Netlify site with Cloudinary!", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc -p ./tsconfig.json", 8 | "lint": "eslint . --ext .ts", 9 | "format": "prettier --write \"(src|test)/*.+(js|ts|json)\"", 10 | "postpublish": "rm ./README.md", 11 | "prepublishOnly": "cp ../README.md . && pnpm build", 12 | "test": "vitest run", 13 | "typecheck": "tsc --noEmit -p ./tsconfig.json" 14 | }, 15 | "author": { 16 | "name": "Colby Fayock", 17 | "email": "hello@colbyfayock.com", 18 | "url": "https://twitter.com/colbyfayock" 19 | }, 20 | "license": "MIT", 21 | "dependencies": { 22 | "cloudinary": "^2.0.1", 23 | "glob": "^10.3.3", 24 | "jsdom": "21", 25 | "node-fetch": "2", 26 | "p-limit": "3.1.0" 27 | }, 28 | "devDependencies": { 29 | "@netlify/config": "20.8.1", 30 | "@types/glob": "8.1.0", 31 | "@types/jsdom": "21.1.2", 32 | "@types/node-fetch": "2.6.4", 33 | "@typescript-eslint/eslint-plugin": "6.5.0", 34 | "@typescript-eslint/parser": "6.5.0", 35 | "dotenv": "^16.3.1", 36 | "eslint": "8.48.0", 37 | "eslint-config-prettier": "9.0.0", 38 | "eslint-config-standard-with-typescript": "39.0.0", 39 | "eslint-plugin-import": "2.28.1", 40 | "eslint-plugin-n": "16.0.2", 41 | "eslint-plugin-promise": "6.1.1", 42 | "prettier": "3.0.3", 43 | "typescript": "5.2.2", 44 | "vitest": "^0.34.6" 45 | }, 46 | "engines": { 47 | "node": ">=16" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/data/analytics.ts: -------------------------------------------------------------------------------- 1 | export const ANALYTICS_SDK_CODE = 'F'; 2 | export const ANALYTICS_SDK_SEMVER = '0.0.0'; 3 | export const ANALYTICS_PRODUCT = 'B'; -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/data/cloudinary.ts: -------------------------------------------------------------------------------- 1 | export const PREFIX = 'cld' 2 | 3 | export const PUBLIC_ASSET_PATH = `${PREFIX}-assets` 4 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/data/errors.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_ASSET_UPLOAD = 'Error uploading asset' 2 | export const ERROR_API_CREDENTIALS_REQUIRED = 'Both your Cloudinary API Key and API Secret are required when using a Delivery Type of Upload. Please confirm the environment variables CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET are configured.'; 3 | export const ERROR_CLOUD_NAME_REQUIRED = 'A Cloudinary Cloud Name is required. Please set cloudName input or use the environment variable CLOUDINARY_CLOUD_NAME'; 4 | export const ERROR_INVALID_IMAGES_PATH = 'Invalid asset path. Please make sure your imagesPath is defined.'; 5 | export const ERROR_NETLIFY_HOST_CLI_SUPPORT = 'Note: The Netlify CLI does not currently support the ability to determine the host locally, try deploying on Netlify.'; 6 | export const ERROR_NETLIFY_HOST_UNKNOWN = 'Cannot determine Netlify host, can not proceed with plugin.'; 7 | export const ERROR_SITE_NAME_REQUIRED = 'Cannot determine the site name, can not proceed with plugin'; 8 | export const ERROR_UPLOAD_PRESET = 'To use a delivery type of "upload", please use an uploadPreset for unsigned requests or an API Key and Secret for signed requests' 9 | export const ERROR_INVALID_SRCSET = 'Invalid srcset path. Please make sure the srcset is defined.' 10 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { glob } from 'glob'; 4 | import pLimit from 'p-limit'; 5 | 6 | import { Inputs } from './types/integration'; 7 | 8 | import { 9 | configureCloudinary, 10 | updateHtmlImagesToCloudinary, 11 | getCloudinaryUrl, 12 | Assets, 13 | getTransformationsFromInputs 14 | } from './lib/cloudinary'; 15 | import { findAssetsByPath } from './lib/util'; 16 | 17 | import { PUBLIC_ASSET_PATH } from './data/cloudinary'; 18 | import { 19 | ERROR_API_CREDENTIALS_REQUIRED, 20 | ERROR_CLOUD_NAME_REQUIRED, 21 | ERROR_INVALID_IMAGES_PATH, 22 | ERROR_NETLIFY_HOST_CLI_SUPPORT, 23 | ERROR_NETLIFY_HOST_UNKNOWN, 24 | ERROR_SITE_NAME_REQUIRED, 25 | } from './data/errors'; 26 | 27 | /** 28 | * Type needs improvement 29 | * Information was found here Netlify Config 30 | */ 31 | 32 | type NetlifyConfig = { 33 | redirects: Array<{ 34 | from: string; 35 | to?: string; 36 | status?: number; 37 | force?: boolean; 38 | signed?: string; 39 | query?: Partial>; 40 | headers?: Partial>; 41 | conditions?: Partial< 42 | Record<'Language' | 'Role' | 'Country' | 'Cookie', readonly string[]> 43 | >; 44 | }>; 45 | headers: Array<{ 46 | for: string; 47 | values: unknown; // marked as unknown because is not required here. 48 | }>; 49 | functions: { 50 | directory: string; 51 | }; 52 | build: { 53 | command: string; 54 | environment: Record; 55 | edge_functions: string; 56 | processing: Record; 57 | }; 58 | }; 59 | type Constants = { 60 | CONFIG_PATH?: string; 61 | PUBLISH_DIR: string; 62 | FUNCTIONS_SRC: string; 63 | FUNCTIONS_DIST: string; 64 | IS_LOCAL: boolean; 65 | NETLIFY_BUILD_VERSION: `${string}.${string}.${string}`; 66 | SITE_ID: string; 67 | }; 68 | 69 | 70 | 71 | type Utils = { 72 | build: { 73 | failBuild: (message: string, { error }?: { error: Error }) => void; 74 | failPlugin: (message: string, { error }?: { error: Error }) => void; 75 | cancelBuild: (message: string, { error }?: { error: Error }) => void; 76 | }; 77 | status: { 78 | show: ({ 79 | title, 80 | summary, 81 | text, 82 | }: { 83 | title: string; 84 | summary: string; 85 | text: string; 86 | }) => void; 87 | }; 88 | }; 89 | type OnBuildParams = { 90 | netlifyConfig: NetlifyConfig; 91 | constants: Constants; 92 | inputs: Inputs; 93 | utils: Utils; 94 | }; 95 | type OnPostBuildParams = Omit; 96 | 97 | const CLOUDINARY_ASSET_DIRECTORIES = [ 98 | { 99 | name: 'images', 100 | inputKey: 'imagesPath', 101 | path: '/images', 102 | }, 103 | ]; 104 | 105 | const DEFAULT_CONCURRENCY = 10; 106 | 107 | /** 108 | * TODO 109 | * - Handle srcset 110 | */ 111 | 112 | const _cloudinaryAssets = { images: {} } as Assets; 113 | const globalErrors = []; 114 | 115 | export async function onBuild({ 116 | netlifyConfig, 117 | constants, 118 | inputs, 119 | utils, 120 | }: OnBuildParams) { 121 | console.log('[Cloudinary] Creating redirects...'); 122 | 123 | let host = process.env.URL; 124 | 125 | if (process.env.CONTEXT === 'branch-deploy' || process.env.CONTEXT === 'deploy-preview') { 126 | host = process.env.DEPLOY_PRIME_URL || '' 127 | } 128 | 129 | console.log(`[Cloudinary] Using host: ${host}`); 130 | 131 | const { PUBLISH_DIR } = constants; 132 | 133 | const { 134 | cname, 135 | deliveryType, 136 | folder = process.env.SITE_NAME || '', 137 | imagesPath = inputs.imagesPath || CLOUDINARY_ASSET_DIRECTORIES.find( 138 | ({ inputKey }) => inputKey === 'imagesPath', 139 | )?.path, 140 | maxSize, 141 | privateCdn, 142 | uploadPreset, 143 | uploadConcurrency = DEFAULT_CONCURRENCY, 144 | } = inputs; 145 | 146 | if (!folder) { 147 | console.error(`[Cloudinary] ${ERROR_SITE_NAME_REQUIRED}`); 148 | utils.build.failPlugin(ERROR_SITE_NAME_REQUIRED); 149 | return; 150 | } 151 | 152 | if (!host && deliveryType === 'fetch') { 153 | console.warn(`[Cloudinary] ${ERROR_NETLIFY_HOST_UNKNOWN}`); 154 | console.log(`[Cloudinary] ${ERROR_NETLIFY_HOST_CLI_SUPPORT}`); 155 | return; 156 | } 157 | 158 | const cloudName = process.env.CLOUDINARY_CLOUD_NAME || inputs.cloudName; 159 | const apiKey = process.env.CLOUDINARY_API_KEY; 160 | const apiSecret = process.env.CLOUDINARY_API_SECRET; 161 | 162 | if (!cloudName) { 163 | console.error(`[Cloudinary] ${ERROR_CLOUD_NAME_REQUIRED}`); 164 | utils.build.failBuild(ERROR_CLOUD_NAME_REQUIRED); 165 | return; 166 | } 167 | 168 | if (deliveryType === 'upload' && (!apiKey || !apiSecret)) { 169 | console.error(`[Cloudinary] ${ERROR_API_CREDENTIALS_REQUIRED}`); 170 | utils.build.failBuild(ERROR_API_CREDENTIALS_REQUIRED); 171 | return; 172 | } 173 | 174 | configureCloudinary({ 175 | // Base credentials 176 | cloudName, 177 | apiKey, 178 | apiSecret, 179 | 180 | // Configuration 181 | cname, 182 | privateCdn, 183 | }); 184 | 185 | const transformations = getTransformationsFromInputs(inputs); 186 | 187 | // Look for any available images in the provided imagesPath to collect 188 | // asset details and to grab a Cloudinary URL to use later 189 | 190 | if (typeof imagesPath === 'undefined') { 191 | console.error(`[Cloudinary] ${ERROR_INVALID_IMAGES_PATH}`) 192 | throw new Error(ERROR_INVALID_IMAGES_PATH); 193 | } 194 | 195 | const imagesFiles = findAssetsByPath({ 196 | baseDir: PUBLISH_DIR, 197 | path: imagesPath, 198 | }); 199 | 200 | if (imagesFiles.length === 0) { 201 | console.warn(`[Cloudinary] No image files found in ${imagesPath}`); 202 | console.log( 203 | `[Cloudinary] Did you update your images path? You can set the imagesPath input in your Netlify config.`, 204 | ); 205 | } 206 | 207 | const limitUploadFiles = pLimit(uploadConcurrency); 208 | const uploadsQueue = imagesFiles.map((image, i) => { 209 | const publishPath = image.replace(PUBLISH_DIR, ''); 210 | return limitUploadFiles(() => { 211 | async function uploadFile() { 212 | try { 213 | const cloudinary = await getCloudinaryUrl({ 214 | deliveryType, 215 | folder, 216 | path: publishPath, 217 | localDir: PUBLISH_DIR, 218 | uploadPreset, 219 | remoteHost: host, 220 | transformations 221 | }); 222 | 223 | return { 224 | publishPath, 225 | ...cloudinary, 226 | }; 227 | } catch(e) { 228 | globalErrors.push(e); 229 | } 230 | } 231 | return uploadFile(); 232 | }) 233 | }) 234 | 235 | _cloudinaryAssets.images = await Promise.all(uploadsQueue); 236 | 237 | // If the delivery type is set to upload, we need to be able to map individual assets based on their public ID, 238 | // which would require a dynamic middle solution, but that adds more hops than we want, so add a new redirect 239 | // for each asset uploaded 240 | 241 | if (deliveryType === 'upload') { 242 | await Promise.all( 243 | Object.keys(_cloudinaryAssets).flatMap(mediaType => { 244 | // @ts-expect-error what are the expected mediaTypes that will be stored in _cloudinaryAssets 245 | if (Object.hasOwn(_cloudinaryAssets[mediaType], 'map')) { 246 | // @ts-expect-error what are the expected mediaTypes that will be stored in _cloudinaryAssets 247 | return _cloudinaryAssets[mediaType].map(async asset => { 248 | const { publishPath, cloudinaryUrl } = asset; 249 | netlifyConfig.redirects.unshift({ 250 | from: `${publishPath}*`, 251 | to: cloudinaryUrl, 252 | status: 302, 253 | force: true, 254 | }); 255 | }); 256 | } 257 | }), 258 | ); 259 | } 260 | 261 | // If the delivery type is fetch, we're able to use the public URL and pass it right along "as is", so 262 | // we can create generic redirects. The tricky thing is to avoid a redirect loop, we modify the 263 | // path, so that we can safely allow Cloudinary to fetch the media remotely 264 | 265 | if (deliveryType === 'fetch') { 266 | await Promise.all( 267 | CLOUDINARY_ASSET_DIRECTORIES.map( 268 | async ({ inputKey, path: defaultPath }) => { 269 | let mediaPaths = inputs[inputKey as keyof Inputs] || defaultPath; 270 | 271 | // Unsure how to type the above so that Inputs['privateCdn'] doesnt mess up types here 272 | 273 | if (!Array.isArray(mediaPaths) && typeof mediaPaths !== 'string') return; 274 | 275 | if (!Array.isArray(mediaPaths)) { 276 | mediaPaths = [mediaPaths]; 277 | } 278 | 279 | mediaPaths.forEach(async mediaPath => { 280 | mediaPath = mediaPath.split(path.win32.sep).join(path.posix.sep); 281 | const cldAssetPath = `/${path.posix.join(PUBLIC_ASSET_PATH, mediaPath)}`; 282 | const cldAssetUrl = `${host}${cldAssetPath}`; 283 | try { 284 | const { cloudinaryUrl: assetRedirectUrl } = await getCloudinaryUrl({ 285 | deliveryType: 'fetch', 286 | folder, 287 | path: `${cldAssetUrl}/:splat`, 288 | uploadPreset, 289 | }); 290 | 291 | netlifyConfig.redirects.unshift({ 292 | from: `${cldAssetPath}/*`, 293 | to: `${mediaPath}/:splat`, 294 | status: 200, 295 | force: true, 296 | }); 297 | 298 | netlifyConfig.redirects.unshift({ 299 | from: `${mediaPath}/*`, 300 | to: assetRedirectUrl, 301 | status: 302, 302 | force: true, 303 | }); 304 | } catch (error) { 305 | globalErrors.push(error) 306 | } 307 | }) 308 | }) 309 | ) 310 | } 311 | 312 | 313 | } 314 | 315 | // Post build looks through all of the output HTML and rewrites any src attributes to use a cloudinary URL 316 | // This only solves on-page references until any JS refreshes the DOM 317 | 318 | export async function onPostBuild({ 319 | constants, 320 | inputs, 321 | utils, 322 | }: OnPostBuildParams) { 323 | console.log('[Cloudinary] Replacing on-page images with Cloudinary URLs...'); 324 | 325 | let host = process.env.URL; 326 | 327 | if (process.env.CONTEXT === 'branch-deploy' || process.env.CONTEXT === 'deploy-preview') { 328 | host = process.env.DEPLOY_PRIME_URL || '' 329 | } 330 | 331 | 332 | console.log(`[Cloudinary] Using host: ${host}`); 333 | 334 | const { PUBLISH_DIR } = constants; 335 | const { 336 | cname, 337 | deliveryType, 338 | folder = process.env.SITE_NAME, 339 | loadingStrategy = inputs.loadingStrategy || 'lazy', 340 | privateCdn, 341 | uploadPreset, 342 | } = inputs; 343 | 344 | if (!folder) { 345 | console.error(`[Cloudinary] ${ERROR_SITE_NAME_REQUIRED}`); 346 | utils.build.failPlugin(ERROR_SITE_NAME_REQUIRED); 347 | return; 348 | } 349 | 350 | const cloudName = process.env.CLOUDINARY_CLOUD_NAME || inputs.cloudName; 351 | const apiKey = process.env.CLOUDINARY_API_KEY; 352 | const apiSecret = process.env.CLOUDINARY_API_SECRET; 353 | 354 | if (!cloudName) { 355 | console.error(`[Cloudinary] ${ERROR_CLOUD_NAME_REQUIRED}`); 356 | utils.build.failBuild(ERROR_CLOUD_NAME_REQUIRED); 357 | return; 358 | } 359 | 360 | if (deliveryType === 'upload' && (!apiKey || !apiSecret)) { 361 | console.error(`[Cloudinary] ${ERROR_API_CREDENTIALS_REQUIRED}`); 362 | utils.build.failBuild(ERROR_API_CREDENTIALS_REQUIRED); 363 | return; 364 | } 365 | 366 | configureCloudinary({ 367 | // Base credentials 368 | cloudName, 369 | apiKey, 370 | apiSecret, 371 | 372 | // Configuration 373 | cname, 374 | privateCdn, 375 | }); 376 | 377 | const transformations = getTransformationsFromInputs(inputs); 378 | 379 | // Find all HTML source files in the publish directory 380 | 381 | const pages = glob.sync(`${PUBLISH_DIR}/**/*.html`); 382 | 383 | const results = await Promise.all( 384 | pages.map(async page => { 385 | const sourceHtml = await fs.readFile(page, 'utf-8'); 386 | 387 | const { html, errors } = await updateHtmlImagesToCloudinary(sourceHtml, { 388 | assets: _cloudinaryAssets, 389 | deliveryType, 390 | uploadPreset, 391 | folder, 392 | loadingStrategy, 393 | localDir: PUBLISH_DIR, 394 | remoteHost: host, 395 | transformations 396 | }); 397 | 398 | await fs.writeFile(page, html); 399 | 400 | return { 401 | page, 402 | errors, 403 | }; 404 | }), 405 | ); 406 | 407 | const errors = results.filter(({ errors }) => errors.length > 0); 408 | // Collect the errors in the global scope to be used in the summary onEnd 409 | globalErrors.push(...errors) 410 | 411 | } 412 | 413 | 414 | export function onEnd({ utils }: { utils: Utils }) { 415 | const summary = globalErrors.length > 0 ? `Cloudinary build plugin completed with ${globalErrors.length} errors` : "Cloudinary build plugin completed successfully" 416 | const text = globalErrors.length > 0 ? `The build process found ${globalErrors.length} errors. Check build logs for more information` : "No errors found during build" 417 | utils.status.show({ 418 | title: "[Cloudinary] Done.", 419 | // Required. 420 | summary, 421 | text 422 | }); 423 | } 424 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/lib/cloudinary.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import path from 'path' 3 | import fetch from 'node-fetch' 4 | import { JSDOM } from 'jsdom' 5 | import { v2 as cloudinary, ConfigOptions, TransformationOptions } from 'cloudinary' 6 | 7 | import { ANALYTICS_SDK_CODE, ANALYTICS_SDK_SEMVER, ANALYTICS_PRODUCT } from '../data/analytics'; 8 | import { isRemoteUrl, determineRemoteUrl } from './util' 9 | import { ERROR_API_CREDENTIALS_REQUIRED, ERROR_ASSET_UPLOAD, ERROR_CLOUD_NAME_REQUIRED, ERROR_UPLOAD_PRESET } from '../data/errors' 10 | 11 | import { Inputs } from '../types/integration'; 12 | 13 | type CloudinaryConfig = { 14 | apiKey?: string; 15 | apiSecret?: string; 16 | cloudName: string; 17 | cname?: string; 18 | privateCdn?: boolean; 19 | } 20 | type DeliveryType = 21 | string 22 | | "upload" 23 | | "private" 24 | | "authenticated" 25 | | "fetch" 26 | | "multi" 27 | | "text" 28 | | "asset" 29 | | "list" 30 | | "facebook" 31 | | "twitter" 32 | | "twitter_name" 33 | | "instagram" 34 | | "gravatar" 35 | | "youtube" 36 | | "hulu" 37 | | "vimeo" 38 | | "animoto" 39 | | "worldstarhiphop" 40 | | "dailymotion"; 41 | 42 | type UploadOptions = { 43 | folder: string; 44 | public_id: string; 45 | overwrite: boolean 46 | upload_preset?: string 47 | } 48 | 49 | type FetchDelivery = { 50 | deliveryType: 'fetch'; 51 | remoteHost: string; 52 | } 53 | 54 | type OtherDelivery = { 55 | deliveryType: Omit; 56 | remoteHost?: string 57 | } 58 | 59 | export type CloudinaryOptions = { 60 | folder: string, 61 | path: string; 62 | localDir?: string; 63 | uploadPreset: string; 64 | transformations?: Array; 65 | } & (FetchDelivery | OtherDelivery) 66 | 67 | export type CloudinaryAnalytics = { 68 | sdkCode?: string; 69 | sdkSemver?: string; 70 | techVersion?: string; 71 | product?: string; 72 | } 73 | 74 | export type Assets = { 75 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 | images: Array 77 | } 78 | 79 | type UpdateCloudinaryOptions = Omit & { 80 | assets: Assets; 81 | loadingStrategy: string; 82 | } 83 | 84 | /** 85 | * getCloudinary 86 | */ 87 | 88 | export function getCloudinary(config: CloudinaryOptions & CloudinaryConfig) { 89 | if (!config) return cloudinary 90 | return configureCloudinary(config) 91 | } 92 | 93 | 94 | /** 95 | * configureCloudinary 96 | */ 97 | export function configureCloudinary(config: CloudinaryConfig) { 98 | const cloudinaryConfig: ConfigOptions = { 99 | cloud_name: config.cloudName, 100 | api_key: config.apiKey, 101 | api_secret: config.apiSecret, 102 | private_cdn: config.privateCdn, 103 | secure: true 104 | } 105 | 106 | if (config.cname) { 107 | cloudinaryConfig.secure_distribution = config.cname; 108 | // When configuring a cname, we need to additionally set private CDN 109 | // to be true in order to work properly, which may not be obvious 110 | // to those setting it up 111 | cloudinaryConfig.private_cdn = true; 112 | } 113 | 114 | cloudinary.config(cloudinaryConfig); 115 | 116 | return cloudinary 117 | } 118 | 119 | 120 | /** 121 | * createPublicId 122 | */ 123 | 124 | export async function createPublicId({ path: filePath }: { path: string }) { 125 | const hash = crypto.createHash('md5') 126 | 127 | const { name: imgName } = path.parse(filePath) 128 | 129 | if (!isRemoteUrl(filePath)) { 130 | hash.update(filePath) 131 | } else { 132 | const response = await fetch(filePath) 133 | const buffer = await response.buffer() 134 | hash.update(buffer) 135 | } 136 | 137 | const digest = hash.digest('hex') 138 | 139 | return `${imgName}-${digest}` 140 | } 141 | 142 | 143 | /** 144 | * getCloudinaryUrl 145 | */ 146 | 147 | export async function getCloudinaryUrl(options: CloudinaryOptions, analytics?: CloudinaryAnalytics) { 148 | const { 149 | deliveryType, 150 | folder, 151 | path: filePath, 152 | localDir, 153 | remoteHost, 154 | transformations = [], 155 | uploadPreset, 156 | } = options 157 | 158 | const { 159 | cloud_name: cloudName, 160 | api_key: apiKey, 161 | api_secret: apiSecret, 162 | } = cloudinary.config() 163 | const canSignUpload = apiKey && apiSecret 164 | 165 | if (!cloudName) { 166 | throw new Error(`[Cloudinary] ${ERROR_CLOUD_NAME_REQUIRED}`) 167 | } 168 | 169 | if (deliveryType === 'upload' && !canSignUpload && !uploadPreset) { 170 | if (!uploadPreset) { 171 | throw new Error(`[Cloudinary] ${ERROR_UPLOAD_PRESET}`) 172 | } 173 | throw new Error(`[Cloudinary] ${ERROR_API_CREDENTIALS_REQUIRED}`) 174 | } 175 | 176 | let fileLocation 177 | let publicId 178 | 179 | if (deliveryType === 'fetch') { 180 | // fetch allows us to pass in a remote URL to the Cloudinary API 181 | // which it will cache and serve from the CDN, but not store 182 | 183 | fileLocation = determineRemoteUrl(filePath, remoteHost as string) 184 | publicId = fileLocation 185 | } else if (deliveryType === 'upload') { 186 | // upload will actually store the image in the Cloudinary account 187 | // and subsequently serve that stored image 188 | 189 | // If our image is locally sourced, we need to obtain the full 190 | // local relative path so that we can tell Cloudinary where 191 | // to upload from 192 | 193 | let fullPath = filePath 194 | 195 | if (!isRemoteUrl(fullPath)) { 196 | fullPath = path.join(localDir ?? '', fullPath) 197 | } 198 | 199 | const id = await createPublicId({ 200 | path: fullPath, 201 | }) 202 | 203 | const uploadOptions: UploadOptions = { 204 | folder, 205 | public_id: id, 206 | overwrite: false, 207 | } 208 | 209 | if (uploadPreset) { 210 | uploadOptions.upload_preset = uploadPreset 211 | } 212 | 213 | let results 214 | const maxAttempts = 3; 215 | 216 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 217 | try { 218 | if (canSignUpload) { 219 | // We need an API Key and Secret to use signed uploading 220 | results = await cloudinary.uploader.upload(fullPath, { 221 | ...uploadOptions, 222 | }) 223 | break; 224 | } 225 | else { 226 | // If we want to avoid signing our uploads, we don't need our API Key and Secret, 227 | // however, we need to provide an uploadPreset 228 | results = await cloudinary.uploader.unsigned_upload( 229 | fullPath, 230 | uploadPreset, 231 | { 232 | ...uploadOptions, 233 | }, 234 | ) 235 | break; 236 | } 237 | } catch (error) { 238 | console.error(`[Cloudinary] Attempt ${attempt + 1} - ${ERROR_ASSET_UPLOAD}`); 239 | console.error(`[Cloudinary] Attempt ${attempt + 1} - \tpath: ${fullPath}`) 240 | if (attempt === maxAttempts - 1) { 241 | // If it's the last attempt, rethrow the error or handle it accordingly 242 | throw Error(ERROR_ASSET_UPLOAD); 243 | } else { 244 | await new Promise(resolve => setTimeout(resolve, 500)); 245 | } 246 | } 247 | } 248 | 249 | // Finally use the stored public ID to grab the image URL 250 | 251 | const { public_id } = results 252 | publicId = public_id 253 | fileLocation = fullPath 254 | } 255 | 256 | const cloudinaryUrl = cloudinary.url(publicId, { 257 | type: deliveryType, 258 | secure: true, 259 | transformation: [ 260 | { 261 | fetch_format: 'auto', 262 | quality: 'auto', 263 | }, 264 | ...transformations 265 | ], 266 | urlAnalytics: true, 267 | sdkCode: ANALYTICS_SDK_CODE, 268 | sdkSemver: ANALYTICS_SDK_SEMVER, 269 | techVersion: '0.0.0', 270 | product: ANALYTICS_PRODUCT, 271 | ...analytics 272 | }); 273 | 274 | return { 275 | sourceUrl: fileLocation, 276 | cloudinaryUrl, 277 | publicId, 278 | } 279 | } 280 | 281 | /** 282 | * updateHtmlImagesToCloudinary 283 | */ 284 | 285 | // function to check for assets previously build by Cloudinary 286 | function getAsset(imgUrl: string, assets: Assets) { 287 | const cloudinaryAsset = 288 | assets && 289 | Array.isArray(assets.images) && 290 | assets.images.find(({ publishPath, publishUrl } = {}) => { 291 | return [publishPath, publishUrl].includes(imgUrl) 292 | }) 293 | 294 | return cloudinaryAsset 295 | } 296 | 297 | export async function updateHtmlImagesToCloudinary(html: string, options: UpdateCloudinaryOptions, analytics?: CloudinaryAnalytics) { 298 | const { 299 | assets, 300 | deliveryType, 301 | uploadPreset, 302 | folder, 303 | localDir, 304 | remoteHost, 305 | loadingStrategy, 306 | transformations 307 | } = options 308 | 309 | const errors = [] 310 | const dom = new JSDOM(html) 311 | 312 | // Loop through all images found in the DOM and swap the source with 313 | // a Cloudinary URL 314 | 315 | const images = Array.from(dom.window.document.querySelectorAll('img')) 316 | 317 | for (const $img of images) { 318 | const imgSrc = $img.getAttribute('src') as string // @TODO can this be really be null at this point? 319 | let cloudinaryUrl 320 | 321 | // Check to see if we have an existing asset already to pick from 322 | // Look at both the path and full URL 323 | 324 | const asset = getAsset(imgSrc, assets) 325 | 326 | if (asset && deliveryType === 'upload') { 327 | cloudinaryUrl = asset.cloudinaryUrl 328 | } 329 | 330 | // If we don't have an asset and thus don't have a Cloudinary URL, create 331 | // one for our asset 332 | if (!cloudinaryUrl) { 333 | try { 334 | const { cloudinaryUrl: url } = await getCloudinaryUrl({ 335 | deliveryType, 336 | folder, 337 | path: imgSrc, 338 | localDir, 339 | uploadPreset, 340 | remoteHost, 341 | transformations 342 | }, analytics) 343 | cloudinaryUrl = url 344 | } catch (e) { 345 | if (e instanceof Error) { 346 | errors.push({ 347 | imgSrc, 348 | message: e.message 349 | }) 350 | } 351 | 352 | continue 353 | } 354 | } 355 | 356 | $img.setAttribute('src', cloudinaryUrl) 357 | $img.setAttribute('loading', loadingStrategy) 358 | 359 | // convert srcset images to cloudinary 360 | 361 | const srcset = $img.getAttribute('srcset') 362 | 363 | if (srcset) { 364 | // convert all srcset urls to cloudinary urls using getCloudinaryUrl function in a Promise.all 365 | 366 | const srcsetUrls = srcset.split(',').map(url => url.trim().split(' ')) 367 | 368 | const srcsetUrlsPromises = srcsetUrls.map(url => { 369 | const exists = getAsset(url[0], assets) 370 | if (exists && deliveryType === 'upload') { 371 | return exists.cloudinaryUrl 372 | } 373 | try { 374 | 375 | return getCloudinaryUrl({ 376 | deliveryType, 377 | folder, 378 | path: url[0], 379 | localDir, 380 | uploadPreset, 381 | remoteHost, 382 | }, analytics) 383 | } catch (e) { 384 | if (e instanceof Error) { 385 | errors.push({ 386 | imgSrc, 387 | message: e.message 388 | }) 389 | } 390 | 391 | } 392 | }) 393 | 394 | const srcsetUrlsCloudinary = await Promise.all(srcsetUrlsPromises) 395 | const srcsetUrlsCloudinaryString = srcsetUrlsCloudinary 396 | .map( 397 | (urlCloudinary, index) => 398 | `${urlCloudinary.cloudinaryUrl} ${srcsetUrls[index][1]}`, 399 | ) 400 | .join(', ') 401 | 402 | $img.setAttribute('srcset', srcsetUrlsCloudinaryString) 403 | } 404 | 405 | // Look for any preload tags that reference the image URLs. A specific use case here 406 | // is Next.js App Router hen using the Image component. 407 | 408 | const $preload = dom.window.document.querySelector( 409 | `link[rel="preload"][as="image"][href="${imgSrc}"]`, 410 | ) 411 | 412 | if ($preload) { 413 | $preload.setAttribute('href', cloudinaryUrl) 414 | } 415 | } 416 | 417 | return { 418 | html: dom.serialize(), 419 | errors, 420 | } 421 | } 422 | 423 | /** 424 | * getTransformationsFromInputs 425 | */ 426 | 427 | export function getTransformationsFromInputs(inputs: Inputs) { 428 | const { maxSize } = inputs; 429 | 430 | const transformations: CloudinaryOptions['transformations'] = []; 431 | 432 | if ( typeof maxSize === 'object' ) { 433 | transformations.push({ 434 | height: maxSize.height, 435 | width: maxSize.width, 436 | crop: 'limit', 437 | dpr: maxSize.dpr 438 | }) 439 | } 440 | return transformations; 441 | } 442 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { glob } from 'glob'; 3 | 4 | /** 5 | * isRemoteUrl 6 | */ 7 | 8 | export function isRemoteUrl(url: string) { 9 | return url.startsWith('http') 10 | } 11 | 12 | 13 | /** 14 | * determineRemoteUrl 15 | */ 16 | 17 | export function determineRemoteUrl(url:string, host: string) { 18 | if (isRemoteUrl(url)) return url 19 | 20 | if (!url.startsWith('/')) { 21 | url = `/${url}` 22 | } 23 | 24 | url = `${host}${url}` 25 | 26 | return url 27 | } 28 | 29 | 30 | /** 31 | * getQueryParams 32 | */ 33 | 34 | 35 | export function getQueryParams(url: string) { 36 | if (typeof url !== 'string') { 37 | throw new Error('Can not getQueryParams. Invalid URL') 38 | } 39 | 40 | const params = {} 41 | 42 | const urlSegments = url.split('?') 43 | 44 | urlSegments[1] && 45 | urlSegments[1].split('&').forEach(segment => { 46 | const [key, value] = segment.split('=') 47 | //@ts-expect-error TS can't check if key is in effect key of params 48 | params[key] = value 49 | }) 50 | 51 | return params 52 | } 53 | 54 | /** 55 | * findAssetsByPath 56 | */ 57 | 58 | interface FindAssetsByPath { 59 | baseDir: string; 60 | path: string | Array; 61 | } 62 | 63 | export function findAssetsByPath(options: FindAssetsByPath) { 64 | if ( !Array.isArray(options.path) ) { 65 | options.path = [options.path]; 66 | } 67 | 68 | return options.path.flatMap(assetsPath => { 69 | const assetsDirectory = glob.sync(path.join(options.baseDir, assetsPath, '/**/*')); 70 | return assetsDirectory.filter(file => !!path.extname(file)); 71 | }) 72 | } -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/src/types/integration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * this type is built based on the content of the plugin manifest file 3 | * Information found here https://docs.netlify.com/integrations/build-plugins/create-plugins/#inputs 4 | */ 5 | export type Inputs = { 6 | cloudName: string; 7 | cname: string; 8 | deliveryType: string; 9 | folder: string; 10 | imagesPath: string | Array; 11 | loadingStrategy: string; 12 | maxSize: { 13 | dpr: number | string; 14 | height: number; 15 | width: number; 16 | }; 17 | privateCdn: boolean; 18 | uploadPreset: string; 19 | uploadConcurrency: number; 20 | }; 21 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/assets/stranger-things-lucas.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/netlify-plugin-cloudinary/tests/assets/stranger-things-lucas.jpeg -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/images/stranger-things-dustin.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/netlify-plugin-cloudinary/tests/images/stranger-things-dustin.jpeg -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/images/stranger-things-eleven.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudinary-community/netlify-plugin-cloudinary/ed9cdf58681cc5d4ed897a6a139cfa73dd2d62fb/netlify-plugin-cloudinary/tests/images/stranger-things-eleven.jpeg -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/lib/cloudinary-cname.test.js: -------------------------------------------------------------------------------- 1 | import { vi, expect, describe, test, beforeEach, afterAll, it } from 'vitest'; 2 | 3 | import { configureCloudinary, getCloudinaryUrl, updateHtmlImagesToCloudinary } from '../../src/lib/cloudinary'; 4 | import { ANALYTICS_SDK_CODE, ANALYTICS_PRODUCT } from '../../src/data/analytics'; 5 | 6 | const TEST_ANALYTICS_CONFIG = { 7 | sdkCode: ANALYTICS_SDK_CODE, 8 | sdkSemver: '1.1.1', 9 | techVersion: '1.1.1', 10 | product: ANALYTICS_PRODUCT, 11 | } 12 | 13 | const TEST_ANALYTICS_STRING = 'BBFCd1Bl0'; 14 | 15 | describe('lib/util', () => { 16 | const ENV_ORIGINAL = process.env; 17 | const cname = 'spacejelly.dev'; 18 | 19 | beforeEach(() => { 20 | vi.resetModules(); 21 | 22 | process.env = { ...ENV_ORIGINAL }; 23 | process.env.CLOUDINARY_CLOUD_NAME = 'testcloud'; 24 | process.env.CLOUDINARY_API_KEY = '123456789012345'; 25 | process.env.CLOUDINARY_API_SECRET = 'abcd1234'; 26 | 27 | configureCloudinary({ 28 | cloudName: process.env.CLOUDINARY_CLOUD_NAME, 29 | apiKey: process.env.CLOUDINARY_API_KEY, 30 | apiSecret: process.env.CLOUDINARY_API_SECRET, 31 | cname, 32 | }); 33 | }); 34 | 35 | afterAll(() => { 36 | process.env = ENV_ORIGINAL; 37 | }); 38 | 39 | describe('getCloudinaryUrl', () => { 40 | 41 | test('should create a Cloudinary URL with delivery type of fetch from a local image', async () => { 42 | const { cloudinaryUrl } = await getCloudinaryUrl({ 43 | deliveryType: 'fetch', 44 | path: '/images/stranger-things-dustin.jpeg', 45 | localDir: '/tests/images', 46 | remoteHost: 'https://cloudinary.netlify.app' 47 | }); 48 | 49 | expect(cloudinaryUrl).toMatch(`https://${cname}/image/fetch/f_auto,q_auto/https://cloudinary.netlify.app/images/stranger-things-dustin.jpeg`); 50 | }); 51 | 52 | }); 53 | 54 | describe('updateHtmlImagesToCloudinary', () => { 55 | 56 | it('should replace a local image with a Cloudinary URL', async () => { 57 | const sourceHtml = '

'; 58 | 59 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 60 | deliveryType: 'fetch', 61 | localDir: 'tests', 62 | remoteHost: 'https://cloudinary.netlify.app', 63 | loadingStrategy: 'lazy' 64 | }, TEST_ANALYTICS_CONFIG); 65 | 66 | expect(html).toEqual(`

`); 67 | }); 68 | 69 | }); 70 | 71 | }); 72 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/lib/cloudinary-privatecdn.test.js: -------------------------------------------------------------------------------- 1 | import { vi, expect, describe, test, beforeEach, afterAll, it } from 'vitest'; 2 | 3 | import { configureCloudinary, getCloudinaryUrl, updateHtmlImagesToCloudinary } from '../../src/lib/cloudinary'; 4 | import { ANALYTICS_SDK_CODE, ANALYTICS_PRODUCT } from '../../src/data/analytics'; 5 | 6 | const TEST_ANALYTICS_CONFIG = { 7 | sdkCode: ANALYTICS_SDK_CODE, 8 | sdkSemver: '1.1.1', 9 | techVersion: '1.1.1', 10 | product: ANALYTICS_PRODUCT, 11 | } 12 | 13 | const TEST_ANALYTICS_STRING = 'BBFCd1Bl0'; 14 | 15 | describe('lib/util', () => { 16 | const ENV_ORIGINAL = process.env; 17 | 18 | beforeEach(() => { 19 | vi.resetModules(); 20 | 21 | process.env = { ...ENV_ORIGINAL }; 22 | process.env.CLOUDINARY_CLOUD_NAME = 'testcloud'; 23 | process.env.CLOUDINARY_API_KEY = '123456789012345'; 24 | process.env.CLOUDINARY_API_SECRET = 'abcd1234'; 25 | 26 | configureCloudinary({ 27 | cloudName: process.env.CLOUDINARY_CLOUD_NAME, 28 | apiKey: process.env.CLOUDINARY_API_KEY, 29 | apiSecret: process.env.CLOUDINARY_API_SECRET, 30 | privateCdn: true, 31 | }); 32 | }); 33 | 34 | afterAll(() => { 35 | process.env = ENV_ORIGINAL; 36 | }); 37 | 38 | describe('getCloudinaryUrl', () => { 39 | 40 | test('should create a Cloudinary URL with delivery type of fetch from a local image', async () => { 41 | const { cloudinaryUrl } = await getCloudinaryUrl({ 42 | deliveryType: 'fetch', 43 | path: '/images/stranger-things-dustin.jpeg', 44 | localDir: '/tests/images', 45 | remoteHost: 'https://cloudinary.netlify.app' 46 | }); 47 | 48 | expect(cloudinaryUrl).toMatch(`https://${process.env.CLOUDINARY_CLOUD_NAME}-res.cloudinary.com/image/fetch/f_auto,q_auto/https://cloudinary.netlify.app/images/stranger-things-dustin.jpeg`); 49 | }); 50 | 51 | }); 52 | 53 | describe('updateHtmlImagesToCloudinary', () => { 54 | 55 | it('should replace a local image with a Cloudinary URL', async () => { 56 | const sourceHtml = '

'; 57 | 58 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 59 | deliveryType: 'fetch', 60 | localDir: 'tests', 61 | remoteHost: 'https://cloudinary.netlify.app', 62 | loadingStrategy: 'lazy' 63 | }, TEST_ANALYTICS_CONFIG); 64 | 65 | expect(html).toEqual(`

`); 66 | }); 67 | 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/lib/cloudinary.test.js: -------------------------------------------------------------------------------- 1 | import { vi, expect, describe, test, beforeEach, afterAll, it } from 'vitest'; 2 | 3 | import { ERROR_ASSET_UPLOAD } from '../../src/data/errors'; 4 | import { getCloudinary, createPublicId, configureCloudinary, getCloudinaryUrl, updateHtmlImagesToCloudinary } from '../../src/lib/cloudinary'; 5 | import { ANALYTICS_SDK_CODE, ANALYTICS_PRODUCT } from '../../src/data/analytics'; 6 | 7 | const mockDemo = require('../mocks/demo.json'); 8 | 9 | const cloudinary = getCloudinary(); 10 | 11 | const TEST_ANALYTICS_CONFIG = { 12 | sdkCode: ANALYTICS_SDK_CODE, 13 | sdkSemver: '1.1.1', 14 | techVersion: '1.1.1', 15 | product: ANALYTICS_PRODUCT, 16 | } 17 | 18 | const TEST_ANALYTICS_STRING = 'BBFCd1Bl0'; 19 | 20 | describe('lib/util', () => { 21 | const ENV_ORIGINAL = process.env; 22 | 23 | beforeEach(() => { 24 | vi.resetModules(); 25 | 26 | process.env = { ...ENV_ORIGINAL }; 27 | process.env.CLOUDINARY_CLOUD_NAME = 'testcloud'; 28 | process.env.CLOUDINARY_API_KEY = '123456789012345'; 29 | process.env.CLOUDINARY_API_SECRET = 'abcd1234'; 30 | 31 | configureCloudinary({ 32 | cloudName: process.env.CLOUDINARY_CLOUD_NAME, 33 | apiKey: process.env.CLOUDINARY_API_KEY, 34 | apiSecret: process.env.CLOUDINARY_API_SECRET, 35 | }) 36 | }); 37 | 38 | afterAll(() => { 39 | process.env = ENV_ORIGINAL; 40 | }); 41 | 42 | describe('createPublicId', () => { 43 | 44 | test('should create a public ID from a remote URL', async () => { 45 | const mikeId = await createPublicId({ path: 'https://i.imgur.com/e6XK75j.png' }); 46 | expect(mikeId).toEqual('e6XK75j-58e290136642a9c711afa6410b07848d'); 47 | 48 | const lucasId = await createPublicId({ path: 'https://i.imgur.com/vtYmp1x.png' }); 49 | expect(lucasId).toEqual('vtYmp1x-ae71a79c9c36b8d5dba872c3b274a444'); 50 | }); 51 | 52 | test('should create a public ID from a local image', async () => { 53 | const dustinId = await createPublicId({ path: '../images/stranger-things-dustin.jpeg' }); 54 | expect(dustinId).toEqual('stranger-things-dustin-9a2a7b1501695c50ad85c329f79fb184'); 55 | 56 | const elevenId = await createPublicId({ path: '../images/stranger-things-eleven.jpeg' }); 57 | expect(elevenId).toEqual('stranger-things-eleven-c5486e412115dbeba03315959c3a6d20'); 58 | }); 59 | 60 | }); 61 | 62 | describe('getCloudinaryUrl', () => { 63 | 64 | test('should create a Cloudinary URL with delivery type of fetch from a local image', async () => { 65 | const { cloudinaryUrl } = await getCloudinaryUrl({ 66 | deliveryType: 'fetch', 67 | path: '/images/stranger-things-dustin.jpeg', 68 | localDir: '/tests/images', 69 | remoteHost: 'https://cloudinary.netlify.app' 70 | }); 71 | 72 | expect(cloudinaryUrl).toMatch(`https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/fetch/f_auto,q_auto/https://cloudinary.netlify.app/images/stranger-things-dustin.jpeg`); 73 | }); 74 | 75 | test('should create a Cloudinary URL with delivery type of fetch from a remote image', async () => { 76 | 77 | const { cloudinaryUrl } = await getCloudinaryUrl({ 78 | deliveryType: 'fetch', 79 | path: 'https://i.imgur.com/vtYmp1x.png' 80 | }); 81 | 82 | expect(cloudinaryUrl).toMatch(`https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/fetch/f_auto,q_auto/https://i.imgur.com/vtYmp1x.png`); 83 | }); 84 | 85 | 86 | test('should create a Cloudinary URL with delivery type of upload from a local image', async () => { 87 | // mock cloudinary.uploader.upload call 88 | cloudinary.uploader.upload = vi.fn().mockResolvedValue({ 89 | public_id: 'stranger-things-dustin-fc571e771d5ca7d9223a7eebfd2c505d', 90 | secure_url: `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/v1613009008/stranger-things-dustin-fc571e771d5ca7d9223a7eebfd2c505d.jpg`, 91 | original_filename: 'stranger-things-dustin', 92 | version: 1613009008, 93 | width: 1280, 94 | height: 720, 95 | format: 'jpg', 96 | resource_type: 'image', 97 | created_at: '2021-02-11T16:43:28Z', 98 | }) 99 | 100 | const { cloudinaryUrl } = await getCloudinaryUrl({ 101 | deliveryType: 'upload', 102 | path: '/images/stranger-things-dustin.jpeg', 103 | localDir: 'tests', 104 | remoteHost: 'https://cloudinary.netlify.app' 105 | }); 106 | 107 | expect(cloudinaryUrl).toMatch(`https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/f_auto,q_auto/stranger-things-dustin-fc571e771d5ca7d9223a7eebfd2c505d`); 108 | }); 109 | 110 | test('should fail to create a Cloudinary URL with delivery type of upload', async () => { 111 | // mock cloudinary.uploader.upload call 112 | cloudinary.uploader.upload = vi.fn().mockImplementation(() => Promise.reject('error')) 113 | 114 | 115 | await expect(getCloudinaryUrl({ 116 | deliveryType: 'upload', 117 | path: '/images/stranger-things-dustin.jpeg', 118 | localDir: 'tests', 119 | remoteHost: 'https://cloudinary.netlify.app' 120 | })).rejects.toThrow(ERROR_ASSET_UPLOAD); 121 | }); 122 | 123 | 124 | 125 | 126 | // TODO: Mock functions to test Cloudinary uploads without actual upload 127 | 128 | // test('should create a Cloudinary URL with delivery type of upload from a remote image', async () => { 129 | // const { cloudinaryUrl } = await getCloudinaryUrl({ 130 | // deliveryType: 'upload', 131 | // path: 'https://i.imgur.com/vtYmp1x.png' 132 | // }); 133 | 134 | // expect(cloudinaryUrl).toMatch(`https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/f_auto,q_auto/vtYmp1x-ae71a79c9c36b8d5dba872c3b274a444`); 135 | // }); 136 | 137 | test('should apply transformations', async () => { 138 | const maxSize = { 139 | width: 800, 140 | height: 600, 141 | dpr: '3.0', 142 | crop: 'limit' 143 | } 144 | const { cloudinaryUrl } = await getCloudinaryUrl({ 145 | deliveryType: 'fetch', 146 | path: '/images/stranger-things-dustin.jpeg', 147 | localDir: '/tests/images', 148 | remoteHost: 'https://cloudinary.netlify.app', 149 | transformations: [maxSize] 150 | }); 151 | 152 | expect(cloudinaryUrl).toMatch(`https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/fetch/f_auto,q_auto/c_${maxSize.crop},dpr_${maxSize.dpr},h_${maxSize.height},w_${maxSize.width}/https://cloudinary.netlify.app/images/stranger-things-dustin.jpeg`); 153 | }); 154 | 155 | }); 156 | 157 | describe('updateHtmlImagesToCloudinary', () => { 158 | 159 | it('should replace a local image with a Cloudinary URL', async () => { 160 | const sourceHtml = '

'; 161 | 162 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 163 | deliveryType: 'fetch', 164 | localDir: 'tests', 165 | remoteHost: 'https://cloudinary.netlify.app', 166 | loadingStrategy: 'lazy' 167 | }, TEST_ANALYTICS_CONFIG); 168 | 169 | expect(html).toEqual(`

`); 170 | }); 171 | 172 | it('should replace a remote image with a Cloudinary URL', async () => { 173 | const sourceHtml = '

'; 174 | 175 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 176 | deliveryType: 'fetch', 177 | loadingStrategy: 'lazy' 178 | }, TEST_ANALYTICS_CONFIG); 179 | 180 | expect(html).toEqual(`

`); 181 | }); 182 | 183 | 184 | it('should replace a local image with a Cloudinary URL in a srcset', async () => { 185 | const sourceHtml = '

'; 186 | 187 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 188 | deliveryType: 'fetch', 189 | localDir: 'tests', 190 | remoteHost: 'https://cloudinary.netlify.app', 191 | loadingStrategy: 'lazy' 192 | }, TEST_ANALYTICS_CONFIG); 193 | 194 | expect(html).toEqual(`

`); 195 | }); 196 | 197 | it('should add eager loading to image when eager option is provided for loadingStrategy', async () => { 198 | const sourceHtml = '

'; 199 | 200 | const { html } = await updateHtmlImagesToCloudinary(sourceHtml, { 201 | deliveryType: 'fetch', 202 | localDir: 'tests', 203 | remoteHost: 'https://cloudinary.netlify.app', 204 | loadingStrategy: 'eager' 205 | }, TEST_ANALYTICS_CONFIG); 206 | 207 | expect(html).toEqual(`

`); 208 | }); 209 | 210 | it('should test uploading multiple assets', async () => { 211 | // This is meant to replicate the current demo 212 | 213 | const { html } = await updateHtmlImagesToCloudinary(mockDemo.htmlBefore, { 214 | deliveryType: 'upload', 215 | localDir: 'demo/.next', 216 | remoteHost: 'https://main--netlify-plugin-cloudinary.netlify.app', 217 | loadingStrategy: 'lazy', 218 | folder: 'netlify-plugin-cloudinary', 219 | assets: mockDemo.assets 220 | }, TEST_ANALYTICS_CONFIG); 221 | 222 | expect(html).toEqual(mockDemo.htmlAfter); 223 | }); 224 | 225 | }); 226 | 227 | }); 228 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/lib/util.test.js: -------------------------------------------------------------------------------- 1 | import { expect, describe, test } from 'vitest'; 2 | 3 | import { isRemoteUrl, determineRemoteUrl, getQueryParams } from '../../src/lib/util'; 4 | 5 | describe('lib/util', () => { 6 | 7 | describe('isRemoteUrl', () => { 8 | 9 | test('should be a remote URL', () => { 10 | expect(isRemoteUrl('https://cloudinary.com')).toEqual(true); 11 | expect(isRemoteUrl('http://cloudinary.com')).toEqual(true); 12 | }); 13 | 14 | test('should not be a remote URL', () => { 15 | expect(isRemoteUrl('/images/cloud.jpg')).toEqual(false); 16 | expect(isRemoteUrl('images/cloud.jpg')).toEqual(false); 17 | }); 18 | 19 | }); 20 | 21 | describe('determineRemoteUrl', () => { 22 | 23 | test('should return original value when remote URL', () => { 24 | const secure = determineRemoteUrl('https://cloudinary.com', 'https://cloudinary.netlify.app'); 25 | expect(secure).toEqual('https://cloudinary.com'); 26 | 27 | const notSecure = determineRemoteUrl('http://cloudinary.com', 'https://cloudinary.netlify.app'); 28 | expect(notSecure).toEqual('http://cloudinary.com'); 29 | }); 30 | 31 | test('should prepend DEPLOY_PRIME_URL when local path', () => { 32 | const withStartingSlash = determineRemoteUrl('/images/cloud.jpg', 'https://cloudinary.netlify.app'); 33 | expect(withStartingSlash).toEqual('https://cloudinary.netlify.app/images/cloud.jpg'); 34 | 35 | const withoutStartingSlash = determineRemoteUrl('images/cloud.jpg', 'https://cloudinary.netlify.app'); 36 | expect(withoutStartingSlash).toEqual('https://cloudinary.netlify.app/images/cloud.jpg'); 37 | }); 38 | 39 | }); 40 | 41 | describe('getQueryParams', () => { 42 | 43 | test('should get query parameters by URL with a single param', () => { 44 | const { myparam } = getQueryParams('https://cloudinary.com?myparam=myvalue') 45 | expect(myparam).toEqual('myvalue'); 46 | }); 47 | 48 | test('should get query parameters by URL with a multiple params', () => { 49 | const { myparam, otherparam } = getQueryParams('https://cloudinary.com?myparam=myvalue&otherparam=othervalue') 50 | expect(myparam).toEqual('myvalue'); 51 | expect(otherparam).toEqual('othervalue'); 52 | }); 53 | 54 | }); 55 | 56 | }); -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/mocks/demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets": { 3 | "images": [ 4 | { 5 | "publishPath": "/images/beach.jpeg", 6 | "sourceUrl": "demo/.next/images/beach.jpeg", 7 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/beach-b7ec1e3b78cccfac8658924c0728c095", 8 | "publicId": "netlify-plugin-cloudinary/beach-b7ec1e3b78cccfac8658924c0728c095" 9 | }, 10 | { 11 | "publishPath": "/images/city.jpeg", 12 | "sourceUrl": "demo/.next/images/city.jpeg", 13 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/city-870b0b21d7a4d2c4d93a41e92c7ec0cc", 14 | "publicId": "netlify-plugin-cloudinary/city-870b0b21d7a4d2c4d93a41e92c7ec0cc" 15 | }, 16 | { 17 | "publishPath": "/images/earth.jpeg", 18 | "sourceUrl": "demo/.next/images/earth.jpeg", 19 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/earth-c41e0d112194fa40bdb770b0fbd60d9d", 20 | "publicId": "netlify-plugin-cloudinary/earth-c41e0d112194fa40bdb770b0fbd60d9d" 21 | }, 22 | { 23 | "publishPath": "/images/galaxy.jpeg", 24 | "sourceUrl": "demo/.next/images/galaxy.jpeg", 25 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/galaxy-87aed886f0383f55c4cb8856f22d6aae", 26 | "publicId": "netlify-plugin-cloudinary/galaxy-87aed886f0383f55c4cb8856f22d6aae" 27 | }, 28 | { 29 | "publishPath": "/images/iceberg.jpeg", 30 | "sourceUrl": "demo/.next/images/iceberg.jpeg", 31 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/iceberg-c53e3eed54890f447da5dacdcf83311c", 32 | "publicId": "netlify-plugin-cloudinary/iceberg-c53e3eed54890f447da5dacdcf83311c" 33 | }, 34 | { 35 | "publishPath": "/images/jungle.jpeg", 36 | "sourceUrl": "demo/.next/images/jungle.jpeg", 37 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/jungle-cc016c24ce648db203fb40121933e136", 38 | "publicId": "netlify-plugin-cloudinary/jungle-cc016c24ce648db203fb40121933e136" 39 | }, 40 | { 41 | "publishPath": "/images/mountain.jpeg", 42 | "sourceUrl": "demo/.next/images/mountain.jpeg", 43 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/mountain-e223899d7cf3204e889bed345a976bba", 44 | "publicId": "netlify-plugin-cloudinary/mountain-e223899d7cf3204e889bed345a976bba" 45 | }, 46 | { 47 | "publishPath": "/images/test/beach.jpeg", 48 | "sourceUrl": "demo/.next/images/test/beach.jpeg", 49 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/beach-b4117f2f07d500ea89a3ae7103bf98be", 50 | "publicId": "netlify-plugin-cloudinary/beach-b4117f2f07d500ea89a3ae7103bf98be" 51 | }, 52 | { 53 | "publishPath": "/images/waterfall.jpeg", 54 | "sourceUrl": "demo/.next/images/waterfall.jpeg", 55 | "cloudinaryUrl": "https://res.cloudinary.com/colbycloud/image/upload/f_auto,q_auto/v1/netlify-plugin-cloudinary/waterfall-0f890be5ceb2f8248693f1300cf1ce9d", 56 | "publicId": "netlify-plugin-cloudinary/waterfall-0f890be5ceb2f8248693f1300cf1ce9d" 57 | } 58 | ] 59 | }, 60 | "htmlBefore": "Cloudinary Netlify Plugin

Cloudinary Netlify Plugin

Supercharge images on your Netlify site with Cloudinary!

Getting Started

More details and advanced configuration on GitHub

Images Transformed to Use Cloudinary

  • \"Beach\"/
  • \"City\"/
  • \"Earth\"/
  • \"Galaxy\"/
  • \"Iceberg\"/
  • \"Jungle\"/
  • \"Mountain\"/
  • \"Waterfall\"/
", 61 | "htmlAfter": "Cloudinary Netlify Plugin

Cloudinary Netlify Plugin

Supercharge images on your Netlify site with Cloudinary!

Getting Started

More details and advanced configuration on GitHub

Images Transformed to Use Cloudinary

  • \"Beach\"
  • \"City\"
  • \"Earth\"
  • \"Galaxy\"
  • \"Iceberg\"
  • \"Jungle\"
  • \"Mountain\"
  • \"Waterfall\"
" 62 | } -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/mocks/html/test-1.html: -------------------------------------------------------------------------------- 1 | Netlify Cloudinary - Automatic Optimization at Scale
13 |

Netlify Cloudinary

Optimize images at scale on Netlify with Cloudinary

Get Started

Netlify LogoCloudinary Logo

MIT 2023 © Colby Fayock
-------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/on-build.test.js: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'node:path'; 3 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 4 | import { onBuild } from '../src/'; 5 | import { ERROR_API_CREDENTIALS_REQUIRED } from '../src/data/errors'; 6 | 7 | const contexts = [ 8 | { 9 | name: 'production', 10 | url: 'https://netlify-plugin-cloudinary.netlify.app' 11 | }, 12 | { 13 | name: 'deploy-preview', 14 | url: 'https://deploy-preview-1234--netlify-plugin-cloudinary.netlify.app' 15 | } 16 | ] 17 | 18 | describe('onBuild', () => { 19 | const ENV_ORIGINAL = process.env; 20 | const readdir = fs.readdir; 21 | 22 | beforeEach(() => { 23 | fs.readdir = vi.fn(); 24 | vi.resetModules(); 25 | 26 | process.env = { ...ENV_ORIGINAL }; 27 | 28 | process.env.SITE_NAME = 'cool-site'; 29 | process.env.CLOUDINARY_CLOUD_NAME = 'testcloud'; 30 | process.env.CLOUDINARY_API_KEY = '123456789012345'; 31 | process.env.CLOUDINARY_API_SECRET = 'abcd1234'; 32 | }); 33 | 34 | afterEach(() => { 35 | fs.readdir = readdir; 36 | process.env = ENV_ORIGINAL; 37 | }); 38 | 39 | describe('Config', () => { 40 | 41 | test('should error when using delivery type of upload without API Key and Secret', async () => { 42 | // Test that verifies that delivery of type of fetch works without API Key and Secret can be found 43 | // below under test: should create redirects with defaut fetch-based configuration in production context 44 | // We don't need a "special" test for this as it's default functionality that should work with 45 | // any valid test, so we can isntead ensure the keys don't exist and delete them 46 | 47 | vi.spyOn(global.console, 'error').mockImplementation(); 48 | 49 | delete process.env.CLOUDINARY_API_KEY; 50 | delete process.env.CLOUDINARY_API_SECRET; 51 | 52 | process.env.DEPLOY_PRIME_URL = 'https://deploy-preview-1234--netlify-plugin-cloudinary.netlify.app'; 53 | 54 | const deliveryType = 'upload'; 55 | const imagesPath = '/images'; 56 | 57 | await onBuild({ 58 | constants: { 59 | PUBLISH_DIR: `.next/out${imagesPath}` 60 | }, 61 | inputs: { 62 | deliveryType 63 | }, 64 | utils: { 65 | build: { 66 | failBuild: () => { } 67 | } 68 | } 69 | }); 70 | 71 | expect(console.error).toBeCalledWith(`[Cloudinary] ${ERROR_API_CREDENTIALS_REQUIRED}`); 72 | }); 73 | 74 | }); 75 | 76 | describe('Redirects', () => { 77 | 78 | let deliveryType = 'fetch'; 79 | let netlifyConfig; 80 | let redirects; 81 | 82 | const defaultRedirect = { 83 | from: '/path', 84 | to: '/other-path', 85 | status: 200 86 | } 87 | 88 | beforeEach(async () => { 89 | // Tests to ensure that delivery type of fetch works without API Key and Secret as it should 90 | 91 | delete process.env.CLOUDINARY_API_KEY; 92 | delete process.env.CLOUDINARY_API_SECRET; 93 | 94 | // resets vars for each tests 95 | redirects = [defaultRedirect]; 96 | 97 | netlifyConfig = { 98 | redirects 99 | }; 100 | 101 | const imagesFunctionName = 'cld_images'; 102 | 103 | fs.readdir.mockResolvedValue([imagesFunctionName]); 104 | }); 105 | 106 | function validate(imagesPath, redirects, url) { 107 | if (typeof (imagesPath) === 'string') { 108 | imagesPath = [imagesPath] 109 | }; 110 | 111 | let count = 0 112 | imagesPath?.reverse().forEach(element => { 113 | let i = element.split(path.win32.sep).join(path.posix.sep).replace('/', ''); 114 | 115 | // The analytics string that's added to the URLs is dynamic based on package version. 116 | // The resulting value is also not static, so we can't simply add it to the end of the 117 | // URL, so strip the analytics from the URLs as it's not important for this particular 118 | // test, being covered elsewhere. 119 | 120 | redirects.forEach(redirect => { 121 | if ( redirect.to.includes('https://res.cloudinary.com') ) { 122 | redirect.to = redirect.to.split('?')[0]; 123 | } 124 | }) 125 | 126 | expect(redirects[count]).toEqual({ 127 | from: `/${i}/*`, 128 | to: `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/${deliveryType}/f_auto,q_auto/${process.env.URL}/cld-assets/${i}/:splat`, 129 | status: 302, 130 | force: true 131 | }); 132 | 133 | expect(redirects[count + 1]).toEqual({ 134 | from: `/cld-assets/${i}/*`, 135 | to: `/${i}/:splat`, 136 | status: 200, 137 | force: true 138 | }); 139 | count += 2; 140 | }); 141 | expect(redirects[redirects.length - 1]).toEqual(defaultRedirect); 142 | } 143 | 144 | describe.each(['posix', 'win32'])('Operating system: %o', (os) => { 145 | let separator = path[os].sep; 146 | let imagesPathStrings = [ 147 | '/images', 148 | '/nest', 149 | '/images/nesttest' 150 | ]; 151 | imagesPathStrings = imagesPathStrings.map(i => i.replace(/\//g, separator)); 152 | 153 | let imagesPathLists = [ 154 | [['/images', '/assets']], 155 | [['/images/nest1', '/assets/nest2']], 156 | [['/example/hey', '/mixed', '/test']] 157 | ]; 158 | imagesPathLists = imagesPathLists.map(collection => 159 | collection.map(imagesPath => 160 | imagesPath.map(i => i.replace(/\//g, separator)) 161 | ) 162 | ); 163 | 164 | describe.each(contexts)(`should create redirects with default ${deliveryType}-based configuration in $name context`, async (context) => { 165 | process.env.URL = context.url; 166 | 167 | test.each(imagesPathLists)('%o', async (imagesPath) => { 168 | 169 | await onBuild({ 170 | netlifyConfig, 171 | constants: { 172 | PUBLISH_DIR: __dirname 173 | }, 174 | inputs: { 175 | deliveryType, 176 | imagesPath 177 | } 178 | }); 179 | validate(imagesPath, redirects); 180 | }); 181 | 182 | test.each(imagesPathStrings)('%o', async (imagesPath) => { 183 | 184 | await onBuild({ 185 | netlifyConfig, 186 | constants: { 187 | PUBLISH_DIR: `.next/out${imagesPath}` 188 | }, 189 | inputs: { 190 | deliveryType, 191 | imagesPath 192 | } 193 | }); 194 | validate(imagesPath, redirects); 195 | }); 196 | 197 | test('imagesPath undefined', async () => { 198 | 199 | await onBuild({ 200 | netlifyConfig, 201 | constants: { 202 | PUBLISH_DIR: '.next/out/' 203 | }, 204 | inputs: { 205 | deliveryType 206 | } 207 | }); 208 | let imagesPath = '/images'; 209 | validate(imagesPath, redirects); 210 | }); 211 | }); 212 | }) 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tests/on-post-build.test.js: -------------------------------------------------------------------------------- 1 | import { vi, expect, describe, test, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; 2 | import { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | import { JSDOM } from 'jsdom'; 5 | import { onPostBuild } from '../src/'; 6 | 7 | const mocksPath = path.join(__dirname, 'mocks/html'); 8 | const tempPath = path.join(mocksPath, 'temp'); 9 | // Avoid illegal characters in file paths, all operating systems 10 | const replaceRegEx = /[\W_]+/g 11 | const replaceValue = '_' 12 | 13 | async function mkdir(directoryPath) { 14 | let dir; 15 | console.log('directoryPath', directoryPath) 16 | try { 17 | dir = await fs.stat(directoryPath); 18 | } catch(e) {} 19 | if ( dir && dir.isDirectory() ) return; 20 | console.log('mkdir') 21 | await fs.mkdir(directoryPath); 22 | } 23 | 24 | describe('onPostBuild', () => { 25 | const ENV_ORIGINAL = process.env; 26 | 27 | beforeAll(async () => { 28 | await mkdir(tempPath); 29 | }); 30 | 31 | beforeEach(async () => { 32 | vi.resetModules(); 33 | 34 | process.env = { ...ENV_ORIGINAL }; 35 | 36 | process.env.SITE_NAME = 'cool-site'; 37 | process.env.CLOUDINARY_CLOUD_NAME = 'testcloud'; 38 | process.env.CLOUDINARY_API_KEY = '123456789012345'; 39 | process.env.CLOUDINARY_API_SECRET = 'abcd1234'; 40 | 41 | const mockFiles = (await fs.readdir(mocksPath)).filter(filePath => filePath.includes('.html')); 42 | const tempTestPath = path.join(tempPath, expect.getState().currentTestName.replace(replaceRegEx, replaceValue)); 43 | await mkdir(tempTestPath); 44 | await Promise.all(mockFiles.map(async file => { 45 | await fs.copyFile(path.join(mocksPath, file), path.join(tempTestPath, file)); 46 | })) 47 | }); 48 | 49 | afterEach(async () => { 50 | process.env = ENV_ORIGINAL; 51 | 52 | await fs.rm(path.join(tempPath, expect.getState().currentTestName.replace(replaceRegEx, replaceValue)), { recursive: true, force: true }); 53 | }); 54 | 55 | afterAll(async () => { 56 | await fs.rm(tempPath, { recursive: true, force: true }) 57 | }) 58 | 59 | describe('Build', () => { 60 | 61 | test('should replace with Cloudinary URLs', async () => { 62 | process.env.CONTEXT = 'production'; 63 | process.env.NETLIFY_HOST = 'https://netlify-plugin-cloudinary.netlify.app'; 64 | 65 | // Tests to ensure that delivery type of fetch works without API Key and Secret as it should 66 | 67 | delete process.env.CLOUDINARY_API_KEY; 68 | delete process.env.CLOUDINARY_API_SECRET; 69 | 70 | const deliveryType = 'fetch'; 71 | 72 | const tempTestPath = path.join(tempPath, expect.getState().currentTestName.replace(replaceRegEx, replaceValue)); 73 | 74 | await onPostBuild({ 75 | constants: { 76 | PUBLISH_DIR: tempTestPath 77 | }, 78 | inputs: { 79 | deliveryType, 80 | folder: process.env.SITE_NAME, 81 | }, 82 | }); 83 | 84 | const files = await fs.readdir(tempTestPath); 85 | 86 | await Promise.all(files.map(async file => { 87 | const data = await fs.readFile(path.join(tempTestPath, file), 'utf-8'); 88 | const dom = new JSDOM(data); 89 | const images = Array.from(dom.window.document.querySelectorAll('img')); 90 | images.forEach(image => { 91 | expect(image.getAttribute('src')).toMatch('https://res.cloudinary.com'); 92 | }) 93 | })); 94 | }); 95 | 96 | }); 97 | 98 | describe('Inputs', () => { 99 | 100 | test('should add transformations', async () => { 101 | process.env.CONTEXT = 'production'; 102 | process.env.NETLIFY_HOST = 'https://netlify-plugin-cloudinary.netlify.app'; 103 | 104 | // Tests to ensure that delivery type of fetch works without API Key and Secret as it should 105 | 106 | delete process.env.CLOUDINARY_API_KEY; 107 | delete process.env.CLOUDINARY_API_SECRET; 108 | 109 | const deliveryType = 'fetch'; 110 | 111 | const tempTestPath = path.join(tempPath, expect.getState().currentTestName.replace(replaceRegEx, replaceValue)); 112 | 113 | const maxSize = { 114 | width: 800, 115 | height: 600, 116 | dpr: '3.0', 117 | crop: 'limit' 118 | }; 119 | 120 | await onPostBuild({ 121 | constants: { 122 | PUBLISH_DIR: tempTestPath 123 | }, 124 | inputs: { 125 | deliveryType, 126 | folder: process.env.SITE_NAME, 127 | maxSize 128 | }, 129 | }); 130 | 131 | const files = await fs.readdir(tempTestPath); 132 | 133 | await Promise.all(files.map(async file => { 134 | const data = await fs.readFile(path.join(tempTestPath, file), 'utf-8'); 135 | const dom = new JSDOM(data); 136 | const images = Array.from(dom.window.document.querySelectorAll('img')); 137 | images.forEach(image => { 138 | expect(image.getAttribute('src')).toMatch('https://res.cloudinary.com'); 139 | expect(image.getAttribute('src')).toMatch(`f_auto,q_auto/c_${maxSize.crop},dpr_${maxSize.dpr},h_${maxSize.height},w_${maxSize.width}`); 140 | }) 141 | })); 142 | }); 143 | 144 | }); 145 | 146 | describe('loadingStrategy', () => { 147 | test.each([ 148 | {loadingStrategy: undefined, expected: 'lazy'}, 149 | {loadingStrategy: 'lazy', expected: 'lazy'}, 150 | {loadingStrategy: 'eager', expected: 'eager'}, 151 | ])('should use $expected as img loading attribute when netlify.toml loadingStrategy is $loadingStrategy', async ({loadingStrategy, expected}) => { 152 | process.env.CONTEXT = 'production'; 153 | process.env.NETLIFY_HOST = 'https://netlify-plugin-cloudinary.netlify.app'; 154 | 155 | // Tests to ensure that delivery type of fetch works without API Key and Secret as it should 156 | 157 | delete process.env.CLOUDINARY_API_KEY; 158 | delete process.env.CLOUDINARY_API_SECRET; 159 | 160 | const deliveryType = 'fetch'; 161 | 162 | const tempTestPath = path.join(tempPath, expect.getState().currentTestName.replace(replaceRegEx, replaceValue)); 163 | 164 | const inputs = { 165 | deliveryType, 166 | folder: process.env.SITE_NAME 167 | } 168 | 169 | if (loadingStrategy != undefined) { 170 | inputs['loadingStrategy'] = loadingStrategy 171 | } 172 | 173 | await onPostBuild({ 174 | constants: { 175 | PUBLISH_DIR: tempTestPath 176 | }, 177 | inputs: inputs, 178 | }); 179 | 180 | const files = await fs.readdir(tempTestPath); 181 | 182 | await Promise.all(files.map(async file => { 183 | const data = await fs.readFile(path.join(tempTestPath, file), 'utf-8'); 184 | const dom = new JSDOM(data); 185 | const images = Array.from(dom.window.document.querySelectorAll('img')); 186 | images.forEach(image => { 187 | expect(image.getAttribute('loading')).toMatch(expected); 188 | }) 189 | })); 190 | }) 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /netlify-plugin-cloudinary/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "module": "commonjs" /* Specify what module code is generated. */, 5 | "rootDir": "./src", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 8 | "strict": true /* Enable all strict type-checking options. */, 9 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 10 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 11 | "outDir": "./dist" 12 | }, 13 | "include": ["src"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "pnpm --filter docs build" 3 | publish = "docs/.next" 4 | 5 | [[plugins]] 6 | package = "@netlify/plugin-nextjs" 7 | 8 | [[plugins]] 9 | # package.json includes a postinstall `npm run compile` which allows 10 | # for this plugin to be locally accessible at build time on Netlify's 11 | # servers. If running locally, the expectation would be that the TS 12 | # project will have been built either by install or during dev 13 | package = "./netlify-plugin-cloudinary" 14 | 15 | [plugins.inputs] 16 | cloudName = "netlify-cloudinary" 17 | deliveryType = "upload" 18 | imagesPath = [ "images", "screenshots" ] 19 | 20 | [plugins.inputs.maxSize] 21 | width = 1200 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-plugin-cloudinary", 3 | "repository": "git@github.com:colbyfayock/netlify-plugin-cloudinary.git", 4 | "author": { 5 | "name": "Colby Fayock", 6 | "email": "hello@colbyfayock.com", 7 | "url": "https://twitter.com/colbyfayock" 8 | }, 9 | "license": "MIT", 10 | "private": true, 11 | "scripts": { 12 | "build": "pnpm -r build", 13 | "build-site": "netlify build --filter docs", 14 | "deploy-site": "netlify deploy --build --filter docs", 15 | "format": "pnpm -r format", 16 | "lint": "pnpm -r lint", 17 | "postinstall": "pnpm --filter netlify-plugin-cloudinary build", 18 | "test": "pnpm -r test" 19 | }, 20 | "devDependencies": { 21 | "@semantic-release/npm": "^10.0.3", 22 | "gh-release": "7.0.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "demo" 3 | - "docs" 4 | - "netlify-plugin-cloudinary" --------------------------------------------------------------------------------