From d942d0f2561588c411adb17c8704fa76d417ac1f Mon Sep 17 00:00:00 2001
From: bnz183
Date: Fri, 12 Jun 2026 07:07:03 +0200
Subject: [PATCH 1/3] feat: improve onboarding with automatic detection and
suggestions
---
apps/studio/server/generateConfig.test.ts | 31 +++
apps/studio/server/generateConfig.ts | 39 +++
apps/studio/server/index.ts | 18 ++
apps/studio/server/setupDetection.ts | 22 +-
.../src/components/SetupDetectionPanel.tsx | 252 +++++++++++++-----
apps/studio/src/index.css | 40 +++
apps/studio/src/lib/setupDetection.ts | 44 +++
docs/setup-detection.md | 25 +-
docs/setup-wizard.md | 7 +
.../setup/src/contentRootDetection.test.ts | 28 ++
packages/setup/src/contentRootDetection.ts | 95 +++++++
.../src/createConfigFromDetection.test.ts | 58 ++++
.../setup/src/createConfigFromDetection.ts | 96 +++++++
packages/setup/src/detectSetup.test.ts | 6 +
packages/setup/src/detectSetup.ts | 108 ++++++--
packages/setup/src/index.ts | 18 ++
.../setup/src/inferFrontmatterSchema.test.ts | 31 +++
packages/setup/src/inferFrontmatterSchema.ts | 136 ++++++++++
packages/setup/src/onboardingCopy.ts | 96 +++++++
packages/setup/src/scanUtils.ts | 149 +++++++++++
packages/setup/src/wizard.ts | 41 ++-
21 files changed, 1254 insertions(+), 86 deletions(-)
create mode 100644 apps/studio/server/generateConfig.test.ts
create mode 100644 apps/studio/server/generateConfig.ts
create mode 100644 packages/setup/src/contentRootDetection.test.ts
create mode 100644 packages/setup/src/contentRootDetection.ts
create mode 100644 packages/setup/src/createConfigFromDetection.test.ts
create mode 100644 packages/setup/src/createConfigFromDetection.ts
create mode 100644 packages/setup/src/inferFrontmatterSchema.test.ts
create mode 100644 packages/setup/src/inferFrontmatterSchema.ts
create mode 100644 packages/setup/src/onboardingCopy.ts
create mode 100644 packages/setup/src/scanUtils.ts
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/components/SetupDetectionPanel.tsx b/apps/studio/src/components/SetupDetectionPanel.tsx
index ae4ac24..3f93c6b 100644
--- a/apps/studio/src/components/SetupDetectionPanel.tsx
+++ b/apps/studio/src/components/SetupDetectionPanel.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import {
fetchSetupDetection,
+ generateSetupConfig,
type SetupDetectionReport,
} from "../lib/setupDetection.js";
@@ -8,6 +9,8 @@ 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,14 +32,34 @@ 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);
+ }
+
return (
- Setup detection
+ Project onboarding
- Scans local project files — does not write configuration automatically
+ Automatic detection for content folders, adapters, and frontmatter
@@ -58,6 +81,16 @@ export function SetupDetectionPanel() {
Scanned: {report.scannedRoot}
+ {report.onboardingMessage && (
+ {report.onboardingMessage}
+ )}
+
+ {!report.detected && report.failureMessage && (
+
+ {report.failureMessage}
+
+ )}
+
{report.warnings.length > 0 && (
{report.warnings.map((warning) => (
@@ -67,48 +100,105 @@ 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}
-
-
+ <>
+
+
+
Framework
+ {report.primary.framework}
+
+
+
Suggested adapter
+
+ {report.primary.adapter}
+
+
+
+
Content root
+
+ {report.primary.contentRoot}
+ {report.primary.postFileCount > 0 && (
+
+ {" "}
+ ({report.primary.postFileCount} post file
+ {report.primary.postFileCount === 1 ? "" : "s"})
+
+ )}
+
+
+
+
Media directory
+
+ {report.primary.mediaDir}
+
+
+
+
Public media path
+
+ {report.primary.publicMediaPath}
+
+
+
+
Default branch
+ {report.primary.defaultBranch}
+
+
+
Confidence
+ {report.primary.confidence}%
+
+
+
Signals
+ {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 && (
+
+
Frontmatter from sample posts
+
+ Studio uses a universal article schema. Detected fields are mapped when you
+ edit or create posts.
+
+
+ {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{" "}
@@ -118,20 +208,45 @@ export function SetupDetectionPanel() {
{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 && (
+
{report.configPreviewSummary}
+ )}
+
+
+ {report.primary && !report.configExists && (
+
{
+ void handleGenerateConfig();
+ }}
+ >
+ {generating ? "Generating…" : "Generate config"}
+
+ )}
+
+ {report.configExists && (
+
+ sourcedraft.config.json already exists. Edit it manually instead of
+ generating a new file.
+
+ )}
+
+ {report.suggestedConfigSnippet && (
Copy suggested config
- {!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/index.css b/apps/studio/src/index.css
index 28fe41d..2d5f55b 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -2035,6 +2035,46 @@ select.field__input:focus-visible {
gap: 8px;
}
+.setup-detection__summary {
+ color: var(--text);
+ font-size: var(--text-sm);
+ line-height: 1.5;
+ margin: 0 0 12px;
+ padding: 0 12px;
+}
+
+.setup-detection__subtitle {
+ font-size: var(--text-sm);
+ margin: 0 0 6px;
+}
+
+.setup-detection__field-list {
+ font-size: var(--text-sm);
+ margin: 0 0 8px;
+ padding-left: 1.2rem;
+}
+
+.setup-detection__meta {
+ color: var(--text-muted);
+ font-size: var(--text-xs);
+}
+
+.setup-detection__preview {
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ margin: 0 12px 12px;
+ overflow-x: auto;
+ padding: 10px 12px;
+ white-space: pre-wrap;
+}
+
+.setup-detection__frontmatter {
+ margin: 0 12px 12px;
+}
+
.setup-detection__hint {
margin: 0;
font-size: 11px;
diff --git a/apps/studio/src/lib/setupDetection.ts b/apps/studio/src/lib/setupDetection.ts
index bac6a08..331405e 100644
--- a/apps/studio/src/lib/setupDetection.ts
+++ b/apps/studio/src/lib/setupDetection.ts
@@ -1,13 +1,29 @@
+export type FrontmatterFieldHint = {
+ key: string;
+ frequency: number;
+ universalField?: string;
+};
+
+export type InferredFrontmatterSchema = {
+ postsSampled: number;
+ fields: FrontmatterFieldHint[];
+ suggestedCategories: string[];
+};
+
export type SetupDetectionSuggestion = {
framework: string;
adapter: string;
contentDir: string;
+ contentRoot: string;
+ contentRootCandidates: string[];
+ postFileCount: number;
mediaDir: string;
publicMediaPath: string;
defaultBranch: string;
confidence: number;
explanation: string;
warnings: string[];
+ frontmatter: InferredFrontmatterSchema | null;
};
export type SetupDetectionReport = {
@@ -16,10 +32,18 @@ export type SetupDetectionReport = {
primary: SetupDetectionSuggestion | null;
alternatives: SetupDetectionSuggestion[];
warnings: string[];
+ onboardingMessage: string | null;
+ failureMessage: string | null;
safeToApply: boolean;
suggestedConfigSnippet: string | null;
+ configExists: boolean;
+ configPreviewSummary: string | null;
};
+export type GenerateConfigResult =
+ | { ok: true; configPath: string; summary: string }
+ | { ok: false; code: string; error: string };
+
export async function fetchSetupDetection(): Promise {
try {
const response = await fetch("/api/setup/detect", { credentials: "include" });
@@ -32,3 +56,23 @@ export async function fetchSetupDetection(): Promise {
+ try {
+ const response = await fetch("/api/setup/generate-config", {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ return (await response.json()) as GenerateConfigResult;
+ } catch {
+ return {
+ ok: false,
+ code: "network",
+ error: "Could not reach the setup API. Is the server running?",
+ };
+ }
+}
diff --git a/docs/setup-detection.md b/docs/setup-detection.md
index 849a9f4..983498f 100644
--- a/docs/setup-detection.md
+++ b/docs/setup-detection.md
@@ -1,6 +1,6 @@
# Setup detection
-Settings → **Setup detection** scans local project files and suggests configuration for SourceDraft. It does **not** write `sourcedraft.config.json` automatically.
+Settings → **Project onboarding** scans local project files and suggests configuration for SourceDraft.
## What it detects
@@ -14,7 +14,16 @@ Framework markers for:
- MkDocs
- Nuxt Content
-For each match it suggests `adapter`, `contentDir`, `mediaDir`, `publicMediaPath`, and `defaultBranch`, plus a **confidence score** and explanation of signals found.
+For each match it suggests:
+
+- **Adapter** — e.g. `astro-mdx`, `hugo-markdown`
+- **Content root** — scans for folders with `.md`/`.mdx` posts (e.g. `src/content/blog`, `content/posts`)
+- **Media paths** — `mediaDir` and `publicMediaPath`
+- **Default branch** — from `.git/HEAD` when present
+- **Frontmatter hints** — reads a few sample posts and lists common fields (with mapping to Studio’s universal schema)
+- **Categories** — inferred from existing post frontmatter when available
+
+Plain-language copy in Studio summarizes what was found, for example: “We found a Hugo project… Posts live in `content/posts`… We recommend the Hugo Markdown adapter.”
## API
@@ -23,11 +32,19 @@ For each match it suggests `adapter`, `contentDir`, `mediaDir`, `publicMediaPath
1. `SOURCEDRAFT_REPO_ROOT` or `CMS_REPO_ROOT` if set
2. Otherwise walks up from the API working directory looking for `package.json`, `sourcedraft.config.json`, or common framework config files
+Scans ignore `node_modules`, `.git`, `dist`, and other common build/cache folders.
+
+`POST /api/setup/generate-config` (authenticated) writes `sourcedraft.config.json` **only when the file does not exist**, using the primary detection result. The response includes a summary of fields written.
+
## Applying suggestions
-When confidence is high and there are no warnings, **Copy suggested config** copies a JSON snippet to the clipboard. Review paths, then paste into `sourcedraft.config.json` and run `pnpm validate:config`.
+1. **Generate config** — one-click write of `sourcedraft.config.json` when missing (review the preview summary first).
+2. **Copy suggested config** — copies JSON to the clipboard when confidence is high and there are no warnings.
+3. **`pnpm setup`** — interactive CLI wizard pre-fills adapter, content folder, branch, and categories from detection when possible ([setup-wizard.md](setup-wizard.md)).
+
+If detection fails (unknown framework, no posts found), Studio explains how to proceed manually.
-For interactive setup, use `pnpm setup` ([setup-wizard.md](setup-wizard.md)).
+Existing `sourcedraft.config.json` files are never overwritten by Generate config.
## Content audit
diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md
index 8da6dc7..2afbceb 100644
--- a/docs/setup-wizard.md
+++ b/docs/setup-wizard.md
@@ -10,6 +10,13 @@ From the repository root:
pnpm setup
```
+On start, the wizard scans your project folder (same rules as [setup detection](setup-detection.md)) and prints a plain-language summary when a framework is recognized. Detected values pre-fill:
+
+- Adapter choice
+- Content and media directories
+- Default git branch
+- Categories (from sample post frontmatter when found)
+
You will be asked about:
- **Adapter** — which site generator / output format (Astro MDX, Hugo, etc.)
diff --git a/packages/setup/src/contentRootDetection.test.ts b/packages/setup/src/contentRootDetection.test.ts
new file mode 100644
index 0000000..d66962f
--- /dev/null
+++ b/packages/setup/src/contentRootDetection.test.ts
@@ -0,0 +1,28 @@
+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 { detectContentRoot } from "./contentRootDetection.js";
+
+describe("detectContentRoot", () => {
+ it("prefers Astro blog folder when posts live there", () => {
+ const root = mkdtempSync(join(tmpdir(), "content-root-astro-"));
+ mkdirSync(join(root, "src/content/blog"), { recursive: true });
+ writeFileSync(join(root, "src/content/blog/post.mdx"), "---\ntitle: Hi\n---\n", "utf8");
+
+ const detected = detectContentRoot(root, "astro-mdx", "src/content/blog");
+ assert.equal(detected.contentDir, "src/content/blog");
+ assert.ok(detected.postCount >= 1);
+ });
+
+ it("detects Hugo content/posts when markdown files are present", () => {
+ const root = mkdtempSync(join(tmpdir(), "content-root-hugo-"));
+ mkdirSync(join(root, "content/posts"), { recursive: true });
+ writeFileSync(join(root, "content/posts/post.md"), "---\ntitle: Hi\n---\n", "utf8");
+
+ const detected = detectContentRoot(root, "hugo-markdown", "content/posts");
+ assert.equal(detected.contentDir, "content/posts");
+ assert.ok(detected.postCount >= 1);
+ });
+});
diff --git a/packages/setup/src/contentRootDetection.ts b/packages/setup/src/contentRootDetection.ts
new file mode 100644
index 0000000..4f66645
--- /dev/null
+++ b/packages/setup/src/contentRootDetection.ts
@@ -0,0 +1,95 @@
+import { join } from "node:path";
+import {
+ countMarkdownFiles,
+ findMarkdownContentDirs,
+ pathExists,
+} from "./scanUtils.js";
+
+const ADAPTER_CONTENT_CANDIDATES: Record = {
+ "astro-mdx": [
+ "src/content/blog",
+ "src/content/posts",
+ "src/content/articles",
+ "src/content",
+ "content/blog",
+ "content",
+ ],
+ "nextjs-mdx": ["content/posts", "content/blog", "content", "src/content"],
+ "hugo-markdown": [
+ "content/posts",
+ "content/blog",
+ "content/articles",
+ "content",
+ ],
+ "eleventy-jekyll-markdown": ["src/posts", "_posts", "src/content", "posts"],
+ markdown: ["content/posts", "content", "posts", "src/content"],
+ "docusaurus-mdx": ["blog", "docs/blog"],
+ "mkdocs-markdown": ["docs"],
+ "nuxt-content-markdown": ["content", "content/blog", "content/articles"],
+};
+
+export type DetectedContentRoot = {
+ contentDir: string;
+ alternatives: string[];
+ postCount: number;
+};
+
+export function detectContentRoot(
+ root: string,
+ adapter: string,
+ fallbackContentDir: string,
+): DetectedContentRoot {
+ const candidates = new Set([
+ ...(ADAPTER_CONTENT_CANDIDATES[adapter] ?? []),
+ fallbackContentDir,
+ ]);
+
+ const scored = [...candidates]
+ .map((relativePath) => {
+ const absolutePath = join(root, relativePath);
+ if (!pathExists(absolutePath)) {
+ return null;
+ }
+
+ const postCount = countMarkdownFiles(absolutePath, 3);
+ if (postCount === 0) {
+ return null;
+ }
+
+ return { contentDir: relativePath, postCount };
+ })
+ .filter((entry): entry is { contentDir: string; postCount: number } => entry !== null)
+ .sort((left, right) => right.postCount - left.postCount);
+
+ const scanned = findMarkdownContentDirs(root, 5)
+ .filter((entry) => entry.postCount > 0)
+ .map((entry) => ({
+ contentDir: entry.relativePath,
+ postCount: entry.postCount,
+ }));
+
+ const merged = new Map();
+ for (const entry of [...scored, ...scanned]) {
+ const current = merged.get(entry.contentDir) ?? 0;
+ merged.set(entry.contentDir, Math.max(current, entry.postCount));
+ }
+
+ const ranked = [...merged.entries()]
+ .map(([contentDir, postCount]) => ({ contentDir, postCount }))
+ .sort((left, right) => right.postCount - left.postCount);
+
+ if (ranked.length === 0) {
+ return {
+ contentDir: fallbackContentDir,
+ alternatives: [],
+ postCount: 0,
+ };
+ }
+
+ const [best, ...rest] = ranked;
+ return {
+ contentDir: best?.contentDir ?? fallbackContentDir,
+ alternatives: rest.map((entry) => entry.contentDir),
+ postCount: best?.postCount ?? 0,
+ };
+}
diff --git a/packages/setup/src/createConfigFromDetection.test.ts b/packages/setup/src/createConfigFromDetection.test.ts
new file mode 100644
index 0000000..e49d9ee
--- /dev/null
+++ b/packages/setup/src/createConfigFromDetection.test.ts
@@ -0,0 +1,58 @@
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, it } from "node:test";
+import type { SetupDetectionSuggestion } from "./detectSetup.js";
+import { generateConfigFromDetection } from "./createConfigFromDetection.js";
+
+const sampleSuggestion: SetupDetectionSuggestion = {
+ framework: "Astro MDX",
+ adapter: "astro-mdx",
+ contentDir: "src/content/blog",
+ contentRoot: "src/content/blog",
+ contentRootCandidates: [],
+ postFileCount: 2,
+ mediaDir: "public/images",
+ publicMediaPath: "/images",
+ defaultBranch: "main",
+ confidence: 95,
+ explanation: "astro dependency",
+ warnings: [],
+ frontmatter: {
+ postsSampled: 2,
+ fields: [{ key: "title", frequency: 2, universalField: "title" }],
+ suggestedCategories: ["Guides", "News"],
+ },
+};
+
+describe("generateConfigFromDetection", () => {
+ it("writes sourcedraft.config.json when missing", () => {
+ const root = mkdtempSync(join(tmpdir(), "generate-config-"));
+ const result = generateConfigFromDetection(root, sampleSuggestion);
+ assert.equal(result.ok, true);
+ if (!result.ok) {
+ return;
+ }
+
+ assert.ok(existsSync(result.configPath));
+ const config = JSON.parse(readFileSync(result.configPath, "utf8")) as Record;
+ assert.equal(config.adapter, "astro-mdx");
+ assert.equal(config.contentDir, "src/content/blog");
+ assert.deepEqual(config.categories, ["Guides", "News"]);
+ });
+
+ it("does not overwrite an existing config file", () => {
+ const root = mkdtempSync(join(tmpdir(), "generate-config-exists-"));
+ const configPath = join(root, "sourcedraft.config.json");
+ writeFileSync(configPath, "{}\n", "utf8");
+
+ const result = generateConfigFromDetection(root, sampleSuggestion);
+ assert.equal(result.ok, false);
+ if (result.ok) {
+ return;
+ }
+
+ assert.equal(result.code, "exists");
+ });
+});
diff --git a/packages/setup/src/createConfigFromDetection.ts b/packages/setup/src/createConfigFromDetection.ts
new file mode 100644
index 0000000..6d77e29
--- /dev/null
+++ b/packages/setup/src/createConfigFromDetection.ts
@@ -0,0 +1,96 @@
+import { existsSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import type { SetupDetectionSuggestion } from "./detectSetup.js";
+import { buildConfigWriteSummary } from "./onboardingCopy.js";
+
+export type GenerateConfigSuccess = {
+ ok: true;
+ configPath: string;
+ summary: string;
+ config: Record;
+};
+
+export type GenerateConfigFailure = {
+ ok: false;
+ code: "exists" | "no-suggestion" | "write-failed";
+ error: string;
+};
+
+export type GenerateConfigResult = GenerateConfigSuccess | GenerateConfigFailure;
+
+const DEFAULT_CATEGORIES = [
+ "Guides",
+ "Notes",
+ "Reviews",
+ "Tutorials",
+ "Reference",
+];
+
+export function buildConfigFromSuggestion(
+ suggestion: SetupDetectionSuggestion,
+): Record {
+ const categories =
+ suggestion.frontmatter?.suggestedCategories &&
+ suggestion.frontmatter.suggestedCategories.length > 0
+ ? suggestion.frontmatter.suggestedCategories
+ : DEFAULT_CATEGORIES;
+
+ return {
+ adapter: suggestion.adapter,
+ publisher: "github",
+ contentDir: suggestion.contentDir,
+ mediaDir: suggestion.mediaDir,
+ publicMediaPath: suggestion.publicMediaPath,
+ defaultBranch: suggestion.defaultBranch,
+ categories,
+ adapterOptions: {},
+ publisherOptions: {},
+ };
+}
+
+export function generateConfigFromDetection(
+ cwd: string,
+ suggestion: SetupDetectionSuggestion | null,
+): GenerateConfigResult {
+ if (suggestion === null) {
+ return {
+ ok: false,
+ code: "no-suggestion",
+ error:
+ "No framework suggestion is available. Run detection on a supported project or configure sourcedraft.config.json manually.",
+ };
+ }
+
+ const configPath = resolve(cwd, "sourcedraft.config.json");
+ if (existsSync(configPath)) {
+ return {
+ ok: false,
+ code: "exists",
+ error:
+ "sourcedraft.config.json already exists. Edit it manually or remove it before generating a new file.",
+ };
+ }
+
+ const config = buildConfigFromSuggestion(suggestion);
+ const summary = buildConfigWriteSummary(configPath, config);
+
+ try {
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
+ } catch (error) {
+ return {
+ ok: false,
+ code: "write-failed",
+ error:
+ error instanceof Error
+ ? error.message
+ : "Could not write sourcedraft.config.json.",
+ };
+ }
+
+ return {
+ ok: true,
+ configPath,
+ summary,
+ config,
+ };
+}
diff --git a/packages/setup/src/detectSetup.test.ts b/packages/setup/src/detectSetup.test.ts
index 7bb2d6a..44ac56c 100644
--- a/packages/setup/src/detectSetup.test.ts
+++ b/packages/setup/src/detectSetup.test.ts
@@ -23,7 +23,10 @@ describe("detectSetup", () => {
assert.equal(result.detected, true);
assert.equal(result.primary?.adapter, "astro-mdx");
assert.equal(result.primary?.contentDir, "src/content/blog");
+ assert.equal(result.primary?.contentRoot, "src/content/blog");
+ assert.ok((result.primary?.postFileCount ?? 0) >= 1);
assert.ok((result.primary?.confidence ?? 0) >= 70);
+ assert.ok(result.onboardingMessage?.includes("Astro"));
});
it("detects Next.js MDX projects", () => {
@@ -48,6 +51,9 @@ describe("detectSetup", () => {
const result = detectSetup(root);
assert.equal(result.primary?.adapter, "hugo-markdown");
+ assert.equal(result.primary?.contentDir, "content/posts");
+ assert.ok((result.primary?.postFileCount ?? 0) >= 1);
+ assert.ok(result.primary?.frontmatter?.fields.some((field) => field.key === "title"));
assert.ok((result.primary?.confidence ?? 0) >= 50);
});
diff --git a/packages/setup/src/detectSetup.ts b/packages/setup/src/detectSetup.ts
index cc32b88..d3dff55 100644
--- a/packages/setup/src/detectSetup.ts
+++ b/packages/setup/src/detectSetup.ts
@@ -1,17 +1,33 @@
-import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { derivePublicMediaPath } from "@sourcedraft/config";
+import { detectContentRoot } from "./contentRootDetection.js";
+import {
+ inferFrontmatterSchema,
+ type InferredFrontmatterSchema,
+} from "./inferFrontmatterSchema.js";
+import {
+ buildOnboardingFailureMessage,
+ buildOnboardingMessage,
+} from "./onboardingCopy.js";
+import { pathExists, SCAN_IGNORE_DIRS } from "./scanUtils.js";
+
+export type { InferredFrontmatterSchema, FrontmatterFieldHint } from "./inferFrontmatterSchema.js";
export type SetupDetectionSuggestion = {
framework: string;
adapter: string;
contentDir: string;
+ contentRoot: string;
+ contentRootCandidates: string[];
+ postFileCount: number;
mediaDir: string;
publicMediaPath: string;
defaultBranch: string;
confidence: number;
explanation: string;
warnings: string[];
+ frontmatter: InferredFrontmatterSchema | null;
};
export type SetupDetectionResult = {
@@ -20,6 +36,8 @@ export type SetupDetectionResult = {
primary: SetupDetectionSuggestion | null;
alternatives: SetupDetectionSuggestion[];
warnings: string[];
+ onboardingMessage: string | null;
+ failureMessage: string | null;
};
type FrameworkRule = {
@@ -38,10 +56,6 @@ function readText(path: string): string | null {
}
}
-function pathExists(path: string): boolean {
- return existsSync(path);
-}
-
function dirHasFilesWithExtension(dir: string, extensions: string[]): boolean {
if (!pathExists(dir)) {
return false;
@@ -55,7 +69,7 @@ function dirHasFilesWithExtension(dir: string, extensions: string[]): boolean {
if (extensions.some((ext) => entry.name.endsWith(ext))) {
return true;
}
- } else if (entry.isDirectory()) {
+ } else if (entry.isDirectory() && !SCAN_IGNORE_DIRS.has(entry.name)) {
if (dirHasFilesWithExtension(fullPath, extensions)) {
return true;
}
@@ -380,21 +394,42 @@ function buildSuggestion(
scored: { points: number; signals: string[]; warnings: string[] },
): SetupDetectionSuggestion {
const confidence = Math.min(100, Math.max(0, scored.points));
- const publicMediaPath = derivePublicMediaPath(rule.mediaDir);
+ const contentRoot = detectContentRoot(root, rule.adapter, rule.contentDir);
+ const mediaDir = rule.mediaDir;
+ const publicMediaPath = derivePublicMediaPath(mediaDir);
+ const frontmatter = inferFrontmatterSchema(root, contentRoot.contentDir);
+ const warnings = [...scored.warnings];
+
+ if (contentRoot.postCount === 0) {
+ warnings.push(
+ `No post files were found under ${contentRoot.contentDir}. Confirm the content folder after setup.`,
+ );
+ }
+
+ const explanationParts = [...scored.signals];
+ if (contentRoot.postCount > 0) {
+ explanationParts.push(
+ `${contentRoot.postCount} post file(s) in ${contentRoot.contentDir}`,
+ );
+ }
return {
framework: rule.framework,
adapter: rule.adapter,
- contentDir: rule.contentDir,
- mediaDir: rule.mediaDir,
+ contentDir: contentRoot.contentDir,
+ contentRoot: contentRoot.contentDir,
+ contentRootCandidates: contentRoot.alternatives,
+ postFileCount: contentRoot.postCount,
+ mediaDir,
publicMediaPath,
defaultBranch: detectDefaultBranch(root),
confidence,
explanation:
- scored.signals.length > 0
- ? scored.signals.join("; ")
+ explanationParts.length > 0
+ ? explanationParts.join("; ")
: "No strong framework markers found.",
- warnings: scored.warnings,
+ warnings,
+ frontmatter,
};
}
@@ -403,12 +438,15 @@ export function detectSetup(root: string): SetupDetectionResult {
const warnings: string[] = [];
if (!pathExists(resolvedRoot)) {
+ const failureMessage = `Scan root does not exist: ${resolvedRoot}. Point SourceDraft at your site folder or set SOURCEDRAFT_REPO_ROOT.`;
return {
scannedRoot: resolvedRoot,
detected: false,
primary: null,
alternatives: [],
- warnings: [`Scan root does not exist: ${resolvedRoot}`],
+ warnings: [failureMessage],
+ onboardingMessage: null,
+ failureMessage,
};
}
@@ -423,12 +461,23 @@ export function detectSetup(root: string): SetupDetectionResult {
warnings.push(
"No supported static-site framework markers were found. Run pnpm setup or configure sourcedraft.config.json manually.",
);
+ const failureMessage = buildOnboardingFailureMessage({
+ scannedRoot: resolvedRoot,
+ detected: false,
+ primary: null,
+ alternatives: [],
+ warnings,
+ onboardingMessage: null,
+ failureMessage: null,
+ });
return {
scannedRoot: resolvedRoot,
detected: false,
primary: null,
alternatives: [],
warnings,
+ onboardingMessage: null,
+ failureMessage,
};
}
@@ -449,18 +498,47 @@ export function detectSetup(root: string): SetupDetectionResult {
}
}
- return {
+ const result: SetupDetectionResult = {
scannedRoot: resolvedRoot,
detected: primary !== undefined,
primary: primary ?? null,
alternatives,
warnings,
+ onboardingMessage: primary ? buildOnboardingMessage(
+ {
+ scannedRoot: resolvedRoot,
+ detected: true,
+ primary,
+ alternatives,
+ warnings,
+ onboardingMessage: null,
+ failureMessage: null,
+ },
+ primary,
+ ) : null,
+ failureMessage: primary ? null : buildOnboardingFailureMessage({
+ scannedRoot: resolvedRoot,
+ detected: false,
+ primary: null,
+ alternatives,
+ warnings,
+ onboardingMessage: null,
+ failureMessage: null,
+ }),
};
+
+ return result;
}
export function buildSuggestedConfigSnippet(
suggestion: SetupDetectionSuggestion,
): string {
+ const categories =
+ suggestion.frontmatter?.suggestedCategories &&
+ suggestion.frontmatter.suggestedCategories.length > 0
+ ? suggestion.frontmatter.suggestedCategories
+ : ["Guides", "Notes", "Reviews", "Tutorials", "Reference"];
+
return JSON.stringify(
{
adapter: suggestion.adapter,
@@ -468,7 +546,7 @@ export function buildSuggestedConfigSnippet(
mediaDir: suggestion.mediaDir,
publicMediaPath: suggestion.publicMediaPath,
defaultBranch: suggestion.defaultBranch,
- categories: ["Guides", "Notes", "Reviews", "Tutorials", "Reference"],
+ categories,
adapterOptions: {},
publisherOptions: {},
},
diff --git a/packages/setup/src/index.ts b/packages/setup/src/index.ts
index 48248ae..2f06b2c 100644
--- a/packages/setup/src/index.ts
+++ b/packages/setup/src/index.ts
@@ -62,10 +62,28 @@ export {
buildSuggestedConfigSnippet,
detectSetup,
isSafeToApplySuggestion,
+ type FrontmatterFieldHint,
+ type InferredFrontmatterSchema,
type SetupDetectionResult,
type SetupDetectionSuggestion,
} from "./detectSetup.js";
+export { detectContentRoot, type DetectedContentRoot } from "./contentRootDetection.js";
+
+export { inferFrontmatterSchema } from "./inferFrontmatterSchema.js";
+
+export {
+ buildConfigFromSuggestion,
+ generateConfigFromDetection,
+ type GenerateConfigResult,
+} from "./createConfigFromDetection.js";
+
+export {
+ buildConfigWriteSummary,
+ buildOnboardingFailureMessage,
+ buildOnboardingMessage,
+} from "./onboardingCopy.js";
+
export {
auditPostFile,
buildContentAuditReport,
diff --git a/packages/setup/src/inferFrontmatterSchema.test.ts b/packages/setup/src/inferFrontmatterSchema.test.ts
new file mode 100644
index 0000000..151563e
--- /dev/null
+++ b/packages/setup/src/inferFrontmatterSchema.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 { inferFrontmatterSchema } from "./inferFrontmatterSchema.js";
+
+describe("inferFrontmatterSchema", () => {
+ it("infers common Hugo frontmatter fields and categories", () => {
+ const root = mkdtempSync(join(tmpdir(), "frontmatter-hugo-"));
+ const contentDir = "content/posts";
+ mkdirSync(join(root, contentDir), { recursive: true });
+ writeFileSync(
+ join(root, contentDir, "one.md"),
+ "---\ntitle: One\ndate: 2026-01-01\ntags: [a, b]\ncategories: Guides\ndraft: true\n---\nBody\n",
+ "utf8",
+ );
+ writeFileSync(
+ join(root, contentDir, "two.md"),
+ "---\ntitle: Two\ndate: 2026-01-02\ntags: [c]\ncategories: Guides\n---\nBody\n",
+ "utf8",
+ );
+
+ const schema = inferFrontmatterSchema(root, contentDir);
+ assert.ok(schema);
+ assert.equal(schema?.postsSampled, 2);
+ assert.ok(schema?.fields.some((field) => field.key === "title"));
+ assert.ok(schema?.fields.some((field) => field.key === "date" && field.universalField === "pubDate"));
+ assert.deepEqual(schema?.suggestedCategories, ["Guides"]);
+ });
+});
diff --git a/packages/setup/src/inferFrontmatterSchema.ts b/packages/setup/src/inferFrontmatterSchema.ts
new file mode 100644
index 0000000..5da008c
--- /dev/null
+++ b/packages/setup/src/inferFrontmatterSchema.ts
@@ -0,0 +1,136 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { splitFrontmatter } from "./frontmatter.js";
+import { listSampleMarkdownFiles } from "./scanUtils.js";
+
+export type FrontmatterFieldHint = {
+ key: string;
+ frequency: number;
+ universalField?: string;
+};
+
+export type InferredFrontmatterSchema = {
+ postsSampled: number;
+ fields: FrontmatterFieldHint[];
+ suggestedCategories: string[];
+};
+
+const UNIVERSAL_FIELD_ALIASES: Record = {
+ title: "title",
+ slug: "slug",
+ description: "description",
+ summary: "description",
+ excerpt: "description",
+ pubdate: "pubDate",
+ pubDate: "pubDate",
+ date: "pubDate",
+ published: "pubDate",
+ publishdate: "pubDate",
+ updateddate: "updatedDate",
+ updatedDate: "updatedDate",
+ lastmod: "updatedDate",
+ modified: "updatedDate",
+ category: "category",
+ categories: "category",
+ tags: "tags",
+ draft: "draft",
+ heroimage: "heroImage",
+ heroImage: "heroImage",
+ image: "heroImage",
+ cover: "heroImage",
+ coverimage: "heroImage",
+ coverImage: "heroImage",
+ author: "author",
+ metatitle: "metaTitle",
+ metaTitle: "metaTitle",
+ metadescription: "metaDescription",
+ metaDescription: "metaDescription",
+};
+
+function readPostContent(root: string, relativePath: string): string | null {
+ try {
+ return readFileSync(join(root, relativePath), "utf8");
+ } catch {
+ return null;
+ }
+}
+
+function normalizeCategoryValue(value: unknown): string[] {
+ if (typeof value === "string" && value.trim().length > 0) {
+ return [value.trim()];
+ }
+
+ if (Array.isArray(value)) {
+ return value
+ .filter((entry): entry is string => typeof entry === "string")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+ }
+
+ return [];
+}
+
+export function inferFrontmatterSchema(
+ root: string,
+ contentDir: string,
+ maxSamples = 5,
+): InferredFrontmatterSchema | null {
+ const samplePaths = listSampleMarkdownFiles(root, contentDir, maxSamples);
+ if (samplePaths.length === 0) {
+ return null;
+ }
+
+ const fieldCounts = new Map();
+ const categoryCounts = new Map();
+ let postsSampled = 0;
+
+ for (const relativePath of samplePaths) {
+ const content = readPostContent(root, relativePath);
+ if (content === null) {
+ continue;
+ }
+
+ const parsed = splitFrontmatter(content);
+ if (parsed === null) {
+ continue;
+ }
+
+ postsSampled += 1;
+ for (const key of Object.keys(parsed.frontmatter)) {
+ fieldCounts.set(key, (fieldCounts.get(key) ?? 0) + 1);
+ }
+
+ const categoryValue =
+ parsed.frontmatter.category ?? parsed.frontmatter.categories;
+ for (const category of normalizeCategoryValue(categoryValue)) {
+ categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
+ }
+ }
+
+ if (postsSampled === 0) {
+ return null;
+ }
+
+ const fields: FrontmatterFieldHint[] = [...fieldCounts.entries()]
+ .map(([key, frequency]) => {
+ const universalField =
+ UNIVERSAL_FIELD_ALIASES[key] ?? UNIVERSAL_FIELD_ALIASES[key.toLowerCase()];
+ const hint: FrontmatterFieldHint = { key, frequency };
+ if (universalField !== undefined) {
+ hint.universalField = universalField;
+ }
+ return hint;
+ })
+ .sort((left, right) => right.frequency - left.frequency);
+
+ const suggestedCategories = [...categoryCounts.entries()]
+ .sort((left, right) => right[1] - left[1])
+ .map(([category]) => category)
+ .slice(0, 12);
+
+ return {
+ postsSampled,
+ fields,
+ suggestedCategories,
+ };
+}
diff --git a/packages/setup/src/onboardingCopy.ts b/packages/setup/src/onboardingCopy.ts
new file mode 100644
index 0000000..0670a4a
--- /dev/null
+++ b/packages/setup/src/onboardingCopy.ts
@@ -0,0 +1,96 @@
+import type { SetupDetectionResult, SetupDetectionSuggestion } from "./detectSetup.js";
+import type { InferredFrontmatterSchema } from "./inferFrontmatterSchema.js";
+
+function adapterLabel(adapter: string): string {
+ switch (adapter) {
+ case "astro-mdx":
+ return "Astro MDX";
+ case "hugo-markdown":
+ return "Hugo Markdown";
+ case "nextjs-mdx":
+ return "Next.js MDX";
+ case "eleventy-jekyll-markdown":
+ return "Eleventy / Jekyll Markdown";
+ case "docusaurus-mdx":
+ return "Docusaurus MDX";
+ case "mkdocs-markdown":
+ return "MkDocs Markdown";
+ case "nuxt-content-markdown":
+ return "Nuxt Content Markdown";
+ case "markdown":
+ return "Markdown";
+ default:
+ return adapter;
+ }
+}
+
+function formatFieldHints(frontmatter: InferredFrontmatterSchema | null | undefined): string {
+ if (!frontmatter || frontmatter.fields.length === 0) {
+ return "We could not read frontmatter from sample posts yet.";
+ }
+
+ const topFields = frontmatter.fields
+ .slice(0, 6)
+ .map((field) => {
+ if (field.universalField && field.universalField !== field.key) {
+ return `${field.key} → ${field.universalField}`;
+ }
+
+ return field.key;
+ })
+ .join(", ");
+
+ return `From ${frontmatter.postsSampled} sample post(s), common frontmatter fields include: ${topFields}.`;
+}
+
+export function buildOnboardingMessage(
+ result: SetupDetectionResult,
+ suggestion: SetupDetectionSuggestion,
+): string {
+ const framework = suggestion.framework;
+ const adapter = adapterLabel(suggestion.adapter);
+ const postsLine =
+ suggestion.postFileCount > 0
+ ? `We found ${suggestion.postFileCount} post file(s) in \`${suggestion.contentDir}\`.`
+ : `Posts are expected in \`${suggestion.contentDir}\` (no sample posts found yet).`;
+
+ return [
+ `We found a ${framework} project at \`${result.scannedRoot}\`.`,
+ postsLine,
+ `We recommend the ${adapter} adapter.`,
+ formatFieldHints(suggestion.frontmatter),
+ "Click Generate config to create sourcedraft.config.json with these values, or adjust them first.",
+ ].join(" ");
+}
+
+export function buildOnboardingFailureMessage(result: SetupDetectionResult): string {
+ if (result.warnings.length > 0) {
+ return [
+ "SourceDraft could not confidently detect your site setup.",
+ result.warnings.join(" "),
+ "You can still run pnpm setup or edit sourcedraft.config.json manually.",
+ ].join(" ");
+ }
+
+ return "SourceDraft could not detect a supported framework. Run pnpm setup or configure sourcedraft.config.json manually.";
+}
+
+export function buildConfigWriteSummary(
+ configPath: string,
+ config: Record,
+): string {
+ const categories = Array.isArray(config.categories)
+ ? (config.categories as string[]).join(", ")
+ : "";
+
+ return [
+ `Will write ${configPath} with:`,
+ `adapter: ${String(config.adapter ?? "")}`,
+ `contentDir: ${String(config.contentDir ?? "")}`,
+ `mediaDir: ${String(config.mediaDir ?? "")}`,
+ `defaultBranch: ${String(config.defaultBranch ?? "")}`,
+ categories.length > 0 ? `categories: ${categories}` : "",
+ ]
+ .filter((line) => line.length > 0)
+ .join("\n");
+}
diff --git a/packages/setup/src/scanUtils.ts b/packages/setup/src/scanUtils.ts
new file mode 100644
index 0000000..097b34a
--- /dev/null
+++ b/packages/setup/src/scanUtils.ts
@@ -0,0 +1,149 @@
+import { existsSync, readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+
+export const SCAN_IGNORE_DIRS = new Set([
+ "node_modules",
+ ".git",
+ "dist",
+ "build",
+ ".next",
+ ".astro",
+ "vendor",
+ ".cache",
+ "coverage",
+ ".turbo",
+ ".vercel",
+ ".pnpm-store",
+ ".source-draft",
+]);
+
+const MARKDOWN_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
+
+export function isMarkdownFilename(filename: string): boolean {
+ const lower = filename.toLowerCase();
+ return MARKDOWN_EXTENSIONS.some((extension) => lower.endsWith(extension));
+}
+
+export function pathExists(path: string): boolean {
+ return existsSync(path);
+}
+
+export function countMarkdownFiles(
+ dir: string,
+ maxDepth = 3,
+ currentDepth = 0,
+): number {
+ if (!pathExists(dir) || currentDepth > maxDepth) {
+ return 0;
+ }
+
+ let count = 0;
+
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isFile() && isMarkdownFilename(entry.name)) {
+ count += 1;
+ continue;
+ }
+
+ if (!entry.isDirectory() || SCAN_IGNORE_DIRS.has(entry.name)) {
+ continue;
+ }
+
+ count += countMarkdownFiles(join(dir, entry.name), maxDepth, currentDepth + 1);
+ }
+ } catch {
+ return count;
+ }
+
+ return count;
+}
+
+export function findMarkdownContentDirs(
+ root: string,
+ maxDepth = 5,
+): Array<{ relativePath: string; postCount: number }> {
+ const results: Array<{ relativePath: string; postCount: number }> = [];
+
+ function walk(dir: string, depth: number): void {
+ if (depth > maxDepth || !pathExists(dir)) {
+ return;
+ }
+
+ let directMarkdown = 0;
+
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isFile() && isMarkdownFilename(entry.name)) {
+ directMarkdown += 1;
+ }
+ }
+
+ if (directMarkdown > 0) {
+ results.push({
+ relativePath: relative(root, dir).replace(/\\/gu, "/"),
+ postCount: countMarkdownFiles(dir, 2),
+ });
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory() || SCAN_IGNORE_DIRS.has(entry.name)) {
+ continue;
+ }
+
+ walk(join(dir, entry.name), depth + 1);
+ }
+ } catch {
+ return;
+ }
+ }
+
+ walk(root, 0);
+
+ return results.sort((left, right) => right.postCount - left.postCount);
+}
+
+export function listSampleMarkdownFiles(
+ root: string,
+ contentDir: string,
+ maxFiles = 5,
+): string[] {
+ const base = join(root, contentDir);
+ if (!pathExists(base)) {
+ return [];
+ }
+
+ const files: string[] = [];
+
+ function walk(dir: string, depth: number): void {
+ if (files.length >= maxFiles || depth > 4) {
+ return;
+ }
+
+ try {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (files.length >= maxFiles) {
+ return;
+ }
+
+ const fullPath = join(dir, entry.name);
+ if (entry.isFile() && isMarkdownFilename(entry.name)) {
+ files.push(relative(root, fullPath).replace(/\\/gu, "/"));
+ continue;
+ }
+
+ if (entry.isDirectory() && !SCAN_IGNORE_DIRS.has(entry.name)) {
+ walk(fullPath, depth + 1);
+ }
+ }
+ } catch {
+ return;
+ }
+ }
+
+ walk(base, 0);
+ return files;
+}
diff --git a/packages/setup/src/wizard.ts b/packages/setup/src/wizard.ts
index eaa53ef..fa47675 100644
--- a/packages/setup/src/wizard.ts
+++ b/packages/setup/src/wizard.ts
@@ -19,6 +19,8 @@ import {
publisherEnvRequirements,
} from "./envRequirements.js";
import { formatEnvValueForDisplay } from "./maskSecrets.js";
+import { detectSetup } from "./detectSetup.js";
+import { buildOnboardingFailureMessage, buildOnboardingMessage } from "./onboardingCopy.js";
import { validateConfigAsync } from "./validateConfig.js";
export type WizardOptions = {
@@ -156,13 +158,31 @@ export async function runWizard(options: WizardOptions = {}): Promise 0) {
+ for (const warning of detection.warnings) {
+ console.log(` • ${warning}`);
+ }
+ }
+ console.log("");
+ } else if (detection.failureMessage) {
+ console.log(buildOnboardingFailureMessage(detection));
+ console.log("");
+ }
+
const adapterIds = listAdapterIds();
const adapterLabels = adapterIds.map((id) => `${id}`);
+ const detectedAdapterIndex =
+ detection.primary !== null
+ ? Math.max(0, adapterIds.indexOf(detection.primary.adapter))
+ : 0;
const adapter = await askChoice(
rl,
"Which site generator / output format (adapter)?",
adapterLabels,
- 0,
+ detectedAdapterIndex,
);
const publisherIds = listPublisherIds();
@@ -190,23 +210,34 @@ export async function runWizard(options: WizardOptions = {}): Promise 0
+ ? detectedSuggestion.frontmatter.suggestedCategories.join(", ")
+ : "Guides, Notes, Reviews, Tutorials, Reference";
const categoriesRaw = await askText(
rl,
"Default categories (comma-separated)",
- "Guides, Notes, Reviews, Tutorials, Reference",
+ defaultCategories,
);
const categories = categoriesRaw
.split(",")
From b76543a247762c2fc39674fad95343861603cf72 Mon Sep 17 00:00:00 2001
From: bnz183
Date: Fri, 12 Jun 2026 07:17:17 +0200
Subject: [PATCH 2/3] fix: align onboarding samples with AI-assisted publishing
workflows
---
apps/studio/e2e/smoke.spec.ts | 17 ++
apps/studio/package.json | 1 +
apps/studio/server/demo/fixtures/posts.ts | 118 ++++++-----
apps/studio/server/demoFixtures.test.ts | 2 +-
apps/studio/src/App.tsx | 21 +-
apps/studio/src/components/LoginScreen.tsx | 162 ++++++++++++---
apps/studio/src/components/MediaDropzone.tsx | 11 +-
apps/studio/src/components/MediaSection.tsx | 3 +-
.../src/components/PostDetailsPanel.tsx | 3 +-
.../src/components/SetupDetectionPanel.tsx | 83 ++++++--
apps/studio/src/components/WritingCanvas.tsx | 8 +-
apps/studio/src/editor/EditorToolbar.tsx | 192 ++++++++++++++----
apps/studio/src/editor/SourceDraftEditor.tsx | 20 +-
.../src/editor/markdownRoundtrip.test.ts | 7 +-
apps/studio/src/editor/markdownRoundtrip.ts | 42 ++++
apps/studio/src/index.css | 84 +++++++-
apps/studio/src/lib/articleForm.ts | 7 +-
apps/studio/src/lib/studioConfig.ts | 3 +-
docs/configuration.md | 8 +-
docs/demo-mode.md | 5 +-
docs/editor-parity.md | 53 +++++
docs/editor.md | 30 ++-
docs/getting-started.md | 4 +-
docs/non-technical-overview.md | 43 ++--
docs/roadmap.md | 7 +
docs/setup-detection.md | 4 +-
docs/setup-wizard.md | 2 +-
packages/config/src/defaultCategories.ts | 11 +
packages/config/src/index.ts | 5 +
packages/config/src/types.ts | 3 +-
.../src/createConfigFromDetection.test.ts | 4 +-
.../setup/src/createConfigFromDetection.ts | 11 +-
packages/setup/src/detectSetup.test.ts | 1 +
packages/setup/src/detectSetup.ts | 4 +-
.../setup/src/inferFrontmatterSchema.test.ts | 6 +-
packages/setup/src/onboardingCopy.ts | 27 ++-
packages/setup/src/wizard.ts | 7 +-
sourcedraft.config.example.json | 8 +-
38 files changed, 819 insertions(+), 208 deletions(-)
create mode 100644 docs/editor-parity.md
create mode 100644 packages/config/src/defaultCategories.ts
diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts
index fc1808a..b56db99 100644
--- a/apps/studio/e2e/smoke.spec.ts
+++ b/apps/studio/e2e/smoke.spec.ts
@@ -14,6 +14,12 @@ 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();
});
@@ -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();
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.
-
+
-## 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/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 ? (
+ {
+ setActiveChoice(choice.id);
+ void handleEnterDemo();
+ }}
+ >
+ {enteringDemo ? "Opening demo…" : "Explore demo mode"}
+
+ ) : choice.action === "sign-in" ? (
+ {
+ setActiveChoice(choice.id);
+ document.getElementById("login-password")?.focus();
+ }}
+ >
+ Sign in below
+
+ ) : (
+ {
+ setActiveChoice(choice.id);
+ }}
+ >
+ Learn more
+
+ )}
+
+
+ );
+ })}
+
+
+ {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.
+
+ )}
+
+
-
- {demoAvailable && (
-
-
- Explore Studio with sample posts. No GitHub token required.
-
-
{
- void handleEnterDemo();
- }}
- >
- {enteringDemo ? "Opening demo…" : "Explore demo mode"}
-
-
- )}
);
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 3f93c6b..c1a4585 100644
--- a/apps/studio/src/components/SetupDetectionPanel.tsx
+++ b/apps/studio/src/components/SetupDetectionPanel.tsx
@@ -5,6 +5,36 @@ import {
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);
@@ -52,14 +82,16 @@ export function SetupDetectionPanel() {
setReport(refreshed);
}
+ const summary = report ? plainLanguageSummary(report) : null;
+
return (
- Project onboarding
+ Setup detection
- Automatic detection for content folders, adapters, and frontmatter
+ Detects content folders, adapters, and frontmatter for AI-assisted publishing workflows
@@ -78,13 +110,21 @@ export function SetupDetectionPanel() {
{report && (
<>
- Scanned: {report.scannedRoot}
+ Scanned folder: {report.scannedRoot}
- {report.onboardingMessage && (
+ {summary && (
+ {summary}
+ )}
+
+ {report.onboardingMessage && !summary && (
{report.onboardingMessage}
)}
+
+ {nextAction(report)}
+
+
{!report.detected && report.failureMessage && (
{report.failureMessage}
@@ -103,36 +143,36 @@ export function SetupDetectionPanel() {
<>
-
Framework
+ Detected site type
{report.primary.framework}
-
Suggested adapter
+ Recommended format
{report.primary.adapter}
-
Content root
+ Likely articles folder
{report.primary.contentRoot}
{report.primary.postFileCount > 0 && (
{" "}
({report.primary.postFileCount} post file
- {report.primary.postFileCount === 1 ? "" : "s"})
+ {report.primary.postFileCount === 1 ? "" : "s"} found)
)}
-
Media directory
+ Likely images folder
{report.primary.mediaDir}
-
Public media path
+ Public image URL path
{report.primary.publicMediaPath}
@@ -146,7 +186,7 @@ export function SetupDetectionPanel() {
{report.primary.confidence}%
-
Signals
+ Why we think so
{report.primary.explanation}
@@ -168,10 +208,10 @@ export function SetupDetectionPanel() {
{report.primary.frontmatter && report.primary.frontmatter.fields.length > 0 && (
-
Frontmatter from sample posts
+
Fields found in sample posts
- Studio uses a universal article schema. Detected fields are mapped when you
- edit or create 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) => (
@@ -201,7 +241,7 @@ export function SetupDetectionPanel() {
>
) : (
- No supported framework detected. Use pnpm setup or edit{" "}
+ No supported site type detected. Run pnpm setup or edit{" "}
sourcedraft.config.json manually.
)}
@@ -222,7 +262,10 @@ export function SetupDetectionPanel() {
)}
{report.configPreviewSummary && !report.configExists && (
- {report.configPreviewSummary}
+
+ Preview config values before writing
+ {report.configPreviewSummary}
+
)}
@@ -241,8 +284,8 @@ export function SetupDetectionPanel() {
{report.configExists && (
- sourcedraft.config.json already exists. Edit it manually instead of
- generating a new file.
+ sourcedraft.config.json already exists — it will not be
+ overwritten. Edit it manually if paths need changing.
)}
@@ -266,8 +309,8 @@ export function SetupDetectionPanel() {
{!report.safeToApply && report.primary && (
- Review detection results before applying. Low confidence or warnings require manual
- confirmation.
+ Review detection results before applying. Low confidence or warnings
+ require manual confirmation.
)}
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) => (
{
event.preventDefault();
}}
@@ -182,6 +301,7 @@ export function EditorToolbar({
aria-label="Insert internal link"
title="Internal link"
aria-expanded={internalLinkOpen}
+ disabled={richDisabled}
onMouseDown={(event) => {
event.preventDefault();
}}
@@ -200,6 +320,8 @@ export function EditorToolbar({
: "editor-toolbar__button"
}
aria-pressed={editorMode === "rich"}
+ aria-label="Rich text mode"
+ title="Rich text mode"
onClick={() => onModeChange("rich")}
>
Rich
@@ -212,6 +334,8 @@ export function EditorToolbar({
: "editor-toolbar__button"
}
aria-pressed={editorMode === "source"}
+ aria-label="Source mode"
+ title="Source mode — edit raw Markdown or MDX"
onClick={() => onModeChange("source")}
>
Source
diff --git a/apps/studio/src/editor/SourceDraftEditor.tsx b/apps/studio/src/editor/SourceDraftEditor.tsx
index cdccef5..f3e2e00 100644
--- a/apps/studio/src/editor/SourceDraftEditor.tsx
+++ b/apps/studio/src/editor/SourceDraftEditor.tsx
@@ -4,6 +4,7 @@ import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
+import Underline from "@tiptap/extension-underline";
import {
useCallback,
useEffect,
@@ -25,12 +26,16 @@ import {
type SlashCommandItem,
} from "./slashCommands.js";
import { SlashCommandMenu } from "./SlashCommandMenu.js";
-import { EditorToolbar } from "./EditorToolbar.js";
+import { EditorToolbar, type LatestMediaUpload } from "./EditorToolbar.js";
+
+export type { LatestMediaUpload };
type SourceDraftEditorProps = {
body: string;
latestImagePath: string | null;
+ latestUpload: LatestMediaUpload | null;
imageAlt: string;
+ mediaUploadAvailable: boolean;
posts: PostSummary[];
editingPath: string | null;
fieldError?: string;
@@ -49,7 +54,9 @@ type SlashMenuState = {
export function SourceDraftEditor({
body,
latestImagePath,
+ latestUpload,
imageAlt,
+ mediaUploadAvailable,
posts,
editingPath,
fieldError,
@@ -110,6 +117,7 @@ export function SourceDraftEditor({
horizontalRule: false,
}),
HorizontalRule,
+ Underline,
Link.configure({
openOnClick: false,
autolink: false,
@@ -214,10 +222,14 @@ export function SourceDraftEditor({
case "image": {
const path =
latestImagePath?.trim() ||
+ (latestUpload?.kind === "image" ? latestUpload.publicPath : "") ||
window.prompt("Image path (public URL or repo path)", "/images/")?.trim() ||
"";
if (path.length > 0) {
- editor.chain().focus().setImage({ src: path, alt: imageAlt, title: imageAlt }).run();
+ const alt =
+ window.prompt("Alt text (for accessibility)", imageAlt)?.trim() ||
+ imageAlt;
+ editor.chain().focus().setImage({ src: path, alt, title: alt }).run();
}
break;
}
@@ -255,7 +267,7 @@ export function SourceDraftEditor({
syncBodyFromEditor(editor);
setSlashMenu(null);
},
- [editor, imageAlt, insertInternalLink, latestImagePath, posts, syncBodyFromEditor],
+ [editor, imageAlt, insertInternalLink, latestImagePath, latestUpload, posts, syncBodyFromEditor],
);
useEffect(() => {
@@ -309,7 +321,9 @@ export function SourceDraftEditor({
editorMode={editorMode}
bodyFieldId={bodyFieldId}
latestImagePath={latestImagePath}
+ latestUpload={latestUpload}
imageAlt={imageAlt}
+ mediaUploadAvailable={mediaUploadAvailable}
posts={posts}
editingPath={editingPath}
onBodyChange={onBodyChange}
diff --git a/apps/studio/src/editor/markdownRoundtrip.test.ts b/apps/studio/src/editor/markdownRoundtrip.test.ts
index 1e3139d..4566c89 100644
--- a/apps/studio/src/editor/markdownRoundtrip.test.ts
+++ b/apps/studio/src/editor/markdownRoundtrip.test.ts
@@ -16,11 +16,14 @@ describe("markdownRoundtrip", () => {
assert.equal(nodes[2]?.attrs?.level, 3);
});
- it("round-trips bold, italic, and links", () => {
- const markdown = "Hello **bold** and *italic* with [a link](https://example.com).";
+ it("round-trips bold, italic, strike, underline, and links", () => {
+ const markdown =
+ "Hello **bold**, *italic*, ~~strike~~, underline , and [a link](https://example.com).";
const serialized = serializeMarkdownNodes(parseMarkdownSegment(markdown));
assert.match(serialized, /\*\*bold\*\*/u);
assert.match(serialized, /\*italic\*/u);
+ assert.match(serialized, /~~strike~~/u);
+ assert.match(serialized, /underline<\/u>/iu);
assert.match(serialized, /\[a link\]\(https:\/\/example.com\)/u);
});
diff --git a/apps/studio/src/editor/markdownRoundtrip.ts b/apps/studio/src/editor/markdownRoundtrip.ts
index 47ee40e..4a3f388 100644
--- a/apps/studio/src/editor/markdownRoundtrip.ts
+++ b/apps/studio/src/editor/markdownRoundtrip.ts
@@ -5,6 +5,8 @@ type InlineToken =
| { type: "text"; value: string }
| { type: "bold"; value: string }
| { type: "italic"; value: string }
+ | { type: "strike"; value: string }
+ | { type: "underline"; value: string }
| { type: "code"; value: string }
| { type: "link"; text: string; href: string }
| { type: "image"; alt: string; src: string };
@@ -50,6 +52,20 @@ function parseInline(text: string): InlineToken[] {
continue;
}
+ const strikeMatch = text.slice(index).match(/^~~([^~]+)~~/u);
+ if (strikeMatch) {
+ tokens.push({ type: "strike", value: strikeMatch[1] ?? "" });
+ index += strikeMatch[0].length;
+ continue;
+ }
+
+ const underlineMatch = text.slice(index).match(/^([^<]+)<\/u>/iu);
+ if (underlineMatch) {
+ tokens.push({ type: "underline", value: underlineMatch[1] ?? "" });
+ index += underlineMatch[0].length;
+ continue;
+ }
+
const italicMatch = text.slice(index).match(/^\*([^*]+)\*/u);
if (italicMatch) {
tokens.push({ type: "italic", value: italicMatch[1] ?? "" });
@@ -101,6 +117,24 @@ function inlineTokensToMarks(tokens: InlineToken[]): JSONContent[] {
continue;
}
+ if (token.type === "strike") {
+ nodes.push({
+ type: "text",
+ text: token.value,
+ marks: [{ type: "strike" }],
+ });
+ continue;
+ }
+
+ if (token.type === "underline") {
+ nodes.push({
+ type: "text",
+ text: token.value,
+ marks: [{ type: "underline" }],
+ });
+ continue;
+ }
+
if (token.type === "code") {
nodes.push({
type: "text",
@@ -277,6 +311,14 @@ function serializeInlineNode(node: JSONContent): string {
value = `*${value}*`;
}
+ if (marks.some((mark) => mark.type === "strike")) {
+ value = `~~${value}~~`;
+ }
+
+ if (marks.some((mark) => mark.type === "underline")) {
+ value = `${value} `;
+ }
+
const linkMark = marks.find((mark) => mark.type === "link");
if (linkMark?.attrs?.href) {
value = `[${text}](${String(linkMark.attrs.href)})`;
diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css
index 2d5f55b..0e1c24a 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -594,6 +594,14 @@ code {
font-style: italic;
}
+.editor-toolbar__button[aria-label="Underline"] {
+ text-decoration: underline;
+}
+
+.editor-toolbar__button[aria-label="Strikethrough"] {
+ text-decoration: line-through;
+}
+
.editor-toolbar__button[aria-label="Inline code"],
.editor-toolbar__button[aria-label="Code block"] {
font-family: var(--font-mono);
@@ -1831,7 +1839,70 @@ select.field__input:focus-visible {
}
.login-screen__panel {
- width: min(100%, 420px);
+ width: min(100%, 720px);
+}
+
+.login-screen__choices {
+ padding: 0 14px 8px;
+}
+
+.login-screen__choices-title {
+ margin: 0 0 12px;
+ font-size: var(--text-sm);
+ font-weight: 600;
+}
+
+.login-screen__choice-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.login-screen__choice-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+}
+
+.login-screen__choice-card--active {
+ border-color: var(--accent-muted);
+ box-shadow: 0 0 0 1px var(--accent-muted);
+}
+
+.login-screen__choice-title {
+ margin: 0;
+ font-size: var(--text-sm);
+ font-weight: 600;
+}
+
+.login-screen__choice-body {
+ margin: 0;
+ flex: 1;
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.login-screen__choice-action {
+ align-self: flex-start;
+}
+
+.login-screen__choice-detail {
+ margin: 10px 0 0;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: var(--bg-raised);
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ line-height: 1.5;
}
.login-screen__form {
@@ -2043,6 +2114,17 @@ select.field__input:focus-visible {
padding: 0 12px;
}
+.setup-detection__next-action {
+ margin: 0 0 12px;
+ padding: 0 12px;
+ font-size: var(--text-sm);
+ line-height: 1.5;
+}
+
+.setup-detection__preview-wrap {
+ margin: 0 12px 12px;
+}
+
.setup-detection__subtitle {
font-size: var(--text-sm);
margin: 0 0 6px;
diff --git a/apps/studio/src/lib/articleForm.ts b/apps/studio/src/lib/articleForm.ts
index 816d37c..04c69a8 100644
--- a/apps/studio/src/lib/articleForm.ts
+++ b/apps/studio/src/lib/articleForm.ts
@@ -1,6 +1,9 @@
+import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "@sourcedraft/config";
import type { ArticleInput } from "@sourcedraft/core";
import { createSlug } from "@sourcedraft/core";
+const DEFAULT_FORM_CATEGORY = DEFAULT_SOURCEDRAFT_CATEGORIES[0] ?? "AI-Assisted Publishing";
+
export type ArticleFormState = {
title: string;
slug: string;
@@ -22,7 +25,7 @@ export type ArticleFormState = {
};
export function createInitialFormState(
- defaultCategory = "Guides",
+ defaultCategory: string = DEFAULT_FORM_CATEGORY,
): ArticleFormState {
return {
title: "",
@@ -137,7 +140,7 @@ function dateField(value: unknown): string {
export function articleInputToFormState(
input: ArticleInput,
- defaultCategory = "Guides",
+ defaultCategory: string = DEFAULT_FORM_CATEGORY,
): ArticleFormState {
const pubDate = dateField(input.pubDate);
diff --git a/apps/studio/src/lib/studioConfig.ts b/apps/studio/src/lib/studioConfig.ts
index f0b4bbb..4fc3211 100644
--- a/apps/studio/src/lib/studioConfig.ts
+++ b/apps/studio/src/lib/studioConfig.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "@sourcedraft/config";
import type { PublishMode } from "@sourcedraft/publishers";
export type StudioConfig = {
@@ -23,7 +24,7 @@ export const FALLBACK_STUDIO_CONFIG: StudioConfig = {
mediaDir: "src/assets/images",
publicMediaPath: "/images",
defaultBranch: "main",
- categories: ["Guides", "Notes", "Reviews", "Tutorials", "Reference"],
+ categories: [...DEFAULT_SOURCEDRAFT_CATEGORIES],
githubOwner: "",
githubRepo: "",
publisher: "github",
diff --git a/docs/configuration.md b/docs/configuration.md
index 1458f33..7ab8897 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -59,7 +59,13 @@ Example:
"publicMediaPath": "/images",
"defaultBranch": "main",
"publisher": "github",
- "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"]
+ "categories": [
+ "AI-Assisted Publishing",
+ "Workflow Automation",
+ "Content Pipelines",
+ "CMS Integrations",
+ "Developer Tooling"
+ ]
}
```
diff --git a/docs/demo-mode.md b/docs/demo-mode.md
index a2df330..c00d1e5 100644
--- a/docs/demo-mode.md
+++ b/docs/demo-mode.md
@@ -4,8 +4,9 @@ Demo mode lets you explore SourceDraft Studio without GitHub credentials. It is
## How to enable
-1. **Environment flag:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env` and restart the API, or
-2. **Opt-in:** leave `GITHUB_TOKEN`, `GITHUB_OWNER`, and `GITHUB_REPO` unset and click **Explore demo mode** on the sign-in screen.
+1. **Sign-in screen:** click **Explore demo mode** on the **Try demo mode** card — sample posts only, nothing is published, or
+2. **Environment flag:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env` and restart the API, or
+3. **Opt-in:** leave `GITHUB_TOKEN`, `GITHUB_OWNER`, and `GITHUB_REPO` unset and use **Explore demo mode**.
Start Studio with:
diff --git a/docs/editor-parity.md b/docs/editor-parity.md
new file mode 100644
index 0000000..9012dca
--- /dev/null
+++ b/docs/editor-parity.md
@@ -0,0 +1,53 @@
+# Editor parity
+
+Honest comparison of writing-tool expectations. SourceDraft targets a professional editor feel while keeping Markdown/MDX as the source of truth.
+
+| Feature | WordPress | Blogger | Medium | Google Docs / Word | Notion | SourceDraft |
+|---------|-----------|---------|--------|-------------------|--------|-------------|
+| Rich formatting | Shipped | Shipped | Shipped | Shipped | Shipped | **Shipped** (toolbar) |
+| Headings | Shipped | Partial | Shipped | Shipped | Shipped | **Shipped** (H1–H3) |
+| Lists | Shipped | Shipped | Shipped | Shipped | Shipped | **Shipped** |
+| Links | Shipped | Shipped | Shipped | Shipped | Shipped | **Shipped** |
+| Images | Shipped | Shipped | Shipped | Shipped | Shipped | **Shipped** (upload + insert) |
+| Attachments | Shipped | Limited | Not really | Shipped | Shipped | **Partial** (PDF/file links via upload) |
+| Tables | Shipped | Limited | Limited | Shipped | Shipped | **Planned** (Source mode only today) |
+| Code blocks | Partial | No | Partial | No | Shipped | **Shipped** |
+| Embeds / video | Shipped | Shipped | Shipped | Partial | Shipped | **Not planned** in toolbar (Source mode) |
+| Markdown/MDX source | No | No | No | No | Partial export | **Shipped** |
+| Preview generated output | Partial | Partial | Shipped | No | Partial | **Shipped** |
+| Publish checklist | No | No | No | No | No | **Shipped** |
+| Git-owned content | No | No | No | No | No | **Shipped** |
+| Comments / collaboration | Shipped | Shipped | Partial | Shipped | Shipped | **Not planned** (MVP) |
+| Track changes | Plugins | No | No | Shipped | Partial | **Not planned** |
+| AI writing | Plugins | No | Partial | Shipped | Shipped | **Not shipped** (future agent workflows) |
+
+## Where SourceDraft fits
+
+SourceDraft is closer to **Medium + Git** than to WordPress hosting. You get a focused writing surface, validation, preview of the file your site will receive, and publish to your own repository or CMS — not a hosted website builder.
+
+**Strengths today**
+
+- Structured article fields (title, description, SEO, categories)
+- Rich editor with source mode for MDX safety
+- Media upload to git or Cloudinary (when configured)
+- Content quality warnings and publish checklist
+- Demo mode for safe exploration
+
+**Honest gaps**
+
+- No real-time collaboration or comments
+- No built-in AI, Agent API, or MCP
+- Attachment support is link-based (not embedded file viewers)
+- Tables and complex embeds need Source mode
+- Publishing requires server-side setup (not one-click for non-technical users)
+
+## Status key
+
+| Label | Meaning |
+|-------|---------|
+| Shipped | Works in current Studio |
+| Partial | Limited or link-only |
+| Planned | On roadmap, not implemented |
+| Not planned | Out of scope for current MVP |
+
+See [project-status.md](project-status.md) and [roadmap.md](roadmap.md) for ongoing work.
diff --git a/docs/editor.md b/docs/editor.md
index fa3b77e..fc7ead5 100644
--- a/docs/editor.md
+++ b/docs/editor.md
@@ -2,11 +2,27 @@
Studio uses a **Tiptap**-powered body editor with a formatting toolbar and slash commands. The article body remains a **Markdown/MDX string** in app state — preview, autosave, outline, and publish all use that string.
-## Rich mode
+## Rich mode toolbar
-- Toolbar: headings, bold, italic, lists, blockquote, code block, link, image, horizontal rule
-- Slash commands: `/h1`, `/h2`, `/h3`, `/quote`, `/code`, `/image`, `/hr`, `/link`, `/internal`, `/callout`
-- Unknown MDX JSX blocks render as locked placeholders in rich mode (not deleted)
+Formatting controls include:
+
+- **Undo / redo**
+- **Headings** — H1, H2, H3
+- **Inline** — bold, italic, underline, strikethrough, inline code
+- **Blocks** — bullet list, numbered list, blockquote, code block, horizontal rule
+- **Insert** — link, internal link, image (with alt text prompt), attachment link (PDF/file)
+- **Table** — shown as disabled; use Source mode for Markdown table syntax
+- **Mode** — Rich / Source toggle
+
+Upload an image or PDF in **Post details → Media**, then use **Image** or **Attach** in the toolbar to insert at the cursor. Attachments insert as normal Markdown links (`[filename](/path/to/file.pdf)`).
+
+Underline serializes as HTML `… ` in the body string. Strikethrough uses `~~text~~`.
+
+## Slash commands
+
+`/h1`, `/h2`, `/h3`, `/quote`, `/code`, `/image`, `/hr`, `/link`, `/internal`, `/callout`
+
+Unknown MDX JSX blocks render as locked placeholders in rich mode (not deleted).
## Source mode
@@ -14,7 +30,7 @@ Toggle **Source** in the editor toolbar to edit raw Markdown/MDX in a textarea.
- Posts contain custom MDX components
- Rich mode cannot round-trip complex syntax cleanly
-- You need exact whitespace or frontmatter-adjacent body text
+- You need exact whitespace or GitHub-flavored table syntax
Switching back to rich mode re-parses the body string. Complex MDX may appear as non-editable blocks.
@@ -22,6 +38,8 @@ Switching back to rich mode re-parses the body string. Complex MDX may appear as
- Custom markdown serializer — not full CommonMark; nested or unusual markdown may not round-trip perfectly in rich mode
- No collaborative editing, comments, or cloud sync
+- Tables are not editable in rich mode yet
+- Video embeds are not supported in the toolbar — paste embeds in Source mode if your site allows them
- Internal link slash command inserts the first loaded post when the picker is not opened manually
-See also: [content-qa.md](content-qa.md) · [design-notes.md](design-notes.md)
+See also: [editor-parity.md](editor-parity.md) · [content-qa.md](content-qa.md) · [design-notes.md](design-notes.md)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 0dd1285..cc03111 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -74,7 +74,9 @@ pnpm dev
Starts the editor and publish API (default API port `8787`). Use this command, not `dev:web` alone, or publish and uploads will fail.
-Sign in with `SOURCEDRAFT_ADMIN_PASSWORD`.
+Sign in with `SOURCEDRAFT_ADMIN_PASSWORD` — the Studio password from your server `.env` file, not a cloud account.
+
+The sign-in screen offers five paths: **Try demo mode**, write in an already-configured Studio, connect an existing blog (setup detection after sign-in), advanced developer setup, and agent-ready workflow positioning (future AI/automation — not shipped today).
**MVP password auth is intended for local/private use.** Do not expose Studio on the public internet without extra hardening.
diff --git a/docs/non-technical-overview.md b/docs/non-technical-overview.md
index 001541e..ee75275 100644
--- a/docs/non-technical-overview.md
+++ b/docs/non-technical-overview.md
@@ -2,6 +2,8 @@
SourceDraft is a writing tool for blogs whose posts live as files in Git (Astro, Hugo, Next.js, …) or on platforms like WordPress and Ghost.
+**SourceDraft is not a hosted website builder.** It runs on a computer or server you (or your technical contact) control. Your public site still builds and deploys the same way as before.
+
## The problem it solves
Each post is usually one file or one API record: metadata (title, date, category) plus your article text. That is reliable, but everyday writing can mean:
@@ -14,42 +16,52 @@ SourceDraft keeps writing, preview, and publish in one interface.
## What you do in Studio
-1. Sign in with the admin password (set once by whoever installed SourceDraft)
-2. Write your post — title, description, category, tags, body, optional SEO fields
-3. Upload cover and inline images (to your git repo or Cloudinary, depending on setup)
-4. Preview the exact output file or fields SourceDraft will send
-5. Publish when validation passes
+1. **Sign in** with the Studio password (set once by whoever installed SourceDraft — not a cloud account)
+2. **Write** your post — title, description, category, tags, body, optional SEO fields
+3. **Add images and files** — upload in Post details, then insert them in the editor toolbar
+4. **Preview** the exact output file or fields SourceDraft will send
+5. **Publish** when validation passes and setup is complete
+
+If publish is disabled, that usually means setup is incomplete — not that you did something wrong. You can still draft and preview.
+
+**Try demo mode** on the sign-in screen to explore with sample posts. Nothing is published; no GitHub account is needed.
There are no traffic charts, billing screens, or account tiers.
## What SourceDraft does not do
- Host or serve your public website
-- Replace Astro, Hugo, or your current site builder
+- Replace Astro, Hugo, WordPress, or your current site builder
- Provide WordPress-style comments, plugins, or full media library for remote CMS targets
- Manage team accounts or OAuth login (one shared password today)
+- Include built-in AI writing, Agent API, or MCP (future possibilities only)
After publish to a **git** target, your normal site build and deploy runs unchanged. After publish to **WordPress/Ghost**, content appears in that CMS — your static site is unaffected unless you wire something else.
-## How publishing works
+## How publishing works (plain language)
SourceDraft does not log into GitHub in your browser. When you publish:
1. Your article is checked on the server
-2. The adapter turns it into Markdown/MDX (for preview and git publishers) or structured fields (for CMS APIs)
-3. A secure token in `.env` (never shown in the page) commits the file or calls the remote API
+2. SourceDraft turns it into the right file format for your site
+3. A secure connection (configured by your technical contact) sends the file to GitHub, GitLab, WordPress, Ghost, or similar
-Without publisher credentials, you can still write and preview — publish stays disabled or runs in **demo mode**.
+Without that setup, you can still write and preview — publish stays disabled or runs in **demo mode**.
-## Two kinds of settings
+## Settings for writers vs technical contacts
-**`sourcedraft.config.json`** — where posts go, which categories appear, which adapter and publisher. Safe to share or commit.
+**Safe to share:** where posts go, which categories appear — usually in a config file your contact maintains.
-**`.env`** — password, API tokens, repository targets, optional Cloudinary or deploy hook. Private; never commit.
+**Private (never in the browser):** passwords and API keys live in a server `.env` file.
Your technical contact can run **`pnpm setup`** once or edit files manually. Writers typically only need the Studio address and password.
-In Studio **Settings**, **Setup health** and **Compatibility & status** show whether configuration looks complete (without showing secrets).
+In Studio **Settings**:
+
+- **Setup detection** — scans your project and suggests where articles and images should go
+- **Setup health** — shows whether configuration looks complete (without showing secrets)
+
+Advanced terms like *adapter*, *publisher*, and *frontmatter* appear in developer docs and Settings details — not on the main writing screen.
## Compared to other tools
@@ -59,6 +71,9 @@ In Studio **Settings**, **Setup health** and **Compatibility & status** show whe
| TinaCMS | File-first with adapters; no Tina Cloud required |
| WordPress admin | Optional publisher only — not a full WP replacement |
| GitHub web editor | Validated fields, preview, media upload, SEO panel |
+| Medium / Google Docs | Similar writing toolbar feel, but output is your Git-owned Markdown/MDX |
+
+See [editor-parity.md](editor-parity.md) for a feature comparison.
## Who sets it up?
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 081d32c..c43cb5a 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -60,9 +60,16 @@ Deliberately out of scope in the current phase:
- Hosted or multi-tenant Studio
- Plugin marketplace
- AI writing tools
+- Agent API, BYOK AI providers, MCP support, and automation endpoints
- Site hosting or running your static-site build
- Large UI redesigns
+## Future: agent-ready publishing workflows
+
+SourceDraft's structured article schema, validation, preview, and publish checklist make it a natural base for AI-assisted workflows where external agents prepare drafts and humans review before publishing.
+
+Agent API, BYOK AI providers, MCP support, and automation endpoints are **future work**, not current shipped features.
+
## Influence the roadmap
Open an issue with the `feature_request`, `adapter_request`, or
diff --git a/docs/setup-detection.md b/docs/setup-detection.md
index 983498f..a5b7718 100644
--- a/docs/setup-detection.md
+++ b/docs/setup-detection.md
@@ -23,7 +23,9 @@ For each match it suggests:
- **Frontmatter hints** — reads a few sample posts and lists common fields (with mapping to Studio’s universal schema)
- **Categories** — inferred from existing post frontmatter when available
-Plain-language copy in Studio summarizes what was found, for example: “We found a Hugo project… Posts live in `content/posts`… We recommend the Hugo Markdown adapter.”
+Plain-language copy in Studio summarizes what was found for git-backed, AI-assisted publishing workflows, for example: “We found a Hugo project… 8 post files in `content/posts`… We recommend the Hugo Markdown adapter for automation-friendly Markdown output.”
+
+Default categories (when not inferred from posts) focus on AI-assisted publishing, workflow automation, content pipelines, CMS integrations, and developer tooling.
## API
diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md
index 2afbceb..f9705cb 100644
--- a/docs/setup-wizard.md
+++ b/docs/setup-wizard.md
@@ -15,7 +15,7 @@ On start, the wizard scans your project folder (same rules as [setup detection](
- Adapter choice
- Content and media directories
- Default git branch
-- Categories (from sample post frontmatter when found)
+- Categories (from sample post frontmatter when found, otherwise AI-assisted publishing / workflow automation defaults)
You will be asked about:
diff --git a/packages/config/src/defaultCategories.ts b/packages/config/src/defaultCategories.ts
new file mode 100644
index 0000000..6721983
--- /dev/null
+++ b/packages/config/src/defaultCategories.ts
@@ -0,0 +1,11 @@
+/** Default Studio categories for AI-assisted publishing and automation workflows. */
+export const DEFAULT_SOURCEDRAFT_CATEGORIES = [
+ "AI-Assisted Publishing",
+ "Workflow Automation",
+ "Content Pipelines",
+ "CMS Integrations",
+ "Developer Tooling",
+] as const;
+
+export const DEFAULT_SOURCEDRAFT_CATEGORIES_CSV =
+ DEFAULT_SOURCEDRAFT_CATEGORIES.join(", ");
diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts
index c83336d..892737a 100644
--- a/packages/config/src/index.ts
+++ b/packages/config/src/index.ts
@@ -4,6 +4,11 @@ export {
resolveConfigPath,
} from "./loadConfig.js";
+export {
+ DEFAULT_SOURCEDRAFT_CATEGORIES,
+ DEFAULT_SOURCEDRAFT_CATEGORIES_CSV,
+} from "./defaultCategories.js";
+
export {
DEFAULT_SOURCEDRAFT_CONFIG,
type SourceDraftConfig,
diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts
index 1ba18b9..155f34a 100644
--- a/packages/config/src/types.ts
+++ b/packages/config/src/types.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "./defaultCategories.js";
import { derivePublicMediaPath } from "./publicMediaPath.js";
export type SourceDraftConfig = {
@@ -27,5 +28,5 @@ export const DEFAULT_SOURCEDRAFT_CONFIG: SourceDraftConfig = {
mediaDir: "src/assets/images",
publicMediaPath: derivePublicMediaPath("src/assets/images"),
defaultBranch: "main",
- categories: ["Guides", "Notes", "Reviews", "Tutorials", "Reference"],
+ categories: [...DEFAULT_SOURCEDRAFT_CATEGORIES],
};
diff --git a/packages/setup/src/createConfigFromDetection.test.ts b/packages/setup/src/createConfigFromDetection.test.ts
index e49d9ee..b92a321 100644
--- a/packages/setup/src/createConfigFromDetection.test.ts
+++ b/packages/setup/src/createConfigFromDetection.test.ts
@@ -22,7 +22,7 @@ const sampleSuggestion: SetupDetectionSuggestion = {
frontmatter: {
postsSampled: 2,
fields: [{ key: "title", frequency: 2, universalField: "title" }],
- suggestedCategories: ["Guides", "News"],
+ suggestedCategories: ["AI-Assisted Publishing", "Workflow Automation"],
},
};
@@ -39,7 +39,7 @@ describe("generateConfigFromDetection", () => {
const config = JSON.parse(readFileSync(result.configPath, "utf8")) as Record;
assert.equal(config.adapter, "astro-mdx");
assert.equal(config.contentDir, "src/content/blog");
- assert.deepEqual(config.categories, ["Guides", "News"]);
+ assert.deepEqual(config.categories, ["AI-Assisted Publishing", "Workflow Automation"]);
});
it("does not overwrite an existing config file", () => {
diff --git a/packages/setup/src/createConfigFromDetection.ts b/packages/setup/src/createConfigFromDetection.ts
index 6d77e29..4bd3521 100644
--- a/packages/setup/src/createConfigFromDetection.ts
+++ b/packages/setup/src/createConfigFromDetection.ts
@@ -1,5 +1,6 @@
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
+import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "@sourcedraft/config";
import type { SetupDetectionSuggestion } from "./detectSetup.js";
import { buildConfigWriteSummary } from "./onboardingCopy.js";
@@ -18,14 +19,6 @@ export type GenerateConfigFailure = {
export type GenerateConfigResult = GenerateConfigSuccess | GenerateConfigFailure;
-const DEFAULT_CATEGORIES = [
- "Guides",
- "Notes",
- "Reviews",
- "Tutorials",
- "Reference",
-];
-
export function buildConfigFromSuggestion(
suggestion: SetupDetectionSuggestion,
): Record {
@@ -33,7 +26,7 @@ export function buildConfigFromSuggestion(
suggestion.frontmatter?.suggestedCategories &&
suggestion.frontmatter.suggestedCategories.length > 0
? suggestion.frontmatter.suggestedCategories
- : DEFAULT_CATEGORIES;
+ : [...DEFAULT_SOURCEDRAFT_CATEGORIES];
return {
adapter: suggestion.adapter,
diff --git a/packages/setup/src/detectSetup.test.ts b/packages/setup/src/detectSetup.test.ts
index 44ac56c..88248ee 100644
--- a/packages/setup/src/detectSetup.test.ts
+++ b/packages/setup/src/detectSetup.test.ts
@@ -27,6 +27,7 @@ describe("detectSetup", () => {
assert.ok((result.primary?.postFileCount ?? 0) >= 1);
assert.ok((result.primary?.confidence ?? 0) >= 70);
assert.ok(result.onboardingMessage?.includes("Astro"));
+ assert.ok(result.onboardingMessage?.includes("AI-assisted"));
});
it("detects Next.js MDX projects", () => {
diff --git a/packages/setup/src/detectSetup.ts b/packages/setup/src/detectSetup.ts
index d3dff55..68fbffd 100644
--- a/packages/setup/src/detectSetup.ts
+++ b/packages/setup/src/detectSetup.ts
@@ -1,6 +1,6 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
-import { derivePublicMediaPath } from "@sourcedraft/config";
+import { DEFAULT_SOURCEDRAFT_CATEGORIES, derivePublicMediaPath } from "@sourcedraft/config";
import { detectContentRoot } from "./contentRootDetection.js";
import {
inferFrontmatterSchema,
@@ -537,7 +537,7 @@ export function buildSuggestedConfigSnippet(
suggestion.frontmatter?.suggestedCategories &&
suggestion.frontmatter.suggestedCategories.length > 0
? suggestion.frontmatter.suggestedCategories
- : ["Guides", "Notes", "Reviews", "Tutorials", "Reference"];
+ : [...DEFAULT_SOURCEDRAFT_CATEGORIES];
return JSON.stringify(
{
diff --git a/packages/setup/src/inferFrontmatterSchema.test.ts b/packages/setup/src/inferFrontmatterSchema.test.ts
index 151563e..6fe86f4 100644
--- a/packages/setup/src/inferFrontmatterSchema.test.ts
+++ b/packages/setup/src/inferFrontmatterSchema.test.ts
@@ -12,12 +12,12 @@ describe("inferFrontmatterSchema", () => {
mkdirSync(join(root, contentDir), { recursive: true });
writeFileSync(
join(root, contentDir, "one.md"),
- "---\ntitle: One\ndate: 2026-01-01\ntags: [a, b]\ncategories: Guides\ndraft: true\n---\nBody\n",
+ "---\ntitle: One\ndate: 2026-01-01\ntags: [a, b]\ncategories: Workflow Automation\ndraft: true\n---\nBody\n",
"utf8",
);
writeFileSync(
join(root, contentDir, "two.md"),
- "---\ntitle: Two\ndate: 2026-01-02\ntags: [c]\ncategories: Guides\n---\nBody\n",
+ "---\ntitle: Two\ndate: 2026-01-02\ntags: [c]\ncategories: Workflow Automation\n---\nBody\n",
"utf8",
);
@@ -26,6 +26,6 @@ describe("inferFrontmatterSchema", () => {
assert.equal(schema?.postsSampled, 2);
assert.ok(schema?.fields.some((field) => field.key === "title"));
assert.ok(schema?.fields.some((field) => field.key === "date" && field.universalField === "pubDate"));
- assert.deepEqual(schema?.suggestedCategories, ["Guides"]);
+ assert.deepEqual(schema?.suggestedCategories, ["Workflow Automation"]);
});
});
diff --git a/packages/setup/src/onboardingCopy.ts b/packages/setup/src/onboardingCopy.ts
index 0670a4a..ce23d3e 100644
--- a/packages/setup/src/onboardingCopy.ts
+++ b/packages/setup/src/onboardingCopy.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_SOURCEDRAFT_CATEGORIES_CSV } from "@sourcedraft/config";
import type { SetupDetectionResult, SetupDetectionSuggestion } from "./detectSetup.js";
import type { InferredFrontmatterSchema } from "./inferFrontmatterSchema.js";
@@ -26,7 +27,7 @@ function adapterLabel(adapter: string): string {
function formatFieldHints(frontmatter: InferredFrontmatterSchema | null | undefined): string {
if (!frontmatter || frontmatter.fields.length === 0) {
- return "We could not read frontmatter from sample posts yet.";
+ return "We could not read frontmatter from sample posts yet — add a draft with title, date, and tags to train your automation workflow.";
}
const topFields = frontmatter.fields
@@ -40,7 +41,7 @@ function formatFieldHints(frontmatter: InferredFrontmatterSchema | null | undefi
})
.join(", ");
- return `From ${frontmatter.postsSampled} sample post(s), common frontmatter fields include: ${topFields}.`;
+ return `From ${frontmatter.postsSampled} sample post(s), common frontmatter fields include: ${topFields}. Studio maps these for AI-assisted and automated publishing workflows.`;
}
export function buildOnboardingMessage(
@@ -51,14 +52,21 @@ export function buildOnboardingMessage(
const adapter = adapterLabel(suggestion.adapter);
const postsLine =
suggestion.postFileCount > 0
- ? `We found ${suggestion.postFileCount} post file(s) in \`${suggestion.contentDir}\`.`
- : `Posts are expected in \`${suggestion.contentDir}\` (no sample posts found yet).`;
+ ? `We found ${suggestion.postFileCount} post file(s) in \`${suggestion.contentDir}\` — ready for git-backed publish pipelines and CMS automation.`
+ : `Posts are expected in \`${suggestion.contentDir}\` (no sample posts found yet). Add content to wire up deploy hooks and workflow tooling.`;
+
+ const categoriesLine =
+ suggestion.frontmatter?.suggestedCategories &&
+ suggestion.frontmatter.suggestedCategories.length > 0
+ ? `Suggested categories from your content: ${suggestion.frontmatter.suggestedCategories.join(", ")}.`
+ : `Default categories cover ${DEFAULT_SOURCEDRAFT_CATEGORIES_CSV}.`;
return [
- `We found a ${framework} project at \`${result.scannedRoot}\`.`,
+ `We found a ${framework} project at \`${result.scannedRoot}\` suited to AI-assisted publishing.`,
postsLine,
- `We recommend the ${adapter} adapter.`,
+ `We recommend the ${adapter} adapter for Markdown/MDX output compatible with automation tools and static deploy workflows.`,
formatFieldHints(suggestion.frontmatter),
+ categoriesLine,
"Click Generate config to create sourcedraft.config.json with these values, or adjust them first.",
].join(" ");
}
@@ -66,13 +74,13 @@ export function buildOnboardingMessage(
export function buildOnboardingFailureMessage(result: SetupDetectionResult): string {
if (result.warnings.length > 0) {
return [
- "SourceDraft could not confidently detect your site setup.",
+ "SourceDraft could not confidently detect your publishing stack.",
result.warnings.join(" "),
- "You can still run pnpm setup or edit sourcedraft.config.json manually.",
+ "You can still run pnpm setup, point SourceDraft at a Hugo/Astro/Next.js repo manually, or edit sourcedraft.config.json for a custom automation workflow.",
].join(" ");
}
- return "SourceDraft could not detect a supported framework. Run pnpm setup or configure sourcedraft.config.json manually.";
+ return "SourceDraft could not detect a supported framework for git-backed publishing. Run pnpm setup or configure sourcedraft.config.json manually for your CMS and automation toolchain.";
}
export function buildConfigWriteSummary(
@@ -90,6 +98,7 @@ export function buildConfigWriteSummary(
`mediaDir: ${String(config.mediaDir ?? "")}`,
`defaultBranch: ${String(config.defaultBranch ?? "")}`,
categories.length > 0 ? `categories: ${categories}` : "",
+ "publisher: github (edit in .env via pnpm setup for GitLab, WordPress, or Ghost)",
]
.filter((line) => line.length > 0)
.join("\n");
diff --git a/packages/setup/src/wizard.ts b/packages/setup/src/wizard.ts
index fa47675..e826d26 100644
--- a/packages/setup/src/wizard.ts
+++ b/packages/setup/src/wizard.ts
@@ -3,7 +3,10 @@ import { resolve } from "node:path";
import { createInterface, type Interface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { listAdapterIds } from "@sourcedraft/adapters";
-import { derivePublicMediaPath } from "@sourcedraft/config";
+import {
+ DEFAULT_SOURCEDRAFT_CATEGORIES_CSV,
+ derivePublicMediaPath,
+} from "@sourcedraft/config";
import { listMediaProviderIds } from "@sourcedraft/media-providers";
import { listPublisherIds } from "@sourcedraft/publishers";
import {
@@ -233,7 +236,7 @@ export async function runWizard(options: WizardOptions = {}): Promise 0
? detectedSuggestion.frontmatter.suggestedCategories.join(", ")
- : "Guides, Notes, Reviews, Tutorials, Reference";
+ : DEFAULT_SOURCEDRAFT_CATEGORIES_CSV;
const categoriesRaw = await askText(
rl,
"Default categories (comma-separated)",
diff --git a/sourcedraft.config.example.json b/sourcedraft.config.example.json
index 9c1a44d..dc5b40e 100644
--- a/sourcedraft.config.example.json
+++ b/sourcedraft.config.example.json
@@ -5,7 +5,13 @@
"mediaDir": "public/images",
"publicMediaPath": "/images",
"defaultBranch": "main",
- "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"],
+ "categories": [
+ "AI-Assisted Publishing",
+ "Workflow Automation",
+ "Content Pipelines",
+ "CMS Integrations",
+ "Developer Tooling"
+ ],
"adapterOptions": {},
"publisherOptions": {},
"plugins": [],
From dbedfeba63426b11d801c77f04519d6f7a65a36f Mon Sep 17 00:00:00 2001
From: bnz183
Date: Fri, 12 Jun 2026 17:12:04 +0200
Subject: [PATCH 3/3] fix: keep Studio browser bundle free of node:fs config
imports
Use browser-safe default categories in the client, align e2e with demo
fixtures and toolbar labels, and lock the underline extension dependency.
---
README.md | 2 +-
apps/studio/e2e/smoke.spec.ts | 4 ++--
apps/studio/src/lib/articleForm.ts | 4 ++--
apps/studio/src/lib/defaultCategories.ts | 8 ++++++++
apps/studio/src/lib/studioConfig.ts | 4 ++--
pnpm-lock.yaml | 3 +++
6 files changed, 18 insertions(+), 7 deletions(-)
create mode 100644 apps/studio/src/lib/defaultCategories.ts
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 b56db99..b913aa4 100644
--- a/apps/studio/e2e/smoke.spec.ts
+++ b/apps/studio/e2e/smoke.spec.ts
@@ -26,7 +26,7 @@ test.describe("Studio smoke", () => {
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 }) => {
@@ -131,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/src/lib/articleForm.ts b/apps/studio/src/lib/articleForm.ts
index 04c69a8..2429ca3 100644
--- a/apps/studio/src/lib/articleForm.ts
+++ b/apps/studio/src/lib/articleForm.ts
@@ -1,8 +1,8 @@
-import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "@sourcedraft/config";
import type { ArticleInput } from "@sourcedraft/core";
import { createSlug } from "@sourcedraft/core";
+import { DEFAULT_STUDIO_CATEGORIES } from "./defaultCategories.js";
-const DEFAULT_FORM_CATEGORY = DEFAULT_SOURCEDRAFT_CATEGORIES[0] ?? "AI-Assisted Publishing";
+const DEFAULT_FORM_CATEGORY = DEFAULT_STUDIO_CATEGORIES[0] ?? "Guides";
export type ArticleFormState = {
title: string;
diff --git a/apps/studio/src/lib/defaultCategories.ts b/apps/studio/src/lib/defaultCategories.ts
new file mode 100644
index 0000000..f76af41
--- /dev/null
+++ b/apps/studio/src/lib/defaultCategories.ts
@@ -0,0 +1,8 @@
+/** Browser-safe default categories (mirrors @sourcedraft/config defaults). */
+export const DEFAULT_STUDIO_CATEGORIES = [
+ "AI-Assisted Publishing",
+ "Workflow Automation",
+ "Content Pipelines",
+ "CMS Integrations",
+ "Developer Tooling",
+] as const;
diff --git a/apps/studio/src/lib/studioConfig.ts b/apps/studio/src/lib/studioConfig.ts
index 4fc3211..0b5aea9 100644
--- a/apps/studio/src/lib/studioConfig.ts
+++ b/apps/studio/src/lib/studioConfig.ts
@@ -1,5 +1,5 @@
-import { DEFAULT_SOURCEDRAFT_CATEGORIES } from "@sourcedraft/config";
import type { PublishMode } from "@sourcedraft/publishers";
+import { DEFAULT_STUDIO_CATEGORIES } from "./defaultCategories.js";
export type StudioConfig = {
adapter: string;
@@ -24,7 +24,7 @@ export const FALLBACK_STUDIO_CONFIG: StudioConfig = {
mediaDir: "src/assets/images",
publicMediaPath: "/images",
defaultBranch: "main",
- categories: [...DEFAULT_SOURCEDRAFT_CATEGORIES],
+ categories: [...DEFAULT_STUDIO_CATEGORIES],
githubOwner: "",
githubRepo: "",
publisher: "github",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7c32945..683461e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -77,6 +77,9 @@ importers:
'@tiptap/extension-placeholder':
specifier: ^3.26.0
version: 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))
+ '@tiptap/extension-underline':
+ specifier: ^3.26.0
+ version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/pm':
specifier: ^3.26.0
version: 3.26.0