Skip to content
Merged
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
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ Your static site still builds and deploys exactly as before. SourceDraft creates

## What it does today

- Edit articles in Studio with a **Tiptap rich editor** (toolbar: headings, bold/italic/underline/strike, lists, links, images, attachments, undo/redo), **slash commands**, and **source mode** for raw Markdown/MDX
- List and edit existing posts from your GitHub `contentDir`
**Writer-facing (no Git jargon required):** edit articles in Studio, upload images, preview the generated file, run content-quality checks, and send to your blog when setup is complete. Demo mode works without GitHub or API tokens.

**Developer-facing details:**

- Edit articles in Studio with a **Tiptap rich editor** (grouped toolbar: headings, bold/italic/underline/strike, inline code, lists, links, images, file links, undo/redo), **slash commands**, and **source mode** for raw Markdown/MDX
- List and edit existing posts from your configured article folder (`contentDir`)
- Validate fields against a universal article schema
- **Content QA** — non-blocking warnings for SEO, alt text, headings, links, and body length
- **Publish checklist** — validation status, output path, publish mode, and warnings before publish
Expand All @@ -45,11 +49,11 @@ Your static site still builds and deploys exactly as before. SourceDraft creates
- Upload images to git `mediaDir`, Cloudinary, or (experimental) S3-compatible storage
- Optional deploy hooks after publish (Vercel, Netlify, Cloudflare Pages, generic)
- **Setup detection** — scan local project files and suggest adapter, content, and media paths
- **Content audit** — read-only scan of existing posts (frontmatter, duplicate slugs, complex MDX)
- **Content audit** — read-only scan of existing posts (metadata blocks, duplicate slugs, complex MDX)
- 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
- **Setup health** — Settings panel checks for missing config (no secrets exposed)
- **Publishing readiness** — Settings panel checks for missing config (no secrets exposed)

## What it does not do yet

