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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ 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**, **slash commands**, and **source mode** for raw Markdown/MDX
- 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`
- Validate fields against a universal article schema
- **Content QA** — non-blocking warnings for SEO, alt text, headings, links, and body length
Expand Down
21 changes: 19 additions & 2 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ 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.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();
});

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("Getting started with SourceDraft")).toBeVisible();
await expect(page.getByText("AI-assisted publishing with SourceDraft")).toBeVisible();
});

test("new post form and editor accept text", async ({ page }) => {
Expand All @@ -43,6 +49,17 @@ test.describe("Studio smoke", () => {
await expect(postBodyEditor(page)).toContainText("Selected text");
});

test("editor toolbar exposes core formatting controls", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).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();
});

test("autosave status appears after edits", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
Expand Down Expand Up @@ -114,7 +131,7 @@ test.describe("Studio smoke", () => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await fillPostBody(page, "<CustomBlock />\n\n## Heading");
await page.getByRole("button", { name: "Source", exact: true }).click();
await page.getByRole("button", { name: "Source mode" }).click();
const source = page.getByTestId("post-body-source");
await expect(source).toBeVisible();
await expect(source).toHaveValue(/<CustomBlock \/>/u);
Expand Down
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@tiptap/extension-image": "^3.26.0",
"@tiptap/extension-link": "^3.26.0",
"@tiptap/extension-placeholder": "^3.26.0",
"@tiptap/extension-underline": "^3.26.0",
"@tiptap/pm": "^3.26.0",
"@tiptap/react": "^3.26.0",
"@tiptap/starter-kit": "^3.26.0",
Expand Down
118 changes: 66 additions & 52 deletions apps/studio/server/demo/fixtures/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,139 +9,153 @@ export const DEMO_POST_FIXTURES: DemoFixturePost[] = [
{
summary: {
path: `${DEMO_CONTENT_DIR}/getting-started-with-sourcedraft.mdx`,
title: "Getting started with SourceDraft",
title: "AI-assisted publishing with SourceDraft",
slug: "getting-started-with-sourcedraft",
pubDate: "2026-06-06",
category: "Guides",
category: "AI-Assisted Publishing",
draft: false,
},
content: `---
title: Getting started with SourceDraft
description: A published guide showing the MDX shape Studio writes to your content folder.
title: AI-assisted publishing with SourceDraft
description: How git-backed Studio workflows help teams draft, validate, and publish technical content with automation-friendly Markdown.
pubDate: 2026-06-06
category: Guides
category: AI-Assisted Publishing
tags:
- sourcedraft
- guides
- ai-assisted-publishing
- git-backed-cms
draft: false
---

# Getting started with SourceDraft
# AI-assisted publishing with SourceDraft

This published guide demonstrates how articles look after you validate metadata and body in Studio.
SourceDraft is built for writers and operators who want **assisted publishing**: validate metadata in Studio, preview adapter output, then commit portable Markdown/MDX to your own repository — ready for CI, deploy hooks, and static-site automation.

## What you can try in demo mode

- Edit title, description, and body locally
- Preview adapter output before a real publish
- Simulate publish without GitHub commits
- Edit title, description, and body with a rich editor or source view
- Preview Astro MDX output before a real publish
- Simulate publish without GitHub commits or tokens

## Automation-friendly by design

