From b38aceca04e4d18845f3fc53960ab723b819257c Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 15:32:36 +0200 Subject: [PATCH] feat: add setup detection and content qa --- apps/studio/e2e/smoke.spec.ts | 13 + apps/studio/server/contentAuditHandler.ts | 85 +++ apps/studio/server/index.ts | 26 + apps/studio/server/posts.ts | 106 +--- apps/studio/server/setupDetection.test.ts | 23 + apps/studio/server/setupDetection.ts | 57 +++ apps/studio/src/App.tsx | 4 + .../src/components/ContentAuditPanel.tsx | 139 +++++ .../src/components/ContentQualityPanel.tsx | 15 +- .../src/components/PostDetailsPanel.tsx | 9 +- .../src/components/PublishChecklist.tsx | 70 +++ apps/studio/src/components/PublishGate.tsx | 35 +- apps/studio/src/components/SettingsPanel.tsx | 4 + .../src/components/SetupDetectionPanel.tsx | 167 ++++++ apps/studio/src/index.css | 129 +++++ apps/studio/src/lib/contentAudit.ts | 48 ++ apps/studio/src/lib/contentQuality.test.ts | 35 ++ apps/studio/src/lib/contentQuality.ts | 154 +++++- apps/studio/src/lib/publishChecklist.test.ts | 55 ++ apps/studio/src/lib/publishChecklist.ts | 133 +++++ apps/studio/src/lib/setupDetection.ts | 34 ++ packages/setup/src/contentAudit.test.ts | 105 ++++ packages/setup/src/contentAudit.ts | 439 ++++++++++++++++ packages/setup/src/detectSetup.test.ts | 131 +++++ packages/setup/src/detectSetup.ts | 482 ++++++++++++++++++ packages/setup/src/frontmatter.ts | 108 ++++ packages/setup/src/index.ts | 27 + packages/setup/src/mdxComplexity.ts | 22 + 28 files changed, 2530 insertions(+), 125 deletions(-) create mode 100644 apps/studio/server/contentAuditHandler.ts create mode 100644 apps/studio/server/setupDetection.test.ts create mode 100644 apps/studio/server/setupDetection.ts create mode 100644 apps/studio/src/components/ContentAuditPanel.tsx create mode 100644 apps/studio/src/components/PublishChecklist.tsx create mode 100644 apps/studio/src/components/SetupDetectionPanel.tsx create mode 100644 apps/studio/src/lib/contentAudit.ts create mode 100644 apps/studio/src/lib/publishChecklist.test.ts create mode 100644 apps/studio/src/lib/publishChecklist.ts create mode 100644 apps/studio/src/lib/setupDetection.ts create mode 100644 packages/setup/src/contentAudit.test.ts create mode 100644 packages/setup/src/contentAudit.ts create mode 100644 packages/setup/src/detectSetup.test.ts create mode 100644 packages/setup/src/detectSetup.ts create mode 100644 packages/setup/src/frontmatter.ts create mode 100644 packages/setup/src/mdxComplexity.ts diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts index dcaed6b..fc1808a 100644 --- a/apps/studio/e2e/smoke.spec.ts +++ b/apps/studio/e2e/smoke.spec.ts @@ -61,11 +61,24 @@ test.describe("Studio smoke", () => { test("settings setup health renders", async ({ page }) => { await enterDemoMode(page); await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("heading", { name: "Setup detection" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Content audit" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible(); await expect(page.getByText("Admin password")).toBeVisible(); await expect(page.getByText("GitHub token (server-side)")).toBeVisible(); }); + test("publish checklist renders in demo mode", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await postTitleInput(page).fill("Checklist smoke test"); + await postDescriptionInput(page).fill("Summary for checklist smoke test."); + await fillPostBody(page, "# Checklist\n\nBody content."); + await expect(page.getByRole("heading", { name: "Publish checklist" })).toBeVisible(); + await expect(page.getByText("Validation")).toBeVisible(); + await expect(page.getByText("Output path")).toBeVisible(); + }); + test("publish success can be simulated in demo mode", async ({ page }) => { await enterDemoMode(page); await page.getByRole("button", { name: "New post" }).click(); diff --git a/apps/studio/server/contentAuditHandler.ts b/apps/studio/server/contentAuditHandler.ts new file mode 100644 index 0000000..42345a6 --- /dev/null +++ b/apps/studio/server/contentAuditHandler.ts @@ -0,0 +1,85 @@ +import type { AdapterId } from "@sourcedraft/adapters"; +import { + buildContentAuditReport, + type ContentAuditReport, +} from "@sourcedraft/setup"; +import type { PublishEnvConfig } from "./config.js"; +import { getDemoPost, listDemoPosts } from "./demoStore.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; +import { normalizeContentDir, safePostPath } from "./postPaths.js"; +import { slugFromPath } from "./posts.js"; + +export type ContentAuditResponse = + | { ok: true; report: ContentAuditReport } + | { ok: false; error: string }; + +function readDemoAuditFiles(): { path: string; content: string }[] { + const files: { path: string; content: string }[] = []; + + for (const summary of listDemoPosts()) { + const stored = getDemoPost(summary.path); + if (stored !== null) { + files.push({ path: summary.path, content: stored.content }); + } + } + + return files; +} + +export function runDemoContentAudit( + adapter: AdapterId, + contentDir: string, +): { status: number; body: ContentAuditResponse } { + const files = readDemoAuditFiles(); + const report = buildContentAuditReport( + files, + adapter, + normalizeContentDir(contentDir), + slugFromPath, + ); + + return { + status: 200, + body: { ok: true, report }, + }; +} + +export async function runContentAudit( + env: PublishEnvConfig, +): Promise<{ status: number; body: ContentAuditResponse }> { + const contentDir = normalizeContentDir(env.contentDir); + const adapter = env.adapter as AdapterId; + + const publisher = createPublisherFromEnv(env); + const listed = await publisher.listPosts({ contentDir }); + + if (!listed.ok) { + return { + status: listed.status === 404 ? 404 : 502, + body: { ok: false, error: listed.error }, + }; + } + + const files: { path: string; content: string }[] = []; + + for (const file of listed.files) { + const safe = safePostPath(file.path, contentDir); + if (!safe.ok) { + continue; + } + + const loaded = await publisher.readPost({ path: safe.path }); + if (!loaded.ok) { + continue; + } + + files.push({ path: safe.path, content: loaded.content }); + } + + const report = buildContentAuditReport(files, adapter, contentDir, slugFromPath); + + return { + status: 200, + body: { ok: true, report }, + }; +} diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 7597eeb..a546914 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -24,7 +24,9 @@ import { listPosts, loadPost } from "./posts.js"; import { publishArticle, type PublishRequestBody } from "./publish.js"; import { requireSameSiteRequest } from "./requestProtection.js"; import { initializePlugins } from "./plugins.js"; +import { runContentAudit, runDemoContentAudit } from "./contentAuditHandler.js"; import { getSetupHealth } from "./setupHealth.js"; +import { runSetupDetection } from "./setupDetection.js"; import { apiLimiter, readLimiter, @@ -126,6 +128,30 @@ app.get("/api/health/setup", readLimiter, requireAuth, (_req, res) => { res.json(getSetupHealth()); }); +app.get("/api/setup/detect", readLimiter, requireAuth, (_req, res) => { + res.json(runSetupDetection()); +}); + +app.get("/api/content/audit", readLimiter, requireAuth, async (req, res) => { + const demoMode = isRequestDemoSession(req); + + if (demoMode) { + const runtime = loadPublicConfig(); + const result = runDemoContentAudit(runtime.adapter, runtime.contentDir); + res.status(result.status).json(result.body); + return; + } + + const envResult = loadPublishEnv(); + if (!envResult.ok) { + res.status(500).json({ ok: false, error: envResult.error }); + return; + } + + const result = await runContentAudit(envResult.config); + res.status(result.status).json(result.body); +}); + app.get("/api/posts", readLimiter, requireAuth, async (req, res) => { const demoMode = isRequestDemoSession(req); const pathParam = diff --git a/apps/studio/server/posts.ts b/apps/studio/server/posts.ts index 7af3072..1c0f8c7 100644 --- a/apps/studio/server/posts.ts +++ b/apps/studio/server/posts.ts @@ -6,6 +6,7 @@ import { validateArticle, type ArticleInput, } from "@sourcedraft/core"; +import { splitFrontmatter as splitFrontmatterFromSetup } from "@sourcedraft/setup"; import type { PublishEnvConfig } from "./config.js"; import { createPublisherFromEnv } from "./publisherRuntime.js"; import { normalizeContentDir, safePostPath } from "./postPaths.js"; @@ -36,113 +37,10 @@ export function slugFromPath(path: string): string { return slugFromFilename(filename); } -function parseScalar(value: string): string { - const trimmed = value.trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed - .slice(1, -1) - .replace(/\\"/gu, '"') - .replace(/\\n/gu, "\n") - .replace(/\\r/gu, "\r") - .replace(/\\t/gu, "\t"); - } - - return trimmed; -} - -function parseYamlValue(value: string): unknown { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return ""; - } - - if (trimmed === "true") { - return true; - } - - if (trimmed === "false") { - return false; - } - - if (trimmed === "null") { - return null; - } - - return parseScalar(trimmed); -} - -function parseFrontmatter(yaml: string): Record { - const result: Record = {}; - const lines = yaml.split("\n"); - let index = 0; - - while (index < lines.length) { - const line = lines[index] ?? ""; - - if (line.trim().length === 0 || line.trimStart().startsWith("#")) { - index += 1; - continue; - } - - if (/^tags:\s*\[\]\s*$/u.test(line)) { - result.tags = []; - index += 1; - continue; - } - - if (/^tags:\s*$/u.test(line)) { - const tags: string[] = []; - index += 1; - - while (index < lines.length && /^\s+-\s+/u.test(lines[index] ?? "")) { - const tagLine = lines[index] ?? ""; - tags.push(parseScalar(tagLine.replace(/^\s+-\s+/u, ""))); - index += 1; - } - - result.tags = tags; - continue; - } - - const match = line.match(/^([A-Za-z]+):\s*(.*)$/u); - if (match) { - const key = match[1]; - const value = match[2] ?? ""; - if (key !== undefined) { - result[key] = parseYamlValue(value); - } - index += 1; - continue; - } - - index += 1; - } - - return result; -} - export function splitFrontmatter( content: string, ): { frontmatter: Record; body: string } | null { - if (!content.startsWith("---\n")) { - return null; - } - - const closingIndex = content.indexOf("\n---\n", 4); - if (closingIndex === -1) { - return null; - } - - const yaml = content.slice(4, closingIndex); - const body = content.slice(closingIndex + 5); - - return { - frontmatter: parseFrontmatter(yaml), - body, - }; + return splitFrontmatterFromSetup(content); } export function frontmatterToArticleInput( diff --git a/apps/studio/server/setupDetection.test.ts b/apps/studio/server/setupDetection.test.ts new file mode 100644 index 0000000..9368286 --- /dev/null +++ b/apps/studio/server/setupDetection.test.ts @@ -0,0 +1,23 @@ +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"; + +describe("setup detection API helpers", () => { + it("detectSetup returns astro suggestion for astro markers", () => { + const root = mkdtempSync(join(tmpdir(), "api-detect-astro-")); + 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 result = detectSetup(root); + assert.equal(result.primary?.adapter, "astro-mdx"); + }); +}); diff --git a/apps/studio/server/setupDetection.ts b/apps/studio/server/setupDetection.ts new file mode 100644 index 0000000..eb6fb03 --- /dev/null +++ b/apps/studio/server/setupDetection.ts @@ -0,0 +1,57 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { + buildSuggestedConfigSnippet, + detectSetup, + isSafeToApplySuggestion, + type SetupDetectionResult, +} from "@sourcedraft/setup"; + +export type SetupDetectionResponse = SetupDetectionResult & { + safeToApply: boolean; + suggestedConfigSnippet: string | null; +}; + +function resolveDetectionRoot(): string { + const explicit = + process.env.SOURCEDRAFT_REPO_ROOT?.trim() || + process.env.CMS_REPO_ROOT?.trim(); + + if (explicit && existsSync(explicit)) { + return resolve(explicit); + } + + let dir = process.cwd(); + for (let depth = 0; depth < 6; depth += 1) { + if ( + existsSync(resolve(dir, "sourcedraft.config.json")) || + existsSync(resolve(dir, "package.json")) || + existsSync(resolve(dir, "astro.config.mjs")) || + existsSync(resolve(dir, "mkdocs.yml")) || + existsSync(resolve(dir, "hugo.toml")) + ) { + return dir; + } + + const parent = resolve(dir, ".."); + if (parent === dir) { + break; + } + + dir = parent; + } + + return process.cwd(); +} + +export function runSetupDetection(): SetupDetectionResponse { + const result = detectSetup(resolveDetectionRoot()); + const primary = result.primary; + + return { + ...result, + safeToApply: primary !== null && isSafeToApplySuggestion(primary), + suggestedConfigSnippet: + primary !== null ? buildSuggestedConfigSnippet(primary) : null, + }; +} diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 9209b9a..0c005af 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -579,6 +579,9 @@ function App() { outputPath={outputPath} prBranchPreview={prBranchPreview} prModeSupported={prModeSupported} + validationIssues={validation.issues} + formValues={form} + knownPostSlugs={posts.map((post) => post.slug)} onPublishModeChange={setPublishMode} onPublish={handlePublish} /> @@ -593,6 +596,7 @@ function App() { valid={validation.valid} issues={validation.issues} outputPath={outputPath} + posts={posts} onChange={handleFieldChange} onSlugManualEdit={handleSlugManualEdit} onSlugResync={handleSlugResync} diff --git a/apps/studio/src/components/ContentAuditPanel.tsx b/apps/studio/src/components/ContentAuditPanel.tsx new file mode 100644 index 0000000..1acbbb3 --- /dev/null +++ b/apps/studio/src/components/ContentAuditPanel.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { + fetchContentAudit, + type ContentAuditReport, +} from "../lib/contentAudit.js"; + +export function ContentAuditPanel() { + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchContentAudit().then((response) => { + if (response === null) { + setError("Could not reach the content audit API."); + } else if (!response.ok) { + setError(response.error); + } else { + setReport(response.report); + } + + setLoading(false); + }); + }, []); + + return ( +
+
+

+ Content audit +

+

+ Read-only scan of existing posts — files are never modified +

+
+ + {loading && ( +

+ Auditing content… +

+ )} + + {!loading && error && ( +

+ {error} +

+ )} + + {report && ( + <> +
+
+
Adapter
+
+ {report.adapter} +
+
+
+
Content directory
+
+ {report.contentDir} +
+
+
+
Valid posts
+
{report.summary.validCount}
+
+
+
Invalid posts
+
{report.summary.invalidCount}
+
+
+
Source-only (complex MDX)
+
{report.summary.sourceOnlyCount}
+
+
+
Ignored files
+
{report.summary.ignoredCount}
+
+
+ + {report.warnings.length > 0 && ( +
    + {report.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} + + {report.duplicateSlugs.length > 0 && ( +
+

Duplicate slugs

+
    + {report.duplicateSlugs.map((group) => ( +
  • + {group.slug} — {group.paths.join(", ")} +
  • + ))} +
+
+ )} + + {report.invalidPosts.length > 0 && ( +
+

Posts with issues

+
    + {report.invalidPosts.map((post) => ( +
  • + {post.title} ({post.path}) +
      + {post.issues.map((issue) => ( +
    • + {issue.message} +
    • + ))} +
    +
  • + ))} +
+
+ )} + + {report.sourceOnlyPosts.length > 0 && ( +
+

Source-only posts

+
    + {report.sourceOnlyPosts.map((post) => ( +
  • + {post.path} — complex MDX detected +
  • + ))} +
+
+ )} + + )} +
+ ); +} diff --git a/apps/studio/src/components/ContentQualityPanel.tsx b/apps/studio/src/components/ContentQualityPanel.tsx index 932c63f..560c7a2 100644 --- a/apps/studio/src/components/ContentQualityPanel.tsx +++ b/apps/studio/src/components/ContentQualityPanel.tsx @@ -1,11 +1,13 @@ import { useMemo } from "react"; import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; +import type { PostSummary } from "../lib/posts"; import { analyzeContentQuality } from "../lib/contentQuality.js"; type ContentQualityPanelProps = { values: ArticleFormState; validationIssues: ValidationIssue[]; + posts?: PostSummary[]; }; function metricLabel(value: string | number, suffix = ""): string { @@ -15,7 +17,13 @@ function metricLabel(value: string | number, suffix = ""): string { export function ContentQualityPanel({ values, validationIssues, + posts = [], }: ContentQualityPanelProps) { + const knownPostSlugs = useMemo( + () => posts.map((post) => post.slug), + [posts], + ); + const analysis = useMemo( () => analyzeContentQuality( @@ -24,10 +32,15 @@ export function ContentQualityPanel({ description: values.description, body: values.body, heroImage: values.heroImage, + metaTitle: values.metaTitle, + metaDescription: values.metaDescription, + socialImage: values.socialImage, + coverImageAlt: values.coverImageAlt, }, validationIssues, + { knownPostSlugs }, ), - [values, validationIssues], + [values, validationIssues, knownPostSlugs], ); const { metrics, warnings } = analysis; diff --git a/apps/studio/src/components/PostDetailsPanel.tsx b/apps/studio/src/components/PostDetailsPanel.tsx index c335674..d02889a 100644 --- a/apps/studio/src/components/PostDetailsPanel.tsx +++ b/apps/studio/src/components/PostDetailsPanel.tsx @@ -1,5 +1,6 @@ import type { ValidationIssue } from "@sourcedraft/core"; import type { ArticleFormState } from "../lib/articleForm"; +import type { PostSummary } from "../lib/posts"; import { ContentQualityPanel } from "./ContentQualityPanel"; import { MediaSection } from "./MediaSection"; import { SeoSharingPanel } from "./SeoSharingPanel"; @@ -13,6 +14,7 @@ type PostDetailsPanelProps = { valid: boolean; issues: ValidationIssue[]; outputPath: string | null; + posts?: PostSummary[]; onChange: (field: keyof ArticleFormState, value: string | boolean) => void; onSlugManualEdit: () => void; onSlugResync: () => void; @@ -31,6 +33,7 @@ export function PostDetailsPanel({ valid, issues, outputPath, + posts = [], onChange, onSlugManualEdit, onSlugResync, @@ -216,7 +219,11 @@ export function PostDetailsPanel({ validationIssues={issues} /> - +
+ buildPublishChecklist({ + valid, + issues, + values, + outputPath, + publishMode, + baseBranch, + prBranchPreview, + knownPostSlugs, + }), + [ + valid, + issues, + values, + outputPath, + publishMode, + baseBranch, + prBranchPreview, + knownPostSlugs, + ], + ); + + return ( +
+

+ Publish checklist +

+
    + {checklist.items.map((item) => ( +
  • + {item.label} + {item.value} +
  • + ))} +
+
+ ); +} diff --git a/apps/studio/src/components/PublishGate.tsx b/apps/studio/src/components/PublishGate.tsx index 053b30b..0e17d42 100644 --- a/apps/studio/src/components/PublishGate.tsx +++ b/apps/studio/src/components/PublishGate.tsx @@ -1,4 +1,7 @@ import type { PublishMode } from "@sourcedraft/publishers"; +import type { ValidationIssue } from "@sourcedraft/core"; +import type { ArticleFormState } from "../lib/articleForm"; +import { PublishChecklist } from "./PublishChecklist"; type PublishGateProps = { ready: boolean; @@ -14,6 +17,9 @@ type PublishGateProps = { outputPath: string | null; prBranchPreview: string | null; prModeSupported: boolean; + validationIssues: ValidationIssue[]; + formValues: ArticleFormState; + knownPostSlugs: string[]; onPublishModeChange: (mode: PublishMode) => void; onPublish: () => void; }; @@ -114,6 +120,9 @@ export function PublishGate({ outputPath, prBranchPreview, prModeSupported, + validationIssues, + formValues, + knownPostSlugs, onPublishModeChange, onPublish, }: PublishGateProps) { @@ -172,22 +181,16 @@ export function PublishGate({ )}
-
-
-
Target branch
-
{baseBranch}
-
-
-
Output path
-
{outputPath ?? "—"}
-
- {publishMode !== "direct" && ( -
-
PR branch
-
{prBranchPreview ?? "—"}
-
- )} -
+ {reason && (

diff --git a/apps/studio/src/components/SettingsPanel.tsx b/apps/studio/src/components/SettingsPanel.tsx index ca04f44..7daea40 100644 --- a/apps/studio/src/components/SettingsPanel.tsx +++ b/apps/studio/src/components/SettingsPanel.tsx @@ -1,5 +1,7 @@ import type { StudioConfig } from "../lib/studioConfig"; import { AdapterStatus } from "./AdapterStatus"; +import { ContentAuditPanel } from "./ContentAuditPanel"; +import { SetupDetectionPanel } from "./SetupDetectionPanel"; import { SetupHealthPanel } from "./SetupHealthPanel"; type SettingsPanelProps = { @@ -9,6 +11,8 @@ type SettingsPanelProps = { export function SettingsPanel({ config }: SettingsPanelProps) { return (

+ +
diff --git a/apps/studio/src/components/SetupDetectionPanel.tsx b/apps/studio/src/components/SetupDetectionPanel.tsx new file mode 100644 index 0000000..ae4ac24 --- /dev/null +++ b/apps/studio/src/components/SetupDetectionPanel.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from "react"; +import { + fetchSetupDetection, + type SetupDetectionReport, +} from "../lib/setupDetection.js"; + +export function SetupDetectionPanel() { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [copyStatus, setCopyStatus] = useState(null); + + useEffect(() => { + fetchSetupDetection().then((next) => { + setReport(next); + setLoading(false); + }); + }, []); + + async function handleCopyConfig(): Promise { + if (!report?.suggestedConfigSnippet) { + return; + } + + try { + await navigator.clipboard.writeText(report.suggestedConfigSnippet); + setCopyStatus("Suggested config copied to clipboard."); + } catch { + setCopyStatus("Could not copy to clipboard."); + } + } + + return ( +
+
+

+ Setup detection +

+

+ Scans local project files — does not write configuration automatically +

+
+ + {loading && ( +

+ Scanning project… +

+ )} + + {!loading && report === null && ( +

+ Could not run setup detection. Confirm the publish API is running. +

+ )} + + {report && ( + <> +

+ Scanned: {report.scannedRoot} +

+ + {report.warnings.length > 0 && ( +
    + {report.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} + + {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}
+
+
+ ) : ( +

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

+ )} + + {report.alternatives.length > 0 && ( +
+ Alternative matches ({report.alternatives.length}) +
    + {report.alternatives.map((candidate) => ( +
  • + {candidate.framework} — {candidate.confidence}% ( + {candidate.adapter}) +
  • + ))} +
+
+ )} + + {report.suggestedConfigSnippet && ( +
+ + {!report.safeToApply && ( +

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

+ )} + {copyStatus && ( +

+ {copyStatus} +

+ )} +
+ )} + + )} +
+ ); +} diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 8c5a0d3..28fe41d 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -1967,6 +1967,135 @@ select.field__input:focus-visible { line-height: 1.45; } +.setup-detection__loading, +.setup-detection__error, +.setup-detection__empty, +.content-audit__loading, +.content-audit__error { + margin: 0; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.setup-detection__root { + margin: 0 0 10px; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.setup-detection__warnings, +.content-audit__warnings { + margin: 0 0 12px; + padding-left: 18px; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.setup-detection__grid, +.content-audit__summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin: 0 0 12px; +} + +.setup-detection__grid > div, +.content-audit__summary > div { + display: grid; + gap: 2px; +} + +.setup-detection__grid dt, +.content-audit__summary dt { + margin: 0; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.setup-detection__grid dd, +.content-audit__summary dd { + margin: 0; + font-size: var(--text-xs); +} + +.setup-detection__explanation { + grid-column: 1 / -1; +} + +.setup-detection__alternatives { + margin-bottom: 12px; + font-size: var(--text-xs); +} + +.setup-detection__actions { + display: grid; + gap: 8px; +} + +.setup-detection__hint { + margin: 0; + font-size: 11px; + color: var(--text-muted); +} + +.content-audit__section h3 { + margin: 0 0 8px; + font-size: var(--text-xs); + font-weight: 700; +} + +.content-audit__posts { + margin: 0; + padding-left: 18px; + font-size: var(--text-xs); +} + +.publish-checklist { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} + +.publish-checklist__title { + margin: 0 0 8px; + font-size: var(--text-xs); + font-weight: 700; +} + +.publish-checklist__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} + +.publish-checklist__item { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 8px; + font-size: 11px; +} + +.publish-checklist__item--ok .publish-checklist__value { + color: var(--text-muted); +} + +.publish-checklist__item--warn .publish-checklist__value { + color: #8a6d1d; +} + +.publish-checklist__item--error .publish-checklist__value { + color: #9b2c2c; +} + +.publish-checklist__label { + font-weight: 600; +} + .compatibility-panel__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/apps/studio/src/lib/contentAudit.ts b/apps/studio/src/lib/contentAudit.ts new file mode 100644 index 0000000..d951f6a --- /dev/null +++ b/apps/studio/src/lib/contentAudit.ts @@ -0,0 +1,48 @@ +export type ContentAuditIssue = { + kind: string; + field?: string; + message: string; +}; + +export type ContentAuditPost = { + path: string; + slug: string; + title: string; + status: "valid" | "invalid" | "source-only"; + issues: ContentAuditIssue[]; +}; + +export type ContentAuditReport = { + adapter: string; + contentDir: string; + summary: { + totalFiles: number; + validCount: number; + invalidCount: number; + sourceOnlyCount: number; + ignoredCount: number; + }; + validPosts: ContentAuditPost[]; + invalidPosts: ContentAuditPost[]; + sourceOnlyPosts: ContentAuditPost[]; + duplicateSlugs: { slug: string; paths: string[] }[]; + ignoredFiles: { path: string; reason: string }[]; + warnings: string[]; +}; + +export type ContentAuditResponse = + | { ok: true; report: ContentAuditReport } + | { ok: false; error: string }; + +export async function fetchContentAudit(): Promise { + try { + const response = await fetch("/api/content/audit", { credentials: "include" }); + if (!response.ok) { + return { ok: false, error: `Audit request failed (${response.status}).` }; + } + + return (await response.json()) as ContentAuditResponse; + } catch { + return null; + } +} diff --git a/apps/studio/src/lib/contentQuality.test.ts b/apps/studio/src/lib/contentQuality.test.ts index 5b0ebb9..214475b 100644 --- a/apps/studio/src/lib/contentQuality.test.ts +++ b/apps/studio/src/lib/contentQuality.test.ts @@ -75,4 +75,39 @@ describe("content quality metrics", () => { assert.ok(result.warnings.some((warning) => warning.id === "multiple-h1")); }); + + it("warns about missing hero alt, short body, and broken internal links", () => { + const result = analyzeContentQuality( + { + title: "Post", + description: "Summary", + body: "Short post with [broken](/post/missing-slug/).", + heroImage: "/images/cover.png", + coverImageAlt: "", + }, + [], + { knownPostSlugs: ["existing-post"] }, + ); + + assert.ok(result.warnings.some((warning) => warning.id === "hero-alt-missing")); + assert.ok(result.warnings.some((warning) => warning.id === "body-short")); + assert.ok( + result.warnings.some((warning) => warning.id === "internal-links-broken"), + ); + }); + + it("warns when long articles have no H2 sections", () => { + const words = Array.from({ length: 420 }, (_, index) => `word${index}`).join(" "); + const result = analyzeContentQuality( + { + title: "Long post", + description: "Summary", + body: `# Title\n\n${words}`, + heroImage: "", + }, + [], + ); + + assert.ok(result.warnings.some((warning) => warning.id === "long-article-no-h2")); + }); }); diff --git a/apps/studio/src/lib/contentQuality.ts b/apps/studio/src/lib/contentQuality.ts index da8cea7..85e330e 100644 --- a/apps/studio/src/lib/contentQuality.ts +++ b/apps/studio/src/lib/contentQuality.ts @@ -1,4 +1,8 @@ import type { ValidationIssue } from "@sourcedraft/core"; +import { + META_DESCRIPTION_LENGTH_GUIDANCE, + META_TITLE_LENGTH_GUIDANCE, +} from "@sourcedraft/core"; import { analyzeDocumentOutline } from "./documentOutline.js"; export type ContentQualityInput = { @@ -6,8 +10,20 @@ export type ContentQualityInput = { description: string; body: string; heroImage: string; + metaTitle?: string; + metaDescription?: string; + socialImage?: string; + coverImageAlt?: string; }; +export type ContentQualityContext = { + knownPostSlugs?: string[]; +}; + +const LONG_ARTICLE_WORD_THRESHOLD = 400; +const SHORT_BODY_WORD_THRESHOLD = 100; +const EXTERNAL_LINK_WARN_THRESHOLD = 8; + export type LinkCounts = { internal: number; external: number; @@ -38,8 +54,8 @@ export type ContentQualityWarning = { }; const WORDS_PER_MINUTE = 200; -const TITLE_LENGTH_GUIDANCE = 60; -const DESCRIPTION_LENGTH_GUIDANCE = 160; +const TITLE_LENGTH_GUIDANCE = META_TITLE_LENGTH_GUIDANCE; +const DESCRIPTION_LENGTH_GUIDANCE = META_DESCRIPTION_LENGTH_GUIDANCE; const MARKDOWN_LINK_PATTERN = /(?, +): string[] { + const broken: string[] = []; + const pattern = new RegExp(MARKDOWN_LINK_PATTERN.source, "gu"); + + for (const match of body.matchAll(pattern)) { + const url = match[2] ?? ""; + if (isExternalUrl(url)) { + continue; + } + + const slug = normalizeInternalSlug(url); + if (slug !== null && knownSlugs.size > 0 && !knownSlugs.has(slug)) { + broken.push(url); + } + } + + return broken; +} + export function buildContentQualityWarnings( input: ContentQualityInput, metrics: ContentQualityMetrics, validationIssues: ValidationIssue[], + context: ContentQualityContext = {}, ): ContentQualityWarning[] { const warnings: ContentQualityWarning[] = []; const issueFields = new Set(validationIssues.map((issue) => issue.field)); @@ -218,6 +277,89 @@ export function buildContentQualityWarnings( }); } + const metaTitle = input.metaTitle?.trim() ?? ""; + if (metaTitle.length > META_TITLE_LENGTH_GUIDANCE) { + warnings.push({ + id: "meta-title-long", + kind: "info", + message: `Meta title is ${metaTitle.length} characters (guidance: ${META_TITLE_LENGTH_GUIDANCE}).`, + }); + } + + const metaDescription = input.metaDescription?.trim() ?? ""; + if (metaDescription.length > META_DESCRIPTION_LENGTH_GUIDANCE) { + warnings.push({ + id: "meta-description-long", + kind: "info", + message: `Meta description is ${metaDescription.length} characters (guidance: ${META_DESCRIPTION_LENGTH_GUIDANCE}).`, + }); + } + + const heroImage = input.heroImage.trim(); + const coverAlt = input.coverImageAlt?.trim() ?? ""; + if (heroImage.length > 0 && coverAlt.length === 0) { + warnings.push({ + id: "hero-alt-missing", + kind: "warn", + message: "Cover image is set but hero alt text is empty.", + }); + } + + const socialImage = input.socialImage?.trim() ?? ""; + if (heroImage.length === 0 && socialImage.length === 0) { + warnings.push({ + id: "social-image-missing", + kind: "info", + message: "No hero or social image set for sharing previews.", + }); + } + + if ( + metrics.wordCount >= LONG_ARTICLE_WORD_THRESHOLD && + outline.headings.length > 0 && + !outline.hasSubheading + ) { + warnings.push({ + id: "long-article-no-h2", + kind: "info", + message: `Article has ${metrics.wordCount} words but no H2 sections.`, + }); + } + + if ( + metrics.wordCount > 0 && + metrics.wordCount < SHORT_BODY_WORD_THRESHOLD + ) { + warnings.push({ + id: "body-short", + kind: "info", + message: `Body is only ${metrics.wordCount} words.`, + }); + } + + if (metrics.externalLinkCount > EXTERNAL_LINK_WARN_THRESHOLD) { + warnings.push({ + id: "external-links-many", + kind: "info", + message: `Body has ${metrics.externalLinkCount} external links.`, + }); + } + + const knownSlugs = new Set( + (context.knownPostSlugs ?? []).map((slug) => slug.trim()).filter(Boolean), + ); + const brokenLinks = findBrokenInternalLinks(input.body, knownSlugs); + if (brokenLinks.length > 0) { + warnings.push({ + id: "internal-links-broken", + kind: "warn", + message: + brokenLinks.length === 1 + ? `Internal link may not match a loaded post: ${brokenLinks[0]}` + : `${brokenLinks.length} internal links may not match loaded posts.`, + }); + } + if (input.body.trim().length === 0 && !issueFields.has("body")) { warnings.push({ id: "body-empty", @@ -232,12 +374,18 @@ export function buildContentQualityWarnings( export function analyzeContentQuality( input: ContentQualityInput, validationIssues: ValidationIssue[], + context: ContentQualityContext = {}, ): { metrics: ContentQualityMetrics; warnings: ContentQualityWarning[]; } { const metrics = buildContentQualityMetrics(input); - const warnings = buildContentQualityWarnings(input, metrics, validationIssues); + const warnings = buildContentQualityWarnings( + input, + metrics, + validationIssues, + context, + ); return { metrics, warnings }; } diff --git a/apps/studio/src/lib/publishChecklist.test.ts b/apps/studio/src/lib/publishChecklist.test.ts new file mode 100644 index 0000000..5c963c7 --- /dev/null +++ b/apps/studio/src/lib/publishChecklist.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createInitialFormState } from "./articleForm.js"; +import { buildPublishChecklist } from "./publishChecklist.js"; + +describe("publish checklist", () => { + it("includes validation, output path, and publish mode", () => { + const values = { + ...createInitialFormState("Guides"), + title: "Checklist post", + slug: "checklist-post", + description: "Summary", + body: "# Hello\n\nBody copy.", + draft: true, + }; + + const checklist = buildPublishChecklist({ + valid: true, + issues: [], + values, + outputPath: "src/content/blog/checklist-post.mdx", + publishMode: "pull-request", + baseBranch: "main", + prBranchPreview: "sourcedraft/checklist-post", + knownPostSlugs: ["other-post"], + }); + + const labels = checklist.items.map((item) => item.id); + assert.ok(labels.includes("validation")); + assert.ok(labels.includes("output-path")); + assert.ok(labels.includes("publish-mode")); + assert.ok(labels.includes("pr-branch")); + assert.ok(labels.includes("draft-status")); + assert.equal( + checklist.items.find((item) => item.id === "draft-status")?.value, + "Draft", + ); + }); + + it("marks validation errors in checklist", () => { + const checklist = buildPublishChecklist({ + valid: false, + issues: [{ field: "title", message: "Title is required." }], + values: createInitialFormState("Guides"), + outputPath: null, + publishMode: "direct", + baseBranch: "main", + prBranchPreview: null, + knownPostSlugs: [], + }); + + const validation = checklist.items.find((item) => item.id === "validation"); + assert.equal(validation?.status, "error"); + }); +}); diff --git a/apps/studio/src/lib/publishChecklist.ts b/apps/studio/src/lib/publishChecklist.ts new file mode 100644 index 0000000..58530d3 --- /dev/null +++ b/apps/studio/src/lib/publishChecklist.ts @@ -0,0 +1,133 @@ +import type { PublishMode } from "@sourcedraft/publishers"; +import type { ValidationIssue } from "@sourcedraft/core"; +import type { ArticleFormState } from "./articleForm"; +import { analyzeContentQuality } from "./contentQuality"; +import { analyzeSeoFields } from "./seoValidation"; + +export type PublishChecklistItem = { + id: string; + label: string; + value: string; + status: "ok" | "warn" | "error"; +}; + +export type PublishChecklist = { + items: PublishChecklistItem[]; + warningCount: number; +}; + +function publishModeLabel(mode: PublishMode): string { + if (mode === "direct") { + return "Direct commit"; + } + + if (mode === "draft-pull-request") { + return "Draft pull request"; + } + + return "Pull request"; +} + +export function buildPublishChecklist(input: { + valid: boolean; + issues: ValidationIssue[]; + values: ArticleFormState; + outputPath: string | null; + publishMode: PublishMode; + baseBranch: string; + prBranchPreview: string | null; + knownPostSlugs: string[]; +}): PublishChecklist { + const items: PublishChecklistItem[] = []; + const quality = analyzeContentQuality( + { + title: input.values.title, + description: input.values.description, + body: input.values.body, + heroImage: input.values.heroImage, + metaTitle: input.values.metaTitle, + metaDescription: input.values.metaDescription, + socialImage: input.values.socialImage, + coverImageAlt: input.values.coverImageAlt, + }, + input.issues, + { knownPostSlugs: input.knownPostSlugs }, + ); + const seo = analyzeSeoFields(input.values); + + items.push({ + id: "validation", + label: "Validation", + value: input.valid + ? "Required fields complete" + : `${input.issues.length} issue(s) to fix`, + status: input.valid ? "ok" : "error", + }); + + items.push({ + id: "output-path", + label: "Output path", + value: input.outputPath ?? "—", + status: input.outputPath ? "ok" : "warn", + }); + + items.push({ + id: "publish-mode", + label: "Publish mode", + value: publishModeLabel(input.publishMode), + status: "ok", + }); + + items.push({ + id: "target-branch", + label: input.publishMode === "direct" ? "Target branch" : "Base branch", + value: input.baseBranch, + status: "ok", + }); + + if (input.publishMode !== "direct") { + items.push({ + id: "pr-branch", + label: "PR branch", + value: input.prBranchPreview ?? "—", + status: input.prBranchPreview ? "ok" : "warn", + }); + } + + items.push({ + id: "draft-status", + label: "Draft status", + value: input.values.draft ? "Draft" : "Live", + status: "ok", + }); + + const mediaWarnings = quality.warnings.filter((warning) => + /image|alt|cover|social/iu.test(warning.message), + ); + items.push({ + id: "media", + label: "Media warnings", + value: + mediaWarnings.length === 0 + ? "None" + : `${mediaWarnings.length} warning(s)`, + status: mediaWarnings.length === 0 ? "ok" : "warn", + }); + + const seoWarnings = [ + ...seo.warnings.map((warning) => warning.message), + ...quality.warnings + .filter((warning) => /title|description|meta|social|link/iu.test(warning.message)) + .map((warning) => warning.message), + ]; + items.push({ + id: "seo", + label: "SEO warnings", + value: seoWarnings.length === 0 ? "None" : `${seoWarnings.length} warning(s)`, + status: seoWarnings.length === 0 ? "ok" : "warn", + }); + + const warningCount = items.filter((item) => item.status === "warn").length; + + return { items, warningCount }; +} diff --git a/apps/studio/src/lib/setupDetection.ts b/apps/studio/src/lib/setupDetection.ts new file mode 100644 index 0000000..bac6a08 --- /dev/null +++ b/apps/studio/src/lib/setupDetection.ts @@ -0,0 +1,34 @@ +export type SetupDetectionSuggestion = { + framework: string; + adapter: string; + contentDir: string; + mediaDir: string; + publicMediaPath: string; + defaultBranch: string; + confidence: number; + explanation: string; + warnings: string[]; +}; + +export type SetupDetectionReport = { + scannedRoot: string; + detected: boolean; + primary: SetupDetectionSuggestion | null; + alternatives: SetupDetectionSuggestion[]; + warnings: string[]; + safeToApply: boolean; + suggestedConfigSnippet: string | null; +}; + +export async function fetchSetupDetection(): Promise { + try { + const response = await fetch("/api/setup/detect", { credentials: "include" }); + if (!response.ok) { + return null; + } + + return (await response.json()) as SetupDetectionReport; + } catch { + return null; + } +} diff --git a/packages/setup/src/contentAudit.test.ts b/packages/setup/src/contentAudit.test.ts new file mode 100644 index 0000000..11efd31 --- /dev/null +++ b/packages/setup/src/contentAudit.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + auditPostFile, + buildContentAuditReport, +} from "./contentAudit.js"; +import { hasComplexMdx } from "./mdxComplexity.js"; + +const VALID_POST = `--- +title: Hello world +description: A valid summary +pubDate: 2024-06-01 +category: Guides +tags: + - one +draft: false +--- + +## Section + +Body text. +`; + +describe("content audit", () => { + it("accepts valid astro posts", () => { + const audited = auditPostFile( + { path: "src/content/blog/hello.mdx", content: VALID_POST }, + "astro-mdx", + ); + + assert.equal(audited.status, "valid"); + assert.equal(audited.slug, "hello"); + assert.equal(audited.issues.length, 0); + }); + + it("reports missing required frontmatter fields", () => { + const audited = auditPostFile( + { + path: "src/content/blog/incomplete.mdx", + content: "---\ntitle: Incomplete\n---\n\nBody", + }, + "astro-mdx", + ); + + assert.equal(audited.status, "invalid"); + assert.ok(audited.issues.some((issue) => issue.kind === "missing-field")); + }); + + it("detects duplicate slugs across files", () => { + const withSlug = VALID_POST.replace( + "title: Hello world", + "title: Hello world\nslug: shared-slug", + ); + const report = buildContentAuditReport( + [ + { path: "src/content/blog/a.mdx", content: withSlug }, + { + path: "src/content/blog/b.mdx", + content: withSlug.replace("Hello world", "Other title"), + }, + ], + "astro-mdx", + "src/content/blog", + ); + + assert.equal(report.duplicateSlugs.length, 1); + assert.equal(report.duplicateSlugs[0]?.slug, "shared-slug"); + }); + + it("flags complex MDX as source-only", () => { + const content = `${VALID_POST.replace("## Section", "Hi")}`; + assert.equal(hasComplexMdx(content), true); + + const audited = auditPostFile( + { path: "src/content/blog/mdx.mdx", content }, + "astro-mdx", + ); + + assert.equal(audited.status, "source-only"); + assert.ok(audited.issues.some((issue) => issue.kind === "complex-mdx")); + }); + + it("ignores non-markdown files in audit report", () => { + const report = buildContentAuditReport( + [{ path: "src/content/blog/readme.txt", content: "plain" }], + "astro-mdx", + "src/content/blog", + ); + + assert.equal(report.summary.ignoredCount, 1); + assert.equal(report.validPosts.length, 0); + }); + + it("reports invalid publication dates", () => { + const audited = auditPostFile( + { + path: "src/content/blog/bad-date.mdx", + content: VALID_POST.replace("pubDate: 2024-06-01", "pubDate: not-a-date"), + }, + "astro-mdx", + ); + + assert.ok(audited.issues.some((issue) => issue.kind === "invalid-date")); + }); +}); diff --git a/packages/setup/src/contentAudit.ts b/packages/setup/src/contentAudit.ts new file mode 100644 index 0000000..532b45a --- /dev/null +++ b/packages/setup/src/contentAudit.ts @@ -0,0 +1,439 @@ +import type { AdapterId } from "@sourcedraft/adapters"; +import { frontmatterToArticleInputWithSlug } from "@sourcedraft/adapters"; +import { validateArticle } from "@sourcedraft/core"; +import { splitFrontmatter } from "./frontmatter.js"; +import { hasComplexMdx } from "./mdxComplexity.js"; + +export type ContentAuditIssueKind = + | "missing-frontmatter" + | "missing-field" + | "unsupported-field" + | "invalid-date" + | "validation" + | "complex-mdx" + | "ignored-file"; + +export type ContentAuditIssue = { + kind: ContentAuditIssueKind; + field?: string; + message: string; +}; + +export type ContentAuditPost = { + path: string; + slug: string; + title: string; + status: "valid" | "invalid" | "source-only"; + issues: ContentAuditIssue[]; +}; + +export type DuplicateSlugGroup = { + slug: string; + paths: string[]; +}; + +export type ContentAuditSummary = { + totalFiles: number; + validCount: number; + invalidCount: number; + sourceOnlyCount: number; + ignoredCount: number; +}; + +export type ContentAuditReport = { + adapter: string; + contentDir: string; + summary: ContentAuditSummary; + validPosts: ContentAuditPost[]; + invalidPosts: ContentAuditPost[]; + sourceOnlyPosts: ContentAuditPost[]; + duplicateSlugs: DuplicateSlugGroup[]; + ignoredFiles: { path: string; reason: string }[]; + warnings: string[]; +}; + +const UNIVERSAL_FRONTMATTER_FIELDS = new Set([ + "title", + "slug", + "description", + "pubDate", + "updatedDate", + "category", + "tags", + "draft", + "heroImage", + "author", + "metaTitle", + "metaDescription", + "canonicalUrl", + "socialImage", + "coverImageAlt", + "heroImageAlt", + "noindex", + "readingTime", +]); + +const ADAPTER_EXTRA_FIELDS: Record> = { + "astro-mdx": new Set(), + markdown: new Set(), + "nextjs-mdx": new Set(["date", "coverImage"]), + "hugo-markdown": new Set([ + "date", + "lastmod", + "categories", + "images", + "slug", + "weight", + ]), + "eleventy-jekyll-markdown": new Set(["date", "layout", "permalink", "eleventyExcludeFromCollections"]), + "docusaurus-mdx": new Set([ + "date", + "image", + "authors", + "author", + "hide_table_of_contents", + "slug", + ]), + "mkdocs-markdown": new Set(["date"]), + "nuxt-content-markdown": new Set(["date", "image"]), +}; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +function slugFromFilename(path: string): string { + const filename = path.split("/").pop() ?? ""; + return filename.replace(/\.(mdx?|markdown)$/iu, ""); +} + +function isValidDateValue(value: unknown): boolean { + if (value instanceof Date) { + return !Number.isNaN(value.getTime()); + } + + if (typeof value !== "string") { + return false; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return false; + } + + if (ISO_DATE_PATTERN.test(trimmed)) { + const parsed = new Date(`${trimmed}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()); + } + + const parsed = new Date(trimmed); + return !Number.isNaN(parsed.getTime()); +} + +function allowedFrontmatterFields(adapter: string): Set { + const allowed = new Set(UNIVERSAL_FRONTMATTER_FIELDS); + const extras = ADAPTER_EXTRA_FIELDS[adapter]; + if (extras) { + for (const field of extras) { + allowed.add(field); + } + } + + return allowed; +} + +function findUnsupportedFields( + frontmatter: Record, + adapter: string, +): string[] { + const allowed = allowedFrontmatterFields(adapter); + return Object.keys(frontmatter).filter((key) => !allowed.has(key)); +} + +function findMissingRequiredFields( + frontmatter: Record, + adapter: string, +): string[] { + const missing: string[] = []; + const required = ["title", "description"]; + + for (const field of required) { + const value = frontmatter[field]; + if (typeof value !== "string" || value.trim().length === 0) { + missing.push(field); + } + } + + const pubDate = frontmatter.pubDate ?? frontmatter.date; + if (pubDate === undefined || pubDate === null) { + missing.push("pubDate"); + } + + if (adapter !== "mkdocs-markdown") { + const category = frontmatter.category ?? frontmatter.categories; + if ( + (typeof category !== "string" || category.trim().length === 0) && + !Array.isArray(category) + ) { + missing.push("category"); + } + } + + return missing; +} + +function findInvalidDates(frontmatter: Record): string[] { + const invalid: string[] = []; + + for (const field of ["pubDate", "date", "updatedDate", "lastmod"]) { + const value = frontmatter[field]; + if (value !== undefined && value !== null && !isValidDateValue(value)) { + invalid.push(field); + } + } + + return invalid; +} + +export function isPostFilePath(path: string): boolean { + return /\.(md|mdx)$/iu.test(path); +} + +export type AuditPostInput = { + path: string; + content: string; +}; + +export function auditPostFile( + input: AuditPostInput, + adapter: AdapterId, + slugFromPath: (path: string) => string = slugFromFilename, +): ContentAuditPost { + const issues: ContentAuditIssue[] = []; + + if (!isPostFilePath(input.path)) { + return { + path: input.path, + slug: slugFromPath(input.path), + title: slugFromPath(input.path), + status: "invalid", + issues: [ + { + kind: "ignored-file", + message: "File is not a Markdown or MDX post.", + }, + ], + }; + } + + const parsed = splitFrontmatter(input.content); + if (parsed === null) { + return { + path: input.path, + slug: slugFromPath(input.path), + title: slugFromPath(input.path), + status: "invalid", + issues: [ + { + kind: "missing-frontmatter", + message: "Frontmatter block is missing or malformed.", + }, + ], + }; + } + + const { frontmatter, body } = parsed; + + for (const field of findMissingRequiredFields(frontmatter, adapter)) { + issues.push({ + kind: "missing-field", + field, + message: `Missing or empty required field: ${field}.`, + }); + } + + for (const field of findUnsupportedFields(frontmatter, adapter)) { + issues.push({ + kind: "unsupported-field", + field, + message: `Unsupported frontmatter field for ${adapter}: ${field}.`, + }); + } + + for (const field of findInvalidDates(frontmatter)) { + issues.push({ + kind: "invalid-date", + field, + message: `Invalid date value for ${field}.`, + }); + } + + const complexMdx = hasComplexMdx(body); + if (complexMdx) { + issues.push({ + kind: "complex-mdx", + message: + "Body contains MDX imports, exports, or JSX. Edit in source mode to avoid accidental changes.", + }); + } + + let article; + try { + article = frontmatterToArticleInputWithSlug( + adapter, + input.path, + frontmatter, + body, + slugFromPath, + ); + } catch (error) { + issues.push({ + kind: "validation", + message: + error instanceof Error + ? error.message + : "Could not map frontmatter to article input.", + }); + + return { + path: input.path, + slug: slugFromPath(input.path), + title: + typeof frontmatter.title === "string" + ? frontmatter.title + : slugFromPath(input.path), + status: complexMdx ? "source-only" : "invalid", + issues, + }; + } + + const validation = validateArticle(article); + if (!validation.valid) { + for (const issue of validation.issues) { + issues.push({ + kind: "validation", + field: issue.field, + message: issue.message, + }); + } + } + + const slug = + typeof article.slug === "string" && article.slug.trim().length > 0 + ? article.slug.trim() + : slugFromPath(input.path); + const title = + typeof article.title === "string" && article.title.trim().length > 0 + ? article.title.trim() + : slug; + + if (complexMdx) { + return { + path: input.path, + slug, + title, + status: "source-only", + issues, + }; + } + + if (issues.length > 0) { + return { + path: input.path, + slug, + title, + status: "invalid", + issues, + }; + } + + return { + path: input.path, + slug, + title, + status: "valid", + issues: [], + }; +} + +export function buildContentAuditReport( + files: AuditPostInput[], + adapter: AdapterId, + contentDir: string, + slugFromPath: (path: string) => string = slugFromFilename, +): ContentAuditReport { + const validPosts: ContentAuditPost[] = []; + const invalidPosts: ContentAuditPost[] = []; + const sourceOnlyPosts: ContentAuditPost[] = []; + const ignoredFiles: { path: string; reason: string }[] = []; + const warnings: string[] = []; + const slugIndex = new Map(); + + for (const file of files) { + if (!isPostFilePath(file.path)) { + ignoredFiles.push({ + path: file.path, + reason: "Not a .md or .mdx file — left unchanged during audit.", + }); + continue; + } + + const audited = auditPostFile(file, adapter, slugFromPath); + + if (audited.status === "valid") { + validPosts.push(audited); + const paths = slugIndex.get(audited.slug) ?? []; + paths.push(audited.path); + slugIndex.set(audited.slug, paths); + continue; + } + + if (audited.status === "source-only") { + sourceOnlyPosts.push(audited); + const paths = slugIndex.get(audited.slug) ?? []; + paths.push(audited.path); + slugIndex.set(audited.slug, paths); + continue; + } + + invalidPosts.push(audited); + const paths = slugIndex.get(audited.slug) ?? []; + paths.push(audited.path); + slugIndex.set(audited.slug, paths); + } + + const duplicateSlugs: DuplicateSlugGroup[] = []; + for (const [slug, paths] of slugIndex) { + if (paths.length > 1) { + duplicateSlugs.push({ slug, paths: [...paths] }); + } + } + + if (duplicateSlugs.length > 0) { + warnings.push( + `${duplicateSlugs.length} duplicate slug(s) found across post files.`, + ); + } + + if (sourceOnlyPosts.length > 0) { + warnings.push( + `${sourceOnlyPosts.length} post(s) contain complex MDX and should remain source-only in the rich editor.`, + ); + } + + return { + adapter, + contentDir, + summary: { + totalFiles: files.length, + validCount: validPosts.length, + invalidCount: invalidPosts.length, + sourceOnlyCount: sourceOnlyPosts.length, + ignoredCount: ignoredFiles.length, + }, + validPosts, + invalidPosts, + sourceOnlyPosts, + duplicateSlugs, + ignoredFiles, + warnings, + }; +} diff --git a/packages/setup/src/detectSetup.test.ts b/packages/setup/src/detectSetup.test.ts new file mode 100644 index 0000000..7bb2d6a --- /dev/null +++ b/packages/setup/src/detectSetup.test.ts @@ -0,0 +1,131 @@ +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, isSafeToApplySuggestion } from "./detectSetup.js"; + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +describe("detectSetup", () => { + it("detects Astro MDX projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-astro-")); + writeFileSync(join(root, "astro.config.mjs"), "export default {};\n", "utf8"); + writeJson(join(root, "package.json"), { + dependencies: { astro: "^5.0.0" }, + }); + mkdirSync(join(root, "src/content/blog"), { recursive: true }); + writeFileSync(join(root, "src/content/blog/post.mdx"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.detected, true); + assert.equal(result.primary?.adapter, "astro-mdx"); + assert.equal(result.primary?.contentDir, "src/content/blog"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("detects Next.js MDX projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-next-")); + writeFileSync(join(root, "next.config.ts"), "export default {};\n", "utf8"); + writeJson(join(root, "package.json"), { + dependencies: { next: "^15.0.0" }, + }); + mkdirSync(join(root, "content/posts"), { recursive: true }); + writeFileSync(join(root, "content/posts/post.mdx"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "nextjs-mdx"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("detects Hugo projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-hugo-")); + writeFileSync(join(root, "hugo.toml"), "baseURL = '/'\n", "utf8"); + mkdirSync(join(root, "content/posts"), { recursive: true }); + writeFileSync(join(root, "content/posts/post.md"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "hugo-markdown"); + assert.ok((result.primary?.confidence ?? 0) >= 50); + }); + + it("detects Eleventy projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-11ty-")); + writeFileSync(join(root, ".eleventy.js"), "module.exports = {};\n", "utf8"); + writeJson(join(root, "package.json"), { + devDependencies: { "@11ty/eleventy": "^3.0.0" }, + }); + mkdirSync(join(root, "src/posts"), { recursive: true }); + writeFileSync(join(root, "src/posts/post.md"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "eleventy-jekyll-markdown"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("detects Jekyll projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-jekyll-")); + writeFileSync(join(root, "_config.yml"), "title: Blog\n", "utf8"); + writeFileSync(join(root, "Gemfile"), 'gem "jekyll"\n', "utf8"); + mkdirSync(join(root, "_posts"), { recursive: true }); + writeFileSync(join(root, "_posts/2024-01-01-post.md"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.framework, "Jekyll"); + assert.ok((result.primary?.confidence ?? 0) >= 50); + }); + + it("detects Docusaurus projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-docusaurus-")); + writeFileSync(join(root, "docusaurus.config.js"), "module.exports = {};\n", "utf8"); + writeJson(join(root, "package.json"), { + dependencies: { "@docusaurus/core": "^3.0.0" }, + }); + mkdirSync(join(root, "blog"), { recursive: true }); + writeFileSync(join(root, "blog/post.mdx"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "docusaurus-mdx"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("detects MkDocs projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-mkdocs-")); + writeFileSync(join(root, "mkdocs.yml"), "site_name: Docs\n", "utf8"); + mkdirSync(join(root, "docs"), { recursive: true }); + writeFileSync(join(root, "docs/page.md"), "# Page\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "mkdocs-markdown"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("detects Nuxt Content projects", () => { + const root = mkdtempSync(join(tmpdir(), "detect-nuxt-")); + writeFileSync( + join(root, "nuxt.config.ts"), + 'export default { modules: ["@nuxt/content"] }\n', + "utf8", + ); + writeJson(join(root, "package.json"), { + dependencies: { nuxt: "^3.0.0", "@nuxt/content": "^2.0.0" }, + }); + mkdirSync(join(root, "content/blog"), { recursive: true }); + writeFileSync(join(root, "content/blog/post.md"), "---\ntitle: Hi\n---\n", "utf8"); + + const result = detectSetup(root); + assert.equal(result.primary?.adapter, "nuxt-content-markdown"); + assert.ok((result.primary?.confidence ?? 0) >= 70); + }); + + it("marks low-confidence suggestions as unsafe to auto-apply", () => { + const root = mkdtempSync(join(tmpdir(), "detect-low-")); + const result = detectSetup(root); + assert.equal(result.detected, false); + if (result.primary) { + assert.equal(isSafeToApplySuggestion(result.primary), false); + } + }); +}); diff --git a/packages/setup/src/detectSetup.ts b/packages/setup/src/detectSetup.ts new file mode 100644 index 0000000..cc32b88 --- /dev/null +++ b/packages/setup/src/detectSetup.ts @@ -0,0 +1,482 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { derivePublicMediaPath } from "@sourcedraft/config"; + +export type SetupDetectionSuggestion = { + framework: string; + adapter: string; + contentDir: string; + mediaDir: string; + publicMediaPath: string; + defaultBranch: string; + confidence: number; + explanation: string; + warnings: string[]; +}; + +export type SetupDetectionResult = { + scannedRoot: string; + detected: boolean; + primary: SetupDetectionSuggestion | null; + alternatives: SetupDetectionSuggestion[]; + warnings: string[]; +}; + +type FrameworkRule = { + framework: string; + adapter: string; + contentDir: string; + mediaDir: string; + score: (root: string) => { points: number; signals: string[]; warnings: string[] }; +}; + +function readText(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function pathExists(path: string): boolean { + return existsSync(path); +} + +function dirHasFilesWithExtension(dir: string, extensions: string[]): boolean { + if (!pathExists(dir)) { + return false; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isFile()) { + if (extensions.some((ext) => entry.name.endsWith(ext))) { + return true; + } + } else if (entry.isDirectory()) { + if (dirHasFilesWithExtension(fullPath, extensions)) { + return true; + } + } + } + } catch { + return false; + } + + return false; +} + +function packageJson(root: string): Record | null { + const raw = readText(join(root, "package.json")); + if (raw === null) { + return null; + } + + try { + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +function dependencyNames(pkg: Record | null): Set { + const names = new Set(); + if (pkg === null) { + return names; + } + + for (const section of ["dependencies", "devDependencies"]) { + const value = pkg[section]; + if (value && typeof value === "object") { + for (const key of Object.keys(value as Record)) { + names.add(key); + } + } + } + + return names; +} + +function detectDefaultBranch(root: string): string { + const headPath = join(root, ".git", "HEAD"); + const head = readText(headPath); + if (head?.startsWith("ref: refs/heads/")) { + return head.slice("ref: refs/heads/".length).trim(); + } + + return "main"; +} + +const FRAMEWORK_RULES: FrameworkRule[] = [ + { + framework: "Astro MDX", + adapter: "astro-mdx", + contentDir: "src/content/blog", + mediaDir: "public/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + const deps = dependencyNames(packageJson(root)); + + if ( + pathExists(join(root, "astro.config.mjs")) || + pathExists(join(root, "astro.config.ts")) || + pathExists(join(root, "astro.config.js")) + ) { + points += 35; + signals.push("astro.config found"); + } + + if (deps.has("astro")) { + points += 40; + signals.push("astro dependency in package.json"); + } + + if (dirHasFilesWithExtension(join(root, "src/content"), [".mdx", ".md"])) { + points += 20; + signals.push("content files under src/content"); + } + + if (points > 0 && !deps.has("astro")) { + warnings.push("Astro config markers found but astro is not in package.json dependencies."); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Next.js MDX", + adapter: "nextjs-mdx", + contentDir: "content/posts", + mediaDir: "public/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + const deps = dependencyNames(packageJson(root)); + + if ( + pathExists(join(root, "next.config.mjs")) || + pathExists(join(root, "next.config.ts")) || + pathExists(join(root, "next.config.js")) + ) { + points += 35; + signals.push("next.config found"); + } + + if (deps.has("next")) { + points += 40; + signals.push("next dependency in package.json"); + } + + if (dirHasFilesWithExtension(join(root, "content"), [".mdx", ".md"])) { + points += 15; + signals.push("content files under content/"); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Hugo", + adapter: "hugo-markdown", + contentDir: "content/posts", + mediaDir: "static/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + + if (pathExists(join(root, "hugo.toml")) || pathExists(join(root, "config.toml"))) { + points += 45; + signals.push("hugo.toml or config.toml found"); + } + + if (pathExists(join(root, "archetypes"))) { + points += 10; + signals.push("archetypes/ directory found"); + } + + if (dirHasFilesWithExtension(join(root, "content"), [".md"])) { + points += 25; + signals.push("markdown files under content/"); + } + + const gemfile = readText(join(root, "Gemfile")); + if (gemfile?.includes("hugo")) { + points += 10; + signals.push("Hugo mentioned in Gemfile"); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Eleventy", + adapter: "eleventy-jekyll-markdown", + contentDir: "src/posts", + mediaDir: "src/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + const deps = dependencyNames(packageJson(root)); + + if ( + pathExists(join(root, ".eleventy.js")) || + pathExists(join(root, ".eleventy.cjs")) || + pathExists(join(root, "eleventy.config.js")) + ) { + points += 50; + signals.push("Eleventy config found"); + } + + if (deps.has("@11ty/eleventy")) { + points += 30; + signals.push("@11ty/eleventy dependency"); + } + + if ( + dirHasFilesWithExtension(join(root, "src/posts"), [".md"]) || + dirHasFilesWithExtension(join(root, "_posts"), [".md"]) + ) { + points += 15; + signals.push("post markdown files found"); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Jekyll", + adapter: "eleventy-jekyll-markdown", + contentDir: "_posts", + mediaDir: "assets/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + + if (pathExists(join(root, "_config.yml"))) { + points += 35; + signals.push("_config.yml found"); + } + + const gemfile = readText(join(root, "Gemfile")); + if (gemfile?.toLowerCase().includes("jekyll")) { + points += 35; + signals.push("jekyll in Gemfile"); + } + + if (dirHasFilesWithExtension(join(root, "_posts"), [".md"])) { + points += 20; + signals.push("_posts/ markdown files"); + } + + if (pathExists(join(root, ".eleventy.js"))) { + points -= 20; + warnings.push("Eleventy config also present — Jekyll score reduced."); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Docusaurus", + adapter: "docusaurus-mdx", + contentDir: "blog", + mediaDir: "static/img", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + const deps = dependencyNames(packageJson(root)); + + if ( + pathExists(join(root, "docusaurus.config.js")) || + pathExists(join(root, "docusaurus.config.ts")) + ) { + points += 50; + signals.push("docusaurus.config found"); + } + + if (deps.has("@docusaurus/core")) { + points += 35; + signals.push("@docusaurus/core dependency"); + } + + if (dirHasFilesWithExtension(join(root, "blog"), [".mdx", ".md"])) { + points += 15; + signals.push("blog/ content files"); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "MkDocs", + adapter: "mkdocs-markdown", + contentDir: "docs", + mediaDir: "docs/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + + if (pathExists(join(root, "mkdocs.yml")) || pathExists(join(root, "mkdocs.yaml"))) { + points += 60; + signals.push("mkdocs.yml found"); + } + + if (dirHasFilesWithExtension(join(root, "docs"), [".md"])) { + points += 25; + signals.push("docs/ markdown files"); + } + + return { points, signals, warnings }; + }, + }, + { + framework: "Nuxt Content", + adapter: "nuxt-content-markdown", + contentDir: "content", + mediaDir: "public/images", + score(root) { + const signals: string[] = []; + const warnings: string[] = []; + let points = 0; + const deps = dependencyNames(packageJson(root)); + const nuxtConfig = + readText(join(root, "nuxt.config.ts")) ?? + readText(join(root, "nuxt.config.js")) ?? + ""; + + if (nuxtConfig.includes("@nuxt/content") || deps.has("@nuxt/content")) { + points += 45; + signals.push("@nuxt/content configured"); + } + + if (deps.has("nuxt") || deps.has("@nuxt/kit")) { + points += 20; + signals.push("nuxt dependency"); + } + + if (dirHasFilesWithExtension(join(root, "content"), [".md", ".mdx"])) { + points += 20; + signals.push("content/ markdown files"); + } + + return { points, signals, warnings }; + }, + }, +]; + +function buildSuggestion( + rule: FrameworkRule, + root: string, + scored: { points: number; signals: string[]; warnings: string[] }, +): SetupDetectionSuggestion { + const confidence = Math.min(100, Math.max(0, scored.points)); + const publicMediaPath = derivePublicMediaPath(rule.mediaDir); + + return { + framework: rule.framework, + adapter: rule.adapter, + contentDir: rule.contentDir, + mediaDir: rule.mediaDir, + publicMediaPath, + defaultBranch: detectDefaultBranch(root), + confidence, + explanation: + scored.signals.length > 0 + ? scored.signals.join("; ") + : "No strong framework markers found.", + warnings: scored.warnings, + }; +} + +export function detectSetup(root: string): SetupDetectionResult { + const resolvedRoot = pathExists(root) && statSync(root).isDirectory() ? root : root; + const warnings: string[] = []; + + if (!pathExists(resolvedRoot)) { + return { + scannedRoot: resolvedRoot, + detected: false, + primary: null, + alternatives: [], + warnings: [`Scan root does not exist: ${resolvedRoot}`], + }; + } + + const suggestions = FRAMEWORK_RULES.map((rule) => { + const scored = rule.score(resolvedRoot); + return buildSuggestion(rule, resolvedRoot, scored); + }) + .filter((suggestion) => suggestion.confidence > 0) + .sort((left, right) => right.confidence - left.confidence); + + if (suggestions.length === 0) { + warnings.push( + "No supported static-site framework markers were found. Run pnpm setup or configure sourcedraft.config.json manually.", + ); + return { + scannedRoot: resolvedRoot, + detected: false, + primary: null, + alternatives: [], + warnings, + }; + } + + const [primary, ...alternatives] = suggestions; + + if (primary && primary.confidence < 50) { + warnings.push( + "Detection confidence is low. Review suggested paths before applying configuration.", + ); + } + + if (alternatives.length > 0 && alternatives[0] && primary) { + const gap = primary.confidence - alternatives[0].confidence; + if (gap < 15) { + warnings.push( + `Multiple frameworks scored similarly (${primary.framework} vs ${alternatives[0].framework}). Confirm adapter manually.`, + ); + } + } + + return { + scannedRoot: resolvedRoot, + detected: primary !== undefined, + primary: primary ?? null, + alternatives, + warnings, + }; +} + +export function buildSuggestedConfigSnippet( + suggestion: SetupDetectionSuggestion, +): string { + return JSON.stringify( + { + adapter: suggestion.adapter, + contentDir: suggestion.contentDir, + mediaDir: suggestion.mediaDir, + publicMediaPath: suggestion.publicMediaPath, + defaultBranch: suggestion.defaultBranch, + categories: ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + adapterOptions: {}, + publisherOptions: {}, + }, + null, + 2, + ); +} + +export function isSafeToApplySuggestion(suggestion: SetupDetectionSuggestion): boolean { + return suggestion.confidence >= 70 && suggestion.warnings.length === 0; +} diff --git a/packages/setup/src/frontmatter.ts b/packages/setup/src/frontmatter.ts new file mode 100644 index 0000000..0ff0240 --- /dev/null +++ b/packages/setup/src/frontmatter.ts @@ -0,0 +1,108 @@ +function parseScalar(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed + .slice(1, -1) + .replace(/\\"/gu, '"') + .replace(/\\n/gu, "\n") + .replace(/\\r/gu, "\r") + .replace(/\\t/gu, "\t"); + } + + return trimmed; +} + +function parseYamlValue(value: string): unknown { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return ""; + } + + if (trimmed === "true") { + return true; + } + + if (trimmed === "false") { + return false; + } + + if (trimmed === "null") { + return null; + } + + return parseScalar(trimmed); +} + +export function parseFrontmatter(yaml: string): Record { + const result: Record = {}; + const lines = yaml.split("\n"); + let index = 0; + + while (index < lines.length) { + const line = lines[index] ?? ""; + + if (line.trim().length === 0 || line.trimStart().startsWith("#")) { + index += 1; + continue; + } + + if (/^tags:\s*\[\]\s*$/u.test(line)) { + result.tags = []; + index += 1; + continue; + } + + if (/^tags:\s*$/u.test(line)) { + const tags: string[] = []; + index += 1; + + while (index < lines.length && /^\s+-\s+/u.test(lines[index] ?? "")) { + const tagLine = lines[index] ?? ""; + tags.push(parseScalar(tagLine.replace(/^\s+-\s+/u, ""))); + index += 1; + } + + result.tags = tags; + continue; + } + + const match = line.match(/^([A-Za-z]+):\s*(.*)$/u); + if (match) { + const key = match[1]; + const value = match[2] ?? ""; + if (key !== undefined) { + result[key] = parseYamlValue(value); + } + index += 1; + continue; + } + + index += 1; + } + + return result; +} + +export function splitFrontmatter( + content: string, +): { frontmatter: Record; body: string } | null { + if (!content.startsWith("---\n")) { + return null; + } + + const closingIndex = content.indexOf("\n---\n", 4); + if (closingIndex === -1) { + return null; + } + + const yaml = content.slice(4, closingIndex); + const body = content.slice(closingIndex + 5); + + return { + frontmatter: parseFrontmatter(yaml), + body, + }; +} diff --git a/packages/setup/src/index.ts b/packages/setup/src/index.ts index 5c2f80b..48248ae 100644 --- a/packages/setup/src/index.ts +++ b/packages/setup/src/index.ts @@ -50,3 +50,30 @@ export { } from "./validateConfig.js"; export { runWizard, type WizardOptions, type WizardResult } from "./wizard.js"; + +export { + parseFrontmatter, + splitFrontmatter, +} from "./frontmatter.js"; + +export { hasComplexMdx } from "./mdxComplexity.js"; + +export { + buildSuggestedConfigSnippet, + detectSetup, + isSafeToApplySuggestion, + type SetupDetectionResult, + type SetupDetectionSuggestion, +} from "./detectSetup.js"; + +export { + auditPostFile, + buildContentAuditReport, + isPostFilePath, + type AuditPostInput, + type ContentAuditIssue, + type ContentAuditPost, + type ContentAuditReport, + type ContentAuditSummary, + type DuplicateSlugGroup, +} from "./contentAudit.js"; diff --git a/packages/setup/src/mdxComplexity.ts b/packages/setup/src/mdxComplexity.ts new file mode 100644 index 0000000..750fc50 --- /dev/null +++ b/packages/setup/src/mdxComplexity.ts @@ -0,0 +1,22 @@ +const MDX_IMPORT_LINE = /^import\s+/u; +const MDX_EXPORT_LINE = /^export\s+/u; +const MDX_TAG_LINE = /^<[A-Za-z][\w.-]*/u; + +export function hasComplexMdx(body: string): boolean { + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + if ( + MDX_IMPORT_LINE.test(trimmed) || + MDX_EXPORT_LINE.test(trimmed) || + MDX_TAG_LINE.test(trimmed) + ) { + return true; + } + } + + return false; +}