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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ Requirements: Node.js 22+, pnpm 11+
git clone https://github.com/bnz183/SourceDraft.git
cd SourceDraft
pnpm install
pnpm setup # guided wizard — or copy example files manually (below)
```

Or copy example files manually:

```bash
cp sourcedraft.config.example.json sourcedraft.config.json
cp .env.example .env
```
Expand All @@ -104,7 +109,7 @@ Sign in, click **New post**, preview the output, publish. The file lands at `con

**Try without GitHub:** set `SOURCEDRAFT_DEMO_MODE=true` in `.env`, or leave GitHub vars empty and click **Explore demo mode** on the sign-in screen. Demo content reloads from repository fixtures on each API start. See [docs/demo-mode.md](docs/demo-mode.md).

Full walkthrough: [docs/getting-started.md](docs/getting-started.md)
Validate config: `pnpm validate:config` · Wizard details: [docs/setup-wizard.md](docs/setup-wizard.md) · Full walkthrough: [docs/getting-started.md](docs/getting-started.md)

## Beginner path

Expand Down
5 changes: 3 additions & 2 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"predev": "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 @sourcedraft/config build",
"prebuild": "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 @sourcedraft/config build",
"predev": "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 @sourcedraft/config build",
"prebuild": "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 @sourcedraft/config build",
"dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch server/index.ts\"",
"dev:web": "vite",
"dev:server": "tsx watch server/index.ts",
Expand Down Expand Up @@ -33,6 +33,7 @@
"@sourcedraft/core": "workspace:*",
"@sourcedraft/github-publisher": "workspace:*",
"@sourcedraft/media-providers": "workspace:*",
"@sourcedraft/setup": "workspace:*",
"busboy": "^1.6.0",
"dotenv": "^16.5.0",
"express": "^5.1.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/server/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function toCmsPayload(article: Article): CmsArticlePayload {
: {}),
...(article.canonicalUrl !== undefined ? { canonicalUrl: article.canonicalUrl } : {}),
...(article.socialImage !== undefined ? { socialImage: article.socialImage } : {}),
...(article.coverImageAlt !== undefined ? { coverImageAlt: article.coverImageAlt } : {}),
...(article.noindex === true ? { noindex: true } : {}),
};
}

