@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 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
+ }