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 && ( + + )} + + {report.configExists && ( +

    + sourcedraft.config.json already exists. Edit it manually instead of + generating a new file. +

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

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

    - )} - {copyStatus && ( -

    - {copyStatus} -

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

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

    + )} + + {copyStatus && ( +

    + {copyStatus} +

    + )} + + {generateStatus && ( +

    + {generateStatus} +

    + )} +
    )}
diff --git a/apps/studio/src/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. -![Diagram of the write-preview-publish flow](/images/workflow-diagram.png) +![Diagram of write → preview → commit → build automation](/images/workflow-diagram.png) -## Cover images +## Hero images -Set a hero image in Post details or reference a path from the media library. +Set a hero image in Post details or pick a path from the media library after upload. -## Inline images +## Inline assets in automated builds -Use the toolbar or paste Markdown like the example above. +Use the editor toolbar or Markdown syntax. Your site generator and CDN workflow treat these like any other static asset in the repo. `, }, { summary: { path: `${DEMO_CONTENT_DIR}/linking-and-outline.mdx`, - title: "Linking and document outline", + title: "CMS integrations and internal linking", slug: "linking-and-outline", pubDate: "2026-05-20", - category: "Tutorials", + category: "CMS Integrations", draft: false, }, content: `--- -title: Linking and document outline -description: Sample post with headings, internal links, and outline-friendly structure. +title: CMS integrations and internal linking +description: Structure long-form technical articles with headings, internal links, and outline navigation for editorial automation. pubDate: 2026-05-20 -category: Tutorials +category: CMS Integrations tags: - - links - - outline + - cms + - internal-links + - editorial-workflow draft: false --- -# Linking and document outline +# CMS integrations and internal linking -Use headings to structure long articles. The document outline panel lists H1–H3 sections. +Use headings to structure articles that feed search, RSS, and AI summarization tools. The document outline lists H1–H3 sections for quick navigation. -## Internal links +## Internal links between posts -Link to other demo posts with the Internal toolbar action or Markdown syntax: +Link to other demo posts with the Internal toolbar action or Markdown: -- [Getting started with SourceDraft](/getting-started-with-sourcedraft) -- [Publishing with images](/publishing-with-images) +- [AI-assisted publishing with SourceDraft](/getting-started-with-sourcedraft) +- [Content pipelines with media uploads](/publishing-with-images) -## External links +## External references -External URLs work as usual: [Markdown guide](https://www.markdownguide.org/). +Automation stacks often combine git CMS with external docs: [Markdown guide](https://www.markdownguide.org/). -### Subsections +### Subsections for tooling docs -Smaller headings help readers scan technical content without extra UI chrome. +Smaller headings help readers scan integration guides without extra UI chrome — useful when content is syndicated to help centers or AI assistants. `, }, ]; diff --git a/apps/studio/server/demoFixtures.test.ts b/apps/studio/server/demoFixtures.test.ts index 8737417..b7a5a93 100644 --- a/apps/studio/server/demoFixtures.test.ts +++ b/apps/studio/server/demoFixtures.test.ts @@ -46,7 +46,7 @@ describe("demo fixtures", () => { assert.equal(draft?.summary.draft, true); assert.equal(guide?.summary.draft, false); assert.match(images?.content ?? "", /!\[[^\]]*\]\([^)]+\)/u); - assert.match(links?.content ?? "", /\[Getting started with SourceDraft\]/u); + assert.match(links?.content ?? "", /\[AI-assisted publishing with SourceDraft\]/u); assert.match(links?.content ?? "", /^## /mu); }); diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 0c005af..08485eb 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -12,6 +12,7 @@ import { PublishGate } from "./components/PublishGate"; import { RestoreDraftBanner } from "./components/RestoreDraftBanner"; import { SettingsPanel } from "./components/SettingsPanel"; import { WritingCanvas } from "./components/WritingCanvas"; +import type { LatestMediaUpload } from "./editor/SourceDraftEditor"; import { useDocumentAutosave } from "./hooks/useDocumentAutosave"; import { enterDemo, @@ -78,6 +79,9 @@ function App() { const [latestUploadedImagePath, setLatestUploadedImagePath] = useState< string | null >(null); + const [latestUpload, setLatestUpload] = useState( + null, + ); const articleInput = useMemo(() => formStateToArticleInput(form), [form]); const validation = useMemo( @@ -135,7 +139,7 @@ function App() { } return { - ...createInitialFormState(config.categories[0] ?? "Guides"), + ...createInitialFormState(config.categories[0] ?? "AI-Assisted Publishing"), slug: current.slug, }; }); @@ -158,6 +162,8 @@ function App() { [studioConfig.githubOwner, studioConfig.githubRepo], ); + const mediaUploadAvailable = githubReady || demoMode; + const normalizedArticle = useMemo(() => { if (!validation.valid) { return null; @@ -305,6 +311,13 @@ function App() { setForm((current) => ({ ...current, slug: slugFromTitle(current.title) })); } + function handleUploadSuccess(upload: LatestMediaUpload) { + setLatestUpload(upload); + if (upload.kind === "image") { + setLatestUploadedImagePath(upload.publicPath); + } + } + function handleUseHeroImage(publicPath: string) { setForm((current) => ({ ...current, heroImage: publicPath })); } @@ -341,7 +354,7 @@ function App() { const loadedForm = articleInputToFormState( result.article, - studioConfig.categories[0] ?? "Guides", + studioConfig.categories[0] ?? "AI-Assisted Publishing", ); setForm(loadedForm); setSlugAuto(false); @@ -546,6 +559,8 @@ function App() { editingPath={editingPath} draft={form.draft} latestImagePath={latestUploadedImagePath} + latestUpload={latestUpload} + mediaUploadAvailable={mediaUploadAvailable} posts={posts} fieldErrors={fieldErrors} onTitleChange={(value) => handleFieldChange("title", value)} @@ -603,7 +618,7 @@ function App() { onUseHeroImage={handleUseHeroImage} onInsertImage={handleInsertImage} onInsertPdfLink={handleInsertPdfLink} - onUploadSuccess={setLatestUploadedImagePath} + onUploadSuccess={handleUploadSuccess} /> )} diff --git a/apps/studio/src/components/LoginScreen.tsx b/apps/studio/src/components/LoginScreen.tsx index 9863f11..23ea66a 100644 --- a/apps/studio/src/components/LoginScreen.tsx +++ b/apps/studio/src/components/LoginScreen.tsx @@ -8,6 +8,39 @@ type LoginScreenProps = { onEnterDemo: () => Promise<{ ok: boolean; error?: string }>; }; +const ONBOARDING_CHOICES = [ + { + id: "demo", + title: "Try demo mode", + body: "Explore SourceDraft with sample posts. Nothing is published.", + action: "demo" as const, + }, + { + id: "studio", + title: "Write in an already-configured Studio", + body: "Use the Studio link and password from the person who set this up.", + action: "sign-in" as const, + }, + { + id: "connect", + title: "Connect an existing blog", + body: "SourceDraft can inspect your project and suggest where articles and images should go.", + action: "info" as const, + }, + { + id: "developer", + title: "Advanced developer setup", + body: "Use config files, adapters, publishers, and environment variables.", + action: "info" as const, + }, + { + id: "agent", + title: "Agent-ready workflow", + body: "SourceDraft is built around structured drafts, validation, preview, and human review, so future AI agents and automation tools can fit into the publishing flow.", + action: "info" as const, + }, +]; + export function LoginScreen({ configured, demoAvailable, @@ -19,6 +52,9 @@ export function LoginScreen({ const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [enteringDemo, setEnteringDemo] = useState(false); + const [activeChoice, setActiveChoice] = useState( + demoAvailable ? "demo" : "studio", + ); async function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -55,7 +91,7 @@ export function LoginScreen({

