Next action
diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css
index 024da84..2e46f9f 100644
--- a/apps/studio/src/index.css
+++ b/apps/studio/src/index.css
@@ -1807,6 +1807,127 @@ select.field__input:focus-visible {
line-height: 1.45;
}
+.compatibility-panel__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 16px;
+ margin: 0 0 12px;
+}
+
+.compatibility-panel__row {
+ display: grid;
+ gap: 2px;
+}
+
+.compatibility-panel__row dt {
+ margin: 0;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+}
+
+.compatibility-panel__row dd {
+ margin: 0;
+ font-size: var(--text-xs);
+ font-family: var(--font-mono);
+}
+
+.compatibility-panel__status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.compatibility-panel__status--ok {
+ color: #1b5e20;
+ background: #e8f5e9;
+}
+
+.compatibility-panel__status--warn {
+ color: #8a5a00;
+ background: #fff8e1;
+}
+
+.compatibility-panel__notice {
+ margin-bottom: 10px;
+}
+
+.compatibility-panel__warnings {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.seo-sharing {
+ margin-top: 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-subtle);
+}
+
+.seo-sharing__summary {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 12px;
+ cursor: pointer;
+ list-style: none;
+}
+
+.seo-sharing__summary::-webkit-details-marker {
+ display: none;
+}
+
+.seo-sharing__title {
+ font-size: var(--text-xs);
+ font-weight: 600;
+}
+
+.seo-sharing__hint {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.seo-sharing__body {
+ padding: 0 12px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.seo-sharing__intro {
+ margin: 0;
+ font-size: 11px;
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.seo-sharing__reading-time {
+ margin: 0;
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.seo-sharing__warnings {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 11px;
+ color: #8a5a00;
+ line-height: 1.45;
+}
+
+.seo-sharing__warning--blocked {
+ color: var(--text-muted);
+}
+
.visually-hidden {
position: absolute;
width: 1px;
diff --git a/apps/studio/src/lib/articleForm.ts b/apps/studio/src/lib/articleForm.ts
index 48a6206..816d37c 100644
--- a/apps/studio/src/lib/articleForm.ts
+++ b/apps/studio/src/lib/articleForm.ts
@@ -12,6 +12,13 @@ export type ArticleFormState = {
draft: boolean;
heroImage: string;
body: string;
+ author: string;
+ metaTitle: string;
+ metaDescription: string;
+ canonicalUrl: string;
+ socialImage: string;
+ coverImageAlt: string;
+ noindex: boolean;
};
export function createInitialFormState(
@@ -28,6 +35,13 @@ export function createInitialFormState(
draft: true,
heroImage: "",
body: "",
+ author: "",
+ metaTitle: "",
+ metaDescription: "",
+ canonicalUrl: "",
+ socialImage: "",
+ coverImageAlt: "",
+ noindex: false,
};
}
@@ -58,6 +72,34 @@ export function formStateToArticleInput(state: ArticleFormState): ArticleInput {
input.heroImage = state.heroImage;
}
+ if (state.author.trim().length > 0) {
+ input.author = state.author;
+ }
+
+ if (state.metaTitle.trim().length > 0) {
+ input.metaTitle = state.metaTitle;
+ }
+
+ if (state.metaDescription.trim().length > 0) {
+ input.metaDescription = state.metaDescription;
+ }
+
+ if (state.canonicalUrl.trim().length > 0) {
+ input.canonicalUrl = state.canonicalUrl;
+ }
+
+ if (state.socialImage.trim().length > 0) {
+ input.socialImage = state.socialImage;
+ }
+
+ if (state.coverImageAlt.trim().length > 0) {
+ input.coverImageAlt = state.coverImageAlt;
+ }
+
+ if (state.noindex) {
+ input.noindex = true;
+ }
+
return input;
}
@@ -110,5 +152,12 @@ export function articleInputToFormState(
draft: typeof input.draft === "boolean" ? input.draft : true,
heroImage: stringField(input.heroImage),
body: stringField(input.body),
+ author: stringField(input.author),
+ metaTitle: stringField(input.metaTitle),
+ metaDescription: stringField(input.metaDescription),
+ canonicalUrl: stringField(input.canonicalUrl),
+ socialImage: stringField(input.socialImage),
+ coverImageAlt: stringField(input.coverImageAlt),
+ noindex: input.noindex === true,
};
}
diff --git a/apps/studio/src/lib/autosave.ts b/apps/studio/src/lib/autosave.ts
index 6341fce..91a5f01 100644
--- a/apps/studio/src/lib/autosave.ts
+++ b/apps/studio/src/lib/autosave.ts
@@ -114,6 +114,15 @@ export function parseAutosave(raw: string): AutosaveRecord | null {
draft: form.draft,
heroImage: form.heroImage as string,
body: form.body as string,
+ author: typeof form.author === "string" ? form.author : "",
+ metaTitle: typeof form.metaTitle === "string" ? form.metaTitle : "",
+ metaDescription:
+ typeof form.metaDescription === "string" ? form.metaDescription : "",
+ canonicalUrl: typeof form.canonicalUrl === "string" ? form.canonicalUrl : "",
+ socialImage: typeof form.socialImage === "string" ? form.socialImage : "",
+ coverImageAlt:
+ typeof form.coverImageAlt === "string" ? form.coverImageAlt : "",
+ noindex: form.noindex === true,
},
editingPath,
slugAuto: candidate.slugAuto,
@@ -153,7 +162,14 @@ export function formsEqual(left: ArticleFormState, right: ArticleFormState): boo
left.tags === right.tags &&
left.draft === right.draft &&
left.heroImage === right.heroImage &&
- left.body === right.body
+ left.body === right.body &&
+ left.author === right.author &&
+ left.metaTitle === right.metaTitle &&
+ left.metaDescription === right.metaDescription &&
+ left.canonicalUrl === right.canonicalUrl &&
+ left.socialImage === right.socialImage &&
+ left.coverImageAlt === right.coverImageAlt &&
+ left.noindex === right.noindex
);
}
diff --git a/apps/studio/src/lib/seoValidation.test.ts b/apps/studio/src/lib/seoValidation.test.ts
new file mode 100644
index 0000000..85ed79a
--- /dev/null
+++ b/apps/studio/src/lib/seoValidation.test.ts
@@ -0,0 +1,23 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { createInitialFormState } from "./articleForm.js";
+import { analyzeSeoFields } from "./seoValidation.js";
+
+describe("analyzeSeoFields", () => {
+ it("returns reading time and soft warnings", () => {
+ const state = {
+ ...createInitialFormState(),
+ title: "Hello",
+ body: "word ".repeat(250).trim(),
+ heroImage: "/images/cover.png",
+ metaDescription: "d".repeat(200),
+ };
+
+ const result = analyzeSeoFields(state);
+ assert.ok(result.readingTimeMinutes >= 2);
+ assert.ok(result.warnings.some((warning) => warning.id === "cover-alt-missing"));
+ assert.ok(
+ result.warnings.some((warning) => warning.id === "meta-description-long"),
+ );
+ });
+});
diff --git a/apps/studio/src/lib/seoValidation.ts b/apps/studio/src/lib/seoValidation.ts
new file mode 100644
index 0000000..75a5890
--- /dev/null
+++ b/apps/studio/src/lib/seoValidation.ts
@@ -0,0 +1,21 @@
+import {
+ buildSeoWarnings,
+ computeReadingTimeMinutes,
+ type SeoWarning,
+} from "@sourcedraft/core";
+import type { ArticleFormState } from "./articleForm.js";
+import { formStateToArticleInput } from "./articleForm.js";
+
+export type SeoValidationResult = {
+ readingTimeMinutes: number;
+ warnings: SeoWarning[];
+};
+
+export function analyzeSeoFields(state: ArticleFormState): SeoValidationResult {
+ const input = formStateToArticleInput(state);
+
+ return {
+ readingTimeMinutes: computeReadingTimeMinutes(state.body),
+ warnings: buildSeoWarnings(input),
+ };
+}
diff --git a/apps/studio/src/lib/setupHealth.ts b/apps/studio/src/lib/setupHealth.ts
index 52b7138..97523ff 100644
--- a/apps/studio/src/lib/setupHealth.ts
+++ b/apps/studio/src/lib/setupHealth.ts
@@ -5,6 +5,15 @@ export type SetupHealthCheck = {
detail: string;
};
+export type SetupCompatibilityReport = {
+ adapter: string;
+ publisher: string;
+ mediaProvider: string;
+ validationOk: boolean;
+ missingEnvVars: string[];
+ warnings: string[];
+};
+
export type SetupHealthReport = {
ok: boolean;
adminPasswordConfigured: boolean;
@@ -15,9 +24,11 @@ export type SetupHealthReport = {
mediaDirConfigured: boolean;
publicMediaPathConfigured: boolean;
adapterValid: boolean;
+ publisherValid: boolean;
demoModeForced: boolean;
demoModeAvailable: boolean;
githubReady: boolean;
+ compatibility: SetupCompatibilityReport;
checks: SetupHealthCheck[];
nextAction: string | null;
};
diff --git a/docs/adapters.md b/docs/adapters.md
index 04190ae..2845e3f 100644
--- a/docs/adapters.md
+++ b/docs/adapters.md
@@ -26,7 +26,7 @@ Unknown adapter ids fail validation in `loadPublishEnv()` with a list of support
| `mkdocs-markdown` | `.md` | `docs` | MkDocs documentation sites | same (no `draft` in output) |
| `nuxt-content-markdown` | `.md` | `content/blog` | Nuxt Content v2 collections | same |
-SEO fields are optional on the universal article schema and emitted when present. Studio UI for editing them is still limited — see [seo-fields-roadmap.md](seo-fields-roadmap.md).
+Optional SEO fields (`metaTitle`, `metaDescription`, `canonicalUrl`, `socialImage`, `coverImageAlt`, `noindex`, `author`, computed `readingTime`) are emitted when present. Edit them in Studio under **SEO / Sharing**. See [seo-fields.md](seo-fields.md).
## Shared adapter options
diff --git a/docs/compatibility-roadmap.md b/docs/compatibility-roadmap.md
index 228a5db..6949bd2 100644
--- a/docs/compatibility-roadmap.md
+++ b/docs/compatibility-roadmap.md
@@ -69,4 +69,4 @@ Defaults: `adapter: "astro-mdx"`, `publisher: "github"`.
- Post list still walks GitHub Contents API — large repos remain an MVP limitation.
- `listPosts` is reused for media library listing until a dedicated `listMedia` capability is added.
-- SEO optional fields exist on the schema but Studio UI exposure is still partial.
+- SEO optional fields are available in schema, adapters, and Studio **SEO / Sharing** panel — see [seo-fields.md](seo-fields.md).
diff --git a/docs/configuration.md b/docs/configuration.md
index d10f54e..98fc488 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,6 +1,18 @@
# Configuration
-See also: [Getting started](getting-started.md) · [GitHub publishing](github-publishing.md) · [Media uploads](media.md)
+See also: [Getting started](getting-started.md) · [Setup wizard](setup-wizard.md) · [GitHub publishing](github-publishing.md) · [Media uploads](media.md)
+
+## Setup wizard and validation
+
+```bash
+pnpm setup # interactive wizard — writes config + .env
+pnpm validate:config # check adapter, publisher, env vars, paths
+pnpm validate:config --connections # optional live API checks
+```
+
+Studio **Settings → Compatibility & status** mirrors validation (adapter, publisher, media provider, missing env var names — never secret values).
+
+Details: [setup-wizard.md](setup-wizard.md).
## Secrets vs project settings
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 2f2504d..a5300b7 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -10,22 +10,37 @@ cd SourceDraft
pnpm install
```
-## 2. Project settings (`sourcedraft.config.json`)
+## 2. Configure SourceDraft
+
+**Recommended — setup wizard**
+
+```bash
+pnpm setup
+```
+
+The wizard asks which adapter, publisher, and media provider you use, then creates `sourcedraft.config.json` and `.env` with plain-language prompts. Existing `.env` values are kept unless you choose to overwrite them. See [setup-wizard.md](setup-wizard.md).
+
+**Manual — copy example files**
```bash
cp sourcedraft.config.example.json sourcedraft.config.json
+cp .env.example .env
```
Edit paths, adapter, and categories to match your site. These values are safe to commit.
Use `astro-mdx` for `.mdx` output or `markdown` for `.md` output. See [adapters.md](adapters.md).
-## 3. Secrets (`.env`)
+Validate anytime:
```bash
-cp .env.example .env
+pnpm validate:config
```
+## 3. Secrets (`.env`)
+
+If you used `pnpm setup`, skip copying `.env` — the wizard already wrote it. Otherwise:
+
```env
SOURCEDRAFT_ADMIN_PASSWORD=your-local-studio-password
# Optional: force demo mode (no GitHub commits)
diff --git a/docs/ghost.md b/docs/ghost.md
index 8d6f69f..a9434d2 100644
--- a/docs/ghost.md
+++ b/docs/ghost.md
@@ -43,9 +43,11 @@ SourceDraft generates short-lived JWTs server-side (HS256, 5-minute expiry) —
| `draft: true` | `status: draft` |
| `draft: false` | `status: GHOST_DEFAULT_STATUS` |
| `heroImage` / `socialImage` (absolute URL) | `feature_image` |
-| `metaTitle` | `meta_title` |
-| `metaDescription` | `meta_description` |
+| `metaTitle` / `title` | `meta_title` |
+| `metaDescription` / `description` | `meta_description` |
| `canonicalUrl` | `canonical_url` |
+| `coverImageAlt` | `feature_image_alt` |
+| Absolute `socialImage` (when different from feature image) | `og_image` |
### HTML content
diff --git a/docs/non-technical-overview.md b/docs/non-technical-overview.md
index 18fdaa4..d9fda8e 100644
--- a/docs/non-technical-overview.md
+++ b/docs/non-technical-overview.md
@@ -47,7 +47,9 @@ You need a GitHub repo for your blog and a token with permission to add or updat
**`.env`** — password, GitHub token, and which repository to write to. Private; never commit.
-Your technical contact sets both up once. Writers typically only need the Studio address and password.
+Your technical contact can run **`pnpm setup`** once — a guided wizard that creates both files with plain-language questions — or edit them manually. Writers typically only need the Studio address and password.
+
+In Studio **Settings**, a read-only status panel shows whether adapter, publisher, and credentials look complete (without showing secrets).
## Who sets it up?
diff --git a/docs/seo-fields-roadmap.md b/docs/seo-fields-roadmap.md
deleted file mode 100644
index ea8cf02..0000000
--- a/docs/seo-fields-roadmap.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# SEO fields roadmap
-
-SourceDraft Studio does not yet expose optional SEO frontmatter fields in the article schema. This document tracks a future, adapter-safe addition.
-
-## Planned optional fields
-
-| Field | Type | Purpose |
-|-------|------|---------|
-| `seoTitle` | string | Alternate title for `
` / Open Graph when different from `title` |
-| `canonicalUrl` | string | Canonical URL hint for static site templates |
-| `noindex` | boolean | Request noindex treatment in consuming site templates |
-
-These fields are **site-template concerns**. SourceDraft stores them in frontmatter; each publishing target decides how to render them.
-
-## Required code touchpoints
-
-Before shipping in Studio UI:
-
-1. `@sourcedraft/core` — extend `ArticleInput` / `Article`, optional validation, normalization
-2. `@sourcedraft/adapter-astro-mdx` — emit fields in YAML when present
-3. `@sourcedraft/adapter-markdown` — same
-4. `apps/studio/server/posts.ts` — parse unknown/frontmatter keys on load
-5. `apps/studio/src/lib/articleForm.ts` — form state + conversions
-6. Studio Post details UI — optional inputs with calm guidance (not ranking promises)
-7. Tests in core and adapters
-
-## Out of scope for schema work
-
-- External SEO APIs or scoring services
-- Google ranking guarantees
-- Site-specific hardcoding (QuBrite or otherwise)
-
-## Content quality panel (shipped separately)
-
-Studio already reports factual checks (word count, reading time, link/image counts, missing alt text, required-field gaps) without these SEO fields. When schema support lands, the quality panel can surface `seoTitle` length alongside `title` length.
diff --git a/docs/seo-fields.md b/docs/seo-fields.md
new file mode 100644
index 0000000..7146931
--- /dev/null
+++ b/docs/seo-fields.md
@@ -0,0 +1,72 @@
+# SEO and sharing fields
+
+SourceDraft stores optional SEO metadata in article frontmatter (git publishers) or maps it to CMS APIs (Ghost, WordPress). Fields are **optional** — old posts without them keep working.
+
+## Schema fields
+
+| Field | Type | Notes |
+|-------|------|--------|
+| `metaTitle` | string | Falls back to `title` when empty |
+| `metaDescription` | string | Falls back to `description` when empty |
+| `canonicalUrl` | string | Must be a valid `http(s)` URL if set |
+| `socialImage` | string | Falls back to cover/`heroImage` when empty |
+| `coverImageAlt` | string | Alt text for the cover image |
+| `noindex` | boolean | Default `false`; emitted in frontmatter only when `true` |
+| `author` | string | Optional byline |
+| `updatedDate` | date | Optional last-updated date |
+| `readingTime` | number | **Computed** from body word count on publish (not required in the editor) |
+
+Cover image path remains `heroImage` in most adapters (Next.js uses `coverImage` in frontmatter).
+
+## Studio
+
+Open **SEO / Sharing** in the post details sidebar (collapsed by default).
+
+- Soft warnings for long meta title/description, missing cover alt when a cover is set
+- Warnings do **not** block publishing
+- Invalid canonical URLs block publish (schema validation)
+
+## Git publishers
+
+All built-in adapters emit SEO fields in frontmatter when present. Your static site template decides how to render them (for example ``, Open Graph tags).
+
+## Ghost
+
+Mapped on publish:
+
+| SourceDraft | Ghost |
+|-------------|-------|
+| `metaTitle` / `title` | `meta_title` |
+| `metaDescription` / `description` | `meta_description` |
+| `canonicalUrl` | `canonical_url` |
+| `socialImage` / `heroImage` | `feature_image` |
+| `coverImageAlt` | `feature_image_alt` |
+| Absolute `socialImage` (when different) | `og_image` |
+
+## WordPress
+
+Core REST fields always sent: `title`, `content`, `excerpt`, `slug`, `status`, taxonomies.
+
+SEO plugin meta (Yoast, Rank Math, etc.) is **not** sent unless you map keys in `publisherOptions`:
+
+```json
+{
+ "publisher": "wordpress",
+ "publisherOptions": {
+ "wordpressSeoMeta": {
+ "_yoast_wpseo_title": "metaTitle",
+ "_yoast_wpseo_metadesc": "metaDescription",
+ "rank_math_title": "metaTitle"
+ }
+ }
+}
+```
+
+WordPress must expose those meta keys to the REST API (often requires plugin configuration). SourceDraft does not assume any SEO plugin is installed.
+
+## Related docs
+
+- [adapters.md](adapters.md)
+- [publishers.md](publishers.md)
+- [wordpress.md](wordpress.md)
+- [ghost.md](ghost.md)
diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md
new file mode 100644
index 0000000..8da6dc7
--- /dev/null
+++ b/docs/setup-wizard.md
@@ -0,0 +1,75 @@
+# Setup wizard
+
+SourceDraft includes an interactive CLI wizard for first-time setup. It asks plain-language questions and writes `sourcedraft.config.json` and `.env` for you.
+
+## Run the wizard
+
+From the repository root:
+
+```bash
+pnpm setup
+```
+
+You will be asked about:
+
+- **Adapter** — which site generator / output format (Astro MDX, Hugo, etc.)
+- **Publisher** — GitHub, GitLab, Bitbucket, WordPress, or Ghost
+- **Media provider** — git-backed uploads, Cloudinary, or S3-compatible (experimental)
+- **Content and media directories** — folders in your repository
+- **Default branch** — usually `main`
+- **Categories** — comma-separated list for the editor
+- **Deploy hook** — optional URL to rebuild your site after publish
+- **Credentials** — tokens and URLs with short explanations (never shown in Studio)
+
+### Existing files
+
+- If `.env` already exists, the wizard creates `.env.backup.TIMESTAMP` before writing.
+- Existing `.env` values are **not** overwritten unless you confirm each change.
+- Secrets are **masked** in the summary output (typed input is still visible on screen).
+
+### Connection checks
+
+At the end, you can run optional **read-only** API checks:
+
+- Git hosts: verify repository access
+- WordPress / Ghost: verify REST / Admin API authentication
+- Deploy hooks: URL shape only — the wizard does **not** trigger a build unless you explicitly opt in
+
+## Validate configuration
+
+Check your setup without opening Studio:
+
+```bash
+pnpm validate:config
+```
+
+Add live connection checks:
+
+```bash
+pnpm validate:config --connections
+```
+
+Validation reports:
+
+- Unknown adapter, publisher, or media provider
+- Missing required environment variables
+- Invalid content or media path format
+- Compatibility **warnings** (non-fatal) such as CMS publisher with git-backed media
+
+Exit code `0` means no errors; warnings may still be printed.
+
+## Studio status panel
+
+In Studio → **Settings**, the **Compatibility & status** panel shows:
+
+- Selected adapter, publisher, and media provider
+- Validation status
+- Missing env var names (not values)
+
+Use the wizard or `validate:config` locally, then restart the API server if you change `.env`.
+
+## Related docs
+
+- [getting-started.md](getting-started.md)
+- [configuration.md](configuration.md)
+- [non-technical-overview.md](non-technical-overview.md)
diff --git a/docs/wordpress.md b/docs/wordpress.md
index 8e03e6e..4b98d7f 100644
--- a/docs/wordpress.md
+++ b/docs/wordpress.md
@@ -70,6 +70,19 @@ SourceDraft does **not** auto-create WordPress terms. Map Studio category/tag na
Unmapped names are omitted from the API payload.
+### SEO plugin meta (optional)
+
+Core REST fields do not include Yoast, Rank Math, or similar SEO plugin keys by default. To send plugin meta, map keys in `publisherOptions.wordpressSeoMeta`:
+
+```json
+"wordpressSeoMeta": {
+ "_yoast_wpseo_title": "metaTitle",
+ "_yoast_wpseo_metadesc": "metaDescription"
+}
+```
+
+Those keys must be registered for REST access in WordPress. Without this mapping, SourceDraft only sends title, content, excerpt, slug, and status. See [seo-fields.md](seo-fields.md).
+
### Featured images
`featured_media` is not set automatically. WordPress expects a media attachment id, not a URL. Upload media in WordPress or extend your workflow separately.
diff --git a/examples/astro-blog/src/content/blog/getting-started-with-sourcedraft.mdx b/examples/astro-blog/src/content/blog/getting-started-with-sourcedraft.mdx
index d061f4f..eba23cd 100644
--- a/examples/astro-blog/src/content/blog/getting-started-with-sourcedraft.mdx
+++ b/examples/astro-blog/src/content/blog/getting-started-with-sourcedraft.mdx
@@ -7,6 +7,10 @@ tags:
- sourcedraft
- astro
draft: false
+metaTitle: Getting started with SourceDraft
+metaDescription: Example MDX post shape with optional SEO frontmatter from SourceDraft Studio.
+coverImageAlt: SourceDraft logo on a laptop screen
+readingTime: 2
---
# Getting started with SourceDraft
diff --git a/examples/nextjs-mdx-blog/content/posts/getting-started-with-sourcedraft.mdx b/examples/nextjs-mdx-blog/content/posts/getting-started-with-sourcedraft.mdx
index e9ee7d3..6cf9df0 100644
--- a/examples/nextjs-mdx-blog/content/posts/getting-started-with-sourcedraft.mdx
+++ b/examples/nextjs-mdx-blog/content/posts/getting-started-with-sourcedraft.mdx
@@ -10,6 +10,10 @@ tags:
- mdx
author: SourceDraft
coverImage: /images/sample-cover.png
+metaTitle: Getting started with SourceDraft
+metaDescription: Publish MDX posts to a Next.js blog from Studio with optional SEO metadata.
+coverImageAlt: Sample cover image for the post
+readingTime: 1
---
## Write in Studio
diff --git a/package.json b/package.json
index ee82807..6972193 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/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/github-publisher --filter studio test",
+ "setup": "tsx packages/setup/src/cli.ts setup",
+ "validate:config": "tsx packages/setup/src/cli.ts validate",
+ "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter studio test",
"test:e2e": "pnpm --filter studio test:e2e",
"screenshots:generate": "pnpm --filter studio screenshots:generate"
},
diff --git a/packages/adapter-astro-mdx/src/toAstroMdx.test.ts b/packages/adapter-astro-mdx/src/toAstroMdx.test.ts
index 4e78a52..5b1663d 100644
--- a/packages/adapter-astro-mdx/src/toAstroMdx.test.ts
+++ b/packages/adapter-astro-mdx/src/toAstroMdx.test.ts
@@ -13,6 +13,9 @@ const article: Article = {
tags: ["alpha", "beta"],
draft: true,
heroImage: "/images/hero.png",
+ metaTitle: "SEO title",
+ coverImageAlt: "Cover alt",
+ noindex: true,
body: "## Intro\n\nParagraph one.",
};
@@ -29,6 +32,10 @@ describe("toAstroMdx", () => {
assert.match(output, /tags:\n - alpha\n - beta\n/);
assert.match(output, /draft: true\n/);
assert.match(output, /heroImage: \/images\/hero\.png\n/);
+ assert.match(output, /metaTitle: SEO title\n/);
+ assert.match(output, /coverImageAlt: Cover alt\n/);
+ assert.match(output, /noindex: true\n/);
+ assert.match(output, /readingTime: 1\n/);
assert.match(output, /---\n\n## Intro\n\nParagraph one\.$/);
});
diff --git a/packages/adapter-astro-mdx/src/toAstroMdx.ts b/packages/adapter-astro-mdx/src/toAstroMdx.ts
index 7e57c01..3d4aa12 100644
--- a/packages/adapter-astro-mdx/src/toAstroMdx.ts
+++ b/packages/adapter-astro-mdx/src/toAstroMdx.ts
@@ -1,4 +1,4 @@
-import type { Article } from "@sourcedraft/core";
+import { appendSeoFrontmatterLines, type Article } from "@sourcedraft/core";
const YAML_NEEDS_QUOTES =
/^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu;
@@ -44,6 +44,7 @@ export function toAstroMdx(article: Article): string {
frontmatter.push(`heroImage: ${yamlScalar(article.heroImage)}`);
}
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
diff --git a/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts
index 70b6a6f..f378a85 100644
--- a/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts
+++ b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { resolveDocusaurusMdxOptions } from "./options.js";
import {
formatYamlAuthors,
@@ -45,10 +50,9 @@ export function toDocusaurusMdx(
frontmatter.push(...formatYamlTags(article.tags));
pushOptional(frontmatter, "image", article.heroImage);
- pushOptional(frontmatter, "metaTitle", article.metaTitle);
- pushOptional(frontmatter, "metaDescription", article.metaDescription);
- pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar, {
+ skipFields: ["author"],
+ });
if (resolved.hideTableOfContents) {
frontmatter.push("hide_table_of_contents: true");
@@ -75,20 +79,19 @@ export function docusaurusMdxFromFrontmatter(
? frontmatter.author
: firstStringFromArray(frontmatter.authors);
- return {
- title: frontmatter.title,
- slug,
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- heroImage: frontmatter.image ?? frontmatter.heroImage,
- body,
- author,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug,
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ heroImage: frontmatter.image ?? frontmatter.heroImage,
+ body,
+ author,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts
index 26d887f..7a1b056 100644
--- a/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts
+++ b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { resolveEleventyJekyllOptions } from "./options.js";
import { formatYamlTags, yamlScalar } from "./yaml.js";
@@ -41,10 +46,7 @@ export function toEleventyJekyllMarkdown(
`draft: ${article.draft}`,
];
- pushOptional(frontmatter, "metaTitle", article.metaTitle);
- pushOptional(frontmatter, "metaDescription", article.metaDescription);
- pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
@@ -62,18 +64,17 @@ export function eleventyJekyllMarkdownFromFrontmatter(
? frontmatter.slug.trim()
: slugFromPath(filename);
- return {
- title: frontmatter.title,
- slug,
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- body,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug,
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ body,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts b/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts
index 84e6cc8..b191823 100644
--- a/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts
+++ b/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { resolveHugoOptions } from "./options.js";
import { formatTomlArray, tomlString } from "./toml.js";
import {
@@ -36,10 +41,7 @@ function renderYamlFrontmatter(article: Article): string[] {
frontmatter.push(...formatYamlImages(article.heroImage));
}
- pushYamlOptional(frontmatter, "metaTitle", article.metaTitle);
- pushYamlOptional(frontmatter, "metaDescription", article.metaDescription);
- pushYamlOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushYamlOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return frontmatter;
@@ -64,20 +66,25 @@ function renderTomlFrontmatter(article: Article): string[] {
lines.push(formatTomlArray("images", [article.heroImage]));
}
- if (article.metaTitle !== undefined) {
- lines.push(`metaTitle = ${tomlString(article.metaTitle)}`);
- }
-
- if (article.metaDescription !== undefined) {
- lines.push(`metaDescription = ${tomlString(article.metaDescription)}`);
+ for (const [field, key] of [
+ [article.author, "author"],
+ [article.metaTitle, "metaTitle"],
+ [article.metaDescription, "metaDescription"],
+ [article.canonicalUrl, "canonicalUrl"],
+ [article.socialImage, "socialImage"],
+ [article.coverImageAlt, "coverImageAlt"],
+ ] as const) {
+ if (field !== undefined) {
+ lines.push(`${key} = ${tomlString(field)}`);
+ }
}
- if (article.canonicalUrl !== undefined) {
- lines.push(`canonicalUrl = ${tomlString(article.canonicalUrl)}`);
+ if (article.noindex === true) {
+ lines.push("noindex = true");
}
- if (article.socialImage !== undefined) {
- lines.push(`socialImage = ${tomlString(article.socialImage)}`);
+ if (article.readingTime !== undefined && article.readingTime > 0) {
+ lines.push(`readingTime = ${article.readingTime}`);
}
lines.push("+++");
@@ -129,20 +136,19 @@ export function hugoMarkdownFromFrontmatter(
? frontmatter.heroImage
: firstStringFromArray(frontmatter.images);
- return {
- title: frontmatter.title,
- slug,
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- updatedDate: frontmatter.lastmod ?? frontmatter.updatedDate,
- category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- heroImage,
- body,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug,
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ updatedDate: frontmatter.lastmod ?? frontmatter.updatedDate,
+ category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ heroImage,
+ body,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapter-markdown/src/toMarkdown.ts b/packages/adapter-markdown/src/toMarkdown.ts
index 7685eba..fa72065 100644
--- a/packages/adapter-markdown/src/toMarkdown.ts
+++ b/packages/adapter-markdown/src/toMarkdown.ts
@@ -1,4 +1,4 @@
-import type { Article } from "@sourcedraft/core";
+import { appendSeoFrontmatterLines, type Article } from "@sourcedraft/core";
const YAML_NEEDS_QUOTES =
/^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu;
@@ -44,6 +44,7 @@ export function toMarkdown(article: Article): string {
frontmatter.push(`heroImage: ${yamlScalar(article.heroImage)}`);
}
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
diff --git a/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts
index 89a9d96..81d7d4f 100644
--- a/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts
+++ b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { formatYamlTags, yamlScalar } from "./yaml.js";
function pushOptional(
@@ -20,10 +25,7 @@ export function toMkdocsMarkdown(article: Article): string {
...formatYamlTags(article.tags),
];
- pushOptional(frontmatter, "metaTitle", article.metaTitle);
- pushOptional(frontmatter, "metaDescription", article.metaDescription);
- pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
@@ -35,18 +37,17 @@ export function mkdocsMarkdownFromFrontmatter(
body: string,
slugFromPath: (path: string) => string,
): ArticleInput {
- return {
- title: frontmatter.title,
- slug: slugFromPath(path),
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft ?? false,
- body,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug: slugFromPath(path),
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft ?? false,
+ body,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts b/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts
index 0dd9db1..e0b929d 100644
--- a/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts
+++ b/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { formatYamlTags, yamlScalar } from "./yaml.js";
function pushOptional(
@@ -24,12 +29,8 @@ export function toNextjsMdx(article: Article): string {
frontmatter.push(`slug: ${yamlScalar(article.slug)}`);
frontmatter.push(`category: ${yamlScalar(article.category)}`);
frontmatter.push(...formatYamlTags(article.tags));
- pushOptional(frontmatter, "author", article.author);
pushOptional(frontmatter, "coverImage", article.heroImage);
- pushOptional(frontmatter, "metaTitle", article.metaTitle);
- pushOptional(frontmatter, "metaDescription", article.metaDescription);
- pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
@@ -46,23 +47,19 @@ export function nextjsMdxFromFrontmatter(
? frontmatter.slug.trim()
: slugFromPath(path);
- const input: ArticleInput = {
- title: frontmatter.title,
- slug,
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- updatedDate: frontmatter.updatedDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- heroImage: frontmatter.coverImage ?? frontmatter.heroImage,
- body,
- author: frontmatter.author,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
-
- return input;
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug,
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ updatedDate: frontmatter.updatedDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ heroImage: frontmatter.coverImage ?? frontmatter.heroImage,
+ body,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts
index 9d0ebf7..9fd073e 100644
--- a/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts
+++ b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts
@@ -1,4 +1,9 @@
-import type { Article, ArticleInput } from "@sourcedraft/core";
+import {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ type Article,
+ type ArticleInput,
+} from "@sourcedraft/core";
import { resolveNuxtContentMarkdownOptions } from "./options.js";
import { formatYamlTags, yamlScalar } from "./yaml.js";
@@ -43,10 +48,7 @@ export function toNuxtContentMarkdown(
...formatYamlTags(article.tags),
];
- pushOptional(frontmatter, "metaTitle", article.metaTitle);
- pushOptional(frontmatter, "metaDescription", article.metaDescription);
- pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl);
- pushOptional(frontmatter, "socialImage", article.socialImage);
+ appendSeoFrontmatterLines(frontmatter, article, yamlScalar);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${article.body}`;
@@ -58,18 +60,17 @@ export function nuxtContentMarkdownFromFrontmatter(
body: string,
slugFromPath: (path: string) => string,
): ArticleInput {
- return {
- title: frontmatter.title,
- slug: slugFromPath(path),
- description: frontmatter.description,
- pubDate: frontmatter.date ?? frontmatter.pubDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- body,
- metaTitle: frontmatter.metaTitle,
- metaDescription: frontmatter.metaDescription,
- canonicalUrl: frontmatter.canonicalUrl,
- socialImage: frontmatter.socialImage,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug: slugFromPath(path),
+ description: frontmatter.description,
+ pubDate: frontmatter.date ?? frontmatter.pubDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ body,
+ },
+ frontmatter,
+ );
}
diff --git a/packages/adapters/src/registerBuiltInAdapters.ts b/packages/adapters/src/registerBuiltInAdapters.ts
index c8bd38f..356d32f 100644
--- a/packages/adapters/src/registerBuiltInAdapters.ts
+++ b/packages/adapters/src/registerBuiltInAdapters.ts
@@ -31,6 +31,7 @@ import {
nuxtContentMarkdownFromFrontmatter,
toNuxtContentMarkdown,
} from "@sourcedraft/adapter-nuxt-content-markdown";
+import { mergeArticleInputWithSeo } from "@sourcedraft/core";
import { registerAdapter } from "./adapterRegistry.js";
function astroFromFrontmatter(
@@ -44,18 +45,21 @@ function astroFromFrontmatter(
? frontmatter.slug.trim()
: slugFromPath(path);
- return {
- title: frontmatter.title,
- slug,
- description: frontmatter.description,
- pubDate: frontmatter.pubDate,
- updatedDate: frontmatter.updatedDate,
- category: frontmatter.category,
- tags: frontmatter.tags,
- draft: frontmatter.draft,
- heroImage: frontmatter.heroImage,
- body,
- };
+ return mergeArticleInputWithSeo(
+ {
+ title: frontmatter.title,
+ slug,
+ description: frontmatter.description,
+ pubDate: frontmatter.pubDate,
+ updatedDate: frontmatter.updatedDate,
+ category: frontmatter.category,
+ tags: frontmatter.tags,
+ draft: frontmatter.draft,
+ heroImage: frontmatter.heroImage,
+ body,
+ },
+ frontmatter,
+ );
}
export function registerBuiltInAdapters(): void {
diff --git a/packages/core/src/article.ts b/packages/core/src/article.ts
index 7b0e154..40f1087 100644
--- a/packages/core/src/article.ts
+++ b/packages/core/src/article.ts
@@ -14,6 +14,9 @@ export type ArticleInput = {
metaDescription?: unknown;
canonicalUrl?: unknown;
socialImage?: unknown;
+ coverImageAlt?: unknown;
+ noindex?: unknown;
+ readingTime?: unknown;
};
export type Article = {
@@ -32,6 +35,9 @@ export type Article = {
metaDescription?: string;
canonicalUrl?: string;
socialImage?: string;
+ coverImageAlt?: string;
+ noindex?: boolean;
+ readingTime?: number;
};
export type ValidationIssue = {
diff --git a/packages/core/src/frontmatterSeo.ts b/packages/core/src/frontmatterSeo.ts
new file mode 100644
index 0000000..b382156
--- /dev/null
+++ b/packages/core/src/frontmatterSeo.ts
@@ -0,0 +1,120 @@
+import type { Article, ArticleInput } from "./article.js";
+import { computeReadingTimeMinutes } from "./seo.js";
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function parseBooleanField(value: unknown): boolean | undefined {
+ if (typeof value === "boolean") {
+ return value;
+ }
+
+ if (value === "true" || value === "yes" || value === 1 || value === "1") {
+ return true;
+ }
+
+ if (value === "false" || value === "no" || value === 0 || value === "0") {
+ return false;
+ }
+
+ return undefined;
+}
+
+export function parseSeoFromFrontmatter(
+ frontmatter: Record,
+): ArticleInput {
+ const input: ArticleInput = {};
+
+ if (isNonEmptyString(frontmatter.author)) {
+ input.author = frontmatter.author.trim();
+ }
+
+ if (isNonEmptyString(frontmatter.metaTitle)) {
+ input.metaTitle = frontmatter.metaTitle.trim();
+ }
+
+ if (isNonEmptyString(frontmatter.metaDescription)) {
+ input.metaDescription = frontmatter.metaDescription.trim();
+ }
+
+ if (isNonEmptyString(frontmatter.canonicalUrl)) {
+ input.canonicalUrl = frontmatter.canonicalUrl.trim();
+ }
+
+ if (isNonEmptyString(frontmatter.socialImage)) {
+ input.socialImage = frontmatter.socialImage.trim();
+ }
+
+ const coverAlt =
+ typeof frontmatter.coverImageAlt === "string"
+ ? frontmatter.coverImageAlt
+ : typeof frontmatter.heroImageAlt === "string"
+ ? frontmatter.heroImageAlt
+ : undefined;
+
+ if (isNonEmptyString(coverAlt)) {
+ input.coverImageAlt = coverAlt.trim();
+ }
+
+ const noindex = parseBooleanField(frontmatter.noindex);
+ if (noindex !== undefined) {
+ input.noindex = noindex;
+ }
+
+ if (typeof frontmatter.readingTime === "number" && frontmatter.readingTime > 0) {
+ input.readingTime = frontmatter.readingTime;
+ }
+
+ return input;
+}
+
+export function mergeArticleInputWithSeo(
+ base: ArticleInput,
+ frontmatter: Record,
+): ArticleInput {
+ return {
+ ...base,
+ ...parseSeoFromFrontmatter(frontmatter),
+ };
+}
+
+export type AppendSeoFrontmatterOptions = {
+ skipFields?: Array;
+};
+
+export function appendSeoFrontmatterLines(
+ lines: string[],
+ article: Article,
+ yamlScalar: (value: string) => string,
+ options?: AppendSeoFrontmatterOptions,
+): void {
+ const skip = new Set(options?.skipFields ?? []);
+ const optionalStringFields: Array<[keyof Article, string]> = [
+ ["author", "author"],
+ ["metaTitle", "metaTitle"],
+ ["metaDescription", "metaDescription"],
+ ["canonicalUrl", "canonicalUrl"],
+ ["socialImage", "socialImage"],
+ ["coverImageAlt", "coverImageAlt"],
+ ];
+
+ for (const [field, key] of optionalStringFields) {
+ if (skip.has(field)) {
+ continue;
+ }
+ const value = article[field];
+ if (typeof value === "string" && value.trim().length > 0) {
+ lines.push(`${key}: ${yamlScalar(value.trim())}`);
+ }
+ }
+
+ if (article.noindex === true) {
+ lines.push("noindex: true");
+ }
+
+ const readingTime = computeReadingTimeMinutes(article.body);
+ if (readingTime > 0) {
+ lines.push(`readingTime: ${readingTime}`);
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 468cf3e..642bd72 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -6,4 +6,21 @@ export type {
} from "./article.js";
export { createSlug } from "./slug.js";
+export {
+ appendSeoFrontmatterLines,
+ mergeArticleInputWithSeo,
+ parseSeoFromFrontmatter,
+ type AppendSeoFrontmatterOptions,
+} from "./frontmatterSeo.js";
+export {
+ META_DESCRIPTION_LENGTH_GUIDANCE,
+ META_TITLE_LENGTH_GUIDANCE,
+ buildSeoWarnings,
+ computeReadingTimeMinutes,
+ isValidCanonicalUrl,
+ resolveMetaDescription,
+ resolveMetaTitle,
+ resolveSocialImage,
+ type SeoWarning,
+} from "./seo.js";
export { normalizeArticle, validateArticle } from "./validation.js";
diff --git a/packages/core/src/seo.test.ts b/packages/core/src/seo.test.ts
new file mode 100644
index 0000000..7349900
--- /dev/null
+++ b/packages/core/src/seo.test.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ buildSeoWarnings,
+ computeReadingTimeMinutes,
+ isValidCanonicalUrl,
+ resolveMetaDescription,
+ resolveMetaTitle,
+ resolveSocialImage,
+} from "./seo.js";
+
+describe("seo helpers", () => {
+ it("resolves meta title and description fallbacks", () => {
+ assert.equal(
+ resolveMetaTitle({ title: "Post title", metaTitle: "SEO title" }),
+ "SEO title",
+ );
+ assert.equal(resolveMetaTitle({ title: "Post title" }), "Post title");
+ assert.equal(
+ resolveMetaDescription({
+ description: "Summary",
+ metaDescription: "SEO summary",
+ }),
+ "SEO summary",
+ );
+ assert.equal(resolveMetaDescription({ description: "Summary" }), "Summary");
+ });
+
+ it("resolves social image from cover fallback", () => {
+ assert.equal(
+ resolveSocialImage({
+ heroImage: "/images/cover.png",
+ socialImage: "/images/social.png",
+ }),
+ "/images/social.png",
+ );
+ assert.equal(
+ resolveSocialImage({ heroImage: "/images/cover.png" }),
+ "/images/cover.png",
+ );
+ });
+
+ it("validates canonical URLs", () => {
+ assert.equal(isValidCanonicalUrl("https://example.com/post"), true);
+ assert.equal(isValidCanonicalUrl("not-a-url"), false);
+ assert.equal(isValidCanonicalUrl("ftp://example.com"), false);
+ });
+
+ it("computes reading time from body", () => {
+ const body = "word ".repeat(400).trim();
+ assert.equal(computeReadingTimeMinutes(body), 2);
+ assert.equal(computeReadingTimeMinutes(""), 0);
+ });
+
+ it("builds soft SEO warnings without blocking", () => {
+ const warnings = buildSeoWarnings({
+ title: "Title",
+ slug: "title",
+ description: "Desc",
+ pubDate: "2024-01-01",
+ category: "Guides",
+ tags: ["a"],
+ draft: false,
+ body: "Body",
+ metaTitle: "x".repeat(70),
+ heroImage: "/cover.png",
+ });
+
+ assert.ok(warnings.some((warning) => warning.id === "meta-title-long"));
+ assert.ok(warnings.some((warning) => warning.id === "cover-alt-missing"));
+ });
+});
diff --git a/packages/core/src/seo.ts b/packages/core/src/seo.ts
new file mode 100644
index 0000000..464c66e
--- /dev/null
+++ b/packages/core/src/seo.ts
@@ -0,0 +1,102 @@
+import type { Article, ArticleInput } from "./article.js";
+
+export const META_TITLE_LENGTH_GUIDANCE = 60;
+export const META_DESCRIPTION_LENGTH_GUIDANCE = 160;
+export const WORDS_PER_MINUTE = 200;
+
+export function computeReadingTimeMinutes(body: string): number {
+ const trimmed = body.trim();
+ if (trimmed.length === 0) {
+ return 0;
+ }
+
+ const wordCount = trimmed.split(/\s+/u).length;
+ return Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
+}
+
+export function resolveMetaTitle(
+ article: Pick,
+): string {
+ const meta = article.metaTitle?.trim();
+ if (meta && meta.length > 0) {
+ return meta;
+ }
+
+ return article.title.trim();
+}
+
+export function resolveMetaDescription(
+ article: Pick,
+): string {
+ const meta = article.metaDescription?.trim();
+ if (meta && meta.length > 0) {
+ return meta;
+ }
+
+ return article.description.trim();
+}
+
+export function resolveSocialImage(
+ article: Pick,
+): string | undefined {
+ const social = article.socialImage?.trim();
+ if (social && social.length > 0) {
+ return social;
+ }
+
+ const cover = article.heroImage?.trim();
+ return cover && cover.length > 0 ? cover : undefined;
+}
+
+export function isValidCanonicalUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export type SeoWarning = {
+ id: string;
+ field: string;
+ message: string;
+};
+
+export function buildSeoWarnings(input: ArticleInput): SeoWarning[] {
+ const warnings: SeoWarning[] = [];
+
+ const metaTitle =
+ typeof input.metaTitle === "string" ? input.metaTitle.trim() : "";
+ if (metaTitle.length > META_TITLE_LENGTH_GUIDANCE) {
+ warnings.push({
+ id: "meta-title-long",
+ field: "metaTitle",
+ message: `Meta title is ${metaTitle.length} characters. Many search results show about ${META_TITLE_LENGTH_GUIDANCE} characters.`,
+ });
+ }
+
+ const metaDescription =
+ typeof input.metaDescription === "string" ? input.metaDescription.trim() : "";
+ if (metaDescription.length > META_DESCRIPTION_LENGTH_GUIDANCE) {
+ warnings.push({
+ id: "meta-description-long",
+ field: "metaDescription",
+ message: `Meta description is ${metaDescription.length} characters. Snippets are often shorter than ${META_DESCRIPTION_LENGTH_GUIDANCE} characters.`,
+ });
+ }
+
+ const heroImage =
+ typeof input.heroImage === "string" ? input.heroImage.trim() : "";
+ const coverImageAlt =
+ typeof input.coverImageAlt === "string" ? input.coverImageAlt.trim() : "";
+ if (heroImage.length > 0 && coverImageAlt.length === 0) {
+ warnings.push({
+ id: "cover-alt-missing",
+ field: "coverImageAlt",
+ message: "Cover image is set but alt text is empty.",
+ });
+ }
+
+ return warnings;
+}
diff --git a/packages/core/src/validation.test.ts b/packages/core/src/validation.test.ts
index f28e882..006fe7a 100644
--- a/packages/core/src/validation.test.ts
+++ b/packages/core/src/validation.test.ts
@@ -48,6 +48,26 @@ describe("validateArticle", () => {
);
});
+ it("rejects invalid canonical URL", () => {
+ const result = validateArticle({
+ ...validInput,
+ canonicalUrl: "not-a-valid-url",
+ });
+ assert.equal(result.valid, false);
+ assert.ok(result.issues.some((issue) => issue.field === "canonicalUrl"));
+ });
+
+ it("accepts optional SEO fields", () => {
+ const result = validateArticle({
+ ...validInput,
+ metaTitle: "SEO title",
+ canonicalUrl: "https://example.com/post",
+ coverImageAlt: "Cover description",
+ noindex: true,
+ });
+ assert.equal(result.valid, true);
+ });
+
it("rejects empty tag entries", () => {
const result = validateArticle({ ...validInput, tags: ["ok", " "] });
assert.equal(result.valid, false);
diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts
index 39c4b49..5b4dd5b 100644
--- a/packages/core/src/validation.ts
+++ b/packages/core/src/validation.ts
@@ -4,6 +4,7 @@ import type {
ValidationIssue,
ValidationResult,
} from "./article.js";
+import { computeReadingTimeMinutes, isValidCanonicalUrl } from "./seo.js";
import { isValidSlug } from "./slug.js";
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -143,8 +144,8 @@ export function validateArticle(input: ArticleInput): ValidationResult {
["author", "Author"],
["metaTitle", "Meta title"],
["metaDescription", "Meta description"],
- ["canonicalUrl", "Canonical URL"],
["socialImage", "Social image"],
+ ["coverImageAlt", "Cover image alt text"],
] as const) {
const value = input[field];
if (value !== undefined && value !== null && !isNonEmptyString(value)) {
@@ -152,6 +153,43 @@ export function validateArticle(input: ArticleInput): ValidationResult {
}
}
+ if (
+ input.canonicalUrl !== undefined &&
+ input.canonicalUrl !== null &&
+ isNonEmptyString(input.canonicalUrl) &&
+ !isValidCanonicalUrl(input.canonicalUrl.trim())
+ ) {
+ issues.push(
+ issue("canonicalUrl", "Canonical URL must be a valid http(s) URL."),
+ );
+ }
+
+ if (
+ input.canonicalUrl !== undefined &&
+ input.canonicalUrl !== null &&
+ !isNonEmptyString(input.canonicalUrl)
+ ) {
+ issues.push(issue("canonicalUrl", "Canonical URL must be a non-empty string."));
+ }
+
+ if (
+ input.noindex !== undefined &&
+ input.noindex !== null &&
+ typeof input.noindex !== "boolean"
+ ) {
+ issues.push(issue("noindex", "Noindex must be a boolean."));
+ }
+
+ if (
+ input.readingTime !== undefined &&
+ input.readingTime !== null &&
+ (typeof input.readingTime !== "number" ||
+ !Number.isFinite(input.readingTime) ||
+ input.readingTime < 0)
+ ) {
+ issues.push(issue("readingTime", "Reading time must be a non-negative number."));
+ }
+
if (!isNonEmptyString(input.body)) {
issues.push(issue("body", "Body is required."));
}
@@ -202,6 +240,7 @@ export function normalizeArticle(input: ArticleInput): Article {
"metaDescription",
"canonicalUrl",
"socialImage",
+ "coverImageAlt",
] as const) {
const value = input[field];
if (isNonEmptyString(value)) {
@@ -209,5 +248,14 @@ export function normalizeArticle(input: ArticleInput): Article {
}
}
+ if (input.noindex === true) {
+ article.noindex = true;
+ }
+
+ const computedReadingTime = computeReadingTimeMinutes(article.body);
+ if (computedReadingTime > 0) {
+ article.readingTime = computedReadingTime;
+ }
+
return article;
}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index c68a6a8..7b73e7b 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
- "lib": ["ES2022"],
+ "lib": ["ES2022", "DOM"],
"rootDir": "src",
"outDir": "dist",
"declaration": true,
diff --git a/packages/publishers/package.json b/packages/publishers/package.json
index ae2ad3c..7a6eb1c 100644
--- a/packages/publishers/package.json
+++ b/packages/publishers/package.json
@@ -19,6 +19,7 @@
"test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/**/*.test.ts'"
},
"dependencies": {
+ "@sourcedraft/core": "workspace:*",
"@sourcedraft/github-publisher": "workspace:*"
},
"devDependencies": {
diff --git a/packages/publishers/src/ghost/ghostPublisher.test.ts b/packages/publishers/src/ghost/ghostPublisher.test.ts
index e7c12b8..7ebef52 100644
--- a/packages/publishers/src/ghost/ghostPublisher.test.ts
+++ b/packages/publishers/src/ghost/ghostPublisher.test.ts
@@ -71,6 +71,37 @@ describe("Ghost publisher", () => {
}
});
+ it("maps cover alt text and meta fallbacks", async () => {
+ const publisher = createGhostPublisher({
+ ...config,
+ fetch: async (_url, init) => {
+ const body = JSON.parse(init?.body?.toString() ?? "{}");
+ const post = body.posts[0];
+ assert.equal(post.meta_title, article.title);
+ assert.equal(post.meta_description, article.description);
+ assert.equal(post.feature_image_alt, "Hero alt text");
+
+ return new Response(
+ JSON.stringify({
+ posts: [{ id: "ghost-uuid-2", slug: "hello-ghost", status: "draft" }],
+ }),
+ { status: 201 },
+ );
+ },
+ });
+
+ const result = await publisher.publishPost({
+ article: {
+ ...article,
+ metaTitle: undefined,
+ metaDescription: undefined,
+ coverImageAlt: "Hero alt text",
+ },
+ });
+
+ assert.equal(result.ok, true);
+ });
+
it("updates an existing post when remoteId is provided", async () => {
const publisher = createGhostPublisher({
...config,
diff --git a/packages/publishers/src/ghost/ghostPublisher.ts b/packages/publishers/src/ghost/ghostPublisher.ts
index 0c5436d..936b839 100644
--- a/packages/publishers/src/ghost/ghostPublisher.ts
+++ b/packages/publishers/src/ghost/ghostPublisher.ts
@@ -1,3 +1,8 @@
+import {
+ resolveMetaDescription,
+ resolveMetaTitle,
+ resolveSocialImage,
+} from "@sourcedraft/core";
import { resolveCmsStatus, resolveFeatureImageUrl } from "../cmsPayload.js";
import {
type HttpFetcher,
@@ -99,16 +104,38 @@ function buildGhostPostPayload(
post.feature_image = featureImage;
}
- if (article.metaTitle) {
- post.meta_title = article.metaTitle;
+ const metaTitle = resolveMetaTitle({
+ title: article.title,
+ ...(article.metaTitle !== undefined ? { metaTitle: article.metaTitle } : {}),
+ });
+ const metaDescription = resolveMetaDescription({
+ description: article.description,
+ ...(article.metaDescription !== undefined
+ ? { metaDescription: article.metaDescription }
+ : {}),
+ });
+
+ post.meta_title = metaTitle;
+ post.meta_description = metaDescription;
+
+ if (article.canonicalUrl) {
+ post.canonical_url = article.canonicalUrl;
}
- if (article.metaDescription) {
- post.meta_description = article.metaDescription;
+ if (article.coverImageAlt) {
+ post.feature_image_alt = article.coverImageAlt;
}
- if (article.canonicalUrl) {
- post.canonical_url = article.canonicalUrl;
+ const socialImage = resolveSocialImage({
+ ...(article.heroImage !== undefined ? { heroImage: article.heroImage } : {}),
+ ...(article.socialImage !== undefined ? { socialImage: article.socialImage } : {}),
+ });
+ if (
+ socialImage &&
+ /^https?:\/\//iu.test(socialImage) &&
+ socialImage !== featureImage
+ ) {
+ post.og_image = socialImage;
}
if (article.updatedDate) {
diff --git a/packages/publishers/src/types.ts b/packages/publishers/src/types.ts
index d2796fb..61a570f 100644
--- a/packages/publishers/src/types.ts
+++ b/packages/publishers/src/types.ts
@@ -62,6 +62,8 @@ export type CmsArticlePayload = {
metaDescription?: string;
canonicalUrl?: string;
socialImage?: string;
+ coverImageAlt?: string;
+ noindex?: boolean;
};
export type PublishArticleInput = {
diff --git a/packages/publishers/src/wordpress/wordpressPublisher.test.ts b/packages/publishers/src/wordpress/wordpressPublisher.test.ts
index cc0acb5..c000fd1 100644
--- a/packages/publishers/src/wordpress/wordpressPublisher.test.ts
+++ b/packages/publishers/src/wordpress/wordpressPublisher.test.ts
@@ -48,6 +48,7 @@ describe("WordPress publisher", () => {
assert.equal(body.excerpt, article.description);
assert.deepEqual(body.categories, [3]);
assert.deepEqual(body.tags, [10, 11]);
+ assert.equal(body.meta, undefined);
return new Response(
JSON.stringify({ id: 42, slug: "hello-wordpress", status: "draft" }),
@@ -117,6 +118,34 @@ describe("WordPress publisher", () => {
}
});
+ it("omits meta unless wordpressSeoMeta keys are configured", async () => {
+ const publisher = createWordPressPublisher({
+ ...config,
+ seoMetaKeys: {
+ _yoast_wpseo_title: "metaTitle",
+ _yoast_wpseo_metadesc: "metaDescription",
+ },
+ fetch: async (_url, init) => {
+ const body = JSON.parse(init?.body?.toString() ?? "{}");
+ assert.deepEqual(body.meta, {
+ _yoast_wpseo_title: "Custom SEO title",
+ _yoast_wpseo_metadesc: "Short excerpt",
+ });
+ return new Response(JSON.stringify({ id: 99, slug: "seo-post" }), { status: 201 });
+ },
+ });
+
+ const result = await publisher.publishPost({
+ article: {
+ ...article,
+ slug: "seo-post",
+ metaTitle: "Custom SEO title",
+ },
+ });
+
+ assert.equal(result.ok, true);
+ });
+
it("returns actionable error on invalid endpoint", async () => {
const publisher = createWordPressPublisher({
...config,
diff --git a/packages/publishers/src/wordpress/wordpressPublisher.ts b/packages/publishers/src/wordpress/wordpressPublisher.ts
index e222c6d..ca64f9a 100644
--- a/packages/publishers/src/wordpress/wordpressPublisher.ts
+++ b/packages/publishers/src/wordpress/wordpressPublisher.ts
@@ -1,3 +1,4 @@
+import { resolveMetaDescription, resolveMetaTitle } from "@sourcedraft/core";
import { resolveCmsStatus } from "../cmsPayload.js";
import {
type HttpFetcher,
@@ -16,6 +17,8 @@ export type WordPressPublisherConfig = {
defaultAuthor?: number;
categoryIds?: Record;
tagIds?: Record;
+ /** WordPress post meta keys mapped to article field names (e.g. Yoast/Rank Math). */
+ seoMetaKeys?: Record;
fetch?: HttpFetcher;
};
@@ -163,6 +166,35 @@ export function createWordPressPublisher(config: WordPressPublisherConfig): Word
payload.author = config.defaultAuthor;
}
+ if (config.seoMetaKeys && Object.keys(config.seoMetaKeys).length > 0) {
+ const meta: Record = {};
+ const resolved = {
+ metaTitle: resolveMetaTitle({
+ title: article.title,
+ ...(article.metaTitle !== undefined ? { metaTitle: article.metaTitle } : {}),
+ }),
+ metaDescription: resolveMetaDescription({
+ description: article.description,
+ ...(article.metaDescription !== undefined
+ ? { metaDescription: article.metaDescription }
+ : {}),
+ }),
+ canonicalUrl: article.canonicalUrl ?? "",
+ };
+
+ for (const [metaKey, articleField] of Object.entries(config.seoMetaKeys)) {
+ const raw = resolved[articleField as keyof typeof resolved] ??
+ (typeof article[articleField] === "string" ? article[articleField] : "");
+ if (typeof raw === "string" && raw.trim().length > 0) {
+ meta[metaKey] = raw.trim();
+ }
+ }
+
+ if (Object.keys(meta).length > 0) {
+ payload.meta = meta;
+ }
+ }
+
return payload;
}
diff --git a/packages/publishers/src/wordpressPublisherAdapter.ts b/packages/publishers/src/wordpressPublisherAdapter.ts
index 0f7548d..55777bc 100644
--- a/packages/publishers/src/wordpressPublisherAdapter.ts
+++ b/packages/publishers/src/wordpressPublisherAdapter.ts
@@ -1,5 +1,6 @@
import { requireCmsArticle } from "./cmsPayload.js";
import type {
+ CmsArticlePayload,
Publisher,
PublisherFactory,
PublisherRuntimeConfig,
@@ -60,6 +61,7 @@ function resolveWordPressConfig(config: PublisherRuntimeConfig) {
const options = config.publisherOptions ?? {};
const categoryIds = readTaxonomyMap(options, "wordpressCategoryIds");
const tagIds = readTaxonomyMap(options, "wordpressTagIds");
+ const seoMetaKeys = readSeoMetaKeys(options.wordpressSeoMeta);
return createWordPressPublisher({
apiUrl,
@@ -71,9 +73,38 @@ function resolveWordPressConfig(config: PublisherRuntimeConfig) {
: {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
...(tagIds !== undefined ? { tagIds } : {}),
+ ...(seoMetaKeys !== undefined ? { seoMetaKeys } : {}),
});
}
+function readSeoMetaKeys(
+ raw: unknown,
+): Record | undefined {
+ if (!raw || typeof raw !== "object") {
+ return undefined;
+ }
+
+ const allowed = new Set([
+ "metaTitle",
+ "metaDescription",
+ "canonicalUrl",
+ "socialImage",
+ "coverImageAlt",
+ "title",
+ "description",
+ "slug",
+ ]);
+
+ const map: Record = {};
+ for (const [metaKey, field] of Object.entries(raw)) {
+ if (typeof field === "string" && allowed.has(field)) {
+ map[metaKey] = field as keyof CmsArticlePayload;
+ }
+ }
+
+ return Object.keys(map).length > 0 ? map : undefined;
+}
+
function createWordPressPublisherInstance(config: PublisherRuntimeConfig): Publisher {
const wordpress = resolveWordPressConfig(config);
diff --git a/packages/setup/package.json b/packages/setup/package.json
new file mode 100644
index 0000000..b763e18
--- /dev/null
+++ b/packages/setup/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@sourcedraft/setup",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Setup wizard and configuration validation for SourceDraft.",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "check": "tsc --noEmit",
+ "test": "node --import tsx --test 'src/**/*.test.ts'"
+ },
+ "dependencies": {
+ "@sourcedraft/adapters": "workspace:*",
+ "@sourcedraft/config": "workspace:*",
+ "@sourcedraft/media-providers": "workspace:*",
+ "@sourcedraft/publishers": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.30",
+ "tsx": "^4.20.3",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/packages/setup/src/cli.ts b/packages/setup/src/cli.ts
new file mode 100644
index 0000000..5dd3e11
--- /dev/null
+++ b/packages/setup/src/cli.ts
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+import { runWizard } from "./wizard.js";
+import { validateConfigAsync } from "./validateConfig.js";
+
+const command = process.argv[2];
+
+async function runValidate(): Promise {
+ const checkConnections = process.argv.includes("--connections");
+ const report = await validateConfigAsync({ checkConnections });
+
+ console.log("SourceDraft configuration validation\n");
+ console.log(`Adapter: ${report.adapter}`);
+ console.log(`Publisher: ${report.publisher}`);
+ console.log(`Media provider: ${report.mediaProvider}`);
+
+ if (report.configPath) {
+ console.log(`Config: ${report.configPath}`);
+ }
+
+ if (report.envPath) {
+ console.log(`Env: ${report.envPath}`);
+ }
+
+ if (report.missingEnvVars.length > 0) {
+ console.log(`\nMissing env vars: ${report.missingEnvVars.join(", ")}`);
+ }
+
+ for (const issue of report.issues) {
+ const prefix = issue.level === "error" ? "ERROR" : "WARN";
+ console.log(`[${prefix}] ${issue.field}: ${issue.message}`);
+ }
+
+ if (report.connection) {
+ const status = report.connection.ok ? "OK" : "FAILED";
+ console.log(`\nConnection: ${status} — ${report.connection.detail}`);
+ }
+
+ console.log(report.ok ? "\nValidation passed." : "\nValidation failed.");
+ process.exit(report.ok ? 0 : 1);
+}
+
+async function main(): Promise {
+ if (command === "validate" || command === "validate:config") {
+ await runValidate();
+ return;
+ }
+
+ if (command === "setup" || command === undefined) {
+ await runWizard();
+ return;
+ }
+
+ console.error(`Unknown command: ${command}`);
+ console.error("Usage: setup [setup|validate] [--connections]");
+ process.exit(1);
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : "Setup failed.");
+ process.exit(1);
+});
diff --git a/packages/setup/src/connectionChecks.ts b/packages/setup/src/connectionChecks.ts
new file mode 100644
index 0000000..ed5d292
--- /dev/null
+++ b/packages/setup/src/connectionChecks.ts
@@ -0,0 +1,227 @@
+export type ConnectionCheckResult = {
+ ok: boolean;
+ detail: string;
+};
+
+function isValidDeployHookUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
+ } catch {
+ return false;
+ }
+}
+
+export async function checkGitHubConnection(
+ env: Record,
+): Promise {
+ const token = env.GITHUB_TOKEN?.trim();
+ const owner = env.GITHUB_OWNER?.trim();
+ const repo = env.GITHUB_REPO?.trim();
+
+ if (!token || !owner || !repo) {
+ return { ok: false, detail: "Missing GitHub credentials for connection check." };
+ }
+
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "User-Agent": "SourceDraft-Setup",
+ },
+ });
+
+ if (response.ok) {
+ return { ok: true, detail: `Repository ${owner}/${repo} is reachable.` };
+ }
+
+ if (response.status === 404) {
+ return { ok: false, detail: "Repository not found or token lacks access." };
+ }
+
+ return { ok: false, detail: `GitHub API returned ${response.status}.` };
+}
+
+export async function checkGitLabConnection(
+ env: Record,
+): Promise {
+ const token = env.GITLAB_TOKEN?.trim();
+ const projectId = env.GITLAB_PROJECT_ID?.trim();
+ const projectPath = env.GITLAB_PROJECT_PATH?.trim();
+ const baseUrl = (env.GITLAB_BASE_URL?.trim() || "https://gitlab.com").replace(/\/$/u, "");
+
+ if (!token) {
+ return { ok: false, detail: "Missing GitLab token for connection check." };
+ }
+
+ const projectRef = projectId
+ ? encodeURIComponent(projectId)
+ : projectPath
+ ? encodeURIComponent(projectPath)
+ : null;
+
+ if (!projectRef) {
+ return { ok: false, detail: "Set GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH." };
+ }
+
+ const response = await fetch(`${baseUrl}/api/v4/projects/${projectRef}`, {
+ headers: {
+ "PRIVATE-TOKEN": token,
+ "User-Agent": "SourceDraft-Setup",
+ },
+ });
+
+ if (response.ok) {
+ return { ok: true, detail: "GitLab project is reachable." };
+ }
+
+ return { ok: false, detail: `GitLab API returned ${response.status}.` };
+}
+
+export async function checkBitbucketConnection(
+ env: Record,
+): Promise {
+ const token = env.BITBUCKET_TOKEN?.trim();
+ const workspace = env.BITBUCKET_WORKSPACE?.trim();
+ const repoSlug = env.BITBUCKET_REPO_SLUG?.trim();
+
+ if (!token || !workspace || !repoSlug) {
+ return { ok: false, detail: "Missing Bitbucket credentials for connection check." };
+ }
+
+ const response = await fetch(
+ `https://api.bitbucket.org/2.0/repositories/${workspace}/${repoSlug}`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json",
+ "User-Agent": "SourceDraft-Setup",
+ },
+ },
+ );
+
+ if (response.ok) {
+ return { ok: true, detail: `Repository ${workspace}/${repoSlug} is reachable.` };
+ }
+
+ return { ok: false, detail: `Bitbucket API returned ${response.status}.` };
+}
+
+export async function checkWordPressConnection(
+ env: Record,
+): Promise {
+ const apiUrl = env.WORDPRESS_API_URL?.trim()?.replace(/\/$/u, "");
+ const username = env.WORDPRESS_USERNAME?.trim();
+ const appPassword = env.WORDPRESS_APP_PASSWORD?.trim();
+
+ if (!apiUrl || !username || !appPassword) {
+ return { ok: false, detail: "Missing WordPress credentials for connection check." };
+ }
+
+ const auth = Buffer.from(`${username}:${appPassword}`).toString("base64");
+ const response = await fetch(`${apiUrl}/wp-json/wp/v2/users/me`, {
+ headers: {
+ Authorization: `Basic ${auth}`,
+ Accept: "application/json",
+ "User-Agent": "SourceDraft-Setup",
+ },
+ });
+
+ if (response.ok) {
+ return { ok: true, detail: "WordPress REST API authenticated successfully." };
+ }
+
+ return { ok: false, detail: `WordPress API returned ${response.status}.` };
+}
+
+export async function checkGhostConnection(
+ env: Record,
+): Promise {
+ const adminUrl = env.GHOST_ADMIN_URL?.trim()?.replace(/\/$/u, "");
+ const apiKey = env.GHOST_ADMIN_API_KEY?.trim();
+
+ if (!adminUrl || !apiKey) {
+ return { ok: false, detail: "Missing Ghost credentials for connection check." };
+ }
+
+ const [id, secret] = apiKey.split(":");
+ if (!id || !secret) {
+ return { ok: false, detail: "GHOST_ADMIN_API_KEY must be id:secret format." };
+ }
+
+ const header = Buffer.from(
+ JSON.stringify({
+ alg: "HS256",
+ typ: "JWT",
+ kid: id,
+ }),
+ ).toString("base64url");
+
+ const now = Math.floor(Date.now() / 1000);
+ const payload = Buffer.from(
+ JSON.stringify({
+ iat: now,
+ exp: now + 5 * 60,
+ aud: "/admin/",
+ }),
+ ).toString("base64url");
+
+ const crypto = await import("node:crypto");
+ const signature = crypto
+ .createHmac("sha256", Buffer.from(secret, "hex"))
+ .update(`${header}.${payload}`)
+ .digest("base64url");
+
+ const token = `${header}.${payload}.${signature}`;
+ const version = env.GHOST_ACCEPT_VERSION?.trim() || "v5.126";
+
+ const response = await fetch(`${adminUrl}/ghost/api/admin/site/`, {
+ headers: {
+ Authorization: `Ghost ${token}`,
+ Accept: "application/json",
+ "Accept-Version": version,
+ "User-Agent": "SourceDraft-Setup",
+ },
+ });
+
+ if (response.ok) {
+ return { ok: true, detail: "Ghost Admin API authenticated successfully." };
+ }
+
+ return { ok: false, detail: `Ghost API returned ${response.status}.` };
+}
+
+export function checkDeployHookUrlShape(
+ env: Record,
+): ConnectionCheckResult {
+ const url = env.DEPLOY_HOOK_URL?.trim();
+ if (!url) {
+ return { ok: false, detail: "DEPLOY_HOOK_URL is not set." };
+ }
+
+ if (!isValidDeployHookUrl(url)) {
+ return { ok: false, detail: "DEPLOY_HOOK_URL must be a valid http(s) URL." };
+ }
+
+ return { ok: true, detail: "Deploy hook URL shape looks valid (not triggered)." };
+}
+
+export async function checkPublisherConnection(
+ publisher: string,
+ env: Record,
+): Promise {
+ switch (publisher) {
+ case "github":
+ return checkGitHubConnection(env);
+ case "gitlab":
+ return checkGitLabConnection(env);
+ case "bitbucket":
+ return checkBitbucketConnection(env);
+ case "wordpress":
+ return checkWordPressConnection(env);
+ case "ghost":
+ return checkGhostConnection(env);
+ default:
+ return null;
+ }
+}
diff --git a/packages/setup/src/envFile.test.ts b/packages/setup/src/envFile.test.ts
new file mode 100644
index 0000000..19565a9
--- /dev/null
+++ b/packages/setup/src/envFile.test.ts
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import {
+ backupEnvFile,
+ loadEnvMap,
+ mergeEnvMaps,
+ parseEnvFile,
+ serializeEnvFile,
+} from "./envFile.js";
+
+test("parseEnvFile ignores comments and parses quoted values", () => {
+ const map = parseEnvFile(`
+# comment
+GITHUB_TOKEN=ghp_secret
+GITHUB_OWNER="acme"
+EMPTY=
+`);
+
+ assert.equal(map.get("GITHUB_TOKEN"), "ghp_secret");
+ assert.equal(map.get("GITHUB_OWNER"), "acme");
+ assert.equal(map.get("EMPTY"), "");
+});
+
+test("backupEnvFile creates timestamped copy", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-"));
+ const envPath = join(dir, ".env");
+ writeFileSync(envPath, "GITHUB_TOKEN=old\n", "utf8");
+
+ const fixedDate = new Date("2026-06-08T12:00:00.000Z");
+ const backupPath = backupEnvFile(envPath, fixedDate);
+
+ assert.ok(backupPath);
+ assert.ok(existsSync(backupPath!));
+ assert.equal(readFileSync(backupPath!, "utf8"), "GITHUB_TOKEN=old\n");
+});
+
+test("mergeEnvMaps respects overwrite decisions", () => {
+ const existing = new Map([
+ ["GITHUB_TOKEN", "keep-me"],
+ ["GITHUB_OWNER", ""],
+ ]);
+ const updates = new Map([
+ ["GITHUB_TOKEN", "new-token"],
+ ["GITHUB_OWNER", "acme"],
+ ]);
+
+ const merged = mergeEnvMaps(existing, updates, (_key, existingValue) =>
+ existingValue.trim().length > 0 ? "skip" : "set",
+ );
+
+ assert.equal(merged.get("GITHUB_TOKEN"), "keep-me");
+ assert.equal(merged.get("GITHUB_OWNER"), "acme");
+});
+
+test("serializeEnvFile round-trips through loadEnvMap", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-"));
+ const envPath = join(dir, ".env");
+ const map = new Map([
+ ["CMS_PUBLISHER", "github"],
+ ["GITHUB_TOKEN", "secret value"],
+ ]);
+
+ writeFileSync(envPath, serializeEnvFile(map), "utf8");
+ const loaded = loadEnvMap(envPath);
+
+ assert.equal(loaded.get("CMS_PUBLISHER"), "github");
+ assert.equal(loaded.get("GITHUB_TOKEN"), "secret value");
+});
diff --git a/packages/setup/src/envFile.ts b/packages/setup/src/envFile.ts
new file mode 100644
index 0000000..34c99c2
--- /dev/null
+++ b/packages/setup/src/envFile.ts
@@ -0,0 +1,105 @@
+import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { formatEnvValueForDisplay } from "./maskSecrets.js";
+
+export type EnvMap = Map;
+
+export function parseEnvFile(content: string): EnvMap {
+ const map: EnvMap = new Map();
+
+ for (const line of content.split(/\r?\n/u)) {
+ const trimmed = line.trim();
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
+ continue;
+ }
+
+ const eq = trimmed.indexOf("=");
+ if (eq <= 0) {
+ continue;
+ }
+
+ const key = trimmed.slice(0, eq).trim();
+ let value = trimmed.slice(eq + 1).trim();
+
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+
+ map.set(key, value);
+ }
+
+ return map;
+}
+
+export function loadEnvMap(envPath: string): EnvMap {
+ if (!existsSync(envPath)) {
+ return new Map();
+ }
+
+ return parseEnvFile(readFileSync(envPath, "utf8"));
+}
+
+export function serializeEnvFile(map: EnvMap, header?: string): string {
+ const lines: string[] = [];
+
+ if (header) {
+ lines.push(header.trimEnd(), "");
+ }
+
+ for (const [key, value] of [...map.entries()].sort(([a], [b]) => a.localeCompare(b))) {
+ const escaped =
+ value.includes(" ") || value.includes("#") ? `"${value.replace(/"/gu, '\\"')}"` : value;
+ lines.push(`${key}=${escaped}`);
+ }
+
+ lines.push("");
+ return lines.join("\n");
+}
+
+export function backupEnvFile(envPath: string, now = new Date()): string | null {
+ if (!existsSync(envPath)) {
+ return null;
+ }
+
+ const stamp = now.toISOString().replace(/[:.]/gu, "-");
+ const backupPath = resolve(`${envPath}.backup.${stamp}`);
+ copyFileSync(envPath, backupPath);
+ return backupPath;
+}
+
+export type EnvMergeDecision = "set" | "skip" | "keep";
+
+export function mergeEnvMaps(
+ existing: EnvMap,
+ updates: EnvMap,
+ shouldOverwrite: (key: string, existingValue: string) => EnvMergeDecision,
+): EnvMap {
+ const merged = new Map(existing);
+
+ for (const [key, value] of updates.entries()) {
+ if (value.trim().length === 0) {
+ continue;
+ }
+
+ const current = merged.get(key);
+ if (current !== undefined && current.trim().length > 0) {
+ const decision = shouldOverwrite(key, current);
+ if (decision !== "set") {
+ continue;
+ }
+ }
+
+ merged.set(key, value);
+ }
+
+ return merged;
+}
+
+export function summarizeEnvUpdates(updates: EnvMap): string[] {
+ return [...updates.entries()]
+ .filter(([, value]) => value.trim().length > 0)
+ .map(([key, value]) => ` ${key}=${formatEnvValueForDisplay(key, value)}`);
+}
diff --git a/packages/setup/src/envRequirements.ts b/packages/setup/src/envRequirements.ts
new file mode 100644
index 0000000..fcecbb7
--- /dev/null
+++ b/packages/setup/src/envRequirements.ts
@@ -0,0 +1,322 @@
+import type { PublisherId } from "@sourcedraft/publishers";
+import type { MediaProviderId } from "@sourcedraft/media-providers";
+
+export type EnvRequirement = {
+ key: string;
+ label: string;
+ help: string;
+ required: boolean;
+};
+
+const GIT_PUBLISHERS = new Set(["github", "gitlab", "bitbucket"]);
+const CMS_PUBLISHERS = new Set(["wordpress", "ghost"]);
+
+export function publisherEnvRequirements(
+ publisher: string,
+): EnvRequirement[] {
+ switch (publisher) {
+ case "github":
+ return [
+ {
+ key: "GITHUB_TOKEN",
+ label: "GitHub personal access token",
+ help: "Create one at GitHub → Settings → Developer settings → Personal access tokens. Needs repo scope for private repos.",
+ required: true,
+ },
+ {
+ key: "GITHUB_OWNER",
+ label: "GitHub username or organization",
+ help: "The account that owns the repository, e.g. acme-corp.",
+ required: true,
+ },
+ {
+ key: "GITHUB_REPO",
+ label: "GitHub repository name",
+ help: "Just the repo name, not the full URL — e.g. my-blog.",
+ required: true,
+ },
+ {
+ key: "GITHUB_BRANCH",
+ label: "Default branch",
+ help: "Usually main or master. Articles are committed to this branch.",
+ required: false,
+ },
+ ];
+ case "gitlab":
+ return [
+ {
+ key: "GITLAB_TOKEN",
+ label: "GitLab personal access token",
+ help: "GitLab → Preferences → Access Tokens with api scope.",
+ required: true,
+ },
+ {
+ key: "GITLAB_PROJECT_ID",
+ label: "GitLab project ID",
+ help: "Numeric project ID from the project home page, or use GITLAB_PROJECT_PATH instead.",
+ required: false,
+ },
+ {
+ key: "GITLAB_PROJECT_PATH",
+ label: "GitLab project path",
+ help: "group/subgroup/project — alternative to project ID.",
+ required: false,
+ },
+ {
+ key: "GITLAB_BRANCH",
+ label: "Default branch",
+ help: "Branch for commits, usually main.",
+ required: false,
+ },
+ {
+ key: "GITLAB_BASE_URL",
+ label: "GitLab base URL",
+ help: "https://gitlab.com for GitLab.com, or your self-hosted URL.",
+ required: false,
+ },
+ ];
+ case "bitbucket":
+ return [
+ {
+ key: "BITBUCKET_TOKEN",
+ label: "Bitbucket app password or token",
+ help: "Bitbucket → Personal settings → App passwords with repository write.",
+ required: true,
+ },
+ {
+ key: "BITBUCKET_WORKSPACE",
+ label: "Bitbucket workspace",
+ help: "The workspace slug in your repo URL.",
+ required: true,
+ },
+ {
+ key: "BITBUCKET_REPO_SLUG",
+ label: "Bitbucket repository slug",
+ help: "The repo name from the URL, e.g. my-blog.",
+ required: true,
+ },
+ {
+ key: "BITBUCKET_BRANCH",
+ label: "Default branch",
+ help: "Branch for commits, usually main.",
+ required: false,
+ },
+ {
+ key: "BITBUCKET_USERNAME",
+ label: "Bitbucket username",
+ help: "Your Bitbucket username — required for some API calls.",
+ required: false,
+ },
+ ];
+ case "wordpress":
+ return [
+ {
+ key: "WORDPRESS_API_URL",
+ label: "WordPress site URL",
+ help: "Your site root, e.g. https://blog.example.com — no trailing slash.",
+ required: true,
+ },
+ {
+ key: "WORDPRESS_USERNAME",
+ label: "WordPress username",
+ help: "A user with permission to create posts.",
+ required: true,
+ },
+ {
+ key: "WORDPRESS_APP_PASSWORD",
+ label: "WordPress application password",
+ help: "Users → Profile → Application Passwords. Not your login password.",
+ required: true,
+ },
+ ];
+ case "ghost":
+ return [
+ {
+ key: "GHOST_ADMIN_URL",
+ label: "Ghost Admin URL",
+ help: "Your Ghost site URL, e.g. https://blog.example.com.",
+ required: true,
+ },
+ {
+ key: "GHOST_ADMIN_API_KEY",
+ label: "Ghost Admin API key",
+ help: "Ghost Admin → Settings → Integrations → Add custom integration.",
+ required: true,
+ },
+ ];
+ default:
+ return [];
+ }
+}
+
+export function mediaProviderEnvRequirements(
+ mediaProvider: string,
+): EnvRequirement[] {
+ switch (mediaProvider) {
+ case "github-media":
+ return [];
+ case "cloudinary":
+ return [
+ {
+ key: "CLOUDINARY_CLOUD_NAME",
+ label: "Cloudinary cloud name",
+ help: "From your Cloudinary dashboard.",
+ required: true,
+ },
+ {
+ key: "CLOUDINARY_API_KEY",
+ label: "Cloudinary API key",
+ help: "Dashboard → API Keys.",
+ required: true,
+ },
+ {
+ key: "CLOUDINARY_API_SECRET",
+ label: "Cloudinary API secret",
+ help: "Keep server-side only — never expose in the browser.",
+ required: true,
+ },
+ {
+ key: "CLOUDINARY_FOLDER",
+ label: "Cloudinary upload folder",
+ help: "Optional folder prefix for uploads.",
+ required: false,
+ },
+ ];
+ case "s3-compatible":
+ return [
+ {
+ key: "S3_ENDPOINT",
+ label: "S3 endpoint URL",
+ help: "e.g. https://s3.amazonaws.com or your MinIO URL.",
+ required: true,
+ },
+ {
+ key: "S3_BUCKET",
+ label: "S3 bucket name",
+ help: "Bucket where media files are stored.",
+ required: true,
+ },
+ {
+ key: "S3_ACCESS_KEY_ID",
+ label: "S3 access key ID",
+ help: "Access key with write permission to the bucket.",
+ required: true,
+ },
+ {
+ key: "S3_SECRET_ACCESS_KEY",
+ label: "S3 secret access key",
+ help: "Keep server-side only.",
+ required: true,
+ },
+ {
+ key: "S3_REGION",
+ label: "S3 region",
+ help: "e.g. us-east-1 — required for AWS and many providers.",
+ required: false,
+ },
+ {
+ key: "S3_PUBLIC_BASE_URL",
+ label: "Public base URL for media",
+ help: "CDN or bucket URL visitors use to load images.",
+ required: false,
+ },
+ ];
+ default:
+ return [];
+ }
+}
+
+export function deployHookEnvRequirements(): EnvRequirement[] {
+ return [
+ {
+ key: "DEPLOY_HOOK_URL",
+ label: "Deploy hook URL",
+ help: "Webhook from Netlify, Vercel, Cloudflare Pages, etc. Called after a successful publish.",
+ required: true,
+ },
+ {
+ key: "DEPLOY_HOOK_METHOD",
+ label: "HTTP method",
+ help: "Usually POST. Leave as POST unless your host says otherwise.",
+ required: false,
+ },
+ {
+ key: "DEPLOY_HOOK_PROVIDER",
+ label: "Provider hint",
+ help: "generic, netlify, vercel, or cloudflare — used for logging only.",
+ required: false,
+ },
+ {
+ key: "DEPLOY_HOOK_STRICT",
+ label: "Fail publish on hook error",
+ help: "Set true only if a failed deploy must block publishing.",
+ required: false,
+ },
+ ];
+}
+
+export function isGitPublisher(publisher: string): boolean {
+ return GIT_PUBLISHERS.has(publisher as PublisherId);
+}
+
+export function isCmsPublisher(publisher: string): boolean {
+ return CMS_PUBLISHERS.has(publisher as PublisherId);
+}
+
+export function resolveMediaProviderId(env: Record): string {
+ const raw = env.CMS_MEDIA_PROVIDER?.trim();
+ if (raw && raw.length > 0) {
+ return raw;
+ }
+
+ return "github-media";
+}
+
+export function resolvePublisherId(
+ configPublisher: string,
+ env: Record,
+): string {
+ const fromEnv = env.CMS_PUBLISHER?.trim();
+ if (fromEnv && fromEnv.length > 0) {
+ return fromEnv;
+ }
+
+ return configPublisher;
+}
+
+export function missingRequiredEnvVars(
+ requirements: EnvRequirement[],
+ env: Record,
+): string[] {
+ const missing: string[] = [];
+
+ for (const req of requirements) {
+ if (!req.required) {
+ continue;
+ }
+
+ const value = env[req.key]?.trim();
+ if (!value) {
+ missing.push(req.key);
+ }
+ }
+
+ return missing;
+}
+
+export function validatePublisherSpecificRules(
+ publisher: string,
+ env: Record,
+): string[] {
+ const errors: string[] = [];
+
+ if (publisher === "gitlab") {
+ const hasId = Boolean(env.GITLAB_PROJECT_ID?.trim());
+ const hasPath = Boolean(env.GITLAB_PROJECT_PATH?.trim());
+ if (!hasId && !hasPath) {
+ errors.push("Set GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH.");
+ }
+ }
+
+ return errors;
+}
diff --git a/packages/setup/src/index.ts b/packages/setup/src/index.ts
new file mode 100644
index 0000000..5c2f80b
--- /dev/null
+++ b/packages/setup/src/index.ts
@@ -0,0 +1,52 @@
+export {
+ backupEnvFile,
+ loadEnvMap,
+ mergeEnvMaps,
+ parseEnvFile,
+ serializeEnvFile,
+ summarizeEnvUpdates,
+ type EnvMap,
+ type EnvMergeDecision,
+} from "./envFile.js";
+
+export {
+ deployHookEnvRequirements,
+ isCmsPublisher,
+ isGitPublisher,
+ mediaProviderEnvRequirements,
+ missingRequiredEnvVars,
+ publisherEnvRequirements,
+ resolveMediaProviderId,
+ resolvePublisherId,
+ type EnvRequirement,
+} from "./envRequirements.js";
+
+export {
+ checkBitbucketConnection,
+ checkDeployHookUrlShape,
+ checkGhostConnection,
+ checkGitHubConnection,
+ checkGitLabConnection,
+ checkPublisherConnection,
+ checkWordPressConnection,
+ type ConnectionCheckResult,
+} from "./connectionChecks.js";
+
+export {
+ formatEnvValueForDisplay,
+ isSecretEnvKey,
+ maskSecretValue,
+} from "./maskSecrets.js";
+
+export { isValidPublicMediaPath, isValidRepoPath } from "./paths.js";
+
+export {
+ validateConfig,
+ validateConfigAsync,
+ type ValidationIssue,
+ type ValidationLevel,
+ type ValidationReport,
+ type ValidateConfigOptions,
+} from "./validateConfig.js";
+
+export { runWizard, type WizardOptions, type WizardResult } from "./wizard.js";
diff --git a/packages/setup/src/maskSecrets.ts b/packages/setup/src/maskSecrets.ts
new file mode 100644
index 0000000..c406bca
--- /dev/null
+++ b/packages/setup/src/maskSecrets.ts
@@ -0,0 +1,27 @@
+const SECRET_KEY_PATTERN =
+ /(TOKEN|PASSWORD|SECRET|KEY|APP_PASSWORD|ADMIN_API_KEY|ACCESS_KEY)/i;
+
+export function isSecretEnvKey(key: string): boolean {
+ return SECRET_KEY_PATTERN.test(key);
+}
+
+export function maskSecretValue(value: string): string {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) {
+ return "(empty)";
+ }
+
+ if (trimmed.length <= 4) {
+ return "****";
+ }
+
+ return `${trimmed.slice(0, 2)}…${trimmed.slice(-2)} (${trimmed.length} chars)`;
+}
+
+export function formatEnvValueForDisplay(key: string, value: string): string {
+ if (isSecretEnvKey(key)) {
+ return maskSecretValue(value);
+ }
+
+ return value;
+}
diff --git a/packages/setup/src/paths.ts b/packages/setup/src/paths.ts
new file mode 100644
index 0000000..ab39b67
--- /dev/null
+++ b/packages/setup/src/paths.ts
@@ -0,0 +1,25 @@
+export function isValidRepoPath(path: string): boolean {
+ const trimmed = path.trim();
+ if (trimmed.length === 0) {
+ return false;
+ }
+
+ if (trimmed.startsWith("/") || trimmed.includes("..")) {
+ return false;
+ }
+
+ if (!/^[a-zA-Z0-9._/-]+$/u.test(trimmed)) {
+ return false;
+ }
+
+ return true;
+}
+
+export function isValidPublicMediaPath(path: string): boolean {
+ const trimmed = path.trim();
+ if (!trimmed.startsWith("/")) {
+ return false;
+ }
+
+ return isValidRepoPath(trimmed.slice(1));
+}
diff --git a/packages/setup/src/validateConfig.test.ts b/packages/setup/src/validateConfig.test.ts
new file mode 100644
index 0000000..b5e88ea
--- /dev/null
+++ b/packages/setup/src/validateConfig.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { maskSecretValue } from "./maskSecrets.js";
+import { validateConfig } from "./validateConfig.js";
+
+test("maskSecretValue hides token contents", () => {
+ assert.equal(maskSecretValue("ghp_abcdefghijklmnop"), "gh…op (20 chars)");
+ assert.equal(maskSecretValue("ab"), "****");
+});
+
+test("validateConfig passes with valid github setup", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-"));
+ writeFileSync(
+ join(dir, "sourcedraft.config.json"),
+ JSON.stringify({
+ adapter: "astro-mdx",
+ publisher: "github",
+ contentDir: "src/content/blog",
+ mediaDir: "public/images",
+ publicMediaPath: "/images",
+ defaultBranch: "main",
+ categories: ["Guides"],
+ }),
+ "utf8",
+ );
+
+ const report = validateConfig({
+ cwd: dir,
+ env: {
+ CMS_PUBLISHER: "github",
+ CMS_MEDIA_PROVIDER: "github-media",
+ GITHUB_TOKEN: "ghp_test",
+ GITHUB_OWNER: "acme",
+ GITHUB_REPO: "blog",
+ },
+ });
+
+ assert.equal(report.ok, true);
+ assert.equal(report.adapter, "astro-mdx");
+ assert.equal(report.publisher, "github");
+ assert.equal(report.mediaProvider, "github-media");
+ assert.deepEqual(report.missingEnvVars, []);
+});
+
+test("validateConfig fails for unknown adapter and publisher", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-"));
+ writeFileSync(
+ join(dir, "sourcedraft.config.json"),
+ JSON.stringify({
+ adapter: "not-real",
+ publisher: "not-real",
+ contentDir: "src/content/blog",
+ mediaDir: "public/images",
+ publicMediaPath: "/images",
+ defaultBranch: "main",
+ categories: ["Guides"],
+ }),
+ "utf8",
+ );
+
+ const report = validateConfig({
+ cwd: dir,
+ env: { CMS_MEDIA_PROVIDER: "github-media" },
+ });
+
+ assert.equal(report.ok, false);
+ assert.ok(report.issues.some((issue) => issue.field === "adapter"));
+ assert.ok(report.issues.some((issue) => issue.field === "publisher"));
+});
+
+test("validateConfig reports missing publisher env vars", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-setup-"));
+ writeFileSync(
+ join(dir, "sourcedraft.config.json"),
+ JSON.stringify({
+ adapter: "astro-mdx",
+ publisher: "github",
+ contentDir: "src/content/blog",
+ mediaDir: "public/images",
+ publicMediaPath: "/images",
+ defaultBranch: "main",
+ categories: ["Guides"],
+ }),
+ "utf8",
+ );
+
+ const report = validateConfig({
+ cwd: dir,
+ env: {
+ CMS_PUBLISHER: "github",
+ CMS_MEDIA_PROVIDER: "github-media",
+ },
+ });
+
+ assert.equal(report.ok, false);
+ assert.ok(report.missingEnvVars.includes("GITHUB_TOKEN"));
+ assert.ok(report.missingEnvVars.includes("GITHUB_OWNER"));
+ assert.ok(report.missingEnvVars.includes("GITHUB_REPO"));
+});
diff --git a/packages/setup/src/validateConfig.ts b/packages/setup/src/validateConfig.ts
new file mode 100644
index 0000000..30ef34c
--- /dev/null
+++ b/packages/setup/src/validateConfig.ts
@@ -0,0 +1,269 @@
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+import { listAdapterIds, isAdapterId } from "@sourcedraft/adapters";
+import { loadSourceDraftConfig, resolveConfigPath } from "@sourcedraft/config";
+import { isMediaProviderId } from "@sourcedraft/media-providers";
+import { isPublisherId } from "@sourcedraft/publishers";
+import {
+ checkDeployHookUrlShape,
+ checkPublisherConnection,
+ type ConnectionCheckResult,
+} from "./connectionChecks.js";
+import {
+ deployHookEnvRequirements,
+ isCmsPublisher,
+ isGitPublisher,
+ mediaProviderEnvRequirements,
+ missingRequiredEnvVars,
+ publisherEnvRequirements,
+ resolveMediaProviderId,
+ resolvePublisherId,
+ validatePublisherSpecificRules,
+} from "./envRequirements.js";
+import { isValidPublicMediaPath, isValidRepoPath } from "./paths.js";
+
+export type ValidationLevel = "error" | "warning";
+
+export type ValidationIssue = {
+ level: ValidationLevel;
+ field: string;
+ message: string;
+};
+
+export type ValidationReport = {
+ ok: boolean;
+ adapter: string;
+ publisher: string;
+ mediaProvider: string;
+ configPath: string | null;
+ envPath: string | null;
+ issues: ValidationIssue[];
+ missingEnvVars: string[];
+ warnings: string[];
+ connection: ConnectionCheckResult | null;
+};
+
+export type ValidateConfigOptions = {
+ cwd?: string;
+ env?: Record;
+ checkConnections?: boolean;
+ triggerDeployHook?: boolean;
+};
+
+function envRecordFromProcess(
+ env: Record | undefined,
+): Record {
+ if (env) {
+ return env;
+ }
+
+ return { ...process.env };
+}
+
+function addIssue(
+ issues: ValidationIssue[],
+ level: ValidationLevel,
+ field: string,
+ message: string,
+): void {
+ issues.push({ level, field, message });
+}
+
+export function validateConfig(options: ValidateConfigOptions = {}): ValidationReport {
+ const cwd = options.cwd ?? process.cwd();
+ const env = envRecordFromProcess(options.env);
+ const configPath = resolveConfigPath(cwd);
+ const envPath = resolve(cwd, ".env");
+ const config = loadSourceDraftConfig(cwd);
+
+ const adapter = config.adapter;
+ const publisher = resolvePublisherId(config.publisher, env);
+ const mediaProvider = resolveMediaProviderId(env);
+
+ const issues: ValidationIssue[] = [];
+ const warnings: string[] = [];
+
+ if (!isAdapterId(adapter)) {
+ addIssue(
+ issues,
+ "error",
+ "adapter",
+ `Unknown adapter "${adapter}". Valid: ${listAdapterIds().join(", ")}.`,
+ );
+ }
+
+ if (!isPublisherId(publisher)) {
+ addIssue(issues, "error", "publisher", `Unknown publisher "${publisher}".`);
+ }
+
+ if (!isMediaProviderId(mediaProvider)) {
+ addIssue(
+ issues,
+ "error",
+ "mediaProvider",
+ `Unknown media provider "${mediaProvider}".`,
+ );
+ }
+
+ if (!isValidRepoPath(config.contentDir)) {
+ addIssue(
+ issues,
+ "error",
+ "contentDir",
+ `Content directory "${config.contentDir}" looks invalid. Use a relative path like src/content/blog.`,
+ );
+ }
+
+ if (!isValidRepoPath(config.mediaDir)) {
+ addIssue(
+ issues,
+ "error",
+ "mediaDir",
+ `Media directory "${config.mediaDir}" looks invalid. Use a relative path like public/images.`,
+ );
+ }
+
+ if (!isValidPublicMediaPath(config.publicMediaPath)) {
+ addIssue(
+ issues,
+ "warning",
+ "publicMediaPath",
+ `Public media path "${config.publicMediaPath}" should start with / (e.g. /images).`,
+ );
+ }
+
+ if (config.categories.length === 0) {
+ addIssue(issues, "warning", "categories", "No categories configured.");
+ }
+
+ const publisherReqs = publisherEnvRequirements(publisher);
+ const mediaReqs = mediaProviderEnvRequirements(mediaProvider);
+ const deployReqs =
+ env.DEPLOY_HOOK_URL?.trim() ? deployHookEnvRequirements() : [];
+
+ const missingPublisher = missingRequiredEnvVars(publisherReqs, env);
+ const missingMedia = missingRequiredEnvVars(mediaReqs, env);
+ const missingDeploy = missingRequiredEnvVars(deployReqs, env);
+
+ for (const key of missingPublisher) {
+ addIssue(issues, "error", key, `Missing required env var for publisher ${publisher}.`);
+ }
+
+ for (const key of missingMedia) {
+ addIssue(issues, "error", key, `Missing required env var for media provider ${mediaProvider}.`);
+ }
+
+ for (const key of missingDeploy) {
+ addIssue(issues, "error", key, "Missing required deploy hook env var.");
+ }
+
+ for (const message of validatePublisherSpecificRules(publisher, env)) {
+ addIssue(issues, "error", "publisher", message);
+ }
+
+ if (isCmsPublisher(publisher) && mediaProvider === "github-media") {
+ const msg =
+ "CMS publisher with github-media: uploads use Git credentials; media library listing still reads from the git repo.";
+ addIssue(issues, "warning", "compatibility", msg);
+ warnings.push(msg);
+ }
+
+ if (isGitPublisher(publisher) && mediaProvider === "cloudinary") {
+ const msg =
+ "Git publisher with Cloudinary: article images may use Cloudinary URLs while the media library lists git files.";
+ addIssue(issues, "warning", "compatibility", msg);
+ warnings.push(msg);
+ }
+
+ if (mediaProvider === "s3-compatible") {
+ const msg = "S3-compatible media provider is experimental — verify uploads before production use.";
+ addIssue(issues, "warning", "mediaProvider", msg);
+ warnings.push(msg);
+ }
+
+ if (publisher === "bitbucket") {
+ const msg =
+ "Bitbucket publisher supports publish and upload; listing posts in Studio may be limited.";
+ addIssue(issues, "warning", "publisher", msg);
+ warnings.push(msg);
+ }
+
+ if (configPath === null) {
+ addIssue(
+ issues,
+ "warning",
+ "config",
+ "sourcedraft.config.json not found — using defaults. Run pnpm setup to create one.",
+ );
+ }
+
+ if (!existsSync(envPath)) {
+ addIssue(
+ issues,
+ "warning",
+ "env",
+ ".env not found — copy .env.example or run pnpm setup.",
+ );
+ }
+
+ const missingEnvVars = [...missingPublisher, ...missingMedia, ...missingDeploy];
+ const hasErrors = issues.some((issue) => issue.level === "error");
+
+ const report: ValidationReport = {
+ ok: !hasErrors,
+ adapter,
+ publisher,
+ mediaProvider,
+ configPath,
+ envPath: existsSync(envPath) ? envPath : null,
+ issues,
+ missingEnvVars,
+ warnings: [
+ ...warnings,
+ ...issues.filter((i) => i.level === "warning").map((i) => i.message),
+ ],
+ connection: null,
+ };
+
+ return report;
+}
+
+export async function validateConfigAsync(
+ options: ValidateConfigOptions = {},
+): Promise {
+ const report = validateConfig(options);
+ const env = envRecordFromProcess(options.env);
+ const publisher = report.publisher;
+
+ if (options.checkConnections) {
+ report.connection = await checkPublisherConnection(publisher, env);
+ if (report.connection && !report.connection.ok) {
+ report.issues.push({
+ level: "error",
+ field: "connection",
+ message: report.connection.detail,
+ });
+ report.ok = false;
+ }
+ }
+
+ if (env.DEPLOY_HOOK_URL?.trim()) {
+ const hookCheck = checkDeployHookUrlShape(env);
+ if (!hookCheck.ok) {
+ report.issues.push({
+ level: "error",
+ field: "DEPLOY_HOOK_URL",
+ message: hookCheck.detail,
+ });
+ report.ok = false;
+ } else if (!options.triggerDeployHook) {
+ report.issues.push({
+ level: "warning",
+ field: "DEPLOY_HOOK_URL",
+ message: hookCheck.detail,
+ });
+ }
+ }
+
+ return report;
+}
diff --git a/packages/setup/src/wizard.test.ts b/packages/setup/src/wizard.test.ts
new file mode 100644
index 0000000..73aa806
--- /dev/null
+++ b/packages/setup/src/wizard.test.ts
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import type { Interface } from "node:readline/promises";
+import { backupEnvFile } from "./envFile.js";
+import { runWizard } from "./wizard.js";
+
+function mockReadline(answers: string[]): Interface {
+ let index = 0;
+ return {
+ question: async () => answers[index++] ?? "",
+ close: () => undefined,
+ } as Interface;
+}
+
+test("runWizard writes config and env in temp directory", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-wizard-"));
+ const answers = [
+ "", // adapter default
+ "", // publisher default
+ "", // media default
+ "", // contentDir default
+ "", // mediaDir default
+ "", // branch default
+ "", // categories default
+ "n", // deploy hook
+ "ghp_test_token", // GITHUB_TOKEN
+ "acme", // GITHUB_OWNER
+ "blog", // GITHUB_REPO
+ "", // GITHUB_BRANCH skip
+ "studio-pass", // admin password
+ "", // write files yes
+ "n", // connection checks
+ ];
+
+ const result = await runWizard({
+ cwd: dir,
+ rl: mockReadline(answers),
+ now: () => new Date("2026-06-08T12:00:00.000Z"),
+ });
+
+ const config = JSON.parse(
+ readFileSync(result.configPath, "utf8"),
+ ) as Record;
+
+ assert.equal(config.adapter, "astro-mdx");
+ assert.equal(config.publisher, "github");
+ assert.equal(config.contentDir, "src/content/blog");
+
+ const envContent = readFileSync(result.envPath, "utf8");
+ assert.match(envContent, /CMS_PUBLISHER=github/);
+ assert.match(envContent, /GITHUB_OWNER=acme/);
+ assert.match(envContent, /GITHUB_TOKEN=ghp_test_token/);
+});
+
+test("runWizard backs up existing env before writing", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-wizard-backup-"));
+ const envPath = join(dir, ".env");
+ writeFileSync(envPath, "GITHUB_TOKEN=existing\nCMS_PUBLISHER=github\n", "utf8");
+
+ const answers = [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "n",
+ "", // keep existing GITHUB_TOKEN
+ "acme",
+ "blog",
+ "",
+ "studio-pass",
+ "",
+ "n",
+ ];
+
+ await runWizard({
+ cwd: dir,
+ rl: mockReadline(answers),
+ now: () => new Date("2026-06-08T12:00:00.000Z"),
+ });
+
+ const backups = existsSync(join(dir, ".env.backup.2026-06-08T12-00-00-000Z"));
+ assert.ok(backups);
+
+ const envContent = readFileSync(envPath, "utf8");
+ assert.match(envContent, /GITHUB_TOKEN=existing/);
+});
+
+test("backupEnvFile is used when env exists before wizard write", () => {
+ const dir = mkdtempSync(join(tmpdir(), "sourcedraft-backup-"));
+ const envPath = join(dir, ".env");
+ writeFileSync(envPath, "KEY=val\n", "utf8");
+ const backup = backupEnvFile(envPath, new Date("2026-06-08T12:00:00.000Z"));
+ assert.ok(backup);
+ assert.ok(existsSync(backup!));
+});
diff --git a/packages/setup/src/wizard.ts b/packages/setup/src/wizard.ts
new file mode 100644
index 0000000..eaa53ef
--- /dev/null
+++ b/packages/setup/src/wizard.ts
@@ -0,0 +1,392 @@
+import { existsSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createInterface, type Interface } from "node:readline/promises";
+import { stdin as input, stdout as output } from "node:process";
+import { listAdapterIds } from "@sourcedraft/adapters";
+import { derivePublicMediaPath } from "@sourcedraft/config";
+import { listMediaProviderIds } from "@sourcedraft/media-providers";
+import { listPublisherIds } from "@sourcedraft/publishers";
+import {
+ backupEnvFile,
+ loadEnvMap,
+ mergeEnvMaps,
+ serializeEnvFile,
+ summarizeEnvUpdates,
+} from "./envFile.js";
+import {
+ deployHookEnvRequirements,
+ mediaProviderEnvRequirements,
+ publisherEnvRequirements,
+} from "./envRequirements.js";
+import { formatEnvValueForDisplay } from "./maskSecrets.js";
+import { validateConfigAsync } from "./validateConfig.js";
+
+export type WizardOptions = {
+ cwd?: string;
+ rl?: Interface;
+ now?: () => Date;
+};
+
+export type WizardResult = {
+ configPath: string;
+ envPath: string;
+ backupPath: string | null;
+};
+
+const ADAPTER_HINTS: Record = {
+ "astro-mdx": { contentDir: "src/content/blog", mediaDir: "public/images" },
+ "markdown": { contentDir: "content/posts", mediaDir: "static/images" },
+ "nextjs-mdx": { contentDir: "content/posts", mediaDir: "public/images" },
+ "hugo-markdown": { contentDir: "content/posts", mediaDir: "static/images" },
+ "eleventy-jekyll-markdown": { contentDir: "_posts", mediaDir: "assets/images" },
+ "docusaurus-mdx": { contentDir: "blog", mediaDir: "static/img" },
+ "mkdocs-markdown": { contentDir: "docs", mediaDir: "docs/images" },
+ "nuxt-content-markdown": { contentDir: "content", mediaDir: "public/images" },
+};
+
+const PUBLISHER_LABELS: Record = {
+ github: "GitHub — commit Markdown/MDX to a repository",
+ gitlab: "GitLab — commit to a GitLab project",
+ bitbucket: "Bitbucket — commit to a Bitbucket repository",
+ wordpress: "WordPress — publish via REST API (no git commits)",
+ ghost: "Ghost — publish via Admin API (no git commits)",
+};
+
+const MEDIA_LABELS: Record = {
+ "github-media": "Git repository — images committed with your publisher",
+ cloudinary: "Cloudinary — hosted image CDN (server-side signed upload)",
+ "s3-compatible": "S3-compatible storage (experimental)",
+};
+
+async function askChoice(
+ rl: Interface,
+ prompt: string,
+ choices: string[],
+ defaultIndex = 0,
+): Promise {
+ console.log(prompt);
+ choices.forEach((choice, index) => {
+ const marker = index === defaultIndex ? "*" : " ";
+ console.log(` ${marker} ${index + 1}. ${choice}`);
+ });
+
+ const answer = (await rl.question(`Choose 1–${choices.length} [${defaultIndex + 1}]: `)).trim();
+ if (answer.length === 0) {
+ return choices[defaultIndex] ?? choices[0] ?? "";
+ }
+
+ const num = Number.parseInt(answer, 10);
+ if (Number.isFinite(num) && num >= 1 && num <= choices.length) {
+ return choices[num - 1] ?? choices[defaultIndex] ?? "";
+ }
+
+ const byId = choices.find((c) => c.startsWith(answer));
+ return byId ?? choices[defaultIndex] ?? "";
+}
+
+async function askYesNo(
+ rl: Interface,
+ prompt: string,
+ defaultYes = false,
+): Promise {
+ const hint = defaultYes ? "Y/n" : "y/N";
+ const answer = (await rl.question(`${prompt} (${hint}): `)).trim().toLowerCase();
+ if (answer.length === 0) {
+ return defaultYes;
+ }
+
+ return answer === "y" || answer === "yes";
+}
+
+async function askText(
+ rl: Interface,
+ prompt: string,
+ defaultValue = "",
+): Promise {
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
+ const answer = (await rl.question(`${prompt}${suffix}: `)).trim();
+ return answer.length > 0 ? answer : defaultValue;
+}
+
+async function collectEnvVars(
+ rl: Interface,
+ existing: Map,
+ requirements: ReturnType,
+): Promise