From 15e6ba0df015f5ad4575ddbdfcd8cfcd7eda4950 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 12:19:01 +0200 Subject: [PATCH] WordPress/Ghost publishers, media providers, and deploy hooks --- .env.example | 39 ++- README.md | 24 +- apps/studio/package.json | 5 +- apps/studio/server/config.ts | 127 +++++++++ apps/studio/server/demoMedia.ts | 2 + apps/studio/server/demoMode.ts | 36 +++ apps/studio/server/deployHook.test.ts | 68 +++++ apps/studio/server/deployHook.ts | 166 ++++++++++++ apps/studio/server/http.ts | 5 + apps/studio/server/media.ts | 28 +- .../server/mediaProviderRuntime.test.ts | 26 ++ apps/studio/server/mediaProviderRuntime.ts | 94 +++++++ apps/studio/server/publish.ts | 73 +++++- apps/studio/server/publisherRuntime.ts | 23 ++ apps/studio/server/runtimeConfig.test.ts | 27 ++ apps/studio/server/setupHealth.ts | 68 +++++ apps/studio/src/App.tsx | 7 +- apps/studio/src/lib/media.ts | 3 + apps/studio/src/lib/publish.ts | 9 + docs/configuration.md | 39 ++- docs/deploy-hooks.md | 69 +++++ docs/ghost.md | 75 ++++++ docs/media.md | 34 ++- docs/publishers.md | 50 ++++ docs/wordpress.md | 99 +++++++ package.json | 2 +- packages/media-providers/package.json | 26 ++ .../src/cloudinary/cloudinaryProvider.test.ts | 95 +++++++ .../src/cloudinary/cloudinaryProvider.ts | 123 +++++++++ .../src/cloudinary/cloudinarySignature.ts | 13 + .../src/cloudinaryMediaProvider.ts | 44 ++++ .../src/githubMediaProvider.ts | 55 ++++ packages/media-providers/src/http.ts | 13 + packages/media-providers/src/index.ts | 26 ++ .../src/mediaProviderRegistry.ts | 49 ++++ .../src/registerBuiltInMediaProviders.ts | 12 + packages/media-providers/src/registry.test.ts | 43 ++++ packages/media-providers/src/s3/s3Config.ts | 73 ++++++ .../media-providers/src/s3/s3Provider.test.ts | 69 +++++ packages/media-providers/src/s3/s3Provider.ts | 32 +++ .../media-providers/src/s3MediaProvider.ts | 45 ++++ packages/media-providers/src/types.ts | 64 +++++ packages/media-providers/tsconfig.json | 23 ++ .../src/bitbucketPublisherAdapter.ts | 3 + packages/publishers/src/cmsPayload.ts | 39 +++ packages/publishers/src/ghost/ghostErrors.ts | 34 +++ .../publishers/src/ghost/ghostJwt.test.ts | 53 ++++ packages/publishers/src/ghost/ghostJwt.ts | 61 +++++ .../src/ghost/ghostPublisher.test.ts | 125 +++++++++ .../publishers/src/ghost/ghostPublisher.ts | 243 ++++++++++++++++++ .../publishers/src/ghostPublisherAdapter.ts | 124 +++++++++ .../publishers/src/githubPublisherAdapter.ts | 3 + .../publishers/src/gitlabPublisherAdapter.ts | 3 + packages/publishers/src/index.ts | 2 + .../src/registerBuiltInPublishers.ts | 4 + packages/publishers/src/registry.test.ts | 56 +++- packages/publishers/src/types.ts | 51 +++- .../src/wordpress/wordpressErrors.ts | 38 +++ .../src/wordpress/wordpressPublisher.test.ts | 133 ++++++++++ .../src/wordpress/wordpressPublisher.ts | 226 ++++++++++++++++ .../src/wordpressPublisherAdapter.ts | 157 +++++++++++ pnpm-lock.yaml | 15 ++ 62 files changed, 3339 insertions(+), 34 deletions(-) create mode 100644 apps/studio/server/deployHook.test.ts create mode 100644 apps/studio/server/deployHook.ts create mode 100644 apps/studio/server/http.ts create mode 100644 apps/studio/server/mediaProviderRuntime.test.ts create mode 100644 apps/studio/server/mediaProviderRuntime.ts create mode 100644 docs/deploy-hooks.md create mode 100644 docs/ghost.md create mode 100644 docs/publishers.md create mode 100644 docs/wordpress.md create mode 100644 packages/media-providers/package.json create mode 100644 packages/media-providers/src/cloudinary/cloudinaryProvider.test.ts create mode 100644 packages/media-providers/src/cloudinary/cloudinaryProvider.ts create mode 100644 packages/media-providers/src/cloudinary/cloudinarySignature.ts create mode 100644 packages/media-providers/src/cloudinaryMediaProvider.ts create mode 100644 packages/media-providers/src/githubMediaProvider.ts create mode 100644 packages/media-providers/src/http.ts create mode 100644 packages/media-providers/src/index.ts create mode 100644 packages/media-providers/src/mediaProviderRegistry.ts create mode 100644 packages/media-providers/src/registerBuiltInMediaProviders.ts create mode 100644 packages/media-providers/src/registry.test.ts create mode 100644 packages/media-providers/src/s3/s3Config.ts create mode 100644 packages/media-providers/src/s3/s3Provider.test.ts create mode 100644 packages/media-providers/src/s3/s3Provider.ts create mode 100644 packages/media-providers/src/s3MediaProvider.ts create mode 100644 packages/media-providers/src/types.ts create mode 100644 packages/media-providers/tsconfig.json create mode 100644 packages/publishers/src/cmsPayload.ts create mode 100644 packages/publishers/src/ghost/ghostErrors.ts create mode 100644 packages/publishers/src/ghost/ghostJwt.test.ts create mode 100644 packages/publishers/src/ghost/ghostJwt.ts create mode 100644 packages/publishers/src/ghost/ghostPublisher.test.ts create mode 100644 packages/publishers/src/ghost/ghostPublisher.ts create mode 100644 packages/publishers/src/ghostPublisherAdapter.ts create mode 100644 packages/publishers/src/wordpress/wordpressErrors.ts create mode 100644 packages/publishers/src/wordpress/wordpressPublisher.test.ts create mode 100644 packages/publishers/src/wordpress/wordpressPublisher.ts create mode 100644 packages/publishers/src/wordpressPublisherAdapter.ts diff --git a/.env.example b/.env.example index 793e4bc..9c9bd80 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ SOURCEDRAFT_ADMIN_PASSWORD= # Set to true to run Studio in demo mode (no remote commits) SOURCEDRAFT_DEMO_MODE= -# Publisher: github (default), gitlab, or bitbucket — also set in sourcedraft.config.json +# Publisher: github, gitlab, bitbucket, wordpress, or ghost — also set in sourcedraft.config.json CMS_PUBLISHER=github # GitHub (when CMS_PUBLISHER=github) @@ -28,6 +28,43 @@ BITBUCKET_REPO_SLUG= BITBUCKET_BRANCH=main BITBUCKET_USERNAME= +# WordPress (when CMS_PUBLISHER=wordpress) — server-side only +WORDPRESS_API_URL= +WORDPRESS_USERNAME= +WORDPRESS_APP_PASSWORD= +WORDPRESS_DEFAULT_STATUS=draft +WORDPRESS_DEFAULT_AUTHOR= + +# Ghost (when CMS_PUBLISHER=ghost) — server-side only +GHOST_ADMIN_URL= +GHOST_ADMIN_API_KEY= +GHOST_ACCEPT_VERSION=v5.126 +GHOST_DEFAULT_STATUS=draft + +# Media storage provider: github-media (default), cloudinary, or s3-compatible +CMS_MEDIA_PROVIDER=github-media + +# Cloudinary (when CMS_MEDIA_PROVIDER=cloudinary) — server-side only +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= +CLOUDINARY_FOLDER= + +# S3-compatible (when CMS_MEDIA_PROVIDER=s3-compatible) — experimental +S3_ENDPOINT= +S3_REGION= +S3_BUCKET= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_PUBLIC_BASE_URL= +S3_FORCE_PATH_STYLE=false + +# Optional deploy hook after successful publish — server-side only +DEPLOY_HOOK_URL= +DEPLOY_HOOK_METHOD=POST +DEPLOY_HOOK_PROVIDER=generic +DEPLOY_HOOK_STRICT=false + # Optional overrides for sourcedraft.config.json CMS_CONTENT_DIR= CMS_MEDIA_DIR= diff --git a/README.md b/README.md index 0f7d049..2a48c5e 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ Your static site — Astro today, others later — still builds and deploys exac - List and edit existing posts from your GitHub `contentDir` - Validate fields against a universal article schema - Preview Markdown or Astro MDX output and target file path before publishing -- Publish to GitHub, GitLab, or Bitbucket (create or update a file on a branch) -- Upload images to the configured remote (`mediaDir`) from Studio +- Publish to Git hosts (GitHub, GitLab, Bitbucket) or remote CMS APIs (WordPress, Ghost) +- Upload images to git `mediaDir`, Cloudinary, or (experimental) S3-compatible storage +- Optional deploy hooks after publish (Vercel, Netlify, Cloudflare Pages, generic) - Configure paths, adapter, and categories in `sourcedraft.config.json` - Protect Studio with a server-side admin password - **Demo mode** — explore Studio with sample posts without GitHub credentials @@ -47,7 +48,7 @@ Your static site — Astro today, others later — still builds and deploys exac - Host your website or run your Astro build - OAuth, user accounts, or role-based access -- Cloud image hosts (Cloudinary, S3, R2, etc.) +- Full S3/R2 media upload (config validation only today; Cloudinary supported) - Adapters beyond `astro-mdx` and `markdown` See [docs/project-status.md](docs/project-status.md). @@ -57,14 +58,19 @@ See [docs/project-status.md](docs/project-status.md). 1. You finish a valid article in Studio and click **Publish**. 2. The **publish API** (server only) validates the article again. 3. The configured **adapter** builds the file (YAML frontmatter + body) as `.mdx` or `.md`. -4. The configured **publisher** (`github`, `gitlab`, or `bitbucket`) commits the file to your repository. -5. Your existing CI or build step picks up the new file from `contentDir`. +4. The configured **publisher** sends content to your target — Git file commit or remote CMS API. +5. For Git publishers, your CI or build step picks up the new file from `contentDir`. API tokens never reach the browser. They are read from `.env` on the server when you publish or upload media. -Details: [docs/git-publishers.md](docs/git-publishers.md) · [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md) +Details: [docs/publishers.md](docs/publishers.md) · [docs/git-publishers.md](docs/git-publishers.md) · [docs/wordpress.md](docs/wordpress.md) · [docs/ghost.md](docs/ghost.md) -**Compatibility:** GitHub (Contents API), GitLab (Repository Files API), and Bitbucket Cloud (commit-upload). GitHub and GitLab support listing/editing existing posts; Bitbucket supports publish and media upload only today. +**Compatibility** + +| Kind | Publishers | Notes | +|------|------------|-------| +| Git file | GitHub, GitLab, Bitbucket | Commit `.md`/`.mdx` to a repo; list/edit in Studio for GitHub/GitLab | +| Remote CMS | WordPress, Ghost | Server-side API connectors; updates need `remoteId` from prior publish | ## Quickstart @@ -147,7 +153,11 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo - [Getting started](docs/getting-started.md) - [Demo mode](docs/demo-mode.md) - [Non-technical overview](docs/non-technical-overview.md) — for writers +- [Publishers overview](docs/publishers.md) - [Git publishing (GitHub, GitLab, Bitbucket)](docs/git-publishers.md) +- [WordPress publishing](docs/wordpress.md) +- [Ghost publishing](docs/ghost.md) +- [Deploy hooks](docs/deploy-hooks.md) - [GitHub publishing](docs/github-publishing.md) - [Media uploads](docs/media.md) - [Configuration](docs/configuration.md) diff --git a/apps/studio/package.json b/apps/studio/package.json index 606057c..9185a27 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "predev": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", - "prebuild": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", + "predev": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", + "prebuild": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", "dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch server/index.ts\"", "dev:web": "vite", "dev:server": "tsx watch server/index.ts", @@ -32,6 +32,7 @@ "@sourcedraft/publishers": "workspace:*", "@sourcedraft/core": "workspace:*", "@sourcedraft/github-publisher": "workspace:*", + "@sourcedraft/media-providers": "workspace:*", "busboy": "^1.6.0", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/apps/studio/server/config.ts b/apps/studio/server/config.ts index aeceab4..33ef7a1 100644 --- a/apps/studio/server/config.ts +++ b/apps/studio/server/config.ts @@ -36,6 +36,15 @@ export type PublishEnvConfig = { gitlabProjectRef?: string; gitlabBaseUrl?: string; bitbucketUsername?: string; + wordpressApiUrl?: string; + wordpressUsername?: string; + wordpressAppPassword?: string; + wordpressDefaultStatus?: string; + wordpressDefaultAuthor?: number; + ghostAdminUrl?: string; + ghostAdminApiKey?: string; + ghostAcceptVersion?: string; + ghostDefaultStatus?: string; }; export type PublishEnvResult = @@ -88,9 +97,27 @@ type PublisherCredentialsResult = gitlabProjectRef?: string; gitlabBaseUrl?: string; bitbucketUsername?: string; + wordpressApiUrl?: string; + wordpressUsername?: string; + wordpressAppPassword?: string; + wordpressDefaultStatus?: string; + wordpressDefaultAuthor?: number; + ghostAdminUrl?: string; + ghostAdminApiKey?: string; + ghostAcceptVersion?: string; + ghostDefaultStatus?: string; } | { ok: false; error: string }; +function parseOptionalAuthorId(raw: string | undefined): number | undefined { + if (!raw) { + return undefined; + } + + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + function resolvePublisherCredentials( publisher: SupportedPublisher, defaultBranch: string, @@ -126,6 +153,73 @@ function resolvePublisherCredentials( }; } + if (publisher === "wordpress") { + const apiUrl = process.env.WORDPRESS_API_URL?.trim(); + const username = process.env.WORDPRESS_USERNAME?.trim(); + const appPassword = process.env.WORDPRESS_APP_PASSWORD?.trim(); + const defaultStatus = + process.env.WORDPRESS_DEFAULT_STATUS?.trim() || "draft"; + const defaultAuthor = parseOptionalAuthorId( + process.env.WORDPRESS_DEFAULT_AUTHOR?.trim(), + ); + + if (!apiUrl) { + return { ok: false, error: "WORDPRESS_API_URL is not configured." }; + } + + if (!username) { + return { ok: false, error: "WORDPRESS_USERNAME is not configured." }; + } + + if (!appPassword) { + return { + ok: false, + error: "WORDPRESS_APP_PASSWORD is not configured.", + }; + } + + return { + ok: true, + token: "", + owner: "", + repo: "", + branch: defaultBranch, + wordpressApiUrl: apiUrl, + wordpressUsername: username, + wordpressAppPassword: appPassword, + wordpressDefaultStatus: defaultStatus, + ...(defaultAuthor !== undefined ? { wordpressDefaultAuthor: defaultAuthor } : {}), + }; + } + + if (publisher === "ghost") { + const adminUrl = process.env.GHOST_ADMIN_URL?.trim(); + const adminApiKey = process.env.GHOST_ADMIN_API_KEY?.trim(); + const acceptVersion = + process.env.GHOST_ACCEPT_VERSION?.trim() || "v5.126"; + const defaultStatus = process.env.GHOST_DEFAULT_STATUS?.trim() || "draft"; + + if (!adminUrl) { + return { ok: false, error: "GHOST_ADMIN_URL is not configured." }; + } + + if (!adminApiKey) { + return { ok: false, error: "GHOST_ADMIN_API_KEY is not configured." }; + } + + return { + ok: true, + token: "", + owner: "", + repo: "", + branch: defaultBranch, + ghostAdminUrl: adminUrl, + ghostAdminApiKey: adminApiKey, + ghostAcceptVersion: acceptVersion, + ghostDefaultStatus: defaultStatus, + }; + } + if (publisher === "bitbucket") { const token = process.env.BITBUCKET_TOKEN?.trim(); const owner = process.env.BITBUCKET_WORKSPACE?.trim(); @@ -233,6 +327,33 @@ export function loadPublishEnv(): PublishEnvResult { ...(credentials.bitbucketUsername !== undefined ? { bitbucketUsername: credentials.bitbucketUsername } : {}), + ...(credentials.wordpressApiUrl !== undefined + ? { wordpressApiUrl: credentials.wordpressApiUrl } + : {}), + ...(credentials.wordpressUsername !== undefined + ? { wordpressUsername: credentials.wordpressUsername } + : {}), + ...(credentials.wordpressAppPassword !== undefined + ? { wordpressAppPassword: credentials.wordpressAppPassword } + : {}), + ...(credentials.wordpressDefaultStatus !== undefined + ? { wordpressDefaultStatus: credentials.wordpressDefaultStatus } + : {}), + ...(credentials.wordpressDefaultAuthor !== undefined + ? { wordpressDefaultAuthor: credentials.wordpressDefaultAuthor } + : {}), + ...(credentials.ghostAdminUrl !== undefined + ? { ghostAdminUrl: credentials.ghostAdminUrl } + : {}), + ...(credentials.ghostAdminApiKey !== undefined + ? { ghostAdminApiKey: credentials.ghostAdminApiKey } + : {}), + ...(credentials.ghostAcceptVersion !== undefined + ? { ghostAcceptVersion: credentials.ghostAcceptVersion } + : {}), + ...(credentials.ghostDefaultStatus !== undefined + ? { ghostDefaultStatus: credentials.ghostDefaultStatus } + : {}), categories: project.categories, }, }; @@ -262,6 +383,12 @@ export function loadPublicConfig(): PublicStudioConfig { owner = process.env.BITBUCKET_WORKSPACE?.trim() || ""; repo = process.env.BITBUCKET_REPO_SLUG?.trim() || ""; branch = process.env.BITBUCKET_BRANCH?.trim() || project.defaultBranch; + } else if (publisher === "wordpress") { + owner = process.env.WORDPRESS_API_URL?.trim() || ""; + branch = project.defaultBranch; + } else if (publisher === "ghost") { + owner = process.env.GHOST_ADMIN_URL?.trim() || ""; + branch = project.defaultBranch; } else { owner = process.env.GITHUB_OWNER?.trim() || ""; repo = process.env.GITHUB_REPO?.trim() || ""; diff --git a/apps/studio/server/demoMedia.ts b/apps/studio/server/demoMedia.ts index 84c462f..82995fd 100644 --- a/apps/studio/server/demoMedia.ts +++ b/apps/studio/server/demoMedia.ts @@ -190,6 +190,8 @@ export async function uploadDemoMedia( repoPath, publicPath, kind, + url: publicPath, + provider: "github-media", sha: commitSha, commitSha, }, diff --git a/apps/studio/server/demoMode.ts b/apps/studio/server/demoMode.ts index 87ff7d6..c792c41 100644 --- a/apps/studio/server/demoMode.ts +++ b/apps/studio/server/demoMode.ts @@ -65,12 +65,48 @@ export function isBitbucketConfigured(): boolean { ); } +export function isWordPressApiConfigured(): boolean { + return (process.env.WORDPRESS_API_URL?.trim().length ?? 0) > 0; +} + +export function isWordPressUsernameConfigured(): boolean { + return (process.env.WORDPRESS_USERNAME?.trim().length ?? 0) > 0; +} + +export function isWordPressAppPasswordConfigured(): boolean { + return (process.env.WORDPRESS_APP_PASSWORD?.trim().length ?? 0) > 0; +} + +export function isWordPressConfigured(): boolean { + return ( + isWordPressApiConfigured() && + isWordPressUsernameConfigured() && + isWordPressAppPasswordConfigured() + ); +} + +export function isGhostAdminUrlConfigured(): boolean { + return (process.env.GHOST_ADMIN_URL?.trim().length ?? 0) > 0; +} + +export function isGhostAdminApiKeyConfigured(): boolean { + return (process.env.GHOST_ADMIN_API_KEY?.trim().length ?? 0) > 0; +} + +export function isGhostConfigured(): boolean { + return isGhostAdminUrlConfigured() && isGhostAdminApiKeyConfigured(); +} + export function isPublisherConfigured(): boolean { switch (resolveActivePublisher()) { case "gitlab": return isGitLabConfigured(); case "bitbucket": return isBitbucketConfigured(); + case "wordpress": + return isWordPressConfigured(); + case "ghost": + return isGhostConfigured(); default: return isGitHubConfigured(); } diff --git a/apps/studio/server/deployHook.test.ts b/apps/studio/server/deployHook.test.ts new file mode 100644 index 0000000..5518af0 --- /dev/null +++ b/apps/studio/server/deployHook.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + applyDeployHookStrictMode, + triggerDeployHook, + type DeployHookConfig, +} from "./deployHook.js"; + +describe("deploy hook", () => { + it("skips when DEPLOY_HOOK_URL is not configured", async () => { + const result = await triggerDeployHook("src/content/blog/post.mdx", {}); + assert.equal(result.triggered, false); + assert.equal(result.ok, true); + }); + + it("reports success when deploy hook returns 200", async () => { + const config: DeployHookConfig = { + url: "https://example.com/hook", + method: "POST", + provider: "vercel", + fetch: async (url, init) => { + assert.equal(url, "https://example.com/hook"); + assert.equal(init?.method, "POST"); + return new Response("ok", { status: 200 }); + }, + }; + + const result = await triggerDeployHook("src/content/blog/post.mdx", config); + assert.equal(result.triggered, true); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + }); + + it("reports failure without failing publish in non-strict mode", async () => { + const config: DeployHookConfig = { + url: "https://example.com/hook", + method: "POST", + provider: "generic", + strict: false, + fetch: async () => new Response("fail", { status: 500 }), + }; + + const hook = await triggerDeployHook("src/content/blog/post.mdx", config); + const gate = applyDeployHookStrictMode(true, hook, false); + + assert.equal(hook.ok, false); + assert.equal(gate.ok, true); + }); + + it("fails publish in strict mode when deploy hook fails", async () => { + const config: DeployHookConfig = { + url: "https://example.com/hook", + method: "POST", + provider: "netlify", + strict: true, + fetch: async () => new Response("fail", { status: 502 }), + }; + + const hook = await triggerDeployHook("src/content/blog/post.mdx", config); + const gate = applyDeployHookStrictMode(true, hook, true); + + assert.equal(hook.ok, false); + assert.equal(gate.ok, false); + if (!gate.ok) { + assert.match(gate.error ?? "", /DEPLOY_HOOK_STRICT/); + } + }); +}); diff --git a/apps/studio/server/deployHook.ts b/apps/studio/server/deployHook.ts new file mode 100644 index 0000000..2c8c61c --- /dev/null +++ b/apps/studio/server/deployHook.ts @@ -0,0 +1,166 @@ +import { resolveFetcher } from "./http.js"; + +export type DeployHookProvider = "generic" | "vercel" | "netlify" | "cloudflare-pages"; + +export type DeployHookResult = { + triggered: boolean; + ok: boolean; + status?: number; + message: string; +}; + +export type DeployHookConfig = { + url?: string; + method?: string; + provider?: DeployHookProvider; + strict?: boolean; + fetch?: typeof globalThis.fetch; +}; + +function resolveProvider(raw: string | undefined): DeployHookProvider { + if ( + raw === "vercel" || + raw === "netlify" || + raw === "cloudflare-pages" || + raw === "generic" + ) { + return raw; + } + + return "generic"; +} + +function buildDeployHookRequest( + provider: DeployHookProvider, + publishPath: string, +): { headers: Record; body?: string } { + const payload = JSON.stringify({ + source: "sourcedraft", + path: publishPath, + }); + + if (provider === "vercel") { + return { + headers: { + "Content-Type": "application/json", + "User-Agent": "SourceDraft/1.0", + }, + body: payload, + }; + } + + if (provider === "netlify") { + return { + headers: { + "Content-Type": "application/json", + "User-Agent": "SourceDraft/1.0", + }, + body: payload, + }; + } + + if (provider === "cloudflare-pages") { + return { + headers: { + "Content-Type": "application/json", + "User-Agent": "SourceDraft/1.0", + }, + body: payload, + }; + } + + return { + headers: { + "Content-Type": "application/json", + "User-Agent": "SourceDraft/1.0", + }, + body: payload, + }; +} + +export function loadDeployHookConfigFromEnv(): DeployHookConfig { + const url = process.env.DEPLOY_HOOK_URL?.trim(); + const method = process.env.DEPLOY_HOOK_METHOD?.trim().toUpperCase() || "POST"; + const provider = resolveProvider(process.env.DEPLOY_HOOK_PROVIDER?.trim()); + const strict = process.env.DEPLOY_HOOK_STRICT?.trim().toLowerCase() === "true"; + + return { + ...(url ? { url } : {}), + method, + provider, + strict, + }; +} + +export async function triggerDeployHook( + publishPath: string, + config: DeployHookConfig = loadDeployHookConfigFromEnv(), +): Promise { + if (!config.url) { + return { + triggered: false, + ok: true, + message: "Deploy hook not configured.", + }; + } + + const fetchImpl = resolveFetcher(config.fetch); + const method = config.method ?? "POST"; + const provider = config.provider ?? "generic"; + const request = buildDeployHookRequest(provider, publishPath); + + try { + const response = await fetchImpl(config.url, { + method, + headers: request.headers, + ...(request.body !== undefined ? { body: request.body } : {}), + }); + + if (!response.ok) { + const message = `Deploy hook failed (${response.status}).`; + return { + triggered: true, + ok: false, + status: response.status, + message, + }; + } + + return { + triggered: true, + ok: true, + status: response.status, + message: `Deploy hook succeeded (${response.status}).`, + }; + } catch (error) { + const message = + error instanceof Error + ? `Deploy hook request failed: ${error.message}` + : "Deploy hook request failed."; + + return { + triggered: true, + ok: false, + message, + }; + } +} + +export function applyDeployHookStrictMode( + publishOk: boolean, + deployHook: DeployHookResult, + strict: boolean, +): { ok: boolean; error?: string } { + if (!publishOk) { + return { ok: false }; + } + + if (!deployHook.triggered || deployHook.ok || !strict) { + return { ok: true }; + } + + return { + ok: false, + error: `${deployHook.message} Publish was rolled back because DEPLOY_HOOK_STRICT=true.`, + }; +} diff --git a/apps/studio/server/http.ts b/apps/studio/server/http.ts new file mode 100644 index 0000000..3caae04 --- /dev/null +++ b/apps/studio/server/http.ts @@ -0,0 +1,5 @@ +export type HttpFetcher = typeof globalThis.fetch; + +export function resolveFetcher(fetchImpl?: HttpFetcher): HttpFetcher { + return fetchImpl ?? globalThis.fetch; +} diff --git a/apps/studio/server/media.ts b/apps/studio/server/media.ts index db6c18a..dd26ab5 100644 --- a/apps/studio/server/media.ts +++ b/apps/studio/server/media.ts @@ -3,7 +3,7 @@ import type { Request } from "express"; import Busboy from "busboy"; import { joinPublicMediaPath } from "@sourcedraft/config"; import type { PublishEnvConfig } from "./config.js"; -import { createPublisherFromEnv } from "./publisherRuntime.js"; +import { createMediaProviderFromEnv } from "./mediaProviderRuntime.js"; import { normalizeMediaDir } from "./mediaPaths.js"; import { ALLOWED_MIME_TYPES, @@ -28,8 +28,11 @@ export type MediaUploadSuccess = { repoPath: string; publicPath: string; kind: "image" | "pdf"; + url: string; + provider: string; sha: string; commitSha: string; + metadata?: Record; }; export type MediaUploadError = { @@ -203,11 +206,14 @@ export async function uploadMedia( const repoPath = `${mediaDir}/${repoFilename}`; const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename); - const publisher = createPublisherFromEnv(env); + const mediaProvider = createMediaProviderFromEnv(env); - const result = await publisher.uploadMedia({ + const result = await mediaProvider.uploadMedia({ + buffer: parsed.buffer, + filename: repoFilename, + mimeType: parsed.mimeType, repoPath, - contentBase64: parsed.buffer.toString("base64"), + publicPath, message: `Upload media: ${repoFilename}`, }); @@ -216,20 +222,26 @@ export async function uploadMedia( status: 502, body: { ok: false, - error: result.error || "Media upload to GitHub failed.", + error: result.error || "Media upload failed.", }, }; } + const displayPath = + result.provider === "github-media" ? publicPath : result.url || publicPath; + return { status: 200, body: { ok: true, repoPath: result.path, - publicPath, + publicPath: displayPath, kind, - sha: result.sha, - commitSha: result.commitSha, + url: result.url, + provider: result.provider, + sha: result.sha ?? result.path, + commitSha: result.commitSha ?? result.path, + ...(result.metadata !== undefined ? { metadata: result.metadata } : {}), }, }; } diff --git a/apps/studio/server/mediaProviderRuntime.test.ts b/apps/studio/server/mediaProviderRuntime.test.ts new file mode 100644 index 0000000..ef6d9fa --- /dev/null +++ b/apps/studio/server/mediaProviderRuntime.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { resolveMediaProviderId } from "./mediaProviderRuntime.js"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("media provider selection", () => { + it("defaults to github-media", () => { + delete process.env.CMS_MEDIA_PROVIDER; + assert.equal(resolveMediaProviderId(), "github-media"); + }); + + it("selects cloudinary when configured", () => { + process.env.CMS_MEDIA_PROVIDER = "cloudinary"; + assert.equal(resolveMediaProviderId(), "cloudinary"); + }); + + it("falls back to github-media for unknown providers", () => { + process.env.CMS_MEDIA_PROVIDER = "imgix"; + assert.equal(resolveMediaProviderId(), "github-media"); + }); +}); diff --git a/apps/studio/server/mediaProviderRuntime.ts b/apps/studio/server/mediaProviderRuntime.ts new file mode 100644 index 0000000..206d7fb --- /dev/null +++ b/apps/studio/server/mediaProviderRuntime.ts @@ -0,0 +1,94 @@ +import { + createMediaProvider, + isMediaProviderId, + type MediaProvider, + type MediaProviderId, + type MediaProviderRuntimeConfig, +} from "@sourcedraft/media-providers"; +import type { PublishEnvConfig } from "./config.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; + +export function resolveMediaProviderId(): MediaProviderId { + const raw = process.env.CMS_MEDIA_PROVIDER?.trim() || "github-media"; + return isMediaProviderId(raw) ? raw : "github-media"; +} + +export function createMediaProviderFromEnv(env: PublishEnvConfig): MediaProvider { + const providerId = resolveMediaProviderId(); + const config = toMediaProviderRuntimeConfig(env, providerId); + return createMediaProvider(providerId, config); +} + +function toMediaProviderRuntimeConfig( + env: PublishEnvConfig, + providerId: MediaProviderId, +): MediaProviderRuntimeConfig { + const base: MediaProviderRuntimeConfig = { + mediaDir: env.mediaDir, + publicMediaPath: env.publicMediaPath, + }; + + if (providerId === "github-media") { + const publisher = createPublisherFromEnv(env); + return { + ...base, + publisherUpload: async (input) => { + const result = await publisher.uploadMedia({ + repoPath: input.repoPath, + contentBase64: input.contentBase64, + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + }; + } + + if (providerId === "cloudinary") { + const cloudName = process.env.CLOUDINARY_CLOUD_NAME?.trim(); + const apiKey = process.env.CLOUDINARY_API_KEY?.trim(); + const apiSecret = process.env.CLOUDINARY_API_SECRET?.trim(); + const folder = process.env.CLOUDINARY_FOLDER?.trim(); + + return { + ...base, + ...(cloudName ? { cloudinaryCloudName: cloudName } : {}), + ...(apiKey ? { cloudinaryApiKey: apiKey } : {}), + ...(apiSecret ? { cloudinaryApiSecret: apiSecret } : {}), + ...(folder ? { cloudinaryFolder: folder } : {}), + }; + } + + const s3Endpoint = process.env.S3_ENDPOINT?.trim(); + const s3Region = process.env.S3_REGION?.trim(); + const s3Bucket = process.env.S3_BUCKET?.trim(); + const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID?.trim(); + const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim(); + const s3PublicBaseUrl = process.env.S3_PUBLIC_BASE_URL?.trim(); + const s3ForcePathStyle = + process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase() === "true"; + + return { + ...base, + ...(s3Endpoint ? { s3Endpoint } : {}), + ...(s3Region ? { s3Region } : {}), + ...(s3Bucket ? { s3Bucket } : {}), + ...(s3AccessKeyId ? { s3AccessKeyId } : {}), + ...(s3SecretAccessKey ? { s3SecretAccessKey } : {}), + ...(s3PublicBaseUrl ? { s3PublicBaseUrl } : {}), + ...(s3ForcePathStyle ? { s3ForcePathStyle: true } : {}), + }; +} diff --git a/apps/studio/server/publish.ts b/apps/studio/server/publish.ts index 1e59bd7..44e3c3d 100644 --- a/apps/studio/server/publish.ts +++ b/apps/studio/server/publish.ts @@ -8,12 +8,21 @@ import { type Article, type ArticleInput, } from "@sourcedraft/core"; +import type { CmsArticlePayload } from "@sourcedraft/publishers"; import type { PublishEnvConfig } from "./config.js"; +import { + applyDeployHookStrictMode, + loadDeployHookConfigFromEnv, + triggerDeployHook, + type DeployHookResult, +} from "./deployHook.js"; import { createPublisherFromEnv } from "./publisherRuntime.js"; import { safePostPath } from "./postPaths.js"; export type PublishRequestBody = ArticleInput & { sourcePath?: unknown; + /** Remote CMS post id (WordPress post id, Ghost uuid) for updates */ + remoteId?: unknown; }; export type PublishSuccessResponse = { @@ -22,6 +31,8 @@ export type PublishSuccessResponse = { created: boolean; sha: string; commitSha: string; + remoteId?: string; + deployHook?: DeployHookResult; }; export type PublishErrorResponse = { @@ -29,6 +40,7 @@ export type PublishErrorResponse = { error: string; issues?: { field: string; message: string }[]; status?: number; + deployHook?: DeployHookResult; }; export type PublishResponse = PublishSuccessResponse | PublishErrorResponse; @@ -46,6 +58,40 @@ function defaultPostPath(article: Article, env: PublishEnvConfig): string { }); } +function toCmsPayload(article: Article): CmsArticlePayload { + return { + title: article.title, + slug: article.slug, + description: article.description, + body: article.body, + pubDate: article.pubDate, + category: article.category, + tags: article.tags, + draft: article.draft, + ...(article.updatedDate !== undefined ? { updatedDate: article.updatedDate } : {}), + ...(article.heroImage !== undefined ? { heroImage: article.heroImage } : {}), + ...(article.author !== undefined ? { author: article.author } : {}), + ...(article.metaTitle !== undefined ? { metaTitle: article.metaTitle } : {}), + ...(article.metaDescription !== undefined + ? { metaDescription: article.metaDescription } + : {}), + ...(article.canonicalUrl !== undefined ? { canonicalUrl: article.canonicalUrl } : {}), + ...(article.socialImage !== undefined ? { socialImage: article.socialImage } : {}), + }; +} + +function parseRemoteId(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return undefined; +} + export async function publishArticle( body: PublishRequestBody, env: PublishEnvConfig, @@ -83,19 +129,21 @@ export async function publishArticle( } const content = renderArticle(article, env); - + const remoteId = parseRemoteId(body.remoteId); const publisher = createPublisherFromEnv(env); const result = await publisher.publishArticle({ path, content, message: `Publish: ${article.slug}`, + article: toCmsPayload(article), + ...(remoteId !== undefined ? { remoteId } : {}), }); if (!result.ok) { const errorBody: PublishErrorResponse = { ok: false, - error: result.error || "Publish to GitHub failed.", + error: result.error || "Publish failed.", }; if (result.status !== undefined) { @@ -108,6 +156,25 @@ export async function publishArticle( }; } + const deployHookConfig = loadDeployHookConfigFromEnv(); + const deployHook = await triggerDeployHook(result.path, deployHookConfig); + const strictGate = applyDeployHookStrictMode( + true, + deployHook, + deployHookConfig.strict === true, + ); + + if (!strictGate.ok) { + return { + status: 502, + body: { + ok: false, + error: strictGate.error ?? "Deploy hook failed in strict mode.", + deployHook, + }, + }; + } + return { status: 200, body: { @@ -116,6 +183,8 @@ export async function publishArticle( created: result.created, sha: result.sha, commitSha: result.commitSha, + ...(result.remoteId !== undefined ? { remoteId: result.remoteId } : {}), + ...(deployHook.triggered ? { deployHook } : {}), }, }; } diff --git a/apps/studio/server/publisherRuntime.ts b/apps/studio/server/publisherRuntime.ts index bdfe455..23f9408 100644 --- a/apps/studio/server/publisherRuntime.ts +++ b/apps/studio/server/publisherRuntime.ts @@ -28,6 +28,29 @@ export function toPublisherRuntimeConfig( ...(env.bitbucketUsername !== undefined ? { bitbucketUsername: env.bitbucketUsername } : {}), + ...(env.wordpressApiUrl !== undefined ? { wordpressApiUrl: env.wordpressApiUrl } : {}), + ...(env.wordpressUsername !== undefined + ? { wordpressUsername: env.wordpressUsername } + : {}), + ...(env.wordpressAppPassword !== undefined + ? { wordpressAppPassword: env.wordpressAppPassword } + : {}), + ...(env.wordpressDefaultStatus !== undefined + ? { wordpressDefaultStatus: env.wordpressDefaultStatus } + : {}), + ...(env.wordpressDefaultAuthor !== undefined + ? { wordpressDefaultAuthor: env.wordpressDefaultAuthor } + : {}), + ...(env.ghostAdminUrl !== undefined ? { ghostAdminUrl: env.ghostAdminUrl } : {}), + ...(env.ghostAdminApiKey !== undefined + ? { ghostAdminApiKey: env.ghostAdminApiKey } + : {}), + ...(env.ghostAcceptVersion !== undefined + ? { ghostAcceptVersion: env.ghostAcceptVersion } + : {}), + ...(env.ghostDefaultStatus !== undefined + ? { ghostDefaultStatus: env.ghostDefaultStatus } + : {}), }; } diff --git a/apps/studio/server/runtimeConfig.test.ts b/apps/studio/server/runtimeConfig.test.ts index e7c2dcf..5eb5bba 100644 --- a/apps/studio/server/runtimeConfig.test.ts +++ b/apps/studio/server/runtimeConfig.test.ts @@ -90,6 +90,33 @@ describe("runtime config resolution", () => { } }); + it("accepts wordpress publisher when credentials are set", () => { + process.env.CMS_PUBLISHER = "wordpress"; + process.env.WORDPRESS_API_URL = "https://example.com/wp-json"; + process.env.WORDPRESS_USERNAME = "editor"; + process.env.WORDPRESS_APP_PASSWORD = "abcd EFGH ijkl MNOP qrst UVWX"; + + const result = loadPublishEnv(); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.config.publisher, "wordpress"); + assert.equal(result.config.wordpressApiUrl, "https://example.com/wp-json"); + } + }); + + it("accepts ghost publisher when credentials are set", () => { + process.env.CMS_PUBLISHER = "ghost"; + process.env.GHOST_ADMIN_URL = "https://example.com"; + process.env.GHOST_ADMIN_API_KEY = "id:secret"; + + const result = loadPublishEnv(); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.config.publisher, "ghost"); + assert.equal(result.config.ghostAdminUrl, "https://example.com"); + } + }); + it("rejects gitlab publisher without project reference", () => { process.env.CMS_PUBLISHER = "gitlab"; process.env.GITLAB_TOKEN = "gl-token"; diff --git a/apps/studio/server/setupHealth.ts b/apps/studio/server/setupHealth.ts index d433169..0099058 100644 --- a/apps/studio/server/setupHealth.ts +++ b/apps/studio/server/setupHealth.ts @@ -16,7 +16,12 @@ import { isGitLabConfigured, isGitLabProjectConfigured, isGitLabTokenConfigured, + isGhostAdminApiKeyConfigured, + isGhostAdminUrlConfigured, isPublisherConfigured, + isWordPressApiConfigured, + isWordPressAppPasswordConfigured, + isWordPressUsernameConfigured, resolveActivePublisher, } from "./demoMode.js"; @@ -101,6 +106,61 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] ]; } + if (activePublisher === "wordpress") { + const apiOk = isWordPressApiConfigured(); + const userOk = isWordPressUsernameConfigured(); + const passwordOk = isWordPressAppPasswordConfigured(); + return [ + { + id: "wordpress-api-url", + label: "WordPress API URL", + ok: apiOk, + detail: apiOk + ? "WORDPRESS_API_URL is configured." + : "Set WORDPRESS_API_URL in .env (e.g. https://example.com/wp-json).", + }, + { + id: "wordpress-username", + label: "WordPress username", + ok: userOk, + detail: userOk + ? "WORDPRESS_USERNAME is configured." + : "Set WORDPRESS_USERNAME in .env.", + }, + { + id: "wordpress-app-password", + label: "WordPress app password (server-side)", + ok: passwordOk, + detail: passwordOk + ? "WORDPRESS_APP_PASSWORD is present on the server. The value is never sent to the browser." + : "Set WORDPRESS_APP_PASSWORD in .env.", + }, + ]; + } + + if (activePublisher === "ghost") { + const urlOk = isGhostAdminUrlConfigured(); + const keyOk = isGhostAdminApiKeyConfigured(); + return [ + { + id: "ghost-admin-url", + label: "Ghost site URL", + ok: urlOk, + detail: urlOk + ? "GHOST_ADMIN_URL is configured." + : "Set GHOST_ADMIN_URL in .env (site root, no /ghost path).", + }, + { + id: "ghost-admin-api-key", + label: "Ghost Admin API key (server-side)", + ok: keyOk, + detail: keyOk + ? "GHOST_ADMIN_API_KEY is present on the server. The value is never sent to the browser." + : "Set GHOST_ADMIN_API_KEY in .env.", + }, + ]; + } + const ownerOk = isGitHubOwnerConfigured(); const repoOk = isGitHubRepoConfigured(); const tokenOk = isGitHubTokenConfigured(); @@ -139,6 +199,14 @@ function publisherSetupMessage(activePublisher: string): string { return "Complete Bitbucket setup in .env (BITBUCKET_TOKEN, BITBUCKET_WORKSPACE, BITBUCKET_REPO_SLUG) or use demo mode to explore without Bitbucket."; } + if (activePublisher === "wordpress") { + return "Complete WordPress setup in .env (WORDPRESS_API_URL, WORDPRESS_USERNAME, WORDPRESS_APP_PASSWORD) or use demo mode to explore without WordPress."; + } + + if (activePublisher === "ghost") { + return "Complete Ghost setup in .env (GHOST_ADMIN_URL, GHOST_ADMIN_API_KEY) or use demo mode to explore without Ghost."; + } + return "Complete GitHub setup in .env (GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO) or use demo mode to explore without GitHub."; } diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index e24c27b..959b744 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -383,8 +383,13 @@ function App() { } const action = result.created ? "Created" : "Updated"; + const deployNote = + result.deployHook?.triggered === true + ? ` ${result.deployHook.ok ? "Deploy hook succeeded." : result.deployHook.message}` + : ""; + setPublishSuccess( - `${action} ${result.path} (commit ${result.commitSha.slice(0, 7)}).`, + `${action} ${result.path} (commit ${result.commitSha.slice(0, 7)}).${deployNote}`, ); setEditingPath(result.path); commitBaseline( diff --git a/apps/studio/src/lib/media.ts b/apps/studio/src/lib/media.ts index 059baa7..38d2759 100644 --- a/apps/studio/src/lib/media.ts +++ b/apps/studio/src/lib/media.ts @@ -26,8 +26,11 @@ export type MediaUploadSuccess = { repoPath: string; publicPath: string; kind: MediaKind; + url: string; + provider: string; sha: string; commitSha: string; + metadata?: Record; }; export type MediaUploadError = { diff --git a/apps/studio/src/lib/publish.ts b/apps/studio/src/lib/publish.ts index b35c4c2..68a92bb 100644 --- a/apps/studio/src/lib/publish.ts +++ b/apps/studio/src/lib/publish.ts @@ -1,11 +1,20 @@ import type { ArticleInput } from "@sourcedraft/core"; +export type DeployHookResult = { + triggered: boolean; + ok: boolean; + status?: number; + message: string; +}; + export type PublishApiSuccess = { ok: true; path: string; created: boolean; sha: string; commitSha: string; + remoteId?: string; + deployHook?: DeployHookResult; }; export type PublishApiError = { diff --git a/docs/configuration.md b/docs/configuration.md index 51049f5..d10f54e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,7 @@ SourceDraft uses two files on purpose: - Content paths (`contentDir`, `mediaDir`, `publicMediaPath`) - Adapter name — see [adapters.md](adapters.md) compatibility matrix -- Publisher name (`github`, `gitlab`, or `bitbucket`) +- Publisher name (`github`, `gitlab`, `bitbucket`, `wordpress`, or `ghost`) - Category list for Studio - Default branch name when `GITHUB_BRANCH` is unset @@ -83,12 +83,14 @@ Set in `sourcedraft.config.json`, or override with `CMS_ADAPTER` in `.env`. | `github` | `@sourcedraft/publishers` → `@sourcedraft/github-publisher` | Publish posts, upload media, list/read files via GitHub Contents API | | `gitlab` | `@sourcedraft/publishers` | Publish posts, upload media, list/read files via GitLab Repository Files API | | `bitbucket` | `@sourcedraft/publishers` | Publish posts and upload media via Bitbucket commit-upload API (no list/read yet) | +| `wordpress` | `@sourcedraft/publishers` | Create/update posts via WordPress REST API (remote CMS) | +| `ghost` | `@sourcedraft/publishers` | Create/update posts via Ghost Admin API (remote CMS) | Set in `sourcedraft.config.json`, or override with `CMS_PUBLISHER` in `.env`. Default: `github`. Studio resolves publishers through `publisherRegistry`. Unknown publisher ids return a clear configuration error before any API call. -Publisher-specific env vars and API behavior: [git-publishers.md](git-publishers.md). +Publisher reference: [publishers.md](publishers.md) · Git: [git-publishers.md](git-publishers.md) · WordPress: [wordpress.md](wordpress.md) · Ghost: [ghost.md](ghost.md). ### `mediaDir` and `publicMediaPath` @@ -121,6 +123,10 @@ Copy from `.env.example`. Set credentials for the publisher selected in `sourced **Bitbucket:** `BITBUCKET_TOKEN`, `BITBUCKET_WORKSPACE`, `BITBUCKET_REPO_SLUG`, optional `BITBUCKET_BRANCH`, `BITBUCKET_USERNAME` +**WordPress:** `WORDPRESS_API_URL`, `WORDPRESS_USERNAME`, `WORDPRESS_APP_PASSWORD`, optional `WORDPRESS_DEFAULT_STATUS`, `WORDPRESS_DEFAULT_AUTHOR` + +**Ghost:** `GHOST_ADMIN_URL`, `GHOST_ADMIN_API_KEY`, optional `GHOST_ACCEPT_VERSION`, `GHOST_DEFAULT_STATUS` + Shared optional overrides: ```env @@ -137,13 +143,40 @@ CMS_PUBLISHER= | `GITHUB_*` | When `publisher` is `github` | GitHub API credentials (server only) | | `GITLAB_*` | When `publisher` is `gitlab` | GitLab API credentials (server only) | | `BITBUCKET_*` | When `publisher` is `bitbucket` | Bitbucket API credentials (server only) | +| `WORDPRESS_*` | When `publisher` is `wordpress` | WordPress REST API credentials (server only) | +| `GHOST_*` | When `publisher` is `ghost` | Ghost Admin API credentials (server only) | | `CMS_CONTENT_DIR` | No | Overrides `contentDir` | | `CMS_MEDIA_DIR` | No | Overrides `mediaDir` | | `CMS_PUBLIC_MEDIA_PATH` | No | Overrides `publicMediaPath` | | `CMS_ADAPTER` | No | Overrides `adapter` | | `CMS_PUBLISHER` | No | Overrides `publisher` (default `github`) | -Full publisher reference: [git-publishers.md](git-publishers.md). +Full publisher reference: [publishers.md](publishers.md). + +### Media providers + +Optional — default `github-media` commits uploads through the active git publisher. + +| Provider | Env prefix | +|----------|------------| +| `github-media` | (uses publisher credentials) | +| `cloudinary` | `CLOUDINARY_*` | +| `s3-compatible` | `S3_*` (experimental) | + +Set `CMS_MEDIA_PROVIDER` in `.env`. Details: [media.md](media.md). + +### Deploy hooks + +Optional — trigger a build webhook after successful publish. + +| Variable | Purpose | +|----------|---------| +| `DEPLOY_HOOK_URL` | Webhook URL (secret) | +| `DEPLOY_HOOK_METHOD` | Default `POST` | +| `DEPLOY_HOOK_PROVIDER` | `generic`, `vercel`, `netlify`, `cloudflare-pages` | +| `DEPLOY_HOOK_STRICT` | `true` to fail publish when hook fails | + +Details: [deploy-hooks.md](deploy-hooks.md). ## Precedence diff --git a/docs/deploy-hooks.md b/docs/deploy-hooks.md new file mode 100644 index 0000000..5d0c6a9 --- /dev/null +++ b/docs/deploy-hooks.md @@ -0,0 +1,69 @@ +# Deploy hooks + +SourceDraft can optionally trigger a **deploy hook** after a successful publish. This is useful when your site builds on Vercel, Netlify, Cloudflare Pages, or another CI system that exposes a webhook URL. + +Deploy hooks are **optional**. If `DEPLOY_HOOK_URL` is unset, publishing works exactly as before. + +## Environment variables + +| Variable | Required | Purpose | +|----------|----------|---------| +| `DEPLOY_HOOK_URL` | Yes to enable | Webhook URL from your host (keep secret in `.env`) | +| `DEPLOY_HOOK_METHOD` | No | HTTP method; default `POST` | +| `DEPLOY_HOOK_PROVIDER` | No | `generic`, `vercel`, `netlify`, or `cloudflare-pages` (default `generic`) | +| `DEPLOY_HOOK_STRICT` | No | When `true`, publish fails if the deploy hook fails | + +All values are server-side only. Never commit hook URLs to git. + +## Behavior + +1. Article publishes successfully through the configured publisher. +2. If `DEPLOY_HOOK_URL` is set, SourceDraft sends a `POST` (or configured method) with a small JSON body: + +```json +{ + "source": "sourcedraft", + "path": "src/content/blog/my-post.mdx" +} +``` + +3. The publish API response includes a `deployHook` object when a hook was called: + +```json +{ + "ok": true, + "path": "src/content/blog/my-post.mdx", + "deployHook": { + "triggered": true, + "ok": true, + "status": 200, + "message": "Deploy hook succeeded (200)." + } +} +``` + +4. Studio shows the deploy hook message in the publish success banner when present. + +### Strict mode + +By default, a deploy hook failure does **not** fail the publish — your content is already committed or saved remotely. + +Set `DEPLOY_HOOK_STRICT=true` when you want publish to return an error if the hook fails. Use this only when you need atomic “publish + deploy” semantics. + +## Provider notes + +| Provider | Typical hook source | +|----------|---------------------| +| `vercel` | Project → Settings → Git → Deploy Hooks | +| `netlify` | Site → Build & deploy → Build hooks | +| `cloudflare-pages` | Pages project → Settings → Builds → Deploy hooks | +| `generic` | Any CI webhook that accepts `POST` + JSON | + +SourceDraft does not store provider API tokens for deploy hooks — the hook URL itself is the credential. + +## Security + +- Treat `DEPLOY_HOOK_URL` like a password. Anyone with the URL can trigger builds. +- Run Studio on a trusted server. Hooks are called from the publish API, never from the browser. + +See also: [configuration.md](configuration.md) · [publishers.md](publishers.md) diff --git a/docs/ghost.md b/docs/ghost.md new file mode 100644 index 0000000..8d6f69f --- /dev/null +++ b/docs/ghost.md @@ -0,0 +1,75 @@ +# Ghost publishing + +SourceDraft can push posts to a **Ghost** site through the Admin API. This is a publishing connector only — SourceDraft does not host Ghost or replace Ghost Admin. + +## Requirements + +### Environment variables (`.env` only) + +| Variable | Required | Purpose | +|----------|----------|---------| +| `GHOST_ADMIN_URL` | Yes | Site root URL, e.g. `https://example.com` (no `/ghost` suffix) | +| `GHOST_ADMIN_API_KEY` | Yes | Admin API key in `id:secret` format from Ghost Integrations | +| `GHOST_ACCEPT_VERSION` | No | Ghost API version header; default `v5.126` | +| `GHOST_DEFAULT_STATUS` | No | Status when `draft: false`; default `draft` | + +Set `CMS_PUBLISHER=ghost` or `"publisher": "ghost"` in `sourcedraft.config.json`. + +### Ghost site setup + +1. Ghost 5.x (Admin API with JWT auth). +2. **Custom integration:** Settings → Integrations → Add custom integration. +3. Copy the **Admin API Key** (`{id}:{secret}`) into `GHOST_ADMIN_API_KEY`. + +SourceDraft generates short-lived JWTs server-side (HS256, 5-minute expiry) — no `@tryghost/admin-api` dependency. + +## How it works + +1. Studio sends article JSON to `POST /api/publish`. +2. Server builds a JWT from `GHOST_ADMIN_API_KEY` and calls the Admin API. +3. **Create:** `POST /ghost/api/admin/posts/?source=html` +4. **Update:** `PUT /ghost/api/admin/posts/{id}/?source=html` when `remoteId` is provided. +5. Response includes `remoteId` (Ghost post uuid) for future updates. + +### Payload mapping + +| SourceDraft field | Ghost field | +|-------------------|-------------| +| `title` | `title` | +| `slug` | `slug` | +| `body` | `html` (`?source=html` — body should be HTML) | +| `description` | `excerpt` | +| `tags` | `tags: [{ name }]` | +| `draft: true` | `status: draft` | +| `draft: false` | `status: GHOST_DEFAULT_STATUS` | +| `heroImage` / `socialImage` (absolute URL) | `feature_image` | +| `metaTitle` | `meta_title` | +| `metaDescription` | `meta_description` | +| `canonicalUrl` | `canonical_url` | + +### HTML content + +Ghost receives content with `?source=html`. SourceDraft sends `article.body` as HTML without conversion. For best results, write HTML in the editor or use a Markdown-to-HTML step in your own pipeline before publish. + +## Limitations + +- No post listing or editing from Ghost in Studio. +- No image upload to Ghost storage — only absolute URLs for `feature_image`. +- Updates require `remoteId` from a previous publish; otherwise each publish creates a new post. +- Lexical JSON is not generated; HTML source mode only. + +## Security + +- **Never** commit `GHOST_ADMIN_API_KEY` or expose it in browser code. +- Admin API keys have full content access — treat like a root password. +- Publish only from the server-side API on a trusted host. + +## Common failures + +| Symptom | Likely cause | +|---------|----------------| +| 401 / 403 | Invalid or revoked Admin API key | +| 404 | Wrong `GHOST_ADMIN_URL` | +| Invalid key format | Key must be `id:hexsecret` from Ghost Integrations | + +See also: [publishers.md](publishers.md) · [configuration.md](configuration.md) diff --git a/docs/media.md b/docs/media.md index efd2abe..89ef2a5 100644 --- a/docs/media.md +++ b/docs/media.md @@ -1,8 +1,10 @@ # Media uploads -SourceDraft can upload cover images, inline images, and PDF attachments to your GitHub repository through the publish API. Uploads are server-side only — the browser never sees your GitHub token. +SourceDraft can upload cover images, inline images, and PDF attachments through the publish API. Uploads are server-side only — credentials never reach the browser. -Studio also includes a **media library** that lists files already committed under your configured `mediaDir`. +By default, files commit to your git publisher's `mediaDir` (`github-media` provider). Optionally use **Cloudinary** or an **S3-compatible** store instead. + +Studio also includes a **media library** that lists files already committed under your configured `mediaDir` (git-backed providers only). See also: [Configuration](configuration.md) · [Security](security.md) · [Getting started](getting-started.md) @@ -11,8 +13,8 @@ See also: [Configuration](configuration.md) · [Security](security.md) · [Getti 1. In Studio, click **New post** or open an existing post from the **Posts** sidebar. 2. Under **Cover image**, use the upload area: drag a file in or click **Choose file**. 3. The browser sends the file to `POST /api/media/upload` (multipart field name: `file`). -4. The server validates type, size, extension, and file signature, sanitizes the filename, and commits the file to `mediaDir` via the GitHub Contents API. -5. On success, Studio shows the public path (for example `/images/my-photo-a1b2c3d4.png`). +4. The server validates type, size, extension, and file signature, sanitizes the filename, and uploads via the configured **media provider**. +5. On success, Studio shows the public path or CDN URL (for example `/images/my-photo-a1b2c3d4.png` or a Cloudinary `https://res.cloudinary.com/...` URL). 6. Use **Use as cover image**, **Insert into article**, or **Insert PDF link** as appropriate, or pick the file later from the media library. Your static site must serve files from the path your site expects — SourceDraft only writes into the repo. @@ -40,7 +42,27 @@ The library refreshes automatically after a successful upload. Use **Refresh** t Listing uses the same GitHub session as publish and post listing. Only allowed file types under `mediaDir` are returned; path traversal and disallowed extensions are rejected. -**Not included yet:** delete, folders, tags, or external storage. +**Not included yet:** delete, folders, or tags. + +## Media providers + +Set `CMS_MEDIA_PROVIDER` in `.env` (default: `github-media`). + +| Provider | Env vars | Notes | +|----------|----------|-------| +| `github-media` | Uses publisher credentials | Commits to `mediaDir` via git publisher; supports images + PDF | +| `cloudinary` | `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`, optional `CLOUDINARY_FOLDER` | Images only (PNG, JPEG, GIF, WebP); returns secure CDN URL | +| `s3-compatible` | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, optional `S3_PUBLIC_BASE_URL`, `S3_FORCE_PATH_STYLE` | **Experimental** — config validation only; upload not implemented yet | + +Upload API response includes `url`, `provider`, and optional `metadata` in addition to legacy `publicPath` / `repoPath` fields. + +### Cloudinary transformations + +Cloudinary supports on-the-fly transforms in delivery URLs (resize, crop, format, quality). SourceDraft uploads originals only — add transforms in your site templates or Cloudinary console. No transform builder in Studio yet. + +### S3-compatible targets + +The S3 provider is designed for Cloudflare R2, AWS S3, Backblaze B2, and similar endpoints through `S3_ENDPOINT` and path-style settings. Until upload is implemented, use `github-media` or `cloudinary`. ## Supported file types @@ -133,7 +155,7 @@ Use that path in `heroImage` or in body Markdown. Your site's build must map tha ## Security - Upload and media listing require the same Studio session as publish and post listing. -- The GitHub token is read only on the server when listing or committing files. +- Media and publish credentials are read only on the server. - Filenames are sanitized; content is checked against declared MIME type using file signatures. - Path traversal (`..`, `.`) and files outside `mediaDir` are blocked. - No SVG, HTML, executables, scripts, or ZIP uploads. diff --git a/docs/publishers.md b/docs/publishers.md new file mode 100644 index 0000000..ca453c6 --- /dev/null +++ b/docs/publishers.md @@ -0,0 +1,50 @@ +# Publishers + +SourceDraft publishers are **connectors** — they send validated article data to a target. SourceDraft is not a hosted CMS. The Studio editor runs locally; publishing always happens through the server-side publish API. + +## Publisher kinds + +| Kind | Publishers | What they do | +|------|------------|--------------| +| **Git file** | `github`, `gitlab`, `bitbucket` | Commit rendered `.md` / `.mdx` files (and media) to a repository | +| **Remote CMS API** | `wordpress`, `ghost` | Create or update posts through HTTP APIs | + +Set the active publisher in `sourcedraft.config.json` (`publisher`) or override with `CMS_PUBLISHER` in `.env`. + +## Git file publishers + +- Render output comes from the selected [adapter](adapters.md) (frontmatter + body). +- `sourcePath` in publish requests selects an existing repo file to update. +- Media uploads use `mediaDir` when supported. + +Details: [git-publishers.md](git-publishers.md) · [github-publishing.md](github-publishing.md) + +## Remote CMS publishers + +- Publish uses structured article fields (`title`, `slug`, `body`, tags, SEO fields, etc.). +- The adapter preview still shows the file that *would* be written for a Git workflow; the CMS publisher sends `body` and metadata to the remote API. +- **Markdown:** SourceDraft does not ship a Markdown-to-HTML converter. WordPress receives the body as-is (rendering depends on your theme/plugins). Ghost uses `?source=html` — write HTML in the body or accept plain-text storage. +- **Updates:** Pass `remoteId` in the publish API body (WordPress post id or Ghost uuid). Without `remoteId`, each publish creates a new remote post. +- **Listing/editing:** Remote CMS publishers do not list existing posts in Studio today. Use Git publishers if you need the Posts sidebar against a remote repo. + +Details: [wordpress.md](wordpress.md) · [ghost.md](ghost.md) + +## Security + +**All publisher credentials stay in `.env` on the server.** They are never sent to the browser, never committed to git, and never belong in `sourcedraft.config.json`. + +Remote CMS publishers (`wordpress`, `ghost`) must only run in a trusted server environment — same as Git tokens. Do not expose Studio publicly without HTTPS and hardened auth. + +## Registry + +Built-in publishers register through `@sourcedraft/publishers` (`publisherRegistry`). Unknown publisher ids fail at config load with a clear error before any API call. + +| Publisher | Package | Kind | +|-----------|---------|------| +| `github` | `@sourcedraft/github-publisher` | git | +| `gitlab` | `@sourcedraft/publishers` | git | +| `bitbucket` | `@sourcedraft/publishers` | git | +| `wordpress` | `@sourcedraft/publishers` | remote-cms | +| `ghost` | `@sourcedraft/publishers` | remote-cms | + +See also: [configuration.md](configuration.md) diff --git a/docs/wordpress.md b/docs/wordpress.md new file mode 100644 index 0000000..8e03e6e --- /dev/null +++ b/docs/wordpress.md @@ -0,0 +1,99 @@ +# WordPress publishing + +SourceDraft can push posts to a **self-hosted or managed WordPress** site through the REST API. This is a publishing connector only — SourceDraft does not host WordPress or replace the wp-admin dashboard. + +## Requirements + +### Environment variables (`.env` only) + +| Variable | Required | Purpose | +|----------|----------|---------| +| `WORDPRESS_API_URL` | Yes | REST base URL, e.g. `https://example.com/wp-json` | +| `WORDPRESS_USERNAME` | Yes | WordPress user that owns the application password | +| `WORDPRESS_APP_PASSWORD` | Yes | Application password (spaces are fine) | +| `WORDPRESS_DEFAULT_STATUS` | No | Status when `draft: false` in article; default `draft` | +| `WORDPRESS_DEFAULT_AUTHOR` | No | Numeric WordPress user id for `author` field | + +Set `CMS_PUBLISHER=wordpress` or `"publisher": "wordpress"` in `sourcedraft.config.json`. + +### WordPress site setup + +1. WordPress 5.6+ with REST API enabled (default on most sites). +2. **Application password** for the publishing user: + - Users → Profile → Application Passwords → Add New + - Copy the generated password into `WORDPRESS_APP_PASSWORD` in `.env` +3. User needs **`edit_posts`** (Author or Editor role minimum). + +Permalinks must not block `/wp-json/` (pretty permalinks recommended). + +## How it works + +1. Studio sends article JSON to `POST /api/publish` (session cookie required). +2. Server validates the article and calls the WordPress publisher. +3. **Create:** `POST /wp/v2/posts` when no `remoteId` is sent. +4. **Update:** `POST /wp/v2/posts/{id}` when `remoteId` is the WordPress post id. +5. Response includes `remoteId` — store it client-side for future updates. + +### Payload mapping + +| SourceDraft field | WordPress field | +|-------------------|-----------------| +| `title` | `title` | +| `body` | `content` (sent as-is; Markdown depends on plugins) | +| `slug` | `slug` | +| `description` | `excerpt` | +| `pubDate` | `date` | +| `draft: true` | `status: draft` | +| `draft: false` | `status: WORDPRESS_DEFAULT_STATUS` (default `draft`) | +| `category` | `categories[]` when mapped (see below) | +| `tags` | `tags[]` when mapped (see below) | + +### Categories and tags + +SourceDraft does **not** auto-create WordPress terms. Map Studio category/tag names to numeric ids in `publisherOptions`: + +```json +{ + "publisher": "wordpress", + "publisherOptions": { + "wordpressCategoryIds": { + "Guides": 3, + "Tutorials": 5 + }, + "wordpressTagIds": { + "astro": 10, + "cms": 12 + } + } +} +``` + +Unmapped names are omitted from the API payload. + +### Featured images + +`featured_media` is not set automatically. WordPress expects a media attachment id, not a URL. Upload media in WordPress or extend your workflow separately. + +## Limitations + +- No post listing or editing from WordPress in Studio (use a Git publisher for the Posts sidebar). +- No media upload through SourceDraft for WordPress. +- Updates require a stored `remoteId` from a previous publish. +- Markdown in `body` is not converted to HTML unless you add that in your stack. + +## Security + +- Never commit `WORDPRESS_APP_PASSWORD` or put it in `sourcedraft.config.json`. +- The browser never receives WordPress credentials. +- Run Studio on a trusted network; use HTTPS if exposed beyond localhost. + +## Common failures + +| Symptom | Likely cause | +|---------|----------------| +| 401 Unauthorized | Wrong username or application password | +| 403 Forbidden | User lacks `edit_posts` permission | +| 404 on API URL | Wrong `WORDPRESS_API_URL` or permalinks blocking REST | +| 404 on update | Invalid `remoteId` | + +See also: [publishers.md](publishers.md) · [configuration.md](configuration.md) diff --git a/package.json b/package.json index f106c0a..ee82807 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "pnpm -r build", "check": "pnpm -r check", "lint": "pnpm -r lint", - "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/github-publisher --filter studio test", + "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/github-publisher --filter studio test", "test:e2e": "pnpm --filter studio test:e2e", "screenshots:generate": "pnpm --filter studio screenshots:generate" }, diff --git a/packages/media-providers/package.json b/packages/media-providers/package.json new file mode 100644 index 0000000..42684f9 --- /dev/null +++ b/packages/media-providers/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sourcedraft/media-providers", + "version": "0.0.1", + "private": true, + "description": "Optional media storage providers for SourceDraft.", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/**/*.test.ts'" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/media-providers/src/cloudinary/cloudinaryProvider.test.ts b/packages/media-providers/src/cloudinary/cloudinaryProvider.test.ts new file mode 100644 index 0000000..75c9e5e --- /dev/null +++ b/packages/media-providers/src/cloudinary/cloudinaryProvider.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createCloudinaryMediaProvider } from "./cloudinaryProvider.js"; + +const config = { + cloudName: "demo", + apiKey: "key123", + apiSecret: "secret456", + folder: "sourcedraft", +}; + +describe("Cloudinary media provider", () => { + it("uploads an image and returns secure URL", async () => { + const provider = createCloudinaryMediaProvider({ + ...config, + fetch: async (url, init) => { + assert.match(url, /api\.cloudinary\.com\/v1_1\/demo\/image\/upload$/); + assert.equal(init?.method, "POST"); + const body = init?.body; + assert.ok(body instanceof FormData); + assert.equal(body.get("api_key"), "key123"); + assert.equal(body.get("folder"), "sourcedraft"); + assert.ok(typeof body.get("signature") === "string"); + + return new Response( + JSON.stringify({ + secure_url: "https://res.cloudinary.com/demo/image/upload/v1/sourcedraft/photo.png", + public_id: "sourcedraft/photo", + format: "png", + bytes: 1024, + }), + { status: 200 }, + ); + }, + }); + + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const result = await provider.uploadMedia({ + buffer: png, + filename: "photo.png", + mimeType: "image/png", + repoPath: "public/images/photo.png", + publicPath: "/images/photo.png", + message: "Upload", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.provider, "cloudinary"); + assert.match(result.url, /^https:\/\/res\.cloudinary\.com\//); + assert.equal(result.path, "sourcedraft/photo"); + } + }); + + it("rejects unsupported file types", async () => { + const provider = createCloudinaryMediaProvider(config); + const result = await provider.uploadMedia({ + buffer: Buffer.from("%PDF"), + filename: "doc.pdf", + mimeType: "application/pdf", + repoPath: "public/images/doc.pdf", + publicPath: "/images/doc.pdf", + message: "Upload", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /Cloudinary media provider supports/); + } + }); + + it("returns actionable error on auth failure", async () => { + const provider = createCloudinaryMediaProvider({ + ...config, + fetch: async () => + new Response(JSON.stringify({ error: { message: "Invalid credentials" } }), { + status: 401, + }), + }); + + const result = await provider.uploadMedia({ + buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + filename: "photo.png", + mimeType: "image/png", + repoPath: "public/images/photo.png", + publicPath: "/images/photo.png", + message: "Upload", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /CLOUDINARY_API_KEY/); + } + }); +}); diff --git a/packages/media-providers/src/cloudinary/cloudinaryProvider.ts b/packages/media-providers/src/cloudinary/cloudinaryProvider.ts new file mode 100644 index 0000000..5b5f9c6 --- /dev/null +++ b/packages/media-providers/src/cloudinary/cloudinaryProvider.ts @@ -0,0 +1,123 @@ +import { resolveFetcher, type HttpFetcher, readResponseBody } from "../http.js"; +import type { MediaUploadInput, MediaUploadResult } from "../types.js"; +import { buildCloudinarySignature } from "./cloudinarySignature.js"; + +const IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); + +export type CloudinaryProviderConfig = { + cloudName: string; + apiKey: string; + apiSecret: string; + folder?: string; + fetch?: HttpFetcher; +}; + +type CloudinaryUploadBody = { + secure_url?: string; + public_id?: string; + bytes?: number; + format?: string; + error?: { message?: string }; +}; + +export function createCloudinaryMediaProvider( + config: CloudinaryProviderConfig, +): { + id: "cloudinary"; + uploadMedia: (input: MediaUploadInput) => Promise; +} { + const fetchImpl = resolveFetcher(config.fetch); + + return { + id: "cloudinary", + async uploadMedia(input: MediaUploadInput): Promise { + if (!IMAGE_MIME_TYPES.has(input.mimeType)) { + return { + ok: false, + error: + "Cloudinary media provider supports PNG, JPEG, GIF, and WebP images only. Use github-media for PDFs or other types.", + }; + } + + const timestamp = Math.round(Date.now() / 1000); + const signatureParams: Record = { timestamp }; + if (config.folder) { + signatureParams.folder = config.folder; + } + + const signature = buildCloudinarySignature(signatureParams, config.apiSecret); + const form = new FormData(); + form.set( + "file", + new Blob([Uint8Array.from(input.buffer)], { type: input.mimeType }), + input.filename, + ); + form.set("api_key", config.apiKey); + form.set("timestamp", String(timestamp)); + form.set("signature", signature); + if (config.folder) { + form.set("folder", config.folder); + } + + const response = await fetchImpl( + `https://api.cloudinary.com/v1_1/${encodeURIComponent(config.cloudName)}/image/upload`, + { + method: "POST", + body: form, + }, + ); + + const bodyText = await readResponseBody(response); + let body: CloudinaryUploadBody | null = null; + try { + body = JSON.parse(bodyText) as CloudinaryUploadBody; + } catch { + body = null; + } + + if (!response.ok) { + const message = body?.error?.message ?? bodyText.trim() ?? "Cloudinary upload failed."; + if (response.status === 401) { + return { + ok: false, + error: + "Cloudinary rejected the credentials (401). Check CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET in .env.", + status: 401, + }; + } + + return { + ok: false, + error: `Cloudinary upload failed (${response.status}): ${message}`, + status: response.status, + }; + } + + const secureUrl = body?.secure_url; + const publicId = body?.public_id; + + if (!secureUrl || !publicId) { + return { + ok: false, + error: "Cloudinary upload succeeded but did not return a secure URL.", + }; + } + + return { + ok: true, + url: secureUrl, + path: publicId, + provider: "cloudinary", + metadata: { + format: body?.format, + bytes: body?.bytes, + }, + }; + }, + }; +} diff --git a/packages/media-providers/src/cloudinary/cloudinarySignature.ts b/packages/media-providers/src/cloudinary/cloudinarySignature.ts new file mode 100644 index 0000000..f2d6699 --- /dev/null +++ b/packages/media-providers/src/cloudinary/cloudinarySignature.ts @@ -0,0 +1,13 @@ +import { createHash } from "node:crypto"; + +export function buildCloudinarySignature( + params: Record, + apiSecret: string, +): string { + const serialized = Object.keys(params) + .sort() + .map((key) => `${key}=${params[key]}`) + .join("&"); + + return createHash("sha1").update(`${serialized}${apiSecret}`).digest("hex"); +} diff --git a/packages/media-providers/src/cloudinaryMediaProvider.ts b/packages/media-providers/src/cloudinaryMediaProvider.ts new file mode 100644 index 0000000..c6abf77 --- /dev/null +++ b/packages/media-providers/src/cloudinaryMediaProvider.ts @@ -0,0 +1,44 @@ +import { createCloudinaryMediaProvider } from "./cloudinary/cloudinaryProvider.js"; +import type { + MediaProvider, + MediaProviderFactory, + MediaProviderRuntimeConfig, +} from "./types.js"; + +function createCloudinaryProviderFromConfig( + config: MediaProviderRuntimeConfig, +): MediaProvider { + const cloudName = config.cloudinaryCloudName?.trim(); + const apiKey = config.cloudinaryApiKey?.trim(); + const apiSecret = config.cloudinaryApiSecret?.trim(); + + if (!cloudName) { + throw new Error("Cloudinary media provider requires CLOUDINARY_CLOUD_NAME in .env."); + } + + if (!apiKey) { + throw new Error("Cloudinary media provider requires CLOUDINARY_API_KEY in .env."); + } + + if (!apiSecret) { + throw new Error("Cloudinary media provider requires CLOUDINARY_API_SECRET in .env."); + } + + const provider = createCloudinaryMediaProvider({ + cloudName, + apiKey, + apiSecret, + ...(config.cloudinaryFolder?.trim() + ? { folder: config.cloudinaryFolder.trim() } + : {}), + }); + + return provider; +} + +export const cloudinaryMediaProviderFactory: MediaProviderFactory = { + id: "cloudinary", + createProvider(config: MediaProviderRuntimeConfig): MediaProvider { + return createCloudinaryProviderFromConfig(config); + }, +}; diff --git a/packages/media-providers/src/githubMediaProvider.ts b/packages/media-providers/src/githubMediaProvider.ts new file mode 100644 index 0000000..20a996d --- /dev/null +++ b/packages/media-providers/src/githubMediaProvider.ts @@ -0,0 +1,55 @@ +import type { + MediaProvider, + MediaProviderFactory, + MediaProviderRuntimeConfig, + MediaUploadInput, + MediaUploadResult, +} from "./types.js"; + +function createGitHubMediaProvider(config: MediaProviderRuntimeConfig): MediaProvider { + return { + id: "github-media", + async uploadMedia(input: MediaUploadInput): Promise { + if (!config.publisherUpload) { + return { + ok: false, + error: + "GitHub media provider requires a configured git publisher with upload support.", + }; + } + + const result = await config.publisherUpload({ + repoPath: input.repoPath, + contentBase64: input.buffer.toString("base64"), + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + url: input.publicPath, + path: result.path, + provider: "github-media", + sha: result.sha, + commitSha: result.commitSha, + metadata: { + publicPath: input.publicPath, + }, + }; + }, + }; +} + +export const githubMediaProviderFactory: MediaProviderFactory = { + id: "github-media", + createProvider(config: MediaProviderRuntimeConfig): MediaProvider { + return createGitHubMediaProvider(config); + }, +}; diff --git a/packages/media-providers/src/http.ts b/packages/media-providers/src/http.ts new file mode 100644 index 0000000..4d4680b --- /dev/null +++ b/packages/media-providers/src/http.ts @@ -0,0 +1,13 @@ +export type HttpFetcher = typeof globalThis.fetch; + +export function resolveFetcher(fetchImpl?: HttpFetcher): HttpFetcher { + return fetchImpl ?? globalThis.fetch; +} + +export async function readResponseBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} diff --git a/packages/media-providers/src/index.ts b/packages/media-providers/src/index.ts new file mode 100644 index 0000000..044e7ce --- /dev/null +++ b/packages/media-providers/src/index.ts @@ -0,0 +1,26 @@ +import "./registerBuiltInMediaProviders.js"; + +export { + createMediaProvider, + getMediaProviderFactory, + isMediaProviderId, + listMediaProviderIds, + mediaProviderRegistry, + registerMediaProvider, + supportedMediaProviderSummary, +} from "./mediaProviderRegistry.js"; + +export { validateS3MediaConfig } from "./s3/s3Config.js"; +export { buildCloudinarySignature } from "./cloudinary/cloudinarySignature.js"; + +export { + MEDIA_PROVIDER_IDS, + type MediaProvider, + type MediaProviderFactory, + type MediaProviderId, + type MediaProviderRuntimeConfig, + type MediaUploadInput, + type MediaUploadResult, + type MediaUploadSuccess, + type PublisherMediaUpload, +} from "./types.js"; diff --git a/packages/media-providers/src/mediaProviderRegistry.ts b/packages/media-providers/src/mediaProviderRegistry.ts new file mode 100644 index 0000000..87bf65e --- /dev/null +++ b/packages/media-providers/src/mediaProviderRegistry.ts @@ -0,0 +1,49 @@ +import type { + MediaProvider, + MediaProviderFactory, + MediaProviderId, + MediaProviderRuntimeConfig, +} from "./types.js"; + +const providers = new Map(); + +export function registerMediaProvider(factory: MediaProviderFactory): void { + providers.set(factory.id, factory); +} + +export function listMediaProviderIds(): MediaProviderId[] { + return [...providers.keys()]; +} + +export function isMediaProviderId(value: string): value is MediaProviderId { + return providers.has(value as MediaProviderId); +} + +export function getMediaProviderFactory(id: MediaProviderId): MediaProviderFactory { + const factory = providers.get(id); + if (factory === undefined) { + throw new Error(`Media provider "${id}" is not registered.`); + } + + return factory; +} + +export function createMediaProvider( + id: MediaProviderId, + config: MediaProviderRuntimeConfig, +): MediaProvider { + return getMediaProviderFactory(id).createProvider(config); +} + +export function supportedMediaProviderSummary(): string { + return listMediaProviderIds().join(", "); +} + +export const mediaProviderRegistry = { + register: registerMediaProvider, + listIds: listMediaProviderIds, + isKnown: isMediaProviderId, + getFactory: getMediaProviderFactory, + create: createMediaProvider, + supportedSummary: supportedMediaProviderSummary, +}; diff --git a/packages/media-providers/src/registerBuiltInMediaProviders.ts b/packages/media-providers/src/registerBuiltInMediaProviders.ts new file mode 100644 index 0000000..d2c667c --- /dev/null +++ b/packages/media-providers/src/registerBuiltInMediaProviders.ts @@ -0,0 +1,12 @@ +import { cloudinaryMediaProviderFactory } from "./cloudinaryMediaProvider.js"; +import { githubMediaProviderFactory } from "./githubMediaProvider.js"; +import { registerMediaProvider } from "./mediaProviderRegistry.js"; +import { s3MediaProviderFactory } from "./s3MediaProvider.js"; + +export function registerBuiltInMediaProviders(): void { + registerMediaProvider(githubMediaProviderFactory); + registerMediaProvider(cloudinaryMediaProviderFactory); + registerMediaProvider(s3MediaProviderFactory); +} + +registerBuiltInMediaProviders(); diff --git a/packages/media-providers/src/registry.test.ts b/packages/media-providers/src/registry.test.ts new file mode 100644 index 0000000..06d4715 --- /dev/null +++ b/packages/media-providers/src/registry.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createMediaProvider, + isMediaProviderId, + listMediaProviderIds, +} from "./mediaProviderRegistry.js"; +import type { MediaProviderRuntimeConfig } from "./types.js"; + +import "./registerBuiltInMediaProviders.js"; + +const baseConfig: MediaProviderRuntimeConfig = { + mediaDir: "public/images", + publicMediaPath: "/images", +}; + +describe("media provider registry", () => { + it("registers built-in media providers", () => { + assert.deepEqual(listMediaProviderIds(), [ + "github-media", + "cloudinary", + "s3-compatible", + ]); + assert.equal(isMediaProviderId("github-media"), true); + assert.equal(isMediaProviderId("cloudinary"), true); + assert.equal(isMediaProviderId("s3-compatible"), true); + assert.equal(isMediaProviderId("imgix"), false); + }); + + it("selects github-media provider by default factory id", () => { + const provider = createMediaProvider("github-media", { + ...baseConfig, + publisherUpload: async () => ({ + ok: true, + path: "public/images/test.png", + sha: "sha", + commitSha: "commit", + }), + }); + + assert.equal(provider.id, "github-media"); + }); +}); diff --git a/packages/media-providers/src/s3/s3Config.ts b/packages/media-providers/src/s3/s3Config.ts new file mode 100644 index 0000000..d3f9312 --- /dev/null +++ b/packages/media-providers/src/s3/s3Config.ts @@ -0,0 +1,73 @@ +export type S3MediaConfig = { + endpoint: string; + region: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + publicBaseUrl?: string; + forcePathStyle?: boolean; +}; + +export type S3ConfigValidationResult = + | { ok: true; config: S3MediaConfig } + | { ok: false; error: string }; + +export function validateS3MediaConfig(input: { + endpoint?: string; + region?: string; + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + publicBaseUrl?: string; + forcePathStyle?: boolean; +}): S3ConfigValidationResult { + const endpoint = input.endpoint?.trim(); + const region = input.region?.trim(); + const bucket = input.bucket?.trim(); + const accessKeyId = input.accessKeyId?.trim(); + const secretAccessKey = input.secretAccessKey?.trim(); + + if (!endpoint) { + return { ok: false, error: "S3_ENDPOINT is not configured." }; + } + + if (!region) { + return { ok: false, error: "S3_REGION is not configured." }; + } + + if (!bucket) { + return { ok: false, error: "S3_BUCKET is not configured." }; + } + + if (!accessKeyId) { + return { ok: false, error: "S3_ACCESS_KEY_ID is not configured." }; + } + + if (!secretAccessKey) { + return { ok: false, error: "S3_SECRET_ACCESS_KEY is not configured." }; + } + + try { + new URL(endpoint); + } catch { + return { + ok: false, + error: "S3_ENDPOINT must be a valid URL (e.g. https://account.r2.cloudflarestorage.com).", + }; + } + + return { + ok: true, + config: { + endpoint: endpoint.replace(/\/+$/, ""), + region, + bucket, + accessKeyId, + secretAccessKey, + ...(input.publicBaseUrl?.trim() + ? { publicBaseUrl: input.publicBaseUrl.trim().replace(/\/+$/, "") } + : {}), + ...(input.forcePathStyle === true ? { forcePathStyle: true } : {}), + }, + }; +} diff --git a/packages/media-providers/src/s3/s3Provider.test.ts b/packages/media-providers/src/s3/s3Provider.test.ts new file mode 100644 index 0000000..dc78c49 --- /dev/null +++ b/packages/media-providers/src/s3/s3Provider.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { validateS3MediaConfig } from "./s3Config.js"; +import { createS3MediaProvider } from "./s3Provider.js"; + +describe("S3-compatible media provider", () => { + it("validates required config fields", () => { + const missing = validateS3MediaConfig({}); + assert.equal(missing.ok, false); + if (!missing.ok) { + assert.match(missing.error, /S3_ENDPOINT/); + } + + const valid = validateS3MediaConfig({ + endpoint: "https://account.r2.cloudflarestorage.com", + region: "auto", + bucket: "blog-media", + accessKeyId: "key", + secretAccessKey: "secret", + publicBaseUrl: "https://cdn.example.com", + forcePathStyle: true, + }); + + assert.equal(valid.ok, true); + if (valid.ok) { + assert.equal(valid.config.bucket, "blog-media"); + assert.equal(valid.config.forcePathStyle, true); + } + }); + + it("rejects invalid endpoint URLs", () => { + const result = validateS3MediaConfig({ + endpoint: "not-a-url", + region: "auto", + bucket: "blog-media", + accessKeyId: "key", + secretAccessKey: "secret", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /S3_ENDPOINT/); + } + }); + + it("returns experimental message when config is valid", async () => { + const provider = createS3MediaProvider({ + endpoint: "https://s3.amazonaws.com", + region: "us-east-1", + bucket: "blog-media", + accessKeyId: "key", + secretAccessKey: "secret", + }); + + const result = await provider.uploadMedia({ + buffer: Buffer.from("test"), + filename: "photo.png", + mimeType: "image/png", + repoPath: "images/photo.png", + publicPath: "/images/photo.png", + message: "Upload", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /experimental/); + } + }); +}); diff --git a/packages/media-providers/src/s3/s3Provider.ts b/packages/media-providers/src/s3/s3Provider.ts new file mode 100644 index 0000000..09068ac --- /dev/null +++ b/packages/media-providers/src/s3/s3Provider.ts @@ -0,0 +1,32 @@ +import { validateS3MediaConfig } from "./s3Config.js"; +import type { MediaUploadInput, MediaUploadResult } from "../types.js"; + +export function createS3MediaProvider(config: { + endpoint?: string; + region?: string; + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + publicBaseUrl?: string; + forcePathStyle?: boolean; +}): { + id: "s3-compatible"; + uploadMedia: (input: MediaUploadInput) => Promise; +} { + const validated = validateS3MediaConfig(config); + + return { + id: "s3-compatible", + async uploadMedia(_input: MediaUploadInput): Promise { + if (!validated.ok) { + return validated; + } + + return { + ok: false, + error: + "S3-compatible media upload is experimental and not implemented yet. Configure Cloudinary or github-media for uploads today. See docs/media.md.", + }; + }, + }; +} diff --git a/packages/media-providers/src/s3MediaProvider.ts b/packages/media-providers/src/s3MediaProvider.ts new file mode 100644 index 0000000..fd4ff8d --- /dev/null +++ b/packages/media-providers/src/s3MediaProvider.ts @@ -0,0 +1,45 @@ +import { createS3MediaProvider } from "./s3/s3Provider.js"; +import type { + MediaProvider, + MediaProviderFactory, + MediaProviderRuntimeConfig, +} from "./types.js"; + +export const s3MediaProviderFactory: MediaProviderFactory = { + id: "s3-compatible", + createProvider(config: MediaProviderRuntimeConfig): MediaProvider { + const s3Config: { + endpoint?: string; + region?: string; + bucket?: string; + accessKeyId?: string; + secretAccessKey?: string; + publicBaseUrl?: string; + forcePathStyle?: boolean; + } = {}; + + if (config.s3Endpoint !== undefined) { + s3Config.endpoint = config.s3Endpoint; + } + if (config.s3Region !== undefined) { + s3Config.region = config.s3Region; + } + if (config.s3Bucket !== undefined) { + s3Config.bucket = config.s3Bucket; + } + if (config.s3AccessKeyId !== undefined) { + s3Config.accessKeyId = config.s3AccessKeyId; + } + if (config.s3SecretAccessKey !== undefined) { + s3Config.secretAccessKey = config.s3SecretAccessKey; + } + if (config.s3PublicBaseUrl !== undefined) { + s3Config.publicBaseUrl = config.s3PublicBaseUrl; + } + if (config.s3ForcePathStyle === true) { + s3Config.forcePathStyle = true; + } + + return createS3MediaProvider(s3Config); + }, +}; diff --git a/packages/media-providers/src/types.ts b/packages/media-providers/src/types.ts new file mode 100644 index 0000000..d71e49e --- /dev/null +++ b/packages/media-providers/src/types.ts @@ -0,0 +1,64 @@ +export const MEDIA_PROVIDER_IDS = ["github-media", "cloudinary", "s3-compatible"] as const; + +export type MediaProviderId = (typeof MEDIA_PROVIDER_IDS)[number]; + +export type MediaUploadInput = { + buffer: Buffer; + filename: string; + mimeType: string; + repoPath: string; + publicPath: string; + message: string; +}; + +export type MediaUploadSuccess = { + ok: true; + url: string; + path: string; + provider: MediaProviderId; + metadata?: Record; + sha?: string; + commitSha?: string; +}; + +export type MediaUploadError = { + ok: false; + error: string; + status?: number; +}; + +export type MediaUploadResult = MediaUploadSuccess | MediaUploadError; + +export type PublisherMediaUpload = ( + input: Pick & { contentBase64: string }, +) => Promise< + | { ok: true; path: string; sha: string; commitSha: string } + | { ok: false; error: string; status?: number } +>; + +export type MediaProviderRuntimeConfig = { + mediaDir: string; + publicMediaPath: string; + publisherUpload?: PublisherMediaUpload; + cloudinaryCloudName?: string; + cloudinaryApiKey?: string; + cloudinaryApiSecret?: string; + cloudinaryFolder?: string; + s3Endpoint?: string; + s3Region?: string; + s3Bucket?: string; + s3AccessKeyId?: string; + s3SecretAccessKey?: string; + s3PublicBaseUrl?: string; + s3ForcePathStyle?: boolean; +}; + +export type MediaProvider = { + id: MediaProviderId; + uploadMedia(input: MediaUploadInput): Promise; +}; + +export type MediaProviderFactory = { + id: MediaProviderId; + createProvider: (config: MediaProviderRuntimeConfig) => MediaProvider; +}; diff --git a/packages/media-providers/tsconfig.json b/packages/media-providers/tsconfig.json new file mode 100644 index 0000000..c050866 --- /dev/null +++ b/packages/media-providers/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/publishers/src/bitbucketPublisherAdapter.ts b/packages/publishers/src/bitbucketPublisherAdapter.ts index 48cb698..95f0373 100644 --- a/packages/publishers/src/bitbucketPublisherAdapter.ts +++ b/packages/publishers/src/bitbucketPublisherAdapter.ts @@ -54,6 +54,7 @@ function createBitbucketPublisherInstance(config: PublisherRuntimeConfig): Publi return { id: "bitbucket", + kind: "git", capabilities: BITBUCKET_CAPABILITIES, async publishArticle(input: PublishArticleInput): Promise { const result = await bitbucket.publishFile({ @@ -115,6 +116,7 @@ function wrapPublisherWithCapabilities( ): Publisher { return { id: factory.id, + kind: factory.kind, capabilities: factory.capabilities, publishArticle: factory.capabilities.publishArticle ? (input) => publisher.publishArticle(input) @@ -133,6 +135,7 @@ function wrapPublisherWithCapabilities( export const bitbucketPublisherFactory: PublisherFactory = { id: "bitbucket", + kind: "git", capabilities: BITBUCKET_CAPABILITIES, createPublisher(config: PublisherRuntimeConfig): Publisher { return wrapPublisherWithCapabilities( diff --git a/packages/publishers/src/cmsPayload.ts b/packages/publishers/src/cmsPayload.ts new file mode 100644 index 0000000..f60fea1 --- /dev/null +++ b/packages/publishers/src/cmsPayload.ts @@ -0,0 +1,39 @@ +import type { CmsArticlePayload } from "./types.js"; + +export function resolveCmsStatus( + draft: boolean, + defaultStatus: string, +): string { + if (draft) { + return "draft"; + } + + return defaultStatus; +} + +export function resolveFeatureImageUrl(article: CmsArticlePayload): string | undefined { + const candidate = article.socialImage ?? article.heroImage; + if (!candidate) { + return undefined; + } + + if (/^https?:\/\//i.test(candidate)) { + return candidate; + } + + return undefined; +} + +export function requireCmsArticle( + input: { article?: CmsArticlePayload }, + publisherId: string, +): CmsArticlePayload | { ok: false; error: string } { + if (!input.article) { + return { + ok: false, + error: `Publisher "${publisherId}" requires article fields but none were provided.`, + }; + } + + return input.article; +} diff --git a/packages/publishers/src/ghost/ghostErrors.ts b/packages/publishers/src/ghost/ghostErrors.ts new file mode 100644 index 0000000..be26a17 --- /dev/null +++ b/packages/publishers/src/ghost/ghostErrors.ts @@ -0,0 +1,34 @@ +export type GhostOperation = "create" | "update" | "uploadMedia"; + +export type GhostErrorContext = { + adminUrl?: string; + postId?: string; +}; + +export function formatGhostApiError( + status: number, + rawMessage: string, + operation: GhostOperation, + context: GhostErrorContext = {}, +): string { + const message = rawMessage.trim(); + const site = context.adminUrl ?? "GHOST_ADMIN_URL"; + + if (status === 401 || status === 403) { + return "Ghost rejected the Admin API credentials. Check GHOST_ADMIN_API_KEY in .env and that the integration is active."; + } + + if (status === 404) { + if (operation === "update") { + return `Ghost post ${context.postId ?? "unknown"} was not found. Provide a valid remoteId to update an existing post.`; + } + + return `Ghost Admin API was not found at ${site}. Check GHOST_ADMIN_URL (site root URL, no /ghost path).`; + } + + if (message.length > 0) { + return `Ghost API error (${status}): ${message}`; + } + + return `Ghost API request failed (${status}).`; +} diff --git a/packages/publishers/src/ghost/ghostJwt.test.ts b/packages/publishers/src/ghost/ghostJwt.test.ts new file mode 100644 index 0000000..153caf0 --- /dev/null +++ b/packages/publishers/src/ghost/ghostJwt.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { describe, it } from "node:test"; +import { createGhostAdminJwt, parseGhostAdminApiKey } from "./ghostJwt.js"; + +const TEST_KEY_ID = "abc123"; +const TEST_SECRET_HEX = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"; +const TEST_API_KEY = `${TEST_KEY_ID}:${TEST_SECRET_HEX}`; + +describe("Ghost JWT", () => { + it("parses a valid Admin API key", () => { + const parsed = parseGhostAdminApiKey(TEST_API_KEY); + assert.equal("ok" in parsed, false); + if (!("ok" in parsed)) { + assert.equal(parsed.id, TEST_KEY_ID); + assert.equal(parsed.secret.length > 0, true); + } + }); + + it("rejects malformed Admin API keys", () => { + const parsed = parseGhostAdminApiKey("not-a-valid-key"); + assert.equal(parsed.ok, false); + }); + + it("generates a JWT with the expected header and signature", () => { + const now = 1_700_000_000; + const result = createGhostAdminJwt(TEST_API_KEY, now); + assert.equal("ok" in result, false); + + if ("ok" in result) { + return; + } + + const [header, payload, signature] = result.token.split("."); + assert.ok(header); + assert.ok(payload); + assert.ok(signature); + + const decodedHeader = JSON.parse(Buffer.from(header, "base64url").toString("utf8")); + assert.equal(decodedHeader.alg, "HS256"); + assert.equal(decodedHeader.kid, TEST_KEY_ID); + + const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + assert.equal(decodedPayload.iat, now); + assert.equal(decodedPayload.exp, now + 300); + assert.equal(decodedPayload.aud, "/admin/"); + + const expectedSignature = createHmac("sha256", Buffer.from(TEST_SECRET_HEX, "hex")) + .update(`${header}.${payload}`) + .digest("base64url"); + assert.equal(signature, expectedSignature); + }); +}); diff --git a/packages/publishers/src/ghost/ghostJwt.ts b/packages/publishers/src/ghost/ghostJwt.ts new file mode 100644 index 0000000..09ada8e --- /dev/null +++ b/packages/publishers/src/ghost/ghostJwt.ts @@ -0,0 +1,61 @@ +import { createHmac } from "node:crypto"; + +function base64UrlEncode(value: string | Buffer): string { + const buffer = typeof value === "string" ? Buffer.from(value, "utf8") : value; + return buffer.toString("base64url"); +} + +export function parseGhostAdminApiKey( + apiKey: string, +): { id: string; secret: Buffer } | { ok: false; error: string } { + const trimmed = apiKey.trim(); + const separator = trimmed.indexOf(":"); + + if (separator <= 0 || separator === trimmed.length - 1) { + return { + ok: false, + error: + "GHOST_ADMIN_API_KEY must be in id:secret format from Ghost Admin → Integrations.", + }; + } + + const id = trimmed.slice(0, separator); + const secretHex = trimmed.slice(separator + 1); + + try { + const secret = Buffer.from(secretHex, "hex"); + if (secret.length === 0) { + return { ok: false, error: "Ghost Admin API key secret is not valid hex." }; + } + + return { id, secret }; + } catch { + return { ok: false, error: "Ghost Admin API key secret is not valid hex." }; + } +} + +export function createGhostAdminJwt( + apiKey: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): { token: string } | { ok: false; error: string } { + const parsed = parseGhostAdminApiKey(apiKey); + if ("ok" in parsed) { + return parsed; + } + + const header = base64UrlEncode( + JSON.stringify({ alg: "HS256", typ: "JWT", kid: parsed.id }), + ); + const payload = base64UrlEncode( + JSON.stringify({ + iat: nowSeconds, + exp: nowSeconds + 5 * 60, + aud: "/admin/", + }), + ); + const signature = createHmac("sha256", parsed.secret) + .update(`${header}.${payload}`) + .digest("base64url"); + + return { token: `${header}.${payload}.${signature}` }; +} diff --git a/packages/publishers/src/ghost/ghostPublisher.test.ts b/packages/publishers/src/ghost/ghostPublisher.test.ts new file mode 100644 index 0000000..e7c12b8 --- /dev/null +++ b/packages/publishers/src/ghost/ghostPublisher.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { CmsArticlePayload } from "../types.js"; +import { createGhostPublisher } from "./ghostPublisher.js"; + +const TEST_KEY_ID = "abc123"; +const TEST_SECRET_HEX = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"; +const TEST_API_KEY = `${TEST_KEY_ID}:${TEST_SECRET_HEX}`; + +const article: CmsArticlePayload = { + title: "Hello Ghost", + slug: "hello-ghost", + description: "Short excerpt", + body: "

