diff --git a/README.md b/README.md index 27a1f4b..84434ae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index fc1808a..b913aa4 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -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 }) => { @@ -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(); @@ -114,7 +131,7 @@ test.describe("Studio smoke", () => { await enterDemoMode(page); await page.getByRole("button", { name: "New post" }).click(); await fillPostBody(page, "\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(//u); diff --git a/apps/studio/package.json b/apps/studio/package.json index c4247ea..a2edbc4 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/apps/studio/server/demo/fixtures/posts.ts b/apps/studio/server/demo/fixtures/posts.ts index 351bba9..d2be062 100644 --- a/apps/studio/server/demo/fixtures/posts.ts +++ b/apps/studio/server/demo/fixtures/posts.ts @@ -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 write → preview → 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. `, }, ]; diff --git a/apps/studio/server/demoFixtures.test.ts b/apps/studio/server/demoFixtures.test.ts index 8737417..b7a5a93 100644 --- a/apps/studio/server/demoFixtures.test.ts +++ b/apps/studio/server/demoFixtures.test.ts @@ -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); }); diff --git a/apps/studio/server/generateConfig.test.ts b/apps/studio/server/generateConfig.test.ts new file mode 100644 index 0000000..f0be25a --- /dev/null +++ b/apps/studio/server/generateConfig.test.ts @@ -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); + }); +}); diff --git a/apps/studio/server/generateConfig.ts b/apps/studio/server/generateConfig.ts new file mode 100644 index 0000000..19796c3 --- /dev/null +++ b/apps/studio/server/generateConfig.ts @@ -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); + + 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"); +} diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index a546914..1a5eafa 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -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, @@ -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); diff --git a/apps/studio/server/setupDetection.ts b/apps/studio/server/setupDetection.ts index eb6fb03..4d2c085 100644 --- a/apps/studio/server/setupDetection.ts +++ b/apps/studio/server/setupDetection.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { + buildConfigFromSuggestion, + buildConfigWriteSummary, buildSuggestedConfigSnippet, detectSetup, isSafeToApplySuggestion, @@ -10,6 +12,8 @@ import { export type SetupDetectionResponse = SetupDetectionResult & { safeToApply: boolean; suggestedConfigSnippet: string | null; + configExists: boolean; + configPreviewSummary: string | null; }; function resolveDetectionRoot(): string { @@ -44,14 +48,30 @@ function resolveDetectionRoot(): string { return process.cwd(); } +export function resolveSetupDetectionRoot(): string { + return resolveDetectionRoot(); +} + export function runSetupDetection(): SetupDetectionResponse { - const result = detectSetup(resolveDetectionRoot()); + const scannedRoot = resolveDetectionRoot(); + const result = detectSetup(scannedRoot); const primary = result.primary; + const configPath = resolve(scannedRoot, "sourcedraft.config.json"); + const configExists = existsSync(configPath); + const configPreview = + primary !== null + ? buildConfigFromSuggestion(primary) + : null; return { ...result, safeToApply: primary !== null && isSafeToApplySuggestion(primary), suggestedConfigSnippet: primary !== null ? buildSuggestedConfigSnippet(primary) : null, + configExists, + configPreviewSummary: + configPreview !== null + ? buildConfigWriteSummary(configPath, configPreview) + : null, }; } diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 0c005af..08485eb 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -12,6 +12,7 @@ import { PublishGate } from "./components/PublishGate"; import { RestoreDraftBanner } from "./components/RestoreDraftBanner"; import { SettingsPanel } from "./components/SettingsPanel"; import { WritingCanvas } from "./components/WritingCanvas"; +import type { LatestMediaUpload } from "./editor/SourceDraftEditor"; import { useDocumentAutosave } from "./hooks/useDocumentAutosave"; import { enterDemo, @@ -78,6 +79,9 @@ function App() { const [latestUploadedImagePath, setLatestUploadedImagePath] = useState< string | null >(null); + const [latestUpload, setLatestUpload] = useState( + null, + ); const articleInput = useMemo(() => formStateToArticleInput(form), [form]); const validation = useMemo( @@ -135,7 +139,7 @@ function App() { } return { - ...createInitialFormState(config.categories[0] ?? "Guides"), + ...createInitialFormState(config.categories[0] ?? "AI-Assisted Publishing"), slug: current.slug, }; }); @@ -158,6 +162,8 @@ function App() { [studioConfig.githubOwner, studioConfig.githubRepo], ); + const mediaUploadAvailable = githubReady || demoMode; + const normalizedArticle = useMemo(() => { if (!validation.valid) { return null; @@ -305,6 +311,13 @@ function App() { setForm((current) => ({ ...current, slug: slugFromTitle(current.title) })); } + function handleUploadSuccess(upload: LatestMediaUpload) { + setLatestUpload(upload); + if (upload.kind === "image") { + setLatestUploadedImagePath(upload.publicPath); + } + } + function handleUseHeroImage(publicPath: string) { setForm((current) => ({ ...current, heroImage: publicPath })); } @@ -341,7 +354,7 @@ function App() { const loadedForm = articleInputToFormState( result.article, - studioConfig.categories[0] ?? "Guides", + studioConfig.categories[0] ?? "AI-Assisted Publishing", ); setForm(loadedForm); setSlugAuto(false); @@ -546,6 +559,8 @@ function App() { editingPath={editingPath} draft={form.draft} latestImagePath={latestUploadedImagePath} + latestUpload={latestUpload} + mediaUploadAvailable={mediaUploadAvailable} posts={posts} fieldErrors={fieldErrors} onTitleChange={(value) => handleFieldChange("title", value)} @@ -603,7 +618,7 @@ function App() { onUseHeroImage={handleUseHeroImage} onInsertImage={handleInsertImage} onInsertPdfLink={handleInsertPdfLink} - onUploadSuccess={setLatestUploadedImagePath} + onUploadSuccess={handleUploadSuccess} /> )} diff --git a/apps/studio/src/components/LoginScreen.tsx b/apps/studio/src/components/LoginScreen.tsx index 9863f11..23ea66a 100644 --- a/apps/studio/src/components/LoginScreen.tsx +++ b/apps/studio/src/components/LoginScreen.tsx @@ -8,6 +8,39 @@ 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, @@ -19,6 +52,9 @@ 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(); @@ -55,7 +91,7 @@ export function LoginScreen({

SourceDraft Studio

-

Sign in to write and publish

+

Write, preview, and publish Markdown or MDX

{demoForced && ( @@ -67,35 +103,125 @@ export function LoginScreen({ )} +
+

+ 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. +

+ )} +
+

- This workspace uses one shared password, checked on the server. It is - meant for local or private use. + SourceDraft is a local writing tool, not a hosted website builder. Sign in + with the Studio password set by whoever installed it.

{!configured && !demoAvailable && (

Password not configured

- Add SOURCEDRAFT_ADMIN_PASSWORD to .env{" "} - and restart the API server. + A technical contact needs to add{" "} + SOURCEDRAFT_ADMIN_PASSWORD to the server{" "} + .env file and restart the API.

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

GitHub not configured

+

Publishing not configured yet

- You can explore demo mode without GitHub credentials, or configure - a password and GitHub settings for real publishing. + Demo mode is safe to try — nothing is published. Real publishing needs + setup by someone technical.

)}
); diff --git a/apps/studio/src/components/MediaDropzone.tsx b/apps/studio/src/components/MediaDropzone.tsx index 748bd70..b3af05b 100644 --- a/apps/studio/src/components/MediaDropzone.tsx +++ b/apps/studio/src/components/MediaDropzone.tsx @@ -5,13 +5,14 @@ import { clientMediaKindForFile, uploadMedia, } from "../lib/media"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; type MediaDropzoneProps = { githubReady: boolean; onUseAsHero: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; onUploaded?: () => void; }; @@ -84,9 +85,11 @@ export function MediaDropzone({ kind: result.kind, filename: file.name, }); - if (result.kind === "image") { - onUploadSuccess?.(result.publicPath); - } + onUploadSuccess?.({ + publicPath: result.publicPath, + filename: file.name, + kind: result.kind, + }); onUploaded?.(); } diff --git a/apps/studio/src/components/MediaSection.tsx b/apps/studio/src/components/MediaSection.tsx index 1357612..b087e64 100644 --- a/apps/studio/src/components/MediaSection.tsx +++ b/apps/studio/src/components/MediaSection.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; import { MediaDropzone } from "./MediaDropzone"; import { MediaLibrary } from "./MediaLibrary"; @@ -7,7 +8,7 @@ type MediaSectionProps = { onUseAsHero: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; }; export function MediaSection({ diff --git a/apps/studio/src/components/PostDetailsPanel.tsx b/apps/studio/src/components/PostDetailsPanel.tsx index d02889a..c4d4149 100644 --- a/apps/studio/src/components/PostDetailsPanel.tsx +++ b/apps/studio/src/components/PostDetailsPanel.tsx @@ -1,6 +1,7 @@ import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; import type { PostSummary } from "../lib/posts"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; import { ContentQualityPanel } from "./ContentQualityPanel"; import { MediaSection } from "./MediaSection"; import { SeoSharingPanel } from "./SeoSharingPanel"; @@ -21,7 +22,7 @@ type PostDetailsPanelProps = { onUseHeroImage: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; }; export function PostDetailsPanel({ diff --git a/apps/studio/src/components/SetupDetectionPanel.tsx b/apps/studio/src/components/SetupDetectionPanel.tsx index ae4ac24..c1a4585 100644 --- a/apps/studio/src/components/SetupDetectionPanel.tsx +++ b/apps/studio/src/components/SetupDetectionPanel.tsx @@ -1,13 +1,46 @@ import { useEffect, useState } from "react"; import { fetchSetupDetection, + generateSetupConfig, type SetupDetectionReport, } from "../lib/setupDetection.js"; +function plainLanguageSummary(report: SetupDetectionReport): string | null { + if (!report.primary) { + return null; + } + + const { primary } = report; + const adapterName = primary.adapter.replace(/-mdx$|-markdown$/u, "").replace(/-/gu, " "); + const postsHint = + primary.postFileCount > 0 + ? `${primary.postFileCount} post file(s) in \`${primary.contentDir}\`` + : `articles expected in \`${primary.contentDir}\``; + return `We found a ${primary.framework} project for git-backed, AI-assisted publishing. Detected ${postsHint}. SourceDraft recommends the ${adapterName} adapter (${primary.confidence}% confidence) for automation-friendly Markdown/MDX workflows.`; +} + +function nextAction(report: SetupDetectionReport): string { + if (report.configExists) { + return "Your config file already exists. Review Settings → Setup health, then edit sourcedraft.config.json if content paths or adapter settings need adjusting for your publish pipeline."; + } + + if (report.primary && report.safeToApply) { + return "Next step: generate a starter config for your content pipeline, or copy the values into sourcedraft.config.json manually before wiring deploy hooks."; + } + + if (report.primary) { + return "Next step: review the warnings below, then copy or generate config only if the detected folders match your CMS and automation setup."; + } + + return "Next step: run pnpm setup from the SourceDraft folder, or ask your technical contact to configure git publishing and workflow tooling."; +} + export function SetupDetectionPanel() { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [copyStatus, setCopyStatus] = useState(null); + const [generateStatus, setGenerateStatus] = useState(null); + const [generating, setGenerating] = useState(false); useEffect(() => { fetchSetupDetection().then((next) => { @@ -29,6 +62,28 @@ export function SetupDetectionPanel() { } } + async function handleGenerateConfig(): Promise { + if (!report?.primary || report.configExists) { + return; + } + + setGenerating(true); + setGenerateStatus(null); + const result = await generateSetupConfig(); + setGenerating(false); + + if (!result.ok) { + setGenerateStatus(result.error); + return; + } + + setGenerateStatus(`Created ${result.configPath}. Restart the API or reload Studio to apply.`); + const refreshed = await fetchSetupDetection(); + setReport(refreshed); + } + + const summary = report ? plainLanguageSummary(report) : null; + return (
@@ -36,7 +91,7 @@ export function SetupDetectionPanel() { Setup detection

- Scans local project files — does not write configuration automatically + Detects content folders, adapters, and frontmatter for AI-assisted publishing workflows

@@ -55,9 +110,27 @@ export function SetupDetectionPanel() { {report && ( <>

- Scanned: {report.scannedRoot} + Scanned folder: {report.scannedRoot} +

+ + {summary && ( +

{summary}

+ )} + + {report.onboardingMessage && !summary && ( +

{report.onboardingMessage}

+ )} + +

+ {nextAction(report)}

+ {!report.detected && report.failureMessage && ( +

+ {report.failureMessage} +

+ )} + {report.warnings.length > 0 && (
    {report.warnings.map((warning) => ( @@ -67,71 +140,156 @@ export function SetupDetectionPanel() { )} {report.primary ? ( -
    -
    -
    Framework
    -
    {report.primary.framework}
    -
    -
    -
    Suggested adapter
    -
    - {report.primary.adapter} -
    -
    -
    -
    Content directory
    -
    - {report.primary.contentDir} -
    -
    -
    -
    Media directory
    -
    - {report.primary.mediaDir} -
    -
    -
    -
    Public media path
    -
    - {report.primary.publicMediaPath} -
    -
    -
    -
    Default branch
    -
    {report.primary.defaultBranch}
    -
    -
    -
    Confidence
    -
    {report.primary.confidence}%
    -
    -
    -
    Signals
    -
    {report.primary.explanation}
    -
    -
    + <> +
    +
    +
    Detected site type
    +
    {report.primary.framework}
    +
    +
    +
    Recommended format
    +
    + {report.primary.adapter} +
    +
    +
    +
    Likely articles folder
    +
    + {report.primary.contentRoot} + {report.primary.postFileCount > 0 && ( + + {" "} + ({report.primary.postFileCount} post file + {report.primary.postFileCount === 1 ? "" : "s"} found) + + )} +
    +
    +
    +
    Likely images folder
    +
    + {report.primary.mediaDir} +
    +
    +
    +
    Public image URL path
    +
    + {report.primary.publicMediaPath} +
    +
    +
    +
    Default branch
    +
    {report.primary.defaultBranch}
    +
    +
    +
    Confidence
    +
    {report.primary.confidence}%
    +
    +
    +
    Why we think so
    +
    {report.primary.explanation}
    +
    +
    + + {report.primary.contentRootCandidates.length > 0 && ( +
    + + Other content folders ({report.primary.contentRootCandidates.length}) + +
      + {report.primary.contentRootCandidates.map((candidate) => ( +
    • + {candidate} +
    • + ))} +
    +
    + )} + + {report.primary.frontmatter && report.primary.frontmatter.fields.length > 0 && ( +
    +

    Fields found in sample posts

    +

    + Studio maps these to its article form when you edit or create posts — useful + for automated and assisted publishing pipelines. +

    +
      + {report.primary.frontmatter.fields.map((field) => ( +
    • + {field.key} + {field.universalField && field.universalField !== field.key && ( + <> + {" "} + → {field.universalField} + + )} + + {" "} + ({field.frequency}/{report.primary?.frontmatter?.postsSampled}) + +
    • + ))} +
    + {report.primary.frontmatter.suggestedCategories.length > 0 && ( +

    + Suggested categories:{" "} + {report.primary.frontmatter.suggestedCategories.join(", ")} +

    + )} +
    + )} + ) : (

    - No supported framework detected. Use pnpm setup or edit{" "} + No supported site type detected. Run pnpm setup or edit{" "} sourcedraft.config.json manually.

    )} {report.alternatives.length > 0 && (
    - Alternative matches ({report.alternatives.length}) + Alternative frameworks ({report.alternatives.length})
      {report.alternatives.map((candidate) => (
    • {candidate.framework} — {candidate.confidence}% ( - {candidate.adapter}) + {candidate.adapter}, content{" "} + {candidate.contentRoot})
    • ))}
    )} - {report.suggestedConfigSnippet && ( -
    + {report.configPreviewSummary && !report.configExists && ( +
    + Preview config values before writing +
    {report.configPreviewSummary}
    +
    + )} + +
    + {report.primary && !report.configExists && ( + + )} + + {report.configExists && ( +

    + sourcedraft.config.json already exists — it will not be + overwritten. Edit it manually if paths need changing. +

    + )} + + {report.suggestedConfigSnippet && ( - {!report.safeToApply && ( -

    - Review detection results before applying. Low confidence or warnings - require manual confirmation. -

    - )} - {copyStatus && ( -

    - {copyStatus} -

    - )} -
    - )} + )} + + {!report.safeToApply && report.primary && ( +

    + Review detection results before applying. Low confidence or warnings + require manual confirmation. +

    + )} + + {copyStatus && ( +

    + {copyStatus} +

    + )} + + {generateStatus && ( +

    + {generateStatus} +

    + )} +
    )}
diff --git a/apps/studio/src/components/WritingCanvas.tsx b/apps/studio/src/components/WritingCanvas.tsx index 371a184..b002953 100644 --- a/apps/studio/src/components/WritingCanvas.tsx +++ b/apps/studio/src/components/WritingCanvas.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from "react"; import type { Editor } from "@tiptap/react"; import type { PostSummary } from "../lib/posts"; import { DocumentOutline } from "./DocumentOutline"; -import { SourceDraftEditor } from "../editor/SourceDraftEditor"; +import { SourceDraftEditor, type LatestMediaUpload } from "../editor/SourceDraftEditor"; type WritingCanvasProps = { title: string; @@ -11,6 +11,8 @@ type WritingCanvasProps = { editingPath: string | null; draft: boolean; latestImagePath: string | null; + latestUpload: LatestMediaUpload | null; + mediaUploadAvailable: boolean; posts: PostSummary[]; fieldErrors: Record; onTitleChange: (value: string) => void; @@ -25,6 +27,8 @@ export function WritingCanvas({ editingPath, draft, latestImagePath, + latestUpload, + mediaUploadAvailable, posts, fieldErrors, onTitleChange, @@ -133,7 +137,9 @@ export function WritingCanvas({ void; + disabled?: boolean; + title?: string; }; type EditorToolbarProps = { @@ -16,7 +24,9 @@ type EditorToolbarProps = { editorMode: "rich" | "source"; bodyFieldId: string; latestImagePath: string | null; + latestUpload: LatestMediaUpload | null; imageAlt: string; + mediaUploadAvailable: boolean; posts: PostSummary[]; editingPath: string | null; onBodyChange: (body: string) => void; @@ -24,12 +34,18 @@ type EditorToolbarProps = { onSelectInternalLink: (post: PostSummary) => void; }; +function attachmentLabel(filename: string): string { + return filename.replace(/\.pdf$/iu, "") || filename; +} + export function EditorToolbar({ editor, editorMode, bodyFieldId, latestImagePath, + latestUpload, imageAlt, + mediaUploadAvailable, posts, editingPath, onBodyChange, @@ -47,23 +63,98 @@ export function EditorToolbar({ onBodyChange(editorDocToBody(editor.getJSON())); } - const buttons: ToolbarButton[] = + function promptImageInsert(): void { + if (!editor) { + return; + } + + const path = + latestImagePath?.trim() || + (latestUpload?.kind === "image" ? latestUpload.publicPath : "") || + window.prompt("Image path (public URL or repo path)", "/images/")?.trim() || + ""; + + if (path.length === 0) { + return; + } + + const alt = + window.prompt("Alt text (for accessibility)", imageAlt)?.trim() || imageAlt; + + editor + .chain() + .focus() + .setImage({ src: path, alt, title: alt }) + .run(); + } + + function promptAttachmentInsert(): void { + if (!editor) { + return; + } + + let path = ""; + let filename = "Document"; + + if (latestUpload?.kind === "pdf") { + path = latestUpload.publicPath; + filename = latestUpload.filename; + } else { + path = + window.prompt("File path (public URL or repo path)", "/files/")?.trim() || + ""; + if (path.length === 0) { + return; + } + const segments = path.split("/"); + filename = segments[segments.length - 1] || "Document"; + } + + const label = attachmentLabel(filename); + editor + .chain() + .focus() + .insertContent({ + type: "text", + text: label, + marks: [{ type: "link", attrs: { href: path } }], + }) + .run(); + } + + const richDisabled = editorMode !== "rich" || !editor; + + const formattingButtons: ToolbarButton[] = editor && editorMode === "rich" ? [ { - label: "H1", + label: "Undo", + ariaLabel: "Undo", + text: "Undo", + action: () => editor.chain().focus().undo().run(), + disabled: !editor.can().undo(), + }, + { + label: "Redo", + ariaLabel: "Redo", + text: "Redo", + action: () => editor.chain().focus().redo().run(), + disabled: !editor.can().redo(), + }, + { + label: "Heading 1", ariaLabel: "Heading 1", text: "H1", action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), }, { - label: "H2", + label: "Heading 2", ariaLabel: "Heading 2", text: "H2", action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), }, { - label: "H3", + label: "Heading 3", ariaLabel: "Heading 3", text: "H3", action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), @@ -81,22 +172,22 @@ export function EditorToolbar({ action: () => editor.chain().focus().toggleItalic().run(), }, { - label: "Link", - ariaLabel: "Insert link", - text: "Link", - action: () => { - const href = - window.prompt("Link URL", "https://")?.trim() || "https://"; - editor - .chain() - .focus() - .insertContent({ - type: "text", - text: "link text", - marks: [{ type: "link", attrs: { href } }], - }) - .run(); - }, + label: "Underline", + ariaLabel: "Underline", + text: "U", + action: () => editor.chain().focus().toggleUnderline().run(), + }, + { + label: "Strikethrough", + ariaLabel: "Strikethrough", + text: "S", + action: () => editor.chain().focus().toggleStrike().run(), + }, + { + label: "Inline code", + ariaLabel: "Inline code", + text: "`", + action: () => editor.chain().focus().toggleCode().run(), }, { label: "Bullet list", @@ -123,30 +214,58 @@ export function EditorToolbar({ action: () => editor.chain().focus().toggleCodeBlock().run(), }, { - label: "Image", - ariaLabel: "Insert image", - text: "Image", + label: "Insert link", + ariaLabel: "Insert link", + text: "Link", action: () => { - const path = - latestImagePath?.trim() || - window.prompt("Image path (public URL or repo path)", "/images/")?.trim() || - ""; - if (path.length === 0) { - return; - } + const href = + window.prompt("Link URL", "https://")?.trim() || "https://"; editor .chain() .focus() - .setImage({ src: path, alt: imageAlt, title: imageAlt }) + .insertContent({ + type: "text", + text: "link text", + marks: [{ type: "link", attrs: { href } }], + }) .run(); }, }, + { + label: "Insert image", + ariaLabel: "Insert image", + text: "Image", + action: () => { + promptImageInsert(); + }, + }, + { + label: "Insert attachment", + ariaLabel: "Insert attachment", + text: "Attach", + action: () => { + promptAttachmentInsert(); + }, + disabled: !mediaUploadAvailable, + title: mediaUploadAvailable + ? "Insert a download link for an uploaded PDF or file" + : "Upload media in Post details after GitHub or demo mode is configured", + }, { label: "Horizontal rule", ariaLabel: "Horizontal rule", text: "HR", action: () => editor.chain().focus().setHorizontalRule().run(), }, + { + label: "Table", + ariaLabel: "Table", + text: "Table", + action: () => {}, + disabled: true, + title: + "Tables are not supported in rich mode yet. Use Source mode for Markdown table syntax.", + }, ] : []; @@ -158,14 +277,14 @@ export function EditorToolbar({ aria-label="Editor formatting" aria-controls={bodyFieldId} > - {buttons.map((button) => ( + {formattingButtons.map((button) => (