@mrtimmy/payload-connector 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/package.json +44 -0
- package/src/index.d.ts +52 -0
- package/src/index.js +267 -0
- package/src/utils.js +163 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# mr.timmy Payload Connector
|
|
2
|
+
|
|
3
|
+
Payload CMS connector for publishing blog posts from mr.timmy.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @mrtimmy/payload-connector
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add the plugin to `payload.config.ts`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { buildConfig } from "payload"
|
|
15
|
+
import { mrTimmyPayloadConnector } from "@mrtimmy/payload-connector"
|
|
16
|
+
|
|
17
|
+
export default buildConfig({
|
|
18
|
+
plugins: [
|
|
19
|
+
mrTimmyPayloadConnector({
|
|
20
|
+
postsCollection: "posts",
|
|
21
|
+
}),
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Deploy the Payload site after changing the config.
|
|
27
|
+
|
|
28
|
+
## Create a Connector Key
|
|
29
|
+
|
|
30
|
+
After deployment, open the Payload admin panel:
|
|
31
|
+
|
|
32
|
+
1. Go to `mr.timmy -> Connector Keys`.
|
|
33
|
+
2. Create a new key.
|
|
34
|
+
3. Copy the API URL and API key into mr.timmy under `Deine Kanäle -> Payload CMS`.
|
|
35
|
+
|
|
36
|
+
The default API URL is:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
https://your-domain.com/api/mrtimmy/create-post
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Endpoints
|
|
43
|
+
|
|
44
|
+
The plugin exposes:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
GET /api/mrtimmy/create-post
|
|
48
|
+
POST /api/mrtimmy/create-post
|
|
49
|
+
POST /api/mrtimmy/create-post/:id/publish
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requests must include:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
x-api-key: timmy_xxx
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`X-MarketingAI-Key` is also accepted for compatibility with the WordPress connector naming.
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
mrTimmyPayloadConnector({
|
|
64
|
+
postsCollection: "posts",
|
|
65
|
+
contentMode: "lexical",
|
|
66
|
+
fields: {
|
|
67
|
+
title: "title",
|
|
68
|
+
content: "content",
|
|
69
|
+
excerpt: "excerpt",
|
|
70
|
+
slug: "slug",
|
|
71
|
+
status: "_status",
|
|
72
|
+
metaTitle: "meta.title",
|
|
73
|
+
metaDescription: "meta.description",
|
|
74
|
+
focusKeyword: "focusKeyword",
|
|
75
|
+
featuredImageUrl: "featuredImage",
|
|
76
|
+
imageAlt: "imageAlt",
|
|
77
|
+
contentAssets: "contentAssets",
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`contentMode` can be:
|
|
83
|
+
|
|
84
|
+
- `lexical` (default): converts Markdown to HTML and then to Payload Lexical JSON.
|
|
85
|
+
- `html`: converts Markdown to HTML.
|
|
86
|
+
- `markdown`: stores Markdown as plain text.
|
|
87
|
+
|
|
88
|
+
If your Payload post schema uses different field names, override `fields`.
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- The plugin uses Payload Custom Endpoints and Payload Local API.
|
|
93
|
+
- Custom endpoints are authenticated by this plugin, not by Payload automatically.
|
|
94
|
+
- Draft/publish defaults to `_status: "draft" | "published"`, matching Payload draft-enabled collections.
|
|
95
|
+
- If your collection does not use drafts or uses a custom status field, configure `fields.status` and `statuses`, or set `fields.status` to `null`.
|
|
96
|
+
- Featured images are passed as URL fields in this MVP. Upload-collection integration is project-specific and should be added with a custom mapper later.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mrtimmy/payload-connector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Payload CMS connector for publishing mr.timmy blog posts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/mucahitoe/marketing-ai.git",
|
|
11
|
+
"directory": "payload-plugin/marketing-ai-payload-connector"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"import": "./src/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src/index.js",
|
|
24
|
+
"src/index.d.ts",
|
|
25
|
+
"src/utils.js",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"payload",
|
|
30
|
+
"payloadcms",
|
|
31
|
+
"payload-plugin",
|
|
32
|
+
"mr-timmy",
|
|
33
|
+
"marketing-ai"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"payload": "^3.0.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@payloadcms/richtext-lexical": "^3.85.1",
|
|
41
|
+
"jsdom": "^29.0.1",
|
|
42
|
+
"marked": "^18.0.4"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type ContentMode = "lexical" | "html" | "markdown"
|
|
2
|
+
|
|
3
|
+
export type FieldMap = {
|
|
4
|
+
title?: string | null
|
|
5
|
+
content?: string | null
|
|
6
|
+
excerpt?: string | null
|
|
7
|
+
slug?: string | null
|
|
8
|
+
status?: string | null
|
|
9
|
+
metaTitle?: string | null
|
|
10
|
+
metaDescription?: string | null
|
|
11
|
+
focusKeyword?: string | null
|
|
12
|
+
featuredImageUrl?: string | null
|
|
13
|
+
imageAlt?: string | null
|
|
14
|
+
contentAssets?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ConnectorStatuses = {
|
|
18
|
+
draft?: string
|
|
19
|
+
published?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type MrTimmyPayloadConnectorOptions = {
|
|
23
|
+
enabled?: boolean
|
|
24
|
+
postsCollection?: string
|
|
25
|
+
keysCollection?: string
|
|
26
|
+
endpointPath?: string
|
|
27
|
+
keyPrefix?: string
|
|
28
|
+
headerNames?: string[]
|
|
29
|
+
contentMode?: ContentMode
|
|
30
|
+
fields?: FieldMap
|
|
31
|
+
statuses?: ConnectorStatuses
|
|
32
|
+
mapCreateData?: (
|
|
33
|
+
data: Record<string, unknown>,
|
|
34
|
+
input: Record<string, unknown>,
|
|
35
|
+
req: unknown,
|
|
36
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
37
|
+
mapPublishData?: (
|
|
38
|
+
data: Record<string, unknown>,
|
|
39
|
+
input: Record<string, unknown>,
|
|
40
|
+
req: unknown,
|
|
41
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
42
|
+
resolvePublishedUrl?: (
|
|
43
|
+
doc: Record<string, unknown>,
|
|
44
|
+
req: unknown,
|
|
45
|
+
) => string | undefined | Promise<string | undefined>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export declare function mrTimmyPayloadConnector(
|
|
49
|
+
options?: MrTimmyPayloadConnectorOptions,
|
|
50
|
+
): (incomingConfig: Record<string, unknown>) => Record<string, unknown>
|
|
51
|
+
|
|
52
|
+
export default mrTimmyPayloadConnector
|
package/src/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ENDPOINT_PATH,
|
|
3
|
+
DEFAULT_HEADER_NAMES,
|
|
4
|
+
DEFAULT_KEYS_COLLECTION,
|
|
5
|
+
buildApiUrlHint,
|
|
6
|
+
buildPostData,
|
|
7
|
+
buildPublishData,
|
|
8
|
+
constantTimeEquals,
|
|
9
|
+
generateConnectorKey,
|
|
10
|
+
maskKey,
|
|
11
|
+
normalizeEndpointPath,
|
|
12
|
+
readConnectorHeader,
|
|
13
|
+
} from "./utils.js"
|
|
14
|
+
|
|
15
|
+
const VERSION = "0.1.0"
|
|
16
|
+
|
|
17
|
+
export function mrTimmyPayloadConnector(pluginOptions = {}) {
|
|
18
|
+
return (incomingConfig = {}) => {
|
|
19
|
+
if (pluginOptions.enabled === false) return incomingConfig
|
|
20
|
+
|
|
21
|
+
const options = normalizeOptions(pluginOptions)
|
|
22
|
+
const existingCollections = incomingConfig.collections ?? []
|
|
23
|
+
const hasKeysCollection = existingCollections.some(
|
|
24
|
+
(collection) => collection?.slug === options.keysCollection,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
...incomingConfig,
|
|
29
|
+
collections: [
|
|
30
|
+
...existingCollections,
|
|
31
|
+
...(hasKeysCollection ? [] : [createKeysCollection(options)]),
|
|
32
|
+
],
|
|
33
|
+
endpoints: [
|
|
34
|
+
...(incomingConfig.endpoints ?? []),
|
|
35
|
+
...createConnectorEndpoints(options),
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default mrTimmyPayloadConnector
|
|
42
|
+
|
|
43
|
+
function normalizeOptions(options) {
|
|
44
|
+
return {
|
|
45
|
+
...options,
|
|
46
|
+
postsCollection: options.postsCollection ?? "posts",
|
|
47
|
+
keysCollection: options.keysCollection ?? DEFAULT_KEYS_COLLECTION,
|
|
48
|
+
endpointPath: normalizeEndpointPath(
|
|
49
|
+
options.endpointPath ?? DEFAULT_ENDPOINT_PATH,
|
|
50
|
+
),
|
|
51
|
+
keyPrefix: options.keyPrefix ?? "timmy",
|
|
52
|
+
headerNames: options.headerNames ?? DEFAULT_HEADER_NAMES,
|
|
53
|
+
contentMode: options.contentMode ?? "lexical",
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAuthed({ req }) {
|
|
58
|
+
return Boolean(req.user)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createKeysCollection(options) {
|
|
62
|
+
return {
|
|
63
|
+
slug: options.keysCollection,
|
|
64
|
+
labels: {
|
|
65
|
+
singular: "mr.timmy Connector-Schlüssel",
|
|
66
|
+
plural: "mr.timmy Connector-Schlüssel",
|
|
67
|
+
},
|
|
68
|
+
admin: {
|
|
69
|
+
group: "mr.timmy",
|
|
70
|
+
useAsTitle: "name",
|
|
71
|
+
defaultColumns: ["name", "apiUrl", "keyPreview", "enabled", "updatedAt"],
|
|
72
|
+
description:
|
|
73
|
+
"Schlüssel und API-URL für die Verbindung zwischen dieser Payload-Seite und mr.timmy.",
|
|
74
|
+
},
|
|
75
|
+
access: {
|
|
76
|
+
create: isAuthed,
|
|
77
|
+
read: isAuthed,
|
|
78
|
+
update: isAuthed,
|
|
79
|
+
delete: isAuthed,
|
|
80
|
+
},
|
|
81
|
+
fields: [
|
|
82
|
+
{
|
|
83
|
+
name: "name",
|
|
84
|
+
type: "text",
|
|
85
|
+
required: true,
|
|
86
|
+
defaultValue: "mr.timmy",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "enabled",
|
|
90
|
+
type: "checkbox",
|
|
91
|
+
defaultValue: true,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "apiUrl",
|
|
95
|
+
type: "text",
|
|
96
|
+
admin: {
|
|
97
|
+
readOnly: true,
|
|
98
|
+
description:
|
|
99
|
+
"Diese URL in mr.timmy unter Deine Kanäle -> Payload CMS eintragen.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "apiKey",
|
|
104
|
+
label: "API-Key",
|
|
105
|
+
type: "text",
|
|
106
|
+
admin: {
|
|
107
|
+
readOnly: true,
|
|
108
|
+
description:
|
|
109
|
+
"Diesen Schlüssel in mr.timmy eintragen. Zum Rotieren einen neuen Schlüssel anlegen und den alten deaktivieren.",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "keyPreview",
|
|
114
|
+
type: "text",
|
|
115
|
+
admin: {
|
|
116
|
+
readOnly: true,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
hooks: {
|
|
121
|
+
beforeValidate: [
|
|
122
|
+
({ data = {}, operation, req }) => {
|
|
123
|
+
const next = { ...data }
|
|
124
|
+
if (operation === "create" && !next.apiKey) {
|
|
125
|
+
next.apiKey = generateConnectorKey(options.keyPrefix)
|
|
126
|
+
}
|
|
127
|
+
if (next.apiKey) {
|
|
128
|
+
next.keyPreview = maskKey(next.apiKey)
|
|
129
|
+
}
|
|
130
|
+
next.apiUrl = buildApiUrlHint(req, options.endpointPath)
|
|
131
|
+
return next
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function createConnectorEndpoints(options) {
|
|
139
|
+
const path = options.endpointPath
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
path,
|
|
143
|
+
method: "get",
|
|
144
|
+
handler: async (req) => {
|
|
145
|
+
const auth = await authenticateConnectorRequest(req, options)
|
|
146
|
+
if (!auth.ok) return connectorError(auth.error, auth.status)
|
|
147
|
+
|
|
148
|
+
return Response.json({
|
|
149
|
+
status: "connected",
|
|
150
|
+
ok: true,
|
|
151
|
+
connector: "mrtimmy-payload-connector",
|
|
152
|
+
version: VERSION,
|
|
153
|
+
apiUrl: buildApiUrlHint(req, path),
|
|
154
|
+
})
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
path,
|
|
159
|
+
method: "post",
|
|
160
|
+
handler: async (req) => {
|
|
161
|
+
const auth = await authenticateConnectorRequest(req, options)
|
|
162
|
+
if (!auth.ok) return connectorError(auth.error, auth.status)
|
|
163
|
+
|
|
164
|
+
const input = await req.json().catch(() => null)
|
|
165
|
+
if (!input || typeof input !== "object") {
|
|
166
|
+
return connectorError("Ungültiger JSON-Body.", 400)
|
|
167
|
+
}
|
|
168
|
+
if (!input.title || typeof input.title !== "string") {
|
|
169
|
+
return connectorError("Titel fehlt.", 400)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const data = await buildPostData(input, options, req)
|
|
173
|
+
const doc = await req.payload.create({
|
|
174
|
+
collection: options.postsCollection,
|
|
175
|
+
data,
|
|
176
|
+
depth: 0,
|
|
177
|
+
draft: true,
|
|
178
|
+
overrideAccess: true,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return Response.json({
|
|
182
|
+
success: true,
|
|
183
|
+
id: String(doc.id),
|
|
184
|
+
slug: doc.slug,
|
|
185
|
+
status: "draft",
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
path: `${path}/:id/publish`,
|
|
191
|
+
method: "post",
|
|
192
|
+
handler: async (req) => {
|
|
193
|
+
const auth = await authenticateConnectorRequest(req, options)
|
|
194
|
+
if (!auth.ok) return connectorError(auth.error, auth.status)
|
|
195
|
+
|
|
196
|
+
const id = req.routeParams?.id
|
|
197
|
+
if (!id || typeof id !== "string") {
|
|
198
|
+
return connectorError("Post-ID fehlt.", 400)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const input = await req.json().catch(() => ({}))
|
|
202
|
+
const data = await buildPublishData(options, input, req)
|
|
203
|
+
const doc = await req.payload.update({
|
|
204
|
+
collection: options.postsCollection,
|
|
205
|
+
id,
|
|
206
|
+
data,
|
|
207
|
+
depth: 0,
|
|
208
|
+
draft: false,
|
|
209
|
+
overrideAccess: true,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const url =
|
|
213
|
+
typeof options.resolvePublishedUrl === "function"
|
|
214
|
+
? await options.resolvePublishedUrl(doc, req)
|
|
215
|
+
: defaultPublishedUrl(doc, req)
|
|
216
|
+
|
|
217
|
+
return Response.json({
|
|
218
|
+
success: true,
|
|
219
|
+
id: String(doc.id),
|
|
220
|
+
slug: doc.slug,
|
|
221
|
+
status: "published",
|
|
222
|
+
...(url ? { url, publishedUrl: url } : {}),
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function authenticateConnectorRequest(req, options) {
|
|
230
|
+
const provided = readConnectorHeader(req, options.headerNames)
|
|
231
|
+
if (!provided) {
|
|
232
|
+
return { ok: false, status: 401, error: "API-Key fehlt." }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const result = await req.payload.find({
|
|
236
|
+
collection: options.keysCollection,
|
|
237
|
+
where: {
|
|
238
|
+
and: [
|
|
239
|
+
{ enabled: { equals: true } },
|
|
240
|
+
{ apiKey: { equals: provided } },
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
depth: 0,
|
|
244
|
+
limit: 1,
|
|
245
|
+
overrideAccess: true,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const key = result.docs?.[0]?.apiKey
|
|
249
|
+
if (!key || !constantTimeEquals(key, provided)) {
|
|
250
|
+
return { ok: false, status: 401, error: "API-Key stimmt nicht." }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { ok: true }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function connectorError(message, status = 500) {
|
|
257
|
+
return Response.json({ success: false, error: message, message }, { status })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function defaultPublishedUrl(doc, req) {
|
|
261
|
+
if (typeof doc.url === "string") return doc.url
|
|
262
|
+
if (typeof doc.publishedUrl === "string") return doc.publishedUrl
|
|
263
|
+
if (!doc.slug) return undefined
|
|
264
|
+
|
|
265
|
+
const origin = buildApiUrlHint(req, "").replace(/\/api\/?$/, "")
|
|
266
|
+
return origin ? `${origin}/blog/${doc.slug}` : undefined
|
|
267
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import crypto from "node:crypto"
|
|
2
|
+
|
|
3
|
+
const optionalImport = (specifier) => import(/* @vite-ignore */ specifier)
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_ENDPOINT_PATH = "/mrtimmy/create-post"
|
|
6
|
+
export const DEFAULT_KEYS_COLLECTION = "mrtimmy-connector-keys"
|
|
7
|
+
export const DEFAULT_HEADER_NAMES = ["x-api-key", "X-MarketingAI-Key"]
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_FIELDS = {
|
|
10
|
+
title: "title",
|
|
11
|
+
content: "content",
|
|
12
|
+
excerpt: "excerpt",
|
|
13
|
+
slug: "slug",
|
|
14
|
+
status: "_status",
|
|
15
|
+
metaTitle: "meta.title",
|
|
16
|
+
metaDescription: "meta.description",
|
|
17
|
+
focusKeyword: "focusKeyword",
|
|
18
|
+
featuredImageUrl: "featuredImage",
|
|
19
|
+
imageAlt: "imageAlt",
|
|
20
|
+
contentAssets: "contentAssets",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_STATUSES = {
|
|
24
|
+
draft: "draft",
|
|
25
|
+
published: "published",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateConnectorKey(prefix = "timmy") {
|
|
29
|
+
return `${prefix}_${crypto.randomBytes(32).toString("hex")}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function maskKey(key) {
|
|
33
|
+
if (!key) return ""
|
|
34
|
+
return `••••••••••••${String(key).slice(-6)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function constantTimeEquals(a, b) {
|
|
38
|
+
const left = Buffer.from(String(a ?? ""))
|
|
39
|
+
const right = Buffer.from(String(b ?? ""))
|
|
40
|
+
if (left.length !== right.length) return false
|
|
41
|
+
return crypto.timingSafeEqual(left, right)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeEndpointPath(path = DEFAULT_ENDPOINT_PATH) {
|
|
45
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`
|
|
46
|
+
return withSlash.replace(/\/+$/, "")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeFields(fields = {}) {
|
|
50
|
+
return { ...DEFAULT_FIELDS, ...fields }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setPath(target, path, value) {
|
|
54
|
+
if (!path || value === undefined || value === null) return target
|
|
55
|
+
const parts = String(path).split(".").filter(Boolean)
|
|
56
|
+
if (parts.length === 0) return target
|
|
57
|
+
let cursor = target
|
|
58
|
+
for (const part of parts.slice(0, -1)) {
|
|
59
|
+
if (
|
|
60
|
+
typeof cursor[part] !== "object" ||
|
|
61
|
+
cursor[part] === null ||
|
|
62
|
+
Array.isArray(cursor[part])
|
|
63
|
+
) {
|
|
64
|
+
cursor[part] = {}
|
|
65
|
+
}
|
|
66
|
+
cursor = cursor[part]
|
|
67
|
+
}
|
|
68
|
+
cursor[parts[parts.length - 1]] = value
|
|
69
|
+
return target
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function extractExcerpt(markdown = "") {
|
|
73
|
+
const lines = String(markdown).split("\n")
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const trimmed = line.trim()
|
|
76
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("![")) continue
|
|
77
|
+
return trimmed
|
|
78
|
+
.replace(/[*_`>]/g, "")
|
|
79
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
80
|
+
.slice(0, 300)
|
|
81
|
+
}
|
|
82
|
+
return ""
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildOriginFromRequest(req) {
|
|
86
|
+
const headers = req?.headers
|
|
87
|
+
const host = headers?.get?.("x-forwarded-host") ?? headers?.get?.("host")
|
|
88
|
+
if (!host) return ""
|
|
89
|
+
const proto = headers?.get?.("x-forwarded-proto") ?? "https"
|
|
90
|
+
return `${proto}://${host}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildApiUrlHint(req, endpointPath) {
|
|
94
|
+
const origin = buildOriginFromRequest(req)
|
|
95
|
+
return origin ? `${origin}/api${endpointPath}` : `/api${endpointPath}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
|
|
99
|
+
for (const name of headerNames) {
|
|
100
|
+
const value = req?.headers?.get?.(name)
|
|
101
|
+
if (value) return value
|
|
102
|
+
}
|
|
103
|
+
return ""
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function markdownToHtml(markdown) {
|
|
107
|
+
const { marked } = await import("marked")
|
|
108
|
+
return marked.parse(String(markdown ?? ""))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function convertContent(markdown, mode, req) {
|
|
112
|
+
if (mode === "markdown") return String(markdown ?? "")
|
|
113
|
+
const html = await markdownToHtml(markdown)
|
|
114
|
+
if (mode === "html") return html
|
|
115
|
+
|
|
116
|
+
const [{ convertHTMLToLexical, editorConfigFactory }, { JSDOM }] =
|
|
117
|
+
await Promise.all([
|
|
118
|
+
optionalImport("@payloadcms/richtext-lexical"),
|
|
119
|
+
optionalImport("jsdom"),
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
return convertHTMLToLexical({
|
|
123
|
+
editorConfig: await editorConfigFactory.default({
|
|
124
|
+
config: req.payload.config,
|
|
125
|
+
}),
|
|
126
|
+
html,
|
|
127
|
+
JSDOM,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function buildPostData(input, options, req) {
|
|
132
|
+
const fields = normalizeFields(options.fields)
|
|
133
|
+
const statuses = { ...DEFAULT_STATUSES, ...(options.statuses ?? {}) }
|
|
134
|
+
const contentMode = options.contentMode ?? "lexical"
|
|
135
|
+
const markdown = input.content ?? input.body ?? ""
|
|
136
|
+
const data = {}
|
|
137
|
+
|
|
138
|
+
setPath(data, fields.title, input.title)
|
|
139
|
+
setPath(data, fields.content, await convertContent(markdown, contentMode, req))
|
|
140
|
+
setPath(data, fields.excerpt, input.excerpt ?? extractExcerpt(markdown))
|
|
141
|
+
setPath(data, fields.slug, input.slug)
|
|
142
|
+
setPath(data, fields.metaTitle, input.metaTitle)
|
|
143
|
+
setPath(data, fields.metaDescription, input.metaDescription)
|
|
144
|
+
setPath(data, fields.focusKeyword, input.focusKeyword)
|
|
145
|
+
setPath(data, fields.featuredImageUrl, input.featuredImage)
|
|
146
|
+
setPath(data, fields.imageAlt, input.imageAlt)
|
|
147
|
+
setPath(data, fields.contentAssets, input.contentAssets)
|
|
148
|
+
setPath(data, fields.status, statuses.draft)
|
|
149
|
+
|
|
150
|
+
return typeof options.mapCreateData === "function"
|
|
151
|
+
? await options.mapCreateData(data, input, req)
|
|
152
|
+
: data
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function buildPublishData(options, input, req) {
|
|
156
|
+
const fields = normalizeFields(options.fields)
|
|
157
|
+
const statuses = { ...DEFAULT_STATUSES, ...(options.statuses ?? {}) }
|
|
158
|
+
const data = {}
|
|
159
|
+
setPath(data, fields.status, statuses.published)
|
|
160
|
+
return typeof options.mapPublishData === "function"
|
|
161
|
+
? await options.mapPublishData(data, input, req)
|
|
162
|
+
: data
|
|
163
|
+
}
|