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
22 changes: 20 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,34 @@
# Project paths/categories belong in sourcedraft.config.json instead

SOURCEDRAFT_ADMIN_PASSWORD=
# Set to true to run Studio in demo mode (no GitHub commits)
# 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
CMS_PUBLISHER=github

# GitHub (when CMS_PUBLISHER=github)
GITHUB_TOKEN=
GITHUB_OWNER=
GITHUB_REPO=
GITHUB_BRANCH=main

# GitLab (when CMS_PUBLISHER=gitlab)
GITLAB_TOKEN=
GITLAB_PROJECT_ID=
GITLAB_PROJECT_PATH=
GITLAB_BRANCH=main
GITLAB_BASE_URL=https://gitlab.com

# Bitbucket Cloud (when CMS_PUBLISHER=bitbucket)
BITBUCKET_TOKEN=
BITBUCKET_WORKSPACE=
BITBUCKET_REPO_SLUG=
BITBUCKET_BRANCH=main
BITBUCKET_USERNAME=

# Optional overrides for sourcedraft.config.json
CMS_CONTENT_DIR=
CMS_MEDIA_DIR=
CMS_PUBLIC_MEDIA_PATH=
CMS_ADAPTER=
CMS_PUBLISHER=
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ 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 (create or update a file on a branch)
- Upload images to GitHub (`mediaDir`) from Studio
- Publish to GitHub, GitLab, or Bitbucket (create or update a file on a branch)
- Upload images to the configured remote (`mediaDir`) from Studio
- 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 @@ -52,19 +52,19 @@ Your static site — Astro today, others later — still builds and deploys exac

See [docs/project-status.md](docs/project-status.md).

## How GitHub publishing works
## How publishing works