Posts stay plain files in \`contentDir\`, so your existing pipelines (GitHub Actions, Cloudflare Pages, Hugo/Astro builds) keep working. No proprietary lock-in.

## Next steps

Open other sample posts to see drafts, images, and internal links.
Open the other sample posts to see drafts, media in automated pipelines, and internal links for content ops workflows.
`,
},
{
summary: {
path: `${DEMO_CONTENT_DIR}/draft-release-notes.mdx`,
title: "Draft release notes",
title: "Draft: workflow automation release notes",
slug: "draft-release-notes",
pubDate: "2026-06-01",
category: "Notes",
category: "Workflow Automation",
draft: true,
},
content: `---
title: Draft release notes
description: A sample draft post for filters, badges, and unpublished workflow.
title: Draft: workflow automation release notes
description: Sample draft for testing publish gates, deploy hooks, and editorial automation before content goes live.
pubDate: 2026-06-01
category: Notes
category: Workflow Automation
tags:
- draft
- release
- automation
- deploy-hooks
draft: true
---

# Draft release notes
# Draft: workflow automation release notes

This post is marked \`draft: true\`. Use it to confirm draft filters, publish checklists, and CI gates behave as expected before your build pipeline promotes content.

## Planned automation improvements

This post is marked \`draft: true\` in frontmatter. It appears in the post list with a draft badge.
- Webhook-triggered rebuilds after Studio publish
- Category-aware RSS and sitemap generation
- Safer preview URLs for editorial review bots

Use it to confirm draft filters and publishing gates behave as expected.
Nothing here is published until you clear the draft flag and run a real publish.
`,
},
{
summary: {
path: `${DEMO_CONTENT_DIR}/publishing-with-images.mdx`,
title: "Publishing with images",
title: "Content pipelines with media uploads",
slug: "publishing-with-images",
pubDate: "2026-05-28",
category: "Tutorials",
category: "Content Pipelines",
draft: false,
},
content: `---
title: Publishing with images
description: Example post with inline image Markdown and a cover image path.
title: Content pipelines with media uploads
description: Example post showing hero images and inline assets in a git-backed media workflow for static sites.
pubDate: 2026-05-28
category: Tutorials
category: Content Pipelines
tags:
- images
- markdown
- media
- content-pipelines
- static-deploy
heroImage: /images/sample-cover.png
draft: false
---

# Publishing with images
# Content pipelines with media uploads

Studio uploads land in your configured media folder. Public paths are inserted into posts.
Studio uploads images to your configured \`mediaDir\`. Public paths are inserted into posts so Astro, Hugo, or Next.js builds pick them up without a separate DAM.

![Diagram of the write-preview-publish flow](/images/workflow-diagram.png)
![Diagram of writepreview → commit → build automation](/images/workflow-diagram.png)

## Cover images
## Hero images

Set a hero image in Post details or reference a path from the media library.
Set a hero image in Post details or pick a path from the media library after upload.

## Inline images
## Inline assets in automated builds

Use the toolbar or paste Markdown like the example above.
Use the editor toolbar or Markdown syntax. Your site generator and CDN workflow treat these like any other static asset in the repo.
`,
},
{
summary: {
path: `${DEMO_CONTENT_DIR}/linking-and-outline.mdx`,
title: "Linking and document outline",
title: "CMS integrations and internal linking",
slug: "linking-and-outline",
pubDate: "2026-05-20",
category: "Tutorials",
category: "CMS Integrations",
draft: false,
},
content: `---
title: Linking and document outline
description: Sample post with headings, internal links, and outline-friendly structure.
title: CMS integrations and internal linking
description: Structure long-form technical articles with headings, internal links, and outline navigation for editorial automation.
pubDate: 2026-05-20
category: Tutorials
category: CMS Integrations
tags:
- links
- outline
- cms
- internal-links
- editorial-workflow
draft: false
---

# Linking and document outline
# CMS integrations and internal linking

Use headings to structure long articles. The document outline panel lists H1–H3 sections.
Use headings to structure articles that feed search, RSS, and AI summarization tools. The document outline lists H1–H3 sections for quick navigation.

## Internal links
## Internal links between posts

Link to other demo posts with the Internal toolbar action or Markdown syntax:
Link to other demo posts with the Internal toolbar action or Markdown:

- [Getting started with SourceDraft](/getting-started-with-sourcedraft)
- [Publishing with images](/publishing-with-images)
- [AI-assisted publishing with SourceDraft](/getting-started-with-sourcedraft)
- [Content pipelines with media uploads](/publishing-with-images)

## External links
## External references

External URLs work as usual: [Markdown guide](https://www.markdownguide.org/).
Automation stacks often combine git CMS with external docs: [Markdown guide](https://www.markdownguide.org/).

### Subsections
### Subsections for tooling docs

Smaller headings help readers scan technical content without extra UI chrome.
Smaller headings help readers scan integration guides without extra UI chrome — useful when content is syndicated to help centers or AI assistants.
`,
},
];
2 changes: 1 addition & 1 deletion apps/studio/server/demoFixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("demo fixtures", () => {
assert.equal(draft?.summary.draft, true);
assert.equal(guide?.summary.draft, false);
assert.match(images?.content ?? "", /!\[[^\]]*\]\([^)]+\)/u);
assert.match(links?.content ?? "", /\[Getting started with SourceDraft\]/u);
assert.match(links?.content ?? "", /\[AI-assisted publishing with SourceDraft\]/u);
assert.match(links?.content ?? "", /^## /mu);
});

