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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,23 @@ test.describe("Studio smoke", () => {
await page.getByRole("button", { name: "Simulate publish" }).click();
await expect(page.getByText("Publish simulated")).toBeVisible({ timeout: 10_000 });
});

test("publish mode selector renders in demo mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New post" }).click();
await postTitleInput(page).fill("Publish mode smoke test");
await postDescriptionInput(page).fill(
"Summary for publish mode smoke test.",
);
await page.locator(".writing-canvas__body").fill("# Publish mode\n\nBody content.");

const modeSelect = page.locator("#publish-mode-select");
await expect(modeSelect).toBeVisible();
await modeSelect.selectOption("pull-request");
await expect(page.getByText("PR branch")).toBeVisible();
await page.getByRole("button", { name: "Simulate PR publish" }).click();
await expect(page.getByText("Pull request simulated")).toBeVisible({
timeout: 10_000,
});
});
});
49 changes: 49 additions & 0 deletions apps/studio/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
import {
isPublisherId,
listPublisherIds,
parsePublishMode,
supportedPublisherSummary,
type PublisherId,
type PublishMode,
} from "@sourcedraft/publishers";

export type SupportedAdapter = AdapterId;
Expand All @@ -30,6 +32,9 @@ export type PublishEnvConfig = {
publicMediaPath: string;
adapter: SupportedAdapter;
publisher: SupportedPublisher;
publishMode: PublishMode;
prBranchPrefix: string;
prDraft: boolean;
adapterOptions?: Record<string, unknown>;
publisherOptions?: Record<string, unknown>;
categories: string[];
Expand All @@ -55,6 +60,44 @@ export function loadProjectConfig(): SourceDraftConfig {
return loadSourceDraftConfig();
}

function parseBooleanEnv(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined) {
return defaultValue;
}

const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes") {
return true;
}

if (normalized === "false" || normalized === "0" || normalized === "no") {
return false;
}

return defaultValue;
}

function resolvePublishModeFromEnv(): PublishMode {
const raw = process.env.SOURCEDRAFT_PUBLISH_MODE?.trim().toLowerCase();
const parsed = parsePublishMode(raw);
let mode: PublishMode = parsed ?? "direct";

if (mode === "pull-request" && parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false)) {
mode = "draft-pull-request";
}

return mode;
}

function resolvePrBranchPrefix(): string {
const raw = process.env.SOURCEDRAFT_PR_BRANCH_PREFIX?.trim();
if (!raw) {
return "sourcedraft/";
}

return raw.endsWith("/") ? raw : `${raw}/`;
}

function resolveAdapter(rawAdapter: string): SupportedAdapter | null {
if (isAdapterId(rawAdapter)) {
return rawAdapter;
Expand Down Expand Up @@ -355,6 +398,9 @@ export function loadPublishEnv(): PublishEnvResult {
? { ghostDefaultStatus: credentials.ghostDefaultStatus }
: {}),
categories: project.categories,
publishMode: resolvePublishModeFromEnv(),
prBranchPrefix: resolvePrBranchPrefix(),
prDraft: parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false),
},
};
}
Expand Down Expand Up @@ -428,6 +474,9 @@ export function loadPublicConfig(): PublicStudioConfig {
publicMediaPath: resolvePublicMediaPath(mediaDir, project),
adapter,
publisher,
publishMode: resolvePublishModeFromEnv(),
prBranchPrefix: resolvePrBranchPrefix(),
prDraft: parseBooleanEnv(process.env.SOURCEDRAFT_PR_DRAFT, false),
...(project.adapterOptions !== undefined
? { adapterOptions: project.adapterOptions }
: {}),
Expand Down
125 changes: 125 additions & 0 deletions apps/studio/server/demoPublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,66 @@ import type { PublishEnvConfig } from "./config.js";
import { summaryFromArticle } from "./demoPosts.js";
import { demoCommitSha, upsertDemoPost } from "./demoStore.js";
import { safePostPath } from "./postPaths.js";
import {
isPrPublishMode,
parsePublishMode,
publishModeSummary,
type PublishMode,
} from "@sourcedraft/publishers";
import type { PublishRequestBody, PublishResponse } from "./publish.js";

function isAllowedBranchChar(char: string): boolean {
const code = char.charCodeAt(0);
return (
(code >= 97 && code <= 122) ||
(code >= 48 && code <= 57) ||
char === "." ||
char === "_" ||
char === "/" ||
char === "-"
);
}

function isTrimChar(char: string | undefined): boolean {
return char === "-" || char === "." || char === "/";
}

function sanitizeBranchSegment(value: string): string {
const chars: string[] = [];

for (const char of value.trim().toLowerCase()) {
if (isAllowedBranchChar(char)) {
if (char === "-") {
if (chars[chars.length - 1] !== "-") {
chars.push(char);
}
} else {
chars.push(char);
}
continue;
}

if (chars.length > 0 && chars[chars.length - 1] !== "-") {
chars.push("-");
}
}

while (isTrimChar(chars[0])) {
chars.shift();
}

while (chars.length > 0 && isTrimChar(chars[chars.length - 1])) {
chars.pop();
}

return chars.length > 0 ? chars.join("") : "post";
}

function demoPrBranch(slug: string, prefix: string): string {
const safePrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
return `${safePrefix}${sanitizeBranchSegment(slug)}`;
}

function renderArticle(article: Article, env: Omit<PublishEnvConfig, "token">): string {
return renderAdapterOutput(env.adapter, article, env.adapterOptions);
}
Expand All @@ -29,6 +87,25 @@ function defaultPostPath(
});
}

