Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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=
Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions apps/studio/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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() || "";
Comment on lines +386 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enable remote CMS publish readiness

When CMS_PUBLISHER is wordpress or ghost, this public config leaves repo empty, but the Studio still computes githubReady as owner && repo and PublishGate disables publishing whenever that is false. A fully configured WordPress/Ghost user therefore sees the GitHub setup message and cannot click Publish at all; expose/use a publisher-ready flag or make the frontend treat remote CMS targets as ready without a repo.

Useful? React with 👍 / 👎.

branch = project.defaultBranch;
} else {
owner = process.env.GITHUB_OWNER?.trim() || "";
repo = process.env.GITHUB_REPO?.trim() || "";
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/server/demoMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ export async function uploadDemoMedia(
repoPath,
publicPath,
kind,
url: publicPath,
provider: "github-media",
sha: commitSha,
commitSha,
},
Expand Down
36 changes: 36 additions & 0 deletions apps/studio/server/demoMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading