From be22b69b60c897a77fbd54a57bd327b8e4f3aaf3 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 12:19:01 +0200 Subject: [PATCH] Studio document-editor layout, typography, and writing features --- CONTRIBUTING.md | 2 + README.md | 5 +- RELEASE_CHECKLIST.md | 56 + apps/studio/package.json | 8 +- apps/studio/server/index.ts | 12 + apps/studio/server/listMedia.ts | 88 + apps/studio/server/media.ts | 104 +- apps/studio/server/mediaPaths.ts | 47 + apps/studio/server/mediaValidation.test.ts | 65 + apps/studio/server/mediaValidation.ts | 172 ++ apps/studio/src/App.tsx | 425 ++-- apps/studio/src/components/AppBar.tsx | 79 + .../studio/src/components/ArticlePipeline.tsx | 136 -- .../studio/src/components/AstroMdxPreview.tsx | 96 +- apps/studio/src/components/CommandBar.tsx | 61 - .../src/components/ContentQualityPanel.tsx | 127 ++ .../studio/src/components/DocumentOutline.tsx | 85 + apps/studio/src/components/DocumentStatus.tsx | 38 + .../studio/src/components/EditorWorkspace.tsx | 24 - .../src/components/FrontmatterInspector.tsx | 203 -- .../src/components/InternalLinkPicker.tsx | 128 ++ .../studio/src/components/MarkdownToolbar.tsx | 212 ++ apps/studio/src/components/MediaDropzone.tsx | 121 +- apps/studio/src/components/MediaLibrary.tsx | 174 ++ apps/studio/src/components/MediaSection.tsx | 43 + .../src/components/PostDetailsPanel.tsx | 244 +++ apps/studio/src/components/PostSidebar.tsx | 249 +++ apps/studio/src/components/PublishGate.tsx | 43 +- .../src/components/RestoreDraftBanner.tsx | 53 + apps/studio/src/components/SettingsPanel.tsx | 119 ++ apps/studio/src/components/WritingCanvas.tsx | 132 ++ apps/studio/src/fonts.css | 8 + apps/studio/src/hooks/useDocumentAutosave.ts | 209 ++ apps/studio/src/index.css | 1855 ++++++++++++----- apps/studio/src/lib/autosave.test.ts | 249 +++ apps/studio/src/lib/autosave.ts | 268 +++ apps/studio/src/lib/contentQuality.test.ts | 78 + apps/studio/src/lib/contentQuality.ts | 243 +++ apps/studio/src/lib/documentOutline.test.ts | 35 + apps/studio/src/lib/documentOutline.ts | 66 + apps/studio/src/lib/internalLinks.test.ts | 56 + apps/studio/src/lib/internalLinks.ts | 73 + apps/studio/src/lib/markdownEditor.test.ts | 72 + apps/studio/src/lib/markdownEditor.ts | 245 +++ apps/studio/src/lib/media.ts | 107 +- apps/studio/src/lib/postListFilters.test.ts | 91 + apps/studio/src/lib/postListFilters.ts | 128 ++ apps/studio/src/main.tsx | 1 + apps/studio/src/types/view.ts | 2 +- apps/studio/tsconfig.app.json | 3 +- docs/design-notes.md | 29 + docs/getting-started.md | 4 +- docs/manual-acceptance-test.md | 47 +- docs/media.md | 86 +- docs/screenshots.md | 4 +- docs/seo-fields-roadmap.md | 35 + examples/astro-blog/README.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 24 + 59 files changed, 6034 insertions(+), 1339 deletions(-) create mode 100644 RELEASE_CHECKLIST.md create mode 100644 apps/studio/server/listMedia.ts create mode 100644 apps/studio/server/mediaPaths.ts create mode 100644 apps/studio/server/mediaValidation.test.ts create mode 100644 apps/studio/server/mediaValidation.ts create mode 100644 apps/studio/src/components/AppBar.tsx delete mode 100644 apps/studio/src/components/ArticlePipeline.tsx delete mode 100644 apps/studio/src/components/CommandBar.tsx create mode 100644 apps/studio/src/components/ContentQualityPanel.tsx create mode 100644 apps/studio/src/components/DocumentOutline.tsx create mode 100644 apps/studio/src/components/DocumentStatus.tsx delete mode 100644 apps/studio/src/components/EditorWorkspace.tsx delete mode 100644 apps/studio/src/components/FrontmatterInspector.tsx create mode 100644 apps/studio/src/components/InternalLinkPicker.tsx create mode 100644 apps/studio/src/components/MarkdownToolbar.tsx create mode 100644 apps/studio/src/components/MediaLibrary.tsx create mode 100644 apps/studio/src/components/MediaSection.tsx create mode 100644 apps/studio/src/components/PostDetailsPanel.tsx create mode 100644 apps/studio/src/components/PostSidebar.tsx create mode 100644 apps/studio/src/components/RestoreDraftBanner.tsx create mode 100644 apps/studio/src/components/SettingsPanel.tsx create mode 100644 apps/studio/src/components/WritingCanvas.tsx create mode 100644 apps/studio/src/fonts.css create mode 100644 apps/studio/src/hooks/useDocumentAutosave.ts create mode 100644 apps/studio/src/lib/autosave.test.ts create mode 100644 apps/studio/src/lib/autosave.ts create mode 100644 apps/studio/src/lib/contentQuality.test.ts create mode 100644 apps/studio/src/lib/contentQuality.ts create mode 100644 apps/studio/src/lib/documentOutline.test.ts create mode 100644 apps/studio/src/lib/documentOutline.ts create mode 100644 apps/studio/src/lib/internalLinks.test.ts create mode 100644 apps/studio/src/lib/internalLinks.ts create mode 100644 apps/studio/src/lib/markdownEditor.test.ts create mode 100644 apps/studio/src/lib/markdownEditor.ts create mode 100644 apps/studio/src/lib/postListFilters.test.ts create mode 100644 apps/studio/src/lib/postListFilters.ts create mode 100644 docs/design-notes.md create mode 100644 docs/seo-fields-roadmap.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70c1b8d..cc0d624 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,8 @@ Edit `.env` with local values for development. **Do not commit `.env` or `.env.l 3. Keep changes focused — avoid unrelated refactors. 4. Open a pull request against `main` with a clear summary and test notes. +Before a release, see [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) and [docs/manual-acceptance-test.md](docs/manual-acceptance-test.md). + ## Commands From the repository root: diff --git a/README.md b/README.md index 5af49c3..141e1c8 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Start Studio (UI + publish API): pnpm dev ``` -Sign in, open **Write**, preview the output, publish. The file lands at `contentDir/.mdx` or `.md` depending on your adapter (default: `src/content/blog/`). +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/`). Full walkthrough: [docs/getting-started.md](docs/getting-started.md) @@ -100,7 +100,7 @@ If someone technical already installed SourceDraft and pointed it at your blog r 2. The admin password they set in `.env` 3. Your site’s category list (from `sourcedraft.config.json`) -Then: sign in → **Posts** to open an existing post, or **Write** → fill in title, description, date, category, tags, and body → upload images if needed → check the preview → **Publish to GitHub**. Your post appears as a file in the blog repo; the normal site build deploys it. +Then: sign in → open a post from the **Posts** sidebar, or click **New post** → fill in title, description, category, tags, and body → upload images if needed → check the preview → **Publish to GitHub**. Your post appears as a file in the blog repo; the normal site build deploys it. You do not edit GitHub by hand or run terminal commands for each post. If publish is disabled, ask your technical contact to check `.env` (GitHub token and repo) and that Studio is running with `pnpm dev`. @@ -146,6 +146,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) +- [Release checklist](RELEASE_CHECKLIST.md) - [Security](docs/security.md) - [Screenshots guide](docs/screenshots.md) - [Changelog](CHANGELOG.md) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..2eaa6d2 --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,56 @@ +# SourceDraft v0.1 release checklist + +Use this before tagging `v0.1.0` or promoting the repository publicly. + +## Automated checks + +```bash +pnpm install --lockfile-only +pnpm build +pnpm test +``` + +- [ ] All three commands exit 0 +- [ ] Studio build includes server TypeScript (`tsc -p server/tsconfig.json` in `apps/studio` build) +- [ ] CI workflow (`.github/workflows/ci.yml`) runs the same build and test commands + +## Repository + +- [ ] `LICENSE` present (MIT) +- [ ] `CHANGELOG.md` has a `v0.1.0` section +- [ ] `CONTRIBUTING.md` present +- [ ] `.env` and `.env.local` are gitignored and not committed +- [ ] No real tokens or passwords in tracked files +- [ ] `sourcedraft.config.example.json` is generic (no site-specific secrets) +- [ ] No QuBrite hardcoding in `*.ts` / `*.tsx` app logic + +## Documentation + +- [ ] README quickstart matches current Studio UI and commands +- [ ] Docs state: early local/private MVP, not hosted SaaS, not production multi-user auth +- [ ] GitHub Contents API limits documented +- [ ] `mediaDir` vs `publicMediaPath` documented +- [ ] Issue templates present under `.github/ISSUE_TEMPLATE/` + +## Manual acceptance + +Run [docs/manual-acceptance-test.md](docs/manual-acceptance-test.md) against a **test** GitHub repository. + +- [ ] Login and logout work +- [ ] Settings show adapter, `contentDir`, `mediaDir`, `publicMediaPath` +- [ ] Create post, upload image, publish +- [ ] Edit existing post, publish update +- [ ] Verify files in GitHub match expectations + +## Tagging (optional) + +```bash +git tag -a v0.1.0 -m "SourceDraft v0.1.0 — early open-source MVP" +git push origin v0.1.0 +``` + +Only tag after automated checks pass and manual acceptance is satisfactory. + +## Known non-goals for v0.1 + +Do not block release on: OAuth, user accounts, hosted SaaS, Cloudinary/S3/R2, Git Trees API, screenshots in repo, or Studio E2E test automation. diff --git a/apps/studio/package.json b/apps/studio/package.json index 711fee1..326bd9d 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -13,9 +13,13 @@ "build:server": "tsc -p server/tsconfig.json", "start:server": "node dist-server/index.js", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --import tsx --test src/**/*.test.ts server/**/*.test.ts" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/ibm-plex-serif": "^5.2.7", "@sourcedraft/adapter-astro-mdx": "workspace:*", "@sourcedraft/adapter-markdown": "workspace:*", "@sourcedraft/config": "workspace:*", @@ -35,10 +39,10 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.0", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "concurrently": "^9.2.0", "globals": "^17.6.0", "tsx": "^4.20.3", "typescript": "~6.0.2", diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 6ebdeb7..4799ba4 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -12,6 +12,7 @@ import { } from "./auth.js"; import { loadPublicConfig, loadPublishEnv } from "./config.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"; @@ -97,6 +98,17 @@ app.get("/api/posts", requireAuth, async (req, res) => { res.status(result.status).json(result.body); }); +app.get("/api/media", requireAuth, async (_req, res) => { + const envResult = loadPublishEnv(); + if (!envResult.ok) { + res.status(500).json({ ok: false, error: envResult.error }); + return; + } + + const result = await listMedia(envResult.config); + res.status(result.status).json(result.body); +}); + app.post( "/api/media/upload", requireSameSiteRequest, diff --git a/apps/studio/server/listMedia.ts b/apps/studio/server/listMedia.ts new file mode 100644 index 0000000..d4d268d --- /dev/null +++ b/apps/studio/server/listMedia.ts @@ -0,0 +1,88 @@ +import { joinPublicMediaPath } from "@sourcedraft/config"; +import { createGitHubPublisher } from "@sourcedraft/github-publisher"; +import type { PublishEnvConfig } from "./config.js"; +import { filenameFromRepoPath, normalizeMediaDir, safeMediaPath } from "./mediaPaths.js"; +import { + mediaKindFromExtension, + normalizeExtension, +} from "./mediaValidation.js"; + +export type MediaFileSummary = { + repoPath: string; + publicPath: string; + filename: string; + extension: string; + kind: "image" | "pdf"; + size: number; +}; + +export type ListMediaSuccess = { + ok: true; + files: MediaFileSummary[]; +}; + +export type ListMediaError = { + ok: false; + error: string; +}; + +export type ListMediaResponse = ListMediaSuccess | ListMediaError; + +export async function listMedia( + env: PublishEnvConfig, +): Promise<{ status: number; body: ListMediaResponse }> { + const mediaDir = normalizeMediaDir(env.mediaDir); + if (mediaDir.length === 0) { + return { + status: 500, + body: { ok: false, error: "Media directory is not configured." }, + }; + } + + const publisher = createGitHubPublisher({ + token: env.token, + owner: env.owner, + repo: env.repo, + branch: env.branch, + }); + + const listed = await publisher.listFiles({ path: mediaDir, contentDir: mediaDir }); + if (!listed.ok) { + return { + status: listed.status === 404 ? 404 : 502, + body: { ok: false, error: listed.error }, + }; + } + + const files: MediaFileSummary[] = []; + + for (const file of listed.files) { + const safe = safeMediaPath(file.path, mediaDir); + if (!safe.ok) { + continue; + } + + const filename = filenameFromRepoPath(safe.path); + const extension = normalizeExtension(filename); + const kind = mediaKindFromExtension(extension); + if (kind === null) { + continue; + } + + files.push({ + repoPath: safe.path, + publicPath: joinPublicMediaPath(env.publicMediaPath, filename), + filename, + extension, + kind, + size: file.size, + }); + } + + files.sort((left, right) => right.filename.localeCompare(left.filename)); + + return { + status: 200, + body: { ok: true, files }, + }; +} diff --git a/apps/studio/server/media.ts b/apps/studio/server/media.ts index 887bf28..13e3755 100644 --- a/apps/studio/server/media.ts +++ b/apps/studio/server/media.ts @@ -4,15 +4,18 @@ import Busboy from "busboy"; import { joinPublicMediaPath } from "@sourcedraft/config"; import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; - -const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; - -const ALLOWED_MIME_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", -]); +import { normalizeMediaDir } from "./mediaPaths.js"; +import { + ALLOWED_MIME_TYPES, + allowedTypesMessage, + extensionForMime, + matchesMediaSignature, + maxBytesForMime, + mediaKindFromMime, + uploadLimitMessage, +} from "./mediaValidation.js"; + +const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; type ParsedUpload = { buffer: Buffer; @@ -24,6 +27,7 @@ export type MediaUploadSuccess = { ok: true; repoPath: string; publicPath: string; + kind: "image" | "pdf"; sha: string; commitSha: string; }; @@ -35,10 +39,6 @@ export type MediaUploadError = { export type MediaUploadResponse = MediaUploadSuccess | MediaUploadError; -function normalizeMediaDir(mediaDir: string): string { - return mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); -} - function sanitizeFilename(filename: string): string { const base = filename.split(/[/\\]/u).pop() ?? "upload"; const cleaned = base @@ -53,52 +53,6 @@ function sanitizeFilename(filename: string): string { return cleaned.slice(0, 120); } -function extensionForMime(mimeType: string): string | null { - switch (mimeType) { - case "image/png": - return "png"; - case "image/jpeg": - return "jpg"; - case "image/gif": - return "gif"; - case "image/webp": - return "webp"; - default: - return null; - } -} - -function matchesSignature(buffer: Buffer, mimeType: string): boolean { - if (buffer.length < 12) { - return false; - } - - switch (mimeType) { - case "image/png": - return ( - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 - ); - case "image/jpeg": - return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; - case "image/gif": - return ( - buffer.toString("ascii", 0, 3) === "GIF" && - (buffer.toString("ascii", 3, 6) === "87a" || - buffer.toString("ascii", 3, 6) === "89a") - ); - case "image/webp": - return ( - buffer.toString("ascii", 0, 4) === "RIFF" && - buffer.toString("ascii", 8, 12) === "WEBP" - ); - default: - return false; - } -} - function parseUpload(req: Request): Promise { return new Promise((resolve, reject) => { const busboy = Busboy({ @@ -136,7 +90,7 @@ function parseUpload(req: Request): Promise { stream.on("limit", () => { rejected = true; - reject(new Error("File exceeds the 5MB upload limit.")); + reject(new Error("File exceeds the maximum upload limit.")); }); stream.on("end", () => { @@ -210,39 +164,42 @@ export async function uploadMedia( }; } - if (parsed.buffer.length > MAX_UPLOAD_BYTES) { + if (!ALLOWED_MIME_TYPES.has(parsed.mimeType)) { return { status: 400, - body: { ok: false, error: "File exceeds the 5MB upload limit." }, + body: { ok: false, error: allowedTypesMessage() }, }; } - if (!ALLOWED_MIME_TYPES.has(parsed.mimeType)) { + const kind = mediaKindFromMime(parsed.mimeType); + const maxBytes = maxBytesForMime(parsed.mimeType); + if (kind === null || maxBytes === null) { return { status: 400, - body: { - ok: false, - error: "Only PNG, JPEG, GIF, and WebP uploads are allowed.", - }, + body: { ok: false, error: allowedTypesMessage() }, + }; + } + + if (parsed.buffer.length > maxBytes) { + return { + status: 400, + body: { ok: false, error: uploadLimitMessage(parsed.mimeType) }, }; } - if (!matchesSignature(parsed.buffer, parsed.mimeType)) { + if (!matchesMediaSignature(parsed.buffer, parsed.mimeType)) { return { status: 400, body: { ok: false, - error: "File content does not match the declared image type.", + error: "File content does not match the declared file type.", }, }; } const filename = buildUploadFilename(parsed.filename, parsed.mimeType); const uniqueSuffix = randomBytes(4).toString("hex"); - const repoFilename = filename.replace( - /(\.[^.]+)$/u, - `-${uniqueSuffix}$1`, - ); + const repoFilename = filename.replace(/(\.[^.]+)$/u, `-${uniqueSuffix}$1`); const repoPath = `${mediaDir}/${repoFilename}`; const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename); @@ -276,6 +233,7 @@ export async function uploadMedia( ok: true, repoPath: result.path, publicPath, + kind, sha: result.sha, commitSha: result.commitSha, }, diff --git a/apps/studio/server/mediaPaths.ts b/apps/studio/server/mediaPaths.ts new file mode 100644 index 0000000..f2bf236 --- /dev/null +++ b/apps/studio/server/mediaPaths.ts @@ -0,0 +1,47 @@ +import { + isAllowedMediaExtension, + normalizeExtension, +} from "./mediaValidation.js"; + +export function normalizeMediaDir(mediaDir: string): string { + return mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim(); +} + +export function safeMediaPath( + inputPath: string, + mediaDir: string, +): { ok: true; path: string } | { ok: false; error: string } { + const normalizedDir = normalizeMediaDir(mediaDir); + if (normalizedDir.length === 0) { + return { ok: false, error: "Media directory is not configured." }; + } + + const path = inputPath.replace(/^\/+/u, "").trim(); + if (path.length === 0) { + return { ok: false, error: "Path is required." }; + } + + const segments = path.split("/"); + if (segments.some((segment) => segment === ".." || segment === ".")) { + return { ok: false, error: "Path must not contain . or .. segments." }; + } + + if (!path.startsWith(`${normalizedDir}/`)) { + return { + ok: false, + error: "Path must stay inside the configured media directory.", + }; + } + + const filename = segments.at(-1) ?? ""; + const extension = normalizeExtension(filename); + if (!isAllowedMediaExtension(extension)) { + return { ok: false, error: "File type is not allowed for media library." }; + } + + return { ok: true, path }; +} + +export function filenameFromRepoPath(repoPath: string): string { + return repoPath.split("/").pop() ?? repoPath; +} diff --git a/apps/studio/server/mediaValidation.test.ts b/apps/studio/server/mediaValidation.test.ts new file mode 100644 index 0000000..a4f25e0 --- /dev/null +++ b/apps/studio/server/mediaValidation.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { safeMediaPath } from "./mediaPaths.js"; +import { + isAllowedMediaFilename, + matchesMediaSignature, + maxBytesForMime, + mediaKindFromExtension, + mediaKindFromMime, + normalizeExtension, +} from "./mediaValidation.js"; + +describe("media validation", () => { + it("accepts allowed image and pdf mime types", () => { + assert.equal(mediaKindFromMime("image/png"), "image"); + assert.equal(mediaKindFromMime("application/pdf"), "pdf"); + assert.equal(mediaKindFromMime("text/html"), null); + }); + + it("maps extensions to media kinds", () => { + assert.equal(mediaKindFromExtension("webp"), "image"); + assert.equal(mediaKindFromExtension("pdf"), "pdf"); + assert.equal(mediaKindFromExtension("svg"), null); + }); + + it("enforces per-type upload limits", () => { + assert.equal(maxBytesForMime("image/png"), 5 * 1024 * 1024); + assert.equal(maxBytesForMime("application/pdf"), 10 * 1024 * 1024); + }); + + it("validates file signatures for images and pdf", () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + assert.equal(matchesMediaSignature(png, "image/png"), true); + + const pdf = Buffer.from("%PDF-1.7\n"); + assert.equal(matchesMediaSignature(pdf, "application/pdf"), true); + + const html = Buffer.from(""); + assert.equal(matchesMediaSignature(html, "image/png"), false); + }); + + it("rejects disallowed filenames", () => { + assert.equal(isAllowedMediaFilename("photo.png"), true); + assert.equal(isAllowedMediaFilename("notes.pdf"), true); + assert.equal(isAllowedMediaFilename("script.svg"), false); + assert.equal(isAllowedMediaFilename("page.html"), false); + assert.equal(normalizeExtension("photo.PNG"), "png"); + }); +}); + +describe("media paths", () => { + it("allows safe paths inside mediaDir and blocks traversal", () => { + const ok = safeMediaPath("public/images/photo-abc.png", "public/images"); + assert.equal(ok.ok, true); + + const traversal = safeMediaPath("public/images/../secret.png", "public/images"); + assert.equal(traversal.ok, false); + + const outside = safeMediaPath("public/other/photo.png", "public/images"); + assert.equal(outside.ok, false); + + const blocked = safeMediaPath("public/images/evil.svg", "public/images"); + assert.equal(blocked.ok, false); + }); +}); diff --git a/apps/studio/server/mediaValidation.ts b/apps/studio/server/mediaValidation.ts new file mode 100644 index 0000000..80ec9d2 --- /dev/null +++ b/apps/studio/server/mediaValidation.ts @@ -0,0 +1,172 @@ +export type MediaKind = "image" | "pdf"; + +export const IMAGE_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +export const PDF_MIME_TYPES = ["application/pdf"] as const; + +export const ALLOWED_MIME_TYPES = new Set([ + ...IMAGE_MIME_TYPES, + ...PDF_MIME_TYPES, +]); + +export const ALLOWED_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "pdf", +]); + +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +export const MAX_PDF_BYTES = 10 * 1024 * 1024; + +const EXTENSION_TO_MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + pdf: "application/pdf", +}; + +export function normalizeExtension(filename: string): string { + const match = filename.match(/\.([^.]+)$/u); + return match?.[1]?.toLowerCase() ?? ""; +} + +export function mediaKindFromMime(mimeType: string): MediaKind | null { + if (IMAGE_MIME_TYPES.includes(mimeType as (typeof IMAGE_MIME_TYPES)[number])) { + return "image"; + } + + if (mimeType === "application/pdf") { + return "pdf"; + } + + return null; +} + +export function mediaKindFromExtension(extension: string): MediaKind | null { + const normalized = extension.toLowerCase(); + if (["png", "jpg", "jpeg", "gif", "webp"].includes(normalized)) { + return "image"; + } + + if (normalized === "pdf") { + return "pdf"; + } + + return null; +} + +export function isAllowedMediaExtension(extension: string): boolean { + return ALLOWED_EXTENSIONS.has(extension.toLowerCase()); +} + +export function isAllowedMediaFilename(filename: string): boolean { + const extension = normalizeExtension(filename); + return isAllowedMediaExtension(extension); +} + +export function extensionForMime(mimeType: string): string | null { + switch (mimeType) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/gif": + return "gif"; + case "image/webp": + return "webp"; + case "application/pdf": + return "pdf"; + default: + return null; + } +} + +export function maxBytesForMime(mimeType: string): number | null { + const kind = mediaKindFromMime(mimeType); + if (kind === "image") { + return MAX_IMAGE_BYTES; + } + + if (kind === "pdf") { + return MAX_PDF_BYTES; + } + + return null; +} + +export function uploadLimitMessage(mimeType: string): string { + const kind = mediaKindFromMime(mimeType); + if (kind === "pdf") { + return "PDF must be 10 MB or smaller."; + } + + return "Image must be 5 MB or smaller."; +} + +export function allowedTypesMessage(): string { + return "Only PNG, JPEG, GIF, WebP images and PDF documents are allowed."; +} + +export function matchesMediaSignature( + buffer: Buffer, + mimeType: string, +): boolean { + if (buffer.length < 4) { + return false; + } + + switch (mimeType) { + case "image/png": + return ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ); + case "image/jpeg": + return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + case "image/gif": + return ( + buffer.length >= 6 && + buffer.toString("ascii", 0, 3) === "GIF" && + (buffer.toString("ascii", 3, 6) === "87a" || + buffer.toString("ascii", 3, 6) === "89a") + ); + case "image/webp": + return ( + buffer.length >= 12 && + buffer.toString("ascii", 0, 4) === "RIFF" && + buffer.toString("ascii", 8, 12) === "WEBP" + ); + case "application/pdf": + return buffer.toString("ascii", 0, 4) === "%PDF"; + default: + return false; + } +} + +export function mimeMatchesExtension( + mimeType: string, + extension: string, +): boolean { + const expected = EXTENSION_TO_MIME[extension.toLowerCase()]; + if (expected === undefined) { + return false; + } + + if (extension === "jpg" || extension === "jpeg") { + return mimeType === "image/jpeg"; + } + + return expected === mimeType; +} diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 09e24bf..4f03cbc 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -1,13 +1,17 @@ +import { getAstroMdxPath } from "@sourcedraft/adapter-astro-mdx"; +import { getMarkdownPath } from "@sourcedraft/adapter-markdown"; import { normalizeArticle, validateArticle } from "@sourcedraft/core"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { AdapterStatus } from "./components/AdapterStatus"; -import { ArticlePipeline } from "./components/ArticlePipeline"; -import { CommandBar } from "./components/CommandBar"; -import { EditorWorkspace } from "./components/EditorWorkspace"; -import { FrontmatterInspector } from "./components/FrontmatterInspector"; +import { AppBar } from "./components/AppBar"; import { AstroMdxPreview } from "./components/AstroMdxPreview"; import { LoginScreen } from "./components/LoginScreen"; +import { PostDetailsPanel } from "./components/PostDetailsPanel"; +import { PostSidebar } from "./components/PostSidebar"; import { PublishGate } from "./components/PublishGate"; +import { RestoreDraftBanner } from "./components/RestoreDraftBanner"; +import { SettingsPanel } from "./components/SettingsPanel"; +import { WritingCanvas } from "./components/WritingCanvas"; +import { useDocumentAutosave } from "./hooks/useDocumentAutosave"; import { fetchAuthStatus, login as loginToStudio, @@ -45,7 +49,7 @@ function App() { const [authChecked, setAuthChecked] = useState(false); const [authenticated, setAuthenticated] = useState(false); const [authConfigured, setAuthConfigured] = useState(false); - const [view, setView] = useState("overview"); + const [view, setView] = useState("editor"); const [studioConfig, setStudioConfig] = useState( FALLBACK_STUDIO_CONFIG, ); @@ -61,6 +65,9 @@ function App() { const [publishing, setPublishing] = useState(false); const [publishError, setPublishError] = useState(null); const [publishSuccess, setPublishSuccess] = useState(null); + const [latestUploadedImagePath, setLatestUploadedImagePath] = useState< + string | null + >(null); const articleInput = useMemo(() => formStateToArticleInput(form), [form]); const validation = useMemo( @@ -135,21 +142,93 @@ function App() { } }, [articleInput, validation.valid]); + const outputPath = useMemo(() => { + if (!validation.valid || !normalizedArticle) { + return null; + } + + if (editingPath && editingPath.length > 0) { + return editingPath; + } + + return studioConfig.adapter === "markdown" + ? getMarkdownPath(normalizedArticle, { + contentDir: studioConfig.contentDir, + }) + : getAstroMdxPath(normalizedArticle, { + contentDir: studioConfig.contentDir, + }); + }, [ + validation.valid, + normalizedArticle, + editingPath, + studioConfig.adapter, + studioConfig.contentDir, + ]); + + const documentSnapshot = useMemo( + () => ({ + form, + editingPath, + slugAuto, + }), + [form, editingPath, slugAuto], + ); + + const { + documentStatus, + restorePrompt, + applyRestore, + discardDraft, + commitBaseline, + checkRestorePrompt, + } = useDocumentAutosave({ + snapshot: documentSnapshot, + publishing, + enabled: authenticated && view === "editor", + }); + function resetEditor(defaultCategory?: string) { setEditingPath(null); setLoadPostError(null); setSlugAuto(true); setPublishError(null); setPublishSuccess(null); - setForm(createInitialFormState(defaultCategory ?? studioConfig.categories[0])); + setView("editor"); + const nextForm = createInitialFormState( + defaultCategory ?? studioConfig.categories[0], + ); + setForm(nextForm); + + const nextSnapshot = { + form: nextForm, + editingPath: null, + slugAuto: true, + }; + commitBaseline(nextSnapshot, { + remoteSync: false, + clearLocalDraft: false, + }); + checkRestorePrompt(nextSnapshot); } - function handleViewChange(next: View) { - if (next === "new-article" && view !== "new-article") { - resetEditor(); + function handleRestoreDraft() { + const restored = applyRestore(); + if (!restored) { + return; } - setView(next); + setForm(restored.form); + setEditingPath(restored.editingPath); + setSlugAuto(restored.slugAuto); + setPublishError(null); + setPublishSuccess(null); + setLoadPostError(null); + setView("editor"); + } + + function handleDiscardDraft() { + discardDraft(); } function handleFieldChange( @@ -194,27 +273,46 @@ function App() { })); } + function handleInsertPdfLink(publicPath: string, filename: string) { + const label = filename.replace(/\.pdf$/iu, "") || "Document"; + const snippet = `\n\n[${label}](${publicPath})\n`; + + setForm((current) => ({ + ...current, + body: `${current.body}${snippet}`, + })); + } + async function handleEditPost(path: string) { setLoadPostError(null); + setView("editor"); const result = await fetchPost(path); if (!result.ok) { setLoadPostError(result.error); - setView("overview"); return; } - setForm( - articleInputToFormState( - result.article, - studioConfig.categories[0] ?? "Guides", - ), + const loadedForm = articleInputToFormState( + result.article, + studioConfig.categories[0] ?? "Guides", ); + setForm(loadedForm); setSlugAuto(false); setEditingPath(result.path); setPublishError(null); setPublishSuccess(null); - setView("new-article"); + + const nextSnapshot = { + form: loadedForm, + editingPath: result.path, + slugAuto: false, + }; + commitBaseline(nextSnapshot, { + remoteSync: true, + clearLocalDraft: false, + }); + checkRestorePrompt(nextSnapshot); } async function handleLogin(password: string) { @@ -232,6 +330,7 @@ function App() { setEditingPath(null); setPublishError(null); setPublishSuccess(null); + setView("editor"); } async function handlePublish() { @@ -257,12 +356,25 @@ function App() { const action = result.created ? "Created" : "Updated"; setPublishSuccess( - `${action} output file ${result.path} (commit ${result.commitSha.slice(0, 7)}).`, + `${action} ${result.path} (commit ${result.commitSha.slice(0, 7)}).`, ); setEditingPath(result.path); + commitBaseline( + { + form, + editingPath: result.path, + slugAuto, + }, + { + remoteSync: true, + clearLocalDraft: true, + }, + ); await refreshPosts(); } catch { - setPublishError("Could not reach the publish API. Is the server running?"); + setPublishError( + "Could not reach the publish API. Start the dev server and try again.", + ); } finally { setPublishing(false); } @@ -286,201 +398,104 @@ function App() { return (
- + setView((current) => (current === "settings" ? "editor" : "settings")) + } onLogout={handleLogout} /> -
- {view === "overview" && ( -
- {loadPostError && ( -
-

Could not open post

-

{loadPostError}

-
+ {view === "settings" ? ( +
+ +
+ ) : ( +
+ resetEditor()} + onRefresh={() => { + void refreshPosts(); + }} + onEdit={(path) => { + void handleEditPost(path); + }} + /> + +
+ {restorePrompt && ( + )} - { - void refreshPosts(); - }} - onEdit={(path) => { - void handleEditPost(path); - }} + fieldErrors={fieldErrors} + onTitleChange={(value) => handleFieldChange("title", value)} + onDescriptionChange={(value) => + handleFieldChange("description", value) + } + onBodyChange={handleBodyChange} /> - -
- )} - - {view === "new-article" && ( -
-
- {editingPath && ( -
-

Editing an existing post

-

- Changes will update output file{" "} - {editingPath} on GitHub. -

-
- )} - - {fieldErrors.body && ( -

{fieldErrors.body}

- )} - - -
- -
- )} - - {view === "settings" && ( -
-
-
-

Settings

-

- Project paths from config · GitHub target from .env -

-
- -

- These values are read-only here. Edit{" "} - sourcedraft.config.json for folders and categories, and{" "} - .env for GitHub credentials. The token never reaches - the browser. -

- -
- - - - - - - - - - - - - - - -
-
- - -
- )} -
+ + + +
+ )} ); } diff --git a/apps/studio/src/components/AppBar.tsx b/apps/studio/src/components/AppBar.tsx new file mode 100644 index 0000000..04cc4bc --- /dev/null +++ b/apps/studio/src/components/AppBar.tsx @@ -0,0 +1,79 @@ +import { DocumentStatusIndicator } from "./DocumentStatus"; +import type { DocumentStatus } from "../lib/autosave.js"; + +type AppBarProps = { + adapter: string; + documentStatus: DocumentStatus | null; + githubOwner: string; + githubRepo: string; + githubReady: boolean; + settingsActive: boolean; + onOpenSettings: () => void; + onLogout: () => void; +}; + +function adapterLabel(adapter: string): string { + return adapter === "markdown" ? "Markdown" : "MDX"; +} + +export function AppBar({ + adapter, + documentStatus, + githubOwner, + githubRepo, + githubReady, + settingsActive, + onOpenSettings, + onLogout, +}: AppBarProps) { + const repoLabel = githubReady + ? `${githubOwner}/${githubRepo}` + : "GitHub not configured"; + + return ( +
+
+ SourceDraft + Git-backed writing studio +
+ +
+ {documentStatus && } + + {repoLabel} + + + {adapterLabel(adapter)} + +
+ +
+ + +
+
+ ); +} diff --git a/apps/studio/src/components/ArticlePipeline.tsx b/apps/studio/src/components/ArticlePipeline.tsx deleted file mode 100644 index 4cec052..0000000 --- a/apps/studio/src/components/ArticlePipeline.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import type { PostSummary } from "../lib/posts"; - -type ArticlePipelineProps = { - posts: PostSummary[]; - loading: boolean; - error: string | null; - githubReady: boolean; - onRefresh: () => void; - onEdit: (path: string) => void; -}; - -export function ArticlePipeline({ - posts, - loading, - error, - githubReady, - onRefresh, - onEdit, -}: ArticlePipelineProps) { - return ( -
-
-

- Your posts -

-

- {loading - ? "Loading posts from GitHub…" - : `${posts.length} post${posts.length === 1 ? "" : "s"} in your content folder`} -

- -
- - {loading && ( -
-

Loading posts…

-

- Fetching posts from your GitHub content folder. This may take a moment - for larger sites. -

-
- )} - - {!githubReady && !loading && ( -
-

GitHub is not configured yet

-

- Set GITHUB_OWNER and GITHUB_REPO in{" "} - .env, then open Settings to confirm the - target repository. You can still write drafts locally. -

-
- )} - - {error && ( -
-

Could not load posts

-

{error}

-

- Check your GitHub token, repository settings, and{" "} - contentDir in Settings. Use Refresh list after fixing - configuration. -

-
- )} - - {!loading && !error && posts.length === 0 && ( -
-

No posts found

-

- {githubReady - ? "Nothing matched your content folder yet. Open Write to draft a post, then publish to GitHub. Published posts appear here for editing." - : "Configure GitHub in Settings, then open Write to create your first post."} -

-
- )} - - {!loading && posts.length > 0 && ( -
- - - - - - - - - - - - {posts.map((post) => ( - - - - - - - - ))} - -
TitleDateCategoryStatus - Actions -
- {post.title} - {post.path} - {post.pubDate}{post.category} - - {post.draft ? "Draft" : "Live"} - - - -
-
- )} -
- ); -} diff --git a/apps/studio/src/components/AstroMdxPreview.tsx b/apps/studio/src/components/AstroMdxPreview.tsx index 2643ce9..85173c4 100644 --- a/apps/studio/src/components/AstroMdxPreview.tsx +++ b/apps/studio/src/components/AstroMdxPreview.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; import type { Article, ValidationIssue } from "@sourcedraft/core"; @@ -23,6 +24,8 @@ export function AstroMdxPreview({ adapter, outputPath, }: AstroMdxPreviewProps) { + const [collapsed, setCollapsed] = useState(false); + const resolvedOutputPath = valid && article ? outputPath && outputPath.length > 0 @@ -40,48 +43,65 @@ export function AstroMdxPreview({ : null; return ( -
-
-

- {previewLabel(adapter)} -

-

- {valid - ? "Review the file that will be saved to GitHub" - : "Complete post details and body to preview"} -

+
+
+
+

+ {previewLabel(adapter)} +

+

+ {valid + ? "File that will be saved to GitHub" + : "Complete post details to preview output"} +

+
+
- {valid && resolvedOutputPath && fileOutput ? ( -
-
- Output file - {resolvedOutputPath} -
-
-            {fileOutput}
-          
-
- ) : ( -
- {issues.length === 0 ? ( -

- Add a title, description, dates, category, and body to continue. -

- ) : ( + {!collapsed && ( +
+ {valid && resolvedOutputPath && fileOutput ? ( <> -

- Fix these items before publishing: -

-
    - {issues.map((issue) => ( -
  • - {issue.field} - {issue.message} -
  • - ))} -
+
+ Output file + {resolvedOutputPath} +
+
+                {fileOutput}
+              
+ ) : ( +
+ {issues.length === 0 ? ( +

+ Add a title, description, dates, category, and body to continue. +

+ ) : ( + <> +

+ Fix these items before publishing: +

+
    + {issues.map((issue) => ( +
  • + + {issue.field} + + {issue.message} +
  • + ))} +
+ + )} +
)}
)} diff --git a/apps/studio/src/components/CommandBar.tsx b/apps/studio/src/components/CommandBar.tsx deleted file mode 100644 index 402cad6..0000000 --- a/apps/studio/src/components/CommandBar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { View } from "../types/view"; - -type CommandBarProps = { - currentView: View; - onViewChange: (view: View) => void; - onLogout: () => void; -}; - -const NAV_ITEMS: { id: View; label: string }[] = [ - { id: "overview", label: "Posts" }, - { id: "new-article", label: "Write" }, - { id: "settings", label: "Settings" }, -]; - -export function CommandBar({ - currentView, - onViewChange, - onLogout, -}: CommandBarProps) { - return ( -
-
- SD -
-

SourceDraft Studio

-

Write, preview, and publish to GitHub

-
-
- - - -
- Auth - Signed in - -
-
- ); -} diff --git a/apps/studio/src/components/ContentQualityPanel.tsx b/apps/studio/src/components/ContentQualityPanel.tsx new file mode 100644 index 0000000..932c63f --- /dev/null +++ b/apps/studio/src/components/ContentQualityPanel.tsx @@ -0,0 +1,127 @@ +import { useMemo } from "react"; +import type { ValidationIssue } from "@sourcedraft/core"; +import type { ArticleFormState } from "../lib/articleForm"; +import { analyzeContentQuality } from "../lib/contentQuality.js"; + +type ContentQualityPanelProps = { + values: ArticleFormState; + validationIssues: ValidationIssue[]; +}; + +function metricLabel(value: string | number, suffix = ""): string { + return `${value}${suffix}`; +} + +export function ContentQualityPanel({ + values, + validationIssues, +}: ContentQualityPanelProps) { + const analysis = useMemo( + () => + analyzeContentQuality( + { + title: values.title, + description: values.description, + body: values.body, + heroImage: values.heroImage, + }, + validationIssues, + ), + [values, validationIssues], + ); + + const { metrics, warnings } = analysis; + const guidanceWarnings = warnings.filter((warning) => warning.kind === "info"); + const issueWarnings = warnings.filter((warning) => warning.kind === "warn"); + + return ( +
+
+

+ Content quality +

+

+ Editorial checks for this draft. Guidance only — not a ranking score. +

+
+ +
+
+
Words
+
{metricLabel(metrics.wordCount)}
+
+
+
Reading time
+
+ {metrics.readingTimeMinutes === 0 + ? "—" + : metricLabel(metrics.readingTimeMinutes, " min")} +
+
+
+
Title length
+
{metricLabel(metrics.titleLength, " chars")}
+
+
+
Description length
+
{metricLabel(metrics.descriptionLength, " chars")}
+
+
+
Cover image
+
{metrics.hasCoverImage ? "Set" : "Not set"}
+
+
+
Body heading
+
{metrics.hasHeading ? "Present" : "None detected"}
+
+
+
Links
+
+ {metrics.internalLinkCount} internal · {metrics.externalLinkCount}{" "} + external +
+
+
+
Images
+
+ {metrics.imageCount} + {metrics.imagesMissingAlt > 0 + ? ` (${metrics.imagesMissingAlt} missing alt)` + : ""} +
+
+
+ + {issueWarnings.length > 0 && ( +
+

Needs attention

+
    + {issueWarnings.map((warning) => ( +
  • {warning.message}
  • + ))} +
+
+ )} + + {guidanceWarnings.length > 0 && ( +
+

Suggestions

+
    + {guidanceWarnings.map((warning) => ( +
  • {warning.message}
  • + ))} +
+
+ )} + + {warnings.length === 0 && ( +

+ No issues detected in current checks. +

+ )} +
+ ); +} diff --git a/apps/studio/src/components/DocumentOutline.tsx b/apps/studio/src/components/DocumentOutline.tsx new file mode 100644 index 0000000..f93fa5b --- /dev/null +++ b/apps/studio/src/components/DocumentOutline.tsx @@ -0,0 +1,85 @@ +import { useMemo, useState } from "react"; +import type { RefObject } from "react"; +import { + analyzeDocumentOutline, + scrollTextareaToOffset, +} from "../lib/documentOutline.js"; + +type DocumentOutlineProps = { + body: string; + textareaRef: RefObject; +}; + +function headingLabel(level: 1 | 2 | 3): string { + return `H${level}`; +} + +export function DocumentOutline({ body, textareaRef }: DocumentOutlineProps) { + const [open, setOpen] = useState(true); + const analysis = useMemo(() => analyzeDocumentOutline(body), [body]); + + return ( +
+
+

+ Document outline +

+ +
+ + {open && ( +
+ {analysis.headings.length === 0 ? ( +

+ No H1–H3 headings found in the body yet. +

+ ) : ( +
    + {analysis.headings.map((heading) => ( +
  • + +
  • + ))} +
+ )} + + {analysis.h1Count > 1 && ( +

+ {analysis.h1Count} H1 headings detected. One title-level heading is + usually enough. +

+ )} + + {analysis.headings.length > 0 && !analysis.hasSubheading && ( +

+ No H2 or H3 sections yet. Longer articles often use subheadings. +

+ )} +
+ )} +
+ ); +} diff --git a/apps/studio/src/components/DocumentStatus.tsx b/apps/studio/src/components/DocumentStatus.tsx new file mode 100644 index 0000000..dde55c3 --- /dev/null +++ b/apps/studio/src/components/DocumentStatus.tsx @@ -0,0 +1,38 @@ +import { + documentStatusLabel, + shouldShowDocumentStatus, + type DocumentStatus, +} from "../lib/autosave.js"; + +type DocumentStatusProps = { + status: DocumentStatus; +}; + +function statusClassName(kind: DocumentStatus["kind"]): string { + switch (kind) { + case "unsaved": + return "document-status document-status--unsaved"; + case "saved-locally": + return "document-status document-status--local"; + case "published": + return "document-status document-status--published"; + case "publishing": + return "document-status document-status--publishing"; + case "restore-available": + return "document-status document-status--restore"; + case "idle": + return "document-status"; + } +} + +export function DocumentStatusIndicator({ status }: DocumentStatusProps) { + if (!shouldShowDocumentStatus(status)) { + return null; + } + + return ( + + {documentStatusLabel(status)} + + ); +} diff --git a/apps/studio/src/components/EditorWorkspace.tsx b/apps/studio/src/components/EditorWorkspace.tsx deleted file mode 100644 index 9da1647..0000000 --- a/apps/studio/src/components/EditorWorkspace.tsx +++ /dev/null @@ -1,24 +0,0 @@ -type EditorWorkspaceProps = { - body: string; - onBodyChange: (body: string) => void; -}; - -export function EditorWorkspace({ body, onBodyChange }: EditorWorkspaceProps) { - return ( -
-
-

Write

-

Markdown or MDX body

-
- -