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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ The GitHub token never reaches the browser. It is read from `.env` on the server

Details: [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md)

v0.1 uses the GitHub Contents API — suitable for typical blogs; very large content folders are a known MVP limitation.

## Quickstart

Requirements: Node.js 22+, pnpm 11+
Expand Down
6 changes: 5 additions & 1 deletion apps/studio/server/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,16 @@ export async function uploadMedia(
path: repoPath,
contentBase64: parsed.buffer.toString("base64"),
message: `Upload media: ${repoFilename}`,
purpose: "media",
});

if (!result.ok) {
return {
status: 502,
body: { ok: false, error: result.error },
body: {
ok: false,
error: result.error || "Media upload to GitHub failed.",
},
};
}

Expand Down
7 changes: 4 additions & 3 deletions apps/studio/server/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,11 @@ export async function listPosts(
): Promise<{ status: number; body: PostsListResponse }> {
const publisher = createPublisher(env);
const contentDir = normalizeContentDir(env.contentDir);
const listed = await publisher.listFiles({ path: contentDir });
const listed = await publisher.listFiles({ path: contentDir, contentDir });

if (!listed.ok) {
return {
status: 502,
status: listed.status === 404 ? 404 : 502,
body: { ok: false, error: listed.error },
};
}
Expand Down Expand Up @@ -246,8 +246,9 @@ export async function loadPost(
const loaded = await publisher.readFile({ path: safe.path });

if (!loaded.ok) {
const status = loaded.status === 404 ? 404 : 502;
return {
status: loaded.status === 404 ? 404 : 502,
status,
body: { ok: false, error: loaded.error },
};
}
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/server/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,13 @@ export async function publishArticle(
path,
content,
message: `Publish: ${article.slug}`,
purpose: "post",
});

if (!result.ok) {
const errorBody: PublishErrorResponse = {
ok: false,
error: result.error,
error: result.error || "Publish to GitHub failed.",
};

if (result.status !== undefined) {
Expand Down
41 changes: 25 additions & 16 deletions apps/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,9 @@ function App() {
}

const action = result.created ? "Created" : "Updated";
setPublishSuccess(`${action} ${result.path} (${result.commitSha.slice(0, 7)})`);
setPublishSuccess(
`${action} output file ${result.path} (commit ${result.commitSha.slice(0, 7)}).`,
);
setEditingPath(result.path);
await refreshPosts();
} catch {
Expand All @@ -269,7 +271,9 @@ function App() {
if (!authChecked) {
return (
<div className="login-screen">
<p className="login-screen__loading">Checking session...</p>
<p className="login-screen__loading" role="status">
Checking session…
</p>
</div>
);
}
Expand All @@ -292,14 +296,16 @@ function App() {
{view === "overview" && (
<div className="studio__stack">
{loadPostError && (
<p className="article-pipeline__error article-pipeline__error--banner">
{loadPostError}
</p>
<div className="notice notice--error" role="alert">
<p className="notice__title">Could not open post</p>
<p className="notice__body">{loadPostError}</p>
</div>
)}
<ArticlePipeline
posts={posts}
loading={postsLoading}
error={postsError}
githubReady={githubReady}
onRefresh={() => {
void refreshPosts();
}}
Expand All @@ -320,10 +326,13 @@ function App() {
<div className="studio__editor-layout">
<div className="studio__editor-column">
{editingPath && (
<p className="editor-notice">
Editing <code>{editingPath}</code>. Publishing will update this
file in GitHub.
</p>
<div className="notice notice--info editor-notice" role="status">
<p className="notice__title">Editing an existing post</p>
<p className="notice__body">
Changes will update output file{" "}
<code>{editingPath}</code> on GitHub.
</p>
</div>
)}
<EditorWorkspace body={form.body} onBodyChange={handleBodyChange} />
{fieldErrors.body && (
Expand All @@ -349,7 +358,7 @@ function App() {
<FrontmatterInspector
values={form}
categories={studioConfig.categories}
mediaDir={studioConfig.mediaDir}
githubReady={githubReady}
fieldErrors={fieldErrors}
slugAuto={slugAuto}
onChange={handleFieldChange}
Expand All @@ -365,17 +374,17 @@ function App() {
<div className="studio__stack">
<section className="panel settings-panel">
<div className="panel__header">
<h2 className="panel__title">Publishing configuration</h2>
<h2 className="panel__title">Settings</h2>
<p className="panel__meta">
Paths and categories from sourcedraft.config.json. GitHub
target from .env.
Project paths from config · GitHub target from .env
</p>
</div>

<p className="settings-panel__note">
Publishing requires a GitHub token with write access to the
target repository. The token is read only by the server when you
publish.
These values are read-only here. Edit{" "}
<code>sourcedraft.config.json</code> for folders and categories, and{" "}
<code>.env</code> for GitHub credentials. The token never reaches
the browser.
</p>

<div className="settings-panel__grid">
Expand Down
23 changes: 12 additions & 11 deletions apps/studio/src/components/AdapterStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,30 @@ export function AdapterStatus({
githubOwner.trim().length > 0 && githubRepo.trim().length > 0;

const rows: StatusRow[] = [
{ label: "Adapter", value: adapter, state: "ok" },
{ label: "Output path", value: contentDir, state: "ok" },
{ label: "Output format", value: adapter, state: "ok" },
{ label: "Content folder", value: contentDir, state: "ok" },
{
label: "GitHub target",
label: "GitHub repository",
value: githubReady ? `${githubOwner}/${githubRepo}` : "Not configured",
state: githubReady ? "idle" : "off",
},
{
label: "GitHub token",
value: "Checked server-side on publish",
state: "idle",
value: "Used on the server when you publish",
state: githubReady ? "idle" : "off",
},
{ label: "Auth", value: "Local password session", state: "ok" },
];

return (
<section className="panel adapter-status">
<section className="panel adapter-status" aria-labelledby="setup-panel-title">
<div className="panel__header">
<h2 className="panel__title">Publishing setup</h2>
<h2 className="panel__title" id="setup-panel-title">
Publishing setup
</h2>
<p className="panel__meta">
{githubReady
? "GitHub repo configured in .env"
: "Set GITHUB_OWNER and GITHUB_REPO in .env to publish"}
? "Connected to your GitHub repository"
: "Finish GitHub setup in .env to publish"}
</p>
</div>

Expand All @@ -56,7 +57,7 @@ export function AdapterStatus({
className={`adapter-status__dot adapter-status__dot--${row.state}`}
aria-hidden="true"
/>
{row.value}
<span>{row.value}</span>
</dd>
</div>
))}
Expand Down
70 changes: 53 additions & 17 deletions apps/studio/src/components/ArticlePipeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ type ArticlePipelineProps = {
posts: PostSummary[];
loading: boolean;
error: string | null;
githubReady: boolean;
onRefresh: () => void;
onEdit: (path: string) => void;
};
Expand All @@ -12,16 +13,19 @@ export function ArticlePipeline({
posts,
loading,
error,
githubReady,
onRefresh,
onEdit,
}: ArticlePipelineProps) {
return (
<section className="panel article-pipeline">
<section className="panel article-pipeline" aria-labelledby="posts-panel-title">
<div className="panel__header">
<h2 className="panel__title">Articles</h2>
<p className="panel__meta">
<h2 className="panel__title" id="posts-panel-title">
Your posts
</h2>
<p className="panel__meta" aria-live="polite">
{loading
? "Loading from GitHub..."
? "Loading posts from GitHub"
: `${posts.length} post${posts.length === 1 ? "" : "s"} in your content folder`}
</p>
<button
Expand All @@ -30,42 +34,74 @@ export function ArticlePipeline({
disabled={loading}
onClick={onRefresh}
>
Refresh
Refresh list
</button>
</div>

{loading && (
<div className="empty-state" role="status">
<p className="empty-state__title">Loading posts…</p>
<p className="empty-state__body">
Fetching posts from your GitHub content folder. This may take a moment
for larger sites.
</p>
</div>
)}

{!githubReady && !loading && (
<div className="notice notice--warning" role="status">
<p className="notice__title">GitHub is not configured yet</p>
<p className="notice__body">
Set <code>GITHUB_OWNER</code> and <code>GITHUB_REPO</code> in{" "}
<code>.env</code>, then open <strong>Settings</strong> to confirm the
target repository. You can still write drafts locally.
</p>
</div>
)}

{error && (
<p className="article-pipeline__error">{error}</p>
<div className="notice notice--error" role="alert">
<p className="notice__title">Could not load posts</p>
<p className="notice__body">{error}</p>
<p className="notice__hint">
Check your GitHub token, repository settings, and{" "}
<code>contentDir</code> in Settings. Use Refresh list after fixing
configuration.
</p>
</div>
)}

{!loading && !error && posts.length === 0 && (
<div className="empty-state">
<p className="empty-state__title">No posts yet</p>
<p className="empty-state__title">No posts found</p>
<p className="empty-state__body">
Use <strong>New Article</strong> to write your first post. After you
publish, it will show up here so you can edit it later.
{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."}
</p>
</div>
)}

{posts.length > 0 && (
{!loading && posts.length > 0 && (
<div className="article-pipeline__table-wrap">
<table className="article-pipeline__table">
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Category</th>
<th>Status</th>
<th aria-label="Actions" />
<th scope="col">Title</th>
<th scope="col">Date</th>
<th scope="col">Category</th>
<th scope="col">Status</th>
<th scope="col" className="article-pipeline__actions">
<span className="visually-hidden">Actions</span>
</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.path}>
<td>
<span className="article-pipeline__title">{post.title}</span>
<code className="article-pipeline__path">{post.path}</code>
<span className="article-pipeline__path">{post.path}</span>
</td>
<td className="article-pipeline__mono">{post.pubDate}</td>
<td>{post.category}</td>
Expand All @@ -77,7 +113,7 @@ export function ArticlePipeline({
: "article-pipeline__badge article-pipeline__badge--ready"
}
>
{post.draft ? "Draft" : "Published"}
{post.draft ? "Draft" : "Live"}
</span>
</td>
<td className="article-pipeline__actions">
Expand Down
Loading