SourceDraft Studio

-

Sign in to write and publish

+

Write, preview, and publish Markdown or MDX

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

+ How would you like to start? +

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

    {choice.title}

    +

    {choice.body}

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

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

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

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

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

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

+ )} +
+

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

{!configured && !demoAvailable && (

Password not configured

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

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

GitHub not configured

+

Publishing not configured yet

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

)}
); diff --git a/apps/studio/src/components/MediaDropzone.tsx b/apps/studio/src/components/MediaDropzone.tsx index 748bd70..b3af05b 100644 --- a/apps/studio/src/components/MediaDropzone.tsx +++ b/apps/studio/src/components/MediaDropzone.tsx @@ -5,13 +5,14 @@ import { clientMediaKindForFile, uploadMedia, } from "../lib/media"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; type MediaDropzoneProps = { githubReady: boolean; onUseAsHero: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; onUploaded?: () => void; }; @@ -84,9 +85,11 @@ export function MediaDropzone({ kind: result.kind, filename: file.name, }); - if (result.kind === "image") { - onUploadSuccess?.(result.publicPath); - } + onUploadSuccess?.({ + publicPath: result.publicPath, + filename: file.name, + kind: result.kind, + }); onUploaded?.(); } diff --git a/apps/studio/src/components/MediaSection.tsx b/apps/studio/src/components/MediaSection.tsx index 1357612..b087e64 100644 --- a/apps/studio/src/components/MediaSection.tsx +++ b/apps/studio/src/components/MediaSection.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; import { MediaDropzone } from "./MediaDropzone"; import { MediaLibrary } from "./MediaLibrary"; @@ -7,7 +8,7 @@ type MediaSectionProps = { onUseAsHero: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; }; export function MediaSection({ diff --git a/apps/studio/src/components/PostDetailsPanel.tsx b/apps/studio/src/components/PostDetailsPanel.tsx index d02889a..c4d4149 100644 --- a/apps/studio/src/components/PostDetailsPanel.tsx +++ b/apps/studio/src/components/PostDetailsPanel.tsx @@ -1,6 +1,7 @@ import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; import type { PostSummary } from "../lib/posts"; +import type { LatestMediaUpload } from "../editor/SourceDraftEditor"; import { ContentQualityPanel } from "./ContentQualityPanel"; import { MediaSection } from "./MediaSection"; import { SeoSharingPanel } from "./SeoSharingPanel"; @@ -21,7 +22,7 @@ type PostDetailsPanelProps = { onUseHeroImage: (publicPath: string) => void; onInsertImage: (publicPath: string) => void; onInsertPdfLink: (publicPath: string, filename: string) => void; - onUploadSuccess?: (publicPath: string) => void; + onUploadSuccess?: (upload: LatestMediaUpload) => void; }; export function PostDetailsPanel({ diff --git a/apps/studio/src/components/SetupDetectionPanel.tsx b/apps/studio/src/components/SetupDetectionPanel.tsx index 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) => (