Expand Down Expand Up @@ -81,6 +85,20 @@ Details: [docs/publishers.md](docs/publishers.md) · [docs/git-publishers.md](do

Full matrices: [adapters](docs/adapters.md) · [publishers](docs/publishers.md) · [media](docs/media.md) · [deploy-hooks](docs/deploy-hooks.md) · [quickstart recipes](docs/quickstart-recipes.md)

## For writers, bloggers, and builders

**Writers:** If someone already installed SourceDraft, you only need the **Studio link** and **password**. Create articles with title, description, category, tags, body, and images; preview the output; and send posts to your blog without touching GitHub manually. If publishing is disabled, that is a setup issue — you can still write and preview, or use demo mode.

**First visit:** Studio shows five paths on the sign-in screen — **Try demo mode** (safest start), sign in to an already-configured Studio, connect an existing blog, advanced developer setup, or learn about the agent-ready draft/review/publish workflow. Demo mode needs no GitHub or API tokens.

**Developers:** AGPL open-source. Content stays in Git-owned Markdown/MDX. Secrets stay server-side. Adapter/publisher architecture with a structured article schema. See [docs/compatibility-roadmap.md](docs/compatibility-roadmap.md).

**AI/automation builders:** SourceDraft uses structured article fields with validation, preview, and a human-in-the-loop publish checklist — a natural base for future agent-assisted workflows. **Agent API, BYOK AI, MCP, and built-in AI writing are not shipped yet.** See [docs/roadmap.md](docs/roadmap.md#future-agent-ready-publishing-workflows).

If you are setting it up yourself, start with **demo mode** or **`pnpm setup`**. Initial setup usually needs someone comfortable with API tokens and environment files.

Plain-language overview: [docs/non-technical-overview.md](docs/non-technical-overview.md).

## Quickstart

Requirements: Node.js 22+, pnpm 11+
Expand Down Expand Up @@ -116,7 +134,7 @@ pnpm dev

Sign in, click **New post**, preview the output, publish. The file lands at `contentDir/<slug>.mdx` or `.md` depending on your adapter (default: `src/content/blog/`).

**Try without GitHub:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty and click **Explore demo mode** on the sign-in screen. Demo content reloads from repository fixtures on each API start. See [docs/demo-mode.md](docs/demo-mode.md).
**Try without GitHub:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty and click **Try demo mode** on the sign-in screen. Demo content reloads from repository fixtures on each API start. See [docs/demo-mode.md](docs/demo-mode.md).

Validate config: `pnpm validate:config` · Wizard details: [docs/setup-wizard.md](docs/setup-wizard.md) · Full walkthrough: [docs/getting-started.md](docs/getting-started.md)

Expand Down
6 changes: 4 additions & 2 deletions apps/studio/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ export async function enterDemoMode(page: Page): Promise<void> {
attachPageErrorLogging(page);
await waitForStudioRoot(page);
await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible();
await page.getByRole("button", { name: "Explore demo mode" }).click();
await expect(page.getByText("Demo mode — no GitHub commits are made")).toBeVisible();
await page.getByTestId("try-demo-mode").click();
await expect(
page.getByText("Demo mode — explore without connecting a real blog"),
).toBeVisible();
}

export function ensureScreenshotDir(): void {
Expand Down
8 changes: 4 additions & 4 deletions apps/studio/e2e/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ test.describe("release screenshots", () => {
path: screenshotPath("preview.png"),
});

await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Screenshot publish example");
await postDescriptionInput(page).fill(
"Summary used for automated publish-success screenshot.",
Expand All @@ -71,14 +71,14 @@ test.describe("release screenshots", () => {
page,
"# Screenshot publish example\n\nBody for release screenshot capture.",
);
await page.getByRole("button", { name: "Simulate publish" }).click();
await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Simulate send to blog" }).click();
await expect(page.getByText("Send simulated")).toBeVisible({ timeout: 10_000 });
await page.locator(".publish-bar").screenshot({
path: screenshotPath("publish-success.png"),
});

await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Publishing readiness" })).toBeVisible();
await page.locator(".setup-health").screenshot({
path: screenshotPath("setup-health.png"),
});
Expand Down
137 changes: 110 additions & 27 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,31 @@ test.describe("Studio smoke", () => {
attachPageErrorLogging(page);
await waitForStudioRoot(page);
await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible();
await expect(page.getByRole("heading", { name: "How would you like to start?" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Try demo mode" })).toBeVisible();
await expect(page.getByTestId("try-demo-mode")).toBeVisible();
await expect(page.getByRole("heading", { name: "How do you want to start?" })).toBeVisible();
await expect(page.getByText("Explore SourceDraft with sample posts. Nothing is published.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Write in an already-configured Studio" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Connect an existing blog" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Advanced developer setup" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Agent-ready workflow" })).toBeVisible();
await expect(page.getByRole("button", { name: "Explore demo mode" })).toBeVisible();
await expect(
page.getByText(
"SourceDraft can inspect your project and suggest where articles and images should go.",
),
).toBeVisible();
});

test("overview/post list renders in demo mode", async ({ page }) => {
await enterDemoMode(page);
await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible();
await expect(page.getByText("Quick start")).toBeVisible();
await page.getByRole("button", { name: "Dismiss" }).click();
await expect(page.getByRole("heading", { name: "Articles" })).toBeVisible();
await expect(page.getByText("AI-assisted publishing with SourceDraft")).toBeVisible();
});

test("new post form and editor accept text", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Smoke test post");
await postDescriptionInput(page).fill(
"A short summary for the smoke test post.",
Expand All @@ -42,27 +49,70 @@ test.describe("Studio smoke", () => {

test("toolbar inserts Markdown", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await fillPostBody(page, "Selected text");
await page.keyboard.press("Control+A");
await page.getByRole("button", { name: "Bold" }).click();
await page.getByRole("button", { name: "Bold", exact: true }).click();
await expect(postBodyEditor(page)).toContainText("Selected text");
await expect(page.getByRole("button", { name: "Bold", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
});

test("toolbar renders grouped formatting controls", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
const toolbar = page.getByRole("toolbar", { name: "Editor formatting" });
for (const name of [
"Paragraph",
"Heading 1",
"Bold",
"Italic",
"Underline",
"Strikethrough",
"Inline code",
"Clear formatting",
"Bullet list",
"Numbered list",
"Blockquote",
"Code block",
"Insert or edit link",
"Insert image",
"Insert file link",
"Undo",
"Redo",
]) {
await expect(toolbar.getByRole("button", { name, exact: true })).toBeVisible();
}
await expect(toolbar.getByRole("button", { name: "Undo" })).toBeDisabled();
await expect(toolbar.getByRole("button", { name: "Redo" })).toBeDisabled();
});

test("mobile sidebar can expand and collapse", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 900 });
await enterDemoMode(page);
const toggle = page.getByRole("button", { name: "Show all posts" });
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await toggle.click();
const collapse = page.getByRole("button", { name: "Collapse posts" });
await expect(collapse).toHaveAttribute("aria-expanded", "true");
});

test("editor toolbar exposes core formatting controls", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await expect(page.getByRole("toolbar", { name: "Editor formatting" })).toBeVisible();
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
await expect(page.getByRole("button", { name: "Italic" })).toBeVisible();
await expect(page.getByRole("button", { name: "Insert image" })).toBeVisible();
await expect(page.getByRole("button", { name: "Insert attachment" })).toBeVisible();
await expect(page.getByRole("button", { name: "Table" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Insert file link" })).toBeVisible();
});

test("autosave status appears after edits", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Autosave smoke test");
await expect(page.getByText("Unsaved changes", { exact: false })).toBeVisible({
timeout: 5000,
Expand All @@ -77,40 +127,47 @@ test.describe("Studio smoke", () => {

test("settings setup health renders", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Status & configuration" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Welcome to SourceDraft" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Publishing readiness" })).toBeVisible();
await page.locator("summary.settings-view__advanced-summary").click();
await expect(page.getByRole("heading", { name: "Diagnostics" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Setup detection" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Content audit" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible();
await expect(page.getByText("Admin password")).toBeVisible();
await expect(page.getByText("GitHub token (server-side)")).toBeVisible();
await expect(page.getByText("Studio password", { exact: true })).toBeVisible();
await expect(page.getByText("GitHub connection", { exact: true })).toBeVisible();
});

test("publish checklist renders in demo mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Checklist smoke test");
await postDescriptionInput(page).fill("Summary for checklist smoke test.");
await fillPostBody(page, "# Checklist\n\nBody content.");
await expect(page.getByRole("heading", { name: "Publish checklist" })).toBeVisible();
await expect(page.getByText("Validation")).toBeVisible();
await expect(page.getByText("Output path")).toBeVisible();
await expect(page.getByRole("heading", { name: "Before you send" })).toBeVisible();
const checklist = page.getByLabel("Before you send");
await expect(checklist.getByText("Validation", { exact: true })).toBeVisible();
await expect(checklist.getByText("Article file", { exact: true })).toBeVisible();
});

test("publish success can be simulated in demo mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Demo publish smoke test");
await postDescriptionInput(page).fill(
"Summary for demo publish smoke test.",
);
await fillPostBody(page, "# Demo publish\n\nBody content.");
await page.getByRole("button", { name: "Simulate publish" }).click();
await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Simulate send to blog" }).click();
await expect(page.getByText("Send simulated")).toBeVisible({ timeout: 10_000 });
});

test("publish mode selector renders in demo mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await postTitleInput(page).fill("Publish mode smoke test");
await postDescriptionInput(page).fill(
"Summary for publish mode smoke test.",
Expand All @@ -121,19 +178,45 @@ test.describe("Studio smoke", () => {
await expect(modeSelect).toBeVisible();
await modeSelect.selectOption("pull-request");
await expect(page.getByText("PR branch")).toBeVisible();
await page.getByRole("button", { name: "Simulate PR publish" }).click();
await expect(page.getByText("Pull request simulated")).toBeVisible({
await page.getByRole("button", { name: "Simulate review request" }).click();
await expect(page.getByText("Review request simulated")).toBeVisible({
timeout: 10_000,
});
});

test("source mode toggle preserves raw body", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await page.getByRole("button", { name: "New article" }).click();
await fillPostBody(page, "<CustomBlock />\n\n## Heading");
await page.getByRole("button", { name: "Source mode" }).click();
await page.getByRole("button", { name: "Source", exact: true }).click();
const source = page.getByTestId("post-body-source");
await expect(source).toBeVisible();
await expect(source).toHaveValue(/<CustomBlock \/>/u);
});

test("formatting controls cannot run in source mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
const toolbar = page.getByRole("toolbar", { name: "Editor formatting" });
await expect(toolbar.getByRole("button", { name: "Bold", exact: true })).toBeEnabled();

await page.getByRole("button", { name: "Source", exact: true }).click();

// In source mode the rich-text controls are not rendered, so none of them
// can execute against the hidden editor.
for (const name of ["Bold", "Underline", "Insert internal link"]) {
await expect(toolbar.getByRole("button", { name, exact: true })).toHaveCount(0);
}
});

test("undo and redo stay disabled with nothing to undo", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
const toolbar = page.getByRole("toolbar", { name: "Editor formatting" });
const undo = toolbar.getByRole("button", { name: "Undo", exact: true });
await expect(undo).toBeDisabled();
// Forcing a click past the native disabled state must not run the command.
await undo.click({ force: true });
await expect(undo).toBeDisabled();
});
});
Loading
Loading