function resolveDemoPublishMode(
body: PublishRequestBody,
env: Omit<PublishEnvConfig, "token">,
): { ok: true; mode: PublishMode } | { ok: false; error: string } {
if (body.publishMode !== undefined) {
const parsed = parsePublishMode(body.publishMode);
if (parsed === null) {
return {
ok: false,
error: `Unsupported publish mode. Supported modes: ${publishModeSummary()}.`,
};
}

return { ok: true, mode: parsed };
}

return { ok: true, mode: env.publishMode };
}

export async function publishDemoArticle(
body: PublishRequestBody,
env: Omit<PublishEnvConfig, "token">,
Expand Down Expand Up @@ -67,9 +144,56 @@ export async function publishDemoArticle(
created = true;
}

const publishModeResult = resolveDemoPublishMode(body, env);
if (!publishModeResult.ok) {
return {
status: 400,
body: {
ok: false,
error: publishModeResult.error,
},
};
}

const publishMode = publishModeResult.mode;
if (isPrPublishMode(publishMode) && env.publisher !== "github") {
return {
status: 400,
body: {
ok: false,
error: `Pull request publish mode is only supported for the GitHub publisher. Current publisher: ${env.publisher}.`,
},
};
}

const content = renderArticle(article, env);
const commitSha = demoCommitSha();

if (isPrPublishMode(publishMode)) {
const prBranch = demoPrBranch(article.slug, env.prBranchPrefix);
const prNumber = 101;
const owner = env.owner || "demo";
const repo = env.repo || "sample-posts";

return {
status: 200,
body: {
ok: true,
path,
created,
sha: commitSha,
commitSha,
publishMode,
prBranch,
baseBranch: env.branch,
prNumber,
prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
deployHookNote:
"PR created; deploy hook not triggered until merge.",
},
};
}

upsertDemoPost(path, content, {
path,
...summaryFromArticle(path, article),
Expand All @@ -83,6 +207,7 @@ export async function publishDemoArticle(
created,
sha: commitSha,
commitSha,
publishMode: "direct",
},
};
}
4 changes: 4 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ app.get("/api/config", readLimiter, requireAuth, (req, res) => {
publicMediaPath: runtime.publicMediaPath,
defaultBranch: runtime.branch,
categories: runtime.categories,
publishMode: runtime.publishMode,
prBranchPrefix: runtime.prBranchPrefix,
prDraft: runtime.prDraft,
publisher: runtime.publisher,
...(runtime.adapterOptions !== undefined
? { adapterOptions: runtime.adapterOptions }
: {}),
Expand Down
110 changes: 110 additions & 0 deletions apps/studio/server/publish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { publishDemoArticle } from "./demoPublish.js";
import { loadPublicConfig } from "./config.js";
import { resetDemoStore } from "./demoStore.js";

const originalEnv = { ...process.env };

afterEach(() => {
process.env = { ...originalEnv };
});

describe("publish API modes", () => {
it("rejects unsupported publish mode in demo", async () => {
resetDemoStore();
const runtime = loadPublicConfig();
const result = await publishDemoArticle(
{
title: "Mode test",
slug: "mode-test",
description: "Validates unsupported publish mode handling.",
pubDate: "2026-06-08",
category: "Guides",
tags: ["demo"],
draft: false,
body: "# Mode test\n\nBody.",
publishMode: "fast-lane",
},
runtime,
);

assert.equal(result.status, 400);
assert.equal(result.body.ok, false);
if (!result.body.ok) {
assert.match(result.body.error, /Unsupported publish mode/);
}
});

it("simulates pull-request publish in demo mode", async () => {
resetDemoStore();
const runtime = loadPublicConfig();
const result = await publishDemoArticle(
{
title: "PR demo test",
slug: "pr-demo-test",
description: "Validates demo PR publish response shape.",
pubDate: "2026-06-08",
category: "Guides",
tags: ["demo"],
draft: false,
body: "# PR demo test\n\nBody.",
publishMode: "pull-request",
},
runtime,
);

assert.equal(result.status, 200);
assert.equal(result.body.ok, true);
if (result.body.ok) {
assert.equal(result.body.publishMode, "pull-request");
assert.equal(result.body.prBranch, "sourcedraft/pr-demo-test");
assert.equal(result.body.baseBranch, runtime.branch);
assert.match(result.body.prUrl ?? "", /\/pull\/101$/u);
assert.equal(result.body.deployHookNote, "PR created; deploy hook not triggered until merge.");
}
});

it("rejects PR mode for non-GitHub publishers", async () => {
resetDemoStore();
const runtime = {
...loadPublicConfig(),
publisher: "wordpress" as const,
publishMode: "direct" as const,
prBranchPrefix: "sourcedraft/",
prDraft: false,
};

const result = await publishDemoArticle(
{
title: "Unsupported PR mode",
slug: "unsupported-pr-mode",
description: "Validates unsupported publisher handling.",
pubDate: "2026-06-08",
category: "Guides",
tags: ["demo"],
draft: false,
body: "# Unsupported\n\nBody.",
publishMode: "pull-request",
},
runtime,
);

assert.equal(result.status, 400);
assert.equal(result.body.ok, false);
if (!result.body.ok) {
assert.match(result.body.error, /only supported for the GitHub publisher/i);
}
});

it("loads publish mode from env", () => {
process.env.SOURCEDRAFT_PUBLISH_MODE = "pull-request";
process.env.SOURCEDRAFT_PR_BRANCH_PREFIX = "custom/";
process.env.SOURCEDRAFT_PR_DRAFT = "true";

const runtime = loadPublicConfig();
assert.equal(runtime.publishMode, "draft-pull-request");
assert.equal(runtime.prBranchPrefix, "custom/");
assert.equal(runtime.prDraft, true);
});
});
Loading
Loading