├── .DS_Store ├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── README.md ├── admin-search ├── .gitignore ├── .prettierrc.json ├── .swcrc ├── LICENSE ├── README.md ├── dev │ ├── .env.example │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── payload-types.ts │ ├── pnpm-lock.yaml │ ├── seed-db.ts │ ├── src │ │ ├── app │ │ │ ├── (payload) │ │ │ │ ├── admin │ │ │ │ │ ├── [[...segments]] │ │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ │ ├── [...slug] │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── graphql-playground │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── graphql │ │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ │ └── my-route │ │ │ │ └── route.ts │ │ ├── collections │ │ │ ├── authors.ts │ │ │ ├── media.ts │ │ │ ├── pages.ts │ │ │ └── posts.ts │ │ ├── helpers │ │ │ ├── credentials.ts │ │ │ └── testEmailAdapter.ts │ │ ├── payload.config.ts │ │ └── seed.ts │ └── tsconfig.json ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src │ ├── components │ │ ├── SearchBar │ │ │ ├── SearchBar.css │ │ │ └── SearchBar.tsx │ │ ├── SearchButton │ │ │ └── SearchButton.tsx │ │ ├── SearchModal │ │ │ ├── SearchModal.css │ │ │ ├── SearchModal.tsx │ │ │ └── SearchModalSkeleton.tsx │ │ └── SearchWrapper │ │ │ └── SearchWrapper.tsx │ ├── exports │ │ └── client.ts │ ├── index.ts │ ├── plugin.ts │ └── types │ │ ├── AdminSearchPluginConfig.ts │ │ └── SearchResult.ts └── tsconfig.json ├── cloudinary ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── dev │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── plugin.spec.ts │ ├── src │ │ ├── app │ │ │ └── (payload) │ │ │ │ ├── admin │ │ │ │ ├── [[...segments]] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ ├── [...slug] │ │ │ │ │ └── route.ts │ │ │ │ ├── graphql-playground │ │ │ │ │ └── route.ts │ │ │ │ └── graphql │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ ├── collections │ │ │ ├── images.ts │ │ │ └── videos.ts │ │ ├── payload-types.ts │ │ └── payload.config.ts │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src │ ├── hooks │ │ ├── afterDelete.ts │ │ └── beforeChange.ts │ ├── index.ts │ ├── plugin.ts │ ├── types │ │ └── CloudinaryPluginConfig.ts │ └── utils │ │ ├── extendUploadCollectionConfig.ts │ │ └── streamUpload.ts └── tsconfig.json ├── geocoding ├── .editorconfig ├── .gitignore ├── .prettierrc.json ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dev │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── plugin.spec.ts │ ├── src │ │ ├── app │ │ │ └── (payload) │ │ │ │ ├── admin │ │ │ │ ├── [[...segments]] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ ├── [...slug] │ │ │ │ │ └── route.ts │ │ │ │ ├── graphql-playground │ │ │ │ │ └── route.ts │ │ │ │ └── graphql │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ ├── collection │ │ │ └── pages.ts │ │ ├── emailAdapter.ts │ │ ├── payload-types.ts │ │ └── payload.config.ts │ └── tsconfig.json ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src │ ├── components │ │ └── GeocodingFieldComponent.tsx │ ├── exports │ │ └── client.ts │ ├── fields │ │ └── geocodingField.ts │ ├── index.ts │ ├── plugin.ts │ └── types │ │ ├── GeoCodingFieldConfig.ts │ │ └── GeoCodingPluginConfig.ts └── tsconfig.json ├── package.json ├── pages ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .swcrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dev │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── plugin.test.ts │ ├── src │ │ ├── app │ │ │ └── (payload) │ │ │ │ ├── admin │ │ │ │ ├── [[...segments]] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ ├── [...slug] │ │ │ │ │ └── route.ts │ │ │ │ ├── graphql-playground │ │ │ │ │ └── route.ts │ │ │ │ └── graphql │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ ├── collections │ │ │ ├── authors.ts │ │ │ ├── blogpost-categories.ts │ │ │ ├── blogposts.ts │ │ │ ├── countries.ts │ │ │ ├── country-travel-tips.ts │ │ │ ├── pages.ts │ │ │ └── redirects.ts │ │ ├── payload-types.ts │ │ ├── payload.config.ts │ │ └── utils │ │ │ └── generatePageURL.ts │ ├── tsconfig.json │ └── vite.config.ts ├── dev_multi_tenant │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── plugin.test.ts │ ├── src │ │ ├── app │ │ │ └── (payload) │ │ │ │ ├── admin │ │ │ │ ├── [[...segments]] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ ├── [...slug] │ │ │ │ │ └── route.ts │ │ │ │ ├── graphql-playground │ │ │ │ │ └── route.ts │ │ │ │ └── graphql │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ ├── collections │ │ │ ├── authors.ts │ │ │ ├── blogpost-categories.ts │ │ │ ├── blogposts.ts │ │ │ ├── countries.ts │ │ │ ├── country-travel-tips.ts │ │ │ ├── pages.ts │ │ │ ├── redirects.ts │ │ │ └── tenants.ts │ │ ├── payload-types.ts │ │ ├── payload.config.ts │ │ └── utils │ │ │ └── generatePageURL.ts │ ├── tsconfig.json │ └── vite.config.ts ├── dev_unlocalized │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── plugin.test.ts │ ├── src │ │ ├── app │ │ │ └── (payload) │ │ │ │ ├── admin │ │ │ │ ├── [[...segments]] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── importMap.js │ │ │ │ ├── api │ │ │ │ ├── [...slug] │ │ │ │ │ └── route.ts │ │ │ │ ├── graphql-playground │ │ │ │ │ └── route.ts │ │ │ │ └── graphql │ │ │ │ │ └── route.ts │ │ │ │ ├── custom.scss │ │ │ │ └── layout.tsx │ │ ├── collections │ │ │ ├── authors.ts │ │ │ ├── blogpost-categories.ts │ │ │ ├── blogposts.ts │ │ │ ├── countries.ts │ │ │ ├── country-travel-tips.ts │ │ │ ├── pages.ts │ │ │ └── redirects.ts │ │ ├── payload-types.ts │ │ ├── payload.config.ts │ │ └── utils │ │ │ └── generatePageURL.ts │ ├── tsconfig.json │ └── vite.config.ts ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src │ ├── collections │ │ ├── PageCollectionConfig.ts │ │ └── RedirectsCollectionConfig.ts │ ├── components │ │ ├── client │ │ │ ├── BreadcrumbsField.tsx │ │ │ ├── IsRootPageStatus.tsx │ │ │ ├── PathField.tsx │ │ │ ├── PreviewButtonField.tsx │ │ │ ├── SlugField.tsx │ │ │ └── hooks │ │ │ │ ├── useBreadcrumbs.ts │ │ │ │ ├── useCollectionConfig.ts │ │ │ │ └── usePageCollectionConfigAtrributes.ts │ │ └── server │ │ │ ├── IsRootPageField.tsx │ │ │ ├── ParentField.tsx │ │ │ └── SlugFieldWrapper.tsx │ ├── exports │ │ ├── client.ts │ │ └── server.ts │ ├── fields │ │ ├── alternatePathsField.ts │ │ ├── breadcrumbsField.ts │ │ ├── isRootPageField.ts │ │ ├── parentField.ts │ │ ├── pathField.ts │ │ └── slugField.ts │ ├── hooks │ │ ├── beforeDuplicate.ts │ │ ├── deleteUnselectedFieldsAfterRead.ts │ │ ├── preventParentDeletion.ts │ │ ├── selectDependentFieldsBeforeOperation.ts │ │ ├── setVirtualFields.ts │ │ ├── validateRedirect.ts │ │ └── validateSlug.ts │ ├── index.ts │ ├── plugin.ts │ ├── translations │ │ ├── de.ts │ │ ├── en.ts │ │ ├── index.ts │ │ └── translation-schema.json │ ├── types │ │ ├── Breadcrumb.ts │ │ ├── Locale.ts │ │ ├── PageCollectionConfig.ts │ │ ├── PageCollectionConfigAttributes.ts │ │ ├── PagesPluginConfig.ts │ │ ├── RedirectsCollectionConfig.ts │ │ ├── RedirectsCollectionConfigAttributes.ts │ │ └── SeoMetadata.ts │ └── utils │ │ ├── AdminPanelError.ts │ │ ├── childDocumentsOf.ts │ │ ├── deepMergeSimple.ts │ │ ├── fetchRestApi.ts │ │ ├── getBreadcrumbs.ts │ │ ├── getPageCollectionConfigAttributes.ts │ │ ├── localeFromRequest.ts │ │ ├── pageCollectionConfigHelpers.ts │ │ ├── pathFromBreadcrumbs.ts │ │ ├── setPageVirtualFields.ts │ │ ├── setRootPageVirtualFields.ts │ │ ├── translatedLabel.ts │ │ ├── useDidUpdateEffect.ts │ │ ├── usePluginTranslations.ts │ │ ├── validateBreadcrumbs.ts │ │ └── validatePath.ts └── tsconfig.json ├── pnpm-lock.yaml └── seo ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── dev ├── .gitignore ├── next-env.d.ts ├── next.config.mjs ├── nodemon.json ├── package.json ├── plugin.spec.ts ├── src │ ├── app │ │ └── (payload) │ │ │ ├── admin │ │ │ ├── [[...segments]] │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── importMap.js │ │ │ ├── api │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ ├── graphql-playground │ │ │ │ └── route.ts │ │ │ └── graphql │ │ │ │ └── route.ts │ │ │ ├── custom.scss │ │ │ └── layout.tsx │ ├── collections │ │ └── pages.ts │ ├── payload-types.ts │ └── payload.config.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── components │ ├── KeywordsFieldLabel.tsx │ └── KeywordsFieldRowLabel.tsx ├── exports │ └── client.ts ├── fields │ └── keywordsField.ts ├── index.ts ├── plugin.ts ├── types │ ├── DocumentContentTransformer.ts │ ├── PageContext.ts │ ├── SeoPluginConfig.ts │ └── WebsiteContext.ts └── utils │ ├── generateMetaDescription.ts │ ├── generateOpenAIChatCompletion.ts │ └── lexicalToPlainText.ts └── tsconfig.json /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve the packages 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Package(s) version:** 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .next -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | importMap.js 2 | payload-types.ts 3 | .vscode 4 | .d.ts 5 | .spec.ts -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /admin-search/.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 | .yarn/install-state.gz 8 | 9 | /.idea/* 10 | !/.idea/runConfigurations 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | .next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | /dist 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | 41 | .env 42 | 43 | /dev/media 44 | 45 | # Playwright 46 | /test-results/ 47 | /playwright-report/ 48 | /blob-report/ 49 | /playwright/.cache/ 50 | -------------------------------------------------------------------------------- /admin-search/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /admin-search/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "target": "esnext", 6 | "parser": { 7 | "syntax": "typescript", 8 | "tsx": true, 9 | "dts": true 10 | }, 11 | "transform": { 12 | "react": { 13 | "runtime": "automatic", 14 | "pragmaFrag": "React.Fragment", 15 | "throwIfNamespace": true, 16 | "development": false, 17 | "useBuiltins": true 18 | } 19 | } 20 | }, 21 | "module": { 22 | "type": "es6" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /admin-search/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 JHB Software 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 | -------------------------------------------------------------------------------- /admin-search/dev/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template 2 | PAYLOAD_SECRET=YOUR_SECRET_HERE 3 | -------------------------------------------------------------------------------- /admin-search/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /admin-search/dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | eslint: { 6 | ignoreDuringBuilds: true, 7 | }, 8 | 9 | // Allow for ESM .js import statements 10 | webpack: (webpackConfig) => { 11 | webpackConfig.resolve.extensionAlias = { 12 | '.cjs': ['.cts', '.cjs'], 13 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 14 | '.mjs': ['.mts', '.mjs'], 15 | } 16 | 17 | return webpackConfig 18 | }, 19 | 20 | redirects: () => [ 21 | { 22 | source: '/', 23 | destination: '/admin', 24 | permanent: true, 25 | }, 26 | ], 27 | } 28 | 29 | export default withPayload(nextConfig) 30 | -------------------------------------------------------------------------------- /admin-search/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-search-dev", 3 | "description": "Dev app for the admin-search plugin.", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "dev:turbo": "pnpm dev --turbo", 10 | "dev:safe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "cross-env PAYLOAD_CONFIG_PATH=./src/payload.config.ts payload", 15 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=./src/payload.config.ts payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "cross-env PAYLOAD_CONFIG_PATH=./src/payload.config.ts payload generate:importmap", 18 | "seed": "cross-env PAYLOAD_CONFIG_PATH=./src/payload.config.ts tsx seed-db.ts" 19 | }, 20 | "dependencies": { 21 | "@jhb.software/payload-admin-search": "workspace:*", 22 | "@payloadcms/db-mongodb": "3.x", 23 | "@payloadcms/next": "3.x", 24 | "@payloadcms/plugin-search": "3.x", 25 | "@payloadcms/richtext-lexical": "3.x", 26 | "@payloadcms/translations": "3.x", 27 | "@payloadcms/ui": "3.x", 28 | "next": "15.x", 29 | "payload": "3.x", 30 | "react": "19.x", 31 | "react-dom": "19.x" 32 | }, 33 | "devDependencies": { 34 | "cross-env": "^7.0.3", 35 | "dotenv": "^16.4.5", 36 | "tsx": "^4.7.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /admin-search/dev/seed-db.ts: -------------------------------------------------------------------------------- 1 | import { getPayload } from 'payload' 2 | 3 | import config from './src/payload.config.js' 4 | 5 | async function seedDatabase() { 6 | try { 7 | console.log('🌱 Starting database seeding...') 8 | 9 | const payload = await getPayload({ config }) 10 | 11 | // Import and run the seed function 12 | const { seed } = await import('./src/seed.js') 13 | await seed(payload) 14 | 15 | console.log('✅ Database seeding completed successfully!') 16 | process.exit(0) 17 | } catch (error) { 18 | console.error('❌ Error seeding database:', error) 19 | process.exit(1) 20 | } 21 | } 22 | 23 | // Handle the unhandled promise 24 | seedDatabase().catch((error) => { 25 | console.error('❌ Unhandled error in seedDatabase:', error) 26 | process.exit(1) 27 | }) 28 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { 6 | REST_DELETE, 7 | REST_GET, 8 | REST_OPTIONS, 9 | REST_PATCH, 10 | REST_POST, 11 | REST_PUT, 12 | } from '@payloadcms/next/routes' 13 | 14 | export const GET = REST_GET(config) 15 | export const POST = REST_POST(config) 16 | export const DELETE = REST_DELETE(config) 17 | export const PATCH = REST_PATCH(config) 18 | export const PUT = REST_PUT(config) 19 | export const OPTIONS = REST_OPTIONS(config) 20 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 6 | 7 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 8 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | 8 | export const OPTIONS = REST_OPTIONS(config) 9 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/admin-search/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /admin-search/dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ServerFunctionClient } from 'payload' 2 | 3 | import '@payloadcms/next/css' 4 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 5 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 6 | import config from '@payload-config' 7 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 8 | import React from 'react' 9 | 10 | import { importMap } from './admin/importMap.js' 11 | import './custom.scss' 12 | 13 | type Args = { 14 | children: React.ReactNode 15 | } 16 | 17 | const serverFunction: ServerFunctionClient = async function (args) { 18 | 'use server' 19 | return handleServerFunctions({ 20 | ...args, 21 | config, 22 | importMap, 23 | }) 24 | } 25 | 26 | const Layout = ({ children }: Args) => ( 27 | 28 | {children} 29 | 30 | ) 31 | 32 | export default Layout 33 | -------------------------------------------------------------------------------- /admin-search/dev/src/app/my-route/route.ts: -------------------------------------------------------------------------------- 1 | import configPromise from '@payload-config' 2 | import { getPayload } from 'payload' 3 | 4 | export const GET = async (request: Request) => { 5 | const payload = await getPayload({ 6 | config: configPromise, 7 | }) 8 | 9 | return Response.json({ 10 | message: 'This is an example of a custom route.', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /admin-search/dev/src/collections/authors.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const authorsSchema: CollectionConfig = { 4 | slug: 'authors', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | fields: [ 9 | { 10 | name: 'name', 11 | type: 'text', 12 | required: true, 13 | }, 14 | { 15 | name: 'bio', 16 | type: 'textarea', 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /admin-search/dev/src/collections/media.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | 5 | const filename = fileURLToPath(import.meta.url) 6 | const dirname = path.dirname(filename) 7 | 8 | export const mediaSchema: CollectionConfig = { 9 | slug: 'media', 10 | fields: [], 11 | upload: { 12 | staticDir: path.resolve(dirname, '../media'), 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /admin-search/dev/src/collections/pages.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const pagesSchema: CollectionConfig = { 4 | slug: 'pages', 5 | fields: [ 6 | { 7 | name: 'title', 8 | type: 'text', 9 | required: true, 10 | }, 11 | { 12 | name: 'slug', 13 | type: 'text', 14 | required: true, 15 | unique: true, 16 | index: true, 17 | }, 18 | { 19 | name: 'content', 20 | type: 'richText', 21 | required: false, 22 | }, 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /admin-search/dev/src/collections/posts.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, CollectionSlug } from 'payload' 2 | 3 | export const postsSchema: CollectionConfig = { 4 | slug: 'posts', 5 | fields: [ 6 | { 7 | name: 'title', 8 | type: 'text', 9 | required: true, 10 | }, 11 | { 12 | name: 'slug', 13 | type: 'text', 14 | required: true, 15 | unique: true, 16 | index: true, 17 | }, 18 | { 19 | name: 'author', 20 | type: 'relationship', 21 | relationTo: 'authors' as CollectionSlug, 22 | required: true, 23 | }, 24 | { 25 | name: 'content', 26 | type: 'richText', 27 | required: false, 28 | }, 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /admin-search/dev/src/helpers/credentials.ts: -------------------------------------------------------------------------------- 1 | export const devUser = { 2 | email: 'dev@payloadcms.com', 3 | password: 'test', 4 | } 5 | -------------------------------------------------------------------------------- /admin-search/dev/src/helpers/testEmailAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { EmailAdapter, SendEmailOptions } from 'payload' 2 | 3 | /** 4 | * Logs all emails to stdout 5 | */ 6 | export const testEmailAdapter: EmailAdapter = ({ payload }) => ({ 7 | name: 'test-email-adapter', 8 | defaultFromAddress: 'dev@payloadcms.com', 9 | defaultFromName: 'Payload Test', 10 | sendEmail: async (message) => { 11 | const stringifiedTo = getStringifiedToAddress(message) 12 | const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'` 13 | payload.logger.info({ content: message, msg: res }) 14 | return Promise.resolve() 15 | }, 16 | }) 17 | 18 | function getStringifiedToAddress(message: SendEmailOptions): string | undefined { 19 | let stringifiedTo: string | undefined 20 | 21 | if (typeof message.to === 'string') { 22 | stringifiedTo = message.to 23 | } else if (Array.isArray(message.to)) { 24 | stringifiedTo = message.to 25 | .map((to: { address: string } | string) => { 26 | if (typeof to === 'string') { 27 | return to 28 | } else if (to.address) { 29 | return to.address 30 | } 31 | return '' 32 | }) 33 | .join(', ') 34 | } else if (message.to?.address) { 35 | stringifiedTo = message.to.address 36 | } 37 | return stringifiedTo 38 | } 39 | -------------------------------------------------------------------------------- /admin-search/dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { adminSearchPlugin } from '@jhb.software/payload-admin-search' 2 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 3 | import { searchPlugin } from '@payloadcms/plugin-search' 4 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 5 | import path from 'path' 6 | import { buildConfig } from 'payload' 7 | import { fileURLToPath } from 'url' 8 | 9 | import { authorsSchema } from './collections/authors' 10 | import { mediaSchema } from './collections/media' 11 | import { pagesSchema } from './collections/pages' 12 | import { postsSchema } from './collections/posts' 13 | import { seed } from './seed' 14 | 15 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 16 | 17 | export default buildConfig({ 18 | admin: { 19 | autoLogin: { 20 | email: 'dev@payloadcms.com', 21 | password: 'test', 22 | }, 23 | user: 'users', 24 | }, 25 | collections: [ 26 | pagesSchema, 27 | postsSchema, 28 | authorsSchema, 29 | mediaSchema, 30 | { 31 | slug: 'users', 32 | auth: true, 33 | fields: [], 34 | }, 35 | ], 36 | db: mongooseAdapter({ 37 | url: process.env.DATABASE_URI!, 38 | }), 39 | 40 | editor: lexicalEditor(), 41 | 42 | secret: process.env.PAYLOAD_SECRET || 'secret', 43 | 44 | typescript: { 45 | outputFile: path.resolve(__dirname, '../payload-types.ts'), 46 | }, 47 | 48 | async onInit(payload) { 49 | const existingUsers = await payload.find({ 50 | collection: 'users', 51 | limit: 1, 52 | }) 53 | 54 | if (existingUsers.docs.length === 0) { 55 | await payload.create({ 56 | collection: 'users', 57 | data: { 58 | email: 'dev@payloadcms.com', 59 | password: 'test', 60 | }, 61 | }) 62 | } 63 | 64 | await seed(payload) 65 | }, 66 | 67 | plugins: [ 68 | adminSearchPlugin({ headerSearchComponentStyle: 'bar' }), 69 | searchPlugin({ 70 | beforeSync: ({ originalDoc, searchDoc }) => { 71 | return { 72 | ...searchDoc, 73 | title: searchDoc.doc.relationTo === 'authors' ? originalDoc.name : originalDoc.title, 74 | } 75 | }, 76 | collections: ['pages', 'posts', 'authors'], 77 | }), 78 | ], 79 | }) 80 | -------------------------------------------------------------------------------- /admin-search/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vite.config.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /admin-search/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import payloadEsLintConfig from '@payloadcms/eslint-config' 4 | 5 | export const defaultESLintIgnores = [ 6 | '**/.temp', 7 | '**/.*', // ignore all dotfiles 8 | '**/.git', 9 | '**/.hg', 10 | '**/.pnp.*', 11 | '**/.svn', 12 | '**/playwright.config.ts', 13 | '**/vitest.config.js', 14 | '**/tsconfig.tsbuildinfo', 15 | '**/README.md', 16 | '**/eslint.config.js', 17 | '**/payload-types.ts', 18 | '**/dist/', 19 | '**/.yarn/', 20 | '**/build/', 21 | '**/node_modules/', 22 | '**/temp/', 23 | ] 24 | 25 | export default [ 26 | ...payloadEsLintConfig, 27 | { 28 | rules: { 29 | 'no-restricted-exports': 'off', 30 | }, 31 | }, 32 | { 33 | languageOptions: { 34 | parserOptions: { 35 | sourceType: 'module', 36 | ecmaVersion: 'latest', 37 | projectService: { 38 | maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40, 39 | allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'], 40 | }, 41 | // projectService: true, 42 | tsconfigRootDir: import.meta.dirname, 43 | }, 44 | }, 45 | }, 46 | ] 47 | -------------------------------------------------------------------------------- /admin-search/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '.' 3 | - 'dev' -------------------------------------------------------------------------------- /admin-search/src/components/SearchBar/SearchBar.css: -------------------------------------------------------------------------------- 1 | .search-bar { 2 | padding: 0; 3 | max-width: 270px; 4 | } 5 | 6 | .search-bar .btn__label { 7 | width: 100%; 8 | } 9 | 10 | .search-bar .shortcut-key, 11 | .search-bar .search-filter__input { 12 | display: inline; 13 | } 14 | 15 | .search-bar__wrap { 16 | display: flex; 17 | width: 100%; 18 | align-items: center; 19 | background-color: var(--theme-elevation-50); 20 | border-radius: var(--style-radius-m); 21 | padding: 6px 12px; 22 | gap: 12px; 23 | } 24 | 25 | .search-bar__wrap svg { 26 | font-weight: 800; 27 | height: 30px; 28 | width: 30px; 29 | } 30 | 31 | .search-bar.position-actions { 32 | margin-right: 100px; 33 | } 34 | 35 | @media (max-width: 780px) { 36 | .search-bar.position-actions { 37 | margin-right: 0px; 38 | } 39 | 40 | .search-bar.position-actions .shortcut-key, 41 | .search-bar.position-actions .search-filter__input { 42 | display: none; 43 | } 44 | } -------------------------------------------------------------------------------- /admin-search/src/components/SearchBar/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import type React from 'react' 3 | 4 | import { Button, Pill, SearchIcon, useHotkey } from '@payloadcms/ui' 5 | import { useEffect, useState } from 'react' 6 | 7 | import { SearchModal } from '../SearchModal/SearchModal.js' 8 | import './SearchBar.css' 9 | 10 | const baseClass = 'search-bar' 11 | 12 | export function SearchBar(): React.ReactElement { 13 | const [isModalOpen, setIsModalOpen] = useState(false) 14 | const [shortcutKey, setShortcutKey] = useState('Ctrl') 15 | 16 | useEffect(() => { 17 | const isMac = typeof window !== 'undefined' && /Mac/i.test(navigator.platform) 18 | setShortcutKey(isMac ? '⌘' : 'Ctrl') 19 | }, []) 20 | 21 | useHotkey( 22 | { 23 | cmdCtrlKey: true, 24 | editDepth: 1, 25 | keyCodes: ['k'], 26 | }, 27 | (e) => { 28 | e.preventDefault() 29 | setIsModalOpen(true) 30 | }, 31 | ) 32 | 33 | return ( 34 | <> 35 | setIsModalOpen(true)} 39 | > 40 | 41 | 42 | 48 | {shortcutKey} + K 49 | 50 | 51 | 52 | {isModalOpen && setIsModalOpen(false)} />} 53 | > 54 | ) 55 | } -------------------------------------------------------------------------------- /admin-search/src/components/SearchButton/SearchButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import type React from 'react' 3 | 4 | import { Button, SearchIcon, useHotkey } from '@payloadcms/ui' 5 | import { useState } from 'react' 6 | 7 | import { SearchModal } from '../SearchModal/SearchModal.js' 8 | 9 | export function SearchButton(): React.ReactElement { 10 | const [isModalOpen, setIsModalOpen] = useState(false) 11 | 12 | useHotkey( 13 | { 14 | cmdCtrlKey: true, 15 | editDepth: 1, 16 | keyCodes: ['k'], 17 | }, 18 | (e) => { 19 | e.preventDefault() 20 | setIsModalOpen(true) 21 | }, 22 | ) 23 | 24 | return ( 25 | <> 26 | setIsModalOpen(true)} 29 | size="small" 30 | tooltip="Search (⌘K)" 31 | > 32 | 33 | 34 | 35 | {isModalOpen && setIsModalOpen(false)} />} 36 | > 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /admin-search/src/components/SearchModal/SearchModalSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface SearchModalSkeletonProps { 4 | count?: number 5 | } 6 | 7 | export const SearchModalSkeleton: React.FC = ({ count = 5 }) => { 8 | return ( 9 | 10 | {Array.from({ length: count }).map((_, index) => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ))} 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /admin-search/src/components/SearchWrapper/SearchWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import type React from 'react' 3 | 4 | import { SearchBar } from '../SearchBar/SearchBar.js' 5 | import { SearchButton } from '../SearchButton/SearchButton.js' 6 | 7 | interface SearchWrapperProps { 8 | style?: 'bar' | 'button' 9 | } 10 | 11 | export function SearchWrapper({ style = 'button' }: SearchWrapperProps): React.ReactElement { 12 | if (style === 'bar') { 13 | return 14 | } 15 | return 16 | } -------------------------------------------------------------------------------- /admin-search/src/exports/client.ts: -------------------------------------------------------------------------------- 1 | export { SearchBar } from '../components/SearchBar/SearchBar.js' 2 | export { SearchButton } from '../components/SearchButton/SearchButton.js' 3 | export { SearchWrapper } from '../components/SearchWrapper/SearchWrapper.js' 4 | -------------------------------------------------------------------------------- /admin-search/src/index.ts: -------------------------------------------------------------------------------- 1 | export { adminSearchPlugin } from './plugin.js' 2 | export type { AdminSearchPluginConfig } from './types/AdminSearchPluginConfig.js' 3 | export type { SearchResult } from './types/SearchResult.js' 4 | -------------------------------------------------------------------------------- /admin-search/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | 3 | import type { AdminSearchPluginConfig } from './types/AdminSearchPluginConfig.js' 4 | 5 | export const adminSearchPlugin = 6 | (pluginOptions: AdminSearchPluginConfig) => 7 | (incomingConfig: Config): Config => { 8 | if (pluginOptions.enabled === false) { 9 | return incomingConfig 10 | } 11 | 12 | const { headerSearchComponentStyle = 'button' } = pluginOptions 13 | 14 | return { 15 | ...incomingConfig, 16 | admin: { 17 | ...incomingConfig.admin, 18 | components: { 19 | ...incomingConfig.admin?.components, 20 | actions: [ 21 | ...(incomingConfig.admin?.components?.actions || []), 22 | { 23 | clientProps: { 24 | style: headerSearchComponentStyle, 25 | }, 26 | path: '@jhb.software/payload-admin-search/client#SearchWrapper', 27 | }, 28 | ], 29 | }, 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /admin-search/src/types/AdminSearchPluginConfig.ts: -------------------------------------------------------------------------------- 1 | export type AdminSearchPluginConfig = { 2 | enabled?: boolean 3 | headerSearchComponentStyle?: 'bar' | 'button' 4 | } 5 | -------------------------------------------------------------------------------- /admin-search/src/types/SearchResult.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | doc: { 3 | relationTo: string 4 | value: string 5 | } 6 | id: string 7 | title: string 8 | } 9 | -------------------------------------------------------------------------------- /admin-search/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "rootDir": "./", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitAny": true, 12 | "esModuleInterop": true, 13 | "module": "NodeNext", 14 | "moduleResolution": "nodenext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "emitDeclarationOnly": true, 20 | "target": "ES2022", 21 | "composite": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./dev/next-env.d.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /cloudinary/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /cloudinary/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/cloudinary/.npmignore -------------------------------------------------------------------------------- /cloudinary/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /cloudinary/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JHB Software 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 | -------------------------------------------------------------------------------- /cloudinary/dev/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /cloudinary/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /cloudinary/dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /cloudinary/dev/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /cloudinary/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-test-app", 3 | "description": "A test app for the plugin", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap" 18 | }, 19 | "dependencies": { 20 | "@jhb.software/payload-cloudinary-plugin": "workspace:*", 21 | "@payloadcms/db-mongodb": "^3.59.1", 22 | "@payloadcms/next": "^3.59.1", 23 | "next": "15.5.5", 24 | "payload": "^3.59.1", 25 | "react": "19.2.0", 26 | "react-dom": "19.2.0" 27 | }, 28 | "devDependencies": { 29 | "copyfiles": "^2.4.1", 30 | "cross-env": "^10.1.0", 31 | "dotenv": "^17.2.3", 32 | "typescript": "5.9.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cloudinary/dev/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | import mongoose from 'mongoose' 3 | import payload from 'payload' 4 | import { start } from './src/server' 5 | 6 | describe('Plugin tests', () => { 7 | let server: Server 8 | 9 | beforeAll(async () => { 10 | await start({ local: true }) 11 | }) 12 | 13 | afterAll(async () => { 14 | await mongoose.connection.dropDatabase() 15 | await mongoose.connection.close() 16 | server.close() 17 | }) 18 | 19 | // Add tests to ensure that the plugin works as expected 20 | 21 | // Example test to check for seeded data 22 | it('seeds data accordingly', async () => { 23 | const newCollectionQuery = await payload.find({ 24 | collection: 'new-collection', 25 | sort: 'createdAt', 26 | }) 27 | 28 | expect(newCollectionQuery.totalDocs).toEqual(1) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const importMap = { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/cloudinary/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /cloudinary/dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /cloudinary/dev/src/collections/images.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | 3 | export const Images: CollectionConfig = { 4 | slug: 'images', 5 | labels: { 6 | singular: 'Image', 7 | plural: 'Images', 8 | }, 9 | upload: { 10 | mimeTypes: ['image/*'], 11 | }, 12 | fields: [ 13 | // The other fields are automatically added by the plugin. 14 | { 15 | name: 'alt', 16 | type: 'text', 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /cloudinary/dev/src/collections/videos.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | 3 | export const Videos: CollectionConfig = { 4 | slug: 'videos', 5 | labels: { 6 | singular: 'Video', 7 | plural: 'Videos', 8 | }, 9 | upload: { 10 | mimeTypes: ['video/*'], 11 | }, 12 | fields: [ 13 | // The other fields are automatically added by the plugin. 14 | { 15 | name: 'description', 16 | type: 'textarea', 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /cloudinary/dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { payloadCloudinaryPlugin } from '@jhb.software/payload-cloudinary-plugin' 2 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 3 | import path from 'path' 4 | import { buildConfig } from 'payload' 5 | import { fileURLToPath } from 'url' 6 | import { Images } from './collections/images' 7 | import { Videos } from './collections/videos' 8 | 9 | const filename = fileURLToPath(import.meta.url) 10 | const dirname = path.dirname(filename) 11 | 12 | export default buildConfig({ 13 | admin: { 14 | autoLogin: { 15 | email: 'dev@payloadcms.com', 16 | password: 'test', 17 | }, 18 | user: 'users', 19 | }, 20 | collections: [ 21 | Videos, 22 | Images, 23 | { 24 | slug: 'users', 25 | auth: true, 26 | fields: [], 27 | }, 28 | ], 29 | db: mongooseAdapter({ 30 | url: process.env.DATABASE_URI!, 31 | }), 32 | secret: process.env.PAYLOAD_SECRET!, 33 | typescript: { 34 | outputFile: path.resolve(dirname, 'payload-types.ts'), 35 | }, 36 | plugins: [ 37 | payloadCloudinaryPlugin({ 38 | credentials: { 39 | apiKey: process.env.CLOUDINARY_API_KEY!, 40 | apiSecret: process.env.CLOUDINARY_API_SECRET!, 41 | }, 42 | cloudinary: { 43 | cloudName: process.env.CLOUDINARY_CLOUD_NAME!, 44 | folder: 'payload-cloudinary-plugin', 45 | }, 46 | uploadCollections: ['images', 'videos'], 47 | uploadOptions: { 48 | useFilename: true, 49 | }, 50 | }), 51 | ], 52 | async onInit(payload) { 53 | const existingUsers = await payload.find({ 54 | collection: 'users', 55 | limit: 1, 56 | }) 57 | 58 | if (existingUsers.docs.length === 0) { 59 | await payload.create({ 60 | collection: 'users', 61 | data: { 62 | email: 'dev@payloadcms.com', 63 | password: 'test', 64 | }, 65 | }) 66 | } 67 | }, 68 | }) 69 | -------------------------------------------------------------------------------- /cloudinary/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /cloudinary/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jhb.software/payload-cloudinary-plugin", 3 | "version": "0.2.2", 4 | "description": "Payload CMS plugin that adds Cloudinary image upload and management capabilities to media collections.", 5 | "bugs": "https://github.com/jhb-software/payload-plugins/issues", 6 | "repository": "https://github.com/jhb-software/payload-plugins", 7 | "keywords": [ 8 | "payload", 9 | "plugin", 10 | "cloudinary" 11 | ], 12 | "author": "JHB Software", 13 | "license": "MIT", 14 | "type": "module", 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "scripts": { 18 | "build": "tsc", 19 | "dev": "tsc -w", 20 | "format": "prettier --write src", 21 | "test": "jest", 22 | "lint": "eslint .", 23 | "prepublish": "tsc" 24 | }, 25 | "dependencies": { 26 | "cloudinary": "^2.7.0" 27 | }, 28 | "peerDependencies": { 29 | "payload": "^3.59.1" 30 | }, 31 | "devDependencies": { 32 | "prettier": "^3.6.2", 33 | "typescript": "5.9.3" 34 | }, 35 | "files": [ 36 | "dist" 37 | ], 38 | "publishConfig": { 39 | "main": "./dist/index.js", 40 | "registry": "https://registry.npmjs.org/", 41 | "types": "./dist/index.d.ts", 42 | "access": "public", 43 | "exports": { 44 | ".": { 45 | "import": "./dist/index.js", 46 | "types": "./dist/index.d.ts", 47 | "default": "./dist/index.js" 48 | } 49 | } 50 | }, 51 | "exports": { 52 | ".": { 53 | "import": "./src/index.ts", 54 | "default": "./src/index.ts", 55 | "types": "./src/index.ts" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cloudinary/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './' # Plugin at the root 3 | - 'dev/' # Testing environment 4 | -------------------------------------------------------------------------------- /cloudinary/src/hooks/afterDelete.ts: -------------------------------------------------------------------------------- 1 | import { APIError, CollectionAfterDeleteHook } from 'payload' 2 | import { v2 as cloudinary } from 'cloudinary' 3 | 4 | /** Hooks which deletes the file from Cloudinary */ 5 | const afterDeleteHook: CollectionAfterDeleteHook = async ({ doc }) => { 6 | type ReturnType = { 7 | result?: 'ok' | 'not-found' | any 8 | } 9 | 10 | let resource_type: 'video' | 'image' | 'raw' | undefined = undefined 11 | if (doc.mimeType?.startsWith('video/')) { 12 | // for videos, the resource_type must explicitly be set to 'video', otherwise Cloudinary will not find the file 13 | resource_type = 'video' 14 | } 15 | 16 | const result = (await cloudinary.uploader.destroy(doc.cloudinaryPublicId, { 17 | resource_type: resource_type, 18 | })) as ReturnType 19 | 20 | if (result?.result === 'ok') { 21 | return doc 22 | } else if (result?.result === 'not found') { 23 | throw new APIError( 24 | 'File to delete not found in Cloudinary', // message 25 | 500, // status 26 | result, // data 27 | true, // isPublic 28 | ) 29 | } else { 30 | throw new APIError( 31 | 'Error deleting file from Cloudinary', // message 32 | 500, // status 33 | result, // data 34 | true, // isPublic 35 | ) 36 | } 37 | } 38 | 39 | export default afterDeleteHook 40 | -------------------------------------------------------------------------------- /cloudinary/src/hooks/beforeChange.ts: -------------------------------------------------------------------------------- 1 | import { CollectionBeforeChangeHook } from 'payload' 2 | import { CloudinaryPluginConfig } from '../types/CloudinaryPluginConfig' 3 | import { streamUpload } from '../utils/streamUpload' 4 | 5 | const beforeChangeHook = (pluginConfig: CloudinaryPluginConfig) => { 6 | const hook: CollectionBeforeChangeHook = async ({ data, req, operation }) => { 7 | if (operation === 'create' && !req.file) { 8 | console.error('req.file undefined. Returning data unchanged.') 9 | return data 10 | } 11 | 12 | // The image of a media document can be updated via the admin UI. Therefore also check for operation === 'update'. 13 | if ((operation === 'create' || operation === 'update') && req.file) { 14 | const streamUploadFunction = streamUpload(pluginConfig) 15 | 16 | const result = await streamUploadFunction(req.file, data.cloudinaryPublicId) 17 | 18 | return { 19 | ...data, 20 | cloudinaryPublicId: result.public_id, 21 | cloudinaryURL: result.secure_url, 22 | } 23 | } 24 | 25 | return data 26 | } 27 | return hook 28 | } 29 | 30 | export default beforeChangeHook 31 | -------------------------------------------------------------------------------- /cloudinary/src/index.ts: -------------------------------------------------------------------------------- 1 | export { payloadCloudinaryPlugin } from './plugin' 2 | export type { CloudinaryPluginConfig } from './types/CloudinaryPluginConfig' 3 | -------------------------------------------------------------------------------- /cloudinary/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from 'cloudinary' 2 | import type { Config } from 'payload' 3 | 4 | import type { CloudinaryPluginConfig } from './types/CloudinaryPluginConfig' 5 | import { extendUploadCollectionConfig } from './utils/extendUploadCollectionConfig' 6 | 7 | /** Payload plugin which integrates cloudinary for hosting media collection items. */ 8 | export const payloadCloudinaryPlugin = 9 | (pluginOptions: CloudinaryPluginConfig) => 10 | (incomingConfig: Config): Config => { 11 | // If the plugin is disabled, return the config without modifying it 12 | if (pluginOptions.enabled === false) { 13 | return incomingConfig 14 | } 15 | 16 | cloudinary.config({ 17 | cloud_name: pluginOptions.cloudinary.cloudName, 18 | api_key: pluginOptions.credentials.apiKey, 19 | api_secret: pluginOptions.credentials.apiSecret, 20 | }) 21 | 22 | return { 23 | ...incomingConfig, 24 | collections: incomingConfig.collections?.map((collection) => { 25 | if (pluginOptions.uploadCollections?.includes(collection.slug)) { 26 | return extendUploadCollectionConfig({ 27 | config: collection, 28 | pluginConfig: pluginOptions, 29 | }) 30 | } 31 | return collection 32 | }), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cloudinary/src/types/CloudinaryPluginConfig.ts: -------------------------------------------------------------------------------- 1 | import { CollectionSlug } from 'payload' 2 | 3 | /** Configuration options for the cloudinary plugin. */ 4 | export type CloudinaryPluginConfig = { 5 | /** Whether the cloudinary plugin is enabled. */ 6 | enabled?: boolean 7 | 8 | /** 9 | * Upload collections which should be integrated with cloudinary. 10 | */ 11 | uploadCollections?: ({} | CollectionSlug)[] 12 | 13 | uploadOptions?: { 14 | /** Whether cloudinary will use the file name of the uploaded image for the public ID. Defaults to false. */ 15 | useFilename?: boolean 16 | 17 | /** 18 | * The size of the chunks to upload the file in. 19 | * See https://cloudinary.com/documentation/upload_images#chunked_asset_upload 20 | */ 21 | chunkSize?: number 22 | } 23 | 24 | /** 25 | * API credentials for the cloudinary account. 26 | */ 27 | credentials: { 28 | apiKey: string 29 | apiSecret: string 30 | } 31 | 32 | /** 33 | * General cloudinary configuration. 34 | */ 35 | cloudinary: { 36 | cloudName: string 37 | folder?: string 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cloudinary/src/utils/streamUpload.ts: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary, UploadApiOptions, UploadApiResponse, UploadStream } from 'cloudinary' 2 | import type { PayloadRequest } from 'payload' 3 | import { Readable } from 'stream' 4 | import { CloudinaryPluginConfig } from '../types/CloudinaryPluginConfig' 5 | 6 | type File = NonNullable 7 | 8 | export const streamUpload = 9 | (pluginConfig: CloudinaryPluginConfig) => 10 | (file: File, id?: string): Promise => { 11 | const readStream = Readable.from(file.data) 12 | 13 | return new Promise((resolve, reject) => { 14 | const options: UploadApiOptions = { 15 | // @ts-ignore 16 | folder: pluginConfig.cloudinary.folder, 17 | chunk_size: pluginConfig.uploadOptions?.chunkSize, 18 | ...(pluginConfig.uploadOptions?.useFilename && { 19 | use_filename: true, 20 | filename_override: file.name, 21 | }), 22 | 23 | invalidate: true, 24 | resource_type: 'auto', 25 | 26 | // In case of updating the image, the public_id will be needed, 27 | // but not the folder as it's already in the URL and if we pass 28 | // the value then it will create file in sub-folder instead of updating. 29 | ...(id && { public_id: id, folder: null }), 30 | } 31 | 32 | const stream: UploadStream = cloudinary.uploader.upload_stream(options, (error, result) => 33 | result ? resolve(result) : reject(error), 34 | ) 35 | 36 | readStream.pipe(stream) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /cloudinary/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "noEmit": false, 5 | "emitDeclarationOnly": false, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "allowJs": true, 11 | "checkJs": false, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "jsx": "preserve", 15 | "lib": ["dom", "dom.iterable", "esnext"], 16 | "skipLibCheck": true, 17 | "moduleResolution": "Bundler", 18 | "module": "ES6", 19 | "sourceMap": true, 20 | "strict": true, 21 | "incremental": true, 22 | "isolatedModules": true, 23 | "target": "ESNext" 24 | }, 25 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], 26 | "exclude": ["dist", "build", "node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /geocoding/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | -------------------------------------------------------------------------------- /geocoding/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /geocoding/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.packageManager": "pnpm", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[typescript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | } 10 | }, 11 | "[typescriptreact]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "editor.formatOnSave": true, 14 | "editor.codeActionsOnSave": { 15 | "source.fixAll.eslint": "explicit" 16 | } 17 | }, 18 | "[javascript]": { 19 | "editor.defaultFormatter": "esbenp.prettier-vscode", 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll.eslint": "explicit" 23 | } 24 | }, 25 | "[json]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode", 27 | "editor.formatOnSave": true 28 | }, 29 | "[jsonc]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode", 31 | "editor.formatOnSave": true 32 | }, 33 | "editor.formatOnSaveMode": "file", 34 | "eslint.rules.customizations": [ 35 | // Default all ESLint errors to 'warn' to differentate from TypeScript's 'error' level 36 | { 37 | "rule": "*", 38 | "severity": "warn" 39 | }, 40 | // Silence some warnings that will get auto-fixed 41 | { 42 | "rule": "perfectionist/*", 43 | "severity": "off", 44 | "fixable": true 45 | }, 46 | { 47 | "rule": "curly", 48 | "severity": "off", 49 | "fixable": true 50 | }, 51 | { 52 | "rule": "object-shorthand", 53 | "severity": "off", 54 | "fixable": true 55 | } 56 | ], 57 | } 58 | -------------------------------------------------------------------------------- /geocoding/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.6 4 | 5 | - fix: display asterisk for required geodata fields in UI ([87413ba](https://github.com/jhb-software/payload-plugins/commit/87413bac8c3f03ec257ac47de979413930816ee8)) 6 | 7 | ## 0.1.0 8 | 9 | - Initial release 10 | -------------------------------------------------------------------------------- /geocoding/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JHB Software 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 | -------------------------------------------------------------------------------- /geocoding/dev/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /geocoding/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /geocoding/dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /geocoding/dev/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /geocoding/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-test-app", 3 | "description": "A test app for the plugin", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap" 18 | }, 19 | "dependencies": { 20 | "@payloadcms/db-mongodb": "^3.27.0", 21 | "@payloadcms/next": "^3.27.0", 22 | "@payloadcms/ui": "^3.27.0", 23 | "next": "15.2.1", 24 | "payload": "^3.27.0", 25 | "@jhb.software/payload-geocoding-plugin": "workspace:*", 26 | "react": "19.0.0", 27 | "react-dom": "19.0.0" 28 | }, 29 | "devDependencies": { 30 | "copyfiles": "^2.4.1", 31 | "cross-env": "^7.0.3", 32 | "dotenv": "^16.4.7", 33 | "typescript": "5.8.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /geocoding/dev/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | import mongoose from 'mongoose' 3 | import payload from 'payload' 4 | import { start } from './src/server' 5 | 6 | describe('Plugin tests', () => { 7 | let server: Server 8 | 9 | beforeAll(async () => { 10 | await start({ local: true }) 11 | }) 12 | 13 | afterAll(async () => { 14 | await mongoose.connection.dropDatabase() 15 | await mongoose.connection.close() 16 | server.close() 17 | }) 18 | 19 | // Add tests to ensure that the plugin works as expected 20 | 21 | // Example test to check for seeded data 22 | it('seeds data accordingly', async () => { 23 | const newCollectionQuery = await payload.find({ 24 | collection: 'new-collection', 25 | sort: 'createdAt', 26 | }) 27 | 28 | expect(newCollectionQuery.totalDocs).toEqual(1) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { GeocodingFieldComponent as GeocodingFieldComponent_f511c7a8656c9889db9f167d5c72bd78 } from '@jhb.software/payload-geocoding-plugin/client' 2 | 3 | export const importMap = { 4 | "@jhb.software/payload-geocoding-plugin/client#GeocodingFieldComponent": GeocodingFieldComponent_f511c7a8656c9889db9f167d5c72bd78 5 | } 6 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/geocoding/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /geocoding/dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /geocoding/dev/src/emailAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { EmailAdapter, SendEmailOptions } from 'payload' 2 | 3 | /** 4 | * Logs all emails to stdout 5 | */ 6 | export const testEmailAdapter: EmailAdapter = ({ payload }) => ({ 7 | name: 'test-email-adapter', 8 | defaultFromAddress: 'dev@payloadcms.com', 9 | defaultFromName: 'Payload Test', 10 | sendEmail: async (message) => { 11 | const stringifiedTo = getStringifiedToAddress(message) 12 | const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'` 13 | payload.logger.info({ msg: res, content: message }) 14 | return Promise.resolve() 15 | }, 16 | }) 17 | 18 | function getStringifiedToAddress(message: SendEmailOptions): string | undefined { 19 | let stringifiedTo: string | undefined 20 | 21 | if (typeof message.to === 'string') { 22 | stringifiedTo = message.to 23 | } else if (Array.isArray(message.to)) { 24 | stringifiedTo = message.to 25 | .map((to: string | { address: string }) => { 26 | if (typeof to === 'string') { 27 | return to 28 | } else if (to.address) { 29 | return to.address 30 | } 31 | return '' 32 | }) 33 | .join(', ') 34 | } else if (message.to.address) { 35 | stringifiedTo = message.to.address 36 | } 37 | return stringifiedTo 38 | } 39 | -------------------------------------------------------------------------------- /geocoding/dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { payloadGeocodingPlugin } from '@jhb.software/payload-geocoding-plugin' 2 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 3 | import path from 'path' 4 | import { buildConfig } from 'payload' 5 | import { fileURLToPath } from 'url' 6 | import { Pages } from './collection/pages' 7 | import { testEmailAdapter } from './emailAdapter' 8 | 9 | const filename = fileURLToPath(import.meta.url) 10 | const dirname = path.dirname(filename) 11 | 12 | export default buildConfig({ 13 | admin: { 14 | autoLogin: { 15 | email: 'dev@payloadcms.com', 16 | password: 'test', 17 | }, 18 | user: 'users', 19 | }, 20 | collections: [ 21 | { 22 | slug: 'users', 23 | auth: true, 24 | fields: [], 25 | }, 26 | Pages, 27 | ], 28 | db: mongooseAdapter({ 29 | url: process.env.DATABASE_URI!, 30 | }), 31 | email: testEmailAdapter, 32 | secret: process.env.PAYLOAD_SECRET!, 33 | typescript: { 34 | outputFile: path.resolve(dirname, 'payload-types.ts'), 35 | }, 36 | plugins: [payloadGeocodingPlugin({})], 37 | async onInit(payload) { 38 | const existingUsers = await payload.find({ 39 | collection: 'users', 40 | limit: 1, 41 | }) 42 | 43 | if (existingUsers.docs.length === 0) { 44 | await payload.create({ 45 | collection: 'users', 46 | data: { 47 | email: 'dev@payloadcms.com', 48 | password: 'test', 49 | }, 50 | }) 51 | } 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /geocoding/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /geocoding/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import payloadEsLintConfig from '@payloadcms/eslint-config' 4 | 5 | export const defaultESLintIgnores = [ 6 | '**/.temp', 7 | '**/.*', // ignore all dotfiles 8 | '**/.git', 9 | '**/.hg', 10 | '**/.pnp.*', 11 | '**/.svn', 12 | '**/playwright.config.ts', 13 | '**/jest.config.js', 14 | '**/tsconfig.tsbuildinfo', 15 | '**/README.md', 16 | '**/eslint.config.js', 17 | '**/payload-types.ts', 18 | '**/dist/', 19 | '**/.yarn/', 20 | '**/build/', 21 | '**/node_modules/', 22 | '**/temp/', 23 | ] 24 | 25 | export default [ 26 | ...payloadEsLintConfig, 27 | { 28 | // Modify any rules from payload's config that you want to override/disable 29 | 'no-restricted-exports': 'off', 30 | }, 31 | // TODO: Bring in '@payloadcms/eslint-plugin' for 'payload/proper-payload-logger-usage' rule 32 | { 33 | ignores: defaultESLintIgnores, 34 | }, 35 | { 36 | languageOptions: { 37 | parserOptions: { 38 | sourceType: 'module', 39 | ecmaVersion: 'latest', 40 | projectService: { 41 | maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40, 42 | allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'], 43 | }, 44 | // projectService: true, 45 | tsconfigRootDir: import.meta.dirname, 46 | }, 47 | }, 48 | }, 49 | ] 50 | -------------------------------------------------------------------------------- /geocoding/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './' # Plugin at the root 3 | - 'dev/' # Testing environment 4 | -------------------------------------------------------------------------------- /geocoding/src/components/GeocodingFieldComponent.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { FieldError, FieldLabel, useField } from '@payloadcms/ui' 3 | import { SelectFieldClientComponent } from 'payload' 4 | import GooglePlacesAutocomplete, { 5 | geocodeByPlaceId, 6 | getLatLng, 7 | } from 'react-google-places-autocomplete' 8 | 9 | /** 10 | * A custom client component that shows the Google Places Autocomplete component and 11 | * fills the point and geodata fields with the received data from the Google Places API. 12 | */ 13 | export const GeocodingFieldComponent: SelectFieldClientComponent = ({ field, path }) => { 14 | const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY 15 | const pointFieldPath = path.replace('_googlePlacesData', '') 16 | 17 | const { value: geoData, setValue: setGeoData } = useField({ 18 | path: path, 19 | }) 20 | const { setValue: setPoint } = useField>({ path: pointFieldPath }) 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | { 34 | if (geoData) { 35 | const placeId = geoData?.value.place_id 36 | const geocode = (await geocodeByPlaceId(placeId)).at(0) 37 | 38 | if (!geocode) return 39 | const latLng = await getLatLng(geocode) 40 | 41 | setPoint([latLng.lng, latLng.lat]) 42 | setGeoData(geoData) 43 | } else { 44 | // reset the fields when it was cleared 45 | setPoint([]) 46 | setGeoData(null) 47 | } 48 | }, 49 | }} 50 | /> 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /geocoding/src/exports/client.ts: -------------------------------------------------------------------------------- 1 | export { GeocodingFieldComponent } from '../components/GeocodingFieldComponent' 2 | -------------------------------------------------------------------------------- /geocoding/src/fields/geocodingField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | import { GeoCodingFieldConfig } from '../types/GeoCodingFieldConfig' 3 | 4 | /** 5 | * Creates a row field containing: 6 | * 1. The provided point field for storing the coordinates from the Google Places API 7 | * 2. A JSON field that stores the raw Google Places API geocoding data 8 | */ 9 | export const geocodingField = (config: GeoCodingFieldConfig): Field => { 10 | return { 11 | type: 'row', 12 | admin: { 13 | position: config.pointField.admin?.position ?? undefined, 14 | }, 15 | fields: [ 16 | { 17 | name: config.pointField.name + '_googlePlacesData', 18 | type: 'json', 19 | label: config.geoDataFieldOverride?.label ?? 'Location', 20 | access: config.geoDataFieldOverride?.access ?? {}, 21 | required: config.geoDataFieldOverride?.required, 22 | admin: { 23 | // overridable props: 24 | readOnly: true, 25 | 26 | ...config.geoDataFieldOverride?.admin, 27 | 28 | // non-overridable props: 29 | components: { 30 | Field: '@jhb.software/payload-geocoding-plugin/client#GeocodingFieldComponent', 31 | }, 32 | }, 33 | }, 34 | config.pointField, 35 | ], 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /geocoding/src/index.ts: -------------------------------------------------------------------------------- 1 | export { geocodingField } from './fields/geocodingField' 2 | export { payloadGeocodingPlugin } from './plugin.js' 3 | export type { GeocodingPluginConfig } from './types/GeoCodingPluginConfig' 4 | export type { GeoCodingFieldConfig } from './types/GeoCodingFieldConfig' 5 | -------------------------------------------------------------------------------- /geocoding/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | 3 | import type { GeocodingPluginConfig } from './types/GeoCodingPluginConfig' 4 | 5 | /** Payload plugin which extends the point field with geocoding functionality. */ 6 | export const payloadGeocodingPlugin = 7 | (pluginOptions: GeocodingPluginConfig) => 8 | (incomingConfig: Config): Config => { 9 | const config = { ...incomingConfig } 10 | 11 | // If the plugin is disabled, return the config without modifying it 12 | if (pluginOptions.enabled === false) { 13 | return config 14 | } 15 | 16 | config.onInit = async (payload) => { 17 | if (incomingConfig.onInit) { 18 | await incomingConfig.onInit(payload) 19 | } 20 | 21 | const neededEnvVars = ['NEXT_PUBLIC_GOOGLE_MAPS_API_KEY'] 22 | 23 | const missingEnvVars = neededEnvVars.filter((envVar) => !process.env[envVar]) 24 | if (missingEnvVars.length > 0) { 25 | throw new Error( 26 | `The following environment variables are required for the geocoding plugin but not defined: ${missingEnvVars.join( 27 | ', ', 28 | )}`, 29 | ) 30 | } 31 | } 32 | 33 | return config 34 | } 35 | -------------------------------------------------------------------------------- /geocoding/src/types/GeoCodingFieldConfig.ts: -------------------------------------------------------------------------------- 1 | import { JSONField, PointField } from 'payload' 2 | 3 | /** Configuration for the geocoding fields. */ 4 | export type GeoCodingFieldConfig = { 5 | pointField: PointField 6 | geoDataFieldOverride?: { 7 | required?: boolean 8 | label?: string 9 | access?: JSONField['access'] 10 | admin?: JSONField['admin'] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /geocoding/src/types/GeoCodingPluginConfig.ts: -------------------------------------------------------------------------------- 1 | /** Configuration options for the geocoding plugin. */ 2 | export type GeocodingPluginConfig = { 3 | /** Whether the geocoding plugin is enabled. */ 4 | enabled?: boolean 5 | } 6 | -------------------------------------------------------------------------------- /geocoding/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "declarationDir": "./dist", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "target": "es5", 9 | "allowJs": true, 10 | "jsx": "preserve", 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "sourceMap": true, 14 | "esModuleInterop": true, 15 | "declaration": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "include": ["src/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugins", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build:all": "pnpm build:cloudinary && pnpm build:pages && pnpm build:seo && pnpm build:geocoding", 7 | "publish:all": "pnpm publish:cloudinary && pnpm publish:pages && pnpm publish:seo && pnpm publish:geocoding", 8 | "build:cloudinary": "cd ./cloudinary && pnpm build", 9 | "build:pages": "cd ./pages && pnpm build", 10 | "build:seo": "cd ./seo && pnpm build", 11 | "publish:cloudinary": "cd ./cloudinary && pnpm publish", 12 | "publish:pages": "cd ./pages && pnpm publish", 13 | "publish:seo": "cd ./seo && pnpm publish", 14 | "build:geocoding": "cd ./geocoding && pnpm build", 15 | "publish:geocoding": "cd ./geocoding && pnpm publish" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | tsconfig.tsbuildinfo 4 | .next 5 | *.db -------------------------------------------------------------------------------- /pages/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/pages/.npmignore -------------------------------------------------------------------------------- /pages/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /pages/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "target": "esnext", 6 | "parser": { 7 | "syntax": "typescript", 8 | "tsx": true, 9 | "dts": true 10 | }, 11 | "transform": { 12 | "react": { 13 | "runtime": "automatic", 14 | "pragmaFrag": "React.Fragment", 15 | "throwIfNamespace": true, 16 | "development": false, 17 | "useBuiltins": true 18 | } 19 | } 20 | }, 21 | "module": { 22 | "type": "es6" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JHB Software 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 | -------------------------------------------------------------------------------- /pages/dev/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /pages/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /pages/dev/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /pages/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pages-plugin-localized-test-app", 3 | "description": "A test app for the pages plugin with localized pages", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap", 18 | "test": "vitest run", 19 | "test:watch": "vitest watch" 20 | }, 21 | "dependencies": { 22 | "@jhb.software/payload-pages-plugin": "workspace:*", 23 | "@payloadcms/db-mongodb": "3.50.0", 24 | "@payloadcms/db-sqlite": "3.50.0", 25 | "@payloadcms/next": "3.50.0", 26 | "@payloadcms/translations": "^3.50.0", 27 | "@payloadcms/ui": "3.50.0", 28 | "next": "15.4.6", 29 | "payload": "3.50.0", 30 | "react": "19.1.1", 31 | "react-dom": "19.1.1" 32 | }, 33 | "devDependencies": { 34 | "copyfiles": "^2.4.1", 35 | "cross-env": "^10.0.0", 36 | "dotenv": "^17.2.1", 37 | "vite": "^7.1.2", 38 | "vitest": "^3.2.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { IsRootPageField as IsRootPageField_817212d6f65b4eb37176541413db3f8c } from '@jhb.software/payload-pages-plugin/server' 2 | import { SlugFieldWrapper as SlugFieldWrapper_817212d6f65b4eb37176541413db3f8c } from '@jhb.software/payload-pages-plugin/server' 3 | import { ParentField as ParentField_817212d6f65b4eb37176541413db3f8c } from '@jhb.software/payload-pages-plugin/server' 4 | import { PathField as PathField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 5 | import { BreadcrumbsField as BreadcrumbsField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 6 | import { PreviewButtonField as PreviewButtonField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 7 | 8 | export const importMap = { 9 | "@jhb.software/payload-pages-plugin/server#IsRootPageField": IsRootPageField_817212d6f65b4eb37176541413db3f8c, 10 | "@jhb.software/payload-pages-plugin/server#SlugFieldWrapper": SlugFieldWrapper_817212d6f65b4eb37176541413db3f8c, 11 | "@jhb.software/payload-pages-plugin/server#ParentField": ParentField_817212d6f65b4eb37176541413db3f8c, 12 | "@jhb.software/payload-pages-plugin/client#PathField": PathField_e6458422044c3374e7ca411c92428566, 13 | "@jhb.software/payload-pages-plugin/client#BreadcrumbsField": BreadcrumbsField_e6458422044c3374e7ca411c92428566, 14 | "@jhb.software/payload-pages-plugin/client#PreviewButtonField": PreviewButtonField_e6458422044c3374e7ca411c92428566 15 | } 16 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/pages/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /pages/dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /pages/dev/src/collections/authors.ts: -------------------------------------------------------------------------------- 1 | import { alternatePathsField, PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Authors: PageCollectionConfig = { 4 | slug: 'authors', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'name', 16 | }, 17 | }, 18 | fields: [ 19 | { 20 | name: 'name', 21 | type: 'text', 22 | required: true, 23 | localized: true, 24 | }, 25 | { 26 | name: 'content', 27 | type: 'textarea', 28 | required: true, 29 | localized: true, 30 | }, 31 | { 32 | // normally this group would be generated by the seo plugin 33 | name: 'meta', 34 | type: 'group', 35 | fields: [alternatePathsField()], 36 | }, 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /pages/dev/src/collections/blogpost-categories.ts: -------------------------------------------------------------------------------- 1 | import { slugField } from '@jhb.software/payload-pages-plugin' 2 | import { CollectionConfig } from 'payload' 3 | 4 | // This collection is designed to test the functionality of the slugField in a non-page context. 5 | export const BlogpostCategories: CollectionConfig = { 6 | slug: 'blogpost-categories', 7 | admin: { 8 | useAsTitle: 'title', 9 | }, 10 | fields: [ 11 | { 12 | name: 'title', 13 | type: 'text', 14 | required: true, 15 | localized: true, 16 | }, 17 | slugField({ fallbackField: 'title' }), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /pages/dev/src/collections/blogposts.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Blogposts: PageCollectionConfig = { 4 | slug: 'blogposts', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'authors', 11 | name: 'author', 12 | sharedDocument: false, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'shortTitle', 16 | }, 17 | }, 18 | fields: [ 19 | { 20 | name: 'title', 21 | type: 'text', 22 | required: true, 23 | localized: true, 24 | }, 25 | { 26 | name: 'shortTitle', 27 | type: 'text', 28 | required: true, 29 | localized: true, 30 | }, 31 | { 32 | name: 'content', 33 | type: 'textarea', 34 | required: true, 35 | localized: true, 36 | }, 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /pages/dev/src/collections/countries.ts: -------------------------------------------------------------------------------- 1 | import { alternatePathsField, PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Countries: PageCollectionConfig = { 4 | slug: 'countries', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | }, 15 | versions: { 16 | drafts: true, 17 | }, 18 | fields: [ 19 | { 20 | name: 'title', 21 | type: 'text', 22 | required: true, 23 | localized: true, 24 | }, 25 | { 26 | name: 'content', 27 | type: 'textarea', 28 | required: true, 29 | localized: true, 30 | }, 31 | { 32 | // normally this group would be generated by the seo plugin 33 | name: 'meta', 34 | type: 'group', 35 | fields: [alternatePathsField()], 36 | }, 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /pages/dev/src/collections/country-travel-tips.ts: -------------------------------------------------------------------------------- 1 | import { alternatePathsField, PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const CountryTravelTips: PageCollectionConfig = { 4 | slug: 'country-travel-tips', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'countries', 11 | name: 'country', 12 | }, 13 | slug: { 14 | unique: false, 15 | staticValue: { 16 | de: 'reisetipps', 17 | en: 'travel-tips', 18 | }, 19 | }, 20 | }, 21 | versions: { 22 | drafts: { 23 | autosave: true, 24 | }, 25 | }, 26 | fields: [ 27 | { 28 | name: 'title', 29 | type: 'text', 30 | required: true, 31 | localized: true, 32 | }, 33 | { 34 | name: 'content', 35 | type: 'textarea', 36 | required: true, 37 | localized: true, 38 | }, 39 | { 40 | // normally this group would be generated by the seo plugin 41 | name: 'meta', 42 | type: 'group', 43 | fields: [alternatePathsField()], 44 | }, 45 | ], 46 | } 47 | -------------------------------------------------------------------------------- /pages/dev/src/collections/pages.ts: -------------------------------------------------------------------------------- 1 | import { alternatePathsField, PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Pages: PageCollectionConfig = { 4 | slug: 'pages', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | access: { 9 | update: () => false, 10 | create: () => false, 11 | delete: () => false, 12 | }, 13 | page: { 14 | parent: { 15 | collection: 'pages', 16 | name: 'parent', 17 | }, 18 | isRootCollection: true, 19 | }, 20 | versions: { 21 | drafts: { 22 | autosave: true, 23 | }, 24 | }, 25 | fields: [ 26 | { 27 | name: 'title', 28 | type: 'text', 29 | required: true, 30 | localized: true, 31 | }, 32 | { 33 | name: 'content', 34 | type: 'textarea', 35 | required: true, 36 | localized: true, 37 | }, 38 | { 39 | // normally this group would be generated by the seo plugin 40 | name: 'meta', 41 | type: 'group', 42 | fields: [alternatePathsField()], 43 | }, 44 | ], 45 | } 46 | -------------------------------------------------------------------------------- /pages/dev/src/collections/redirects.ts: -------------------------------------------------------------------------------- 1 | import { RedirectsCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Redirects: RedirectsCollectionConfig = { 4 | slug: 'redirects', 5 | admin: { 6 | defaultColumns: ['sourcePath', 'destinationPath', 'permanent', 'createdAt'], 7 | listSearchableFields: ['sourcePath', 'destinationPath'], 8 | }, 9 | redirects: {}, 10 | fields: [ 11 | // the fields are added by the plugin automatically 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /pages/dev/src/utils/generatePageURL.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate the full URL to a frontend page. 3 | * This is a helper function used by the dev environment. 4 | */ 5 | export const generatePageURL = ({ 6 | path, 7 | preview, 8 | }: { 9 | path: string 10 | preview: boolean 11 | }): string => { 12 | const domain = process.env.NEXT_PUBLIC_FRONTEND_URL 13 | 14 | if (!domain) { 15 | throw new Error('NEXT_PUBLIC_FRONTEND_URL environment variable is required') 16 | } 17 | 18 | if (!path) { 19 | console.error('generatePageURL received an empty path:', path) 20 | return '' 21 | } 22 | 23 | if (!path.startsWith('/')) { 24 | throw new Error('Path must start with a slash: ' + path) 25 | } 26 | 27 | return `${domain}${preview ? '/preview' : ''}${path}` 28 | } 29 | -------------------------------------------------------------------------------- /pages/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vite.config.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv } from 'vite' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig(({ mode }) => { 5 | return { 6 | test: { 7 | env: loadEnv(mode, process.cwd(), ''), // Load environment variables 8 | }, 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /pages/dev_multi_tenant/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pages-plugin-unlocalized-test-app", 3 | "description": "A test app for the pages plugin with unlocalized pages", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap", 18 | "test": "vitest run", 19 | "test:watch": "vitest watch" 20 | }, 21 | "dependencies": { 22 | "@jhb.software/payload-pages-plugin": "workspace:*", 23 | "@payloadcms/db-mongodb": "3.50.0", 24 | "@payloadcms/db-sqlite": "3.50.0", 25 | "@payloadcms/next": "3.50.0", 26 | "@payloadcms/plugin-multi-tenant": "3.50.0", 27 | "@payloadcms/ui": "3.50.0", 28 | "next": "15.4.6", 29 | "payload": "3.50.0", 30 | "react": "19.1.1", 31 | "react-dom": "19.1.1" 32 | }, 33 | "devDependencies": { 34 | "copyfiles": "^2.4.1", 35 | "cross-env": "^10.0.0", 36 | "dotenv": "^17.2.1", 37 | "vite": "^7.1.2", 38 | "vitest": "^3.2.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/pages/dev_multi_tenant/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/authors.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Authors: PageCollectionConfig = { 4 | slug: 'authors', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'name', 16 | }, 17 | slug: { 18 | // Disable the slug uniqueness because of the multi-tenant setup (see indexes below) 19 | unique: false, 20 | }, 21 | }, 22 | indexes: [ 23 | { 24 | fields: ['slug', 'tenant'], 25 | unique: true, 26 | }, 27 | ], 28 | fields: [ 29 | { 30 | name: 'name', 31 | type: 'text', 32 | required: true, 33 | }, 34 | { 35 | name: 'content', 36 | type: 'textarea', 37 | required: true, 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/blogpost-categories.ts: -------------------------------------------------------------------------------- 1 | import { slugField } from '@jhb.software/payload-pages-plugin' 2 | import { CollectionConfig } from 'payload' 3 | 4 | // This collection is designed to test the functionality of the slugField in a non-page context. 5 | export const BlogpostCategories: CollectionConfig = { 6 | slug: 'blogpost-categories', 7 | admin: { 8 | useAsTitle: 'title', 9 | }, 10 | fields: [ 11 | { 12 | name: 'title', 13 | type: 'text', 14 | required: true, 15 | }, 16 | slugField({ fallbackField: 'title' }), 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/blogposts.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Blogposts: PageCollectionConfig = { 4 | slug: 'blogposts', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'authors', 11 | name: 'author', 12 | sharedDocument: false, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'shortTitle', 16 | }, 17 | slug: { 18 | // Disable the slug uniqueness because of the multi-tenant setup (see indexes below) 19 | unique: false, 20 | }, 21 | }, 22 | indexes: [ 23 | { 24 | fields: ['slug', 'tenant'], 25 | unique: true, 26 | }, 27 | ], 28 | fields: [ 29 | { 30 | name: 'title', 31 | type: 'text', 32 | required: true, 33 | }, 34 | { 35 | name: 'shortTitle', 36 | type: 'text', 37 | required: true, 38 | }, 39 | { 40 | name: 'content', 41 | type: 'textarea', 42 | required: true, 43 | }, 44 | ], 45 | } 46 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/countries.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Countries: PageCollectionConfig = { 4 | slug: 'countries', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | slug: { 15 | // Disable the slug uniqueness because of the multi-tenant setup (see indexes below) 16 | unique: false, 17 | }, 18 | }, 19 | versions: { 20 | drafts: true, 21 | }, 22 | indexes: [ 23 | { 24 | fields: ['slug', 'tenant'], 25 | unique: true, 26 | }, 27 | ], 28 | fields: [ 29 | { 30 | name: 'title', 31 | type: 'text', 32 | required: true, 33 | }, 34 | { 35 | name: 'content', 36 | type: 'textarea', 37 | required: true, 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/country-travel-tips.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const CountryTravelTips: PageCollectionConfig = { 4 | slug: 'country-travel-tips', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'countries', 11 | name: 'country', 12 | }, 13 | slug: { 14 | // Disable the slug uniqueness because of the multi-tenant setup (see indexes below) 15 | unique: false, 16 | staticValue: 'reisetipps', 17 | }, 18 | }, 19 | indexes: [ 20 | { 21 | fields: ['slug', 'tenant'], 22 | unique: true, 23 | }, 24 | ], 25 | versions: { 26 | drafts: { 27 | autosave: true, 28 | }, 29 | }, 30 | fields: [ 31 | { 32 | name: 'title', 33 | type: 'text', 34 | required: true, 35 | }, 36 | { 37 | name: 'content', 38 | type: 'textarea', 39 | required: true, 40 | }, 41 | ], 42 | } 43 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/pages.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Pages: PageCollectionConfig = { 4 | slug: 'pages', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | }, 13 | isRootCollection: true, 14 | slug: { 15 | // Disable the slug uniqueness because of the multi-tenant setup (see indexes below) 16 | unique: false, 17 | }, 18 | }, 19 | versions: { 20 | drafts: true, 21 | }, 22 | indexes: [ 23 | { 24 | fields: ['slug', 'tenant'], 25 | unique: true, 26 | }, 27 | ], 28 | fields: [ 29 | { 30 | name: 'title', 31 | type: 'text', 32 | required: true, 33 | }, 34 | { 35 | name: 'content', 36 | type: 'textarea', 37 | required: true, 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/redirects.ts: -------------------------------------------------------------------------------- 1 | import { RedirectsCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Redirects: RedirectsCollectionConfig = { 4 | slug: 'redirects', 5 | admin: { 6 | defaultColumns: ['sourcePath', 'destinationPath', 'permanent', 'createdAt'], 7 | listSearchableFields: ['sourcePath', 'destinationPath'], 8 | }, 9 | redirects: {}, 10 | fields: [ 11 | // the fields are added by the plugin automatically 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/collections/tenants.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | 3 | const Tenants: CollectionConfig = { 4 | slug: 'tenants', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | fields: [ 9 | { 10 | name: 'slug', 11 | type: 'text', 12 | required: true, 13 | unique: true, 14 | index: true, 15 | }, 16 | { 17 | name: 'name', 18 | type: 'text', 19 | required: true, 20 | }, 21 | ], 22 | } 23 | 24 | export default Tenants 25 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/src/utils/generatePageURL.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate the full URL to a frontend page. 3 | * This is a helper function used by the dev environment. 4 | */ 5 | export const generatePageURL = ({ 6 | path, 7 | preview, 8 | }: { 9 | path: string 10 | preview: boolean 11 | }): string => { 12 | const domain = process.env.NEXT_PUBLIC_FRONTEND_URL 13 | 14 | if (!domain) { 15 | throw new Error('NEXT_PUBLIC_FRONTEND_URL environment variable is required') 16 | } 17 | 18 | if (!path) { 19 | console.error('generatePageURL received an empty path:', path) 20 | return '' 21 | } 22 | 23 | if (!path.startsWith('/')) { 24 | throw new Error('Path must start with a slash: ' + path) 25 | } 26 | 27 | return `${domain}${preview ? '/preview' : ''}${path}` 28 | } 29 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vite.config.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev_multi_tenant/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv } from 'vite' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig(({ mode }) => { 5 | return { 6 | test: { 7 | env: loadEnv(mode, process.cwd(), ''), // Load environment variables 8 | }, 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /pages/dev_unlocalized/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pages-plugin-unlocalized-test-app", 3 | "description": "A test app for the pages plugin with unlocalized pages", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap", 18 | "test": "vitest run", 19 | "test:watch": "vitest watch" 20 | }, 21 | "dependencies": { 22 | "@jhb.software/payload-pages-plugin": "workspace:*", 23 | "@payloadcms/db-mongodb": "3.50.0", 24 | "@payloadcms/db-sqlite": "3.50.0", 25 | "@payloadcms/next": "3.50.0", 26 | "@payloadcms/ui": "3.50.0", 27 | "next": "15.4.6", 28 | "payload": "3.50.0", 29 | "react": "19.1.1", 30 | "react-dom": "19.1.1" 31 | }, 32 | "devDependencies": { 33 | "copyfiles": "^2.4.1", 34 | "cross-env": "^10.0.0", 35 | "dotenv": "^17.2.1", 36 | "vite": "^7.1.2", 37 | "vitest": "^3.2.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { IsRootPageField as IsRootPageField_817212d6f65b4eb37176541413db3f8c } from '@jhb.software/payload-pages-plugin/server' 2 | import { SlugField as SlugField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 3 | import { ParentField as ParentField_817212d6f65b4eb37176541413db3f8c } from '@jhb.software/payload-pages-plugin/server' 4 | import { PathField as PathField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 5 | import { BreadcrumbsField as BreadcrumbsField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 6 | import { PreviewButtonField as PreviewButtonField_e6458422044c3374e7ca411c92428566 } from '@jhb.software/payload-pages-plugin/client' 7 | 8 | export const importMap = { 9 | "@jhb.software/payload-pages-plugin/server#IsRootPageField": IsRootPageField_817212d6f65b4eb37176541413db3f8c, 10 | "@jhb.software/payload-pages-plugin/client#SlugField": SlugField_e6458422044c3374e7ca411c92428566, 11 | "@jhb.software/payload-pages-plugin/server#ParentField": ParentField_817212d6f65b4eb37176541413db3f8c, 12 | "@jhb.software/payload-pages-plugin/client#PathField": PathField_e6458422044c3374e7ca411c92428566, 13 | "@jhb.software/payload-pages-plugin/client#BreadcrumbsField": BreadcrumbsField_e6458422044c3374e7ca411c92428566, 14 | "@jhb.software/payload-pages-plugin/client#PreviewButtonField": PreviewButtonField_e6458422044c3374e7ca411c92428566 15 | } 16 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/pages/dev_unlocalized/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/authors.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Authors: PageCollectionConfig = { 4 | slug: 'authors', 5 | admin: { 6 | useAsTitle: 'name', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'name', 16 | }, 17 | }, 18 | fields: [ 19 | { 20 | name: 'name', 21 | type: 'text', 22 | required: true, 23 | }, 24 | { 25 | name: 'content', 26 | type: 'textarea', 27 | required: true, 28 | }, 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/blogpost-categories.ts: -------------------------------------------------------------------------------- 1 | import { slugField } from '@jhb.software/payload-pages-plugin' 2 | import { CollectionConfig } from 'payload' 3 | 4 | // This collection is designed to test the functionality of the slugField in a non-page context. 5 | export const BlogpostCategories: CollectionConfig = { 6 | slug: 'blogpost-categories', 7 | admin: { 8 | useAsTitle: 'title', 9 | }, 10 | fields: [ 11 | { 12 | name: 'title', 13 | type: 'text', 14 | required: true, 15 | }, 16 | slugField({ fallbackField: 'title' }), 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/blogposts.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Blogposts: PageCollectionConfig = { 4 | slug: 'blogposts', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'authors', 11 | name: 'author', 12 | sharedDocument: false, 13 | }, 14 | breadcrumbs: { 15 | labelField: 'shortTitle', 16 | }, 17 | }, 18 | fields: [ 19 | { 20 | name: 'title', 21 | type: 'text', 22 | required: true, 23 | }, 24 | { 25 | name: 'shortTitle', 26 | type: 'text', 27 | required: true, 28 | }, 29 | { 30 | name: 'content', 31 | type: 'textarea', 32 | required: true, 33 | }, 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/countries.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Countries: PageCollectionConfig = { 4 | slug: 'countries', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | sharedDocument: true, 13 | }, 14 | }, 15 | versions: { 16 | drafts: true, 17 | }, 18 | fields: [ 19 | { 20 | name: 'title', 21 | type: 'text', 22 | required: true, 23 | }, 24 | { 25 | name: 'content', 26 | type: 'textarea', 27 | required: true, 28 | }, 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/country-travel-tips.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const CountryTravelTips: PageCollectionConfig = { 4 | slug: 'country-travel-tips', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'countries', 11 | name: 'country', 12 | }, 13 | slug: { 14 | unique: false, 15 | staticValue: 'reisetipps', 16 | }, 17 | }, 18 | versions: { 19 | drafts: { 20 | autosave: true, 21 | }, 22 | }, 23 | fields: [ 24 | { 25 | name: 'title', 26 | type: 'text', 27 | required: true, 28 | }, 29 | { 30 | name: 'content', 31 | type: 'textarea', 32 | required: true, 33 | }, 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/pages.ts: -------------------------------------------------------------------------------- 1 | import { PageCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Pages: PageCollectionConfig = { 4 | slug: 'pages', 5 | admin: { 6 | useAsTitle: 'title', 7 | }, 8 | page: { 9 | parent: { 10 | collection: 'pages', 11 | name: 'parent', 12 | }, 13 | isRootCollection: true, 14 | }, 15 | versions: { 16 | drafts: true, 17 | }, 18 | fields: [ 19 | { 20 | name: 'title', 21 | type: 'text', 22 | required: true, 23 | }, 24 | { 25 | name: 'content', 26 | type: 'textarea', 27 | required: true, 28 | }, 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/collections/redirects.ts: -------------------------------------------------------------------------------- 1 | import { RedirectsCollectionConfig } from '@jhb.software/payload-pages-plugin' 2 | 3 | export const Redirects: RedirectsCollectionConfig = { 4 | slug: 'redirects', 5 | admin: { 6 | defaultColumns: ['sourcePath', 'destinationPath', 'permanent', 'createdAt'], 7 | listSearchableFields: ['sourcePath', 'destinationPath'], 8 | }, 9 | redirects: {}, 10 | fields: [ 11 | // the fields are added by the plugin automatically 12 | ], 13 | } 14 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/src/utils/generatePageURL.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate the full URL to a frontend page. 3 | * This is a helper function used by the dev environment. 4 | */ 5 | export const generatePageURL = ({ 6 | path, 7 | preview, 8 | }: { 9 | path: string 10 | preview: boolean 11 | }): string => { 12 | const domain = process.env.NEXT_PUBLIC_FRONTEND_URL 13 | 14 | if (!domain) { 15 | throw new Error('NEXT_PUBLIC_FRONTEND_URL environment variable is required') 16 | } 17 | 18 | if (!path) { 19 | console.error('generatePageURL received an empty path:', path) 20 | return '' 21 | } 22 | 23 | if (!path.startsWith('/')) { 24 | throw new Error('Path must start with a slash: ' + path) 25 | } 26 | 27 | return `${domain}${preview ? '/preview' : ''}${path}` 28 | } 29 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vite.config.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /pages/dev_unlocalized/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv } from 'vite' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig(({ mode }) => { 5 | return { 6 | test: { 7 | env: loadEnv(mode, process.cwd(), ''), // Load environment variables 8 | }, 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /pages/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './' 3 | - './dev' 4 | - './dev_unlocalized' 5 | - './dev_multi_tenant' 6 | -------------------------------------------------------------------------------- /pages/src/components/client/BreadcrumbsField.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ArrayField, Drawer, Button, useModal } from '@payloadcms/ui' 4 | import { ArrayFieldClientComponent } from 'payload' 5 | 6 | const breadcrumbsModalSlug = 'breadcrumbs-drawer' 7 | 8 | export const BreadcrumbsFieldModalButton: React.FC = () => { 9 | const { toggleModal } = useModal() 10 | 11 | return ( 12 | toggleModal(breadcrumbsModalSlug)} 14 | buttonStyle="transparent" 15 | size="small" 16 | icon={ 17 | 18 | 19 | 20 | } 21 | tooltip="Show Breadcrumbs" 22 | /> 23 | ) 24 | } 25 | export const BreadcrumbsField: ArrayFieldClientComponent = (props) => { 26 | const { field, path } = props 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | export default BreadcrumbsField 42 | -------------------------------------------------------------------------------- /pages/src/components/client/IsRootPageStatus.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { CheckboxField, useField } from '@payloadcms/ui' 3 | import { CheckboxFieldClientProps } from '@payloadcms/ui/fields/Checkbox' 4 | import { usePluginTranslation } from '../../utils/usePluginTranslations.js' 5 | /** 6 | * Field which displays either a checkbox to set the page to be root page or a message if the page is the root page. 7 | */ 8 | export const IsRootPageStatus: React.FC< 9 | CheckboxFieldClientProps & { hasRootPage: boolean; readOnly?: boolean } 10 | > = ({ field, path, hasRootPage, readOnly }) => { 11 | const { value } = useField({ path: path! }) 12 | const isRootPage = value ?? false 13 | const { t } = usePluginTranslation() 14 | 15 | if (isRootPage) { 16 | return ( 17 | 18 | 29 | 30 | 31 | 32 | {t('rootPage')} 33 | 34 | ) 35 | } else if (!hasRootPage && !isRootPage) { 36 | return 37 | } 38 | 39 | return null 40 | } 41 | -------------------------------------------------------------------------------- /pages/src/components/client/hooks/useCollectionConfig.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useConfig, useDocumentInfo } from '@payloadcms/ui' 4 | import { ClientCollectionConfig } from 'payload' 5 | 6 | /** 7 | * Returns the collection config for the collection of the document. 8 | */ 9 | export function useCollectionConfig(): ClientCollectionConfig { 10 | const { collectionSlug } = useDocumentInfo() 11 | const { 12 | config: { collections }, 13 | } = useConfig() 14 | 15 | const collection = collections.find((c) => c.slug === collectionSlug)! 16 | 17 | return collection 18 | } 19 | -------------------------------------------------------------------------------- /pages/src/components/client/hooks/usePageCollectionConfigAtrributes.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { PageCollectionConfigAttributes } from '../../../types/PageCollectionConfigAttributes.js' 4 | import { useCollectionConfig } from './useCollectionConfig.js' 5 | import { asPageCollectionConfigOrThrow } from '../../../utils/pageCollectionConfigHelpers.js' 6 | 7 | /** 8 | * Returns the PageCollectionConfigAttributes for the collection of the document. 9 | */ 10 | export function usePageCollectionConfigAttributes(): PageCollectionConfigAttributes { 11 | const collection = useCollectionConfig() 12 | 13 | return asPageCollectionConfigOrThrow(collection).page 14 | } 15 | -------------------------------------------------------------------------------- /pages/src/components/server/IsRootPageField.tsx: -------------------------------------------------------------------------------- 1 | import { CheckboxFieldServerComponent, Where } from 'payload' 2 | import { IsRootPageStatus } from '../client/IsRootPageStatus.js' 3 | 4 | /** 5 | * Field which fetches the root page and forwards the result to the `IsRootPageStatus` client component. 6 | */ 7 | export const IsRootPageField: CheckboxFieldServerComponent = async ({ 8 | clientField, 9 | path, 10 | field, 11 | collectionSlug, 12 | payload, 13 | req, 14 | permissions, 15 | readOnly, 16 | // @ts-expect-error: TODO: extend the CheckboxFieldServerComponent type to allow passing the baseFilter 17 | baseFilter, 18 | }) => { 19 | const baseFilterWhere: Where | undefined = 20 | typeof baseFilter === 'function' ? baseFilter({ req }) : undefined 21 | 22 | const response = await payload.count({ 23 | collection: collectionSlug, 24 | where: { 25 | and: [{ isRootPage: { equals: true } }, { ...baseFilterWhere }], 26 | }, 27 | }) 28 | 29 | const hasRootPage = response.totalDocs > 0 30 | 31 | // Determine if field should be readonly based on permissions 32 | const isReadOnly = readOnly || (permissions !== true && permissions?.update !== true) 33 | 34 | return ( 35 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /pages/src/components/server/ParentField.tsx: -------------------------------------------------------------------------------- 1 | import { RelationshipField } from '@payloadcms/ui' 2 | import { RelationshipFieldServerComponent } from 'payload' 3 | import { getPageCollectionConfigAttributes } from '../../utils/getPageCollectionConfigAttributes.js' 4 | 5 | /** 6 | * Parent field which sets the field to be read only if the collection has a shared parent document and the field has a value. 7 | */ 8 | export const ParentField: RelationshipFieldServerComponent = async ({ 9 | path, 10 | collectionSlug, 11 | payload, 12 | clientField, 13 | data, 14 | permissions, 15 | readOnly, 16 | }) => { 17 | const { 18 | parent: { name: parentField, sharedDocument: sharedParentDocument }, 19 | } = getPageCollectionConfigAttributes({ 20 | collectionSlug, 21 | payload, 22 | }) 23 | 24 | var parentValue: string | undefined = data?.[parentField] ?? undefined 25 | // Check both shared parent document and permissions 26 | var isReadOnly = 27 | Boolean(sharedParentDocument && parentValue) || 28 | readOnly || 29 | (permissions !== true && permissions?.update !== true) 30 | 31 | return 32 | } 33 | -------------------------------------------------------------------------------- /pages/src/components/server/SlugFieldWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { TextFieldServerComponent } from 'payload' 2 | import SlugField from '../client/SlugField.js' 3 | 4 | /** 5 | * Server component wrapper for SlugField that handles access-aware readOnly state. 6 | */ 7 | export const SlugFieldWrapper: TextFieldServerComponent = async ({ 8 | clientField, 9 | path, 10 | permissions, 11 | readOnly, 12 | }) => { 13 | const isReadOnly = readOnly || (permissions !== true && permissions?.update !== true) 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /pages/src/exports/client.ts: -------------------------------------------------------------------------------- 1 | export { usePageCollectionConfigAttributes } from '../components/client/hooks/usePageCollectionConfigAtrributes.js' 2 | export { PathField } from '../components/client/PathField.js' 3 | export { PreviewButtonField } from '../components/client/PreviewButtonField.js' 4 | export { SlugField } from '../components/client/SlugField.js' 5 | export { BreadcrumbsField } from '../components/client/BreadcrumbsField.js' 6 | -------------------------------------------------------------------------------- /pages/src/exports/server.ts: -------------------------------------------------------------------------------- 1 | export { IsRootPageField } from '../components/server/IsRootPageField.js' 2 | export { ParentField } from '../components/server/ParentField.js' 3 | export { SlugFieldWrapper } from '../components/server/SlugFieldWrapper.js' 4 | -------------------------------------------------------------------------------- /pages/src/fields/alternatePathsField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | import { translatedLabel } from '../utils/translatedLabel.js' 3 | 4 | /** Virtual field which holds the paths for the alternate languages. */ 5 | export function alternatePathsField(): Field { 6 | return { 7 | name: 'alternatePaths', 8 | label: translatedLabel('alternatePaths'), 9 | labels: { 10 | singular: translatedLabel('alternatePath'), 11 | plural: translatedLabel('alternatePaths'), 12 | }, 13 | type: 'array', 14 | required: true, 15 | localized: false, 16 | virtual: true, 17 | // Validate by default to allow the document to be updated, without having to set the alternatePaths field. 18 | validate: (_: any): true => true, 19 | admin: { 20 | readOnly: true, 21 | hidden: true, 22 | }, 23 | hooks: { 24 | afterRead: [ 25 | // The alternate paths are generated in the setVirtualFields collection hook 26 | ], 27 | }, 28 | fields: [ 29 | { 30 | name: 'hreflang', 31 | type: 'text', 32 | required: true, 33 | }, 34 | { 35 | name: 'path', 36 | type: 'text', 37 | required: true, 38 | }, 39 | ], 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pages/src/fields/isRootPageField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | import { translatedLabel } from '../utils/translatedLabel.js' 3 | import { beforeDuplicateIsRootPage } from '../hooks/beforeDuplicate.js' 4 | import { PagesPluginConfig } from '../types/PagesPluginConfig.js' 5 | 6 | export function isRootPageField({ 7 | baseFilter, 8 | }: { 9 | baseFilter: PagesPluginConfig['baseFilter'] 10 | }): Field { 11 | return { 12 | name: 'isRootPage', 13 | label: translatedLabel('isRootPage'), 14 | type: 'checkbox', 15 | admin: { 16 | position: 'sidebar', 17 | components: { 18 | Field: { 19 | path: '@jhb.software/payload-pages-plugin/server#IsRootPageField', 20 | serverProps: { 21 | baseFilter, 22 | }, 23 | }, 24 | }, 25 | }, 26 | hooks: { 27 | beforeDuplicate: [beforeDuplicateIsRootPage], 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/src/fields/pathField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | import { translatedLabel } from '../utils/translatedLabel.js' 3 | 4 | /** 5 | * Creates a virtual path field that generates the path based on the parents' slugs, the document's slug and the locale. 6 | * 7 | * It is not stored in the database, because this would not automatically reflect changes in the parent(s) slug(s). 8 | */ 9 | export function pathField(): Field { 10 | return { 11 | name: 'path', 12 | label: translatedLabel('path'), 13 | type: 'text', 14 | required: true, 15 | virtual: true, 16 | // Validate by default to allow the document to be updated, without having to set the path field. 17 | validate: (_: any): true => true, 18 | localized: true, 19 | admin: { 20 | readOnly: true, 21 | position: 'sidebar', 22 | components: { 23 | Field: '@jhb.software/payload-pages-plugin/client#PathField', 24 | }, 25 | }, 26 | hooks: { 27 | afterRead: [ 28 | // The path is generated in the getVirtualFields collection hook 29 | ], 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pages/src/hooks/beforeDuplicate.ts: -------------------------------------------------------------------------------- 1 | import { FieldHook } from 'payload' 2 | import { ROOT_PAGE_SLUG } from '../utils/setRootPageVirtualFields.js' 3 | 4 | /** Hooks which adjusts the slug to make sure the slug is still unique after duplication. */ 5 | export const beforeDuplicateSlug: FieldHook = ({ value }) => { 6 | if (value === ROOT_PAGE_SLUG) { 7 | return 'root-page-copy' 8 | } 9 | 10 | return value && typeof value === 'string' ? value + '-copy' : value 11 | } 12 | 13 | /** Hooks which adjusts the title to indicate this is a copy. */ 14 | export const beforeDuplicateTitle: FieldHook = ({ value }) => { 15 | return value && typeof value === 'string' ? value + ' (copy)' : value 16 | } 17 | 18 | /** Hook which ensures that if the root page is duplicated, the new page has not set isRootPage to true. */ 19 | export const beforeDuplicateIsRootPage: FieldHook = ({ value }) => { 20 | return typeof value === 'boolean' && value === true ? false : value 21 | } 22 | -------------------------------------------------------------------------------- /pages/src/hooks/deleteUnselectedFieldsAfterRead.ts: -------------------------------------------------------------------------------- 1 | import { CollectionAfterReadHook, SelectType } from 'payload' 2 | import { getSelectMode } from 'payload/shared' 3 | 4 | /** 5 | * A CollectionAfterReadHook that deletes fields from the document that are not in the original select. 6 | * 7 | * This is necessary because during the read operation, the original select is extended with all fields 8 | * that are required for the setVirtualFields hook to work. 9 | */ 10 | export const deleteUnselectedFieldsAfterRead: CollectionAfterReadHook = ({ 11 | doc, 12 | context, 13 | collection, 14 | }) => { 15 | const operation = `${doc.id}-${collection.slug}` 16 | // early return if this hook runs for nested operations (e.g. for population operations or field level hooks) 17 | if (operation !== context.rootOperation) { 18 | return doc 19 | } 20 | 21 | const originalSelect = context.originalSelect as SelectType | undefined 22 | const select = context.select as SelectType | undefined 23 | 24 | // if there is no original select, this means that the selection was not altered, therefore return early. 25 | if (!originalSelect) { 26 | return doc 27 | } 28 | 29 | const selectMode = getSelectMode(originalSelect) 30 | 31 | if (selectMode === 'include') { 32 | // remove all fields that are not in the original select (except id) 33 | Object.keys(doc).forEach((field) => { 34 | if (!originalSelect[field] && field !== 'id') { 35 | delete doc[field] 36 | } 37 | }) 38 | } else if (selectMode === 'exclude') { 39 | // remove all fields that were added to the select (present in originalSelect but not in select) 40 | const addedFields = Object.keys(originalSelect || {}).filter( 41 | (field) => !select?.[field] && field !== 'id', 42 | ) 43 | 44 | addedFields.forEach((field) => { 45 | delete doc[field] 46 | }) 47 | } 48 | 49 | // reset the context to prevent the context from being used in other operations 50 | context.originalSelect = undefined 51 | context.select = undefined 52 | context.generateVirtualFields = undefined 53 | context.rootOperation = undefined 54 | 55 | return doc 56 | } 57 | -------------------------------------------------------------------------------- /pages/src/hooks/preventParentDeletion.ts: -------------------------------------------------------------------------------- 1 | import { CollectionBeforeDeleteHook } from 'payload' 2 | import { PagesPluginConfig } from '../types/PagesPluginConfig.js' 3 | import { childDocumentsOf } from '../utils/childDocumentsOf.js' 4 | import { AdminPanelError } from '../utils/AdminPanelError.js' 5 | 6 | const ADAPTERS_REQUIRING_CUSTOM_LOGIC = [ 7 | '@payloadcms/db-mongodb', 8 | '@payloadcms/db-sqlite', 9 | '@payloadcms/db-postgres', 10 | ] 11 | 12 | export const preventParentDeletion: CollectionBeforeDeleteHook = async ({ 13 | req, 14 | id, 15 | collection, 16 | }) => { 17 | const databaseAdapter = req.payload.db.packageName || req.payload.db.name 18 | if (!ADAPTERS_REQUIRING_CUSTOM_LOGIC.includes(databaseAdapter)) { 19 | return 20 | } 21 | 22 | const pagesPluginConfig = collection.custom?.pagesPluginConfig as PagesPluginConfig 23 | 24 | const childDocuments = await childDocumentsOf( 25 | req, 26 | id, 27 | collection.slug, 28 | pagesPluginConfig?.baseFilter, 29 | ) 30 | 31 | if (childDocuments.length > 0) { 32 | const childrenByCollection = childDocuments.reduce( 33 | (acc, child) => { 34 | if (!acc[child.collection]) { 35 | acc[child.collection] = [] 36 | } 37 | acc[child.collection].push(child.id) 38 | return acc 39 | }, 40 | {} as Record, 41 | ) 42 | 43 | const collectionMessages = Object.entries(childrenByCollection) 44 | .map( 45 | ([collectionSlug, ids]) => 46 | `${ids.length} document(s) in the "${collectionSlug}" collection`, 47 | ) 48 | .join(', ') 49 | 50 | const errorMessage = `Cannot delete this document because it is referenced as a parent by ${collectionMessages}. Please remove or reassign the child documents before deleting this parent document.` 51 | 52 | throw new AdminPanelError(errorMessage) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pages/src/hooks/validateSlug.ts: -------------------------------------------------------------------------------- 1 | import type { FieldHook } from 'payload' 2 | import { ROOT_PAGE_SLUG } from '../utils/setRootPageVirtualFields.js' 3 | 4 | const germanCharacterReplacements: Record = { 5 | ä: 'ae', 6 | ö: 'oe', 7 | ü: 'ue', 8 | ß: 'ss', 9 | } 10 | 11 | export const formatSlug = (val: string): string => 12 | val 13 | .toString() // Cast to string (optional) 14 | .toLowerCase() // Convert the string to lowercase letters 15 | .replace(/[äöüß]/g, (match) => germanCharacterReplacements[match]) 16 | .normalize('NFKD') // The normalize() using NFKD method returns the Unicode Normalization Form of a given string. 17 | .trim() // Remove whitespace from both sides of a string 18 | .replace(/\s+/g, '-') // Replace spaces with hyphen 19 | .replace(/[^\w\-]+/g, '') // Remove all non-word chars 20 | .replace(/\-\-+/g, '-') // Replace multiple hyphens with single hyphen 21 | .replace(/^\-+|\-+$/g, '') // Trim hyphens from start and end 22 | 23 | /** 24 | * Same as formatSlug, except that it does not remove hyphens from the end of the string. Also converts spaces at the end of the string to hyphens. 25 | * 26 | * This is used to format a slug while typing. 27 | */ 28 | export const liveFormatSlug = (val: string): string => 29 | val 30 | .toString() // Cast to string (optional) 31 | .toLowerCase() // Convert the string to lowercase letters 32 | .replace(/[äöüß]/g, (match) => germanCharacterReplacements[match]) 33 | .normalize('NFKD') // The normalize() using NFKD method returns the Unicode Normalization Form of a given string. 34 | .replace(/\s+/g, '-') // Replace spaces with hyphen 35 | .replace(/[^\w\-]+/g, '') // Remove all non-word chars 36 | .replace(/\-\-+/g, '-') // Replace multiple hyphens with single hyphen 37 | .replace(/^-+/, '') // Trim hyphens from start 38 | // place trim at the end to replace inner whitespaces with hyphens: 39 | .trim() // Remove whitespace from both sides of a string 40 | -------------------------------------------------------------------------------- /pages/src/index.ts: -------------------------------------------------------------------------------- 1 | export { alternatePathsField } from './fields/alternatePathsField.js' 2 | export { slugField } from './fields/slugField.js' 3 | export { payloadPagesPlugin } from './plugin.js' 4 | export type { IncomingPageCollectionConfig as PageCollectionConfig } from './types/PageCollectionConfig.js' 5 | export type { IncomingRedirectsCollectionConfig as RedirectsCollectionConfig } from './types/RedirectsCollectionConfig.js' 6 | export type { 7 | PageCollectionConfigAttributes, 8 | IncomingPageCollectionConfigAttributes as PageCollectionIncomingConfigAttributes, 9 | } from './types/PageCollectionConfigAttributes.js' 10 | export type { 11 | RedirectsCollectionConfigAttributes, 12 | IncomingRedirectsCollectionConfigAttributes as RedirectsCollectionIncomingConfigAttributes, 13 | } from './types/RedirectsCollectionConfigAttributes.js' 14 | export type { PagesPluginConfig } from './types/PagesPluginConfig.js' 15 | export { childDocumentsOf, hasChildDocuments } from './utils/childDocumentsOf.js' 16 | -------------------------------------------------------------------------------- /pages/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | import type { PagesPluginConfig } from './types/PagesPluginConfig.js' 3 | import { translations } from './translations/index.js' 4 | import { deepMergeSimple } from './utils/deepMergeSimple.js' 5 | import { createPageCollectionConfig } from './collections/PageCollectionConfig.js' 6 | import { IncomingPageCollectionConfig } from './types/PageCollectionConfig.js' 7 | import { createRedirectsCollectionConfig } from './collections/RedirectsCollectionConfig.js' 8 | import { IncomingRedirectsCollectionConfig } from './types/RedirectsCollectionConfig.js' 9 | 10 | /** Payload plugin which integrates fields for managing website pages. */ 11 | export const payloadPagesPlugin = 12 | (pluginOptions: PagesPluginConfig) => 13 | (incomingConfig: Config): Config => { 14 | const config = { ...incomingConfig } 15 | 16 | // If the plugin is disabled, return the config without modifying it 17 | if (pluginOptions.enabled === false) { 18 | return config 19 | } 20 | 21 | config.onInit = async (payload) => { 22 | if (incomingConfig.onInit) { 23 | await incomingConfig.onInit(payload) 24 | } 25 | } 26 | 27 | // Ensure collections array exists 28 | config.collections = config.collections || [] 29 | 30 | // Find and transform collections 31 | config.collections = config.collections.map((collection) => { 32 | if ('page' in collection) { 33 | // Create page collection using the page configuration 34 | return createPageCollectionConfig({ 35 | collectionConfig: collection as IncomingPageCollectionConfig, 36 | pluginConfig: pluginOptions, 37 | }) 38 | } else if ('redirects' in collection) { 39 | // Create redirects collection using the redirects configuration 40 | return createRedirectsCollectionConfig({ 41 | collectionConfig: collection as IncomingRedirectsCollectionConfig, 42 | pluginConfig: pluginOptions, 43 | }) 44 | } 45 | return collection 46 | }) 47 | 48 | return { 49 | ...config, 50 | i18n: { 51 | ...config.i18n, 52 | translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}), 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/src/translations/de.ts: -------------------------------------------------------------------------------- 1 | import { GenericTranslationsObject } from './index.js' 2 | 3 | export const de: GenericTranslationsObject = { 4 | $schema: './translation-schema.json', 5 | '@jhb.software/payload-pages-plugin': { 6 | path: 'Pfad', 7 | label: 'Beschriftung', 8 | slug: 'URL-Endung', 9 | parent: 'Übergeordnete Seite', 10 | rootPage: 'Startseite', 11 | isRootPage: 'ist Startseite', 12 | alternatePaths: 'Alternative Pfade', 13 | alternatePath: 'Alternative Pfad', 14 | breadcrumbs: 'Navigationspfade', 15 | breadcrumb: 'Navigationspfad', 16 | saveDocumentToPreview: 'Speichere das Dokument um die Vorschau zu sehen', 17 | openWebsitePageInPreviewMode: 'Seite der Website im Vorschau-Modus öffnen', 18 | syncSlugWithX: 'Mit {X} synchronisieren', 19 | slugWasChangedFromXToY: 20 | 'Die URL-Endung wurde von {X} zu {Y} geändert. Dies erfordert eine manuelle Erstellung einer Umleitung vom alten zum neuen Seitenpfad.', 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /pages/src/translations/en.ts: -------------------------------------------------------------------------------- 1 | import { GenericTranslationsObject } from './index.js' 2 | 3 | export const en: GenericTranslationsObject = { 4 | $schema: './translation-schema.json', 5 | '@jhb.software/payload-pages-plugin': { 6 | path: 'Path', 7 | label: 'Label', 8 | slug: 'Slug', 9 | parent: 'Parent Page', 10 | rootPage: 'Root Page', 11 | isRootPage: 'is Root Page', 12 | alternatePaths: 'Alternate Paths', 13 | alternatePath: 'Alternate Path', 14 | breadcrumbs: 'Breadcrumbs', 15 | breadcrumb: 'Breadcrumb', 16 | saveDocumentToPreview: 'Save the document to preview the changes', 17 | openWebsitePageInPreviewMode: 'Open Website Page in preview mode', 18 | syncSlugWithX: 'Sync with {X}', 19 | slugWasChangedFromXToY: 20 | 'The slug was changed from {X} to {Y}. This requires a redirection from the old to the new page path to be manually created.', 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /pages/src/translations/index.ts: -------------------------------------------------------------------------------- 1 | import { de } from './de.js' 2 | import { en } from './en.js' 3 | 4 | // copied from https://github.com/payloadcms/payload/blob/main/packages/translations/src/types.ts 5 | export type GenericTranslationsObject = { 6 | [key: string]: GenericTranslationsObject | string 7 | } 8 | 9 | // copied from https://github.com/payloadcms/payload/blob/main/packages/translations/src/types.ts 10 | export type NestedKeysStripped = T extends object 11 | ? { 12 | [K in keyof T]-?: K extends string 13 | ? T[K] extends object 14 | ? `${K}:${NestedKeysStripped}` 15 | : `${StripCountVariants}` 16 | : never 17 | }[keyof T] 18 | : '' 19 | 20 | // copied from https://github.com/payloadcms/payload/blob/main/packages/translations/src/types.ts 21 | export type StripCountVariants = TKey extends 22 | | `${infer Base}_many` 23 | | `${infer Base}_one` 24 | | `${infer Base}_other` 25 | ? Base 26 | : TKey 27 | 28 | export const translations = { 29 | de, 30 | en, 31 | } 32 | 33 | export type PluginPagesTranslations = GenericTranslationsObject 34 | 35 | export type PluginPagesTranslationKeys = NestedKeysStripped 36 | -------------------------------------------------------------------------------- /pages/src/translations/translation-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "additionalProperties": false, 5 | "properties": { 6 | "$schema": { 7 | "type": "string" 8 | }, 9 | "@jhb.software/payload-pages-plugin": { 10 | "type": "object", 11 | "additionalProperties": false, 12 | "properties": { 13 | "path": { 14 | "type": "string" 15 | }, 16 | "label": { 17 | "type": "string" 18 | }, 19 | "slug": { 20 | "type": "string" 21 | }, 22 | "parent": { 23 | "type": "string" 24 | }, 25 | "rootPage": { 26 | "type": "string" 27 | }, 28 | "isRootPage": { 29 | "type": "string" 30 | }, 31 | "alternatePaths": { 32 | "type": "string" 33 | }, 34 | "alternatePath": { 35 | "type": "string" 36 | }, 37 | "breadcrumbs": { 38 | "type": "string" 39 | }, 40 | "breadcrumb": { 41 | "type": "string" 42 | }, 43 | "saveDocumentToPreview": { 44 | "type": "string" 45 | }, 46 | "openWebsitePageInPreviewMode": { 47 | "type": "string" 48 | }, 49 | "syncSlugWithX": { 50 | "type": "string" 51 | }, 52 | "slugWasChangedFromXToY": { 53 | "type": "string" 54 | } 55 | }, 56 | "required": [ 57 | "path", 58 | "label", 59 | "slug", 60 | "parent", 61 | "rootPage", 62 | "isRootPage", 63 | "alternatePaths", 64 | "alternatePath", 65 | "breadcrumbs", 66 | "breadcrumb", 67 | "saveDocumentToPreview", 68 | "openWebsitePageInPreviewMode", 69 | "syncSlugWithX", 70 | "slugWasChangedFromXToY" 71 | ] 72 | } 73 | }, 74 | "required": ["@jhb.software/payload-pages-plugin"] 75 | } -------------------------------------------------------------------------------- /pages/src/types/Breadcrumb.ts: -------------------------------------------------------------------------------- 1 | /** Field of a page which contain all information about a breadcrumb. */ 2 | export type Breadcrumb = { 3 | path: string 4 | slug: string 5 | label: string 6 | } 7 | -------------------------------------------------------------------------------- /pages/src/types/Locale.ts: -------------------------------------------------------------------------------- 1 | /** All locales that are configured in the payload config for this project. */ 2 | export type Locale = string 3 | -------------------------------------------------------------------------------- /pages/src/types/PageCollectionConfig.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | import { 3 | IncomingPageCollectionConfigAttributes, 4 | PageCollectionConfigAttributes, 5 | } from './PageCollectionConfigAttributes.js' 6 | 7 | /** The plugins incoming config for page collections. */ 8 | export type IncomingPageCollectionConfig = CollectionConfig & { 9 | page: IncomingPageCollectionConfigAttributes 10 | } 11 | 12 | /** A collection config with additional attributes for page collections after they have been processed. */ 13 | export type PageCollectionConfig = CollectionConfig & { 14 | page: PageCollectionConfigAttributes 15 | } 16 | -------------------------------------------------------------------------------- /pages/src/types/PagesPluginConfig.ts: -------------------------------------------------------------------------------- 1 | import { PayloadRequest, Where } from 'payload' 2 | 3 | /** Configuration options for the pages plugin. */ 4 | export type PagesPluginConfig = { 5 | /** Whether the pages plugin is enabled. */ 6 | enabled?: boolean 7 | 8 | /** 9 | * The base filter to apply to find queries which are executed by the pages plugin internally. 10 | * 11 | * This is useful for multi-tenant setups where you want to restrict the pages plugin to only 12 | * operate on pages which belong to the current tenant. 13 | */ 14 | baseFilter?: (args: { req: PayloadRequest }) => Where 15 | 16 | /** 17 | * The filter to apply to find queries when validating redirects. 18 | * 19 | * This is useful for multi-tenant setups where you want to restrict the redirects plugin to 20 | * account for redirects in the same tenant while validating redirects on create/update. 21 | */ 22 | redirectValidationFilter?: (args: { req: PayloadRequest; doc: any }) => Where 23 | 24 | /** 25 | * Whether to prevent deletion of parent documents that have child documents referencing them. 26 | * 27 | * When enabled (default), the plugin will check for child documents before allowing deletion 28 | * of a parent document. This protection is only applied for MongoDB, SQLite, and PostgreSQL 29 | * database adapters that don't enforce foreign key constraints natively. 30 | * 31 | * Set to false to disable this protection and allow deletion of parent documents regardless 32 | * of existing child references. 33 | * 34 | * @default true 35 | */ 36 | preventParentDeletion?: boolean 37 | 38 | /** 39 | * Function to generate the full URL to a frontend page. 40 | * 41 | * @param args - The arguments for URL generation 42 | * @param args.path - The path to the page (always starts with '/') 43 | * @param args.preview - Whether this is a preview URL 44 | * @returns The full URL to the frontend page 45 | * 46 | * @example 47 | * ```ts 48 | * generatePageURL: ({ path, preview }) => 49 | * `${process.env.NEXT_PUBLIC_FRONTEND_URL}${preview ? '/preview' : ''}${path}` 50 | * ``` 51 | */ 52 | generatePageURL: (args: { path: string; preview: boolean }) => string 53 | } 54 | -------------------------------------------------------------------------------- /pages/src/types/RedirectsCollectionConfig.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | import { 3 | IncomingRedirectsCollectionConfigAttributes, 4 | RedirectsCollectionConfigAttributes, 5 | } from './RedirectsCollectionConfigAttributes.js' 6 | 7 | /** The plugins incoming config for page collections. */ 8 | export type IncomingRedirectsCollectionConfig = CollectionConfig & { 9 | redirects: IncomingRedirectsCollectionConfigAttributes 10 | } 11 | 12 | /** A collection config with additional attributes for page collections after they have been processed. */ 13 | export type RedirectsCollectionConfig = CollectionConfig & { 14 | redirects: RedirectsCollectionConfigAttributes 15 | } 16 | -------------------------------------------------------------------------------- /pages/src/types/RedirectsCollectionConfigAttributes.ts: -------------------------------------------------------------------------------- 1 | /** The incoming attributes for the redirects collection config. */ 2 | export type IncomingRedirectsCollectionConfigAttributes = {} 3 | 4 | /** The attributes for the redirects collection config after they have been processed using the incoming config attributes. */ 5 | export type RedirectsCollectionConfigAttributes = {} 6 | -------------------------------------------------------------------------------- /pages/src/types/SeoMetadata.ts: -------------------------------------------------------------------------------- 1 | /** The SEO Metadata fields that are created by the plugin. */ 2 | export interface SeoMetadata { 3 | [key: string]: any 4 | alternatePaths: { 5 | hreflang: string 6 | path: string 7 | id?: string | null 8 | }[] 9 | } 10 | -------------------------------------------------------------------------------- /pages/src/utils/AdminPanelError.ts: -------------------------------------------------------------------------------- 1 | import { APIError } from 'payload' 2 | 3 | /** Class which extends Payload's APIError and ensures that the error is shown in the admin panel. */ 4 | export class AdminPanelError extends APIError { 5 | constructor(message: string, statusCode: number = 400) { 6 | super( 7 | message, 8 | statusCode, 9 | undefined, 10 | // isPublic must be true in order for the error to show up in the admin panel 11 | true, 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pages/src/utils/deepMergeSimple.ts: -------------------------------------------------------------------------------- 1 | // copied from https://github.com/payloadcms/payload/blob/main/packages/translations/src/utilities/deepMergeSimple.ts 2 | 3 | /** 4 | * Very simple, but fast deepMerge implementation. Only deepMerges objects, not arrays and clones everything. 5 | * Do not use this if your object contains any complex objects like React Components, or if you would like to combine Arrays. 6 | * If you only have simple objects and need a fast deepMerge, this is the function for you. 7 | * 8 | * obj2 takes precedence over obj1 - thus if obj2 has a key that obj1 also has, obj2's value will be used. 9 | * 10 | * @param obj1 base object 11 | * @param obj2 object to merge "into" obj1 12 | */ 13 | export function deepMergeSimple(obj1: object, obj2: object): T { 14 | const output = { ...obj1 } 15 | 16 | for (const key in obj2) { 17 | if (Object.prototype.hasOwnProperty.call(obj2, key)) { 18 | // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve 19 | if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) { 20 | // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve 21 | output[key] = deepMergeSimple(obj1[key], obj2[key]) 22 | } else { 23 | // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve 24 | output[key] = obj2[key] 25 | } 26 | } 27 | } 28 | 29 | return output as T 30 | } 31 | -------------------------------------------------------------------------------- /pages/src/utils/fetchRestApi.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'qs-esm' 2 | 3 | /** Fetches a document via the Payload REST API. This should only be used if the local API is not available. */ 4 | export async function fetchRestApi(path: string, options: Record) { 5 | const response = await fetch('/api' + path + '?' + stringify(options), { 6 | method: 'GET', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | }, 10 | }) 11 | 12 | if (!response.ok) { 13 | throw new Error( 14 | `Failed to fetch the requested document via the Payload REST API. ${response.statusText}`, 15 | ) 16 | } 17 | 18 | return await response.json() 19 | } 20 | -------------------------------------------------------------------------------- /pages/src/utils/getPageCollectionConfigAttributes.ts: -------------------------------------------------------------------------------- 1 | import { asPageCollectionConfigOrThrow } from '../utils/pageCollectionConfigHelpers.js' 2 | import { BasePayload } from 'payload' 3 | import { PageCollectionConfigAttributes } from '../types/PageCollectionConfigAttributes.js' 4 | 5 | /** 6 | * Get the page config attributes for a collection. 7 | * 8 | * This is useful inside server components, where the `usePageCollectionConfigAttributes` hook is not available. 9 | */ 10 | export function getPageCollectionConfigAttributes({ 11 | collectionSlug, 12 | payload, 13 | }: { 14 | collectionSlug: string 15 | payload: BasePayload 16 | }): PageCollectionConfigAttributes { 17 | const collection = payload.collections[collectionSlug] 18 | const pageConfig = asPageCollectionConfigOrThrow(collection.config) 19 | 20 | return pageConfig.page 21 | } 22 | -------------------------------------------------------------------------------- /pages/src/utils/localeFromRequest.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from '../types/Locale.js' 2 | import { PayloadRequest } from 'payload' 3 | 4 | /** Returns the locale string (or undefined) from the PayloadRequest. */ 5 | export function localeFromRequest(req: PayloadRequest): Locale | 'all' | undefined { 6 | // When using the REST API, the locale query param can be set to undefined, in this case it is a string 'undefined' 7 | // In this case, convert it to an undefined value 8 | if (typeof req.locale === 'string' && req.locale === 'undefined') { 9 | return undefined 10 | } 11 | 12 | return req.locale as Locale | 'all' | undefined 13 | } 14 | 15 | /** Returns the locales from the request. */ 16 | export function localesFromRequest(req: PayloadRequest): Locale[] | undefined { 17 | if (typeof req?.payload.config.localization === 'object' && req?.payload.config.localization) { 18 | return req.payload.config.localization.localeCodes 19 | } else { 20 | return undefined 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pages/src/utils/pageCollectionConfigHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { ClientCollectionConfig, CollectionConfig } from 'payload' 2 | import type { PageCollectionConfig } from 'src/types/PageCollectionConfig.js' 3 | 4 | /** Checks if the config is a PageCollectionConfig. */ 5 | export const isPageCollectionConfig = ( 6 | config: CollectionConfig | ClientCollectionConfig, 7 | ): config is PageCollectionConfig => { 8 | if (!config) { 9 | console.error('config is not defined') 10 | return false 11 | } 12 | 13 | return 'page' in config && typeof config.page === 'object' 14 | } 15 | 16 | /** 17 | * Returns the PageCollectionConfig or null if the config is not a PageCollectionConfig. 18 | * 19 | * This provides type-safe access to the page attributes. 20 | */ 21 | export const asPageCollectionConfig = ( 22 | config: CollectionConfig | ClientCollectionConfig, 23 | ): PageCollectionConfig | null => { 24 | if (isPageCollectionConfig(config)) { 25 | return config 26 | } 27 | return null 28 | } 29 | 30 | /** 31 | * Returns the PageCollectionConfig or throws an error if the config is not a PageCollectionConfig. 32 | * 33 | * This provides type-safe access to the page attributes. 34 | */ 35 | export const asPageCollectionConfigOrThrow = ( 36 | config: CollectionConfig | ClientCollectionConfig, 37 | ): PageCollectionConfig => { 38 | if (isPageCollectionConfig(config)) { 39 | return config 40 | } 41 | 42 | throw new Error('Collection is not a page collection') 43 | } 44 | -------------------------------------------------------------------------------- /pages/src/utils/pathFromBreadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { Breadcrumb } from '../types/Breadcrumb.js' 2 | import { Locale } from '../types/Locale.js' 3 | 4 | /** Converts the given breadcrumbs and the locale to a path */ 5 | export function pathFromBreadcrumbs({ 6 | locale, 7 | breadcrumbs, 8 | additionalSlug, 9 | }: { 10 | locale: Locale | undefined 11 | breadcrumbs: Breadcrumb[] 12 | additionalSlug?: string 13 | }): string { 14 | return [ 15 | locale ? `/${locale}` : '', 16 | ...[...breadcrumbs.map(({ slug }) => slug), additionalSlug].filter(Boolean), 17 | ].join('/') 18 | } 19 | -------------------------------------------------------------------------------- /pages/src/utils/translatedLabel.ts: -------------------------------------------------------------------------------- 1 | import { StaticLabel } from 'payload' 2 | import { translations } from '../translations/index.js' 3 | 4 | /** Returns the StaticLabel object for the given translation to to use inside the field label. */ 5 | export function translatedLabel(key: string): StaticLabel { 6 | return Object.fromEntries( 7 | Object.entries(translations).map(([locale, translation]) => [ 8 | locale, 9 | (translation['@jhb.software/payload-pages-plugin'] as Record)[key] || key, 10 | ]), 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /pages/src/utils/useDidUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | /** 4 | * A hook similar to useEffect but skips the first execution on the initial mount. 5 | */ 6 | export function useDidUpdateEffect(fn: () => void, inputs: any[]) { 7 | const isMountingRef = useRef(false) 8 | 9 | useEffect(() => { 10 | isMountingRef.current = true 11 | }, []) 12 | 13 | useEffect(() => { 14 | if (!isMountingRef.current) { 15 | return fn() 16 | } else { 17 | isMountingRef.current = false 18 | } 19 | }, inputs) 20 | } 21 | -------------------------------------------------------------------------------- /pages/src/utils/usePluginTranslations.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation } from '@payloadcms/ui' 2 | import { PluginPagesTranslationKeys } from 'src/translations/index.js' 3 | import { PluginPagesTranslations } from 'src/translations/index.js' 4 | 5 | /** Hook which returns a translation function for the plugin translations. */ 6 | export const usePluginTranslation = () => { 7 | const { i18n } = useTranslation() 8 | const pluginTranslations = i18n.translations[ 9 | '@jhb.software/payload-pages-plugin' 10 | ] as PluginPagesTranslations 11 | 12 | return { 13 | t: (key: PluginPagesTranslationKeys) => { 14 | const translation = pluginTranslations[key] as string 15 | 16 | if (!translation) { 17 | console.log('Plugin translation not found', key) 18 | } 19 | return translation ?? key 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pages/src/utils/validateBreadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { Breadcrumb } from '../types/Breadcrumb.js' 2 | import { Locale } from '../types/Locale.js' 3 | import { pathFromBreadcrumbs } from './pathFromBreadcrumbs.js' 4 | 5 | /** 6 | * Validates the breadcrumbs and throws an error if invalid. 7 | */ 8 | export function validateBreadcrumbs(locale: Locale | undefined, breadcrumbs: Breadcrumb[]) { 9 | if (!breadcrumbs) { 10 | throw new Error(locale ? 'No breadcrumbs found for locale ' + locale : 'No breadcrumbs found') 11 | } 12 | 13 | if (breadcrumbs.length === 0) { 14 | throw new Error( 15 | locale ? 'Empty breadcrumbs found for locale ' + locale : 'Empty breadcrumbs found', 16 | ) 17 | } 18 | 19 | if ( 20 | pathFromBreadcrumbs({ locale: locale, breadcrumbs: breadcrumbs }) !== breadcrumbs.at(-1)?.path 21 | ) { 22 | throw new Error( 23 | `Path generated from breadcrumbs (${pathFromBreadcrumbs({ 24 | locale: locale, 25 | breadcrumbs: breadcrumbs, 26 | })}) is not equal to the path of the last breadcrumb: ${breadcrumbs.at(-1)?.path}`, 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pages/src/utils/validatePath.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from '../types/Locale.js' 2 | 3 | /** Validates the path and throws an error if invalid. */ 4 | export function validatePath(path: string, docId: string, locale: Locale | undefined) { 5 | // If the document has a parent which slug is not set for the requesting locale, the path of the document will be undefined. 6 | // Do not throw an error in this case, because then the document could not be edited in the admin UI. 7 | if (!path) { 8 | console.warn('Path for doc', docId, 'in locale', locale, 'undefined.') 9 | return 10 | } 11 | 12 | if ( 13 | path.includes('undefined') || 14 | path.includes('null') || 15 | path.includes('[object Object]') || 16 | !path.startsWith('/') || 17 | path.includes('//') || 18 | (path.length > 1 && path.endsWith('/')) 19 | ) { 20 | throw new Error('Path for doc ' + docId + ' in locale ' + locale + ' is not valid: ' + path) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "rootDir": "./", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "module": "NodeNext", 11 | "moduleResolution": "nodenext", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "emitDeclarationOnly": true, 17 | "target": "ES2022", 18 | "composite": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ] 24 | }, 25 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./dev/next-env.d.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: {} 10 | -------------------------------------------------------------------------------- /seo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | tsconfig.tsbuildinfo 4 | -------------------------------------------------------------------------------- /seo/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/seo/.npmignore -------------------------------------------------------------------------------- /seo/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "parser": "typescript", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "overrides": [ 8 | { 9 | "files": ["**/importMap.js", "**/payload-types.ts", "**/**.yaml"], 10 | "options": { 11 | "requirePragma": true 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /seo/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 JHB Software 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 | -------------------------------------------------------------------------------- /seo/README.md: -------------------------------------------------------------------------------- 1 | # JHB Software - Payload SEO Plugin 2 | 3 | [](https://www.npmjs.com/package/@jhb.software/payload-seo-plugin) 4 | 5 | Extends the official [SEO plugin](https://payloadcms.com/docs/plugins/seo) with additional features: 6 | 7 | - AI-powered meta description generation for compelling search results 8 | - Focus keyword tracking with intelligent warnings when keywords are missing from crucial elements (title, description, content) 9 | - Multi-keyword support with content usage analytics 10 | 11 | ## Setup 12 | 13 | Add the plugin to your payload config as follows: 14 | 15 | ```ts 16 | plugins: [ 17 | payloadSeoPlugin({ 18 | // ### Options which are passed to the official seo plugin ### 19 | collections: ['pages', 'projects'], 20 | 21 | // ### Options of this seo plugin ### 22 | websiteContext: { 23 | topic: 'A website of a software developer for mobile, web-apps and websites.', 24 | }, 25 | documentContentTransformers: { 26 | pages: async (doc, lexicalToPlainText) => ({ 27 | title: doc.title, 28 | body: (await lexicalToPlainText(doc.body)) ?? '', 29 | }), 30 | projects: async (doc, lexicalToPlainText) => ({ 31 | title: doc.title, 32 | excerpt: doc.excerpt, 33 | tags: doc.tags?.join(', '), 34 | body: (await lexicalToPlainText(doc.body)) ?? '', 35 | }), 36 | }, 37 | }), 38 | ], 39 | ``` 40 | 41 | Because this plugin extends the official SEO plugin, you can pass all config options of the official SEO plugin to this plugin. 42 | 43 | ### Config options 44 | 45 | For information about the config options of this plugin, see the [SeoPluginConfig](https://github.com/jhb-software/payload-plugins/blob/main/seo/src/types/SeoPluginConfig.ts) type. 46 | 47 | ### Environment variables 48 | 49 | The following environment variables are required: 50 | 51 | - `OPENAI_API_KEY` 52 | -------------------------------------------------------------------------------- /seo/dev/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | /media 4 | node_modules 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /seo/dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /seo/dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // Your Next.js config here 6 | 7 | // Allow for ESM .js import statements 8 | webpack: (webpackConfig) => { 9 | webpackConfig.resolve.extensionAlias = { 10 | '.cjs': ['.cts', '.cjs'], 11 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 12 | '.mjs': ['.mts', '.mjs'], 13 | } 14 | 15 | return webpackConfig 16 | }, 17 | 18 | redirects: () => [ 19 | { 20 | source: '/', 21 | destination: '/admin', 22 | permanent: true, 23 | }, 24 | ], 25 | } 26 | 27 | export default withPayload(nextConfig) 28 | -------------------------------------------------------------------------------- /seo/dev/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nodemon.json", 3 | "ext": "ts", 4 | "exec": "ts-node src/server.ts -- -I", 5 | "stdin": false 6 | } -------------------------------------------------------------------------------- /seo/dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-test-app", 3 | "description": "A test app for the plugin", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "cross-env NODE_OPTIONS=\"${NODE_OPTIONS} --no-deprecation\" next dev", 9 | "devturbo": "pnpm dev --turbo", 10 | "devsafe": "rm -rf .next && pnpm dev", 11 | "build": "cross-env NODE_OPTIONS=--no-deprecation next build", 12 | "start": "cross-env NODE_OPTIONS=--no-deprecation next start", 13 | "format": "prettier --write src", 14 | "payload": "payload", 15 | "generate:types": "payload generate:types", 16 | "generate:schema": "payload-graphql generate:schema", 17 | "generate:importmap": "payload generate:importmap" 18 | }, 19 | "dependencies": { 20 | "@jhb.software/payload-seo-plugin": "workspace:*", 21 | "@payloadcms/db-mongodb": "^3.5.0", 22 | "@payloadcms/next": "^3.5.0", 23 | "@payloadcms/ui": "^3.5.0", 24 | "next": "15.0.0", 25 | "payload": "^3.5.0", 26 | "react": "19.0.0", 27 | "react-dom": "19.0.0" 28 | }, 29 | "devDependencies": { 30 | "copyfiles": "^2.4.1", 31 | "cross-env": "^7.0.3", 32 | "dotenv": "^16.4.7", 33 | "typescript": "5.7.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /seo/dev/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | import mongoose from 'mongoose' 3 | import payload from 'payload' 4 | import { start } from './src/server' 5 | 6 | describe('Plugin tests', () => { 7 | let server: Server 8 | 9 | beforeAll(async () => { 10 | await start({ local: true }) 11 | }) 12 | 13 | afterAll(async () => { 14 | await mongoose.connection.dropDatabase() 15 | await mongoose.connection.close() 16 | server.close() 17 | }) 18 | 19 | // Add tests to ensure that the plugin works as expected 20 | 21 | // Example test to check for seeded data 22 | it('seeds data accordingly', async () => { 23 | const newCollectionQuery = await payload.find({ 24 | collection: 'new-collection', 25 | sort: 'createdAt', 26 | }) 27 | 28 | expect(newCollectionQuery.totalDocs).toEqual(1) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap.js' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { KeywordsFieldLabel as KeywordsFieldLabel_ee88c3772cf709a4f300fbed1eddb78c } from '@jhb.software/payload-seo-plugin/client' 2 | import { KeywordsFieldRowLabel as KeywordsFieldRowLabel_ee88c3772cf709a4f300fbed1eddb78c } from '@jhb.software/payload-seo-plugin/client' 3 | import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' 4 | import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' 5 | import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' 6 | import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' 7 | 8 | export const importMap = { 9 | "@jhb.software/payload-seo-plugin/client#KeywordsFieldLabel": KeywordsFieldLabel_ee88c3772cf709a4f300fbed1eddb78c, 10 | "@jhb.software/payload-seo-plugin/client#KeywordsFieldRowLabel": KeywordsFieldRowLabel_ee88c3772cf709a4f300fbed1eddb78c, 11 | "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, 12 | "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, 13 | "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, 14 | "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 15 | } 16 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' 5 | 6 | export const GET = REST_GET(config) 7 | export const POST = REST_POST(config) 8 | export const DELETE = REST_DELETE(config) 9 | export const PATCH = REST_PATCH(config) 10 | export const OPTIONS = REST_OPTIONS(config) 11 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 5 | 6 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 7 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhb-software/payload-plugins/efb8fdb99155a824327e56c7f42a7712c2796054/seo/dev/src/app/(payload)/custom.scss -------------------------------------------------------------------------------- /seo/dev/src/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 6 | import type { ServerFunctionClient } from 'payload' 7 | import React from 'react' 8 | 9 | import { importMap } from './admin/importMap.js' 10 | import './custom.scss' 11 | 12 | type Args = { 13 | children: React.ReactNode 14 | } 15 | 16 | const serverFunction: ServerFunctionClient = async function (args) { 17 | 'use server' 18 | return handleServerFunctions({ 19 | ...args, 20 | config, 21 | importMap, 22 | }) 23 | } 24 | 25 | const Layout = ({ children }: Args) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | export default Layout 32 | -------------------------------------------------------------------------------- /seo/dev/src/collections/pages.ts: -------------------------------------------------------------------------------- 1 | import { CollectionConfig } from 'payload' 2 | 3 | export const Pages: CollectionConfig = { 4 | slug: 'pages', 5 | fields: [ 6 | { 7 | name: 'title', 8 | type: 'text', 9 | required: true, 10 | localized: true, 11 | }, 12 | { 13 | name: 'contentPlaintext', 14 | type: 'textarea', 15 | required: true, 16 | localized: true, 17 | }, 18 | { 19 | name: 'contentLexical', 20 | type: 'textarea', 21 | required: true, 22 | localized: true, 23 | }, 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /seo/dev/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { payloadSeoPlugin } from '@jhb.software/payload-seo-plugin' 2 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 3 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 4 | import path from 'path' 5 | import { buildConfig } from 'payload' 6 | import { fileURLToPath } from 'url' 7 | import { Pages } from './collections/pages' 8 | 9 | const filename = fileURLToPath(import.meta.url) 10 | const dirname = path.dirname(filename) 11 | 12 | export default buildConfig({ 13 | admin: { 14 | autoLogin: { 15 | email: 'dev@payloadcms.com', 16 | password: 'test', 17 | }, 18 | user: 'users', 19 | }, 20 | collections: [ 21 | Pages, 22 | { 23 | slug: 'users', 24 | auth: true, 25 | fields: [], 26 | }, 27 | ], 28 | db: mongooseAdapter({ 29 | url: process.env.DATABASE_URI!, 30 | }), 31 | editor: lexicalEditor({}), 32 | secret: process.env.PAYLOAD_SECRET!, 33 | typescript: { 34 | outputFile: path.resolve(dirname, 'payload-types.ts'), 35 | }, 36 | async onInit(payload) { 37 | const existingUsers = await payload.find({ 38 | collection: 'users', 39 | limit: 1, 40 | }) 41 | 42 | if (existingUsers.docs.length === 0) { 43 | await payload.create({ 44 | collection: 'users', 45 | data: { 46 | email: 'dev@payloadcms.com', 47 | password: 'test', 48 | }, 49 | }) 50 | } 51 | }, 52 | plugins: [ 53 | payloadSeoPlugin({ 54 | // ### Options of the official seo plugin ### 55 | collections: ['pages'], 56 | 57 | // ### Options of this seo plugin ### 58 | websiteContext: { 59 | topic: 'A website of a software developer for mobile, web-apps and websites.', 60 | }, 61 | documentContentTransformers: { 62 | pages: async (doc, lexicalToPlainText) => ({ 63 | title: doc.title, 64 | contentLexical: (await lexicalToPlainText(doc.contentLexical)) ?? '', 65 | contentPlaintext: doc.contentPlaintext, 66 | }), 67 | }, 68 | }), 69 | ], 70 | }) 71 | -------------------------------------------------------------------------------- /seo/dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "outDir": "./dist", 10 | "rootDir": ".", 11 | "jsx": "preserve", 12 | "paths": { 13 | "@payload-config": ["./src/payload.config.ts"], 14 | "payload/generated-types": ["./src/payload-types.ts"] 15 | }, 16 | "noEmit": true, 17 | "incremental": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "plugins": [ 23 | { 24 | "name": "next" 25 | } 26 | ] 27 | }, 28 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 29 | "exclude": ["node_modules", "dist", "build"] 30 | } 31 | -------------------------------------------------------------------------------- /seo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jhb.software/payload-seo-plugin", 3 | "version": "0.1.4", 4 | "description": "SEO plugin for Payload CMS with AI-powered meta descriptions, keyword tracking and content analysis", 5 | "bugs": "https://github.com/jhb-software/payload-plugins/issues", 6 | "repository": "https://github.com/jhb-software/payload-plugins", 7 | "keywords": [ 8 | "payload", 9 | "plugin", 10 | "seo" 11 | ], 12 | "author": "JHB Software", 13 | "license": "MIT", 14 | "type": "module", 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "scripts": { 18 | "build": "tsc", 19 | "dev": "tsc -w", 20 | "format": "prettier --write src", 21 | "test": "jest", 22 | "lint": "eslint .", 23 | "prepublish": "tsc" 24 | }, 25 | "dependencies": { 26 | "openai": "^4.76.0" 27 | }, 28 | "peerDependencies": { 29 | "next": "15.0.0", 30 | "@payloadcms/plugin-seo": "^3.5.0", 31 | "@payloadcms/richtext-lexical": "^3.5.0", 32 | "@payloadcms/ui": "^3.5.0", 33 | "payload": "^3.5.0", 34 | "react": "19.0.0", 35 | "react-dom": "19.0.0" 36 | }, 37 | "devDependencies": { 38 | "typescript": "5.7.2", 39 | "@types/react": "^19.0.1", 40 | "@types/react-dom": "^19.0.1", 41 | "prettier": "^3.6.2" 42 | }, 43 | "files": [ 44 | "dist" 45 | ], 46 | "publishConfig": { 47 | "main": "./dist/index.js", 48 | "registry": "https://registry.npmjs.org/", 49 | "types": "./dist/index.d.ts", 50 | "access": "public", 51 | "exports": { 52 | ".": { 53 | "import": "./dist/index.js", 54 | "types": "./dist/index.d.ts", 55 | "default": "./dist/index.js" 56 | }, 57 | "./client": { 58 | "import": "./dist/exports/client.js", 59 | "types": "./dist/exports/client.d.ts", 60 | "default": "./dist/exports/client.js" 61 | } 62 | } 63 | }, 64 | "exports": { 65 | ".": { 66 | "import": "./src/index.ts", 67 | "default": "./src/index.ts", 68 | "types": "./src/index.ts" 69 | }, 70 | "./client": { 71 | "import": "./src/exports/client.ts", 72 | "default": "./src/exports/client.ts", 73 | "types": "./src/exports/client.ts" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /seo/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - './' 3 | - './dev' 4 | -------------------------------------------------------------------------------- /seo/src/components/KeywordsFieldLabel.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useLocale } from '@payloadcms/ui' 3 | import React from 'react' 4 | 5 | /** An improved array label which properly adds the required and localized indicator. */ 6 | export const KeywordsFieldLabel = ({ 7 | field, 8 | }: { 9 | field: { required: boolean; localized: boolean } 10 | }) => { 11 | const locale = useLocale() 12 | 13 | return ( 14 | 19 | Keywords {field.required ? * : ''} 20 | {field.localized && — {locale.label as string}} 21 | 22 | ) 23 | } 24 | 25 | export default KeywordsFieldLabel 26 | -------------------------------------------------------------------------------- /seo/src/exports/client.ts: -------------------------------------------------------------------------------- 1 | export { KeywordsFieldLabel } from '../components/KeywordsFieldLabel' 2 | export { KeywordsFieldRowLabel } from '../components/KeywordsFieldRowLabel' 3 | -------------------------------------------------------------------------------- /seo/src/fields/keywordsField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from 'payload' 2 | 3 | /** A keywords field which is necessary for the plugin to generate a meta description. */ 4 | export const keywordsField = (): Field => ({ 5 | name: 'keywords', 6 | admin: { 7 | description: 8 | 'Keywords that indicate what the page is about. These are used for generating the meta description.', 9 | components: { 10 | Label: '@jhb.software/payload-seo-plugin/client#KeywordsFieldLabel', 11 | RowLabel: '@jhb.software/payload-seo-plugin/client#KeywordsFieldRowLabel', 12 | }, 13 | initCollapsed: true, 14 | }, 15 | label: { 16 | de: 'Schlagwörter', 17 | en: 'Keywords', 18 | }, 19 | type: 'array', 20 | required: true, 21 | localized: true, 22 | minRows: 1, 23 | maxRows: 5, 24 | fields: [ 25 | { 26 | name: 'keyword', 27 | type: 'text', 28 | required: true, 29 | localized: true, 30 | maxLength: 100, 31 | // do not show a label as the array label already includes the label 32 | label: '', 33 | }, 34 | ], 35 | }) 36 | -------------------------------------------------------------------------------- /seo/src/index.ts: -------------------------------------------------------------------------------- 1 | export { payloadSeoPlugin } from './plugin' 2 | export type { SeoPluginConfig } from './types/SeoPluginConfig' 3 | -------------------------------------------------------------------------------- /seo/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'payload' 2 | 3 | import { seoPlugin } from '@payloadcms/plugin-seo' 4 | import { keywordsField } from './fields/keywordsField' 5 | import type { SeoPluginConfig } from './types/SeoPluginConfig' 6 | import { getGenerateMetaDescription } from './utils/generateMetaDescription' 7 | 8 | /** Payload plugin which integrates additional seo capabilities to pages documents. */ 9 | export const payloadSeoPlugin = 10 | (pluginOptions: SeoPluginConfig) => 11 | (incomingConfig: Config): Config => { 12 | let config = { ...incomingConfig } 13 | 14 | // If the plugin is disabled, return the config without modifying it 15 | if (pluginOptions.enabled === false) { 16 | return config 17 | } 18 | 19 | const generateMetaDescriptionFunction = getGenerateMetaDescription( 20 | pluginOptions, 21 | incomingConfig, 22 | ) 23 | 24 | const officialSeoPlugin = seoPlugin({ 25 | ...pluginOptions, 26 | generateDescription: generateMetaDescriptionFunction, 27 | fields: ({ defaultFields }) => [ 28 | keywordsField(), 29 | ...(pluginOptions.fields ? pluginOptions.fields({ defaultFields }) : defaultFields), 30 | ], 31 | }) 32 | 33 | // Merge the config after the initialization of the official seo plugin with the incoming config 34 | config = { ...incomingConfig, ...officialSeoPlugin(incomingConfig) } 35 | 36 | config.onInit = async (payload) => { 37 | if (incomingConfig.onInit) { 38 | await incomingConfig.onInit(payload) 39 | } 40 | 41 | const neededEnvVars = ['OPENAI_API_KEY'] 42 | 43 | const missingEnvVars = neededEnvVars.filter((envVar) => !process.env[envVar]) 44 | if (missingEnvVars.length > 0) { 45 | throw new Error( 46 | `The following environment variables are required for the seo plugin but not defined: ${missingEnvVars.join( 47 | ', ', 48 | )}`, 49 | ) 50 | } 51 | } 52 | 53 | return config 54 | } 55 | -------------------------------------------------------------------------------- /seo/src/types/DocumentContentTransformer.ts: -------------------------------------------------------------------------------- 1 | /** A transformer that picks and transforms the relevant fields from a document and returns a structured object with only the necessary content fields. */ 2 | export type DocumentContentTransformer = ( 3 | doc: any, 4 | lexicalToPlainText: (doc: any) => Promise, 5 | ) => Promise> | Record 6 | -------------------------------------------------------------------------------- /seo/src/types/PageContext.ts: -------------------------------------------------------------------------------- 1 | /** The context each prompt receives about the page. */ 2 | export type PageContext = { 3 | /** The title of the page. */ 4 | title: string 5 | 6 | /** The type of the page content (e.g. page, project, blog post, etc.) */ 7 | type: string 8 | 9 | /** The keywords which describe the content of the page. The first keyword is the focus keyword. */ 10 | keywords: string[] 11 | } 12 | -------------------------------------------------------------------------------- /seo/src/types/SeoPluginConfig.ts: -------------------------------------------------------------------------------- 1 | import { SEOPluginConfig as PayloadSeoPluginConfig } from '@payloadcms/plugin-seo/types' 2 | import { CollectionSlug } from 'payload' 3 | import { DocumentContentTransformer } from './DocumentContentTransformer' 4 | import { WebsiteContext } from './WebsiteContext' 5 | 6 | /** Configuration options for the seo plugin (Extends the official seo plugin config). */ 7 | export type SeoPluginConfig = { 8 | /** Whether the seo plugin is enabled. */ 9 | enabled?: boolean 10 | 11 | /** Contextual information about the website which is added to the meta description generation prompt. */ 12 | websiteContext: WebsiteContext 13 | 14 | /** A map of transformers which transform the raw document into a structured object used to generate the meta description. */ 15 | documentContentTransformers: Partial> 16 | 17 | /** The OpenAI LLM model to use for meta description generation (e.g. 'gpt-4o'). */ 18 | llmModel?: string 19 | } & PayloadSeoPluginConfig 20 | -------------------------------------------------------------------------------- /seo/src/types/WebsiteContext.ts: -------------------------------------------------------------------------------- 1 | /** Contextual information about the website which is added to the meta description generation prompt. */ 2 | export type WebsiteContext = { 3 | topic: string 4 | } 5 | -------------------------------------------------------------------------------- /seo/src/utils/generateOpenAIChatCompletion.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai' 2 | 3 | /** Calls OpenAI and returns the text response. */ 4 | export async function generateOpenAIChatCompletion(body: OpenAI.Chat.ChatCompletionCreateParams) { 5 | const openai = new OpenAI({ 6 | apiKey: process.env.OPENAI_API_KEY, 7 | }) 8 | 9 | const chatCompletion = (await openai.chat.completions.create(body, { 10 | stream: false, 11 | })) as unknown as OpenAI.Chat.ChatCompletion 12 | 13 | if (chatCompletion.choices.length === 0) { 14 | console.log(chatCompletion) 15 | throw new Error('No choices returned from OpenAI') 16 | } 17 | 18 | const message = chatCompletion.choices[0].message 19 | 20 | if (!message.content) { 21 | console.log(chatCompletion) 22 | throw new Error('No content returned from OpenAI') 23 | } 24 | 25 | console.log( 26 | JSON.stringify( 27 | { 28 | input: body, 29 | output: chatCompletion, 30 | }, 31 | null, 32 | 2, 33 | ), 34 | ) 35 | 36 | return message.content 37 | } 38 | -------------------------------------------------------------------------------- /seo/src/utils/lexicalToPlainText.ts: -------------------------------------------------------------------------------- 1 | import { getEnabledNodes } from '@payloadcms/richtext-lexical' 2 | 3 | import { defaultEditorConfig, sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical' 4 | import { $getRoot } from '@payloadcms/richtext-lexical/lexical' 5 | import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless' 6 | import { SanitizedConfig } from 'payload' 7 | 8 | /** Converts the given lexical data to plain text */ 9 | export async function lexicalToPlainText( 10 | data: any, 11 | payloadConfig: SanitizedConfig, 12 | ): Promise { 13 | if (!data) { 14 | console.warn('No data to convert to plain text. Returning undefined.') 15 | return undefined 16 | } 17 | 18 | if (!data.root && typeof data === 'string') { 19 | console.warn('Plaintext passed to lexicalToPlainText. Returning string.') 20 | return data 21 | } 22 | 23 | // Docs: https://payloadcms.com/docs/lexical/converters#headless-editor 24 | 25 | try { 26 | const headlessEditor = createHeadlessEditor({ 27 | nodes: getEnabledNodes({ 28 | editorConfig: await sanitizeServerEditorConfig(defaultEditorConfig, payloadConfig), 29 | }), 30 | }) 31 | 32 | const parsedEditorState = headlessEditor.parseEditorState(data) 33 | 34 | headlessEditor.update( 35 | () => headlessEditor.setEditorState(parsedEditorState), 36 | { discrete: true }, // This should commit the editor state immediately 37 | ) 38 | 39 | let plainTextContent = headlessEditor.getEditorState().read(() => $getRoot().getTextContent()) 40 | 41 | // Remove linebreaks and multiple spaces 42 | plainTextContent = plainTextContent 43 | .replace(/(\r\n|\n|\r)/gm, ' ') 44 | .replace(/\s+/g, ' ') 45 | .trim() 46 | 47 | // Returning undefined if the text is empty, so that we can use the ?? operator in the excerpt transformer 48 | return plainTextContent ? plainTextContent : undefined 49 | } catch (e) { 50 | console.error({ 51 | message: 'ERROR parsing lexical to plaintext.', 52 | error: e, 53 | }) 54 | return undefined 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /seo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "noEmit": false, 5 | "emitDeclarationOnly": false, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "allowJs": true, 11 | "checkJs": false, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "jsx": "preserve", 15 | "lib": ["dom", "dom.iterable", "esnext"], 16 | "skipLibCheck": true, 17 | "moduleResolution": "Bundler", 18 | "module": "ES6", 19 | "sourceMap": true, 20 | "strict": true, 21 | "incremental": true, 22 | "isolatedModules": true, 23 | "target": "ESNext" 24 | }, 25 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], 26 | "exclude": ["dist", "build", "node_modules"] 27 | } 28 | --------------------------------------------------------------------------------
{X}
{Y}