diff --git a/README.md b/README.md index b5dd62d..99e913e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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+ @@ -116,7 +134,7 @@ pnpm dev Sign in, click **New post**, preview the output, publish. The file lands at `contentDir/.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) diff --git a/apps/studio/e2e/helpers.ts b/apps/studio/e2e/helpers.ts index 1fd7362..a47f1a2 100644 --- a/apps/studio/e2e/helpers.ts +++ b/apps/studio/e2e/helpers.ts @@ -42,8 +42,10 @@ export async function enterDemoMode(page: Page): Promise { 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 { diff --git a/apps/studio/e2e/screenshots.spec.ts b/apps/studio/e2e/screenshots.spec.ts index 6ba9fd4..19a531c 100644 --- a/apps/studio/e2e/screenshots.spec.ts +++ b/apps/studio/e2e/screenshots.spec.ts @@ -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.", @@ -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"), }); diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index b913aa4..c520e5f 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -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.", @@ -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, @@ -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.", @@ -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, "\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(//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(); + }); }); diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index c8b94a5..f7f4293 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -220,6 +220,36 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo res.status(401).json({ ok: false, error: "Authentication required." }); } +/** + * A request is a "hard" demo request when the deployment is forced into demo + * mode or the session itself was created through demo entry. This is stricter + * than {@link isRequestDemoSession}: a real (non-demo) authenticated user whose + * publisher is not configured yet is NOT treated as demo, so they can still run + * legitimate setup writes such as config generation. + */ +export function isHardDemoRequest(req: Request): boolean { + return isDemoModeForced() || isDemoSession(getSessionToken(req)); +} + +/** + * Guards routes that mutate real files or configuration. Demo mode must stay + * read/demo-only, so demo sessions (and forced-demo deployments) are rejected + * before any real write happens. Pair with {@link requireAuth}, which rejects + * unauthenticated requests first. + */ +export function requireNonDemo(req: Request, res: Response, next: NextFunction): void { + if (isHardDemoRequest(req)) { + res.status(403).json({ + ok: false, + error: + "This action is disabled in demo mode. Demo mode never changes real files or configuration.", + }); + return; + } + + next(); +} + export async function login( req: Request, password: string, @@ -245,7 +275,7 @@ export function enterDemo( if (!isDemoModeAvailable()) { return { ok: false, - error: "Demo mode is not available when GitHub is fully configured.", + error: "Demo mode is disabled on this Studio instance.", }; } diff --git a/apps/studio/server/demo/fixtures/posts.ts b/apps/studio/server/demo/fixtures/posts.ts index d2be062..f404728 100644 --- a/apps/studio/server/demo/fixtures/posts.ts +++ b/apps/studio/server/demo/fixtures/posts.ts @@ -110,7 +110,7 @@ Studio uploads images to your configured \`mediaDir\`. Public paths are inserted ## Hero images -Set a hero image in Post details or pick a path from the media library after upload. +Set a hero image in Article details or pick a path from the media library after upload. ## Inline assets in automated builds diff --git a/apps/studio/server/demoMode.ts b/apps/studio/server/demoMode.ts index c792c41..7080c89 100644 --- a/apps/studio/server/demoMode.ts +++ b/apps/studio/server/demoMode.ts @@ -113,9 +113,10 @@ export function isPublisherConfigured(): boolean { } export function isDemoModeAvailable(): boolean { - if (isDemoModeForced()) { - return true; + const flag = process.env.SOURCEDRAFT_DEMO_MODE?.trim().toLowerCase(); + if (flag === "false") { + return false; } - return !isPublisherConfigured(); + return true; } diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 184479a..19ef0a9 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -12,6 +12,7 @@ import { login, logout, requireAuth, + requireNonDemo, } from "./auth.js"; import { loadPublicConfig, loadPublishEnv } from "./config.js"; import { listDemoPostsHandler, loadDemoPost } from "./demoPosts.js"; @@ -138,6 +139,7 @@ app.post( writeLimiter, requireSameSiteRequest, requireAuth, + requireNonDemo, (_req, res) => { const result = runGenerateConfig(); if (!result.ok) { diff --git a/apps/studio/server/requireNonDemo.test.ts b/apps/studio/server/requireNonDemo.test.ts new file mode 100644 index 0000000..e2094f2 --- /dev/null +++ b/apps/studio/server/requireNonDemo.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import type { NextFunction, Request, Response } from "express"; +import { + createSession, + isHardDemoRequest, + isRequestDemoSession, + requireNonDemo, +} from "./auth.js"; + +const SESSION_COOKIE = "sourcedraft_session"; +const ENV_KEYS = [ + "SOURCEDRAFT_DEMO_MODE", + "CMS_PUBLISHER", + "GITHUB_TOKEN", + "GITHUB_OWNER", + "GITHUB_REPO", +] as const; + +const original = new Map(); + +function mockRequest(cookie?: string): Request { + return { headers: cookie ? { cookie } : {} } as Request; +} + +function mockResponse() { + const result: { statusCode?: number; body?: { ok?: boolean; error?: string } } = + {}; + const res = { + status(code: number) { + result.statusCode = code; + return this; + }, + json(payload: { ok?: boolean; error?: string }) { + result.body = payload; + return this; + }, + } as unknown as Response; + return { res, result }; +} + +function run(req: Request) { + const { res, result } = mockResponse(); + let nextCalled = false; + const next: NextFunction = () => { + nextCalled = true; + }; + requireNonDemo(req, res, next); + return { nextCalled, result }; +} + +describe("requireNonDemo", () => { + afterEach(() => { + for (const key of ENV_KEYS) { + const value = original.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + original.clear(); + }); + + function clearEnv(): void { + for (const key of ENV_KEYS) { + original.set(key, process.env[key]); + delete process.env[key]; + } + } + + it("rejects forced demo deployments before any real write", () => { + clearEnv(); + process.env.SOURCEDRAFT_DEMO_MODE = "true"; + + const { nextCalled, result } = run(mockRequest()); + assert.equal(nextCalled, false); + assert.equal(result.statusCode, 403); + assert.equal(result.body?.ok, false); + }); + + it("rejects demo sessions", () => { + clearEnv(); + const token = createSession({ demo: true }); + + const { nextCalled, result } = run(mockRequest(`${SESSION_COOKIE}=${token}`)); + assert.equal(nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it("allows a real authenticated session even before a publisher is configured", () => { + clearEnv(); + const token = createSession(); + const req = mockRequest(`${SESSION_COOKIE}=${token}`); + + // With no publisher configured, the broad demo-routing check still reports + // demo for read fallbacks, but the write guard must NOT block a real user + // who is trying to set up their config. + assert.equal(isRequestDemoSession(req), true); + assert.equal(isHardDemoRequest(req), false); + + const { nextCalled, result } = run(req); + assert.equal(nextCalled, true); + assert.equal(result.statusCode, undefined); + }); +}); diff --git a/apps/studio/server/setupHealth.ts b/apps/studio/server/setupHealth.ts index 86f8d25..2352bea 100644 --- a/apps/studio/server/setupHealth.ts +++ b/apps/studio/server/setupHealth.ts @@ -65,19 +65,19 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] return [ { id: "gitlab-token", - label: "GitLab token (server-side)", + label: "GitLab connection", ok: tokenOk, detail: tokenOk - ? "GITLAB_TOKEN is present on the server. The value is never sent to the browser." - : "Set GITLAB_TOKEN in .env for GitLab publishing.", + ? "GitLab is connected on the server. Credentials never appear in the browser." + : "Publishing to GitLab is not connected yet. Add a GitLab token in .env or ask the person who installed SourceDraft.", }, { id: "gitlab-project", label: "GitLab project", ok: projectOk, detail: projectOk - ? "GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH is configured." - : "Set GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH in .env.", + ? "SourceDraft knows which GitLab project to update." + : "SourceDraft does not know which GitLab project stores your site yet.", }, ]; } @@ -89,27 +89,27 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] return [ { id: "bitbucket-token", - label: "Bitbucket token (server-side)", + label: "Bitbucket connection", ok: tokenOk, detail: tokenOk - ? "BITBUCKET_TOKEN is present on the server. The value is never sent to the browser." - : "Set BITBUCKET_TOKEN in .env for Bitbucket publishing.", + ? "Bitbucket is connected on the server. Credentials never appear in the browser." + : "Publishing to Bitbucket is not connected yet. Add a Bitbucket token in .env or ask the person who installed SourceDraft.", }, { id: "bitbucket-workspace", label: "Bitbucket workspace", ok: workspaceOk, detail: workspaceOk - ? "BITBUCKET_WORKSPACE is configured." - : "Set BITBUCKET_WORKSPACE in .env.", + ? "SourceDraft knows which Bitbucket workspace owns your blog." + : "SourceDraft does not know which Bitbucket workspace to use yet.", }, { id: "bitbucket-repo", label: "Bitbucket repository", ok: repoOk, detail: repoOk - ? "BITBUCKET_REPO_SLUG is configured." - : "Set BITBUCKET_REPO_SLUG in .env.", + ? "SourceDraft knows which Bitbucket repository stores your site." + : "SourceDraft does not know which Bitbucket repository stores your site yet.", }, ]; } @@ -121,27 +121,27 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] return [ { id: "wordpress-api-url", - label: "WordPress API URL", + label: "WordPress site address", ok: apiOk, detail: apiOk - ? "WORDPRESS_API_URL is configured." - : "Set WORDPRESS_API_URL in .env (e.g. https://example.com/wp-json).", + ? "SourceDraft knows where your WordPress site lives." + : "SourceDraft does not know your WordPress site address yet.", }, { id: "wordpress-username", - label: "WordPress username", + label: "WordPress sign-in", ok: userOk, detail: userOk - ? "WORDPRESS_USERNAME is configured." - : "Set WORDPRESS_USERNAME in .env.", + ? "A WordPress username is configured for publishing." + : "SourceDraft needs a WordPress username before it can publish posts.", }, { id: "wordpress-app-password", - label: "WordPress app password (server-side)", + label: "WordPress app password", 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.", + ? "WordPress publishing credentials are stored on the server only." + : "Publishing to WordPress is not connected yet. Add an app password in .env or ask the person who installed SourceDraft.", }, ]; } @@ -152,19 +152,19 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] return [ { id: "ghost-admin-url", - label: "Ghost site URL", + label: "Ghost site address", ok: urlOk, detail: urlOk - ? "GHOST_ADMIN_URL is configured." - : "Set GHOST_ADMIN_URL in .env (site root, no /ghost path).", + ? "SourceDraft knows where your Ghost site lives." + : "SourceDraft does not know your Ghost site address yet.", }, { id: "ghost-admin-api-key", - label: "Ghost Admin API key (server-side)", + label: "Ghost Admin connection", 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.", + ? "Ghost publishing credentials are stored on the server only." + : "Publishing to Ghost is not connected yet. Add a Ghost Admin API key in .env or ask the person who installed SourceDraft.", }, ]; } @@ -175,47 +175,72 @@ function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] return [ { id: "github-owner", - label: "GitHub owner", + label: "GitHub account or organization", ok: ownerOk, detail: ownerOk - ? "GITHUB_OWNER is configured." - : "Set GITHUB_OWNER in .env.", + ? "SourceDraft knows which GitHub account owns your blog." + : "SourceDraft does not know which GitHub account owns your blog yet.", }, { id: "github-repo", - label: "GitHub repository", + label: "Blog repository", ok: repoOk, - detail: repoOk ? "GITHUB_REPO is configured." : "Set GITHUB_REPO in .env.", + detail: repoOk + ? "SourceDraft knows which repository stores your site." + : "SourceDraft does not know which repository stores your site yet.", }, { id: "github-token", - label: "GitHub token (server-side)", + label: "GitHub connection", ok: tokenOk, detail: tokenOk - ? "GITHUB_TOKEN is present on the server. The value is never sent to the browser." - : "Set GITHUB_TOKEN in .env for GitHub publishing.", + ? "GitHub is connected on the server. Credentials never appear in the browser." + : "Publishing to GitHub is not connected yet. Add a GitHub token in .env or ask the person who installed SourceDraft.", }, ]; } function publisherSetupMessage(activePublisher: string): string { if (activePublisher === "gitlab") { - return "Complete GitLab setup in .env (GITLAB_TOKEN, GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH) or use demo mode to explore without GitLab."; + return "Finish connecting SourceDraft to your GitLab project, or try demo mode to explore without publishing."; } if (activePublisher === "bitbucket") { - return "Complete Bitbucket setup in .env (BITBUCKET_TOKEN, BITBUCKET_WORKSPACE, BITBUCKET_REPO_SLUG) or use demo mode to explore without Bitbucket."; + return "Finish connecting SourceDraft to your Bitbucket repository, or try demo mode to explore without publishing."; } 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."; + return "Finish connecting SourceDraft to your WordPress site, or try demo mode to explore without publishing."; } 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 "Finish connecting SourceDraft to your Ghost site, or try demo mode to explore without publishing."; } - return "Complete GitHub setup in .env (GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO) or use demo mode to explore without GitHub."; + return "Finish connecting SourceDraft to your GitHub blog repository, or try demo mode to explore without publishing."; +} + +function adapterDisplayName(adapter: string): string { + switch (adapter) { + case "astro-mdx": + return "Astro"; + case "markdown": + return "Markdown"; + case "nextjs-mdx": + return "Next.js"; + case "hugo-markdown": + return "Hugo"; + case "eleventy-jekyll-markdown": + return "Eleventy or Jekyll"; + case "docusaurus-mdx": + return "Docusaurus"; + case "mkdocs-markdown": + return "MkDocs"; + case "nuxt-content-markdown": + return "Nuxt Content"; + default: + return adapter; + } } export function getSetupHealth(): SetupHealthReport { @@ -247,71 +272,71 @@ export function getSetupHealth(): SetupHealthReport { const checks: SetupHealthCheck[] = [ { id: "admin-password", - label: "Admin password", + label: "Studio password", ok: adminPasswordConfigured, detail: adminPasswordConfigured - ? "SOURCEDRAFT_ADMIN_PASSWORD is set on the server." - : "Set SOURCEDRAFT_ADMIN_PASSWORD in .env for normal sign-in.", + ? "Writers can sign in with the Studio password." + : "Add a Studio password on the server, or use demo mode to explore without signing in.", }, ...publisherCredentialChecks(activePublisher), { id: "content-dir", - label: "Content directory", + label: "Article folder", ok: contentDirConfigured, detail: contentDirConfigured - ? `contentDir: ${contentDir}` - : "Configure contentDir in sourcedraft.config.json.", + ? `Articles will be saved in ${contentDir}.` + : "SourceDraft does not know where your articles should be saved yet.", }, { id: "media-dir", - label: "Media directory", + label: "Image folder", ok: mediaDirConfigured, detail: mediaDirConfigured - ? `mediaDir: ${mediaDir}` - : "Configure mediaDir in sourcedraft.config.json.", + ? `Uploaded images will be saved in ${mediaDir}.` + : "SourceDraft does not know where uploaded images should be saved yet.", }, { id: "public-media-path", - label: "Public media path", + label: "Public image path", ok: publicMediaPathConfigured, detail: publicMediaPathConfigured - ? `publicMediaPath: ${publicMediaPath}` - : "Configure publicMediaPath in sourcedraft.config.json or CMS_PUBLIC_MEDIA_PATH.", + ? `Images will appear on your site under ${publicMediaPath}.` + : "SourceDraft does not know the web path for images on your site yet.", }, { id: "adapter", - label: "Adapter", + label: "Blog type", ok: adapterValid, detail: adapterValid - ? `Using ${adapter} adapter.` - : `Unsupported adapter "${rawAdapter}". Use a built-in adapter id from docs/adapters.md.`, + ? `SourceDraft is writing for ${adapterDisplayName(adapter)}.` + : "Choose the blog type SourceDraft should write for, such as Astro, Hugo, or Next.js.", }, { id: "publisher", - label: "Publisher", + label: "Publishing destination", ok: publisherValid, detail: publisherValid - ? `Using ${publisher} publisher.` - : `Unsupported publisher "${rawPublisher}". Use a built-in publisher id from docs/configuration.md.`, + ? `Finished articles are sent through ${publisher}.` + : "Choose where SourceDraft should send finished articles.", }, { id: "demo-mode", label: "Demo mode", ok: true, detail: demoModeForced - ? "SOURCEDRAFT_DEMO_MODE=true — remote commits are disabled." + ? "Demo mode is forced on. Explore safely — nothing is sent to a real blog." : !isPublisherConfigured() - ? "Publisher is not fully configured — Studio uses demo content and simulated publish." - : "Demo mode is off. Publishing is enabled when credentials are valid.", + ? "Your blog is not fully connected yet. Studio shows sample articles and simulates publishing." + : "Publishing to your connected blog is available when the checks above pass.", }, ]; const nextAction = demoModeForced - ? "Demo mode is active. Explore Studio locally or configure your publisher and disable SOURCEDRAFT_DEMO_MODE for real publishing." + ? "You are in demo mode. Explore Studio safely, then ask your technical helper to connect a real blog when you are ready." : !adminPasswordConfigured && demoModeAvailable - ? "Enter demo mode from the sign-in screen or set SOURCEDRAFT_ADMIN_PASSWORD for password sign-in." + ? "Try demo mode from the sign-in screen, or ask your technical helper to add a Studio password." : !adminPasswordConfigured - ? "Set SOURCEDRAFT_ADMIN_PASSWORD in .env and restart the API server." + ? "Ask your technical helper to add a Studio password on the server." : !publisherReady ? publisherSetupMessage(activePublisher) : null; @@ -329,25 +354,26 @@ export function getSetupHealth(): SetupHealthReport { if (!validation.ok && validation.missingEnvVars.length > 0) { checks.push({ id: "config-validation", - label: "Configuration validation", + label: "Full setup check", ok: false, - detail: `Missing: ${validation.missingEnvVars.join(", ")}. Run pnpm validate:config for details.`, + detail: + "Some server settings are still missing. Ask your technical helper to run the guided setup or check the README.", }); } else if (validation.warnings.length > 0) { checks.push({ id: "config-validation", - label: "Configuration validation", + label: "Full setup check", ok: true, - detail: validation.warnings[0] ?? "Configuration validated with warnings.", + detail: validation.warnings[0] ?? "Setup looks mostly ready, with a few warnings to review.", }); } else { checks.push({ id: "config-validation", - label: "Configuration validation", + label: "Full setup check", ok: validation.ok, detail: validation.ok - ? "Adapter, publisher, and media provider look compatible." - : "Run pnpm validate:config locally for details.", + ? "Blog type, publishing destination, and image storage look compatible." + : "Ask your technical helper to review the setup guide in the README.", }); } diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 08485eb..d78a951 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -7,6 +7,7 @@ import { AstroMdxPreview } from "./components/AstroMdxPreview"; import { DemoBanner } from "./components/DemoBanner"; import { LoginScreen } from "./components/LoginScreen"; import { PostDetailsPanel } from "./components/PostDetailsPanel"; +import { PostLoginWelcomeBanner } from "./components/PostLoginWelcomeBanner"; import { PostSidebar } from "./components/PostSidebar"; import { PublishGate } from "./components/PublishGate"; import { RestoreDraftBanner } from "./components/RestoreDraftBanner"; @@ -507,7 +508,7 @@ function App() { return (
- {demoMode && } + {demoMode && } ) : ( -
+ <> + setView("settings")} + /> +
-
+
+ )}
); diff --git a/apps/studio/src/components/AdapterStatus.tsx b/apps/studio/src/components/AdapterStatus.tsx index 0ac8fb4..3f4f35e 100644 --- a/apps/studio/src/components/AdapterStatus.tsx +++ b/apps/studio/src/components/AdapterStatus.tsx @@ -21,16 +21,16 @@ export function AdapterStatus({ githubOwner.trim().length > 0 && githubRepo.trim().length > 0; const rows: StatusRow[] = [ - { label: "Output format", value: adapter, state: "ok" }, - { label: "Content folder", value: contentDir, state: "ok" }, + { label: "Blog type", value: adapter, state: "ok" }, + { label: "Article folder", value: contentDir, state: "ok" }, { - label: "GitHub repository", + label: "Blog repository", value: githubReady ? `${githubOwner}/${githubRepo}` : "Not configured", state: githubReady ? "idle" : "off", }, { - label: "GitHub token", - value: "Used on the server when you publish", + label: "Blog connection", + value: "Checked on the server when you send articles", state: githubReady ? "idle" : "off", }, ]; @@ -43,8 +43,8 @@ export function AdapterStatus({

{githubReady - ? "Connected to your GitHub repository" - : "Finish GitHub setup in .env to publish"} + ? "Connected to your blog repository" + : "Finish blog connection in Settings before sending articles"}

diff --git a/apps/studio/src/components/AppBar.tsx b/apps/studio/src/components/AppBar.tsx index 290085b..2b86f12 100644 --- a/apps/studio/src/components/AppBar.tsx +++ b/apps/studio/src/components/AppBar.tsx @@ -45,13 +45,13 @@ export function AppBar({ }: AppBarProps) { const repoLabel = githubReady ? `${githubOwner}/${githubRepo}` - : "GitHub not configured"; + : "Blog not connected"; return (
SourceDraft - Git-backed writing studio + Writing dashboard
@@ -60,7 +60,7 @@ export function AppBar({ className={ githubReady ? "app-bar__badge" : "app-bar__badge app-bar__badge--muted" } - title={githubReady ? "Target repository" : "Set GITHUB_OWNER and GITHUB_REPO in .env"} + title={githubReady ? "Connected blog repository" : "Open Settings to connect your blog"} > {repoLabel} diff --git a/apps/studio/src/components/AstroMdxPreview.tsx b/apps/studio/src/components/AstroMdxPreview.tsx index d2779eb..149a334 100644 --- a/apps/studio/src/components/AstroMdxPreview.tsx +++ b/apps/studio/src/components/AstroMdxPreview.tsx @@ -7,6 +7,7 @@ import { renderAdapterOutput, } from "@sourcedraft/adapters"; import type { Article, ValidationIssue } from "@sourcedraft/core"; +import { fieldLabel } from "../lib/fieldLabels.js"; type AstroMdxPreviewProps = { valid: boolean; @@ -65,8 +66,8 @@ export function AstroMdxPreview({

{valid - ? "File that will be saved to GitHub" - : "Complete post details to preview output"} + ? "See the generated article file before you send it to your blog" + : "Complete article details to preview the generated output"}

+ )} ); } diff --git a/apps/studio/src/components/LoginScreen.tsx b/apps/studio/src/components/LoginScreen.tsx index 23ea66a..9cdf335 100644 --- a/apps/studio/src/components/LoginScreen.tsx +++ b/apps/studio/src/components/LoginScreen.tsx @@ -1,4 +1,5 @@ import { useState, type FormEvent } from "react"; +import { WriterWelcomeCard } from "./WriterWelcomeCard.js"; type LoginScreenProps = { configured: boolean; @@ -8,39 +9,6 @@ type LoginScreenProps = { onEnterDemo: () => Promise<{ ok: boolean; error?: string }>; }; -const ONBOARDING_CHOICES = [ - { - id: "demo", - title: "Try demo mode", - body: "Explore SourceDraft with sample posts. Nothing is published.", - action: "demo" as const, - }, - { - id: "studio", - title: "Write in an already-configured Studio", - body: "Use the Studio link and password from the person who set this up.", - action: "sign-in" as const, - }, - { - id: "connect", - title: "Connect an existing blog", - body: "SourceDraft can inspect your project and suggest where articles and images should go.", - action: "info" as const, - }, - { - id: "developer", - title: "Advanced developer setup", - body: "Use config files, adapters, publishers, and environment variables.", - action: "info" as const, - }, - { - id: "agent", - title: "Agent-ready workflow", - body: "SourceDraft is built around structured drafts, validation, preview, and human review, so future AI agents and automation tools can fit into the publishing flow.", - action: "info" as const, - }, -]; - export function LoginScreen({ configured, demoAvailable, @@ -52,9 +20,6 @@ export function LoginScreen({ const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [enteringDemo, setEnteringDemo] = useState(false); - const [activeChoice, setActiveChoice] = useState( - demoAvailable ? "demo" : "studio", - ); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -91,129 +56,73 @@ export function LoginScreen({

SourceDraft Studio

-

Write, preview, and publish Markdown or MDX

+

+ Write, preview, and publish articles you own — try demo mode first, + no GitHub or API tokens required. +

+
+ +
+ +

+ Explore sample articles in Studio. Nothing is published to your blog. +

+ {!demoAvailable && ( +

+ Demo mode was turned off on this server. Sign in below or ask your + technical contact to enable it. +

+ )}
+ + {demoForced && (
-

Demo mode enabled

+

Demo mode is on

- This instance runs in demo mode. GitHub commits are disabled. + This Studio runs in demo mode. Nothing is sent to a real blog.

)} -
-

- How would you like to start? -

-
    - {ONBOARDING_CHOICES.map((choice) => { - const isDemoCard = choice.action === "demo"; - const demoDisabled = - isDemoCard && (!demoAvailable || submitting || enteringDemo); - - return ( -
  • -
    -

    {choice.title}

    -

    {choice.body}

    - {isDemoCard ? ( - - ) : choice.action === "sign-in" ? ( - - ) : ( - - )} -
    -
  • - ); - })} -
- - {activeChoice === "connect" && ( -

- After sign-in, open Settings → Setup detection to scan - your project folder. SourceDraft suggests where posts and images belong. - You can still draft and preview before publishing is configured. -

- )} - - {activeChoice === "developer" && ( -

- Run pnpm setup from the SourceDraft repository, or edit{" "} - sourcedraft.config.json and .env manually. - See the docs for adapters, publishers, and server-side secrets. -

- )} - - {activeChoice === "agent" && ( -

- Structured article fields, validation, preview, and a publish checklist - make SourceDraft a natural fit for AI-assisted workflows where agents - prepare drafts and humans review before publishing. Agent API, MCP, and - built-in AI providers are future work — not shipped today. -

- )} -
- -
-

- SourceDraft is a local writing tool, not a hosted website builder. Sign in - with the Studio password set by whoever installed it. + +

Sign in to your Studio

+

+ Already set up for you? Enter the Studio password below.

{!configured && !demoAvailable && (
-

Password not configured

+

Studio password not set yet

- A technical contact needs to add{" "} - SOURCEDRAFT_ADMIN_PASSWORD to the server{" "} - .env file and restart the API. + Ask whoever installed SourceDraft to add a sign-in password on the + server, or follow the developer setup steps in the README.

)} {!configured && demoAvailable && (
-

Publishing not configured yet

+

Your blog is not connected yet

- Demo mode is safe to try — nothing is published. Real publishing needs - setup by someone technical. + This is a setup issue, not your fault. Try demo mode to write and + preview safely, or ask your technical helper to finish connecting + your blog.

)} @@ -242,7 +151,7 @@ export function LoginScreen({ className="button button--primary login-screen__submit" disabled={!configured || submitting || enteringDemo || password.length === 0} > - {submitting ? "Signing in…" : "Sign in"} + {submitting ? "Signing in…" : "Sign in to Studio"}
diff --git a/apps/studio/src/components/PostDetailsPanel.tsx b/apps/studio/src/components/PostDetailsPanel.tsx index c4d4149..81af7fc 100644 --- a/apps/studio/src/components/PostDetailsPanel.tsx +++ b/apps/studio/src/components/PostDetailsPanel.tsx @@ -1,5 +1,6 @@ import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; +import { fieldLabel } from "../lib/fieldLabels.js"; import type { PostSummary } from "../lib/posts"; import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; import { ContentQualityPanel } from "./ContentQualityPanel"; @@ -53,9 +54,9 @@ export function PostDetailsPanel({