Hello Ghost

", + pubDate: "2026-06-08T10:00:00.000Z", + category: "Guides", + tags: ["cms"], + draft: true, + metaTitle: "Meta title", + metaDescription: "Meta description", + canonicalUrl: "https://example.com/hello-ghost/", + heroImage: "https://example.com/images/hero.jpg", +}; + +const config = { + adminUrl: "https://example.com", + adminApiKey: TEST_API_KEY, + acceptVersion: "v5.126", + defaultStatus: "draft", +}; + +function requestMethod(init?: RequestInit): string { + return init?.method?.toUpperCase() ?? "GET"; +} + +describe("Ghost publisher", () => { + it("creates a draft post with JWT auth and source=html", async () => { + const publisher = createGhostPublisher({ + ...config, + fetch: async (url, init) => { + assert.equal(url, "https://example.com/ghost/api/admin/posts/?source=html"); + assert.equal(requestMethod(init), "POST"); + + const headers = new Headers(init?.headers); + assert.match(headers.get("Authorization") ?? "", /^Ghost /); + assert.equal(headers.get("Accept-Version"), "v5.126"); + + const body = JSON.parse(init?.body?.toString() ?? "{}"); + const post = body.posts[0]; + assert.equal(post.title, article.title); + assert.equal(post.slug, article.slug); + assert.equal(post.status, "draft"); + assert.equal(post.html, article.body); + assert.equal(post.feature_image, article.heroImage); + assert.equal(post.meta_title, article.metaTitle); + + return new Response( + JSON.stringify({ + posts: [{ id: "ghost-uuid-1", slug: "hello-ghost", status: "draft" }], + }), + { status: 201 }, + ); + }, + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + assert.equal(result.remoteId, "ghost-uuid-1"); + } + }); + + it("updates an existing post when remoteId is provided", async () => { + const publisher = createGhostPublisher({ + ...config, + fetch: async (url, init) => { + assert.match(url, /\/ghost\/api\/admin\/posts\/ghost-uuid-1\/\?source=html$/); + assert.equal(requestMethod(init), "PUT"); + return new Response( + JSON.stringify({ + posts: [{ id: "ghost-uuid-1", slug: "hello-ghost" }], + }), + { status: 200 }, + ); + }, + }); + + const result = await publisher.publishPost({ article, remoteId: "ghost-uuid-1" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, false); + } + }); + + it("returns actionable error on auth failure", async () => { + const publisher = createGhostPublisher({ + ...config, + fetch: async () => + new Response(JSON.stringify({ errors: [{ message: "Authorization failed" }] }), { + status: 403, + }), + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GHOST_ADMIN_API_KEY/); + } + }); + + it("returns actionable error on invalid endpoint", async () => { + const publisher = createGhostPublisher({ + ...config, + fetch: async () => + new Response(JSON.stringify({ errors: [{ message: "Not found" }] }), { status: 404 }), + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GHOST_ADMIN_URL/); + } + }); +}); diff --git a/packages/publishers/src/ghost/ghostPublisher.ts b/packages/publishers/src/ghost/ghostPublisher.ts new file mode 100644 index 0000000..0c5436d --- /dev/null +++ b/packages/publishers/src/ghost/ghostPublisher.ts @@ -0,0 +1,243 @@ +import { resolveCmsStatus, resolveFeatureImageUrl } from "../cmsPayload.js"; +import { + type HttpFetcher, + parseJsonBody, + readResponseBody, + resolveFetcher, +} from "../http.js"; +import type { CmsArticlePayload } from "../types.js"; +import { createGhostAdminJwt } from "./ghostJwt.js"; +import { formatGhostApiError, type GhostOperation } from "./ghostErrors.js"; + +export const DEFAULT_GHOST_ACCEPT_VERSION = "v5.126"; + +export type GhostPublisherConfig = { + adminUrl: string; + adminApiKey: string; + acceptVersion: string; + defaultStatus: string; + fetch?: HttpFetcher; +}; + +type GhostPost = { + id?: string; + slug?: string; + url?: string; + status?: string; +}; + +type GhostPostsResponse = { + posts?: GhostPost[]; +}; + +export type PublishPostInput = { + article: CmsArticlePayload; + remoteId?: string; +}; + +export type PublishPostSuccess = { + ok: true; + created: boolean; + path: string; + sha: string; + commitSha: string; + remoteId: string; +}; + +export type PublishPostError = { + ok: false; + error: string; + status?: number; +}; + +export type PublishPostResult = PublishPostSuccess | PublishPostError; + +export type GhostPublisher = { + publishPost: (input: PublishPostInput) => Promise; +}; + +function trimAdminUrl(adminUrl: string): string { + return adminUrl.replace(/\/+$/, ""); +} + +function ghostErrorMessage(body: unknown, fallback: string): string { + if (Array.isArray(body) && body.length > 0) { + const first = body[0] as { message?: string }; + if (first.message) { + return first.message; + } + } + + if (typeof body === "object" && body !== null) { + const record = body as { errors?: { message?: string }[] }; + const message = record.errors?.[0]?.message; + if (message) { + return message; + } + } + + return fallback; +} + +function buildGhostPostPayload( + article: CmsArticlePayload, + defaultStatus: string, +): Record { + const status = resolveCmsStatus(article.draft, defaultStatus); + const featureImage = resolveFeatureImageUrl(article); + + const post: Record = { + title: article.title, + slug: article.slug, + html: article.body, + status, + excerpt: article.description, + tags: article.tags.map((name) => ({ name })), + }; + + if (featureImage) { + post.feature_image = featureImage; + } + + if (article.metaTitle) { + post.meta_title = article.metaTitle; + } + + if (article.metaDescription) { + post.meta_description = article.metaDescription; + } + + if (article.canonicalUrl) { + post.canonical_url = article.canonicalUrl; + } + + if (article.updatedDate) { + post.updated_at = article.updatedDate; + } + + return post; +} + +export function createGhostPublisher(config: GhostPublisherConfig): GhostPublisher { + const fetchImpl = resolveFetcher(config.fetch); + const adminBase = trimAdminUrl(config.adminUrl); + + async function ghostRequest( + method: string, + path: string, + operation: GhostOperation, + body?: Record, + postId?: string, + ): Promise<{ ok: true; bodyText: string } | PublishPostError> { + const jwt = createGhostAdminJwt(config.adminApiKey); + if ("ok" in jwt) { + return jwt; + } + + const response = await fetchImpl(`${adminBase}${path}`, { + method, + headers: { + Authorization: `Ghost ${jwt.token}`, + Accept: "application/json", + "Content-Type": "application/json", + "Accept-Version": config.acceptVersion, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const bodyText = await readResponseBody(response); + + if (!response.ok) { + const parsed = parseJsonBody(bodyText); + return { + ok: false, + error: formatGhostApiError( + response.status, + ghostErrorMessage(parsed, bodyText), + operation, + { + adminUrl: adminBase, + ...(postId !== undefined ? { postId } : {}), + }, + ), + status: response.status, + }; + } + + return { ok: true, bodyText }; + } + + function parsePostResponse(bodyText: string, fallbackSlug: string): PublishPostSuccess | PublishPostError { + const body = parseJsonBody(bodyText); + const post = body?.posts?.[0]; + const id = post?.id ?? ""; + + if (id.length === 0) { + return { + ok: false, + error: "Ghost returned a response without a post id.", + }; + } + + return { + ok: true, + created: true, + path: post?.slug ?? fallbackSlug, + sha: id, + commitSha: id, + remoteId: id, + }; + } + + return { + async publishPost(input: PublishPostInput): Promise { + const postPayload = buildGhostPostPayload(input.article, config.defaultStatus); + const remoteId = input.remoteId?.trim(); + + if (remoteId) { + const result = await ghostRequest( + "PUT", + `/ghost/api/admin/posts/${encodeURIComponent(remoteId)}/?source=html`, + "update", + { posts: [postPayload] }, + remoteId, + ); + + if (!result.ok) { + return result; + } + + const body = parseJsonBody(result.bodyText); + const post = body?.posts?.[0]; + const id = post?.id ?? remoteId; + + return { + ok: true, + created: false, + path: post?.slug ?? input.article.slug, + sha: id, + commitSha: id, + remoteId: id, + }; + } + + const result = await ghostRequest( + "POST", + "/ghost/api/admin/posts/?source=html", + "create", + { posts: [postPayload] }, + ); + + if (!result.ok) { + return result; + } + + const parsed = parsePostResponse(result.bodyText, input.article.slug); + if (!parsed.ok) { + return parsed; + } + + return { ...parsed, created: true }; + }, + }; +} diff --git a/packages/publishers/src/ghostPublisherAdapter.ts b/packages/publishers/src/ghostPublisherAdapter.ts new file mode 100644 index 0000000..f53ed11 --- /dev/null +++ b/packages/publishers/src/ghostPublisherAdapter.ts @@ -0,0 +1,124 @@ +import { requireCmsArticle } from "./cmsPayload.js"; +import { DEFAULT_GHOST_ACCEPT_VERSION, createGhostPublisher } from "./ghost/ghostPublisher.js"; +import type { + Publisher, + PublisherFactory, + PublisherRuntimeConfig, + PublishArticleInput, + PublishArticleResult, +} from "./types.js"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; + +const GHOST_CAPABILITIES = { + publishArticle: true, + uploadMedia: false, + listPosts: false, + readPost: false, +} as const; + +function resolveGhostConfig(config: PublisherRuntimeConfig) { + const adminUrl = config.ghostAdminUrl?.trim(); + const adminApiKey = config.ghostAdminApiKey?.trim(); + + if (!adminUrl) { + throw new Error("Ghost publisher requires GHOST_ADMIN_URL in .env."); + } + + if (!adminApiKey) { + throw new Error("Ghost publisher requires GHOST_ADMIN_API_KEY in .env."); + } + + return createGhostPublisher({ + adminUrl, + adminApiKey, + acceptVersion: config.ghostAcceptVersion?.trim() || DEFAULT_GHOST_ACCEPT_VERSION, + defaultStatus: config.ghostDefaultStatus?.trim() || "draft", + }); +} + +function createGhostPublisherInstance(config: PublisherRuntimeConfig): Publisher { + const ghost = resolveGhostConfig(config); + + return { + id: "ghost", + kind: "remote-cms", + capabilities: GHOST_CAPABILITIES, + async publishArticle(input: PublishArticleInput): Promise { + const article = requireCmsArticle(input, "ghost"); + if ("ok" in article) { + return article; + } + + const result = await ghost.publishPost({ + article, + ...(input.remoteId ? { remoteId: input.remoteId } : {}), + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + remoteId: result.remoteId, + }; + }, + async uploadMedia() { + return unsupportedUploadMedia("ghost"); + }, + async listPosts() { + return unsupportedListPosts("ghost"); + }, + async readPost() { + return unsupportedReadPost("ghost"); + }, + }; +} + +function wrapPublisherWithCapabilities( + factory: PublisherFactory, + publisher: Publisher, +): Publisher { + return { + id: factory.id, + kind: factory.kind, + capabilities: factory.capabilities, + publishArticle: factory.capabilities.publishArticle + ? (input) => publisher.publishArticle(input) + : () => Promise.resolve(unsupportedPublishArticle(factory.id)), + uploadMedia: factory.capabilities.uploadMedia + ? (input) => publisher.uploadMedia(input) + : () => Promise.resolve(unsupportedUploadMedia(factory.id)), + listPosts: factory.capabilities.listPosts + ? (input) => publisher.listPosts(input) + : () => Promise.resolve(unsupportedListPosts(factory.id)), + readPost: factory.capabilities.readPost + ? (input) => publisher.readPost(input) + : () => Promise.resolve(unsupportedReadPost(factory.id)), + }; +} + +export const ghostPublisherFactory: PublisherFactory = { + id: "ghost", + kind: "remote-cms", + capabilities: GHOST_CAPABILITIES, + createPublisher(config: PublisherRuntimeConfig): Publisher { + return wrapPublisherWithCapabilities( + ghostPublisherFactory, + createGhostPublisherInstance(config), + ); + }, +}; diff --git a/packages/publishers/src/githubPublisherAdapter.ts b/packages/publishers/src/githubPublisherAdapter.ts index 92c7ffd..6885d77 100644 --- a/packages/publishers/src/githubPublisherAdapter.ts +++ b/packages/publishers/src/githubPublisherAdapter.ts @@ -36,6 +36,7 @@ function createGitHubPublisherInstance(config: PublisherRuntimeConfig): Publishe return { id: "github", + kind: "git", capabilities: GITHUB_CAPABILITIES, async publishArticle(input: PublishArticleInput): Promise { const result = await github.publishFile({ @@ -127,6 +128,7 @@ function wrapPublisherWithCapabilities( ): Publisher { return { id: factory.id, + kind: factory.kind, capabilities: factory.capabilities, publishArticle: factory.capabilities.publishArticle ? (input) => publisher.publishArticle(input) @@ -145,6 +147,7 @@ function wrapPublisherWithCapabilities( export const githubPublisherFactory: PublisherFactory = { id: "github", + kind: "git", capabilities: GITHUB_CAPABILITIES, createPublisher(config: PublisherRuntimeConfig): Publisher { return wrapPublisherWithCapabilities( diff --git a/packages/publishers/src/gitlabPublisherAdapter.ts b/packages/publishers/src/gitlabPublisherAdapter.ts index 9f9a880..27ca8e0 100644 --- a/packages/publishers/src/gitlabPublisherAdapter.ts +++ b/packages/publishers/src/gitlabPublisherAdapter.ts @@ -61,6 +61,7 @@ function createGitLabPublisherInstance(config: PublisherRuntimeConfig): Publishe return { id: "gitlab", + kind: "git", capabilities: GITLAB_CAPABILITIES, async publishArticle(input: PublishArticleInput): Promise { const result = await gitlab.publishFile({ @@ -150,6 +151,7 @@ function wrapPublisherWithCapabilities( ): Publisher { return { id: factory.id, + kind: factory.kind, capabilities: factory.capabilities, publishArticle: factory.capabilities.publishArticle ? (input) => publisher.publishArticle(input) @@ -168,6 +170,7 @@ function wrapPublisherWithCapabilities( export const gitlabPublisherFactory: PublisherFactory = { id: "gitlab", + kind: "git", capabilities: GITLAB_CAPABILITIES, createPublisher(config: PublisherRuntimeConfig): Publisher { return wrapPublisherWithCapabilities( diff --git a/packages/publishers/src/index.ts b/packages/publishers/src/index.ts index b189679..73b4df0 100644 --- a/packages/publishers/src/index.ts +++ b/packages/publishers/src/index.ts @@ -12,6 +12,7 @@ export { export { PUBLISHER_IDS, + type CmsArticlePayload, type ListPostsInput, type ListPostsResult, type ListedPostFile, @@ -21,6 +22,7 @@ export { type PublisherCapabilities, type PublisherFactory, type PublisherId, + type PublisherKind, type PublisherRuntimeConfig, type ReadPostInput, type ReadPostResult, diff --git a/packages/publishers/src/registerBuiltInPublishers.ts b/packages/publishers/src/registerBuiltInPublishers.ts index 2796fb3..4fd3f83 100644 --- a/packages/publishers/src/registerBuiltInPublishers.ts +++ b/packages/publishers/src/registerBuiltInPublishers.ts @@ -1,12 +1,16 @@ import { bitbucketPublisherFactory } from "./bitbucketPublisherAdapter.js"; +import { ghostPublisherFactory } from "./ghostPublisherAdapter.js"; import { githubPublisherFactory } from "./githubPublisherAdapter.js"; import { gitlabPublisherFactory } from "./gitlabPublisherAdapter.js"; import { registerPublisher } from "./publisherRegistry.js"; +import { wordpressPublisherFactory } from "./wordpressPublisherAdapter.js"; export function registerBuiltInPublishers(): void { registerPublisher(githubPublisherFactory); registerPublisher(gitlabPublisherFactory); registerPublisher(bitbucketPublisherFactory); + registerPublisher(wordpressPublisherFactory); + registerPublisher(ghostPublisherFactory); } registerBuiltInPublishers(); diff --git a/packages/publishers/src/registry.test.ts b/packages/publishers/src/registry.test.ts index 52f257f..3ecf669 100644 --- a/packages/publishers/src/registry.test.ts +++ b/packages/publishers/src/registry.test.ts @@ -31,19 +31,50 @@ const bitbucketRuntimeConfig: PublisherRuntimeConfig = { repo: "blog", }; +const wordpressRuntimeConfig: PublisherRuntimeConfig = { + ...githubRuntimeConfig, + token: "", + owner: "", + repo: "", + wordpressApiUrl: "https://example.com/wp-json", + wordpressUsername: "editor", + wordpressAppPassword: "app-password", + wordpressDefaultStatus: "draft", +}; + +const ghostRuntimeConfig: PublisherRuntimeConfig = { + ...githubRuntimeConfig, + token: "", + owner: "", + repo: "", + ghostAdminUrl: "https://example.com", + ghostAdminApiKey: "abc:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + ghostAcceptVersion: "v5.126", + ghostDefaultStatus: "draft", +}; + describe("publisher registry", () => { it("registers built-in publishers", () => { - assert.deepEqual(listPublisherIds(), ["github", "gitlab", "bitbucket"]); + assert.deepEqual(listPublisherIds(), [ + "github", + "gitlab", + "bitbucket", + "wordpress", + "ghost", + ]); assert.equal(isPublisherId("github"), true); assert.equal(isPublisherId("gitlab"), true); assert.equal(isPublisherId("bitbucket"), true); - assert.equal(isPublisherId("wordpress"), false); + assert.equal(isPublisherId("wordpress"), true); + assert.equal(isPublisherId("ghost"), true); + assert.equal(isPublisherId("drupal"), false); }); it("creates github publisher with full capabilities", () => { const publisher = createPublisher("github", githubRuntimeConfig); assert.equal(publisher.id, "github"); + assert.equal(publisher.kind, "git"); assert.equal(publisher.capabilities.publishArticle, true); assert.equal(publisher.capabilities.uploadMedia, true); assert.equal(publisher.capabilities.listPosts, true); @@ -54,6 +85,7 @@ describe("publisher registry", () => { const publisher = createPublisher("gitlab", gitlabRuntimeConfig); assert.equal(publisher.id, "gitlab"); + assert.equal(publisher.kind, "git"); assert.equal(publisher.capabilities.publishArticle, true); assert.equal(publisher.capabilities.uploadMedia, true); assert.equal(publisher.capabilities.listPosts, true); @@ -64,12 +96,32 @@ describe("publisher registry", () => { const publisher = createPublisher("bitbucket", bitbucketRuntimeConfig); assert.equal(publisher.id, "bitbucket"); + assert.equal(publisher.kind, "git"); assert.equal(publisher.capabilities.publishArticle, true); assert.equal(publisher.capabilities.uploadMedia, true); assert.equal(publisher.capabilities.listPosts, false); assert.equal(publisher.capabilities.readPost, false); }); + it("creates wordpress remote CMS publisher", () => { + const publisher = createPublisher("wordpress", wordpressRuntimeConfig); + + assert.equal(publisher.id, "wordpress"); + assert.equal(publisher.kind, "remote-cms"); + assert.equal(publisher.capabilities.publishArticle, true); + assert.equal(publisher.capabilities.uploadMedia, false); + assert.equal(publisher.capabilities.listPosts, false); + }); + + it("creates ghost remote CMS publisher", () => { + const publisher = createPublisher("ghost", ghostRuntimeConfig); + + assert.equal(publisher.id, "ghost"); + assert.equal(publisher.kind, "remote-cms"); + assert.equal(publisher.capabilities.publishArticle, true); + assert.equal(publisher.capabilities.uploadMedia, false); + }); + it("exposes registry helpers", () => { assert.equal(publisherRegistry.isKnown("github"), true); assert.match(publisherRegistry.supportedSummary(), /github/); diff --git a/packages/publishers/src/types.ts b/packages/publishers/src/types.ts index aa1aae5..d2796fb 100644 --- a/packages/publishers/src/types.ts +++ b/packages/publishers/src/types.ts @@ -1,7 +1,16 @@ -export const PUBLISHER_IDS = ["github", "gitlab", "bitbucket"] as const; +export const PUBLISHER_IDS = [ + "github", + "gitlab", + "bitbucket", + "wordpress", + "ghost", +] as const; export type PublisherId = (typeof PUBLISHER_IDS)[number]; +/** Git publishers commit files to a repository; remote CMS publishers call HTTP APIs. */ +export type PublisherKind = "git" | "remote-cms"; + export type PublisherRuntimeConfig = { token: string; owner: string; @@ -16,6 +25,17 @@ export type PublisherRuntimeConfig = { gitlabBaseUrl?: string; /** Bitbucket app-password username when Basic auth is required */ bitbucketUsername?: string; + /** WordPress REST API base URL (e.g. https://example.com/wp-json) */ + wordpressApiUrl?: string; + wordpressUsername?: string; + wordpressAppPassword?: string; + wordpressDefaultStatus?: string; + wordpressDefaultAuthor?: number; + /** Ghost site URL (e.g. https://example.com) */ + ghostAdminUrl?: string; + ghostAdminApiKey?: string; + ghostAcceptVersion?: string; + ghostDefaultStatus?: string; }; export type PublisherCapabilities = { @@ -25,10 +45,35 @@ export type PublisherCapabilities = { readPost: boolean; }; +/** Article fields used by remote CMS publishers (WordPress, Ghost). */ +export type CmsArticlePayload = { + title: string; + slug: string; + description: string; + body: string; + pubDate: string; + category: string; + tags: string[]; + draft: boolean; + updatedDate?: string; + heroImage?: string; + author?: string; + metaTitle?: string; + metaDescription?: string; + canonicalUrl?: string; + socialImage?: string; +}; + export type PublishArticleInput = { + /** Repo-relative path for git publishers; slug or label for CMS publishers */ path: string; + /** Rendered file content for git publishers */ content: string; message: string; + /** Structured article data for remote CMS publishers */ + article?: CmsArticlePayload; + /** Remote post ID for CMS updates (WordPress post id, Ghost uuid) */ + remoteId?: string; }; export type PublishArticleSuccess = { @@ -37,6 +82,8 @@ export type PublishArticleSuccess = { created: boolean; sha: string; commitSha: string; + /** Remote CMS post identifier when applicable */ + remoteId?: string; }; export type PublishArticleError = { @@ -114,6 +161,7 @@ export type ReadPostResult = ReadPostSuccess | ReadPostError; /** Sends rendered content and media to a publishing target. */ export type Publisher = { id: PublisherId; + kind: PublisherKind; capabilities: PublisherCapabilities; publishArticle(input: PublishArticleInput): Promise; uploadMedia(input: UploadMediaInput): Promise; @@ -123,6 +171,7 @@ export type Publisher = { export type PublisherFactory = { id: PublisherId; + kind: PublisherKind; capabilities: PublisherCapabilities; createPublisher: (config: PublisherRuntimeConfig) => Publisher; }; diff --git a/packages/publishers/src/wordpress/wordpressErrors.ts b/packages/publishers/src/wordpress/wordpressErrors.ts new file mode 100644 index 0000000..0e2dc56 --- /dev/null +++ b/packages/publishers/src/wordpress/wordpressErrors.ts @@ -0,0 +1,38 @@ +export type WordPressOperation = "create" | "update" | "uploadMedia"; + +export type WordPressErrorContext = { + apiUrl?: string; + postId?: string; +}; + +export function formatWordPressApiError( + status: number, + rawMessage: string, + operation: WordPressOperation, + context: WordPressErrorContext = {}, +): string { + const message = rawMessage.trim(); + const endpoint = context.apiUrl ?? "WORDPRESS_API_URL"; + + if (status === 401) { + return "WordPress rejected the credentials (401). Check WORDPRESS_USERNAME and WORDPRESS_APP_PASSWORD in .env."; + } + + if (status === 403) { + return `WordPress denied access (403). The user needs permission to ${operation === "update" ? "edit" : "create"} posts.`; + } + + if (status === 404) { + if (operation === "update") { + return `WordPress post ${context.postId ?? "unknown"} was not found. Provide a valid remoteId to update an existing post.`; + } + + return `WordPress REST API was not found at ${endpoint}. Check WORDPRESS_API_URL (expected format: https://example.com/wp-json).`; + } + + if (message.length > 0) { + return `WordPress API error (${status}): ${message}`; + } + + return `WordPress API request failed (${status}).`; +} diff --git a/packages/publishers/src/wordpress/wordpressPublisher.test.ts b/packages/publishers/src/wordpress/wordpressPublisher.test.ts new file mode 100644 index 0000000..cc0acb5 --- /dev/null +++ b/packages/publishers/src/wordpress/wordpressPublisher.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { CmsArticlePayload } from "../types.js"; +import { createWordPressPublisher } from "./wordpressPublisher.js"; + +const article: CmsArticlePayload = { + title: "Hello WordPress", + slug: "hello-wordpress", + description: "Short excerpt", + body: "## Hello\n\nMarkdown body.", + pubDate: "2026-06-08T10:00:00.000Z", + category: "Guides", + tags: ["cms", "test"], + draft: true, +}; + +const config = { + apiUrl: "https://example.com/wp-json", + username: "editor", + appPassword: "abcd EFGH ijkl MNOP qrst UVWX", + defaultStatus: "draft", + categoryIds: { Guides: 3 }, + tagIds: { cms: 10, test: 11 }, +}; + +function requestMethod(init?: RequestInit): string { + return init?.method?.toUpperCase() ?? "GET"; +} + +describe("WordPress publisher", () => { + it("creates a draft post with application password auth", async () => { + const publisher = createWordPressPublisher({ + ...config, + fetch: async (url, init) => { + assert.equal(url, "https://example.com/wp-json/wp/v2/posts"); + assert.equal(requestMethod(init), "POST"); + + const headers = new Headers(init?.headers); + const auth = headers.get("Authorization") ?? ""; + assert.match(auth, /^Basic /); + const decoded = Buffer.from(auth.replace("Basic ", ""), "base64").toString("utf8"); + assert.equal(decoded, `${config.username}:${config.appPassword}`); + + const body = JSON.parse(init?.body?.toString() ?? "{}"); + assert.equal(body.title, article.title); + assert.equal(body.slug, article.slug); + assert.equal(body.status, "draft"); + assert.equal(body.excerpt, article.description); + assert.deepEqual(body.categories, [3]); + assert.deepEqual(body.tags, [10, 11]); + + return new Response( + JSON.stringify({ id: 42, slug: "hello-wordpress", status: "draft" }), + { status: 201 }, + ); + }, + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + assert.equal(result.remoteId, "42"); + assert.equal(result.path, "hello-wordpress"); + } + }); + + it("updates an existing post when remoteId is provided", async () => { + const publisher = createWordPressPublisher({ + ...config, + fetch: async (url, init) => { + assert.equal(url, "https://example.com/wp-json/wp/v2/posts/42"); + assert.equal(requestMethod(init), "POST"); + return new Response(JSON.stringify({ id: 42, slug: "hello-wordpress" }), { + status: 200, + }); + }, + }); + + const result = await publisher.publishPost({ article, remoteId: "42" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, false); + assert.equal(result.remoteId, "42"); + } + }); + + it("uses publish status when article is not a draft", async () => { + const publisher = createWordPressPublisher({ + ...config, + defaultStatus: "publish", + fetch: async (_url, init) => { + const body = JSON.parse(init?.body?.toString() ?? "{}"); + assert.equal(body.status, "publish"); + return new Response(JSON.stringify({ id: 7, slug: "live-post" }), { status: 201 }); + }, + }); + + const result = await publisher.publishPost({ + article: { ...article, draft: false, slug: "live-post" }, + }); + + assert.equal(result.ok, true); + }); + + it("returns actionable error on auth failure", async () => { + const publisher = createWordPressPublisher({ + ...config, + fetch: async () => + new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 }), + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /WORDPRESS_USERNAME/); + } + }); + + it("returns actionable error on invalid endpoint", async () => { + const publisher = createWordPressPublisher({ + ...config, + fetch: async () => + new Response(JSON.stringify({ message: "Not found" }), { status: 404 }), + }); + + const result = await publisher.publishPost({ article }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /WORDPRESS_API_URL/); + } + }); +}); diff --git a/packages/publishers/src/wordpress/wordpressPublisher.ts b/packages/publishers/src/wordpress/wordpressPublisher.ts new file mode 100644 index 0000000..e222c6d --- /dev/null +++ b/packages/publishers/src/wordpress/wordpressPublisher.ts @@ -0,0 +1,226 @@ +import { resolveCmsStatus } from "../cmsPayload.js"; +import { + type HttpFetcher, + parseJsonBody, + readResponseBody, + resolveFetcher, +} from "../http.js"; +import type { CmsArticlePayload } from "../types.js"; +import { formatWordPressApiError, type WordPressOperation } from "./wordpressErrors.js"; + +export type WordPressPublisherConfig = { + apiUrl: string; + username: string; + appPassword: string; + defaultStatus: string; + defaultAuthor?: number; + categoryIds?: Record; + tagIds?: Record; + fetch?: HttpFetcher; +}; + +type WordPressPostBody = { + id?: number; + slug?: string; + link?: string; + status?: string; +}; + +export type PublishPostInput = { + article: CmsArticlePayload; + remoteId?: string; +}; + +export type PublishPostSuccess = { + ok: true; + created: boolean; + path: string; + sha: string; + commitSha: string; + remoteId: string; +}; + +export type PublishPostError = { + ok: false; + error: string; + status?: number; +}; + +export type PublishPostResult = PublishPostSuccess | PublishPostError; + +export type WordPressPublisher = { + publishPost: (input: PublishPostInput) => Promise; +}; + +function trimApiUrl(apiUrl: string): string { + return apiUrl.replace(/\/+$/, ""); +} + +function basicAuthHeader(username: string, appPassword: string): string { + const credentials = Buffer.from(`${username}:${appPassword}`).toString("base64"); + return `Basic ${credentials}`; +} + +function resolveTaxonomyIds( + names: string[], + map: Record | undefined, +): number[] { + if (!map) { + return []; + } + + const ids: number[] = []; + for (const name of names) { + const id = map[name]; + if (typeof id === "number" && id > 0) { + ids.push(id); + } + } + + return ids; +} + +function wordpressErrorMessage(body: unknown, fallback: string): string { + if (typeof body === "object" && body !== null) { + const record = body as { message?: string; code?: string }; + if (record.message && record.message.trim().length > 0) { + return record.message.trim(); + } + } + + return fallback; +} + +export function createWordPressPublisher(config: WordPressPublisherConfig): WordPressPublisher { + const fetchImpl = resolveFetcher(config.fetch); + const apiBase = trimApiUrl(config.apiUrl); + + async function wpRequest( + method: string, + path: string, + operation: WordPressOperation, + body?: Record, + postId?: string, + ): Promise< + | { ok: true; bodyText: string } + | PublishPostError + > { + const response = await fetchImpl(`${apiBase}${path}`, { + method, + headers: { + Authorization: basicAuthHeader(config.username, config.appPassword), + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + const bodyText = await readResponseBody(response); + + if (!response.ok) { + const parsed = parseJsonBody(bodyText); + return { + ok: false, + error: formatWordPressApiError( + response.status, + wordpressErrorMessage(parsed, bodyText), + operation, + { + apiUrl: apiBase, + ...(postId !== undefined ? { postId } : {}), + }, + ), + status: response.status, + }; + } + + return { ok: true, bodyText }; + } + + function buildPayload(article: CmsArticlePayload): Record { + const status = resolveCmsStatus(article.draft, config.defaultStatus); + const categories = resolveTaxonomyIds([article.category], config.categoryIds); + const tags = resolveTaxonomyIds(article.tags, config.tagIds); + + const payload: Record = { + title: article.title, + content: article.body, + slug: article.slug, + status, + excerpt: article.description, + date: article.pubDate, + }; + + if (categories.length > 0) { + payload.categories = categories; + } + + if (tags.length > 0) { + payload.tags = tags; + } + + if (config.defaultAuthor !== undefined) { + payload.author = config.defaultAuthor; + } + + return payload; + } + + return { + async publishPost(input: PublishPostInput): Promise { + const payload = buildPayload(input.article); + const remoteId = input.remoteId?.trim(); + + if (remoteId) { + const result = await wpRequest( + "POST", + `/wp/v2/posts/${encodeURIComponent(remoteId)}`, + "update", + payload, + remoteId, + ); + + if (!result.ok) { + return result; + } + + const body = parseJsonBody(result.bodyText); + const id = String(body?.id ?? remoteId); + + return { + ok: true, + created: false, + path: body?.slug ?? input.article.slug, + sha: id, + commitSha: id, + remoteId: id, + }; + } + + const result = await wpRequest("POST", "/wp/v2/posts", "create", payload); + + if (!result.ok) { + return result; + } + + const body = parseJsonBody(result.bodyText); + const id = String(body?.id ?? ""); + + if (id.length === 0) { + return { + ok: false, + error: "WordPress created a post but did not return an id.", + }; + } + + return { + ok: true, + created: true, + path: body?.slug ?? input.article.slug, + sha: id, + commitSha: id, + remoteId: id, + }; + }, + }; +} diff --git a/packages/publishers/src/wordpressPublisherAdapter.ts b/packages/publishers/src/wordpressPublisherAdapter.ts new file mode 100644 index 0000000..0f7548d --- /dev/null +++ b/packages/publishers/src/wordpressPublisherAdapter.ts @@ -0,0 +1,157 @@ +import { requireCmsArticle } from "./cmsPayload.js"; +import type { + Publisher, + PublisherFactory, + PublisherRuntimeConfig, + PublishArticleInput, + PublishArticleResult, +} from "./types.js"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; +import { createWordPressPublisher } from "./wordpress/wordpressPublisher.js"; + +const WORDPRESS_CAPABILITIES = { + publishArticle: true, + uploadMedia: false, + listPosts: false, + readPost: false, +} as const; + +function readTaxonomyMap( + options: Record, + key: string, +): Record | undefined { + const raw = options[key]; + if (!raw || typeof raw !== "object") { + return undefined; + } + + const map: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (typeof value === "number" && value > 0) { + map[name] = value; + } + } + + return Object.keys(map).length > 0 ? map : undefined; +} + +function resolveWordPressConfig(config: PublisherRuntimeConfig) { + const apiUrl = config.wordpressApiUrl?.trim(); + const username = config.wordpressUsername?.trim(); + const appPassword = config.wordpressAppPassword?.trim(); + + if (!apiUrl) { + throw new Error("WordPress publisher requires WORDPRESS_API_URL in .env."); + } + + if (!username) { + throw new Error("WordPress publisher requires WORDPRESS_USERNAME in .env."); + } + + if (!appPassword) { + throw new Error("WordPress publisher requires WORDPRESS_APP_PASSWORD in .env."); + } + + const options = config.publisherOptions ?? {}; + const categoryIds = readTaxonomyMap(options, "wordpressCategoryIds"); + const tagIds = readTaxonomyMap(options, "wordpressTagIds"); + + return createWordPressPublisher({ + apiUrl, + username, + appPassword, + defaultStatus: config.wordpressDefaultStatus?.trim() || "draft", + ...(config.wordpressDefaultAuthor !== undefined + ? { defaultAuthor: config.wordpressDefaultAuthor } + : {}), + ...(categoryIds !== undefined ? { categoryIds } : {}), + ...(tagIds !== undefined ? { tagIds } : {}), + }); +} + +function createWordPressPublisherInstance(config: PublisherRuntimeConfig): Publisher { + const wordpress = resolveWordPressConfig(config); + + return { + id: "wordpress", + kind: "remote-cms", + capabilities: WORDPRESS_CAPABILITIES, + async publishArticle(input: PublishArticleInput): Promise { + const article = requireCmsArticle(input, "wordpress"); + if ("ok" in article) { + return article; + } + + const result = await wordpress.publishPost({ + article, + ...(input.remoteId ? { remoteId: input.remoteId } : {}), + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + remoteId: result.remoteId, + }; + }, + async uploadMedia() { + return unsupportedUploadMedia("wordpress"); + }, + async listPosts() { + return unsupportedListPosts("wordpress"); + }, + async readPost() { + return unsupportedReadPost("wordpress"); + }, + }; +} + +function wrapPublisherWithCapabilities( + factory: PublisherFactory, + publisher: Publisher, +): Publisher { + return { + id: factory.id, + kind: factory.kind, + capabilities: factory.capabilities, + publishArticle: factory.capabilities.publishArticle + ? (input) => publisher.publishArticle(input) + : () => Promise.resolve(unsupportedPublishArticle(factory.id)), + uploadMedia: factory.capabilities.uploadMedia + ? (input) => publisher.uploadMedia(input) + : () => Promise.resolve(unsupportedUploadMedia(factory.id)), + listPosts: factory.capabilities.listPosts + ? (input) => publisher.listPosts(input) + : () => Promise.resolve(unsupportedListPosts(factory.id)), + readPost: factory.capabilities.readPost + ? (input) => publisher.readPost(input) + : () => Promise.resolve(unsupportedReadPost(factory.id)), + }; +} + +export const wordpressPublisherFactory: PublisherFactory = { + id: "wordpress", + kind: "remote-cms", + capabilities: WORDPRESS_CAPABILITIES, + createPublisher(config: PublisherRuntimeConfig): Publisher { + return wrapPublisherWithCapabilities( + wordpressPublisherFactory, + createWordPressPublisherInstance(config), + ); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2598c6b..9653b36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@sourcedraft/github-publisher': specifier: workspace:* version: link:../../packages/github-publisher + '@sourcedraft/media-providers': + specifier: workspace:* + version: link:../../packages/media-providers '@sourcedraft/publishers': specifier: workspace:* version: link:../../packages/publishers @@ -292,6 +295,18 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/media-providers: + devDependencies: + '@types/node': + specifier: ^22.15.30 + version: 22.19.19 + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/publishers: dependencies: '@sourcedraft/github-publisher':