Expand Down
47 changes: 47 additions & 0 deletions apps/studio/server/setupHealth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isAdapterId } from "@sourcedraft/adapters";
import { isPublisherId } from "@sourcedraft/publishers";
import { validateConfig } from "@sourcedraft/setup";
import { isAuthConfigured } from "./auth.js";
import { loadProjectConfig, loadPublicConfig } from "./config.js";
import {
Expand Down Expand Up @@ -32,6 +33,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;
Expand All @@ -46,6 +56,7 @@ export type SetupHealthReport = {
demoModeForced: boolean;
demoModeAvailable: boolean;
githubReady: boolean;
compatibility: SetupCompatibilityReport;
checks: SetupHealthCheck[];
nextAction: string | null;
};
Expand Down Expand Up @@ -314,6 +325,41 @@ export function getSetupHealth(): SetupHealthReport {
nextAction = null;
}

const validation = validateConfig();
const compatibility: SetupCompatibilityReport = {
adapter: validation.adapter,
publisher: validation.publisher,
mediaProvider: validation.mediaProvider,
validationOk: validation.ok,
missingEnvVars: validation.missingEnvVars,
warnings: validation.warnings,
};

if (!validation.ok && validation.missingEnvVars.length > 0) {
checks.push({
id: "config-validation",
label: "Configuration validation",
ok: false,
detail: `Missing: ${validation.missingEnvVars.join(", ")}. Run pnpm validate:config for details.`,
});
} else if (validation.warnings.length > 0) {
checks.push({
id: "config-validation",
label: "Configuration validation",
ok: true,
detail: validation.warnings[0] ?? "Configuration validated with warnings.",
});
} else {
checks.push({
id: "config-validation",
label: "Configuration validation",
ok: validation.ok,
detail: validation.ok
? "Adapter, publisher, and media provider look compatible."
: "Run pnpm validate:config locally for details.",
});
}

return {
ok: publisherReady || demoModeAvailable,
adminPasswordConfigured,
Expand All @@ -328,6 +374,7 @@ export function getSetupHealth(): SetupHealthReport {
demoModeForced,
demoModeAvailable,
githubReady: publisherReady,
compatibility,
checks,
nextAction,
};
Expand Down
72 changes: 72 additions & 0 deletions apps/studio/src/components/CompatibilityPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { SetupCompatibilityReport } from "../lib/setupHealth.js";

type CompatibilityPanelProps = {
compatibility: SetupCompatibilityReport;
};

export function CompatibilityPanel({ compatibility }: CompatibilityPanelProps) {
const statusLabel = compatibility.validationOk ? "Valid" : "Needs attention";
const statusClass = compatibility.validationOk
? "compatibility-panel__status--ok"
: "compatibility-panel__status--warn";

return (
<section
className="panel compatibility-panel"
aria-labelledby="compatibility-panel-title"
>
<div className="panel__header">
<h2 className="panel__title" id="compatibility-panel-title">
Compatibility &amp; status
</h2>
<p className="panel__meta">
Read-only summary — secrets are never shown in the browser
</p>
</div>

<dl className="compatibility-panel__grid">
<div className="compatibility-panel__row">
<dt>Adapter</dt>
<dd>{compatibility.adapter}</dd>
</div>
<div className="compatibility-panel__row">
<dt>Publisher</dt>
<dd>{compatibility.publisher}</dd>
</div>
<div className="compatibility-panel__row">
<dt>Media provider</dt>
<dd>{compatibility.mediaProvider}</dd>
</div>
<div className="compatibility-panel__row">
<dt>Validation</dt>
<dd>
<span
className={`compatibility-panel__status ${statusClass}`}
>
{statusLabel}
</span>
</dd>
</div>
</dl>

{compatibility.missingEnvVars.length > 0 && (
<div className="notice notice--warning compatibility-panel__notice" role="status">
<p className="notice__title">Missing environment variables</p>
<p className="notice__body">
Set these in <code>.env</code> on the server (or run{" "}
<code>pnpm setup</code>):{" "}
{compatibility.missingEnvVars.join(", ")}
</p>
</div>
)}

{compatibility.warnings.length > 0 && (
<ul className="compatibility-panel__warnings">
{compatibility.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</section>
);
}
8 changes: 8 additions & 0 deletions apps/studio/src/components/PostDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ValidationIssue } from "@sourcedraft/core";
import type { ArticleFormState } from "../lib/articleForm";
import { ContentQualityPanel } from "./ContentQualityPanel";
import { MediaSection } from "./MediaSection";
import { SeoSharingPanel } from "./SeoSharingPanel";

type PostDetailsPanelProps = {
values: ArticleFormState;
Expand Down Expand Up @@ -208,6 +209,13 @@ export function PostDetailsPanel({
)}
</div>

<SeoSharingPanel
values={values}
fieldErrors={fieldErrors}
onChange={onChange}
validationIssues={issues}
/>

<ContentQualityPanel values={values} validationIssues={issues} />

<div
Expand Down
173 changes: 173 additions & 0 deletions apps/studio/src/components/SeoSharingPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { ValidationIssue } from "@sourcedraft/core";
import type { ArticleFormState } from "../lib/articleForm.js";
import { analyzeSeoFields } from "../lib/seoValidation.js";

type SeoSharingPanelProps = {
values: ArticleFormState;
fieldErrors: Record<string, string>;
onChange: (field: keyof ArticleFormState, value: string | boolean) => void;
validationIssues: ValidationIssue[];
};

export function SeoSharingPanel({
values,
fieldErrors,
onChange,
validationIssues,
}: SeoSharingPanelProps) {
const { readingTimeMinutes, warnings } = analyzeSeoFields(values);
const blockingFields = new Set(validationIssues.map((issue) => issue.field));

function fieldClass(field: string): string {
return fieldErrors[field]
? "field__input field__input--error"
: "field__input";
}

return (
<details className="seo-sharing">
<summary className="seo-sharing__summary">
<span className="seo-sharing__title">SEO / Sharing</span>
<span className="seo-sharing__hint">Optional metadata for search and social</span>
</summary>

<div className="seo-sharing__body">
<p className="seo-sharing__intro">
Leave fields blank to fall back to the post title, description, or cover
image. Soft warnings below do not block publishing.
</p>

<label className="field field--full">
<span className="field__label">Meta title</span>
<input
className={`${fieldClass("metaTitle")} field__input--mono`}
type="text"
value={values.metaTitle}
onChange={(event) => onChange("metaTitle", event.target.value)}
placeholder={values.title || "Defaults to post title"}
/>
{fieldErrors.metaTitle && (
<span className="field__error" role="alert">
{fieldErrors.metaTitle}
</span>
)}
</label>

<label className="field field--full">
<span className="field__label">Meta description</span>
<textarea
className={`${fieldClass("metaDescription")} field__input field__input--textarea`}
value={values.metaDescription}
onChange={(event) => onChange("metaDescription", event.target.value)}
placeholder={values.description || "Defaults to post description"}
rows={3}
/>
{fieldErrors.metaDescription && (
<span className="field__error" role="alert">
{fieldErrors.metaDescription}
</span>
)}
</label>

<label className="field field--full">
<span className="field__label">Canonical URL</span>
<input
className={`${fieldClass("canonicalUrl")} field__input--mono`}
type="url"
value={values.canonicalUrl}
onChange={(event) => onChange("canonicalUrl", event.target.value)}
placeholder="https://example.com/post/slug"
/>
<span className="field__hint">Full https URL when this post lives elsewhere</span>
{fieldErrors.canonicalUrl && (
<span className="field__error" role="alert">
{fieldErrors.canonicalUrl}
</span>
)}
</label>

<label className="field field--full">
<span className="field__label">Social image</span>
<input
className={`${fieldClass("socialImage")} field__input--mono`}
type="text"
value={values.socialImage}
onChange={(event) => onChange("socialImage", event.target.value)}
placeholder={values.heroImage || "Defaults to cover image path"}
/>
<span className="field__hint">Open Graph / Twitter image path or URL</span>
{fieldErrors.socialImage && (
<span className="field__error" role="alert">
{fieldErrors.socialImage}
</span>
)}
</label>

<label className="field field--full">
<span className="field__label">Cover image alt text</span>
<input
className={`${fieldClass("coverImageAlt")} field__input--mono`}
type="text"
value={values.coverImageAlt}
onChange={(event) => onChange("coverImageAlt", event.target.value)}
placeholder="Describe the cover image for accessibility"
/>
{fieldErrors.coverImageAlt && (
<span className="field__error" role="alert">
{fieldErrors.coverImageAlt}
</span>
)}
</label>

<label className="field field--full">
<span className="field__label">Author</span>
<input
className={`${fieldClass("author")} field__input--mono`}
type="text"
value={values.author}
onChange={(event) => onChange("author", event.target.value)}
placeholder="Optional byline"
/>
{fieldErrors.author && (
<span className="field__error" role="alert">
{fieldErrors.author}
</span>
)}
</label>

<label className="field field--checkbox">
<input
type="checkbox"
checked={values.noindex}
onChange={(event) => onChange("noindex", event.target.checked)}
/>
<span className="field__label">Noindex (ask search engines not to index)</span>
</label>

<p className="seo-sharing__reading-time" role="status">
Estimated reading time:{" "}
{readingTimeMinutes === 0
? "—"
: `${readingTimeMinutes} min (added to frontmatter on publish)`}
</p>

{warnings.length > 0 && (
<ul className="seo-sharing__warnings" aria-live="polite">
{warnings.map((warning) => (
<li
key={warning.id}
className={
blockingFields.has(warning.field)
? "seo-sharing__warning seo-sharing__warning--blocked"
: "seo-sharing__warning"
}
>
{warning.message}
</li>
))}
</ul>
)}
</div>
</details>
);
}
Loading