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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Start Studio (UI + publish API):
pnpm dev
```

Sign in, open **Write**, preview the output, publish. The file lands at `contentDir/<slug>.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/<slug>.mdx` or `.md` depending on your adapter (default: `src/content/blog/`).

Full walkthrough: [docs/getting-started.md](docs/getting-started.md)

Expand All @@ -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`.

Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
} 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";
Expand Down Expand Up @@ -97,6 +98,17 @@
res.status(result.status).json(result.body);
});

app.get("/api/media", requireAuth, async (_req, res) => {

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
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,
Expand Down
88 changes: 88 additions & 0 deletions apps/studio/server/listMedia.ts
Original file line number Diff line number Diff line change
@@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve subfolders when building media URLs

When the media library includes files inside subdirectories of mediaDir, publisher.listFiles recurses into those directories but this line builds the public URL from only the basename. For example, public/images/posts/photo.png is shown/inserted as /images/photo.png instead of /images/posts/photo.png, so choosing existing nested media creates broken cover images or Markdown links. Build the public path from the path relative to mediaDir, not just filename.

Useful? React with 👍 / 👎.

filename,
extension,
kind,
size: file.size,
});
}

files.sort((left, right) => right.filename.localeCompare(left.filename));

return {
status: 200,
body: { ok: true, files },
};
}
Loading