Expand Down
31 changes: 31 additions & 0 deletions apps/studio/server/generateConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { detectSetup } from "@sourcedraft/setup";
import { generateConfigFromDetection } from "@sourcedraft/setup";

describe("generate config from detection", () => {
it("creates config for an Astro sample project", () => {
const root = mkdtempSync(join(tmpdir(), "api-generate-config-"));
writeFileSync(join(root, "astro.config.mjs"), "export default {};\n", "utf8");
writeFileSync(
join(root, "package.json"),
JSON.stringify({ dependencies: { astro: "^5.0.0" } }),
"utf8",
);
mkdirSync(join(root, "src/content/blog"), { recursive: true });
writeFileSync(join(root, "src/content/blog/post.mdx"), "---\ntitle: Hi\n---\n", "utf8");

const detection = detectSetup(root);
const result = generateConfigFromDetection(root, detection.primary);
assert.equal(result.ok, true);
if (!result.ok) {
return;
}

assert.match(result.summary, /adapter: astro-mdx/u);
assert.match(result.summary, /contentDir: src\/content\/blog/u);
});
});
39 changes: 39 additions & 0 deletions apps/studio/server/generateConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { resolve } from "node:path";
import { generateConfigFromDetection } from "@sourcedraft/setup";
import { resolveSetupDetectionRoot, runSetupDetection } from "./setupDetection.js";

export type GenerateConfigResponse =
| {
ok: true;
configPath: string;
summary: string;
}
| {
ok: false;
code: string;
error: string;
};

export function runGenerateConfig(): GenerateConfigResponse {
const detection = runSetupDetection();
const root = resolveSetupDetectionRoot();
const result = generateConfigFromDetection(root, detection.primary);
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid writing generated config under the Studio package

When the API is started from apps/studio (the normal package script cwd), resolveSetupDetectionRoot() stops at that package's package.json, so this writes apps/studio/sourcedraft.config.json rather than the project/root config that loadSourceDraftConfig() would otherwise find in a parent directory. Because app-local config is loaded first, clicking Generate config can silently shadow an existing root config and make Studio switch to the newly detected/wrong paths.

Useful? React with 👍 / 👎.


if (!result.ok) {
return {
ok: false,
code: result.code,
error: result.error,
};
}

return {
ok: true,
configPath: result.configPath,
summary: result.summary,
};
}

export function resolveGeneratedConfigPath(): string {
return resolve(resolveSetupDetectionRoot(), "sourcedraft.config.json");
}
18 changes: 18 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { requireSameSiteRequest } from "./requestProtection.js";
import { initializePlugins } from "./plugins.js";
import { runContentAudit, runDemoContentAudit } from "./contentAuditHandler.js";
import { getSetupHealth } from "./setupHealth.js";
import { runGenerateConfig } from "./generateConfig.js";
import { runSetupDetection } from "./setupDetection.js";
import {
apiLimiter,
Expand Down Expand Up @@ -132,6 +133,23 @@ app.get("/api/setup/detect", readLimiter, requireAuth, (_req, res) => {
res.json(runSetupDetection());
});

app.post(
"/api/setup/generate-config",
writeLimiter,
requireSameSiteRequest,
requireAuth,
(_req, res) => {
const result = runGenerateConfig();
if (!result.ok) {
const status = result.code === "exists" ? 409 : 400;
res.status(status).json(result);
return;
}

res.status(201).json(result);
},
);

app.get("/api/content/audit", readLimiter, requireAuth, async (req, res) => {
const demoMode = isRequestDemoSession(req);

Expand Down
Loading
Loading