Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
85 changes: 85 additions & 0 deletions apps/studio/server/contentAuditHandler.ts
Original file line number Diff line number Diff line change
@@ -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 },
};
}
26 changes: 26 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
106 changes: 2 additions & 104 deletions apps/studio/server/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>; 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(
Expand Down
23 changes: 23 additions & 0 deletions apps/studio/server/setupDetection.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
57 changes: 57 additions & 0 deletions apps/studio/server/setupDetection.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
4 changes: 4 additions & 0 deletions apps/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand All @@ -593,6 +596,7 @@ function App() {
valid={validation.valid}
issues={validation.issues}
outputPath={outputPath}
posts={posts}
onChange={handleFieldChange}
onSlugManualEdit={handleSlugManualEdit}
onSlugResync={handleSlugResync}
Expand Down
Loading
Loading