1. You finish a valid article in Studio and click **Publish to GitHub**.
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 **GitHub publisher** checks whether the file exists in your repo, then creates or updates it via the GitHub API.
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`.

The GitHub token never reaches the browser. It is read from `.env` on the server when you publish or upload media.
API tokens never reach the browser. They are read from `.env` on the server when you publish or upload media.

Details: [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md)
Details: [docs/git-publishers.md](docs/git-publishers.md) · [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md)

v0.1 uses the GitHub Contents API — suitable for typical blogs; very large content folders are a known MVP limitation.
**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.

## Quickstart

Expand Down Expand Up @@ -147,6 +147,7 @@ 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
- [Git publishing (GitHub, GitLab, Bitbucket)](docs/git-publishers.md)
- [GitHub publishing](docs/github-publishing.md)
- [Media uploads](docs/media.md)
- [Configuration](docs/configuration.md)
Expand Down
8 changes: 6 additions & 2 deletions apps/studio/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import { isDemoModeAvailable, isDemoModeForced, isGitHubConfigured } from "./demoMode.js";
import {
isDemoModeAvailable,
isDemoModeForced,
isPublisherConfigured,
} from "./demoMode.js";

const SESSION_COOKIE = "sourcedraft_session";
/** 24 hours — in-memory MVP sessions, not durable account auth. */
Expand Down Expand Up @@ -168,7 +172,7 @@ export function isDemoSession(token: string | null): boolean {
}

export function isRequestDemoSession(req: Request): boolean {
if (isDemoModeForced() || !isGitHubConfigured()) {
if (isDemoModeForced() || !isPublisherConfigured()) {
return true;
}

Expand Down
155 changes: 136 additions & 19 deletions apps/studio/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export type PublishEnvConfig = {
adapterOptions?: Record<string, unknown>;
publisherOptions?: Record<string, unknown>;
categories: string[];
gitlabProjectRef?: string;
gitlabBaseUrl?: string;
bitbucketUsername?: string;
};

export type PublishEnvResult =
Expand Down Expand Up @@ -75,22 +78,87 @@ function resolvePublicMediaPath(
return derivePublicMediaPath(mediaDir);
}

export function loadPublishEnv(): PublishEnvResult {
const project = loadProjectConfig();
type PublisherCredentialsResult =
| {
ok: true;
token: string;
owner: string;
repo: string;
branch: string;
gitlabProjectRef?: string;
gitlabBaseUrl?: string;
bitbucketUsername?: string;
}
| { ok: false; error: string };

function resolvePublisherCredentials(
publisher: SupportedPublisher,
defaultBranch: string,
): PublisherCredentialsResult {
if (publisher === "gitlab") {
const token = process.env.GITLAB_TOKEN?.trim();
const projectId = process.env.GITLAB_PROJECT_ID?.trim();
const projectPath = process.env.GITLAB_PROJECT_PATH?.trim();
const gitlabProjectRef = projectId || projectPath;
const branch = process.env.GITLAB_BRANCH?.trim() || defaultBranch;
const gitlabBaseUrl =
process.env.GITLAB_BASE_URL?.trim() || "https://gitlab.com";

if (!token) {
return { ok: false, error: "GITLAB_TOKEN is not configured." };
}

if (!gitlabProjectRef) {
return {
ok: false,
error: "GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH is not configured.",
};
}

return {
ok: true,
token,
owner: projectPath || projectId || "",
repo: "",
branch,
gitlabProjectRef,
gitlabBaseUrl,
};
}

if (publisher === "bitbucket") {
const token = process.env.BITBUCKET_TOKEN?.trim();
const owner = process.env.BITBUCKET_WORKSPACE?.trim();
const repo = process.env.BITBUCKET_REPO_SLUG?.trim();
const branch = process.env.BITBUCKET_BRANCH?.trim() || defaultBranch;
const bitbucketUsername = process.env.BITBUCKET_USERNAME?.trim();

if (!token) {
return { ok: false, error: "BITBUCKET_TOKEN is not configured." };
}

if (!owner) {
return { ok: false, error: "BITBUCKET_WORKSPACE is not configured." };
}

if (!repo) {
return { ok: false, error: "BITBUCKET_REPO_SLUG is not configured." };
}

return {
ok: true,
token,
owner,
repo,
branch,
...(bitbucketUsername ? { bitbucketUsername } : {}),
};
}

const token = process.env.GITHUB_TOKEN?.trim();
const owner = process.env.GITHUB_OWNER?.trim();
const repo = process.env.GITHUB_REPO?.trim();
const branch =
process.env.GITHUB_BRANCH?.trim() || project.defaultBranch;
const contentDir =
process.env.CMS_CONTENT_DIR?.trim() || project.contentDir;
const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir;
const publicMediaPath = resolvePublicMediaPath(mediaDir, project);
const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter;
const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher;
const adapter = resolveAdapter(rawAdapter);
const publisher = resolvePublisher(rawPublisher);
const branch = process.env.GITHUB_BRANCH?.trim() || defaultBranch;

if (!token) {
return { ok: false, error: "GITHUB_TOKEN is not configured." };
Expand All @@ -104,6 +172,21 @@ export function loadPublishEnv(): PublishEnvResult {
return { ok: false, error: "GITHUB_REPO is not configured." };
}

return { ok: true, token, owner, repo, branch };
}

export function loadPublishEnv(): PublishEnvResult {
const project = loadProjectConfig();

const contentDir =
process.env.CMS_CONTENT_DIR?.trim() || project.contentDir;
const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir;
const publicMediaPath = resolvePublicMediaPath(mediaDir, project);
const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter;
const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher;
const adapter = resolveAdapter(rawAdapter);
const publisher = resolvePublisher(rawPublisher);

if (adapter === null) {
return {
ok: false,
Expand All @@ -118,13 +201,18 @@ export function loadPublishEnv(): PublishEnvResult {
};
}

const credentials = resolvePublisherCredentials(publisher, project.defaultBranch);
if (!credentials.ok) {
return credentials;
}

return {
ok: true,
config: {
token,
owner,
repo,
branch,
token: credentials.token,
owner: credentials.owner,
repo: credentials.repo,
branch: credentials.branch,
contentDir,
mediaDir,
publicMediaPath,
Expand All @@ -136,6 +224,15 @@ export function loadPublishEnv(): PublishEnvResult {
...(project.publisherOptions !== undefined
? { publisherOptions: project.publisherOptions }
: {}),
...(credentials.gitlabProjectRef !== undefined
? { gitlabProjectRef: credentials.gitlabProjectRef }
: {}),
...(credentials.gitlabBaseUrl !== undefined
? { gitlabBaseUrl: credentials.gitlabBaseUrl }
: {}),
...(credentials.bitbucketUsername !== undefined
? { bitbucketUsername: credentials.bitbucketUsername }
: {}),
categories: project.categories,
},
};
Expand All @@ -151,10 +248,30 @@ export function loadPublicConfig(): PublicStudioConfig {
const publisher = resolvePublisher(rawPublisher) ?? "github";
const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir;

let owner = "";
let repo = "";
let branch = project.defaultBranch;

if (publisher === "gitlab") {
owner =
process.env.GITLAB_PROJECT_PATH?.trim() ||
process.env.GITLAB_PROJECT_ID?.trim() ||
"";
branch = process.env.GITLAB_BRANCH?.trim() || project.defaultBranch;
} else if (publisher === "bitbucket") {
owner = process.env.BITBUCKET_WORKSPACE?.trim() || "";
repo = process.env.BITBUCKET_REPO_SLUG?.trim() || "";
branch = process.env.BITBUCKET_BRANCH?.trim() || project.defaultBranch;
} else {
owner = process.env.GITHUB_OWNER?.trim() || "";
repo = process.env.GITHUB_REPO?.trim() || "";
branch = process.env.GITHUB_BRANCH?.trim() || project.defaultBranch;
}

return {
owner: process.env.GITHUB_OWNER?.trim() || "",
repo: process.env.GITHUB_REPO?.trim() || "",
branch: process.env.GITHUB_BRANCH?.trim() || project.defaultBranch,
owner,
repo,
branch,
contentDir: process.env.CMS_CONTENT_DIR?.trim() || project.contentDir,
mediaDir,
publicMediaPath: resolvePublicMediaPath(mediaDir, project),
Expand Down
56 changes: 55 additions & 1 deletion apps/studio/server/demoMode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { loadSourceDraftConfig } from "@sourcedraft/config";
import { isPublisherId } from "@sourcedraft/publishers";

export function isDemoModeForced(): boolean {
return process.env.SOURCEDRAFT_DEMO_MODE?.trim().toLowerCase() === "true";
}

export function resolveActivePublisher(): string {
const project = loadSourceDraftConfig();
const raw = process.env.CMS_PUBLISHER?.trim() || project.publisher;
return isPublisherId(raw) ? raw : "github";
}

export function isGitHubTokenConfigured(): boolean {
return (process.env.GITHUB_TOKEN?.trim().length ?? 0) > 0;
}
Expand All @@ -22,10 +31,55 @@ export function isGitHubConfigured(): boolean {
);
}

export function isGitLabTokenConfigured(): boolean {
return (process.env.GITLAB_TOKEN?.trim().length ?? 0) > 0;
}

export function isGitLabProjectConfigured(): boolean {
const projectId = process.env.GITLAB_PROJECT_ID?.trim();
const projectPath = process.env.GITLAB_PROJECT_PATH?.trim();
return (projectId?.length ?? 0) > 0 || (projectPath?.length ?? 0) > 0;
}

export function isGitLabConfigured(): boolean {
return isGitLabTokenConfigured() && isGitLabProjectConfigured();
}

export function isBitbucketTokenConfigured(): boolean {
return (process.env.BITBUCKET_TOKEN?.trim().length ?? 0) > 0;
}

export function isBitbucketWorkspaceConfigured(): boolean {
return (process.env.BITBUCKET_WORKSPACE?.trim().length ?? 0) > 0;
}

export function isBitbucketRepoConfigured(): boolean {
return (process.env.BITBUCKET_REPO_SLUG?.trim().length ?? 0) > 0;
}

export function isBitbucketConfigured(): boolean {
return (
isBitbucketTokenConfigured() &&
isBitbucketWorkspaceConfigured() &&
isBitbucketRepoConfigured()
);
}

export function isPublisherConfigured(): boolean {
switch (resolveActivePublisher()) {
case "gitlab":
return isGitLabConfigured();
case "bitbucket":
return isBitbucketConfigured();
default:
return isGitHubConfigured();
}
}

export function isDemoModeAvailable(): boolean {
if (isDemoModeForced()) {
return true;
}

return !isGitHubConfigured();
return !isPublisherConfigured();
}
7 changes: 7 additions & 0 deletions apps/studio/server/publisherRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export function toPublisherRuntimeConfig(
...(env.publisherOptions !== undefined
? { publisherOptions: env.publisherOptions }
: {}),
...(env.gitlabProjectRef !== undefined
? { gitlabProjectRef: env.gitlabProjectRef }
: {}),
...(env.gitlabBaseUrl !== undefined ? { gitlabBaseUrl: env.gitlabBaseUrl } : {}),
...(env.bitbucketUsername !== undefined
? { bitbucketUsername: env.bitbucketUsername }
: {}),
};
}

Expand Down
Loading