diff --git a/.env.example b/.env.example index 278b68a..81966e7 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ # Project paths/categories belong in sourcedraft.config.json instead SOURCEDRAFT_ADMIN_PASSWORD= +# Set to true to run Studio in demo mode (no GitHub commits) +SOURCEDRAFT_DEMO_MODE= GITHUB_TOKEN= GITHUB_OWNER= GITHUB_REPO= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da33054..67758c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,32 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test + + studio-e2e: + runs-on: ubuntu-latest + needs: build-and-test + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.1.2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + + - name: Install Playwright Chromium + working-directory: apps/studio + run: pnpm exec playwright install --with-deps chromium + + - name: Run Studio smoke tests (demo mode) + working-directory: apps/studio + env: + CI: true + run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index c25819f..28b6776 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist .DS_Store .vscode .pnpm-store +apps/studio/test-results +apps/studio/playwright-report diff --git a/README.md b/README.md index 141e1c8..3729ad9 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,13 @@ SourceDraft began as an internal tool for [QuBrite.com](https://qubrite.com) and ## Screenshots -Screenshots are not included in the repository yet. Expected images and capture instructions: [docs/screenshots.md](docs/screenshots.md). +![Studio overview](docs/assets/studio-overview.png) -When added, they will live in `docs/assets/` (overview, editor preview, media upload, publish success). +| | | +|---|---| +| ![Editor](docs/assets/editor.png) | ![Publish simulated in demo mode](docs/assets/publish-success.png) | + +More views (toolbar, autosave, media library, content quality, preview, setup health): [docs/screenshots.md](docs/screenshots.md). Regenerate with `pnpm screenshots:generate`. ## What is SourceDraft? @@ -36,6 +40,8 @@ Your static site — Astro today, others later — still builds and deploys exac - Upload images to GitHub (`mediaDir`) from Studio - Configure paths, adapter, and categories in `sourcedraft.config.json` - Protect Studio with a server-side admin password +- **Demo mode** — explore Studio with sample posts without GitHub credentials +- **Setup health** — Settings panel checks for missing config (no secrets exposed) ## What it does not do yet @@ -90,6 +96,8 @@ pnpm dev Sign in, click **New post**, preview the output, publish. The file lands at `contentDir/.mdx` or `.md` depending on your adapter (default: `src/content/blog/`). +**Try without GitHub:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty and click **Explore demo mode** on the sign-in screen. Demo content reloads from repository fixtures on each API start. See [docs/demo-mode.md](docs/demo-mode.md). + Full walkthrough: [docs/getting-started.md](docs/getting-started.md) ## Beginner path @@ -137,6 +145,7 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo ## Documentation - [Getting started](docs/getting-started.md) +- [Demo mode](docs/demo-mode.md) - [Non-technical overview](docs/non-technical-overview.md) — for writers - [GitHub publishing](docs/github-publishing.md) - [Media uploads](docs/media.md) @@ -146,6 +155,7 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo - [Adapters](docs/adapters.md) - [Project status](docs/project-status.md) - [Manual acceptance test](docs/manual-acceptance-test.md) +- [Smoke tests (Playwright)](docs/getting-started.md#smoke-tests-playwright) - [Release checklist](RELEASE_CHECKLIST.md) - [Security](docs/security.md) - [Screenshots guide](docs/screenshots.md) diff --git a/apps/studio/e2e/helpers.ts b/apps/studio/e2e/helpers.ts new file mode 100644 index 0000000..4136a32 --- /dev/null +++ b/apps/studio/e2e/helpers.ts @@ -0,0 +1,35 @@ +import { expect, type Page } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +export const REPO_ROOT = resolve(import.meta.dirname, "../../.."); +export const SCREENSHOT_DIR = resolve(REPO_ROOT, "docs/assets"); + +export const STUDIO_VIEWPORT = { width: 1280, height: 900 }; + +export function attachPageErrorLogging(page: Page): void { + page.on("pageerror", (error) => { + console.error("Page error:", error.message); + }); +} + +export async function waitForStudioRoot(page: Page): Promise { + await page.goto("/"); + await expect(page.locator("#root")).not.toBeEmpty({ timeout: 30_000 }); +} + +export async function enterDemoMode(page: Page): Promise { + attachPageErrorLogging(page); + await waitForStudioRoot(page); + await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible(); + await page.getByRole("button", { name: "Explore demo mode" }).click(); + await expect(page.getByText("Demo mode — no GitHub commits are made")).toBeVisible(); +} + +export function ensureScreenshotDir(): void { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); +} + +export function screenshotPath(filename: string): string { + return resolve(SCREENSHOT_DIR, filename); +} diff --git a/apps/studio/e2e/screenshots.spec.ts b/apps/studio/e2e/screenshots.spec.ts new file mode 100644 index 0000000..2aa4909 --- /dev/null +++ b/apps/studio/e2e/screenshots.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { + attachPageErrorLogging, + ensureScreenshotDir, + enterDemoMode, + screenshotPath, + STUDIO_VIEWPORT, +} from "./helpers.js"; + +test.describe("release screenshots", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeAll(() => { + ensureScreenshotDir(); + }); + + test.use({ viewport: STUDIO_VIEWPORT }); + + test("generates docs/assets screenshots from demo mode", async ({ page }) => { + test.setTimeout(120_000); + attachPageErrorLogging(page); + + await enterDemoMode(page); + await page.screenshot({ + path: screenshotPath("studio-overview.png"), + fullPage: false, + }); + + await page.getByRole("button", { name: "Getting started with SourceDraft" }).click(); + await expect(page.locator(".writing-canvas__body")).toBeVisible(); + + await page.screenshot({ + path: screenshotPath("editor.png"), + fullPage: false, + }); + + await page.locator(".editor-toolbar-wrap").screenshot({ + path: screenshotPath("toolbar.png"), + }); + + await page.getByPlaceholder("Post title").fill("Screenshot autosave example"); + await expect(page.getByText("Unsaved changes", { exact: false })).toBeVisible({ + timeout: 5000, + }); + await page.locator(".app-bar").screenshot({ + path: screenshotPath("autosave.png"), + }); + + await page.locator(".media-library").screenshot({ + path: screenshotPath("media-library.png"), + }); + + await page.locator(".content-quality").screenshot({ + path: screenshotPath("content-quality.png"), + }); + + await page.locator(".preview-panel").screenshot({ + path: screenshotPath("preview.png"), + }); + + await page.getByRole("button", { name: "New post" }).click(); + await page.getByPlaceholder("Post title").fill("Screenshot publish example"); + await page.getByPlaceholder("Short description or subtitle").fill( + "Summary used for automated publish-success screenshot.", + ); + await page.locator(".writing-canvas__body").fill( + "# Screenshot publish example\n\nBody for release screenshot capture.", + ); + await page.getByRole("button", { name: "Simulate publish" }).click(); + await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 }); + await page.locator(".publish-bar").screenshot({ + path: screenshotPath("publish-success.png"), + }); + + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByRole("heading", { name: "Setup health" })).toBeVisible(); + await page.locator(".setup-health").screenshot({ + path: screenshotPath("setup-health.png"), + }); + }); +}); diff --git a/apps/studio/e2e/smoke.spec.ts b/apps/studio/e2e/smoke.spec.ts new file mode 100644 index 0000000..241bd23 --- /dev/null +++ b/apps/studio/e2e/smoke.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { + attachPageErrorLogging, + enterDemoMode, + waitForStudioRoot, +} from "./helpers.js"; + +test.describe("Studio smoke", () => { + test("login/demo entry renders", async ({ page }) => { + attachPageErrorLogging(page); + await waitForStudioRoot(page); + await expect(page.getByRole("heading", { name: "SourceDraft Studio" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Explore demo mode" })).toBeVisible(); + }); + + test("overview/post list renders in demo mode", async ({ page }) => { + await enterDemoMode(page); + await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible(); + await expect(page.getByText("Getting started with SourceDraft")).toBeVisible(); + }); + + test("new post form and editor accept text", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await page.getByPlaceholder("Post title").fill("Smoke test post"); + await page.getByPlaceholder("Short description or subtitle").fill( + "A short summary for the smoke test post.", + ); + const body = page.locator(".writing-canvas__body"); + await body.fill("## Smoke test section\n\nBody text for smoke testing."); + await expect(body).toHaveValue(/Smoke test section/u); + }); + + test("toolbar inserts Markdown", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + const body = page.locator(".writing-canvas__body"); + await body.fill("Selected text"); + await body.selectText(); + await page.getByRole("button", { name: "Bold" }).click(); + await expect(body).toHaveValue("**Selected text**"); + }); + + test("autosave status appears after edits", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await page.getByPlaceholder("Post title").fill("Autosave smoke test"); + await expect(page.getByText("Unsaved changes", { exact: false })).toBeVisible({ + timeout: 5000, + }); + }); + + test("media library and content quality panels render", async ({ page }) => { + await enterDemoMode(page); + await expect(page.getByRole("heading", { name: "Media library" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Content quality" })).toBeVisible(); + }); + + test("settings setup health renders", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "Settings" }).click(); + 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 success can be simulated in demo mode", async ({ page }) => { + await enterDemoMode(page); + await page.getByRole("button", { name: "New post" }).click(); + await page.getByPlaceholder("Post title").fill("Demo publish smoke test"); + await page.getByPlaceholder("Short description or subtitle").fill( + "Summary for demo publish smoke test.", + ); + await page.locator(".writing-canvas__body").fill("# Demo publish\n\nBody content."); + await page.getByRole("button", { name: "Simulate publish" }).click(); + await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/apps/studio/package.json b/apps/studio/package.json index 326bd9d..c8cd828 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -14,7 +14,9 @@ "start:server": "node dist-server/index.js", "lint": "eslint .", "preview": "vite preview", - "test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts" + "test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts", + "test:e2e": "playwright test e2e/smoke.spec.ts", + "screenshots:generate": "playwright test e2e/screenshots.spec.ts" }, "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", @@ -33,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.55.0", "@types/busboy": "^1.5.4", "@types/express": "^5.0.3", "@types/node": "^24.12.3", diff --git a/apps/studio/playwright.config.ts b/apps/studio/playwright.config.ts new file mode 100644 index 0000000..0a8368c --- /dev/null +++ b/apps/studio/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "@playwright/test"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, "../.."); + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:5173", + trace: "on-first-retry", + viewport: { width: 1280, height: 900 }, + }, + webServer: { + command: "SOURCEDRAFT_DEMO_MODE=true STUDIO_API_PORT=8787 pnpm --filter studio dev", + cwd: repoRoot, + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, +}); diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index 1e87757..8d437ea 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -1,5 +1,6 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import type { NextFunction, Request, Response } from "express"; +import { isDemoModeAvailable, isDemoModeForced, isGitHubConfigured } from "./demoMode.js"; const SESSION_COOKIE = "sourcedraft_session"; /** 24 hours — in-memory MVP sessions, not durable account auth. */ @@ -7,6 +8,7 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; type SessionRecord = { expiresAt: number; + demo?: boolean; }; const sessions = new Map(); @@ -116,10 +118,13 @@ export function verifyPassword(password: string): boolean { return timingSafeEqual(provided, target); } -export function createSession(): string { +export function createSession(options?: { demo?: boolean }): string { purgeExpiredSessions(); const token = randomBytes(32).toString("hex"); - sessions.set(token, { expiresAt: Date.now() + SESSION_TTL_MS }); + sessions.set(token, { + expiresAt: Date.now() + SESSION_TTL_MS, + demo: options?.demo === true, + }); return token; } @@ -152,8 +157,41 @@ export function getSessionToken(req: Request): string | null { return readCookie(req, SESSION_COOKIE); } +export function isDemoSession(token: string | null): boolean { + if (!token) { + return false; + } + + purgeExpiredSessions(); + const session = sessions.get(token); + return session?.demo === true; +} + +export function isRequestDemoSession(req: Request): boolean { + if (isDemoModeForced() || !isGitHubConfigured()) { + return true; + } + + return isDemoSession(getSessionToken(req)); +} + +export function isAuthenticatedDemoActive(req: Request): boolean { + const token = getSessionToken(req); + if (!isSessionValid(token)) { + return false; + } + + return isRequestDemoSession(req); +} + export function requireAuth(req: Request, res: Response, next: NextFunction): void { - if (!isAuthConfigured()) { + const token = getSessionToken(req); + if (isSessionValid(token)) { + next(); + return; + } + + if (!isAuthConfigured() && !isDemoModeAvailable()) { res.status(500).json({ ok: false, error: "SOURCEDRAFT_ADMIN_PASSWORD is not configured.", @@ -161,13 +199,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo return; } - const token = getSessionToken(req); - if (!isSessionValid(token)) { - res.status(401).json({ ok: false, error: "Authentication required." }); - return; - } - - next(); + res.status(401).json({ ok: false, error: "Authentication required." }); } export function login( @@ -188,6 +220,22 @@ export function login( return { ok: true }; } +export function enterDemo( + req: Request, + res: Response, +): { ok: boolean; error?: string } { + if (!isDemoModeAvailable()) { + return { + ok: false, + error: "Demo mode is not available when GitHub is fully configured.", + }; + } + + const token = createSession({ demo: true }); + setSessionCookie(req, res, token); + return { ok: true }; +} + export function logout(req: Request, res: Response): void { destroySession(getSessionToken(req)); clearSessionCookie(req, res); diff --git a/apps/studio/server/demo/fixtures/constants.ts b/apps/studio/server/demo/fixtures/constants.ts new file mode 100644 index 0000000..2c03ccc --- /dev/null +++ b/apps/studio/server/demo/fixtures/constants.ts @@ -0,0 +1,3 @@ +export const DEMO_CONTENT_DIR = "src/content/blog"; +export const DEMO_MEDIA_DIR = "public/images"; +export const DEMO_PUBLIC_MEDIA_PATH = "/images"; diff --git a/apps/studio/server/demo/fixtures/media.ts b/apps/studio/server/demo/fixtures/media.ts new file mode 100644 index 0000000..b45ed04 --- /dev/null +++ b/apps/studio/server/demo/fixtures/media.ts @@ -0,0 +1,33 @@ +import type { DemoFixtureMedia } from "../types.js"; +import { DEMO_MEDIA_DIR, DEMO_PUBLIC_MEDIA_PATH } from "./constants.js"; + +/** + * Stable seed media metadata for demo mode. No binary files are stored in the repo; + * uploads during a session append in-memory entries with simulated public paths. + */ +export const DEMO_MEDIA_FIXTURES: DemoFixtureMedia[] = [ + { + repoPath: `${DEMO_MEDIA_DIR}/sample-cover.png`, + publicPath: `${DEMO_PUBLIC_MEDIA_PATH}/sample-cover.png`, + filename: "sample-cover.png", + extension: "png", + kind: "image", + size: 48_000, + }, + { + repoPath: `${DEMO_MEDIA_DIR}/workflow-diagram.png`, + publicPath: `${DEMO_PUBLIC_MEDIA_PATH}/workflow-diagram.png`, + filename: "workflow-diagram.png", + extension: "png", + kind: "image", + size: 72_500, + }, + { + repoPath: `${DEMO_MEDIA_DIR}/sample-handbook.pdf`, + publicPath: `${DEMO_PUBLIC_MEDIA_PATH}/sample-handbook.pdf`, + filename: "sample-handbook.pdf", + extension: "pdf", + kind: "pdf", + size: 128_000, + }, +]; diff --git a/apps/studio/server/demo/fixtures/posts.ts b/apps/studio/server/demo/fixtures/posts.ts new file mode 100644 index 0000000..351bba9 --- /dev/null +++ b/apps/studio/server/demo/fixtures/posts.ts @@ -0,0 +1,147 @@ +import type { DemoFixturePost } from "../types.js"; +import { DEMO_CONTENT_DIR } from "./constants.js"; + +/** + * Stable seed posts for demo mode. Reloaded from these fixtures on every API start + * and when resetDemoStore() runs. Edits during a session are in-memory only. + */ +export const DEMO_POST_FIXTURES: DemoFixturePost[] = [ + { + summary: { + path: `${DEMO_CONTENT_DIR}/getting-started-with-sourcedraft.mdx`, + title: "Getting started with SourceDraft", + slug: "getting-started-with-sourcedraft", + pubDate: "2026-06-06", + category: "Guides", + draft: false, + }, + content: `--- +title: Getting started with SourceDraft +description: A published guide showing the MDX shape Studio writes to your content folder. +pubDate: 2026-06-06 +category: Guides +tags: + - sourcedraft + - guides +draft: false +--- + +# Getting started with SourceDraft + +This published guide demonstrates how articles look after you validate metadata and body in Studio. + +## 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 + +## Next steps + +Open other sample posts to see drafts, images, and internal links. +`, + }, + { + summary: { + path: `${DEMO_CONTENT_DIR}/draft-release-notes.mdx`, + title: "Draft release notes", + slug: "draft-release-notes", + pubDate: "2026-06-01", + category: "Notes", + draft: true, + }, + content: `--- +title: Draft release notes +description: A sample draft post for filters, badges, and unpublished workflow. +pubDate: 2026-06-01 +category: Notes +tags: + - draft + - release +draft: true +--- + +# Draft release notes + +This post is marked \`draft: true\` in frontmatter. It appears in the post list with a draft badge. + +Use it to confirm draft filters and publishing gates behave as expected. +`, + }, + { + summary: { + path: `${DEMO_CONTENT_DIR}/publishing-with-images.mdx`, + title: "Publishing with images", + slug: "publishing-with-images", + pubDate: "2026-05-28", + category: "Tutorials", + draft: false, + }, + content: `--- +title: Publishing with images +description: Example post with inline image Markdown and a cover image path. +pubDate: 2026-05-28 +category: Tutorials +tags: + - images + - markdown +heroImage: /images/sample-cover.png +draft: false +--- + +# Publishing with images + +Studio uploads land in your configured media folder. Public paths are inserted into posts. + +![Diagram of the write-preview-publish flow](/images/workflow-diagram.png) + +## Cover images + +Set a hero image in Post details or reference a path from the media library. + +## Inline images + +Use the toolbar or paste Markdown like the example above. +`, + }, + { + summary: { + path: `${DEMO_CONTENT_DIR}/linking-and-outline.mdx`, + title: "Linking and document outline", + slug: "linking-and-outline", + pubDate: "2026-05-20", + category: "Tutorials", + draft: false, + }, + content: `--- +title: Linking and document outline +description: Sample post with headings, internal links, and outline-friendly structure. +pubDate: 2026-05-20 +category: Tutorials +tags: + - links + - outline +draft: false +--- + +# Linking and document outline + +Use headings to structure long articles. The document outline panel lists H1–H3 sections. + +## Internal links + +Link to other demo posts with the Internal toolbar action or Markdown syntax: + +- [Getting started with SourceDraft](/getting-started-with-sourcedraft) +- [Publishing with images](/publishing-with-images) + +## External links + +External URLs work as usual: [Markdown guide](https://www.markdownguide.org/). + +### Subsections + +Smaller headings help readers scan technical content without extra UI chrome. +`, + }, +]; diff --git a/apps/studio/server/demo/loadFixtures.ts b/apps/studio/server/demo/loadFixtures.ts new file mode 100644 index 0000000..236d3de --- /dev/null +++ b/apps/studio/server/demo/loadFixtures.ts @@ -0,0 +1,30 @@ +import type { DemoFixtureMedia, DemoFixturePost } from "./types.js"; +import { DEMO_MEDIA_FIXTURES } from "./fixtures/media.js"; +import { DEMO_POST_FIXTURES } from "./fixtures/posts.js"; + +function clonePost(fixture: DemoFixturePost): DemoFixturePost { + return { + summary: { ...fixture.summary }, + content: fixture.content, + }; +} + +function cloneMedia(fixture: DemoFixtureMedia): DemoFixtureMedia { + return { ...fixture }; +} + +export function loadDemoPostFixtures(): DemoFixturePost[] { + return DEMO_POST_FIXTURES.map(clonePost); +} + +export function loadDemoMediaFixtures(): DemoFixtureMedia[] { + return DEMO_MEDIA_FIXTURES.map(cloneMedia); +} + +export function demoFixturePostCount(): number { + return DEMO_POST_FIXTURES.length; +} + +export function demoFixtureMediaCount(): number { + return DEMO_MEDIA_FIXTURES.length; +} diff --git a/apps/studio/server/demo/types.ts b/apps/studio/server/demo/types.ts new file mode 100644 index 0000000..2372592 --- /dev/null +++ b/apps/studio/server/demo/types.ts @@ -0,0 +1,15 @@ +import type { PostSummary } from "../posts.js"; + +export type DemoFixturePost = { + summary: PostSummary; + content: string; +}; + +export type DemoFixtureMedia = { + repoPath: string; + publicPath: string; + filename: string; + extension: string; + kind: "image" | "pdf"; + size: number; +}; diff --git a/apps/studio/server/demoFixtures.test.ts b/apps/studio/server/demoFixtures.test.ts new file mode 100644 index 0000000..8737417 --- /dev/null +++ b/apps/studio/server/demoFixtures.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, it } from "node:test"; +import { loadPublicConfig } from "./config.js"; +import { + demoFixtureMediaCount, + demoFixturePostCount, + loadDemoMediaFixtures, + loadDemoPostFixtures, +} from "./demo/loadFixtures.js"; +import { listDemoPostsHandler } from "./demoPosts.js"; +import { publishDemoArticle } from "./demoPublish.js"; +import { listDemoPosts, resetDemoStore, upsertDemoPost } from "./demoStore.js"; +import { getSetupHealth } from "./setupHealth.js"; + +describe("demo fixtures", () => { + it("loads stable post fixtures on every call", () => { + const first = loadDemoPostFixtures(); + const second = loadDemoPostFixtures(); + + assert.equal(first.length, demoFixturePostCount()); + assert.equal(second.length, demoFixturePostCount()); + assert.deepEqual( + first.map((post) => post.summary.slug), + second.map((post) => post.summary.slug), + ); + }); + + it("includes a published guide, draft, image post, and linking post", () => { + const posts = loadDemoPostFixtures(); + const slugs = new Set(posts.map((post) => post.summary.slug)); + + assert.ok(slugs.has("getting-started-with-sourcedraft")); + assert.ok(slugs.has("draft-release-notes")); + assert.ok(slugs.has("publishing-with-images")); + assert.ok(slugs.has("linking-and-outline")); + + const draft = posts.find((post) => post.summary.slug === "draft-release-notes"); + const guide = posts.find( + (post) => post.summary.slug === "getting-started-with-sourcedraft", + ); + const images = posts.find((post) => post.summary.slug === "publishing-with-images"); + const links = posts.find((post) => post.summary.slug === "linking-and-outline"); + + 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 ?? "", /^## /mu); + }); + + it("loads stable media fixtures with required metadata", () => { + const media = loadDemoMediaFixtures(); + + assert.equal(media.length, demoFixtureMediaCount()); + assert.ok(media.length >= 2); + + for (const file of media) { + assert.ok(file.repoPath.length > 0); + assert.ok(file.publicPath.startsWith("/")); + assert.ok(file.filename.length > 0); + assert.ok(file.extension.length > 0); + assert.ok(file.kind === "image" || file.kind === "pdf"); + assert.ok(file.size > 0); + } + }); + + it("resetDemoStore reloads seed content after in-memory edits", async () => { + resetDemoStore(); + const before = (await listDemoPostsHandler()).body; + assert.equal(before.ok, true); + if (!before.ok) { + return; + } + + const initialCount = before.posts.length; + upsertDemoPost("src/content/blog/temp-demo-post.mdx", "---\ntitle: Temp\n---\n", { + path: "src/content/blog/temp-demo-post.mdx", + title: "Temp", + slug: "temp-demo-post", + pubDate: "2026-06-09", + category: "Notes", + draft: true, + }); + assert.equal(listDemoPosts().length, initialCount + 1); + + resetDemoStore(); + assert.equal(listDemoPosts().length, initialCount); + assert.equal(listDemoPosts().some((post) => post.slug === "temp-demo-post"), false); + }); + + it("demo publish does not use the GitHub publisher module", () => { + const source = readFileSync(resolve(import.meta.dirname, "demoPublish.ts"), "utf8"); + assert.doesNotMatch(source, /@sourcedraft\/github-publisher/u); + }); + + it("simulates publish in demo without GitHub credentials", async () => { + resetDemoStore(); + const runtime = loadPublicConfig(); + const result = await publishDemoArticle( + { + title: "Fixture publish test", + slug: "fixture-publish-test", + description: "Validates demo publish stays local to fixtures store.", + pubDate: "2026-06-08", + category: "Guides", + tags: ["demo"], + draft: false, + body: "# Fixture publish test\n\nNo GitHub commit.", + }, + runtime, + ); + + assert.equal(result.status, 200); + assert.equal(result.body.ok, true); + }); + + it("setup health diagnostics do not expose secrets", () => { + const report = getSetupHealth(); + const serialized = JSON.stringify(report); + + assert.doesNotMatch(serialized, /ghp_/u); + assert.doesNotMatch(serialized, /GITHUB_TOKEN=/u); + assert.doesNotMatch(serialized, /SOURCEDRAFT_ADMIN_PASSWORD=/u); + }); +}); diff --git a/apps/studio/server/demoMedia.ts b/apps/studio/server/demoMedia.ts new file mode 100644 index 0000000..84c462f --- /dev/null +++ b/apps/studio/server/demoMedia.ts @@ -0,0 +1,197 @@ +import { randomBytes } from "node:crypto"; +import type { Request } from "express"; +import Busboy from "busboy"; +import { joinPublicMediaPath } from "@sourcedraft/config"; +import type { PublishEnvConfig } from "./config.js"; +import { addDemoMedia, demoCommitSha, listDemoMedia } from "./demoStore.js"; +import type { ListMediaResponse } from "./listMedia.js"; +import { normalizeMediaDir } from "./mediaPaths.js"; +import { + ALLOWED_MIME_TYPES, + allowedTypesMessage, + extensionForMime, + matchesMediaSignature, + maxBytesForMime, + mediaKindFromMime, + uploadLimitMessage, +} from "./mediaValidation.js"; +import type { MediaUploadResponse } from "./media.js"; + +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; + +type ParsedUpload = { + buffer: Buffer; + filename: string; + mimeType: string; +}; + +function parseUpload(req: Request): Promise { + return new Promise((resolve, reject) => { + const busboy = Busboy({ + headers: req.headers, + limits: { + files: 1, + fileSize: MAX_UPLOAD_BYTES, + }, + }); + + let upload: ParsedUpload | null = null; + let rejected = false; + + busboy.on("file", (fieldName, stream, info) => { + if (fieldName !== "file") { + stream.resume(); + return; + } + + if (upload !== null) { + stream.resume(); + return; + } + + const chunks: Buffer[] = []; + upload = { + buffer: Buffer.alloc(0), + filename: info.filename, + mimeType: info.mimeType, + }; + + stream.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + + stream.on("limit", () => { + rejected = true; + reject(new Error("File exceeds the maximum upload limit.")); + }); + + stream.on("end", () => { + if (upload !== null) { + upload.buffer = Buffer.concat(chunks); + } + }); + }); + + busboy.on("finish", () => { + if (rejected) { + return; + } + + if (upload === null) { + reject(new Error('Upload requires a multipart field named "file".')); + return; + } + + resolve(upload); + }); + + busboy.on("error", (error) => { + reject(error); + }); + + req.pipe(busboy); + }); +} + +export async function listDemoMediaHandler(): Promise<{ + status: number; + body: ListMediaResponse; +}> { + return { + status: 200, + body: { ok: true, files: listDemoMedia() }, + }; +} + +export async function uploadDemoMedia( + req: Request, + env: Omit, +): Promise<{ status: number; body: MediaUploadResponse }> { + const mediaDir = normalizeMediaDir(env.mediaDir); + if (mediaDir.length === 0) { + return { + status: 500, + body: { ok: false, error: "Media directory is not configured." }, + }; + } + + let parsed: ParsedUpload; + try { + parsed = await parseUpload(req); + } catch (error) { + const message = + error instanceof Error ? error.message : "Could not parse upload."; + return { + status: 400, + body: { ok: false, error: message }, + }; + } + + if (parsed.buffer.length === 0) { + return { + status: 400, + body: { ok: false, error: "Uploaded file is empty." }, + }; + } + + if (!ALLOWED_MIME_TYPES.has(parsed.mimeType)) { + return { + status: 400, + body: { ok: false, error: allowedTypesMessage() }, + }; + } + + const kind = mediaKindFromMime(parsed.mimeType); + const maxBytes = maxBytesForMime(parsed.mimeType); + if (kind === null || maxBytes === null) { + return { + status: 400, + body: { ok: false, error: allowedTypesMessage() }, + }; + } + + if (parsed.buffer.length > maxBytes) { + return { + status: 400, + body: { ok: false, error: uploadLimitMessage(parsed.mimeType) }, + }; + } + + if (!matchesMediaSignature(parsed.buffer, parsed.mimeType)) { + return { + status: 400, + body: { + ok: false, + error: "File content does not match the declared file type.", + }, + }; + } + + const extension = extensionForMime(parsed.mimeType) ?? "bin"; + const uniqueSuffix = randomBytes(4).toString("hex"); + const repoFilename = `upload-${uniqueSuffix}.${extension}`; + const repoPath = `${mediaDir}/${repoFilename}`; + const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename); + const commitSha = demoCommitSha(); + + addDemoMedia({ + repoPath, + publicPath, + filename: repoFilename, + extension, + kind, + size: parsed.buffer.length, + }); + + return { + status: 200, + body: { + ok: true, + repoPath, + publicPath, + kind, + sha: commitSha, + commitSha, + }, + }; +} diff --git a/apps/studio/server/demoMode.ts b/apps/studio/server/demoMode.ts new file mode 100644 index 0000000..c07f59f --- /dev/null +++ b/apps/studio/server/demoMode.ts @@ -0,0 +1,31 @@ +export function isDemoModeForced(): boolean { + return process.env.SOURCEDRAFT_DEMO_MODE?.trim().toLowerCase() === "true"; +} + +export function isGitHubTokenConfigured(): boolean { + return (process.env.GITHUB_TOKEN?.trim().length ?? 0) > 0; +} + +export function isGitHubOwnerConfigured(): boolean { + return (process.env.GITHUB_OWNER?.trim().length ?? 0) > 0; +} + +export function isGitHubRepoConfigured(): boolean { + return (process.env.GITHUB_REPO?.trim().length ?? 0) > 0; +} + +export function isGitHubConfigured(): boolean { + return ( + isGitHubTokenConfigured() && + isGitHubOwnerConfigured() && + isGitHubRepoConfigured() + ); +} + +export function isDemoModeAvailable(): boolean { + if (isDemoModeForced()) { + return true; + } + + return !isGitHubConfigured(); +} diff --git a/apps/studio/server/demoPosts.ts b/apps/studio/server/demoPosts.ts new file mode 100644 index 0000000..c14cb67 --- /dev/null +++ b/apps/studio/server/demoPosts.ts @@ -0,0 +1,101 @@ +import { + validateArticle, + type ArticleInput, +} from "@sourcedraft/core"; +import type { PublishEnvConfig } from "./config.js"; +import { getDemoPost, listDemoPosts } from "./demoStore.js"; +import { + frontmatterToArticleInput, + splitFrontmatter, + slugFromPath, + type PostLoadResponse, + type PostsListResponse, +} from "./posts.js"; +import { safePostPath } from "./postPaths.js"; + +export async function listDemoPostsHandler(): Promise<{ + status: number; + body: PostsListResponse; +}> { + return { + status: 200, + body: { ok: true, posts: listDemoPosts() }, + }; +} + +export async function loadDemoPost( + path: string, + env: Omit, +): Promise<{ status: number; body: PostLoadResponse }> { + const safe = safePostPath(path, env.contentDir); + if (!safe.ok) { + return { + status: 400, + body: { ok: false, error: safe.error }, + }; + } + + const stored = getDemoPost(safe.path); + if (stored === null) { + return { + status: 404, + body: { ok: false, error: "Post not found in demo content." }, + }; + } + + const parsed = splitFrontmatter(stored.content); + if (parsed === null) { + return { + status: 400, + body: { ok: false, error: "Post frontmatter is missing or invalid." }, + }; + } + + const article = frontmatterToArticleInput( + safe.path, + parsed.frontmatter, + parsed.body, + ); + const validation = validateArticle(article); + if (!validation.valid) { + return { + status: 400, + body: { + ok: false, + error: "Loaded post failed validation.", + issues: validation.issues, + }, + }; + } + + return { + status: 200, + body: { + ok: true, + path: safe.path, + article: { + ...article, + sourcePath: safe.path, + }, + }, + }; +} + +export function summaryFromArticle( + path: string, + article: ArticleInput, +): { + title: string; + slug: string; + pubDate: string; + category: string; + draft: boolean; +} { + return { + title: typeof article.title === "string" ? article.title : slugFromPath(path), + slug: typeof article.slug === "string" ? article.slug : slugFromPath(path), + pubDate: typeof article.pubDate === "string" ? article.pubDate : "", + category: typeof article.category === "string" ? article.category : "", + draft: article.draft === true, + }; +} diff --git a/apps/studio/server/demoPublish.ts b/apps/studio/server/demoPublish.ts new file mode 100644 index 0000000..88257bb --- /dev/null +++ b/apps/studio/server/demoPublish.ts @@ -0,0 +1,90 @@ +import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; +import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; +import { + normalizeArticle, + validateArticle, + type Article, +} from "@sourcedraft/core"; +import type { PublishEnvConfig } from "./config.js"; +import { summaryFromArticle } from "./demoPosts.js"; +import { demoCommitSha, upsertDemoPost } from "./demoStore.js"; +import { safePostPath } from "./postPaths.js"; +import type { PublishRequestBody, PublishResponse } from "./publish.js"; + +function renderArticle(article: Article, adapter: PublishEnvConfig["adapter"]): string { + if (adapter === "markdown") { + return toMarkdown(article); + } + + return toAstroMdx(article); +} + +function defaultPostPath( + article: Article, + adapter: PublishEnvConfig["adapter"], + contentDir: string, +): string { + if (adapter === "markdown") { + return getMarkdownPath(article, { contentDir }); + } + + return getAstroMdxPath(article, { contentDir }); +} + +export async function publishDemoArticle( + body: PublishRequestBody, + env: Omit, +): Promise<{ status: number; body: PublishResponse }> { + const validation = validateArticle(body); + if (!validation.valid) { + return { + status: 400, + body: { + ok: false, + error: "Article validation failed.", + issues: validation.issues, + }, + }; + } + + const article = normalizeArticle(body); + let path: string; + let created = false; + + if (typeof body.sourcePath === "string" && body.sourcePath.trim().length > 0) { + const safe = safePostPath(body.sourcePath.trim(), env.contentDir); + if (!safe.ok) { + return { + status: 400, + body: { + ok: false, + error: safe.error, + }, + }; + } + + path = safe.path; + } else { + path = defaultPostPath(article, env.adapter, env.contentDir); + created = true; + } + + const content = renderArticle(article, env.adapter); + const commitSha = demoCommitSha(); + + upsertDemoPost(path, content, { + path, + ...summaryFromArticle(path, article), + }); + + return { + status: 200, + body: { + ok: true, + path, + created, + sha: commitSha, + commitSha, + }, + }; +} diff --git a/apps/studio/server/demoSession.test.ts b/apps/studio/server/demoSession.test.ts new file mode 100644 index 0000000..286d308 --- /dev/null +++ b/apps/studio/server/demoSession.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import type { Request } from "express"; +import { + isAuthenticatedDemoActive, + isRequestDemoSession, +} from "./auth.js"; + +function mockRequest(cookie?: string): Request { + return { + headers: cookie ? { cookie } : {}, + } as Request; +} + +describe("demo session guards", () => { + const originalDemoMode = process.env.SOURCEDRAFT_DEMO_MODE; + const originalToken = process.env.GITHUB_TOKEN; + const originalOwner = process.env.GITHUB_OWNER; + const originalRepo = process.env.GITHUB_REPO; + + afterEach(() => { + if (originalDemoMode === undefined) { + delete process.env.SOURCEDRAFT_DEMO_MODE; + } else { + process.env.SOURCEDRAFT_DEMO_MODE = originalDemoMode; + } + if (originalToken === undefined) { + delete process.env.GITHUB_TOKEN; + } else { + process.env.GITHUB_TOKEN = originalToken; + } + if (originalOwner === undefined) { + delete process.env.GITHUB_OWNER; + } else { + process.env.GITHUB_OWNER = originalOwner; + } + if (originalRepo === undefined) { + delete process.env.GITHUB_REPO; + } else { + process.env.GITHUB_REPO = originalRepo; + } + }); + + it("treats forced demo mode as active for authenticated demo routing", () => { + process.env.SOURCEDRAFT_DEMO_MODE = "true"; + process.env.GITHUB_TOKEN = "ghp_test"; + process.env.GITHUB_OWNER = "owner"; + process.env.GITHUB_REPO = "repo"; + + assert.equal(isRequestDemoSession(mockRequest()), true); + }); + + it("does not report demo active on auth status when unauthenticated", () => { + process.env.SOURCEDRAFT_DEMO_MODE = "true"; + process.env.GITHUB_TOKEN = "ghp_test"; + process.env.GITHUB_OWNER = "owner"; + process.env.GITHUB_REPO = "repo"; + + assert.equal(isAuthenticatedDemoActive(mockRequest()), false); + }); +}); diff --git a/apps/studio/server/demoStore.ts b/apps/studio/server/demoStore.ts new file mode 100644 index 0000000..ad3aa33 --- /dev/null +++ b/apps/studio/server/demoStore.ts @@ -0,0 +1,53 @@ +import { loadDemoMediaFixtures, loadDemoPostFixtures } from "./demo/loadFixtures.js"; +import type { DemoFixtureMedia, DemoFixturePost } from "./demo/types.js"; +import type { PostSummary } from "./posts.js"; +import type { MediaFileSummary } from "./listMedia.js"; + +type StoredPost = DemoFixturePost; + +let posts = new Map(); +let media: DemoFixtureMedia[] = []; + +function loadPostsFromFixtures(): Map { + const next = new Map(); + for (const fixture of loadDemoPostFixtures()) { + next.set(fixture.summary.path, fixture); + } + return next; +} + +export function resetDemoStore(): void { + posts = loadPostsFromFixtures(); + media = loadDemoMediaFixtures(); +} + +resetDemoStore(); + +export function listDemoPosts(): PostSummary[] { + return [...posts.values()] + .map((entry) => ({ ...entry.summary })) + .sort((left, right) => right.pubDate.localeCompare(left.pubDate)); +} + +export function getDemoPost(path: string): StoredPost | null { + return posts.get(path) ?? null; +} + +export function upsertDemoPost(path: string, content: string, summary: PostSummary): void { + posts.set(path, { + summary: { ...summary }, + content, + }); +} + +export function listDemoMedia(): MediaFileSummary[] { + return media.map((file) => ({ ...file })); +} + +export function addDemoMedia(file: MediaFileSummary): void { + media = [file, ...media.filter((entry) => entry.repoPath !== file.repoPath)]; +} + +export function demoCommitSha(): string { + return `demo${Date.now().toString(16).slice(-7)}`; +} diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 4799ba4..4ca942d 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -3,19 +3,27 @@ import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import express from "express"; import { + isAuthenticatedDemoActive, + enterDemo, getSessionToken, isAuthConfigured, + isRequestDemoSession, isSessionValid, login, logout, requireAuth, } from "./auth.js"; import { loadPublicConfig, loadPublishEnv } from "./config.js"; +import { listDemoPostsHandler, loadDemoPost } from "./demoPosts.js"; +import { listDemoMediaHandler, uploadDemoMedia } from "./demoMedia.js"; +import { publishDemoArticle } from "./demoPublish.js"; +import { isDemoModeAvailable, isDemoModeForced } from "./demoMode.js"; import { uploadMedia } from "./media.js"; import { listMedia } from "./listMedia.js"; import { listPosts, loadPost } from "./posts.js"; import { publishArticle, type PublishRequestBody } from "./publish.js"; import { requireSameSiteRequest } from "./requestProtection.js"; +import { getSetupHealth } from "./setupHealth.js"; const envPaths = [ resolve(process.cwd(), ".env"), @@ -36,10 +44,16 @@ const app = express(); app.use(express.json({ limit: "1mb" })); app.get("/api/auth/status", (req, res) => { + const token = getSessionToken(req); + const authenticated = isSessionValid(token); + res.json({ configured: isAuthConfigured(), - authenticated: isSessionValid(getSessionToken(req)), + authenticated, mode: "mvp-local-password", + demoMode: isAuthenticatedDemoActive(req), + demoModeForced: isDemoModeForced(), + demoModeAvailable: isDemoModeAvailable(), }); }); @@ -58,13 +72,25 @@ app.post("/api/auth/login", requireSameSiteRequest, (req, res) => { res.json({ ok: true }); }); +app.post("/api/auth/demo", requireSameSiteRequest, (req, res) => { + const result = enterDemo(req, res); + + if (!result.ok) { + res.status(403).json({ ok: false, error: result.error }); + return; + } + + res.json({ ok: true, demoMode: true }); +}); + app.post("/api/auth/logout", requireSameSiteRequest, (req, res) => { logout(req, res); res.json({ ok: true }); }); -app.get("/api/config", requireAuth, (_req, res) => { +app.get("/api/config", requireAuth, (req, res) => { const runtime = loadPublicConfig(); + const demoMode = isRequestDemoSession(req); res.json({ adapter: runtime.adapter, @@ -73,21 +99,41 @@ app.get("/api/config", requireAuth, (_req, res) => { publicMediaPath: runtime.publicMediaPath, defaultBranch: runtime.branch, categories: runtime.categories, - githubOwner: runtime.owner, - githubRepo: runtime.repo, + githubOwner: demoMode ? "demo" : runtime.owner, + githubRepo: demoMode ? "sample-posts" : runtime.repo, + demoMode, }); }); +app.get("/api/health/setup", requireAuth, (_req, res) => { + res.json(getSetupHealth()); +}); + app.get("/api/posts", requireAuth, async (req, res) => { + const demoMode = isRequestDemoSession(req); + const pathParam = + typeof req.query.path === "string" ? req.query.path.trim() : ""; + + if (demoMode) { + const runtime = loadPublicConfig(); + + if (pathParam.length > 0) { + const result = await loadDemoPost(pathParam, runtime); + res.status(result.status).json(result.body); + return; + } + + const result = await listDemoPostsHandler(); + 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 pathParam = - typeof req.query.path === "string" ? req.query.path.trim() : ""; - if (pathParam.length > 0) { const result = await loadPost(pathParam, envResult.config); res.status(result.status).json(result.body); @@ -98,7 +144,13 @@ app.get("/api/posts", requireAuth, async (req, res) => { res.status(result.status).json(result.body); }); -app.get("/api/media", requireAuth, async (_req, res) => { +app.get("/api/media", requireAuth, async (req, res) => { + if (isRequestDemoSession(req)) { + const result = await listDemoMediaHandler(); + res.status(result.status).json(result.body); + return; + } + const envResult = loadPublishEnv(); if (!envResult.ok) { res.status(500).json({ ok: false, error: envResult.error }); @@ -114,6 +166,13 @@ app.post( requireSameSiteRequest, requireAuth, async (req, res) => { + if (isRequestDemoSession(req)) { + const runtime = loadPublicConfig(); + const result = await uploadDemoMedia(req, runtime); + res.status(result.status).json(result.body); + return; + } + const envResult = loadPublishEnv(); if (!envResult.ok) { res.status(500).json({ ok: false, error: envResult.error }); @@ -126,6 +185,16 @@ app.post( ); app.post("/api/publish", requireSameSiteRequest, requireAuth, async (req, res) => { + if (isRequestDemoSession(req)) { + const runtime = loadPublicConfig(); + const result = await publishDemoArticle( + req.body as PublishRequestBody, + runtime, + ); + res.status(result.status).json(result.body); + return; + } + const envResult = loadPublishEnv(); if (!envResult.ok) { res.status(500).json({ ok: false, error: envResult.error }); diff --git a/apps/studio/server/posts.ts b/apps/studio/server/posts.ts index e0b5114..5f15bd6 100644 --- a/apps/studio/server/posts.ts +++ b/apps/studio/server/posts.ts @@ -32,7 +32,7 @@ function createPublisher(env: PublishEnvConfig) { }); } -function slugFromPath(path: string): string { +export function slugFromPath(path: string): string { const filename = path.split("/").pop() ?? ""; return filename.replace(/\.(mdx|md)$/iu, ""); } @@ -125,7 +125,7 @@ function parseFrontmatter(yaml: string): Record { return result; } -function splitFrontmatter( +export function splitFrontmatter( content: string, ): { frontmatter: Record; body: string } | null { if (!content.startsWith("---\n")) { @@ -146,7 +146,7 @@ function splitFrontmatter( }; } -function frontmatterToArticleInput( +export function frontmatterToArticleInput( path: string, frontmatter: Record, body: string, diff --git a/apps/studio/server/setupHealth.test.ts b/apps/studio/server/setupHealth.test.ts new file mode 100644 index 0000000..5bb2315 --- /dev/null +++ b/apps/studio/server/setupHealth.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { getSetupHealth } from "./setupHealth.js"; + +describe("setup health", () => { + it("returns safe diagnostics without secrets", () => { + const report = getSetupHealth(); + + assert.equal(typeof report.adminPasswordConfigured, "boolean"); + assert.equal(typeof report.githubTokenConfigured, "boolean"); + assert.equal(typeof report.demoModeAvailable, "boolean"); + assert.ok(Array.isArray(report.checks)); + assert.ok(report.checks.length >= 8); + + const serialized = JSON.stringify(report); + assert.doesNotMatch(serialized, /ghp_/u); + assert.doesNotMatch(serialized, /GITHUB_TOKEN=/u); + }); + + it("includes a next action when setup is incomplete", () => { + const report = getSetupHealth(); + if (!report.githubReady && !report.demoModeForced) { + assert.ok(report.nextAction); + } + }); +}); diff --git a/apps/studio/server/setupHealth.ts b/apps/studio/server/setupHealth.ts new file mode 100644 index 0000000..aaf6431 --- /dev/null +++ b/apps/studio/server/setupHealth.ts @@ -0,0 +1,189 @@ +import { isAuthConfigured } from "./auth.js"; +import { + loadProjectConfig, + loadPublicConfig, + type SupportedAdapter, +} from "./config.js"; +import { + isDemoModeAvailable, + isDemoModeForced, + isGitHubConfigured, + isGitHubOwnerConfigured, + isGitHubRepoConfigured, + isGitHubTokenConfigured, +} from "./demoMode.js"; + +export type SetupHealthCheck = { + id: string; + label: string; + ok: boolean; + detail: string; +}; + +export type SetupHealthReport = { + ok: boolean; + adminPasswordConfigured: boolean; + githubOwnerConfigured: boolean; + githubRepoConfigured: boolean; + githubTokenConfigured: boolean; + contentDirConfigured: boolean; + mediaDirConfigured: boolean; + publicMediaPathConfigured: boolean; + adapterValid: boolean; + demoModeForced: boolean; + demoModeAvailable: boolean; + githubReady: boolean; + checks: SetupHealthCheck[]; + nextAction: string | null; +}; + +const SUPPORTED_ADAPTERS = new Set(["astro-mdx", "markdown"]); + +function resolveAdapter(rawAdapter: string): SupportedAdapter | null { + if (SUPPORTED_ADAPTERS.has(rawAdapter)) { + return rawAdapter as SupportedAdapter; + } + + return null; +} + +export function getSetupHealth(): SetupHealthReport { + const project = loadProjectConfig(); + const runtime = loadPublicConfig(); + const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; + const adapter = resolveAdapter(rawAdapter); + const contentDir = runtime.contentDir.trim(); + const mediaDir = runtime.mediaDir.trim(); + const publicMediaPath = runtime.publicMediaPath.trim(); + + const adminPasswordConfigured = isAuthConfigured(); + const githubOwnerConfigured = isGitHubOwnerConfigured(); + const githubRepoConfigured = isGitHubRepoConfigured(); + const githubTokenConfigured = isGitHubTokenConfigured(); + const contentDirConfigured = contentDir.length > 0; + const mediaDirConfigured = mediaDir.length > 0; + const publicMediaPathConfigured = publicMediaPath.length > 0; + const adapterValid = adapter !== null; + const demoModeForced = isDemoModeForced(); + const demoModeAvailable = isDemoModeAvailable(); + const githubReady = + githubOwnerConfigured && + githubRepoConfigured && + githubTokenConfigured && + adapterValid; + + const checks: SetupHealthCheck[] = [ + { + id: "admin-password", + label: "Admin password", + ok: adminPasswordConfigured, + detail: adminPasswordConfigured + ? "SOURCEDRAFT_ADMIN_PASSWORD is set on the server." + : "Set SOURCEDRAFT_ADMIN_PASSWORD in .env for normal sign-in.", + }, + { + id: "github-owner", + label: "GitHub owner", + ok: githubOwnerConfigured, + detail: githubOwnerConfigured + ? "GITHUB_OWNER is configured." + : "Set GITHUB_OWNER in .env.", + }, + { + id: "github-repo", + label: "GitHub repository", + ok: githubRepoConfigured, + detail: githubRepoConfigured + ? "GITHUB_REPO is configured." + : "Set GITHUB_REPO in .env.", + }, + { + id: "github-token", + label: "GitHub token (server-side)", + ok: githubTokenConfigured, + detail: githubTokenConfigured + ? "GITHUB_TOKEN is present on the server. The value is never sent to the browser." + : "Set GITHUB_TOKEN in .env for GitHub publishing.", + }, + { + id: "content-dir", + label: "Content directory", + ok: contentDirConfigured, + detail: contentDirConfigured + ? `contentDir: ${contentDir}` + : "Configure contentDir in sourcedraft.config.json.", + }, + { + id: "media-dir", + label: "Media directory", + ok: mediaDirConfigured, + detail: mediaDirConfigured + ? `mediaDir: ${mediaDir}` + : "Configure mediaDir in sourcedraft.config.json.", + }, + { + id: "public-media-path", + label: "Public media path", + ok: publicMediaPathConfigured, + detail: publicMediaPathConfigured + ? `publicMediaPath: ${publicMediaPath}` + : "Configure publicMediaPath in sourcedraft.config.json or CMS_PUBLIC_MEDIA_PATH.", + }, + { + id: "adapter", + label: "Adapter", + ok: adapterValid, + detail: adapterValid + ? `Using ${adapter} adapter.` + : `Unsupported adapter "${rawAdapter}". Use astro-mdx or markdown.`, + }, + { + id: "demo-mode", + label: "Demo mode", + ok: true, + detail: demoModeForced + ? "SOURCEDRAFT_DEMO_MODE=true — GitHub commits are disabled." + : !isGitHubConfigured() + ? "GitHub is not fully configured — Studio uses demo content and simulated publish." + : "Demo mode is off. GitHub publishing is enabled when credentials are valid.", + }, + ]; + + let nextAction: string | null = null; + + if (demoModeForced) { + nextAction = + "Demo mode is active. Explore Studio locally or configure GitHub and disable SOURCEDRAFT_DEMO_MODE for real publishing."; + } else if (!adminPasswordConfigured && demoModeAvailable) { + nextAction = + "Enter demo mode from the sign-in screen or set SOURCEDRAFT_ADMIN_PASSWORD for password sign-in."; + } else if (!adminPasswordConfigured) { + nextAction = "Set SOURCEDRAFT_ADMIN_PASSWORD in .env and restart the API server."; + } else if (!githubReady) { + nextAction = + "Complete GitHub setup in .env (GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO) or use demo mode to explore without GitHub."; + } else { + nextAction = null; + } + + return { + ok: githubReady || demoModeAvailable, + adminPasswordConfigured, + githubOwnerConfigured, + githubRepoConfigured, + githubTokenConfigured, + contentDirConfigured, + mediaDirConfigured, + publicMediaPathConfigured, + adapterValid, + demoModeForced, + demoModeAvailable, + githubReady, + checks, + nextAction, + }; +} + +export function isRequestInDemoMode(sessionDemo: boolean): boolean { + return isDemoModeForced() || !isGitHubConfigured() || sessionDemo; +} diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 4f03cbc..0787545 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -4,6 +4,7 @@ import { normalizeArticle, validateArticle } from "@sourcedraft/core"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AppBar } from "./components/AppBar"; import { AstroMdxPreview } from "./components/AstroMdxPreview"; +import { DemoBanner } from "./components/DemoBanner"; import { LoginScreen } from "./components/LoginScreen"; import { PostDetailsPanel } from "./components/PostDetailsPanel"; import { PostSidebar } from "./components/PostSidebar"; @@ -13,6 +14,7 @@ import { SettingsPanel } from "./components/SettingsPanel"; import { WritingCanvas } from "./components/WritingCanvas"; import { useDocumentAutosave } from "./hooks/useDocumentAutosave"; import { + enterDemo, fetchAuthStatus, login as loginToStudio, logout as logoutFromStudio, @@ -49,6 +51,9 @@ function App() { const [authChecked, setAuthChecked] = useState(false); const [authenticated, setAuthenticated] = useState(false); const [authConfigured, setAuthConfigured] = useState(false); + const [demoMode, setDemoMode] = useState(false); + const [demoModeForced, setDemoModeForced] = useState(false); + const [demoModeAvailable, setDemoModeAvailable] = useState(false); const [view, setView] = useState("editor"); const [studioConfig, setStudioConfig] = useState( FALLBACK_STUDIO_CONFIG, @@ -97,6 +102,9 @@ function App() { fetchAuthStatus().then((status) => { setAuthConfigured(status.configured); setAuthenticated(status.authenticated); + setDemoMode(status.demoMode === true); + setDemoModeForced(status.demoModeForced === true); + setDemoModeAvailable(status.demoModeAvailable === true); setAuthChecked(true); }); }, []); @@ -108,6 +116,7 @@ function App() { fetchStudioConfig().then((config) => { setStudioConfig(config); + setDemoMode(config.demoMode === true); setForm((current) => { if (current.title.length > 0 || current.body.length > 0) { return current; @@ -318,7 +327,23 @@ function App() { async function handleLogin(password: string) { const result = await loginToStudio(password); if (result.ok) { + const status = await fetchAuthStatus(); setAuthenticated(true); + setDemoMode(status.demoMode === true); + setDemoModeForced(status.demoModeForced === true); + setDemoModeAvailable(status.demoModeAvailable === true); + } + return result; + } + + async function handleEnterDemo() { + const result = await enterDemo(); + if (result.ok) { + const status = await fetchAuthStatus(); + setAuthenticated(true); + setDemoMode(true); + setDemoModeForced(status.demoModeForced === true); + setDemoModeAvailable(status.demoModeAvailable === true); } return result; } @@ -392,12 +417,19 @@ function App() { if (!authenticated) { return ( - + ); } return (
+ {demoMode && } diff --git a/apps/studio/src/components/DemoBanner.tsx b/apps/studio/src/components/DemoBanner.tsx new file mode 100644 index 0000000..a6db8b1 --- /dev/null +++ b/apps/studio/src/components/DemoBanner.tsx @@ -0,0 +1,16 @@ +type DemoBannerProps = { + forced?: boolean; +}; + +export function DemoBanner({ forced = false }: DemoBannerProps) { + return ( +
+

Demo mode — no GitHub commits are made

+

+ {forced + ? "This Studio instance is running with SOURCEDRAFT_DEMO_MODE enabled." + : "You are exploring sample posts locally. Publish and uploads are simulated only."} +

+
+ ); +} diff --git a/apps/studio/src/components/LoginScreen.tsx b/apps/studio/src/components/LoginScreen.tsx index d3df621..9863f11 100644 --- a/apps/studio/src/components/LoginScreen.tsx +++ b/apps/studio/src/components/LoginScreen.tsx @@ -1,16 +1,26 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; type LoginScreenProps = { configured: boolean; + demoAvailable: boolean; + demoForced: boolean; onLogin: (password: string) => Promise<{ ok: boolean; error?: string }>; + onEnterDemo: () => Promise<{ ok: boolean; error?: string }>; }; -export function LoginScreen({ configured, onLogin }: LoginScreenProps) { +export function LoginScreen({ + configured, + demoAvailable, + demoForced, + onLogin, + onEnterDemo, +}: LoginScreenProps) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [enteringDemo, setEnteringDemo] = useState(false); - async function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: FormEvent) { event.preventDefault(); setSubmitting(true); setError(null); @@ -26,6 +36,20 @@ export function LoginScreen({ configured, onLogin }: LoginScreenProps) { setSubmitting(false); } + async function handleEnterDemo() { + setEnteringDemo(true); + setError(null); + + const result = await onEnterDemo(); + if (!result.ok) { + setError(result.error ?? "Could not enter demo mode."); + setEnteringDemo(false); + return; + } + + setEnteringDemo(false); + } + return (
@@ -34,13 +58,22 @@ export function LoginScreen({ configured, onLogin }: LoginScreenProps) {

Sign in to write and publish

+ {demoForced && ( +
+

Demo mode enabled

+

+ This instance runs in demo mode. GitHub commits are disabled. +

+
+ )} +

This workspace uses one shared password, checked on the server. It is meant for local or private use.

- {!configured && ( + {!configured && !demoAvailable && (

Password not configured

@@ -50,6 +83,16 @@ export function LoginScreen({ configured, onLogin }: LoginScreenProps) {

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

GitHub not configured

+

+ You can explore demo mode without GitHub credentials, or configure + a password and GitHub settings for real publishing. +

+
+ )} + @@ -71,11 +114,29 @@ export function LoginScreen({ configured, onLogin }: LoginScreenProps) {
+ + {demoAvailable && ( +
+

+ Explore Studio with sample posts. No GitHub token required. +

+ +
+ )}
); diff --git a/apps/studio/src/components/PublishGate.tsx b/apps/studio/src/components/PublishGate.tsx index 8060faa..57b41b1 100644 --- a/apps/studio/src/components/PublishGate.tsx +++ b/apps/studio/src/components/PublishGate.tsx @@ -4,6 +4,7 @@ type PublishGateProps = { publishError: string | null; publishSuccess: string | null; githubReady: boolean; + demoMode: boolean; onPublish: () => void; }; @@ -11,12 +12,13 @@ function disabledReason( ready: boolean, githubReady: boolean, publishing: boolean, + demoMode: boolean, ): string | null { if (publishing) { return null; } - if (!githubReady) { + if (!githubReady && !demoMode) { return "Set GITHUB_OWNER, GITHUB_REPO, and GITHUB_TOKEN in .env, then check Settings."; } @@ -33,10 +35,11 @@ export function PublishGate({ publishError, publishSuccess, githubReady, + demoMode, onPublish, }: PublishGateProps) { - const canPublish = ready && !publishing && githubReady; - const reason = disabledReason(ready, githubReady, publishing); + const canPublish = ready && !publishing && (githubReady || demoMode); + const reason = disabledReason(ready, githubReady, publishing, demoMode); return (
@@ -47,9 +50,13 @@ export function PublishGate({

{publishing - ? "Saving to GitHub…" + ? demoMode + ? "Simulating publish…" + : "Saving to GitHub…" : ready - ? "Your post will be committed to the repository" + ? demoMode + ? "Demo mode will simulate a successful publish" + : "Your post will be committed to the repository" : "Complete required fields to enable publish"}

@@ -60,7 +67,11 @@ export function PublishGate({ aria-describedby={reason ? "publish-disabled-reason" : undefined} onClick={onPublish} > - {publishing ? "Publishing…" : "Publish to GitHub"} + {publishing + ? "Publishing…" + : demoMode + ? "Simulate publish" + : "Publish to GitHub"} @@ -82,10 +93,14 @@ export function PublishGate({ {publishSuccess && (
-

Published successfully

+

+ {demoMode ? "Publish simulated" : "Published successfully"} +

{publishSuccess}

- Your site build or CI will pick up the file from the repository. + {demoMode + ? "No GitHub commit was made. Configure GitHub in .env for real publishing." + : "Your site build or CI will pick up the file from the repository."}

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

Settings

diff --git a/apps/studio/src/components/SetupHealthPanel.tsx b/apps/studio/src/components/SetupHealthPanel.tsx new file mode 100644 index 0000000..16f5fa7 --- /dev/null +++ b/apps/studio/src/components/SetupHealthPanel.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { + fetchSetupHealth, + type SetupHealthReport, +} from "../lib/setupHealth.js"; + +export function SetupHealthPanel() { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchSetupHealth().then((next) => { + setReport(next); + setLoading(false); + }); + }, []); + + return ( +
+
+

+ Setup health +

+

+ Safe server-side checks — tokens and secrets are never shown +

+
+ + {loading && ( +

+ Checking setup… +

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

+ Could not load setup health. Confirm the publish API is running. +

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

Next action

+

{report.nextAction}

+
+ )} + +
    + {report.checks.map((check) => ( +
  • + + {check.label} + {check.detail} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 1fcae91..024da84 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -1704,6 +1704,109 @@ select.field__input:focus-visible { color: var(--text-dim); } +.login-screen__demo { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.login-screen__demo-copy { + margin: 0; + font-size: var(--text-xs); + color: var(--text-muted); + line-height: 1.45; +} + +.login-screen__demo-button { + align-self: flex-start; +} + +.login-screen__demo-notice { + margin: 0 0 12px; +} + +.demo-banner { + padding: 10px 16px; + border-bottom: 1px solid #e8d4a8; + background: #fff8e8; +} + +.demo-banner__title { + margin: 0; + font-size: var(--text-sm); + font-weight: 600; + color: var(--text); +} + +.demo-banner__body { + margin: 4px 0 0; + font-size: var(--text-xs); + color: var(--text-muted); + line-height: 1.45; +} + +.setup-health__loading, +.setup-health__error { + margin: 0; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.setup-health__next-action { + margin-bottom: 12px; +} + +.setup-health__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.setup-health__item { + display: grid; + grid-template-columns: 88px 140px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} + +.setup-health__item--ok { + border-color: #c8e6c9; + background: #f7fff7; +} + +.setup-health__item--warn { + border-color: #e8d4a8; + background: #fffaf0; +} + +.setup-health__status { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.setup-health__label { + font-size: var(--text-xs); + font-weight: 600; +} + +.setup-health__detail { + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + .visually-hidden { position: absolute; width: 1px; diff --git a/apps/studio/src/lib/auth.ts b/apps/studio/src/lib/auth.ts index 82dc7ea..9e03b19 100644 --- a/apps/studio/src/lib/auth.ts +++ b/apps/studio/src/lib/auth.ts @@ -2,25 +2,58 @@ export type AuthStatus = { configured: boolean; authenticated: boolean; mode: string; + demoMode?: boolean; + demoModeForced?: boolean; + demoModeAvailable?: boolean; }; +const AUTH_FETCH_TIMEOUT_MS = 5000; + const AUTH_FETCH_OPTIONS: RequestInit = { credentials: "include", }; export async function fetchAuthStatus(): Promise { try { - const response = await fetch("/api/auth/status", AUTH_FETCH_OPTIONS); + const response = await fetch("/api/auth/status", { + ...AUTH_FETCH_OPTIONS, + signal: AbortSignal.timeout(AUTH_FETCH_TIMEOUT_MS), + }); if (!response.ok) { - return { configured: false, authenticated: false, mode: "mvp-local-password" }; + return { + configured: false, + authenticated: false, + mode: "mvp-local-password", + demoModeAvailable: false, + }; } return (await response.json()) as AuthStatus; } catch { - return { configured: false, authenticated: false, mode: "mvp-local-password" }; + return { + configured: false, + authenticated: false, + mode: "mvp-local-password", + demoModeAvailable: false, + }; } } +export async function enterDemo(): Promise<{ ok: boolean; error?: string }> { + const response = await fetch("/api/auth/demo", { + ...AUTH_FETCH_OPTIONS, + method: "POST", + }); + + const data = (await response.json()) as { ok: boolean; error?: string }; + + if (!response.ok || !data.ok) { + return { ok: false, error: data.error ?? "Could not enter demo mode." }; + } + + return { ok: true }; +} + export async function login(password: string): Promise<{ ok: boolean; error?: string }> { const response = await fetch("/api/auth/login", { ...AUTH_FETCH_OPTIONS, diff --git a/apps/studio/src/lib/setupHealth.ts b/apps/studio/src/lib/setupHealth.ts new file mode 100644 index 0000000..52b7138 --- /dev/null +++ b/apps/studio/src/lib/setupHealth.ts @@ -0,0 +1,36 @@ +export type SetupHealthCheck = { + id: string; + label: string; + ok: boolean; + detail: string; +}; + +export type SetupHealthReport = { + ok: boolean; + adminPasswordConfigured: boolean; + githubOwnerConfigured: boolean; + githubRepoConfigured: boolean; + githubTokenConfigured: boolean; + contentDirConfigured: boolean; + mediaDirConfigured: boolean; + publicMediaPathConfigured: boolean; + adapterValid: boolean; + demoModeForced: boolean; + demoModeAvailable: boolean; + githubReady: boolean; + checks: SetupHealthCheck[]; + nextAction: string | null; +}; + +export async function fetchSetupHealth(): Promise { + try { + const response = await fetch("/api/health/setup", { credentials: "include" }); + if (!response.ok) { + return null; + } + + return (await response.json()) as SetupHealthReport; + } catch { + return null; + } +} diff --git a/apps/studio/src/lib/studioConfig.ts b/apps/studio/src/lib/studioConfig.ts index 6a10b0a..225a7e3 100644 --- a/apps/studio/src/lib/studioConfig.ts +++ b/apps/studio/src/lib/studioConfig.ts @@ -7,6 +7,7 @@ export type StudioConfig = { categories: string[]; githubOwner: string; githubRepo: string; + demoMode?: boolean; }; export const FALLBACK_STUDIO_CONFIG: StudioConfig = { @@ -41,6 +42,7 @@ export async function fetchStudioConfig(): Promise { : FALLBACK_STUDIO_CONFIG.categories, githubOwner: data.githubOwner || "", githubRepo: data.githubRepo || "", + demoMode: data.demoMode === true, }; } catch { return FALLBACK_STUDIO_CONFIG; diff --git a/docs/assets/README.md b/docs/assets/README.md index e17166e..905a011 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -7,9 +7,16 @@ Static images for SourceDraft documentation (primarily README screenshots). Use lowercase kebab-case PNG files: - `studio-overview.png` -- `editor-preview.png` -- `media-upload.png` +- `editor.png` +- `toolbar.png` +- `autosave.png` +- `media-library.png` +- `content-quality.png` +- `preview.png` - `publish-success.png` +- `setup-health.png` + +Regenerate with `pnpm screenshots:generate` from the repository root (demo mode, no GitHub credentials). Add new screenshots only when they reflect the current Studio UI. @@ -22,7 +29,7 @@ Before committing an image, confirm it does **not** show: - Private repository names you do not want public (use a test repo or blur) - Personal email addresses, internal URLs, or unrelated proprietary content -Studio Settings fields are read-only, but screenshots can still expose owner/repo names and folder paths. Use a dedicated test GitHub repository when possible. +Automated screenshots use demo mode fixtures only. Manual GitHub-mode captures should use a dedicated test repository. ## Usage @@ -32,4 +39,4 @@ Reference images from the root README or docs with relative paths, for example: ![Studio overview](docs/assets/studio-overview.png) ``` -See [screenshots.md](../screenshots.md) for capture instructions. +See [screenshots.md](../screenshots.md) for capture and regeneration instructions. diff --git a/docs/assets/autosave.png b/docs/assets/autosave.png new file mode 100644 index 0000000..a93e47a Binary files /dev/null and b/docs/assets/autosave.png differ diff --git a/docs/assets/content-quality.png b/docs/assets/content-quality.png new file mode 100644 index 0000000..3707698 Binary files /dev/null and b/docs/assets/content-quality.png differ diff --git a/docs/assets/editor.png b/docs/assets/editor.png new file mode 100644 index 0000000..d829223 Binary files /dev/null and b/docs/assets/editor.png differ diff --git a/docs/assets/media-library.png b/docs/assets/media-library.png new file mode 100644 index 0000000..51e40f9 Binary files /dev/null and b/docs/assets/media-library.png differ diff --git a/docs/assets/preview.png b/docs/assets/preview.png new file mode 100644 index 0000000..2209269 Binary files /dev/null and b/docs/assets/preview.png differ diff --git a/docs/assets/publish-success.png b/docs/assets/publish-success.png new file mode 100644 index 0000000..30f5f0d Binary files /dev/null and b/docs/assets/publish-success.png differ diff --git a/docs/assets/setup-health.png b/docs/assets/setup-health.png new file mode 100644 index 0000000..077a4c9 Binary files /dev/null and b/docs/assets/setup-health.png differ diff --git a/docs/assets/studio-overview.png b/docs/assets/studio-overview.png new file mode 100644 index 0000000..dc17936 Binary files /dev/null and b/docs/assets/studio-overview.png differ diff --git a/docs/assets/toolbar.png b/docs/assets/toolbar.png new file mode 100644 index 0000000..eb747e7 Binary files /dev/null and b/docs/assets/toolbar.png differ diff --git a/docs/demo-mode.md b/docs/demo-mode.md new file mode 100644 index 0000000..a2df330 --- /dev/null +++ b/docs/demo-mode.md @@ -0,0 +1,80 @@ +# Demo mode + +Demo mode lets you explore SourceDraft Studio without GitHub credentials. It is intended for onboarding, smoke tests, screenshots, and local evaluation — not production publishing. + +## How to enable + +1. **Environment flag:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env` and restart the API, or +2. **Opt-in:** leave `GITHUB_TOKEN`, `GITHUB_OWNER`, and `GITHUB_REPO` unset and click **Explore demo mode** on the sign-in screen. + +Start Studio with: + +```bash +pnpm dev +``` + +Use `pnpm dev` from the repository root so both the Vite UI and publish API run together. + +## What demo mode does + +- Loads **stable seed content** from fixture files in the repository +- Lets you open, edit, and preview sample posts in the browser +- Simulates media upload paths and publish success +- Shows a banner: **Demo mode — no GitHub commits are made** +- Never calls the GitHub API for posts or media, even if credentials exist while `SOURCEDRAFT_DEMO_MODE=true` + +## Seed content (fixtures) + +Fixture files live under: + +- `apps/studio/server/demo/fixtures/posts.ts` — sample MDX posts +- `apps/studio/server/demo/fixtures/media.ts` — sample media metadata + +The seed set includes: + +| Post | Purpose | +|------|---------| +| Getting started with SourceDraft | Published guide | +| Draft release notes | Draft badge and filters | +| Publishing with images | Inline image Markdown and hero image path | +| Linking and document outline | Headings and internal link examples | + +Media fixtures include PNG and PDF metadata with `repoPath`, `publicPath`, `filename`, `extension`, `kind`, and `size`. No binary files are stored in the repo for demo media. + +## Session behavior vs API restart + +| Event | What happens | +|-------|----------------| +| **API starts** | Demo store reloads from fixture files — same seed every time | +| **During a session** | Edits, simulated publish, and uploads update in-memory state only | +| **API restarts** | In-memory edits are discarded; fixtures load again | +| **GitHub** | No commits are made in demo mode | + +Demo edits are **temporary for the running API process**. This is expected. Persisting demo changes across restarts is not a goal for v0.1. + +## Security notes + +- GitHub tokens and admin passwords stay server-side in `.env` +- `GET /api/health/setup` returns booleans only — never secret values +- Demo mode is not a substitute for production auth hardening + +**MVP password auth is intended for local/private use.** Do not expose Studio on the public internet without HTTPS, stronger auth, and deployment hardening. + +## Related docs + +- [getting-started.md](getting-started.md) — install and first run +- [manual-acceptance-test.md](manual-acceptance-test.md) — release checklist +- [screenshots.md](screenshots.md) — capture guide using demo mode +- [security.md](security.md) — secrets and request protection + +## Smoke tests and screenshots + +Playwright smoke tests and screenshot generation run against demo mode: + +```bash +pnpm exec playwright install chromium # first time only +pnpm test:e2e +pnpm screenshots:generate # writes docs/assets/*.png +``` + +CI runs `pnpm test:e2e` on every push/PR to `main`. diff --git a/docs/getting-started.md b/docs/getting-started.md index d95f558..2f2504d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,6 +28,8 @@ cp .env.example .env ```env SOURCEDRAFT_ADMIN_PASSWORD=your-local-studio-password +# Optional: force demo mode (no GitHub commits) +# SOURCEDRAFT_DEMO_MODE=true GITHUB_TOKEN=ghp_... GITHUB_OWNER=your-username-or-org GITHUB_REPO=your-site-repo @@ -57,6 +59,23 @@ Sign in with `SOURCEDRAFT_ADMIN_PASSWORD`. **MVP password auth is intended for local/private use.** Do not expose Studio on the public internet without extra hardening. +## Demo mode (no GitHub required) + +Use demo mode to explore Studio before connecting a repository: + +1. **Environment flag:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env` and restart the API, or +2. **Opt-in:** leave `GITHUB_TOKEN`, `GITHUB_OWNER`, and `GITHUB_REPO` unset and click **Explore demo mode** on the sign-in screen. + +Demo mode provides sample posts from repository fixtures, local editing, simulated media upload paths, and simulated publish success. A banner reads: **Demo mode — no GitHub commits are made**. Session edits are temporary; restarting the API reloads the same seed content. See [demo-mode.md](demo-mode.md). + +Demo mode never sends your GitHub token to the browser and never commits to GitHub, even if credentials are present while `SOURCEDRAFT_DEMO_MODE=true`. + +## Setup health + +Open **Settings** in Studio. The **Setup health** section shows booleans for admin password, GitHub owner/repo, server-side token presence (never the value), content/media paths, adapter, and demo mode status. It suggests a next action when setup is incomplete. + +The publish API also exposes `GET /api/health/setup` (authenticated) with the same safe diagnostics. + ## 5. Write and publish 1. **Posts** sidebar — open an existing post, or click **New post** @@ -68,6 +87,31 @@ SourceDraft validates, builds the file with your adapter, and commits to `conten How that commit works: [github-publishing.md](github-publishing.md) +## Smoke tests (Playwright) + +Browser smoke tests run against demo mode — no live GitHub credentials required: + +```bash +pnpm exec playwright install chromium # first time only +pnpm test:e2e +``` + +From `apps/studio`, use the same commands. CI runs `pnpm test:e2e` after build and unit tests on every push/PR to `main`. + +These tests cover sign-in/demo entry, post list, editor, toolbar, autosave status, media library, content quality, setup health, and simulated publish. + +Regenerate README screenshots (writes to `docs/assets/`): + +```bash +pnpm screenshots:generate +``` + +Unit tests (default): + +```bash +pnpm test +``` + ## 6. Verify Open your site repo on GitHub and confirm the new file (and any uploaded images in `mediaDir`). Run your usual site build or wait for CI. @@ -86,5 +130,6 @@ Open your site repo on GitHub and confirm the new file (and any uploaded images | Wrong file path | `contentDir` in config | | Upload rejected | File type or 5 MB limit; see [media.md](media.md) | | Empty post list | Wrong repo in `.env` or no `.md`/`.mdx` in `contentDir` | +| Demo only | GitHub not configured or `SOURCEDRAFT_DEMO_MODE=true` — expected; configure GitHub for real publish | Plain-language intro: [non-technical-overview.md](non-technical-overview.md) diff --git a/docs/manual-acceptance-test.md b/docs/manual-acceptance-test.md index 08d54b7..e1e6812 100644 --- a/docs/manual-acceptance-test.md +++ b/docs/manual-acceptance-test.md @@ -7,13 +7,31 @@ Run this checklist before promoting SourceDraft v0.1. Use a **test** GitHub repo - [ ] `pnpm install` - [ ] `pnpm build` — exit 0 - [ ] `pnpm test` — exit 0 +- [ ] `pnpm test:e2e` — exit 0 (Playwright smoke tests, demo mode) ```bash -pnpm install --lockfile-only +pnpm install --frozen-lockfile pnpm build pnpm test +pnpm exec playwright install chromium # first time only +CI=true pnpm test:e2e ``` +Optional: regenerate README screenshots before release if UI changed: + +```bash +pnpm screenshots:generate +``` + +## Demo mode checklist + +- [ ] With GitHub unset or `SOURCEDRAFT_DEMO_MODE=true`, **Explore demo mode** appears on sign-in +- [ ] Demo banner reads **Demo mode — no GitHub commits are made** +- [ ] Sample posts load in the **Posts** sidebar +- [ ] Opening and editing a sample post works +- [ ] **Simulate publish** succeeds without a GitHub commit +- [ ] Settings → **Setup health** shows check statuses (no token values) + ## Setup - [ ] Copy `sourcedraft.config.example.json` → `sourcedraft.config.json` and adjust paths @@ -29,6 +47,7 @@ pnpm test - [ ] After login, the **Posts** sidebar and center editor workspace load (not a blank page) - [ ] Open **Settings** in the top bar and confirm adapter, `contentDir`, `mediaDir`, and `publicMediaPath` match your config +- [ ] **Setup health** lists admin password, GitHub, paths, adapter, and demo mode status - [ ] Click **Back to editor** to return to the writing workspace ## Create and preview diff --git a/docs/project-status.md b/docs/project-status.md index 2c55a8e..3b0073c 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -13,7 +13,10 @@ Early open-source MVP — usable for single-editor writing and GitHub publishing - Image upload from Studio (PNG, JPEG, GIF, WebP; 5 MB max) with configurable `publicMediaPath` - `sourcedraft.config.json` + `.env` configuration - Server-side password auth for Studio and API -- CI: build and unit tests on push/PR +- **Demo mode** with sample posts and simulated publish/upload (no GitHub writes) +- **Setup health** checks in Settings and `GET /api/health/setup` +- CI: build, unit tests, and Playwright smoke tests (demo mode) on push/PR +- Release screenshots in `docs/assets/` (regenerate with `pnpm screenshots:generate`) ## What does not work yet @@ -27,6 +30,19 @@ Early open-source MVP — usable for single-editor writing and GitHub publishing | Adapters | `astro-mdx` and `markdown` only | | Media | GitHub repo uploads only; no Cloudinary/S3/R2 | | Teams | No roles, review workflow, or multi-editor accounts | +| Demo mode | Fixture-backed seed content; session edits are temporary; not a hosted demo SaaS | + +## Demo mode (fixtures) + +Demo mode loads stable seed posts and media metadata from `apps/studio/server/demo/fixtures/`. On every API restart, the same fixtures are loaded again. Edits and simulated publishes during a session stay in memory only and are discarded when the process restarts. No GitHub commits are made. + +Details: [demo-mode.md](demo-mode.md) + +## Known limitations (demo mode) + +- Session edits and simulated uploads are in-memory only — restarting the API reloads fixtures. +- `SOURCEDRAFT_DEMO_MODE=true` disables all GitHub writes even if a token is configured. +- Demo mode is for exploration, smoke tests, and screenshots — not production publishing. ## Known MVP limitations (GitHub) diff --git a/docs/screenshots.md b/docs/screenshots.md index 40fdd10..8372de6 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -1,34 +1,78 @@ # Screenshots -Screenshots help first-time visitors understand SourceDraft without running the project locally. They are optional for development but useful for the README and release notes. +Screenshots help first-time visitors understand SourceDraft without running the project locally. They live in `docs/assets/` and are referenced from the root README. -## Expected screenshots +## Automated generation (demo mode) -Maintainers can add these under `docs/assets/` when captured from a real local session: +Regenerate all release screenshots from Playwright using deterministic demo fixtures — no GitHub credentials required: -| File | What to show | -|------|----------------| -| `studio-overview.png` | **Posts** view — post list or empty state with publishing setup visible | -| `editor-preview.png` | Editor workspace — center canvas, **Post details** panel, and MDX/Markdown preview | -| `media-upload.png` | Cover image upload area with accepted formats noted | -| `publish-success.png` | Publish confirmation after a successful commit | +```bash +pnpm screenshots:generate +``` -Do not commit placeholder or generated fake screenshots. +From `apps/studio` only: -## How to capture locally +```bash +pnpm exec playwright install chromium +pnpm screenshots:generate +``` -1. Start Studio: `pnpm dev` from the repository root. -2. Sign in with your local admin password. -3. Use a **test repository** or sanitized config — not production secrets. -4. Open the view you want (editor workspace, Settings). -5. Capture the browser window at a readable width (about 1280px works well). -6. Save as PNG with the filenames above into `docs/assets/`. -7. Review the image: crop out tokens, private repo names, personal paths, or email addresses if needed. +This writes nine PNG files under `docs/assets/` at 1280×900. Commit updated images when Studio UI changes. -On Linux, many desktops support **Print Screen** or a region capture tool. Browser dev tools device toolbar is optional; full-window captures are fine. +Smoke tests (no file writes) run separately: + +```bash +pnpm test:e2e +``` + +CI runs `pnpm test:e2e` on every push/PR to `main` after build and unit tests. + +## Required screenshots checklist + +These files are maintained under `docs/assets/`: + +| File | What to show | Mode | +|------|----------------|------| +| `studio-overview.png` | **Posts** sidebar with list or empty state; app bar visible | Demo or GitHub | +| `editor.png` | Center writing canvas with title, description, and body | Demo or GitHub | +| `toolbar.png` | Markdown toolbar above the body field | Demo or GitHub | +| `autosave.png` | Document status in the app bar (e.g. “Unsaved changes” or “Saved locally”) | Demo or GitHub | +| `media-library.png` | Media library section in **Post details** | Demo or GitHub | +| `content-quality.png` | Content quality panel with word count / warnings | Demo or GitHub | +| `preview.png` | MDX/Markdown preview panel with output path | Demo or GitHub | +| `publish-success.png` | Publish confirmation (GitHub or **Publish simulated** in demo) | Demo or GitHub | +| `setup-health.png` | Settings → **Setup health** with check rows | Demo or GitHub | + +## Manual capture (optional) + +### GitHub mode + +1. Configure `.env` with a **test repository** (not production secrets). +2. Start Studio: `pnpm dev` from the repository root. +3. Sign in with your admin password. +4. Create or open a post, capture the views above. + +### Demo mode (no GitHub) + +1. Set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty. +2. Start Studio: `pnpm dev`. +3. Click **Explore demo mode** on the sign-in screen. +4. Capture the same views using sample posts and **Simulate publish**. + +### General tips + +- Use a readable browser width (about 1280px). +- Crop out tokens, private repo names, personal paths, or email addresses. +- On Linux, use **Print Screen** or a region capture tool. ## Before committing Read [assets/README.md](assets/README.md) for naming and privacy rules. If screenshots are not ready yet, the root README links here instead of showing broken images. + +## Related testing + +- Unit tests: `pnpm test` +- Playwright smoke tests (demo mode): `pnpm test:e2e` — see [getting-started.md](getting-started.md) +- Screenshot regeneration: `pnpm screenshots:generate` diff --git a/docs/security.md b/docs/security.md index d4ecc00..27c518c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,6 +7,17 @@ Studio stores a session cookie after login. It does not store the GitHub token or admin password in the browser. +## Demo mode + +When `SOURCEDRAFT_DEMO_MODE=true` or GitHub is not fully configured: + +- Studio serves sample posts from server memory — not your repository. +- `POST /api/publish` and `POST /api/media/upload` simulate success and **never call the GitHub API**. +- Forced demo mode (`SOURCEDRAFT_DEMO_MODE=true`) blocks GitHub writes even if `GITHUB_TOKEN` is set. +- Demo sessions use the same HttpOnly cookie as password login; no secrets are stored in the browser. + +Use demo mode for local exploration and smoke tests only. **MVP password auth is still intended for local/private use.** + ## Session cookies (MVP) After login, the server sets an in-memory session cookie: @@ -51,6 +62,7 @@ All GitHub API calls run in `apps/studio/server`: | `POST /api/publish` | Create or update post files | | `GET /api/posts` | List and load posts from `contentDir` | | `POST /api/media/upload` | Commit image files to `mediaDir` | +| `GET /api/health/setup` | Safe setup diagnostics (authenticated; no secrets) | The client sends article JSON, post path queries, or multipart uploads. The server attaches credentials from `.env`. diff --git a/package.json b/package.json index a6b50af..d2b746b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "build": "pnpm -r build", "check": "pnpm -r check", "lint": "pnpm -r lint", - "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/github-publisher --filter studio test" + "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/github-publisher --filter studio test", + "test:e2e": "pnpm --filter studio test:e2e", + "screenshots:generate": "pnpm --filter studio screenshots:generate" }, "devDependencies": { "tsx": "^4.20.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57e1dc0..d1bfc34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.4.1) + '@playwright/test': + specifier: ^1.55.0 + version: 1.60.0 '@types/busboy': specifier: ^1.5.4 version: 1.5.4 @@ -486,6 +489,11 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1003,6 +1011,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1300,6 +1313,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1876,6 +1899,10 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -2433,6 +2460,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2659,6 +2689,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.15: dependencies: nanoid: 3.3.12