B2B-88: add starter kit structure and elements
This commit is contained in:
3
packages/cms/core/README.md
Normal file
3
packages/cms/core/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# CMS - @kit/cms
|
||||
|
||||
CMS abstraction layer for the Makerkit framework.
|
||||
3
packages/cms/core/eslint.config.mjs
Normal file
3
packages/cms/core/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
1
packages/cms/core/node_modules/@kit/cms-types
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/cms-types
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../types
|
||||
1
packages/cms/core/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/cms/core/node_modules/@kit/keystatic
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/keystatic
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../keystatic
|
||||
1
packages/cms/core/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/cms/core/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../shared
|
||||
1
packages/cms/core/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/cms/core/node_modules/@kit/wordpress
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@kit/wordpress
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../wordpress
|
||||
1
packages/cms/core/node_modules/@types/node
generated
vendored
Symbolic link
1
packages/cms/core/node_modules/@types/node
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+node@22.15.30/node_modules/@types/node
|
||||
32
packages/cms/core/package.json
Normal file
32
packages/cms/core/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@kit/cms",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/cms-types": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/keystatic": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/wordpress": "workspace:*",
|
||||
"@types/node": "^22.15.18"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
48
packages/cms/core/src/content-renderer.tsx
Normal file
48
packages/cms/core/src/content-renderer.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CmsType } from '@kit/cms-types';
|
||||
|
||||
const CMS_CLIENT = process.env.CMS_CLIENT as CmsType;
|
||||
|
||||
interface ContentRendererProps {
|
||||
content: unknown;
|
||||
type?: CmsType;
|
||||
}
|
||||
|
||||
export async function ContentRenderer({
|
||||
content,
|
||||
type = CMS_CLIENT,
|
||||
}: ContentRendererProps) {
|
||||
const Renderer = await getContentRenderer(type);
|
||||
|
||||
return Renderer ? <Renderer content={content} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content renderer for the specified CMS client.
|
||||
*
|
||||
* @param {CmsType} type - The type of CMS client.
|
||||
*/
|
||||
async function getContentRenderer(type: CmsType) {
|
||||
switch (type) {
|
||||
case 'keystatic': {
|
||||
const { KeystaticContentRenderer } = await import(
|
||||
'@kit/keystatic/renderer'
|
||||
);
|
||||
|
||||
return KeystaticContentRenderer;
|
||||
}
|
||||
|
||||
case 'wordpress': {
|
||||
const { WordpressContentRenderer } = await import(
|
||||
'@kit/wordpress/renderer'
|
||||
);
|
||||
|
||||
return WordpressContentRenderer;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error(`Unknown CMS client: ${type as string}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/cms/core/src/create-cms-client.ts
Normal file
33
packages/cms/core/src/create-cms-client.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CmsClient, CmsType } from '@kit/cms-types';
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
/**
|
||||
* The type of CMS client to use.
|
||||
*/
|
||||
const CMS_CLIENT = process.env.CMS_CLIENT as CmsType;
|
||||
|
||||
// Create a registry for CMS client implementations
|
||||
const cmsRegistry = createRegistry<CmsClient, CmsType>();
|
||||
|
||||
// Register the WordPress CMS client implementation
|
||||
cmsRegistry.register('wordpress', async () => {
|
||||
const { createWordpressClient } = await import('@kit/wordpress');
|
||||
return createWordpressClient();
|
||||
});
|
||||
|
||||
// Register the Keystatic CMS client implementation
|
||||
cmsRegistry.register('keystatic', async () => {
|
||||
const { createKeystaticClient } = await import('@kit/keystatic');
|
||||
return createKeystaticClient();
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a CMS client based on the specified type.
|
||||
*
|
||||
* @param {CmsType} type - The type of CMS client to create. Defaults to the value of the CMS_CLIENT environment variable.
|
||||
* @returns {Promise<CmsClient>} A Promise that resolves to the created CMS client.
|
||||
* @throws {Error} If the specified CMS type is unknown.
|
||||
*/
|
||||
export async function createCmsClient(type: CmsType = CMS_CLIENT) {
|
||||
return cmsRegistry.get(type);
|
||||
}
|
||||
6
packages/cms/core/src/index.ts
Normal file
6
packages/cms/core/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Cms } from '@kit/cms-types';
|
||||
|
||||
export * from './create-cms-client';
|
||||
export * from './content-renderer';
|
||||
|
||||
export type { Cms };
|
||||
8
packages/cms/core/tsconfig.json
Normal file
8
packages/cms/core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
5
packages/cms/keystatic/README.md
Normal file
5
packages/cms/keystatic/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# CMS/Keystatic - @kit/keystatic
|
||||
|
||||
Implementation of the CMS layer using the Keystatic library.
|
||||
|
||||
Please refer to the [Documentation](https://makerkit.dev/docs/next-supabase-turbo/content/keystatic).
|
||||
3
packages/cms/keystatic/eslint.config.mjs
Normal file
3
packages/cms/keystatic/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
17
packages/cms/keystatic/node_modules/.bin/acorn
generated
vendored
Executable file
17
packages/cms/keystatic/node_modules/.bin/acorn
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/acorn@8.14.1/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/acorn" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../node_modules/.pnpm/acorn@8.14.1/node_modules/acorn/bin/acorn" "$@"
|
||||
fi
|
||||
17
packages/cms/keystatic/node_modules/.bin/next
generated
vendored
Executable file
17
packages/cms/keystatic/node_modules/.bin/next
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/next" "$@"
|
||||
fi
|
||||
1
packages/cms/keystatic/node_modules/@keystatic/core
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@keystatic/core
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@keystatic+core@0.5.47_next@15.3.2_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1._kouv24hxuvqx24xsgfo2adgioa/node_modules/@keystatic/core
|
||||
1
packages/cms/keystatic/node_modules/@keystatic/next
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@keystatic/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@keystatic+next@5.0.4_@keystatic+core@0.5.47_next@15.3.2_@opentelemetry+api@1.9.0_babel-plugi_lvsqea6jzflby4w5sl65yilkza/node_modules/@keystatic/next
|
||||
1
packages/cms/keystatic/node_modules/@kit/cms-types
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@kit/cms-types
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../types
|
||||
1
packages/cms/keystatic/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/cms/keystatic/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/cms/keystatic/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/cms/keystatic/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/cms/keystatic/node_modules/@markdoc/markdoc
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@markdoc/markdoc
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@markdoc+markdoc@0.5.2_@types+react@19.1.4_react@19.1.0/node_modules/@markdoc/markdoc
|
||||
1
packages/cms/keystatic/node_modules/@types/node
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@types/node
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+node@22.15.30/node_modules/@types/node
|
||||
1
packages/cms/keystatic/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/cms/keystatic/node_modules/react
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/cms/keystatic/node_modules/zod
generated
vendored
Symbolic link
1
packages/cms/keystatic/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
41
packages/cms/keystatic/package.json
Normal file
41
packages/cms/keystatic/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@kit/keystatic",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./renderer": "./src/content-renderer.tsx",
|
||||
"./admin": "./src/keystatic-admin.tsx",
|
||||
"./route-handler": "./src/keystatic-route-handler.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@keystatic/core": "0.5.47",
|
||||
"@keystatic/next": "^5.0.4",
|
||||
"@markdoc/markdoc": "^0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/cms-types": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "19.1.4",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/cms/keystatic/src/content-renderer.tsx
Normal file
3
packages/cms/keystatic/src/content-renderer.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function KeystaticContentRenderer(props: { content: unknown }) {
|
||||
return props.content as React.ReactNode;
|
||||
}
|
||||
37
packages/cms/keystatic/src/create-keystatic-cms.ts
Normal file
37
packages/cms/keystatic/src/create-keystatic-cms.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CmsClient } from '@kit/cms-types';
|
||||
|
||||
/**
|
||||
* Creates a new Keystatic client instance.
|
||||
*/
|
||||
export async function createKeystaticClient() {
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === 'nodejs' ||
|
||||
process.env.KEYSTATIC_STORAGE_KIND !== 'local'
|
||||
) {
|
||||
const { createKeystaticClient: createClient } = await import(
|
||||
'./keystatic-client'
|
||||
);
|
||||
|
||||
return createClient();
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[CMS] Keystatic client using "Local" mode is only available in Node.js runtime. Please choose a different CMS client. Returning a mock client instead of throwing an error.`,
|
||||
);
|
||||
|
||||
return mockCMSClient() as unknown as CmsClient;
|
||||
}
|
||||
|
||||
function mockCMSClient() {
|
||||
return {
|
||||
getContentItems() {
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
total: 0,
|
||||
});
|
||||
},
|
||||
getContentItemBySlug() {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
66
packages/cms/keystatic/src/create-reader.ts
Normal file
66
packages/cms/keystatic/src/create-reader.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { KeystaticStorage } from './keystatic-storage';
|
||||
import { keyStaticConfig } from './keystatic.config';
|
||||
|
||||
/**
|
||||
* @name createKeystaticReader
|
||||
* @description Creates a new Keystatic reader instance.
|
||||
*/
|
||||
export async function createKeystaticReader() {
|
||||
switch (KeystaticStorage.kind) {
|
||||
case 'local': {
|
||||
// we need to import this dynamically to avoid parsing the package in edge environments
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { createReader } = await import('@keystatic/core/reader');
|
||||
|
||||
return createReader(process.cwd(), keyStaticConfig);
|
||||
} else {
|
||||
// we should never get here but the compiler requires the check
|
||||
// to ensure we don't parse the package at build time
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
case 'github':
|
||||
case 'cloud': {
|
||||
const { createGitHubReader } = await import(
|
||||
'@keystatic/core/reader/github'
|
||||
);
|
||||
|
||||
return createGitHubReader(
|
||||
keyStaticConfig,
|
||||
getKeystaticGithubConfiguration(),
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown storage kind`);
|
||||
}
|
||||
}
|
||||
|
||||
function getKeystaticGithubConfiguration() {
|
||||
/**
|
||||
* @description The repository to use for the GitHub storage.
|
||||
* This can be provided through the `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` environment variable. The previous environment variable `KEYSTATIC_STORAGE_REPO` is deprecated.
|
||||
*/
|
||||
const repo =
|
||||
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO ??
|
||||
/* @deprecated */
|
||||
process.env.KEYSTATIC_STORAGE_REPO;
|
||||
|
||||
return z
|
||||
.object({
|
||||
token: z.string({
|
||||
description:
|
||||
'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.',
|
||||
}),
|
||||
repo: z.custom<`${string}/${string}`>(),
|
||||
pathPrefix: z.string().optional(),
|
||||
})
|
||||
.parse({
|
||||
token: process.env.KEYSTATIC_GITHUB_TOKEN,
|
||||
repo,
|
||||
pathPrefix: process.env.KEYSTATIC_PATH_PREFIX,
|
||||
});
|
||||
}
|
||||
36
packages/cms/keystatic/src/custom-components.tsx
Normal file
36
packages/cms/keystatic/src/custom-components.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import type { Schema } from '@markdoc/markdoc';
|
||||
|
||||
type Component = ComponentType<unknown>;
|
||||
|
||||
/**
|
||||
* @name CustomMarkdocComponents
|
||||
* @description Custom components for Markdoc. Please define your custom components here.
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* function Youtube(props: { src: string }) { ... }
|
||||
*
|
||||
* export const CustomMarkdocComponents: Record<string, React.ComponentType<never>> = {
|
||||
* Youtube,
|
||||
* };
|
||||
*/
|
||||
export const CustomMarkdocComponents: Record<string, Component> = {
|
||||
// define your custom components here
|
||||
};
|
||||
|
||||
/**
|
||||
* @name CustomMarkdocTags
|
||||
* @description Custom tags for Markdoc. Please define your custom tags here.
|
||||
* @example
|
||||
* export const CustomMarkdocTags = {
|
||||
* youtube: {
|
||||
* render: "Youtube",
|
||||
* selfClosing: true,
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export const CustomMarkdocTags: Record<string, Schema> = {
|
||||
// define your custom tags here
|
||||
};
|
||||
1
packages/cms/keystatic/src/index.ts
Normal file
1
packages/cms/keystatic/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './create-keystatic-cms';
|
||||
7
packages/cms/keystatic/src/keystatic-admin.tsx
Normal file
7
packages/cms/keystatic/src/keystatic-admin.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { makePage } from '@keystatic/next/ui/app';
|
||||
|
||||
import { keyStaticConfig } from './keystatic.config';
|
||||
|
||||
export default makePage(keyStaticConfig);
|
||||
368
packages/cms/keystatic/src/keystatic-client.ts
Normal file
368
packages/cms/keystatic/src/keystatic-client.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { Cms, CmsClient } from '@kit/cms-types';
|
||||
|
||||
import { createKeystaticReader } from './create-reader';
|
||||
import { DocumentationEntryProps, PostEntryProps } from './keystatic.config';
|
||||
import { renderMarkdoc } from './markdoc';
|
||||
|
||||
export function createKeystaticClient() {
|
||||
return new KeystaticClient();
|
||||
}
|
||||
|
||||
class KeystaticClient implements CmsClient {
|
||||
async getContentItems(options: Cms.GetContentItemsOptions) {
|
||||
const reader = await createKeystaticReader();
|
||||
|
||||
const collection =
|
||||
options.collection as keyof (typeof reader)['collections'];
|
||||
|
||||
if (!reader.collections[collection]) {
|
||||
throw new Error(`Collection ${collection} not found`);
|
||||
}
|
||||
|
||||
const fetchContent = options.content ?? true;
|
||||
|
||||
const startOffset = options?.offset ?? 0;
|
||||
const endOffset = startOffset + (options?.limit ?? 10);
|
||||
|
||||
const docs = await reader.collections[collection].all();
|
||||
|
||||
const filtered = docs
|
||||
.filter((item) => {
|
||||
const status = options?.status ?? 'published';
|
||||
|
||||
if (item.entry.status !== status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const categoryMatch = options?.categories?.length
|
||||
? options.categories.find((category) =>
|
||||
item.entry.categories.includes(category),
|
||||
)
|
||||
: true;
|
||||
|
||||
if (!categoryMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.language) {
|
||||
if (item.entry.language && item.entry.language !== options.language) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const tagMatch = options?.tags?.length
|
||||
? options.tags.find((tag) => item.entry.tags.includes(tag))
|
||||
: true;
|
||||
|
||||
if (!tagMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const direction = options.sortDirection ?? 'asc';
|
||||
const sortBy = options.sortBy ?? 'publishedAt';
|
||||
|
||||
const transform = (value: string | number | undefined | null) => {
|
||||
if (typeof value === 'string') {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
return value ?? 0;
|
||||
};
|
||||
|
||||
const left = transform(a.entry[sortBy]);
|
||||
const right = transform(b.entry[sortBy]);
|
||||
|
||||
if (direction === 'asc') {
|
||||
return left - right;
|
||||
}
|
||||
|
||||
return right - left;
|
||||
});
|
||||
|
||||
function processItems(items: typeof filtered) {
|
||||
const slugSet = new Set(items.map((item) => item.slug));
|
||||
const indexFileCache = new Map<string, boolean>();
|
||||
const parentCache = new Map<string, string | null>();
|
||||
|
||||
const isIndexFile = (slug: string): boolean => {
|
||||
if (indexFileCache.has(slug)) {
|
||||
return indexFileCache.get(slug)!;
|
||||
}
|
||||
|
||||
const parts = slug.split('/');
|
||||
|
||||
const result =
|
||||
parts.length === 1 ||
|
||||
(parts.length >= 2 &&
|
||||
parts[parts.length - 1] === parts[parts.length - 2]);
|
||||
|
||||
indexFileCache.set(slug, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const findClosestValidParent = (pathParts: string[]): string | null => {
|
||||
const path = pathParts.join('/');
|
||||
|
||||
if (parentCache.has(path)) {
|
||||
return parentCache.get(path)!;
|
||||
}
|
||||
|
||||
for (let i = pathParts.length - 1; i > 0; i--) {
|
||||
const parentParts = pathParts.slice(0, i);
|
||||
const lastPart = parentParts[parentParts.length - 1];
|
||||
|
||||
if (!lastPart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const possibleIndexParent = parentParts.concat(lastPart).join('/');
|
||||
|
||||
if (slugSet.has(possibleIndexParent)) {
|
||||
parentCache.set(path, possibleIndexParent);
|
||||
return possibleIndexParent;
|
||||
}
|
||||
|
||||
const regularParent = parentParts.join('/');
|
||||
|
||||
if (slugSet.has(regularParent)) {
|
||||
parentCache.set(path, regularParent);
|
||||
return regularParent;
|
||||
}
|
||||
}
|
||||
|
||||
parentCache.set(path, null);
|
||||
return null;
|
||||
};
|
||||
|
||||
const results = new Array(items.length) as typeof items;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the parent is already set, we don't need to do anything
|
||||
if (item.entry.parent !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isIndexFile(item.slug)) {
|
||||
item.entry.parent = null;
|
||||
results[i] = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathParts = item.slug.split('/');
|
||||
const parentParts = pathParts.slice(0, -1);
|
||||
const lastPart = parentParts[parentParts.length - 1]!;
|
||||
const possibleIndexParent = parentParts.concat(lastPart).join('/');
|
||||
|
||||
if (slugSet.has(possibleIndexParent)) {
|
||||
item.entry.parent = possibleIndexParent;
|
||||
} else {
|
||||
const regularParent = parentParts.join('/');
|
||||
if (slugSet.has(regularParent)) {
|
||||
item.entry.parent = regularParent;
|
||||
} else {
|
||||
item.entry.parent = findClosestValidParent(pathParts);
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = item;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const itemsWithParents = processItems(filtered);
|
||||
|
||||
const items = await Promise.all(
|
||||
itemsWithParents
|
||||
.slice(startOffset, endOffset)
|
||||
.sort((a, b) => {
|
||||
return (a.entry.order ?? 0) - (b.entry.order ?? 0);
|
||||
})
|
||||
.map((item) => {
|
||||
if (collection === 'documentation') {
|
||||
return this.mapDocumentationPost(
|
||||
item as {
|
||||
entry: DocumentationEntryProps;
|
||||
slug: string;
|
||||
},
|
||||
{ fetchContent },
|
||||
);
|
||||
}
|
||||
|
||||
return this.mapPost(
|
||||
item as {
|
||||
entry: PostEntryProps;
|
||||
slug: string;
|
||||
},
|
||||
{ fetchContent },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
total: filtered.length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
async getContentItemBySlug(params: {
|
||||
slug: string;
|
||||
collection: string;
|
||||
status?: Cms.ContentItemStatus;
|
||||
}) {
|
||||
const reader = await createKeystaticReader();
|
||||
|
||||
const collection =
|
||||
params.collection as keyof (typeof reader)['collections'];
|
||||
|
||||
if (!reader.collections[collection]) {
|
||||
throw new Error(`Collection ${collection} not found`);
|
||||
}
|
||||
|
||||
const doc = await reader.collections[collection].read(params.slug);
|
||||
const status = params.status ?? 'published';
|
||||
|
||||
// verify that the document exists
|
||||
if (!doc) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
// check the document matches the status provided in the params
|
||||
if (doc.status !== status) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return this.mapPost({ entry: doc as PostEntryProps, slug: params.slug });
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async getTags() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async getTagBySlug() {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
async getCategoryBySlug() {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private async mapDocumentationPost<
|
||||
Type extends {
|
||||
entry: DocumentationEntryProps;
|
||||
slug: string;
|
||||
},
|
||||
>(
|
||||
item: Type,
|
||||
params: {
|
||||
fetchContent: boolean;
|
||||
} = {
|
||||
fetchContent: true,
|
||||
},
|
||||
): Promise<Cms.ContentItem> {
|
||||
const publishedAt = item.entry.publishedAt
|
||||
? new Date(item.entry.publishedAt)
|
||||
: new Date();
|
||||
|
||||
const content = await item.entry.content();
|
||||
const html = params.fetchContent ? await renderMarkdoc(content.node) : [];
|
||||
|
||||
return {
|
||||
id: item.slug,
|
||||
title: item.entry.title,
|
||||
label: item.entry.label,
|
||||
url: item.slug,
|
||||
slug: item.slug,
|
||||
description: item.entry.description,
|
||||
publishedAt: publishedAt.toISOString(),
|
||||
content: html as string,
|
||||
image: item.entry.image ?? undefined,
|
||||
status: item.entry.status,
|
||||
collapsible: item.entry.collapsible,
|
||||
collapsed: item.entry.collapsed,
|
||||
categories:
|
||||
(item.entry.categories ?? []).map((item) => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
slug: item,
|
||||
};
|
||||
}) ?? [],
|
||||
tags: (item.entry.tags ?? []).map((item) => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
slug: item,
|
||||
};
|
||||
}),
|
||||
parentId: item.entry.parent ?? undefined,
|
||||
order: item.entry.order ?? 1,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async mapPost<
|
||||
Type extends {
|
||||
entry: PostEntryProps;
|
||||
slug: string;
|
||||
},
|
||||
>(
|
||||
item: Type,
|
||||
params: {
|
||||
fetchContent: boolean;
|
||||
} = {
|
||||
fetchContent: true,
|
||||
},
|
||||
): Promise<Cms.ContentItem> {
|
||||
const publishedAt = item.entry.publishedAt
|
||||
? new Date(item.entry.publishedAt)
|
||||
: new Date();
|
||||
|
||||
const content = await item.entry.content();
|
||||
const html = params.fetchContent ? await renderMarkdoc(content.node) : [];
|
||||
|
||||
return {
|
||||
id: item.slug,
|
||||
title: item.entry.title,
|
||||
label: item.entry.label,
|
||||
url: item.slug,
|
||||
slug: item.slug,
|
||||
description: item.entry.description,
|
||||
publishedAt: publishedAt.toISOString(),
|
||||
content: html as string,
|
||||
image: item.entry.image ?? undefined,
|
||||
status: item.entry.status,
|
||||
categories:
|
||||
(item.entry.categories ?? []).map((item) => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
slug: item,
|
||||
};
|
||||
}) ?? [],
|
||||
tags: (item.entry.tags ?? []).map((item) => {
|
||||
return {
|
||||
id: item,
|
||||
name: item,
|
||||
slug: item,
|
||||
};
|
||||
}),
|
||||
parentId: item.entry.parent ?? undefined,
|
||||
order: item.entry.order ?? 1,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
29
packages/cms/keystatic/src/keystatic-route-handler.ts
Normal file
29
packages/cms/keystatic/src/keystatic-route-handler.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { makeRouteHandler } from '@keystatic/next/route-handler';
|
||||
|
||||
import { keyStaticConfig } from './keystatic.config';
|
||||
|
||||
const handlers = makeRouteHandler({
|
||||
config: keyStaticConfig,
|
||||
});
|
||||
|
||||
/**
|
||||
* @name productionGuard
|
||||
* @description Guard for production environment. Returns 404 if in production.
|
||||
* @param routeHandler
|
||||
*/
|
||||
function productionGuard(routeHandler: (req: Request) => Promise<Response>) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return (req: Request) => routeHandler(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name keystaticRouteHandlers
|
||||
* @description Route handlers for keystatic
|
||||
*/
|
||||
export const keystaticRouteHandlers = {
|
||||
POST: productionGuard(handlers.POST),
|
||||
GET: productionGuard(handlers.GET),
|
||||
};
|
||||
79
packages/cms/keystatic/src/keystatic-storage.ts
Normal file
79
packages/cms/keystatic/src/keystatic-storage.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
|
||||
/**
|
||||
* @name STORAGE_KIND
|
||||
* @description The kind of storage to use for the Keystatic reader.
|
||||
*
|
||||
* This can be provided through the `KEYSTATIC_STORAGE_KIND` environment variable or 'NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND'.
|
||||
* The previous environment variable `KEYSTATIC_STORAGE_KIND` is deprecated - as Keystatic may need this to be available in the client-side.
|
||||
*
|
||||
*/
|
||||
const STORAGE_KIND =
|
||||
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_KIND ??
|
||||
/* @deprecated */
|
||||
process.env.KEYSTATIC_STORAGE_KIND ??
|
||||
'local';
|
||||
|
||||
/**
|
||||
* @name REPO
|
||||
* @description The repository to use for the GitHub storage.
|
||||
* This can be provided through the `NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO` environment variable. The previous environment variable `KEYSTATIC_STORAGE_REPO` is deprecated.
|
||||
*/
|
||||
const REPO =
|
||||
process.env.NEXT_PUBLIC_KEYSTATIC_STORAGE_REPO ??
|
||||
/* @deprecated */
|
||||
process.env.KEYSTATIC_STORAGE_REPO;
|
||||
|
||||
const BRANCH_PREFIX = process.env.KEYSTATIC_STORAGE_BRANCH_PREFIX;
|
||||
const PATH_PREFIX = process.env.KEYSTATIC_PATH_PREFIX;
|
||||
const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT;
|
||||
|
||||
/**
|
||||
* @name local
|
||||
* @description The configuration for the local storage.
|
||||
*/
|
||||
const local = z.object({
|
||||
kind: z.literal('local'),
|
||||
}) satisfies ZodOutputFor<LocalConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name cloud
|
||||
* @description The configuration for the cloud storage.
|
||||
*/
|
||||
const cloud = z.object({
|
||||
kind: z.literal('cloud'),
|
||||
project: z
|
||||
.string({
|
||||
description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
|
||||
})
|
||||
.min(1),
|
||||
branchPrefix: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
}) satisfies ZodOutputFor<CloudConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name github
|
||||
* @description The configuration for the GitHub storage.
|
||||
*/
|
||||
const github = z.object({
|
||||
kind: z.literal('github'),
|
||||
repo: z.custom<`${string}/${string}`>(),
|
||||
branchPrefix: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
}) satisfies ZodOutputFor<GitHubConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name KeystaticStorage
|
||||
* @description The configuration for the Keystatic storage. This is used to determine where the content is stored.
|
||||
* This configuration is validated through Zod to ensure that the configuration is correct.
|
||||
*/
|
||||
export const KeystaticStorage = z.union([local, cloud, github]).parse({
|
||||
kind: STORAGE_KIND,
|
||||
project: PROJECT,
|
||||
repo: REPO,
|
||||
branchPrefix: BRANCH_PREFIX,
|
||||
pathPrefix: PATH_PREFIX,
|
||||
});
|
||||
153
packages/cms/keystatic/src/keystatic.config.ts
Normal file
153
packages/cms/keystatic/src/keystatic.config.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { collection, config, fields } from '@keystatic/core';
|
||||
import { Entry } from '@keystatic/core/reader';
|
||||
|
||||
import { KeystaticStorage } from './keystatic-storage';
|
||||
|
||||
export const keyStaticConfig = createKeyStaticConfig(
|
||||
process.env.NEXT_PUBLIC_KEYSTATIC_CONTENT_PATH ?? '',
|
||||
);
|
||||
|
||||
function getContentField() {
|
||||
return fields.markdoc({
|
||||
label: 'Content',
|
||||
options: {
|
||||
link: true,
|
||||
blockquote: true,
|
||||
bold: true,
|
||||
divider: true,
|
||||
orderedList: true,
|
||||
unorderedList: true,
|
||||
strikethrough: true,
|
||||
heading: true,
|
||||
code: true,
|
||||
italic: true,
|
||||
image: {
|
||||
directory: 'public/site/images',
|
||||
publicPath: '/site/images',
|
||||
schema: {
|
||||
title: fields.text({
|
||||
label: 'Caption',
|
||||
description: 'The text to display under the image in a caption.',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type PostEntryProps = Entry<
|
||||
(typeof keyStaticConfig)['collections']['posts']
|
||||
>;
|
||||
|
||||
export type DocumentationEntryProps = Entry<
|
||||
(typeof keyStaticConfig)['collections']['documentation']
|
||||
>;
|
||||
|
||||
function createKeyStaticConfig(path = '') {
|
||||
if (path && !path.endsWith('/')) {
|
||||
path += '/';
|
||||
}
|
||||
|
||||
const cloud = {
|
||||
project: KeystaticStorage.kind === 'cloud' ? KeystaticStorage.project : '',
|
||||
};
|
||||
|
||||
const collections = getKeystaticCollections(path);
|
||||
|
||||
return config({
|
||||
storage: KeystaticStorage,
|
||||
cloud,
|
||||
collections,
|
||||
});
|
||||
}
|
||||
|
||||
function getKeystaticCollections(path: string) {
|
||||
return {
|
||||
posts: collection({
|
||||
label: 'Posts',
|
||||
slugField: 'title',
|
||||
path: `${path}posts/*`,
|
||||
format: { contentField: 'content' },
|
||||
schema: {
|
||||
title: fields.slug({ name: { label: 'Title' } }),
|
||||
label: fields.text({
|
||||
label: 'Label',
|
||||
validation: { isRequired: false },
|
||||
}),
|
||||
image: fields.image({
|
||||
label: 'Image',
|
||||
directory: 'public/site/images',
|
||||
publicPath: '/site/images',
|
||||
}),
|
||||
categories: fields.array(fields.text({ label: 'Category' })),
|
||||
tags: fields.array(fields.text({ label: 'Tag' })),
|
||||
description: fields.text({ label: 'Description' }),
|
||||
publishedAt: fields.date({ label: 'Published At' }),
|
||||
parent: fields.relationship({
|
||||
label: 'Parent',
|
||||
collection: 'posts',
|
||||
}),
|
||||
language: fields.text({ label: 'Language' }),
|
||||
order: fields.number({ label: 'Order' }),
|
||||
content: getContentField(),
|
||||
status: fields.select({
|
||||
defaultValue: 'draft',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
{ label: 'Review', value: 'review' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
documentation: collection({
|
||||
label: 'Documentation',
|
||||
slugField: 'title',
|
||||
path: `${path}documentation/**`,
|
||||
format: { contentField: 'content' },
|
||||
schema: {
|
||||
title: fields.slug({ name: { label: 'Title' } }),
|
||||
label: fields.text({
|
||||
label: 'Label',
|
||||
validation: { isRequired: false },
|
||||
}),
|
||||
content: getContentField(),
|
||||
image: fields.image({
|
||||
label: 'Image',
|
||||
directory: 'public/site/images',
|
||||
publicPath: '/site/images',
|
||||
}),
|
||||
description: fields.text({ label: 'Description' }),
|
||||
publishedAt: fields.date({ label: 'Published At' }),
|
||||
order: fields.number({ label: 'Order' }),
|
||||
language: fields.text({ label: 'Language' }),
|
||||
parent: fields.relationship({
|
||||
label: 'Parent',
|
||||
collection: 'documentation',
|
||||
}),
|
||||
categories: fields.array(fields.text({ label: 'Category' })),
|
||||
tags: fields.array(fields.text({ label: 'Tag' })),
|
||||
status: fields.select({
|
||||
defaultValue: 'draft',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
{ label: 'Review', value: 'review' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
],
|
||||
}),
|
||||
collapsible: fields.checkbox({
|
||||
label: 'Collapsible',
|
||||
defaultValue: false,
|
||||
}),
|
||||
collapsed: fields.checkbox({
|
||||
label: 'Collapsed',
|
||||
defaultValue: false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
42
packages/cms/keystatic/src/markdoc-nodes.ts
Normal file
42
packages/cms/keystatic/src/markdoc-nodes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Or replace this with your own function
|
||||
import { Config, Node, RenderableTreeNode, Tag } from '@markdoc/markdoc';
|
||||
|
||||
function generateID(
|
||||
children: Array<RenderableTreeNode>,
|
||||
attributes: Record<string, unknown>,
|
||||
) {
|
||||
if (attributes.id && typeof attributes.id === 'string') {
|
||||
return attributes.id;
|
||||
}
|
||||
|
||||
return children
|
||||
.filter((child) => typeof child === 'string')
|
||||
.join(' ')
|
||||
.replace(/[?]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const heading = {
|
||||
children: ['inline'],
|
||||
attributes: {
|
||||
id: { type: String },
|
||||
level: { type: Number, required: true, default: 1 },
|
||||
},
|
||||
transform(node: Node, config: Config) {
|
||||
const attributes = node.transformAttributes(config);
|
||||
const children = node.transformChildren(config);
|
||||
|
||||
const id = generateID(children, attributes);
|
||||
|
||||
return new Tag(
|
||||
`h${node.attributes.level}`,
|
||||
{ ...attributes, id },
|
||||
children,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MarkdocNodes = {
|
||||
heading,
|
||||
};
|
||||
30
packages/cms/keystatic/src/markdoc.tsx
Normal file
30
packages/cms/keystatic/src/markdoc.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Node } from '@markdoc/markdoc';
|
||||
|
||||
import {
|
||||
CustomMarkdocComponents,
|
||||
CustomMarkdocTags,
|
||||
} from './custom-components';
|
||||
import { MarkdocNodes } from './markdoc-nodes';
|
||||
|
||||
/**
|
||||
* @name renderMarkdoc
|
||||
* @description Renders a Markdoc tree to React
|
||||
*/
|
||||
export async function renderMarkdoc(node: Node) {
|
||||
const { transform, renderers } = await import('@markdoc/markdoc');
|
||||
|
||||
const content = transform(node, {
|
||||
tags: {
|
||||
...CustomMarkdocTags,
|
||||
},
|
||||
nodes: {
|
||||
...MarkdocNodes,
|
||||
},
|
||||
});
|
||||
|
||||
return renderers.react(content, React, {
|
||||
components: CustomMarkdocComponents,
|
||||
});
|
||||
}
|
||||
8
packages/cms/keystatic/tsconfig.json
Normal file
8
packages/cms/keystatic/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3
packages/cms/types/eslint.config.mjs
Normal file
3
packages/cms/types/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
1
packages/cms/types/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/cms/types/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/cms/types/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/cms/types/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/cms/types/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/cms/types/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
27
packages/cms/types/package.json
Normal file
27
packages/cms/types/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@kit/cms-types",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/cms/types/src/cms-client.ts
Normal file
117
packages/cms/types/src/cms-client.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace Cms {
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string | undefined;
|
||||
url: string;
|
||||
description: string | undefined;
|
||||
content: unknown;
|
||||
publishedAt: string;
|
||||
image: string | undefined;
|
||||
status: ContentItemStatus;
|
||||
slug: string;
|
||||
categories: Category[];
|
||||
tags: Tag[];
|
||||
order: number;
|
||||
children: ContentItem[];
|
||||
parentId: string | undefined;
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export type ContentItemStatus = 'draft' | 'published' | 'review' | 'pending';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface GetContentItemsOptions {
|
||||
collection: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
content?: boolean;
|
||||
parentIds?: string[];
|
||||
language?: string | undefined;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
sortBy?: 'publishedAt' | 'order' | 'title';
|
||||
status?: ContentItemStatus;
|
||||
}
|
||||
|
||||
export interface GetCategoriesOptions {
|
||||
slugs?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface GetTagsOptions {
|
||||
slugs?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class representing a CMS client.
|
||||
*/
|
||||
export abstract class CmsClient {
|
||||
/**
|
||||
* Retrieves content items based on the provided options.
|
||||
* @param options - Options for filtering and pagination.
|
||||
* @returns A promise that resolves to an array of content items.
|
||||
*/
|
||||
abstract getContentItems(options?: Cms.GetContentItemsOptions): Promise<{
|
||||
total: number;
|
||||
items: Cms.ContentItem[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Retrieves a content item by its ID and type.
|
||||
* @returns A promise that resolves to the content item, or undefined if not found.
|
||||
*/
|
||||
abstract getContentItemBySlug(params: {
|
||||
slug: string;
|
||||
collection: string;
|
||||
status?: Cms.ContentItemStatus;
|
||||
}): Promise<Cms.ContentItem | undefined>;
|
||||
|
||||
/**
|
||||
* Retrieves categories based on the provided options.
|
||||
* @param options - Options for filtering and pagination.
|
||||
* @returns A promise that resolves to an array of categories.
|
||||
*/
|
||||
abstract getCategories(
|
||||
options?: Cms.GetCategoriesOptions,
|
||||
): Promise<Cms.Category[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a category by its slug.
|
||||
* @param slug - The slug of the category.
|
||||
* @returns A promise that resolves to the category, or undefined if not found.
|
||||
*/
|
||||
abstract getCategoryBySlug(slug: string): Promise<Cms.Category | undefined>;
|
||||
|
||||
/**
|
||||
* Retrieves tags based on the provided options.
|
||||
* @param options - Options for filtering and pagination.
|
||||
* @returns A promise that resolves to an array of tags.
|
||||
*/
|
||||
abstract getTags(options?: Cms.GetTagsOptions): Promise<Cms.Tag[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a tag by its slug.
|
||||
* @param slug - The slug of the tag.
|
||||
* @returns A promise that resolves to the tag, or undefined if not found.
|
||||
*/
|
||||
abstract getTagBySlug(slug: string): Promise<Cms.Tag | undefined>;
|
||||
}
|
||||
3
packages/cms/types/src/cms.type.ts
Normal file
3
packages/cms/types/src/cms.type.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// we can add more types here if we have more CMSs
|
||||
// ex. export type CmsType = 'contentlayer' | 'other-cms';
|
||||
export type CmsType = 'wordpress' | 'keystatic';
|
||||
2
packages/cms/types/src/index.ts
Normal file
2
packages/cms/types/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './cms-client';
|
||||
export * from './cms.type';
|
||||
8
packages/cms/types/tsconfig.json
Normal file
8
packages/cms/types/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3
packages/cms/wordpress/README.md
Normal file
3
packages/cms/wordpress/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# CMS/Wordpress - @kit/wordpress
|
||||
|
||||
Please refer to the [documentation](https://makerkit.dev/docs/next-supabase-turbo/content/wordpress).
|
||||
48
packages/cms/wordpress/docker-compose.yml
Normal file
48
packages/cms/wordpress/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
db:
|
||||
# We use a mariadb image which supports both amd64 & arm64 architecture
|
||||
image: mariadb:10.6.4-focal
|
||||
# If you really want to use MySQL, uncomment the following line
|
||||
#image: mysql:8.0.27
|
||||
command: '--default-authentication-plugin=mysql_native_password'
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
restart: always
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=somewordpress
|
||||
- MYSQL_DATABASE=wordpress
|
||||
- MYSQL_USER=wordpress
|
||||
- MYSQL_PASSWORD=wordpress
|
||||
expose:
|
||||
- 3306
|
||||
- 33060
|
||||
wordpress:
|
||||
image: wordpress:latest
|
||||
ports:
|
||||
- 8080:80
|
||||
restart: always
|
||||
working_dir: /var/www/html
|
||||
volumes:
|
||||
- ./wp-content:/var/www/html/wp-content
|
||||
environment:
|
||||
- WORDPRESS_DB_HOST=db
|
||||
- WORDPRESS_DB_USER=wordpress
|
||||
- WORDPRESS_DB_PASSWORD=wordpress
|
||||
- WORDPRESS_DB_NAME=wordpress
|
||||
- WORDPRESS_DEBUG=1
|
||||
- WORDPRESS_CONFIG_EXTRA = |
|
||||
define('FS_METHOD', 'direct');
|
||||
/** disable wp core auto update */
|
||||
define('WP_AUTO_UPDATE_CORE', false);
|
||||
|
||||
/** local environment settings */
|
||||
define('WP_CACHE', false);
|
||||
define('ENVIRONMENT', 'local');
|
||||
|
||||
/** force site home url */
|
||||
if(!defined('WP_HOME')) {
|
||||
define('WP_HOME', 'http://localhost');
|
||||
define('WP_SITEURL', WP_HOME);
|
||||
}
|
||||
volumes:
|
||||
db_data:
|
||||
3
packages/cms/wordpress/eslint.config.mjs
Normal file
3
packages/cms/wordpress/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
1
packages/cms/wordpress/node_modules/@kit/cms-types
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@kit/cms-types
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../types
|
||||
1
packages/cms/wordpress/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/cms/wordpress/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/cms/wordpress/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/cms/wordpress/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/cms/wordpress/node_modules/@types/node
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@types/node
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+node@22.15.30/node_modules/@types/node
|
||||
1
packages/cms/wordpress/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/cms/wordpress/node_modules/wp-types
generated
vendored
Symbolic link
1
packages/cms/wordpress/node_modules/wp-types
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/wp-types@4.68.0/node_modules/wp-types
|
||||
34
packages/cms/wordpress/package.json
Normal file
34
packages/cms/wordpress/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/wordpress",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "docker compose up"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./renderer": "./src/content-renderer.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/cms-types": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "19.1.4",
|
||||
"wp-types": "^4.68.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/cms/wordpress/src/content-renderer.tsx
Normal file
5
packages/cms/wordpress/src/content-renderer.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export function WordpressContentRenderer(props: { content: unknown }) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: props.content as string }} />;
|
||||
}
|
||||
1
packages/cms/wordpress/src/index.ts
Normal file
1
packages/cms/wordpress/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './wp-client';
|
||||
423
packages/cms/wordpress/src/wp-client.ts
Normal file
423
packages/cms/wordpress/src/wp-client.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
WP_Post_Status_Name,
|
||||
WP_REST_API_Category,
|
||||
WP_REST_API_Post,
|
||||
WP_REST_API_Tag,
|
||||
} from 'wp-types';
|
||||
|
||||
import { Cms, CmsClient } from '@kit/cms-types';
|
||||
|
||||
import GetTagsOptions = Cms.GetTagsOptions;
|
||||
|
||||
/**
|
||||
* Creates a new WordpressClient instance.
|
||||
*
|
||||
* @param {string} apiUrl - The URL of the Wordpress API.
|
||||
* @returns {WordpressClient} A new WordpressClient instance.
|
||||
*/
|
||||
export function createWordpressClient(
|
||||
apiUrl = process.env.WORDPRESS_API_URL as string,
|
||||
) {
|
||||
return new WordpressClient(apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name WordpressClient
|
||||
* @description Represents a client for interacting with a Wordpress CMS.
|
||||
* Implements the CmsClient interface.
|
||||
*/
|
||||
class WordpressClient implements CmsClient {
|
||||
constructor(private readonly apiUrl: string) {}
|
||||
|
||||
/**
|
||||
* Retrieves content items from a CMS based on the provided options.
|
||||
*
|
||||
* @param {Cms.GetContentItemsOptions} options - The options to customize the retrieval of content items.
|
||||
*/
|
||||
async getContentItems(options: Cms.GetContentItemsOptions) {
|
||||
const queryParams = new URLSearchParams({
|
||||
_embed: 'true',
|
||||
});
|
||||
|
||||
if (options?.limit && options.limit !== Infinity) {
|
||||
queryParams.append('per_page', options.limit.toString());
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
queryParams.append('offset', options.offset.toString());
|
||||
}
|
||||
|
||||
if (options.sortBy) {
|
||||
const sortBy = mapSortByParam(options.sortBy);
|
||||
|
||||
if (sortBy) {
|
||||
queryParams.append('orderby', sortBy);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sortDirection) {
|
||||
queryParams.append('order', options.sortDirection);
|
||||
}
|
||||
|
||||
if (options?.categories) {
|
||||
const ids = await this.getCategories({
|
||||
slugs: options.categories,
|
||||
}).then((categories) => categories.map((category) => category.id));
|
||||
|
||||
if (ids.length) {
|
||||
queryParams.append('categories', ids.join(','));
|
||||
} else {
|
||||
console.warn(
|
||||
'No categories found for the provided slugs',
|
||||
options.categories,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.tags) {
|
||||
const allTags = [...options.tags, options.language ?? ''].filter(Boolean);
|
||||
|
||||
const ids = await this.getTags({
|
||||
slugs: allTags,
|
||||
}).then((tags) => tags.map((tag) => tag.id));
|
||||
|
||||
if (ids.length) {
|
||||
queryParams.append('tags', ids.join(','));
|
||||
} else {
|
||||
console.warn('No tags found for the provided slugs', options.tags);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.parentIds && options.parentIds.length > 0) {
|
||||
queryParams.append('parent', options.parentIds.join(','));
|
||||
}
|
||||
|
||||
const endpoints = [
|
||||
`/wp-json/wp/v2/posts?${queryParams.toString()}`,
|
||||
`/wp-json/wp/v2/pages?${queryParams.toString()}`,
|
||||
];
|
||||
|
||||
const endpoint =
|
||||
options.collection === 'posts' ? endpoints[0] : endpoints[1];
|
||||
|
||||
const url = `${this.apiUrl}${endpoint}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const totalHeader = response.headers.get('X-WP-Total');
|
||||
|
||||
const total = totalHeader ? Number(totalHeader) : 0;
|
||||
const results = (await response.json()) as WP_REST_API_Post[];
|
||||
|
||||
const status = options.status ?? 'published';
|
||||
|
||||
const postsResults = await Promise.allSettled(
|
||||
results.map(async (item: WP_REST_API_Post) => {
|
||||
let parentId: string | undefined;
|
||||
|
||||
if (!item) {
|
||||
throw new Error('Failed to fetch content items');
|
||||
}
|
||||
|
||||
if (item.parent) {
|
||||
parentId = item.parent.toString();
|
||||
}
|
||||
|
||||
const mappedStatus = mapToStatus(item.status as WP_Post_Status_Name);
|
||||
|
||||
if (mappedStatus !== status) {
|
||||
throw new Error('Status does not match');
|
||||
}
|
||||
|
||||
const categories = await this.getCategoriesByIds(item.categories ?? []);
|
||||
const tags = await this.getTagsByIds(item.tags ?? []);
|
||||
const image = item.featured_media ? this.getFeaturedMedia(item) : '';
|
||||
const order = item.menu_order ?? 0;
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
title: item.title.rendered,
|
||||
label: item.title.rendered,
|
||||
content: item.content.rendered,
|
||||
description: item.excerpt.rendered,
|
||||
image,
|
||||
url: item.link,
|
||||
slug: item.slug,
|
||||
publishedAt: item.date,
|
||||
status: mappedStatus ?? 'draft',
|
||||
categories: categories,
|
||||
tags: tags,
|
||||
parentId,
|
||||
order,
|
||||
children: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const posts = postsResults
|
||||
.filter((item) => item.status === 'fulfilled')
|
||||
.map((item) => item.value);
|
||||
|
||||
return {
|
||||
total,
|
||||
items: posts,
|
||||
};
|
||||
}
|
||||
|
||||
async getContentItemBySlug({
|
||||
slug,
|
||||
collection,
|
||||
status,
|
||||
}: {
|
||||
slug: string;
|
||||
collection: string;
|
||||
status?: Cms.ContentItemStatus;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams({
|
||||
_embed: 'true',
|
||||
slug,
|
||||
});
|
||||
|
||||
const endpoints = [
|
||||
`/wp-json/wp/v2/posts?${searchParams.toString()}`,
|
||||
`/wp-json/wp/v2/pages?${searchParams.toString()}`,
|
||||
];
|
||||
|
||||
const endpoint = collection === 'posts' ? endpoints[0] : endpoints[1];
|
||||
|
||||
const responses = await fetch(this.apiUrl + endpoint).then(
|
||||
(res) => res.json() as Promise<WP_REST_API_Post[]>,
|
||||
);
|
||||
|
||||
const item = responses[0];
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedStatus = status
|
||||
? mapToStatus(item.status as WP_Post_Status_Name)
|
||||
: undefined;
|
||||
const statusMatch = status ? mappedStatus === status : true;
|
||||
|
||||
if (!statusMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = await this.getCategoriesByIds(item.categories ?? []);
|
||||
const tags = await this.getTagsByIds(item.tags ?? []);
|
||||
const image = item.featured_media ? this.getFeaturedMedia(item) : '';
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
image,
|
||||
order: item.menu_order ?? 0,
|
||||
url: item.link,
|
||||
description: item.excerpt.rendered,
|
||||
children: [],
|
||||
title: item.title.rendered,
|
||||
label: item.title.rendered,
|
||||
content: item.content.rendered,
|
||||
slug: item.slug,
|
||||
publishedAt: item.date,
|
||||
status: mappedStatus ?? 'draft',
|
||||
categories,
|
||||
tags,
|
||||
parentId: item.parent?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getCategoryBySlug(slug: string) {
|
||||
const url = `${this.apiUrl}/wp-json/wp/v2/categories?slug=${slug}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = data[0] as WP_REST_API_Category;
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
async getTagBySlug(slug: string) {
|
||||
const url = `${this.apiUrl}/wp-json/wp/v2/tags?slug=${slug}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = data[0] as WP_REST_API_Tag;
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
async getCategories(options?: Cms.GetCategoriesOptions) {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (options?.limit) {
|
||||
queryParams.append('per_page', options.limit.toString());
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
queryParams.append('offset', options.offset.toString());
|
||||
}
|
||||
|
||||
if (options?.slugs) {
|
||||
const slugs = options.slugs.join(',');
|
||||
|
||||
queryParams.append('slug', slugs);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.apiUrl}/wp-json/wp/v2/categories?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch categories', await response.json());
|
||||
|
||||
throw new Error('Failed to fetch categories');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as WP_REST_API_Category[];
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTags(options: GetTagsOptions) {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (options?.limit) {
|
||||
queryParams.append('per_page', options.limit.toString());
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
queryParams.append('offset', options.offset.toString());
|
||||
}
|
||||
|
||||
if (options?.slugs) {
|
||||
const slugs = options.slugs.join(',');
|
||||
queryParams.append('slug', slugs);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.apiUrl}/wp-json/wp/v2/tags?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch tags', await response.json());
|
||||
|
||||
throw new Error('Failed to fetch tags');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as WP_REST_API_Tag[];
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getTagsByIds(ids: number[]) {
|
||||
const promises = ids.map((id) =>
|
||||
fetch(`${this.apiUrl}/wp-json/wp/v2/tags/${id}`),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
const data = (await Promise.all(
|
||||
responses.map((response) => response.json()),
|
||||
)) as WP_REST_API_Tag[];
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getCategoriesByIds(ids: number[]) {
|
||||
const promises = ids.map((id) =>
|
||||
fetch(`${this.apiUrl}/wp-json/wp/v2/categories/${id}`),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
const data = (await Promise.all(
|
||||
responses.map((response) => response.json()),
|
||||
)) as WP_REST_API_Category[];
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
private getFeaturedMedia(post: WP_REST_API_Post) {
|
||||
const embedded = post._embedded ?? {
|
||||
'wp:featuredmedia': [],
|
||||
};
|
||||
|
||||
const media = embedded['wp:featuredmedia'] ?? [];
|
||||
const item = media?.length > 0 ? media[0] : null;
|
||||
|
||||
return item
|
||||
? (
|
||||
item as {
|
||||
source_url: string;
|
||||
}
|
||||
).source_url
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function mapSortByParam(sortBy: string) {
|
||||
switch (sortBy) {
|
||||
case 'publishedAt':
|
||||
return 'date';
|
||||
case 'title':
|
||||
return 'title';
|
||||
case 'slug':
|
||||
return 'slug';
|
||||
case 'order':
|
||||
return 'menu_order';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function mapToStatus(status: WP_Post_Status_Name): Cms.ContentItemStatus {
|
||||
const Draft = 'draft' as WP_Post_Status_Name;
|
||||
const Publish = 'publish' as WP_Post_Status_Name;
|
||||
const Pending = 'pending' as WP_Post_Status_Name;
|
||||
|
||||
switch (status) {
|
||||
case Draft:
|
||||
return 'draft';
|
||||
|
||||
case Publish:
|
||||
return 'published';
|
||||
|
||||
case Pending:
|
||||
return 'pending';
|
||||
|
||||
default:
|
||||
return 'draft';
|
||||
}
|
||||
}
|
||||
8
packages/cms/wordpress/tsconfig.json
Normal file
8
packages/cms/wordpress/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
|
||||
<footer>
|
||||
</footer>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
function register_category_and_tag_with_pages(){
|
||||
/*add categories and tags to pages*/
|
||||
register_taxonomy_for_object_type('category', 'page');
|
||||
register_taxonomy_for_object_type('post_tag', 'page');
|
||||
}
|
||||
add_action( 'init', 'register_category_and_tag_with_pages');
|
||||
|
||||
function register_pre_get_category_and_tag_with_pages( $query ) {
|
||||
|
||||
if ( is_admin() || ! $query->is_main_query() ) {
|
||||
return;
|
||||
}
|
||||
/*view categories and tags archive pages */
|
||||
if($query->is_category && $query->is_main_query()){
|
||||
$query->set('post_type', array( 'post', 'page'));
|
||||
}
|
||||
if($query->is_tag && $query->is_main_query()){
|
||||
$query->set('post_type', array( 'post', 'page'));
|
||||
}
|
||||
}
|
||||
add_action( 'pre_get_posts', 'register_pre_get_category_and_tag_with_pages');
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html <?php language_attributes(); ?>>
|
||||
<head>
|
||||
<meta charset="<?php bloginfo( 'charset' ); ?>">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
|
||||
<body <?php body_class(); ?>>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="primary" class="content-area">
|
||||
<main id="main" class="site-main" role="main">
|
||||
|
||||
<?php
|
||||
if (have_posts()) :
|
||||
/* Start the Loop */
|
||||
while (have_posts()) : the_post();
|
||||
?>
|
||||
<div>
|
||||
<a href="<?php the_permalink(); ?>"><h3><?php the_title(); ?></h3></a>
|
||||
</div>
|
||||
<?php
|
||||
endwhile;
|
||||
/* End the Loop */
|
||||
else :
|
||||
// Nothing
|
||||
endif;
|
||||
?>
|
||||
|
||||
</main><!-- #main -->
|
||||
</div><!-- #primary -->
|
||||
</div><!-- .wrap -->
|
||||
|
||||
<?php get_footer(); ?>
|
||||
<?php wp_footer(); ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user