From 409dc834303b1a138f1413c24d17a27158dd3eeb Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 16 Jun 2026 19:02:10 +0200 Subject: [PATCH 1/4] feat: improve setup detection, config generation, and Studio editor polish Add content-root detection, frontmatter inference, and server-side config generation while hardening auth, rate limits, and writer-facing Studio workflows. --- README.md | 28 +- apps/studio/e2e/helpers.ts | 6 +- apps/studio/e2e/screenshots.spec.ts | 8 +- apps/studio/e2e/smoke.spec.ts | 110 +++- apps/studio/server/demo/fixtures/posts.ts | 2 +- apps/studio/server/setupHealth.ts | 168 ++--- apps/studio/src/App.tsx | 12 +- apps/studio/src/components/AdapterStatus.tsx | 14 +- apps/studio/src/components/AppBar.tsx | 6 +- .../studio/src/components/AstroMdxPreview.tsx | 9 +- .../src/components/CompatibilityPanel.tsx | 14 +- .../src/components/ContentAuditPanel.tsx | 2 +- apps/studio/src/components/DemoBanner.tsx | 8 +- apps/studio/src/components/LoginScreen.tsx | 187 ++---- .../src/components/PostDetailsPanel.tsx | 17 +- .../src/components/PostLoginWelcomeBanner.tsx | 72 +++ apps/studio/src/components/PostSidebar.tsx | 46 +- .../src/components/PublishChecklist.tsx | 2 +- apps/studio/src/components/PublishGate.tsx | 60 +- apps/studio/src/components/SettingsPanel.tsx | 216 ++++--- .../src/components/SetupDetectionPanel.tsx | 3 +- .../src/components/SetupHealthPanel.tsx | 32 +- .../src/components/WriterWelcomeCard.tsx | 122 ++++ apps/studio/src/editor/EditorToolbar.tsx | 575 +++++++++++------- .../src/editor/markdownRoundtrip.test.ts | 17 + apps/studio/src/editor/markdownRoundtrip.ts | 9 +- apps/studio/src/index.css | 325 ++++++++-- apps/studio/src/lib/autosave.test.ts | 2 +- apps/studio/src/lib/autosave.ts | 2 +- apps/studio/src/lib/contentQuality.ts | 3 +- apps/studio/src/lib/fieldLabels.ts | 20 + apps/studio/src/lib/publishChecklist.ts | 10 +- docs/demo-mode.md | 10 +- docs/editor-parity.md | 83 ++- docs/editor.md | 38 +- docs/getting-started.md | 20 +- docs/media.md | 12 + docs/non-technical-overview.md | 112 +++- docs/roadmap.md | 24 +- docs/screenshots.md | 6 +- 40 files changed, 1554 insertions(+), 858 deletions(-) create mode 100644 apps/studio/src/components/PostLoginWelcomeBanner.tsx create mode 100644 apps/studio/src/components/WriterWelcomeCard.tsx create mode 100644 apps/studio/src/lib/fieldLabels.ts 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..9e35d5d 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.getByRole("button", { name: "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..6a357be 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,69 @@ 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", + "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 +126,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,17 +177,17 @@ 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); 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/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..1b73842 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"; @@ -526,7 +527,13 @@ function App() { ) : ( -
+ <> + 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"}

+

+ 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({