diff --git a/.env.example b/.env.example index 81966e7..30d737f 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ CMS_CONTENT_DIR= CMS_MEDIA_DIR= CMS_PUBLIC_MEDIA_PATH= CMS_ADAPTER= +CMS_PUBLISHER= diff --git a/apps/studio/package.json b/apps/studio/package.json index c8cd828..606057c 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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/github-publisher --filter @sourcedraft/config build", - "prebuild": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --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/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/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", @@ -23,8 +23,13 @@ "@fontsource/ibm-plex-sans": "^5.2.8", "@fontsource/ibm-plex-serif": "^5.2.7", "@sourcedraft/adapter-astro-mdx": "workspace:*", + "@sourcedraft/adapter-eleventy-jekyll-markdown": "workspace:*", + "@sourcedraft/adapter-hugo-markdown": "workspace:*", "@sourcedraft/adapter-markdown": "workspace:*", + "@sourcedraft/adapter-nextjs-mdx": "workspace:*", + "@sourcedraft/adapters": "workspace:*", "@sourcedraft/config": "workspace:*", + "@sourcedraft/publishers": "workspace:*", "@sourcedraft/core": "workspace:*", "@sourcedraft/github-publisher": "workspace:*", "busboy": "^1.6.0", diff --git a/apps/studio/server/config.ts b/apps/studio/server/config.ts index b3f2748..c29231c 100644 --- a/apps/studio/server/config.ts +++ b/apps/studio/server/config.ts @@ -4,8 +4,21 @@ import { normalizePublicMediaPath, } from "@sourcedraft/config"; import type { SourceDraftConfig } from "@sourcedraft/config"; +import { + isAdapterId, + listAdapterIds, + supportedAdapterSummary, + type AdapterId, +} from "@sourcedraft/adapters"; +import { + isPublisherId, + listPublisherIds, + supportedPublisherSummary, + type PublisherId, +} from "@sourcedraft/publishers"; -export type SupportedAdapter = "astro-mdx" | "markdown"; +export type SupportedAdapter = AdapterId; +export type SupportedPublisher = PublisherId; export type PublishEnvConfig = { token: string; @@ -16,6 +29,9 @@ export type PublishEnvConfig = { mediaDir: string; publicMediaPath: string; adapter: SupportedAdapter; + publisher: SupportedPublisher; + adapterOptions?: Record; + publisherOptions?: Record; categories: string[]; }; @@ -23,18 +39,24 @@ export type PublishEnvResult = | { ok: true; config: PublishEnvConfig } | { ok: false; error: string }; -const SUPPORTED_ADAPTERS = new Set(["astro-mdx", "markdown"]); +export function loadProjectConfig(): SourceDraftConfig { + return loadSourceDraftConfig(); +} function resolveAdapter(rawAdapter: string): SupportedAdapter | null { - if (SUPPORTED_ADAPTERS.has(rawAdapter)) { - return rawAdapter as SupportedAdapter; + if (isAdapterId(rawAdapter)) { + return rawAdapter; } return null; } -export function loadProjectConfig(): SourceDraftConfig { - return loadSourceDraftConfig(); +function resolvePublisher(rawPublisher: string): SupportedPublisher | null { + if (isPublisherId(rawPublisher)) { + return rawPublisher; + } + + return null; } function resolvePublicMediaPath( @@ -66,7 +88,9 @@ export function loadPublishEnv(): PublishEnvResult { const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; const publicMediaPath = resolvePublicMediaPath(mediaDir, project); const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; + const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; const adapter = resolveAdapter(rawAdapter); + const publisher = resolvePublisher(rawPublisher); if (!token) { return { ok: false, error: "GITHUB_TOKEN is not configured." }; @@ -83,7 +107,14 @@ export function loadPublishEnv(): PublishEnvResult { if (adapter === null) { return { ok: false, - error: `Unsupported adapter "${rawAdapter}". Supported adapters: astro-mdx, markdown.`, + error: `Unsupported adapter "${rawAdapter}". Supported adapters: ${supportedAdapterSummary()}.`, + }; + } + + if (publisher === null) { + return { + ok: false, + error: `Unsupported publisher "${rawPublisher}". Supported publishers: ${supportedPublisherSummary()}.`, }; } @@ -98,15 +129,26 @@ export function loadPublishEnv(): PublishEnvResult { mediaDir, publicMediaPath, adapter, + publisher, + ...(project.adapterOptions !== undefined + ? { adapterOptions: project.adapterOptions } + : {}), + ...(project.publisherOptions !== undefined + ? { publisherOptions: project.publisherOptions } + : {}), categories: project.categories, }, }; } -export function loadPublicConfig(): Omit { +export type PublicStudioConfig = Omit; + +export function loadPublicConfig(): PublicStudioConfig { const project = loadProjectConfig(); const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; + const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; const adapter = resolveAdapter(rawAdapter) ?? "astro-mdx"; + const publisher = resolvePublisher(rawPublisher) ?? "github"; const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; return { @@ -117,6 +159,21 @@ export function loadPublicConfig(): Omit { mediaDir, publicMediaPath: resolvePublicMediaPath(mediaDir, project), adapter, + publisher, + ...(project.adapterOptions !== undefined + ? { adapterOptions: project.adapterOptions } + : {}), + ...(project.publisherOptions !== undefined + ? { publisherOptions: project.publisherOptions } + : {}), categories: project.categories, }; } + +export function listSupportedAdapters(): SupportedAdapter[] { + return listAdapterIds(); +} + +export function listSupportedPublishers(): SupportedPublisher[] { + return listPublisherIds(); +} diff --git a/apps/studio/server/demoPosts.ts b/apps/studio/server/demoPosts.ts index c14cb67..f55ba89 100644 --- a/apps/studio/server/demoPosts.ts +++ b/apps/studio/server/demoPosts.ts @@ -55,6 +55,7 @@ export async function loadDemoPost( safe.path, parsed.frontmatter, parsed.body, + env.adapter, ); const validation = validateArticle(article); if (!validation.valid) { diff --git a/apps/studio/server/demoPublish.ts b/apps/studio/server/demoPublish.ts index 88257bb..88094a7 100644 --- a/apps/studio/server/demoPublish.ts +++ b/apps/studio/server/demoPublish.ts @@ -1,5 +1,7 @@ -import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; -import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; +import { + getAdapterPostPath, + renderAdapterOutput, +} from "@sourcedraft/adapters"; import { normalizeArticle, validateArticle, @@ -11,24 +13,20 @@ import { demoCommitSha, upsertDemoPost } from "./demoStore.js"; import { safePostPath } from "./postPaths.js"; import type { PublishRequestBody, PublishResponse } from "./publish.js"; -function renderArticle(article: Article, adapter: PublishEnvConfig["adapter"]): string { - if (adapter === "markdown") { - return toMarkdown(article); - } - - return toAstroMdx(article); +function renderArticle(article: Article, env: Omit): string { + return renderAdapterOutput(env.adapter, article, env.adapterOptions); } function defaultPostPath( article: Article, - adapter: PublishEnvConfig["adapter"], - contentDir: string, + env: Omit, ): string { - if (adapter === "markdown") { - return getMarkdownPath(article, { contentDir }); - } - - return getAstroMdxPath(article, { contentDir }); + return getAdapterPostPath(env.adapter, article, { + contentDir: env.contentDir, + ...(env.adapterOptions !== undefined + ? { adapterOptions: env.adapterOptions } + : {}), + }); } export async function publishDemoArticle( @@ -65,11 +63,11 @@ export async function publishDemoArticle( path = safe.path; } else { - path = defaultPostPath(article, env.adapter, env.contentDir); + path = defaultPostPath(article, env); created = true; } - const content = renderArticle(article, env.adapter); + const content = renderArticle(article, env); const commitSha = demoCommitSha(); upsertDemoPost(path, content, { diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 4ca942d..8d7455c 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -99,6 +99,9 @@ app.get("/api/config", requireAuth, (req, res) => { publicMediaPath: runtime.publicMediaPath, defaultBranch: runtime.branch, categories: runtime.categories, + ...(runtime.adapterOptions !== undefined + ? { adapterOptions: runtime.adapterOptions } + : {}), githubOwner: demoMode ? "demo" : runtime.owner, githubRepo: demoMode ? "sample-posts" : runtime.repo, demoMode, diff --git a/apps/studio/server/listMedia.ts b/apps/studio/server/listMedia.ts index d4d268d..7504821 100644 --- a/apps/studio/server/listMedia.ts +++ b/apps/studio/server/listMedia.ts @@ -1,6 +1,6 @@ import { joinPublicMediaPath } from "@sourcedraft/config"; -import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; import { filenameFromRepoPath, normalizeMediaDir, safeMediaPath } from "./mediaPaths.js"; import { mediaKindFromExtension, @@ -39,14 +39,9 @@ export async function listMedia( }; } - const publisher = createGitHubPublisher({ - token: env.token, - owner: env.owner, - repo: env.repo, - branch: env.branch, - }); + const publisher = createPublisherFromEnv(env); - const listed = await publisher.listFiles({ path: mediaDir, contentDir: mediaDir }); + const listed = await publisher.listPosts({ contentDir: mediaDir }); if (!listed.ok) { return { status: listed.status === 404 ? 404 : 502, diff --git a/apps/studio/server/media.ts b/apps/studio/server/media.ts index 13e3755..db6c18a 100644 --- a/apps/studio/server/media.ts +++ b/apps/studio/server/media.ts @@ -2,8 +2,8 @@ import { randomBytes } from "node:crypto"; import type { Request } from "express"; import Busboy from "busboy"; import { joinPublicMediaPath } from "@sourcedraft/config"; -import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; import { normalizeMediaDir } from "./mediaPaths.js"; import { ALLOWED_MIME_TYPES, @@ -203,18 +203,12 @@ export async function uploadMedia( const repoPath = `${mediaDir}/${repoFilename}`; const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename); - const publisher = createGitHubPublisher({ - token: env.token, - owner: env.owner, - repo: env.repo, - branch: env.branch, - }); + const publisher = createPublisherFromEnv(env); - const result = await publisher.publishFile({ - path: repoPath, + const result = await publisher.uploadMedia({ + repoPath, contentBase64: parsed.buffer.toString("base64"), message: `Upload media: ${repoFilename}`, - purpose: "media", }); if (!result.ok) { diff --git a/apps/studio/server/posts.ts b/apps/studio/server/posts.ts index 5f15bd6..7af3072 100644 --- a/apps/studio/server/posts.ts +++ b/apps/studio/server/posts.ts @@ -1,9 +1,13 @@ +import { slugFromFilename } from "@sourcedraft/adapter-eleventy-jekyll-markdown"; +import { + frontmatterToArticleInput as adapterFrontmatterToArticleInput, +} from "@sourcedraft/adapters"; import { validateArticle, type ArticleInput, } from "@sourcedraft/core"; -import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; import { normalizeContentDir, safePostPath } from "./postPaths.js"; export type PostSummary = { @@ -24,17 +28,12 @@ export type PostLoadResponse = | { ok: false; error: string; issues?: { field: string; message: string }[] }; function createPublisher(env: PublishEnvConfig) { - return createGitHubPublisher({ - token: env.token, - owner: env.owner, - repo: env.repo, - branch: env.branch, - }); + return createPublisherFromEnv(env); } export function slugFromPath(path: string): string { const filename = path.split("/").pop() ?? ""; - return filename.replace(/\.(mdx|md)$/iu, ""); + return slugFromFilename(filename); } function parseScalar(value: string): string { @@ -150,24 +149,9 @@ export function frontmatterToArticleInput( path: string, frontmatter: Record, body: string, + adapter: PublishEnvConfig["adapter"], ): ArticleInput { - const slug = - typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 - ? 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 adapterFrontmatterToArticleInput(adapter, path, frontmatter, body); } export async function listPosts( @@ -175,7 +159,7 @@ export async function listPosts( ): Promise<{ status: number; body: PostsListResponse }> { const publisher = createPublisher(env); const contentDir = normalizeContentDir(env.contentDir); - const listed = await publisher.listFiles({ path: contentDir, contentDir }); + const listed = await publisher.listPosts({ contentDir }); if (!listed.ok) { return { @@ -192,7 +176,7 @@ export async function listPosts( continue; } - const loaded = await publisher.readFile({ path: safe.path }); + const loaded = await publisher.readPost({ path: safe.path }); if (!loaded.ok) { continue; } @@ -206,6 +190,7 @@ export async function listPosts( safe.path, parsed.frontmatter, parsed.body, + env.adapter, ); const validation = validateArticle(article); if (!validation.valid) { @@ -243,7 +228,7 @@ export async function loadPost( } const publisher = createPublisher(env); - const loaded = await publisher.readFile({ path: safe.path }); + const loaded = await publisher.readPost({ path: safe.path }); if (!loaded.ok) { const status = loaded.status === 404 ? 404 : 502; @@ -265,6 +250,7 @@ export async function loadPost( safe.path, parsed.frontmatter, parsed.body, + env.adapter, ); const validation = validateArticle(article); if (!validation.valid) { diff --git a/apps/studio/server/publish.ts b/apps/studio/server/publish.ts index 423a9e2..1e59bd7 100644 --- a/apps/studio/server/publish.ts +++ b/apps/studio/server/publish.ts @@ -1,13 +1,15 @@ -import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; -import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; +import { + getAdapterPostPath, + renderAdapterOutput, +} from "@sourcedraft/adapters"; import { normalizeArticle, validateArticle, type Article, type ArticleInput, } from "@sourcedraft/core"; -import { createGitHubPublisher } from "@sourcedraft/github-publisher"; import type { PublishEnvConfig } from "./config.js"; +import { createPublisherFromEnv } from "./publisherRuntime.js"; import { safePostPath } from "./postPaths.js"; export type PublishRequestBody = ArticleInput & { @@ -31,24 +33,17 @@ export type PublishErrorResponse = { export type PublishResponse = PublishSuccessResponse | PublishErrorResponse; -function renderArticle(article: Article, adapter: PublishEnvConfig["adapter"]): string { - if (adapter === "markdown") { - return toMarkdown(article); - } - - return toAstroMdx(article); +function renderArticle(article: Article, env: PublishEnvConfig): string { + return renderAdapterOutput(env.adapter, article, env.adapterOptions); } -function defaultPostPath( - article: Article, - adapter: PublishEnvConfig["adapter"], - contentDir: string, -): string { - if (adapter === "markdown") { - return getMarkdownPath(article, { contentDir }); - } - - return getAstroMdxPath(article, { contentDir }); +function defaultPostPath(article: Article, env: PublishEnvConfig): string { + return getAdapterPostPath(env.adapter, article, { + contentDir: env.contentDir, + ...(env.adapterOptions !== undefined + ? { adapterOptions: env.adapterOptions } + : {}), + }); } export async function publishArticle( @@ -84,23 +79,17 @@ export async function publishArticle( path = safe.path; } else { - path = defaultPostPath(article, env.adapter, env.contentDir); + path = defaultPostPath(article, env); } - const content = renderArticle(article, env.adapter); + const content = renderArticle(article, env); - const publisher = createGitHubPublisher({ - token: env.token, - owner: env.owner, - repo: env.repo, - branch: env.branch, - }); + const publisher = createPublisherFromEnv(env); - const result = await publisher.publishFile({ + const result = await publisher.publishArticle({ path, content, message: `Publish: ${article.slug}`, - purpose: "post", }); if (!result.ok) { diff --git a/apps/studio/server/publisherRuntime.ts b/apps/studio/server/publisherRuntime.ts new file mode 100644 index 0000000..c5706fa --- /dev/null +++ b/apps/studio/server/publisherRuntime.ts @@ -0,0 +1,41 @@ +import { + createPublisher, + isPublisherId, + supportedPublisherSummary, + type Publisher, + type PublisherId, + type PublisherRuntimeConfig, +} from "@sourcedraft/publishers"; +import type { PublishEnvConfig } from "./config.js"; + +export function toPublisherRuntimeConfig( + env: PublishEnvConfig, +): PublisherRuntimeConfig { + return { + token: env.token, + owner: env.owner, + repo: env.repo, + branch: env.branch, + contentDir: env.contentDir, + mediaDir: env.mediaDir, + ...(env.publisherOptions !== undefined + ? { publisherOptions: env.publisherOptions } + : {}), + }; +} + +export function createPublisherFromEnv(env: PublishEnvConfig): Publisher { + return createPublisher(env.publisher, toPublisherRuntimeConfig(env)); +} + +export function resolvePublisherId(rawPublisher: string): PublisherId | null { + if (isPublisherId(rawPublisher)) { + return rawPublisher; + } + + return null; +} + +export function unknownPublisherError(rawPublisher: string): string { + return `Unsupported publisher "${rawPublisher}". Supported publishers: ${supportedPublisherSummary()}.`; +} diff --git a/apps/studio/server/runtimeConfig.test.ts b/apps/studio/server/runtimeConfig.test.ts new file mode 100644 index 0000000..6c6f4e1 --- /dev/null +++ b/apps/studio/server/runtimeConfig.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { normalizeSourceDraftConfig } from "@sourcedraft/config"; +import { loadPublishEnv } from "./config.js"; + +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + +describe("runtime config resolution", () => { + it("defaults adapter and publisher from project config", () => { + const project = normalizeSourceDraftConfig({ + adapter: "markdown", + publisher: "github", + }); + + assert.equal(project.adapter, "markdown"); + assert.equal(project.publisher, "github"); + }); + + it("rejects unknown adapter with a clear error", () => { + process.env.GITHUB_TOKEN = "token"; + process.env.GITHUB_OWNER = "owner"; + process.env.GITHUB_REPO = "repo"; + process.env.CMS_ADAPTER = "wordpress"; + + const result = loadPublishEnv(); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /Unsupported adapter "wordpress"/); + } + }); + + it("rejects unknown publisher with a clear error", () => { + process.env.GITHUB_TOKEN = "token"; + process.env.GITHUB_OWNER = "owner"; + process.env.GITHUB_REPO = "repo"; + process.env.CMS_PUBLISHER = "ghost-api"; + + const result = loadPublishEnv(); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /Unsupported publisher "ghost-api"/); + } + }); + + it("accepts default github publisher when credentials are set", () => { + process.env.GITHUB_TOKEN = "token"; + process.env.GITHUB_OWNER = "owner"; + process.env.GITHUB_REPO = "repo"; + process.env.CMS_ADAPTER = "astro-mdx"; + process.env.CMS_PUBLISHER = "github"; + + const result = loadPublishEnv(); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.config.adapter, "astro-mdx"); + assert.equal(result.config.publisher, "github"); + } + }); +}); diff --git a/apps/studio/server/setupHealth.ts b/apps/studio/server/setupHealth.ts index aaf6431..434338c 100644 --- a/apps/studio/server/setupHealth.ts +++ b/apps/studio/server/setupHealth.ts @@ -1,9 +1,7 @@ +import { isAdapterId } from "@sourcedraft/adapters"; +import { isPublisherId } from "@sourcedraft/publishers"; import { isAuthConfigured } from "./auth.js"; -import { - loadProjectConfig, - loadPublicConfig, - type SupportedAdapter, -} from "./config.js"; +import { loadProjectConfig, loadPublicConfig } from "./config.js"; import { isDemoModeAvailable, isDemoModeForced, @@ -30,6 +28,7 @@ export type SetupHealthReport = { mediaDirConfigured: boolean; publicMediaPathConfigured: boolean; adapterValid: boolean; + publisherValid: boolean; demoModeForced: boolean; demoModeAvailable: boolean; githubReady: boolean; @@ -37,21 +36,13 @@ export type SetupHealthReport = { nextAction: string | null; }; -const SUPPORTED_ADAPTERS = new Set(["astro-mdx", "markdown"]); - -function resolveAdapter(rawAdapter: string): SupportedAdapter | null { - if (SUPPORTED_ADAPTERS.has(rawAdapter)) { - return rawAdapter as SupportedAdapter; - } - - return null; -} - export function getSetupHealth(): SetupHealthReport { const project = loadProjectConfig(); const runtime = loadPublicConfig(); const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; - const adapter = resolveAdapter(rawAdapter); + const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; + const adapter = isAdapterId(rawAdapter) ? rawAdapter : null; + const publisher = isPublisherId(rawPublisher) ? rawPublisher : null; const contentDir = runtime.contentDir.trim(); const mediaDir = runtime.mediaDir.trim(); const publicMediaPath = runtime.publicMediaPath.trim(); @@ -64,13 +55,15 @@ export function getSetupHealth(): SetupHealthReport { const mediaDirConfigured = mediaDir.length > 0; const publicMediaPathConfigured = publicMediaPath.length > 0; const adapterValid = adapter !== null; + const publisherValid = publisher !== null; const demoModeForced = isDemoModeForced(); const demoModeAvailable = isDemoModeAvailable(); const githubReady = githubOwnerConfigured && githubRepoConfigured && githubTokenConfigured && - adapterValid; + adapterValid && + publisherValid; const checks: SetupHealthCheck[] = [ { @@ -135,7 +128,15 @@ export function getSetupHealth(): SetupHealthReport { ok: adapterValid, detail: adapterValid ? `Using ${adapter} adapter.` - : `Unsupported adapter "${rawAdapter}". Use astro-mdx or markdown.`, + : `Unsupported adapter "${rawAdapter}". Use a built-in adapter id from docs/adapters.md.`, + }, + { + id: "publisher", + label: "Publisher", + ok: publisherValid, + detail: publisherValid + ? `Using ${publisher} publisher.` + : `Unsupported publisher "${rawPublisher}". Use a built-in publisher id from docs/configuration.md.`, }, { id: "demo-mode", @@ -176,6 +177,7 @@ export function getSetupHealth(): SetupHealthReport { mediaDirConfigured, publicMediaPathConfigured, adapterValid, + publisherValid, demoModeForced, demoModeAvailable, githubReady, diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 0787545..e24c27b 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -1,5 +1,4 @@ -import { getAstroMdxPath } from "@sourcedraft/adapter-astro-mdx"; -import { getMarkdownPath } from "@sourcedraft/adapter-markdown"; +import { getAdapterPostPath, isAdapterId } from "@sourcedraft/adapters"; import { normalizeArticle, validateArticle } from "@sourcedraft/core"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AppBar } from "./components/AppBar"; @@ -160,18 +159,22 @@ function App() { return editingPath; } - return studioConfig.adapter === "markdown" - ? getMarkdownPath(normalizedArticle, { - contentDir: studioConfig.contentDir, - }) - : getAstroMdxPath(normalizedArticle, { - contentDir: studioConfig.contentDir, - }); + const adapterId = isAdapterId(studioConfig.adapter) + ? studioConfig.adapter + : "astro-mdx"; + + return getAdapterPostPath(adapterId, normalizedArticle, { + contentDir: studioConfig.contentDir, + ...(studioConfig.adapterOptions !== undefined + ? { adapterOptions: studioConfig.adapterOptions } + : {}), + }); }, [ validation.valid, normalizedArticle, editingPath, studioConfig.adapter, + studioConfig.adapterOptions, studioConfig.contentDir, ]); @@ -496,6 +499,7 @@ function App() { article={normalizedArticle} contentDir={studioConfig.contentDir} adapter={studioConfig.adapter} + adapterOptions={studioConfig.adapterOptions} outputPath={editingPath} /> diff --git a/apps/studio/src/components/AppBar.tsx b/apps/studio/src/components/AppBar.tsx index 04cc4bc..290085b 100644 --- a/apps/studio/src/components/AppBar.tsx +++ b/apps/studio/src/components/AppBar.tsx @@ -13,7 +13,24 @@ type AppBarProps = { }; function adapterLabel(adapter: string): string { - return adapter === "markdown" ? "Markdown" : "MDX"; + switch (adapter) { + case "markdown": + return "Markdown"; + case "nextjs-mdx": + return "Next.js MDX"; + case "hugo-markdown": + return "Hugo Markdown"; + case "eleventy-jekyll-markdown": + return "Eleventy/Jekyll"; + case "docusaurus-mdx": + return "Docusaurus MDX"; + case "mkdocs-markdown": + return "MkDocs"; + case "nuxt-content-markdown": + return "Nuxt Content"; + default: + return "MDX"; + } } export function AppBar({ diff --git a/apps/studio/src/components/AstroMdxPreview.tsx b/apps/studio/src/components/AstroMdxPreview.tsx index 85173c4..d2779eb 100644 --- a/apps/studio/src/components/AstroMdxPreview.tsx +++ b/apps/studio/src/components/AstroMdxPreview.tsx @@ -1,6 +1,11 @@ import { useState } from "react"; -import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; -import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; +import { + getAdapterPostPath, + getAdapterPreviewMeta, + getAdapterPreviewNavHint, + isAdapterId, + renderAdapterOutput, +} from "@sourcedraft/adapters"; import type { Article, ValidationIssue } from "@sourcedraft/core"; type AstroMdxPreviewProps = { @@ -9,45 +14,54 @@ type AstroMdxPreviewProps = { article: Article | null; contentDir: string; adapter: string; + adapterOptions?: Record; outputPath?: string | null; }; -function previewLabel(adapter: string): string { - return adapter === "markdown" ? "Markdown preview" : "MDX preview"; -} - export function AstroMdxPreview({ valid, issues, article, contentDir, adapter, + adapterOptions, outputPath, }: AstroMdxPreviewProps) { const [collapsed, setCollapsed] = useState(false); + const adapterId = isAdapterId(adapter) ? adapter : "astro-mdx"; + const previewMeta = getAdapterPreviewMeta(adapterId); const resolvedOutputPath = valid && article ? outputPath && outputPath.length > 0 ? outputPath - : adapter === "markdown" - ? getMarkdownPath(article, { contentDir }) - : getAstroMdxPath(article, { contentDir }) + : getAdapterPostPath(adapterId, article, { + contentDir, + ...(adapterOptions !== undefined ? { adapterOptions } : {}), + }) : null; const fileOutput = valid && article - ? adapter === "markdown" - ? toMarkdown(article) - : toAstroMdx(article) + ? renderAdapterOutput(adapterId, article, adapterOptions) : null; + const navHint = + valid && article && resolvedOutputPath + ? getAdapterPreviewNavHint( + adapterId, + article, + resolvedOutputPath, + adapterOptions, + ) ?? previewMeta.navHint + : previewMeta.navHint; + return (

- {previewLabel(adapter)} + {previewMeta.label}

{valid @@ -74,6 +88,11 @@ export function AstroMdxPreview({ Output file {resolvedOutputPath}

+ {navHint && ( +

+ {navHint} +

+ )}
                 {fileOutput}
               
diff --git a/apps/studio/src/lib/studioConfig.ts b/apps/studio/src/lib/studioConfig.ts index 225a7e3..f59362d 100644 --- a/apps/studio/src/lib/studioConfig.ts +++ b/apps/studio/src/lib/studioConfig.ts @@ -5,6 +5,7 @@ export type StudioConfig = { publicMediaPath: string; defaultBranch: string; categories: string[]; + adapterOptions?: Record; githubOwner: string; githubRepo: string; demoMode?: boolean; @@ -40,6 +41,9 @@ export async function fetchStudioConfig(): Promise { data.categories?.length > 0 ? data.categories : FALLBACK_STUDIO_CONFIG.categories, + ...(data.adapterOptions !== undefined + ? { adapterOptions: data.adapterOptions } + : {}), githubOwner: data.githubOwner || "", githubRepo: data.githubRepo || "", demoMode: data.demoMode === true, diff --git a/docs/adapters.md b/docs/adapters.md index 7c8a61b..04190ae 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -1,22 +1,112 @@ # Adapters -An adapter turns a validated SourceDraft article into content for a specific platform — YAML frontmatter plus a Markdown or MDX body. +An adapter turns a validated SourceDraft article into content for a specific platform — YAML (or TOML) frontmatter plus a Markdown or MDX body. -Adapter choice is set in `sourcedraft.config.json` (`adapter` field) or overridden with `CMS_ADAPTER` in `.env`. +Adapter choice is set in `sourcedraft.config.json` (`adapter` field) or overridden with `CMS_ADAPTER` in `.env`. Built-in adapters register themselves in `@sourcedraft/adapters` through `adapterRegistry`. -## Shipped +```typescript +import { adapterRegistry } from "@sourcedraft/adapters"; -| Adapter | Package | Output | -|---------|---------|--------| -| **astro-mdx** | `@sourcedraft/adapter-astro-mdx` | Astro content collection `.mdx` file | -| **markdown** | `@sourcedraft/adapter-markdown` | Plain `.md` file with the same frontmatter fields | +adapterRegistry.render("astro-mdx", article, adapterOptions); +adapterRegistry.getPath("astro-mdx", article, { contentDir, adapterOptions }); +``` -Both adapters use the same universal article schema from `@sourcedraft/core`. Studio preview and publish pick the adapter at runtime. +Unknown adapter ids fail validation in `loadPublishEnv()` with a list of supported ids. + +## Compatibility matrix + +| Adapter key | Extension | Default `contentDir` | Best use case | Supported SEO fields | +|-------------|-----------|-------------------|---------------|----------------------| +| `astro-mdx` | `.mdx` | `src/content/blog` | Astro content collections | `metaTitle`, `metaDescription`, `canonicalUrl`, `socialImage` (when set on article) | +| `markdown` | `.md` | `src/content/blog` | Generic Markdown repos | same | +| `nextjs-mdx` | `.mdx` | `content/posts` | Next.js MDX blogs | same + `author` → `author`, `heroImage` → `coverImage` | +| `hugo-markdown` | `.md` | `content/posts` | Hugo static sites | same | +| `eleventy-jekyll-markdown` | `.md` | `src/posts` / `_posts` | Eleventy or Jekyll | same | +| `docusaurus-mdx` | `.mdx` | `blog` | Docusaurus blog plugin | same + `author` → `authors[]`, `heroImage` → `image` | +| `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). + +## Shared adapter options + +Several adapters accept `filenameConvention` in `adapterOptions`: + +| Value | Output path example | +|-------|---------------------| +| `slug` (default) | `contentDir/hello-world.md` | +| `date-slug` | `contentDir/2024-06-01-hello-world.md` | +| `index` | `contentDir/hello-world/index.md` | + +Set `contentDir` in `sourcedraft.config.json` (or `CMS_CONTENT_DIR` in `.env`). + +## Adapter-specific options + +### `hugo-markdown` + +| Option | Values | Default | +|--------|--------|---------| +| `frontmatterFormat` | `yaml`, `toml` | `yaml` | + +### `eleventy-jekyll-markdown` + +| Option | Values | Default | +|--------|--------|---------| +| `layout` | any non-empty string | `post` | +| `jekyllFilename` | `true`, `false` | `false` | +| `permalinkPrefix` | URL path prefix | `/` | +| `filenameConvention` | `slug`, `date-slug`, `index` | `slug` | + +### `docusaurus-mdx` + +| Option | Values | Default | +|--------|--------|---------| +| `filenameConvention` | `slug`, `date-slug`, `index` | `slug` | +| `hideTableOfContents` | `true`, `false` | `false` | + +Emits `hide_table_of_contents: true` when enabled. Maps `author` → `authors` (YAML array), `heroImage` → `image`. + +### `mkdocs-markdown` + +| Option | Values | Default | +|--------|--------|---------| +| `filenameConvention` | `slug`, `date-slug`, `index` | `slug` | +| `navSection` | string | — | + +Does not edit `mkdocs.yml`. Studio preview shows a **nav hint** with the path to wire into your nav manually. + +### `nuxt-content-markdown` + +| Option | Values | Default | +|--------|--------|---------| +| `filenameConvention` | `slug`, `date-slug`, `index` | `slug` | +| `navigation` | `true`, string label, or omit | article `title` | + +## Field mapping summary + +| Universal (SourceDraft) | astro-mdx / markdown | nextjs-mdx | hugo | eleventy-jekyll | docusaurus | mkdocs | nuxt-content | +|-------------------------|----------------------|------------|------|-----------------|------------|--------|--------------| +| `pubDate` | `pubDate` | `date` | `date` | `date` | — | `date` | `date` | +| `updatedDate` | `updatedDate` | `updatedDate` | `lastmod` | — | — | — | — | +| `category` | `category` | `category` | `categories[]` | `category` | — | — | `category` | +| `heroImage` | `heroImage` | `coverImage` | `images[]` | — | `image` | — | — | +| `author` | — | `author` | — | — | `authors[]` | — | — | +| `draft` | `draft` | `draft` | `draft` | `draft` | — | — | `draft` | + +## Integration examples + +| Site type | Example folder | +|-----------|----------------| +| Astro MDX | [examples/astro-blog](../examples/astro-blog/) | +| Next.js MDX | [examples/nextjs-mdx-blog](../examples/nextjs-mdx-blog/) | +| Hugo | [examples/hugo-blog](../examples/hugo-blog/) | +| Eleventy / Jekyll | [examples/eleventy-jekyll-blog](../examples/eleventy-jekyll-blog/) | +| Docusaurus | [examples/docusaurus-blog](../examples/docusaurus-blog/) | +| MkDocs | [examples/mkdocs-blog](../examples/mkdocs-blog/) | +| Nuxt Content | [examples/nuxt-content-blog](../examples/nuxt-content-blog/) | ## Future (not in repo yet) -- Next.js MDX -- Hugo Markdown (site-specific frontmatter) - WordPress REST API - Ghost API diff --git a/docs/architecture.md b/docs/architecture.md index 27545d7..70576f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -SourceDraft is a small monorepo: typed packages for schema and publishing, plus a Studio app for editing. +SourceDraft is a small monorepo: typed packages for schema, adapters, publishers, plus a Studio app for editing. ## Data flow @@ -11,8 +11,8 @@ Studio (browser) → article JSON (+ optional sourcePath when editing) Publish API (server) → validate (@sourcedraft/core) - → adapt (@sourcedraft/adapter-astro-mdx or @sourcedraft/adapter-markdown) - → publish (@sourcedraft/github-publisher) + → adapt (adapterRegistry / @sourcedraft/adapters) + → publish (publisherRegistry / @sourcedraft/publishers) → GitHub repository file in contentDir Your static site build (outside SourceDraft) → deployed site @@ -26,7 +26,7 @@ Studio (browser) Publish API (server) → validate type, size, signature → sanitize filename - → publish (@sourcedraft/github-publisher, contentBase64) + → publisher.uploadMedia (github publisher today) → GitHub repository file in mediaDir Studio → publicPath for heroImage / Markdown body @@ -38,8 +38,8 @@ Studio Studio (browser) → GET /api/posts (or ?path= for one file) Publish API (server) - → listFiles / readFile (@sourcedraft/github-publisher) - → parse frontmatter, validate (@sourcedraft/core) + → publisher.listPosts / readPost + → adapterRegistry.fromFrontmatter + validate (@sourcedraft/core) → JSON for Posts list and edit flow ``` @@ -48,29 +48,32 @@ Publish API (server) | Package | Role | |---------|------| | `@sourcedraft/core` | Article schema and validation | -| `@sourcedraft/adapter-astro-mdx` | Article → Astro MDX file content | -| `@sourcedraft/adapter-markdown` | Article → Markdown file content | -| `@sourcedraft/github-publisher` | GitHub Contents API (publish, list, read) | +| `@sourcedraft/adapter-*` | Platform-specific file output (Astro, Markdown, Next.js, Hugo, Eleventy/Jekyll) | +| `@sourcedraft/adapters` | `adapterRegistry` — built-in adapter registration and dispatch | +| `@sourcedraft/github-publisher` | Low-level GitHub Contents API client | +| `@sourcedraft/publishers` | `publisherRegistry` — typed publish/upload/list/read surface | | `@sourcedraft/config` | Load `sourcedraft.config.json` | ## Studio - **Browser** — React editor, post list, preview, media dropzone, login UI. No secrets in client code. -- **Server** — Express app: auth, config, posts, media upload, publish. +- **Server** — Express app: auth, config, posts, media upload, publish. Resolves adapter and publisher from config/env. `pnpm dev` in the repo root runs Studio UI and the publish API together. ## Configuration split -- **Commit-safe** — `sourcedraft.config.json` (paths, categories, adapter) +- **Commit-safe** — `sourcedraft.config.json` (paths, categories, `adapter`, `publisher`, options) - **Secret** — `.env` (token, repo target, admin password) -See [configuration.md](configuration.md), [github-publishing.md](github-publishing.md), and [media.md](media.md). +Env overrides: `CMS_ADAPTER`, `CMS_PUBLISHER`, `CMS_CONTENT_DIR`, `CMS_MEDIA_DIR`, `CMS_PUBLIC_MEDIA_PATH`. + +See [configuration.md](configuration.md), [adapters.md](adapters.md), [compatibility-roadmap.md](compatibility-roadmap.md), [github-publishing.md](github-publishing.md), and [media.md](media.md). ## Adapters and publishers -**Adapters** turn a validated article into platform-specific file content. Shipped: Astro MDX and Markdown. +**Adapters** turn a validated article into platform-specific file content. Registered in `adapterRegistry`. -**Publishers** send content to a target. Shipped: GitHub file commits (text and binary via base64). +**Publishers** send content to a target. Registered in `publisherRegistry`. Shipped: GitHub (`github`). -Future adapters (not implemented here): Next.js MDX, Hugo, WordPress API, Ghost API. +Future publishers (not implemented): WordPress API, Ghost API. diff --git a/docs/astro-blog-example.md b/docs/astro-blog-example.md index f0840ba..159a054 100644 --- a/docs/astro-blog-example.md +++ b/docs/astro-blog-example.md @@ -10,8 +10,15 @@ It includes: Read [examples/astro-blog/README.md](../examples/astro-blog/README.md) for what to copy into your own Astro blog repo. +Other static-site examples: + +- [examples/nextjs-mdx-blog](../examples/nextjs-mdx-blog/) — Next.js MDX +- [examples/hugo-blog](../examples/hugo-blog/) — Hugo Markdown +- [examples/eleventy-jekyll-blog](../examples/eleventy-jekyll-blog/) — Eleventy / Jekyll + Related: - [getting-started.md](getting-started.md) — install and first publish +- [adapters.md](adapters.md) — all built-in adapters - [github-publishing.md](github-publishing.md) — how commits reach GitHub - [configuration.md](configuration.md) — config file vs `.env` diff --git a/docs/compatibility-roadmap.md b/docs/compatibility-roadmap.md new file mode 100644 index 0000000..228a5db --- /dev/null +++ b/docs/compatibility-roadmap.md @@ -0,0 +1,72 @@ +# Compatibility roadmap + +Extension foundation for SourceDraft adapters and publishers. + +## Registry architecture (shipped) + +| Registry | Package | Responsibility | +|----------|---------|----------------| +| `adapterRegistry` | `@sourcedraft/adapters` | Article → file content, paths, frontmatter parsing | +| `publisherRegistry` | `@sourcedraft/publishers` | Publish articles, upload media, list/read posts | + +Built-in connectors register on package load via `registerBuiltInAdapters()` and `registerBuiltInPublishers()`. + +### Adapter interface + +Each adapter implements: + +- `render(article, adapterOptions?)` — file body +- `getPath(article, { contentDir, adapterOptions? })` — target repo path +- `fromFrontmatter(...)` — load existing posts back into the universal schema +- `previewMeta` — Studio preview label and extension + +### Publisher interface + +Each publisher implements: + +- `publishArticle({ path, content, message })` +- `uploadMedia({ repoPath, contentBase64, message })` when supported +- `listPosts({ contentDir })` and `readPost({ path })` for GitHub-backed listing + +Publishers declare `capabilities`. Unsupported methods return `{ ok: false, error: "..." }` with a clear message. + +## Configuration + +| Setting | File | Override env | +|---------|------|--------------| +| `adapter` | `sourcedraft.config.json` | `CMS_ADAPTER` | +| `publisher` | `sourcedraft.config.json` | `CMS_PUBLISHER` | +| `adapterOptions` | `sourcedraft.config.json` | — | +| `publisherOptions` | `sourcedraft.config.json` | — | +| GitHub token, owner, repo | `.env` | — | + +Defaults: `adapter: "astro-mdx"`, `publisher: "github"`. + +## Shipped connectors + +**Adapters:** `astro-mdx`, `markdown`, `nextjs-mdx`, `hugo-markdown`, `eleventy-jekyll-markdown`, `docusaurus-mdx`, `mkdocs-markdown`, `nuxt-content-markdown` + +**Publishers:** `github` (wraps `@sourcedraft/github-publisher`) + +## Studio integration points + +| Area | Uses | +|------|------| +| Preview | `adapterRegistry.render`, `getPath`, `previewMeta` | +| Publish | `adapterRegistry` + `publisherRegistry.create(...).publishArticle` | +| Media upload | `publisher.uploadMedia` | +| Post list/load | `publisher.listPosts`, `readPost` + `adapterRegistry.fromFrontmatter` | +| Setup health | Validates adapter and publisher ids | + +## Future (not implemented) + +- WordPress REST API publisher +- Ghost API publisher +- Plugin/marketplace loading +- Git Trees API for large repos + +## Risks + +- 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. diff --git a/docs/configuration.md b/docs/configuration.md index f629a4f..38eb2cb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,8 @@ SourceDraft uses two files on purpose: **`sourcedraft.config.json`** — shareable project settings - Content paths (`contentDir`, `mediaDir`, `publicMediaPath`) -- Adapter name (`astro-mdx` or `markdown`) +- Adapter name — see [adapters.md](adapters.md) compatibility matrix +- Publisher name (`github` today) - Category list for Studio - Default branch name when `GITHUB_BRANCH` is unset @@ -41,13 +42,17 @@ Example: "mediaDir": "public/images", "publicMediaPath": "/images", "defaultBranch": "main", + "publisher": "github", "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"] } ``` | Field | Purpose | |-------|---------| -| `adapter` | Output format: `astro-mdx` (`.mdx`) or `markdown` (`.md`) | +| `adapter` | Output format — see [adapters.md](adapters.md) | +| `publisher` | Publishing target — see [Publishers](#publishers) below | +| `adapterOptions` | Optional adapter-specific settings (layout, Hugo TOML, Jekyll filenames, etc.) | +| `publisherOptions` | Optional publisher-specific settings (reserved for future targets) | | `contentDir` | Directory for generated post files | | `mediaDir` | Repository path where Studio commits uploaded images | | `publicMediaPath` | Site-relative URL path inserted into `heroImage` and body Markdown | @@ -60,9 +65,27 @@ Example: |-------|---------|--------| | `astro-mdx` | `@sourcedraft/adapter-astro-mdx` | `contentDir/.mdx` | | `markdown` | `@sourcedraft/adapter-markdown` | `contentDir/.md` | +| `nextjs-mdx` | `@sourcedraft/adapter-nextjs-mdx` | `contentDir/.mdx` | +| `hugo-markdown` | `@sourcedraft/adapter-hugo-markdown` | `contentDir/.md` | +| `eleventy-jekyll-markdown` | `@sourcedraft/adapter-eleventy-jekyll-markdown` | `contentDir/.md` or `contentDir/YYYY-MM-DD-.md` | +| `docusaurus-mdx` | `@sourcedraft/adapter-docusaurus-mdx` | `contentDir/.mdx` (filename conventions via `adapterOptions`) | +| `mkdocs-markdown` | `@sourcedraft/adapter-mkdocs-markdown` | `contentDir/.md` | +| `nuxt-content-markdown` | `@sourcedraft/adapter-nuxt-content-markdown` | `contentDir/.md` | + +Full compatibility matrix and options: [adapters.md](adapters.md). Set in `sourcedraft.config.json`, or override with `CMS_ADAPTER` in `.env`. +### Publishers + +| Value | Package | Capabilities | +|-------|---------|--------------| +| `github` | `@sourcedraft/publishers` → `@sourcedraft/github-publisher` | Publish posts, upload media, list/read files via GitHub Contents API | + +Set in `sourcedraft.config.json`, or override with `CMS_PUBLISHER` in `.env`. Default: `github`. + +Studio resolves publishers through `publisherRegistry`. Unknown publisher ids return a clear configuration error before any API call. + ### `mediaDir` and `publicMediaPath` **`mediaDir`** is the folder inside your **site repository** where image uploads are committed (for example `public/images` or `src/assets/images`). @@ -101,6 +124,7 @@ CMS_CONTENT_DIR= CMS_MEDIA_DIR= CMS_PUBLIC_MEDIA_PATH= CMS_ADAPTER= +CMS_PUBLISHER= ``` | Variable | Required | Purpose | @@ -113,7 +137,8 @@ CMS_ADAPTER= | `CMS_CONTENT_DIR` | No | Overrides `contentDir` | | `CMS_MEDIA_DIR` | No | Overrides `mediaDir` | | `CMS_PUBLIC_MEDIA_PATH` | No | Overrides `publicMediaPath` | -| `CMS_ADAPTER` | No | Overrides `adapter` (`astro-mdx` or `markdown`) | +| `CMS_ADAPTER` | No | Overrides `adapter` | +| `CMS_PUBLISHER` | No | Overrides `publisher` (default `github`) | ## Precedence diff --git a/docs/project-status.md b/docs/project-status.md index 3b0073c..aad6b85 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -27,7 +27,7 @@ Early open-source MVP — usable for single-editor writing and GitHub publishing | Hosting | You run Studio locally or on your own server | | Publishers | GitHub Contents API only (no Git Trees API yet) | | Large repos | Directory listings capped at 1000 entries per folder; inline files capped at ~1 MB | -| Adapters | `astro-mdx` and `markdown` only | +| Adapters | `astro-mdx`, `markdown`, `nextjs-mdx`, `hugo-markdown`, `eleventy-jekyll-markdown`, `docusaurus-mdx`, `mkdocs-markdown`, `nuxt-content-markdown` | | Media | GitHub repo uploads only; no Cloudinary/S3/R2 | | Teams | No roles, review workflow, or multi-editor accounts | | Demo mode | Fixture-backed seed content; session edits are temporary; not a hosted demo SaaS | diff --git a/examples/docusaurus-blog/.env.example b/examples/docusaurus-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/docusaurus-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/docusaurus-blog/README.md b/examples/docusaurus-blog/README.md new file mode 100644 index 0000000..fba8e29 --- /dev/null +++ b/examples/docusaurus-blog/README.md @@ -0,0 +1,32 @@ +# Docusaurus blog integration example (folder layout) + +This is not a runnable Docusaurus site. It shows how SourceDraft publishes MDX blog posts for Docusaurus. + +## Example config + +[`sourcedraft.config.json`](sourcedraft.config.json): + +```json +{ + "adapter": "docusaurus-mdx", + "contentDir": "blog", + "adapterOptions": { + "filenameConvention": "date-slug", + "hideTableOfContents": false + } +} +``` + +A post with slug `getting-started-with-sourcedraft` is published to: + +``` +blog/2024-06-01-getting-started-with-sourcedraft.mdx +``` + +(with `filenameConvention: "date-slug"`) + +## Sample output + +See [`blog/2024-06-01-getting-started-with-sourcedraft.mdx`](blog/2024-06-01-getting-started-with-sourcedraft.mdx). + +Wire your Docusaurus `blog` plugin to the same folder. SourceDraft only writes files — it does not run Docusaurus. diff --git a/examples/docusaurus-blog/blog/2024-06-01-getting-started-with-sourcedraft.mdx b/examples/docusaurus-blog/blog/2024-06-01-getting-started-with-sourcedraft.mdx new file mode 100644 index 0000000..5ed80d7 --- /dev/null +++ b/examples/docusaurus-blog/blog/2024-06-01-getting-started-with-sourcedraft.mdx @@ -0,0 +1,17 @@ +--- +title: Getting started with SourceDraft +description: Publish Docusaurus blog posts from Studio. +slug: getting-started-with-sourcedraft +authors: + - SourceDraft +tags: + - docusaurus + - mdx +image: /img/sample-cover.png +--- + +## Write in Studio + +SourceDraft generates MDX with Docusaurus-friendly frontmatter (`authors`, `tags`, `image`). + +Your Docusaurus site build picks up the file from the `blog/` folder on the next deploy. diff --git a/examples/docusaurus-blog/sourcedraft.config.json b/examples/docusaurus-blog/sourcedraft.config.json new file mode 100644 index 0000000..052bfc2 --- /dev/null +++ b/examples/docusaurus-blog/sourcedraft.config.json @@ -0,0 +1,12 @@ +{ + "adapter": "docusaurus-mdx", + "publisher": "github", + "contentDir": "blog", + "mediaDir": "static/img", + "publicMediaPath": "/img", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "filenameConvention": "date-slug" + } +} diff --git a/examples/eleventy-jekyll-blog/.env.example b/examples/eleventy-jekyll-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/eleventy-jekyll-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/eleventy-jekyll-blog/README.md b/examples/eleventy-jekyll-blog/README.md new file mode 100644 index 0000000..3692527 --- /dev/null +++ b/examples/eleventy-jekyll-blog/README.md @@ -0,0 +1,34 @@ +# Eleventy / Jekyll integration example (folder layout) + +This is not a complete Eleventy or Jekyll site. It shows two common content layouts SourceDraft supports through `eleventy-jekyll-markdown`. + +## Eleventy-style (`src/posts`) + +[`sourcedraft.config.eleventy.json`](sourcedraft.config.eleventy.json) — slug-based filenames: + +``` +src/posts/getting-started-with-sourcedraft.md +``` + +## Jekyll-style (`_posts`) + +[`sourcedraft.config.jekyll.json`](sourcedraft.config.jekyll.json) — date-prefixed filenames: + +``` +_posts/2024-06-01-getting-started-with-sourcedraft.md +``` + +Enable Jekyll filenames with: + +```json +"adapterOptions": { + "layout": "post", + "jekyllFilename": true +} +``` + +Set `layout` to match your site's layout name. Permalink defaults to `//`; override with `permalinkPrefix` if needed. + +## Sample output + +See [`src/posts/getting-started-with-sourcedraft.md`](src/posts/getting-started-with-sourcedraft.md). diff --git a/examples/eleventy-jekyll-blog/sourcedraft.config.eleventy.json b/examples/eleventy-jekyll-blog/sourcedraft.config.eleventy.json new file mode 100644 index 0000000..ce5c659 --- /dev/null +++ b/examples/eleventy-jekyll-blog/sourcedraft.config.eleventy.json @@ -0,0 +1,11 @@ +{ + "adapter": "eleventy-jekyll-markdown", + "contentDir": "src/posts", + "mediaDir": "src/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "layout": "post" + } +} diff --git a/examples/eleventy-jekyll-blog/sourcedraft.config.jekyll.json b/examples/eleventy-jekyll-blog/sourcedraft.config.jekyll.json new file mode 100644 index 0000000..a3a063c --- /dev/null +++ b/examples/eleventy-jekyll-blog/sourcedraft.config.jekyll.json @@ -0,0 +1,12 @@ +{ + "adapter": "eleventy-jekyll-markdown", + "contentDir": "_posts", + "mediaDir": "assets/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "layout": "post", + "jekyllFilename": true + } +} diff --git a/examples/eleventy-jekyll-blog/src/posts/getting-started-with-sourcedraft.md b/examples/eleventy-jekyll-blog/src/posts/getting-started-with-sourcedraft.md new file mode 100644 index 0000000..81ad438 --- /dev/null +++ b/examples/eleventy-jekyll-blog/src/posts/getting-started-with-sourcedraft.md @@ -0,0 +1,18 @@ +--- +title: Getting started with SourceDraft +description: Publish Eleventy or Jekyll posts from Studio. +date: 2024-06-01 +permalink: /getting-started-with-sourcedraft/ +layout: post +tags: + - eleventy + - jekyll +category: Guides +draft: false +--- + +## Write in Studio + +SourceDraft writes YAML frontmatter with `layout`, `permalink`, and `date`, then commits Markdown to GitHub. + +Your static site generator builds the site as before. diff --git a/examples/hugo-blog/.env.example b/examples/hugo-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/hugo-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/hugo-blog/README.md b/examples/hugo-blog/README.md new file mode 100644 index 0000000..1b495dc --- /dev/null +++ b/examples/hugo-blog/README.md @@ -0,0 +1,33 @@ +# Hugo integration example (folder layout) + +This is not a complete Hugo site. It shows the folder structure and configuration SourceDraft expects when publishing Hugo Markdown posts. + +## Example config (YAML frontmatter) + +[`sourcedraft.config.json`](sourcedraft.config.json): + +```json +{ + "adapter": "hugo-markdown", + "contentDir": "content/posts", + "mediaDir": "static/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "frontmatterFormat": "yaml" + } +} +``` + +Set `"frontmatterFormat": "toml"` for TOML frontmatter (`+++` delimiters). + +A post with slug `getting-started-with-sourcedraft` is published to: + +``` +content/posts/getting-started-with-sourcedraft.md +``` + +## Sample output + +See [`content/posts/getting-started-with-sourcedraft.md`](content/posts/getting-started-with-sourcedraft.md). diff --git a/examples/hugo-blog/content/posts/getting-started-with-sourcedraft.md b/examples/hugo-blog/content/posts/getting-started-with-sourcedraft.md new file mode 100644 index 0000000..9558dbc --- /dev/null +++ b/examples/hugo-blog/content/posts/getting-started-with-sourcedraft.md @@ -0,0 +1,20 @@ +--- +title: Getting started with SourceDraft +description: Publish Hugo posts from Studio. +date: 2024-06-01 +draft: false +slug: getting-started-with-sourcedraft +categories: + - Guides +tags: + - hugo + - markdown +images: + - /images/sample-cover.png +--- + +## Write in Studio + +SourceDraft maps your article to Hugo frontmatter (`date`, `categories`, `images`) and commits the file to GitHub. + +Run `hugo` in your site repo as usual after publishing. diff --git a/examples/hugo-blog/sourcedraft.config.json b/examples/hugo-blog/sourcedraft.config.json new file mode 100644 index 0000000..c69aa05 --- /dev/null +++ b/examples/hugo-blog/sourcedraft.config.json @@ -0,0 +1,11 @@ +{ + "adapter": "hugo-markdown", + "contentDir": "content/posts", + "mediaDir": "static/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "frontmatterFormat": "yaml" + } +} diff --git a/examples/mkdocs-blog/.env.example b/examples/mkdocs-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/mkdocs-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/mkdocs-blog/README.md b/examples/mkdocs-blog/README.md new file mode 100644 index 0000000..3cc4a9a --- /dev/null +++ b/examples/mkdocs-blog/README.md @@ -0,0 +1,31 @@ +# MkDocs integration example (folder layout) + +This is not a runnable MkDocs site. It shows how SourceDraft publishes Markdown pages for MkDocs. + +SourceDraft does **not** edit `mkdocs.yml`. After publishing, add the new file path to your nav manually (Studio preview shows a nav hint). + +## Example config + +[`sourcedraft.config.json`](sourcedraft.config.json): + +```json +{ + "adapter": "mkdocs-markdown", + "contentDir": "docs", + "adapterOptions": { + "navSection": "Blog" + } +} +``` + +## Sample output + +See [`docs/getting-started-with-sourcedraft.md`](docs/getting-started-with-sourcedraft.md). + +Example `mkdocs.yml` nav entry (you add this yourself): + +```yaml +nav: + - Blog: + - Getting started: docs/getting-started-with-sourcedraft.md +``` diff --git a/examples/mkdocs-blog/docs/getting-started-with-sourcedraft.md b/examples/mkdocs-blog/docs/getting-started-with-sourcedraft.md new file mode 100644 index 0000000..3cfa8e6 --- /dev/null +++ b/examples/mkdocs-blog/docs/getting-started-with-sourcedraft.md @@ -0,0 +1,14 @@ +--- +title: Getting started with SourceDraft +description: Publish MkDocs pages from Studio. +date: 2024-06-01 +tags: + - mkdocs + - documentation +--- + +## Write in Studio + +SourceDraft writes Markdown with YAML frontmatter under `docs/`. + +Add the file to `mkdocs.yml` nav when you want it to appear in the site menu. diff --git a/examples/mkdocs-blog/sourcedraft.config.json b/examples/mkdocs-blog/sourcedraft.config.json new file mode 100644 index 0000000..8b248e7 --- /dev/null +++ b/examples/mkdocs-blog/sourcedraft.config.json @@ -0,0 +1,12 @@ +{ + "adapter": "mkdocs-markdown", + "publisher": "github", + "contentDir": "docs", + "mediaDir": "docs/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "navSection": "Blog" + } +} diff --git a/examples/nextjs-mdx-blog/.env.example b/examples/nextjs-mdx-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/nextjs-mdx-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/nextjs-mdx-blog/README.md b/examples/nextjs-mdx-blog/README.md new file mode 100644 index 0000000..a347797 --- /dev/null +++ b/examples/nextjs-mdx-blog/README.md @@ -0,0 +1,30 @@ +# Next.js MDX integration example (folder layout) + +This is not a complete Next.js app. It shows the folder structure and configuration SourceDraft expects when publishing to a Next.js MDX blog. + +## Example config + +[`sourcedraft.config.json`](sourcedraft.config.json): + +```json +{ + "adapter": "nextjs-mdx", + "contentDir": "content/posts", + "mediaDir": "public/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"] +} +``` + +A post with slug `getting-started-with-sourcedraft` is published to: + +``` +content/posts/getting-started-with-sourcedraft.mdx +``` + +## Sample output + +See [`content/posts/getting-started-with-sourcedraft.mdx`](content/posts/getting-started-with-sourcedraft.mdx) for YAML frontmatter (`date`, `coverImage`, SEO fields) plus MDX body — the output of `@sourcedraft/adapter-nextjs-mdx`. + +Your Next.js app must read these files from `contentDir` and render MDX as you already do. SourceDraft only writes files to GitHub. 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 new file mode 100644 index 0000000..e9ee7d3 --- /dev/null +++ b/examples/nextjs-mdx-blog/content/posts/getting-started-with-sourcedraft.mdx @@ -0,0 +1,19 @@ +--- +title: Getting started with SourceDraft +description: Publish MDX posts to a Next.js blog from Studio. +date: 2024-06-01 +draft: false +slug: getting-started-with-sourcedraft +category: Guides +tags: + - nextjs + - mdx +author: SourceDraft +coverImage: /images/sample-cover.png +--- + +## Write in Studio + +SourceDraft validates your post, previews the MDX file, and commits it to GitHub. + +Your Next.js build picks up the new file from `content/posts/` on the next deploy. diff --git a/examples/nextjs-mdx-blog/sourcedraft.config.json b/examples/nextjs-mdx-blog/sourcedraft.config.json new file mode 100644 index 0000000..ee9b242 --- /dev/null +++ b/examples/nextjs-mdx-blog/sourcedraft.config.json @@ -0,0 +1,8 @@ +{ + "adapter": "nextjs-mdx", + "contentDir": "content/posts", + "mediaDir": "public/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"] +} diff --git a/examples/nuxt-content-blog/.env.example b/examples/nuxt-content-blog/.env.example new file mode 100644 index 0000000..815beb7 --- /dev/null +++ b/examples/nuxt-content-blog/.env.example @@ -0,0 +1,5 @@ +SOURCEDRAFT_ADMIN_PASSWORD= +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BRANCH=main diff --git a/examples/nuxt-content-blog/README.md b/examples/nuxt-content-blog/README.md new file mode 100644 index 0000000..4302bf2 --- /dev/null +++ b/examples/nuxt-content-blog/README.md @@ -0,0 +1,29 @@ +# Nuxt Content integration example (folder layout) + +This is not a runnable Nuxt app. It shows how SourceDraft publishes Markdown for Nuxt Content collections. + +## Example config + +[`sourcedraft.config.json`](sourcedraft.config.json): + +```json +{ + "adapter": "nuxt-content-markdown", + "contentDir": "content/blog", + "adapterOptions": { + "navigation": true + } +} +``` + +A post with slug `getting-started-with-sourcedraft` is published to: + +``` +content/blog/getting-started-with-sourcedraft.md +``` + +## Sample output + +See [`content/blog/getting-started-with-sourcedraft.md`](content/blog/getting-started-with-sourcedraft.md). + +Match `contentDir` to your Nuxt Content source path. SourceDraft only writes files. diff --git a/examples/nuxt-content-blog/content/blog/getting-started-with-sourcedraft.md b/examples/nuxt-content-blog/content/blog/getting-started-with-sourcedraft.md new file mode 100644 index 0000000..a83d3dd --- /dev/null +++ b/examples/nuxt-content-blog/content/blog/getting-started-with-sourcedraft.md @@ -0,0 +1,17 @@ +--- +title: Getting started with SourceDraft +description: Publish Nuxt Content posts from Studio. +date: 2024-06-01 +draft: false +navigation: true +category: Guides +tags: + - nuxt + - content +--- + +## Write in Studio + +SourceDraft generates Markdown with Nuxt Content frontmatter (`navigation`, `category`, `draft`). + +Your Nuxt app reads from `content/blog/` as configured in your project. diff --git a/examples/nuxt-content-blog/sourcedraft.config.json b/examples/nuxt-content-blog/sourcedraft.config.json new file mode 100644 index 0000000..7bd4cbf --- /dev/null +++ b/examples/nuxt-content-blog/sourcedraft.config.json @@ -0,0 +1,12 @@ +{ + "adapter": "nuxt-content-markdown", + "publisher": "github", + "contentDir": "content/blog", + "mediaDir": "public/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": { + "navigation": true + } +} diff --git a/package.json b/package.json index d2b746b..f106c0a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "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/github-publisher --filter studio test", + "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/github-publisher --filter studio test", "test:e2e": "pnpm --filter studio test:e2e", "screenshots:generate": "pnpm --filter studio screenshots:generate" }, diff --git a/packages/adapter-docusaurus-mdx/package.json b/packages/adapter-docusaurus-mdx/package.json new file mode 100644 index 0000000..aab7a43 --- /dev/null +++ b/packages/adapter-docusaurus-mdx/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-docusaurus-mdx", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into Docusaurus MDX blog files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-docusaurus-mdx/src/index.ts b/packages/adapter-docusaurus-mdx/src/index.ts new file mode 100644 index 0000000..cf0f14f --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/index.ts @@ -0,0 +1,5 @@ +export { getDocusaurusMdxPath, slugFromFilename } from "./path.js"; +export type { DocusaurusMdxPathConfig } from "./path.js"; +export { resolveDocusaurusMdxOptions } from "./options.js"; +export type { DocusaurusMdxOptions, FilenameConvention } from "./options.js"; +export { docusaurusMdxFromFrontmatter, toDocusaurusMdx } from "./toDocusaurusMdx.js"; diff --git a/packages/adapter-docusaurus-mdx/src/options.ts b/packages/adapter-docusaurus-mdx/src/options.ts new file mode 100644 index 0000000..d9ea416 --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/options.ts @@ -0,0 +1,23 @@ +export type FilenameConvention = "slug" | "date-slug" | "index"; + +export type DocusaurusMdxOptions = { + filenameConvention: FilenameConvention; + hideTableOfContents: boolean; +}; + +const DEFAULT_FILENAME_CONVENTION: FilenameConvention = "slug"; + +export function resolveDocusaurusMdxOptions( + options?: Record, +): DocusaurusMdxOptions { + const rawConvention = options?.filenameConvention; + const filenameConvention = + rawConvention === "date-slug" || rawConvention === "index" + ? rawConvention + : DEFAULT_FILENAME_CONVENTION; + + return { + filenameConvention, + hideTableOfContents: options?.hideTableOfContents === true, + }; +} diff --git a/packages/adapter-docusaurus-mdx/src/path.ts b/packages/adapter-docusaurus-mdx/src/path.ts new file mode 100644 index 0000000..379ff30 --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/path.ts @@ -0,0 +1,51 @@ +import type { Article } from "@sourcedraft/core"; +import { + resolveDocusaurusMdxOptions, + type FilenameConvention, +} from "./options.js"; + +export type DocusaurusMdxPathConfig = { + contentDir: string; + extension?: string; + adapterOptions?: Record; +}; + +export function slugFromFilename(filename: string): string { + const base = filename.replace(/\/index\.(mdx|md)$/iu, "").replace(/\.(mdx|md)$/iu, ""); + const dateMatch = base.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u); + if (dateMatch?.[1]) { + return dateMatch[1]; + } + + const segments = base.split("/"); + return segments[segments.length - 1] ?? base; +} + +function buildFilename( + article: Article, + extension: string, + convention: FilenameConvention, +): string { + switch (convention) { + case "date-slug": + return `${article.pubDate}-${article.slug}.${extension}`; + case "index": + return `${article.slug}/index.${extension}`; + default: + return `${article.slug}.${extension}`; + } +} + +export function getDocusaurusMdxPath( + article: Article, + config: DocusaurusMdxPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "mdx"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + const options = resolveDocusaurusMdxOptions(config.adapterOptions); + + return `${contentDir}/${buildFilename(article, extension, options.filenameConvention)}`; +} diff --git a/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.test.ts b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.test.ts new file mode 100644 index 0000000..d8a2706 --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { getDocusaurusMdxPath } from "./path.js"; +import { toDocusaurusMdx } from "./toDocusaurusMdx.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + category: "Guides", + tags: ["alpha", "beta"], + draft: false, + heroImage: "/images/cover.png", + author: "Ada Lovelace", + metaTitle: "SEO Title", + metaDescription: "SEO description", + canonicalUrl: "https://example.com/hello-world", + socialImage: "/images/social.png", + body: "## Intro\n\nParagraph one.", +}; + +describe("toDocusaurusMdx", () => { + it("renders YAML frontmatter with Docusaurus field names", () => { + const output = toDocusaurusMdx(article, { hideTableOfContents: true }); + + assert.match(output, /^---\n/); + assert.match(output, /title: "Hello: World"\n/); + assert.match(output, /description: "A post with special: chars"\n/); + assert.match(output, /slug: hello-world\n/); + assert.match(output, /authors:\n - Ada Lovelace\n/); + assert.match(output, /tags:\n - alpha\n - beta\n/); + assert.match(output, /image: \/images\/cover\.png\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /hide_table_of_contents: true\n/); + assert.match(output, /---\n\n## Intro/); + }); + + it("omits optional fields when absent", () => { + const output = toDocusaurusMdx({ + ...article, + heroImage: undefined, + author: undefined, + metaTitle: undefined, + metaDescription: undefined, + canonicalUrl: undefined, + socialImage: undefined, + }); + + assert.doesNotMatch(output, /image:/); + assert.doesNotMatch(output, /authors:/); + assert.doesNotMatch(output, /metaTitle:/); + assert.doesNotMatch(output, /hide_table_of_contents:/); + }); + + it("generates paths with filename conventions", () => { + assert.equal( + getDocusaurusMdxPath(article, { contentDir: "blog" }), + "blog/hello-world.mdx", + ); + assert.equal( + getDocusaurusMdxPath(article, { + contentDir: "blog", + adapterOptions: { filenameConvention: "date-slug" }, + }), + "blog/2024-06-01-hello-world.mdx", + ); + assert.equal( + getDocusaurusMdxPath(article, { + contentDir: "blog", + adapterOptions: { filenameConvention: "index" }, + }), + "blog/hello-world/index.mdx", + ); + }); +}); diff --git a/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts new file mode 100644 index 0000000..70b6a6f --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/toDocusaurusMdx.ts @@ -0,0 +1,94 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { resolveDocusaurusMdxOptions } from "./options.js"; +import { + formatYamlAuthors, + formatYamlTags, + yamlScalar, +} from "./yaml.js"; + +function pushOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +function firstStringFromArray(value: unknown): string | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + + const first = value[0]; + return typeof first === "string" && first.trim().length > 0 + ? first.trim() + : undefined; +} + +export function toDocusaurusMdx( + article: Article, + options?: Record, +): string { + const resolved = resolveDocusaurusMdxOptions(options); + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `slug: ${yamlScalar(article.slug)}`, + ]; + + if (article.author !== undefined) { + frontmatter.push(...formatYamlAuthors(article.author)); + } + + 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); + + if (resolved.hideTableOfContents) { + frontmatter.push("hide_table_of_contents: true"); + } + + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +export function docusaurusMdxFromFrontmatter( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +): ArticleInput { + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? frontmatter.slug.trim() + : slugFromPath(path); + + const author = + typeof frontmatter.author === "string" + ? 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, + }; +} diff --git a/packages/adapter-docusaurus-mdx/src/yaml.ts b/packages/adapter-docusaurus-mdx/src/yaml.ts new file mode 100644 index 0000000..c06447e --- /dev/null +++ b/packages/adapter-docusaurus-mdx/src/yaml.ts @@ -0,0 +1,27 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} + +export function formatYamlAuthors(author: string): string[] { + return ["authors:", ` - ${yamlScalar(author)}`]; +} diff --git a/packages/adapter-docusaurus-mdx/tsconfig.json b/packages/adapter-docusaurus-mdx/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-docusaurus-mdx/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapter-eleventy-jekyll-markdown/package.json b/packages/adapter-eleventy-jekyll-markdown/package.json new file mode 100644 index 0000000..4b4d977 --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-eleventy-jekyll-markdown", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into Eleventy or Jekyll Markdown files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-eleventy-jekyll-markdown/src/index.ts b/packages/adapter-eleventy-jekyll-markdown/src/index.ts new file mode 100644 index 0000000..bff78d1 --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/index.ts @@ -0,0 +1,13 @@ +export { + getEleventyJekyllMarkdownPath, + slugFromFilename, +} from "./path.js"; +export type { EleventyJekyllMarkdownPathConfig } from "./path.js"; +export { + resolveEleventyJekyllOptions, +} from "./options.js"; +export type { EleventyJekyllMarkdownOptions } from "./options.js"; +export { + eleventyJekyllMarkdownFromFrontmatter, + toEleventyJekyllMarkdown, +} from "./toEleventyJekyllMarkdown.js"; diff --git a/packages/adapter-eleventy-jekyll-markdown/src/options.ts b/packages/adapter-eleventy-jekyll-markdown/src/options.ts new file mode 100644 index 0000000..38611f0 --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/options.ts @@ -0,0 +1,29 @@ +export type EleventyJekyllMarkdownOptions = { + layout: string; + jekyllFilename: boolean; + permalinkPrefix: string; +}; + +const DEFAULT_LAYOUT = "post"; +const DEFAULT_PERMALINK_PREFIX = "/"; + +export function resolveEleventyJekyllOptions( + options?: Record, +): EleventyJekyllMarkdownOptions { + const layout = + typeof options?.layout === "string" && options.layout.trim().length > 0 + ? options.layout.trim() + : DEFAULT_LAYOUT; + + const permalinkPrefix = + typeof options?.permalinkPrefix === "string" && + options.permalinkPrefix.trim().length > 0 + ? options.permalinkPrefix.trim() + : DEFAULT_PERMALINK_PREFIX; + + return { + layout, + jekyllFilename: options?.jekyllFilename === true, + permalinkPrefix, + }; +} diff --git a/packages/adapter-eleventy-jekyll-markdown/src/path.ts b/packages/adapter-eleventy-jekyll-markdown/src/path.ts new file mode 100644 index 0000000..8e92d1b --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/path.ts @@ -0,0 +1,36 @@ +import type { Article } from "@sourcedraft/core"; +import { resolveEleventyJekyllOptions } from "./options.js"; + +export type EleventyJekyllMarkdownPathConfig = { + contentDir: string; + extension?: string; + adapterOptions?: Record; +}; + +export function slugFromFilename(filename: string): string { + const withoutExtension = filename.replace(/\.(mdx|md)$/iu, ""); + const jekyllMatch = withoutExtension.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u); + if (jekyllMatch?.[1]) { + return jekyllMatch[1]; + } + + return withoutExtension; +} + +export function getEleventyJekyllMarkdownPath( + article: Article, + config: EleventyJekyllMarkdownPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "md"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + const options = resolveEleventyJekyllOptions(config.adapterOptions); + + const filename = options.jekyllFilename + ? `${article.pubDate}-${article.slug}.${extension}` + : `${article.slug}.${extension}`; + + return `${contentDir}/${filename}`; +} diff --git a/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.test.ts b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.test.ts new file mode 100644 index 0000000..d5d8eef --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { getEleventyJekyllMarkdownPath } from "./path.js"; +import { toEleventyJekyllMarkdown } from "./toEleventyJekyllMarkdown.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + category: "Guides", + tags: ["alpha", "beta"], + draft: true, + metaTitle: "SEO Title", + metaDescription: "SEO description", + canonicalUrl: "https://example.com/hello-world", + socialImage: "/images/social.png", + body: "## Intro\n\nParagraph one.", +}; + +describe("toEleventyJekyllMarkdown", () => { + it("renders YAML frontmatter with layout and permalink", () => { + const output = toEleventyJekyllMarkdown(article, { layout: "layouts/post" }); + + assert.match(output, /^---\n/); + assert.match(output, /date: 2024-06-01\n/); + assert.match(output, /permalink: \/hello-world\/\n/); + assert.match(output, /layout: layouts\/post\n/); + assert.match(output, /category: Guides\n/); + assert.match(output, /draft: true\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /socialImage: \/images\/social\.png\n/); + }); + + it("uses default layout when adapter option is missing", () => { + const output = toEleventyJekyllMarkdown(article); + assert.match(output, /layout: post\n/); + }); + + it("generates slug-based path by default", () => { + assert.equal( + getEleventyJekyllMarkdownPath(article, { contentDir: "src/posts" }), + "src/posts/hello-world.md", + ); + }); + + it("generates Jekyll date-prefixed filename when enabled", () => { + assert.equal( + getEleventyJekyllMarkdownPath(article, { + contentDir: "_posts", + adapterOptions: { jekyllFilename: true }, + }), + "_posts/2024-06-01-hello-world.md", + ); + }); +}); diff --git a/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts new file mode 100644 index 0000000..26d887f --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/toEleventyJekyllMarkdown.ts @@ -0,0 +1,79 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { resolveEleventyJekyllOptions } from "./options.js"; +import { formatYamlTags, yamlScalar } from "./yaml.js"; + +function pushOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +function buildPermalink( + slug: string, + prefix: string, +): string { + const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`; + if (normalizedPrefix === "/") { + return `/${slug}/`; + } + + return `${normalizedPrefix}${slug}/`; +} + +export function toEleventyJekyllMarkdown( + article: Article, + options?: Record, +): string { + const resolved = resolveEleventyJekyllOptions(options); + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `date: ${yamlScalar(article.pubDate)}`, + `permalink: ${yamlScalar(buildPermalink(article.slug, resolved.permalinkPrefix))}`, + `layout: ${yamlScalar(resolved.layout)}`, + ...formatYamlTags(article.tags), + `category: ${yamlScalar(article.category)}`, + `draft: ${article.draft}`, + ]; + + pushOptional(frontmatter, "metaTitle", article.metaTitle); + pushOptional(frontmatter, "metaDescription", article.metaDescription); + pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl); + pushOptional(frontmatter, "socialImage", article.socialImage); + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +export function eleventyJekyllMarkdownFromFrontmatter( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +): ArticleInput { + const filename = path.split("/").pop() ?? ""; + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? 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, + }; +} diff --git a/packages/adapter-eleventy-jekyll-markdown/src/yaml.ts b/packages/adapter-eleventy-jekyll-markdown/src/yaml.ts new file mode 100644 index 0000000..b525f0d --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/src/yaml.ts @@ -0,0 +1,23 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} diff --git a/packages/adapter-eleventy-jekyll-markdown/tsconfig.json b/packages/adapter-eleventy-jekyll-markdown/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-eleventy-jekyll-markdown/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapter-hugo-markdown/package.json b/packages/adapter-hugo-markdown/package.json new file mode 100644 index 0000000..84f91af --- /dev/null +++ b/packages/adapter-hugo-markdown/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-hugo-markdown", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into Hugo Markdown files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-hugo-markdown/src/index.ts b/packages/adapter-hugo-markdown/src/index.ts new file mode 100644 index 0000000..e5bf29f --- /dev/null +++ b/packages/adapter-hugo-markdown/src/index.ts @@ -0,0 +1,5 @@ +export { getHugoMarkdownPath } from "./path.js"; +export type { HugoMarkdownPathConfig } from "./path.js"; +export { resolveHugoOptions } from "./options.js"; +export type { HugoFrontmatterFormat, HugoMarkdownOptions } from "./options.js"; +export { hugoMarkdownFromFrontmatter, toHugoMarkdown } from "./toHugoMarkdown.js"; diff --git a/packages/adapter-hugo-markdown/src/options.ts b/packages/adapter-hugo-markdown/src/options.ts new file mode 100644 index 0000000..8eb451e --- /dev/null +++ b/packages/adapter-hugo-markdown/src/options.ts @@ -0,0 +1,16 @@ +export type HugoFrontmatterFormat = "yaml" | "toml"; + +export type HugoMarkdownOptions = { + frontmatterFormat?: HugoFrontmatterFormat; +}; + +export function resolveHugoOptions( + options?: Record, +): HugoMarkdownOptions { + const format = options?.frontmatterFormat; + if (format === "toml") { + return { frontmatterFormat: "toml" }; + } + + return { frontmatterFormat: "yaml" }; +} diff --git a/packages/adapter-hugo-markdown/src/path.ts b/packages/adapter-hugo-markdown/src/path.ts new file mode 100644 index 0000000..b2815e6 --- /dev/null +++ b/packages/adapter-hugo-markdown/src/path.ts @@ -0,0 +1,19 @@ +import type { Article } from "@sourcedraft/core"; + +export type HugoMarkdownPathConfig = { + contentDir: string; + extension?: string; +}; + +export function getHugoMarkdownPath( + article: Article, + config: HugoMarkdownPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "md"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + + return `${contentDir}/${article.slug}.${extension}`; +} diff --git a/packages/adapter-hugo-markdown/src/toHugoMarkdown.test.ts b/packages/adapter-hugo-markdown/src/toHugoMarkdown.test.ts new file mode 100644 index 0000000..48c9c59 --- /dev/null +++ b/packages/adapter-hugo-markdown/src/toHugoMarkdown.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { getHugoMarkdownPath } from "./path.js"; +import { toHugoMarkdown } from "./toHugoMarkdown.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + updatedDate: "2024-06-02", + category: "Guides", + tags: ["alpha", "beta"], + draft: true, + heroImage: "/images/cover.png", + metaTitle: "SEO Title", + metaDescription: "SEO description", + canonicalUrl: "https://example.com/hello-world", + socialImage: "/images/social.png", + body: "## Intro\n\nParagraph one.", +}; + +describe("toHugoMarkdown", () => { + it("renders YAML frontmatter with Hugo field names", () => { + const output = toHugoMarkdown(article); + + assert.match(output, /^---\n/); + assert.match(output, /date: 2024-06-01\n/); + assert.match(output, /lastmod: 2024-06-02\n/); + assert.match(output, /draft: true\n/); + assert.match(output, /slug: hello-world\n/); + assert.match(output, /categories:\n - Guides\n/); + assert.match(output, /tags:\n - alpha\n - beta\n/); + assert.match(output, /images:\n - \/images\/cover\.png\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /canonicalUrl: https:\/\/example\.com\/hello-world\n/); + }); + + it("renders TOML frontmatter when configured", () => { + const output = toHugoMarkdown(article, { frontmatterFormat: "toml" }); + + assert.match(output, /^\+\+\+\n/); + assert.match(output, /title = "Hello: World"\n/); + assert.match(output, /date = "2024-06-01"\n/); + assert.match(output, /categories = \["Guides"\]\n/); + assert.match(output, /tags = \["alpha", "beta"\]\n/); + assert.match(output, /images = \["\/images\/cover\.png"\]\n/); + assert.match(output, /\+\+\+\n\n## Intro/); + }); + + it("omits draft false handling and optional fields", () => { + const output = toHugoMarkdown({ + ...article, + draft: false, + updatedDate: undefined, + heroImage: undefined, + metaTitle: undefined, + }); + + assert.match(output, /draft: false\n/); + assert.doesNotMatch(output, /lastmod:/); + assert.doesNotMatch(output, /images:/); + }); + + it("generates .md path under contentDir", () => { + assert.equal( + getHugoMarkdownPath(article, { contentDir: "content/posts" }), + "content/posts/hello-world.md", + ); + }); +}); diff --git a/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts b/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts new file mode 100644 index 0000000..84e6cc8 --- /dev/null +++ b/packages/adapter-hugo-markdown/src/toHugoMarkdown.ts @@ -0,0 +1,148 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { resolveHugoOptions } from "./options.js"; +import { formatTomlArray, tomlString } from "./toml.js"; +import { + formatYamlCategories, + formatYamlImages, + formatYamlTags, + yamlScalar, +} from "./yaml.js"; + +function pushYamlOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +function renderYamlFrontmatter(article: Article): string[] { + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `date: ${yamlScalar(article.pubDate)}`, + ]; + + pushYamlOptional(frontmatter, "lastmod", article.updatedDate); + frontmatter.push(`draft: ${article.draft}`); + pushYamlOptional(frontmatter, "slug", article.slug); + frontmatter.push(...formatYamlCategories(article.category)); + frontmatter.push(...formatYamlTags(article.tags)); + + if (article.heroImage !== undefined) { + 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); + frontmatter.push("---"); + + return frontmatter; +} + +function renderTomlFrontmatter(article: Article): string[] { + const lines: string[] = ["+++", `title = ${tomlString(article.title)}`]; + + lines.push(`description = ${tomlString(article.description)}`); + lines.push(`date = ${tomlString(article.pubDate)}`); + + if (article.updatedDate !== undefined) { + lines.push(`lastmod = ${tomlString(article.updatedDate)}`); + } + + lines.push(`draft = ${article.draft}`); + lines.push(`slug = ${tomlString(article.slug)}`); + lines.push(formatTomlArray("categories", [article.category])); + lines.push(formatTomlArray("tags", article.tags)); + + if (article.heroImage !== undefined) { + 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)}`); + } + + if (article.canonicalUrl !== undefined) { + lines.push(`canonicalUrl = ${tomlString(article.canonicalUrl)}`); + } + + if (article.socialImage !== undefined) { + lines.push(`socialImage = ${tomlString(article.socialImage)}`); + } + + lines.push("+++"); + return lines; +} + +export function toHugoMarkdown( + article: Article, + options?: Record, +): string { + const resolved = resolveHugoOptions(options); + const frontmatter = + resolved.frontmatterFormat === "toml" + ? renderTomlFrontmatter(article) + : renderYamlFrontmatter(article); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +function firstStringFromArray(value: unknown): string | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + + const first = value[0]; + return typeof first === "string" && first.trim().length > 0 + ? first.trim() + : undefined; +} + +export function hugoMarkdownFromFrontmatter( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +): ArticleInput { + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? frontmatter.slug.trim() + : slugFromPath(path); + + const category = + typeof frontmatter.category === "string" + ? frontmatter.category + : firstStringFromArray(frontmatter.categories); + + const heroImage = + typeof frontmatter.heroImage === "string" + ? 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, + }; +} diff --git a/packages/adapter-hugo-markdown/src/toml.ts b/packages/adapter-hugo-markdown/src/toml.ts new file mode 100644 index 0000000..5ac166f --- /dev/null +++ b/packages/adapter-hugo-markdown/src/toml.ts @@ -0,0 +1,17 @@ +export function tomlString(value: string): string { + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatTomlArray(key: string, values: string[]): string { + if (values.length === 0) { + return `${key} = []`; + } + + const items = values.map((value) => tomlString(value)).join(", "); + return `${key} = [${items}]`; +} diff --git a/packages/adapter-hugo-markdown/src/yaml.ts b/packages/adapter-hugo-markdown/src/yaml.ts new file mode 100644 index 0000000..b1e5a46 --- /dev/null +++ b/packages/adapter-hugo-markdown/src/yaml.ts @@ -0,0 +1,31 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} + +export function formatYamlCategories(category: string): string[] { + return ["categories:", ` - ${yamlScalar(category)}`]; +} + +export function formatYamlImages(image: string): string[] { + return ["images:", ` - ${yamlScalar(image)}`]; +} diff --git a/packages/adapter-hugo-markdown/tsconfig.json b/packages/adapter-hugo-markdown/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-hugo-markdown/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapter-mkdocs-markdown/package.json b/packages/adapter-mkdocs-markdown/package.json new file mode 100644 index 0000000..5d9dae4 --- /dev/null +++ b/packages/adapter-mkdocs-markdown/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-mkdocs-markdown", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into MkDocs Markdown files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-mkdocs-markdown/src/index.ts b/packages/adapter-mkdocs-markdown/src/index.ts new file mode 100644 index 0000000..16e534b --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/index.ts @@ -0,0 +1,8 @@ +export { getMkdocsMarkdownPath, slugFromFilename } from "./path.js"; +export type { MkdocsMarkdownPathConfig } from "./path.js"; +export { + buildMkdocsNavHint, + resolveMkdocsMarkdownOptions, +} from "./options.js"; +export type { MkdocsMarkdownOptions, FilenameConvention } from "./options.js"; +export { mkdocsMarkdownFromFrontmatter, toMkdocsMarkdown } from "./toMkdocsMarkdown.js"; diff --git a/packages/adapter-mkdocs-markdown/src/options.ts b/packages/adapter-mkdocs-markdown/src/options.ts new file mode 100644 index 0000000..1f2a7bb --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/options.ts @@ -0,0 +1,41 @@ +export type FilenameConvention = "slug" | "date-slug" | "index"; + +export type MkdocsMarkdownOptions = { + filenameConvention: FilenameConvention; + navSection: string | undefined; +}; + +const DEFAULT_FILENAME_CONVENTION: FilenameConvention = "slug"; + +export function resolveMkdocsMarkdownOptions( + options?: Record, +): MkdocsMarkdownOptions { + const rawConvention = options?.filenameConvention; + const filenameConvention = + rawConvention === "date-slug" || rawConvention === "index" + ? rawConvention + : DEFAULT_FILENAME_CONVENTION; + + const navSection = + typeof options?.navSection === "string" && options.navSection.trim().length > 0 + ? options.navSection.trim() + : undefined; + + return { + filenameConvention, + navSection, + }; +} + +export function buildMkdocsNavHint( + title: string, + repoPath: string, + navSection: string | undefined, +): string { + const entry = `${title}: ${repoPath}`; + if (navSection !== undefined) { + return `Add under "${navSection}" in mkdocs.yml nav: ${entry}`; + } + + return `Add to mkdocs.yml nav manually: ${entry}`; +} diff --git a/packages/adapter-mkdocs-markdown/src/path.ts b/packages/adapter-mkdocs-markdown/src/path.ts new file mode 100644 index 0000000..5aca30e --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/path.ts @@ -0,0 +1,51 @@ +import type { Article } from "@sourcedraft/core"; +import { + resolveMkdocsMarkdownOptions, + type FilenameConvention, +} from "./options.js"; + +export type MkdocsMarkdownPathConfig = { + contentDir: string; + extension?: string; + adapterOptions?: Record; +}; + +export function slugFromFilename(filename: string): string { + const base = filename.replace(/\/index\.(mdx|md)$/iu, "").replace(/\.(mdx|md)$/iu, ""); + const dateMatch = base.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u); + if (dateMatch?.[1]) { + return dateMatch[1]; + } + + const segments = base.split("/"); + return segments[segments.length - 1] ?? base; +} + +function buildFilename( + article: Article, + extension: string, + convention: FilenameConvention, +): string { + switch (convention) { + case "date-slug": + return `${article.pubDate}-${article.slug}.${extension}`; + case "index": + return `${article.slug}/index.${extension}`; + default: + return `${article.slug}.${extension}`; + } +} + +export function getMkdocsMarkdownPath( + article: Article, + config: MkdocsMarkdownPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "md"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + const options = resolveMkdocsMarkdownOptions(config.adapterOptions); + + return `${contentDir}/${buildFilename(article, extension, options.filenameConvention)}`; +} diff --git a/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.test.ts b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.test.ts new file mode 100644 index 0000000..205a5f0 --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { buildMkdocsNavHint } from "./options.js"; +import { getMkdocsMarkdownPath } from "./path.js"; +import { toMkdocsMarkdown } from "./toMkdocsMarkdown.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + category: "Guides", + tags: ["alpha", "beta"], + draft: false, + body: "## Intro\n\nParagraph one.", + metaTitle: "SEO Title", + canonicalUrl: "https://example.com/hello-world", +}; + +describe("toMkdocsMarkdown", () => { + it("renders YAML frontmatter with MkDocs field names", () => { + const output = toMkdocsMarkdown(article); + + assert.match(output, /^---\n/); + assert.match(output, /title: "Hello: World"\n/); + assert.match(output, /description: "A post with special: chars"\n/); + assert.match(output, /date: 2024-06-01\n/); + assert.match(output, /tags:\n - alpha\n - beta\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /canonicalUrl: https:\/\/example\.com\/hello-world\n/); + assert.doesNotMatch(output, /draft:/); + }); + + it("omits SEO fields when absent", () => { + const output = toMkdocsMarkdown({ + ...article, + metaTitle: undefined, + canonicalUrl: undefined, + }); + + assert.doesNotMatch(output, /metaTitle:/); + assert.doesNotMatch(output, /canonicalUrl:/); + }); + + it("generates paths under docs with filename conventions", () => { + assert.equal( + getMkdocsMarkdownPath(article, { contentDir: "docs" }), + "docs/hello-world.md", + ); + assert.equal( + getMkdocsMarkdownPath(article, { + contentDir: "docs", + adapterOptions: { filenameConvention: "date-slug" }, + }), + "docs/2024-06-01-hello-world.md", + ); + }); + + it("builds nav hints for preview metadata", () => { + assert.match( + buildMkdocsNavHint("Hello", "docs/hello-world.md", undefined), + /mkdocs\.yml nav manually/, + ); + assert.match( + buildMkdocsNavHint("Hello", "docs/hello-world.md", "Blog"), + /under "Blog"/, + ); + }); +}); diff --git a/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts new file mode 100644 index 0000000..89a9d96 --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/toMkdocsMarkdown.ts @@ -0,0 +1,52 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { formatYamlTags, yamlScalar } from "./yaml.js"; + +function pushOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +export function toMkdocsMarkdown(article: Article): string { + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `date: ${yamlScalar(article.pubDate)}`, + ...formatYamlTags(article.tags), + ]; + + pushOptional(frontmatter, "metaTitle", article.metaTitle); + pushOptional(frontmatter, "metaDescription", article.metaDescription); + pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl); + pushOptional(frontmatter, "socialImage", article.socialImage); + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +export function mkdocsMarkdownFromFrontmatter( + path: string, + frontmatter: Record, + 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, + }; +} diff --git a/packages/adapter-mkdocs-markdown/src/yaml.ts b/packages/adapter-mkdocs-markdown/src/yaml.ts new file mode 100644 index 0000000..b525f0d --- /dev/null +++ b/packages/adapter-mkdocs-markdown/src/yaml.ts @@ -0,0 +1,23 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} diff --git a/packages/adapter-mkdocs-markdown/tsconfig.json b/packages/adapter-mkdocs-markdown/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-mkdocs-markdown/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapter-nextjs-mdx/package.json b/packages/adapter-nextjs-mdx/package.json new file mode 100644 index 0000000..c96d7c9 --- /dev/null +++ b/packages/adapter-nextjs-mdx/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-nextjs-mdx", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into Next.js MDX files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-nextjs-mdx/src/index.ts b/packages/adapter-nextjs-mdx/src/index.ts new file mode 100644 index 0000000..9ef1d4e --- /dev/null +++ b/packages/adapter-nextjs-mdx/src/index.ts @@ -0,0 +1,3 @@ +export { getNextjsMdxPath } from "./path.js"; +export type { NextjsMdxPathConfig } from "./path.js"; +export { nextjsMdxFromFrontmatter, toNextjsMdx } from "./toNextjsMdx.js"; diff --git a/packages/adapter-nextjs-mdx/src/path.ts b/packages/adapter-nextjs-mdx/src/path.ts new file mode 100644 index 0000000..136d1c3 --- /dev/null +++ b/packages/adapter-nextjs-mdx/src/path.ts @@ -0,0 +1,19 @@ +import type { Article } from "@sourcedraft/core"; + +export type NextjsMdxPathConfig = { + contentDir: string; + extension?: string; +}; + +export function getNextjsMdxPath( + article: Article, + config: NextjsMdxPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "mdx"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + + return `${contentDir}/${article.slug}.${extension}`; +} diff --git a/packages/adapter-nextjs-mdx/src/toNextjsMdx.test.ts b/packages/adapter-nextjs-mdx/src/toNextjsMdx.test.ts new file mode 100644 index 0000000..c07fd68 --- /dev/null +++ b/packages/adapter-nextjs-mdx/src/toNextjsMdx.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { getNextjsMdxPath } from "./path.js"; +import { toNextjsMdx } from "./toNextjsMdx.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + updatedDate: "2024-06-02", + category: "Guides", + tags: ["alpha", "beta"], + draft: true, + heroImage: "/images/cover.png", + author: "Ada Lovelace", + metaTitle: "SEO Title", + metaDescription: "SEO description", + canonicalUrl: "https://example.com/hello-world", + socialImage: "/images/social.png", + body: "## Intro\n\nParagraph one.", +}; + +describe("toNextjsMdx", () => { + it("renders YAML frontmatter with Next.js field names", () => { + const output = toNextjsMdx(article); + + assert.match(output, /^---\n/); + assert.match(output, /title: "Hello: World"\n/); + assert.match(output, /description: "A post with special: chars"\n/); + assert.match(output, /date: 2024-06-01\n/); + assert.match(output, /updatedDate: 2024-06-02\n/); + assert.match(output, /draft: true\n/); + assert.match(output, /slug: hello-world\n/); + assert.match(output, /category: Guides\n/); + assert.match(output, /tags:\n - alpha\n - beta\n/); + assert.match(output, /author: Ada Lovelace\n/); + assert.match(output, /coverImage: \/images\/cover\.png\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /metaDescription: SEO description\n/); + assert.match(output, /canonicalUrl: https:\/\/example\.com\/hello-world\n/); + assert.match(output, /socialImage: \/images\/social\.png\n/); + assert.match(output, /---\n\n## Intro\n\nParagraph one\.$/); + }); + + it("omits optional fields when absent", () => { + const output = toNextjsMdx({ + ...article, + updatedDate: undefined, + heroImage: undefined, + author: undefined, + metaTitle: undefined, + metaDescription: undefined, + canonicalUrl: undefined, + socialImage: undefined, + }); + + assert.doesNotMatch(output, /updatedDate:/); + assert.doesNotMatch(output, /coverImage:/); + assert.doesNotMatch(output, /author:/); + assert.doesNotMatch(output, /metaTitle:/); + }); + + it("generates .mdx path under contentDir", () => { + assert.equal( + getNextjsMdxPath(article, { contentDir: "content/posts" }), + "content/posts/hello-world.mdx", + ); + }); +}); diff --git a/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts b/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts new file mode 100644 index 0000000..0dd9db1 --- /dev/null +++ b/packages/adapter-nextjs-mdx/src/toNextjsMdx.ts @@ -0,0 +1,68 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { formatYamlTags, yamlScalar } from "./yaml.js"; + +function pushOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +export function toNextjsMdx(article: Article): string { + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `date: ${yamlScalar(article.pubDate)}`, + ]; + + pushOptional(frontmatter, "updatedDate", article.updatedDate); + frontmatter.push(`draft: ${article.draft}`); + 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); + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +export function nextjsMdxFromFrontmatter( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +): ArticleInput { + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? 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; +} diff --git a/packages/adapter-nextjs-mdx/src/yaml.ts b/packages/adapter-nextjs-mdx/src/yaml.ts new file mode 100644 index 0000000..b525f0d --- /dev/null +++ b/packages/adapter-nextjs-mdx/src/yaml.ts @@ -0,0 +1,23 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} diff --git a/packages/adapter-nextjs-mdx/tsconfig.json b/packages/adapter-nextjs-mdx/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-nextjs-mdx/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapter-nuxt-content-markdown/package.json b/packages/adapter-nuxt-content-markdown/package.json new file mode 100644 index 0000000..0648c6c --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/adapter-nuxt-content-markdown", + "version": "0.0.1", + "private": true, + "description": "Convert SourceDraft articles into Nuxt Content Markdown files.", + "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/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapter-nuxt-content-markdown/src/index.ts b/packages/adapter-nuxt-content-markdown/src/index.ts new file mode 100644 index 0000000..14a2d90 --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/index.ts @@ -0,0 +1,8 @@ +export { getNuxtContentMarkdownPath, slugFromFilename } from "./path.js"; +export type { NuxtContentMarkdownPathConfig } from "./path.js"; +export { resolveNuxtContentMarkdownOptions } from "./options.js"; +export type { NuxtContentMarkdownOptions, FilenameConvention } from "./options.js"; +export { + nuxtContentMarkdownFromFrontmatter, + toNuxtContentMarkdown, +} from "./toNuxtContentMarkdown.js"; diff --git a/packages/adapter-nuxt-content-markdown/src/options.ts b/packages/adapter-nuxt-content-markdown/src/options.ts new file mode 100644 index 0000000..7e7f97f --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/options.ts @@ -0,0 +1,33 @@ +export type FilenameConvention = "slug" | "date-slug" | "index"; + +export type NuxtContentMarkdownOptions = { + filenameConvention: FilenameConvention; + navigation: string | boolean | undefined; +}; + +const DEFAULT_FILENAME_CONVENTION: FilenameConvention = "slug"; + +export function resolveNuxtContentMarkdownOptions( + options?: Record, +): NuxtContentMarkdownOptions { + const rawConvention = options?.filenameConvention; + const filenameConvention = + rawConvention === "date-slug" || rawConvention === "index" + ? rawConvention + : DEFAULT_FILENAME_CONVENTION; + + let navigation: string | boolean | undefined; + if (typeof options?.navigation === "boolean") { + navigation = options.navigation; + } else if ( + typeof options?.navigation === "string" && + options.navigation.trim().length > 0 + ) { + navigation = options.navigation.trim(); + } + + return { + filenameConvention, + navigation, + }; +} diff --git a/packages/adapter-nuxt-content-markdown/src/path.ts b/packages/adapter-nuxt-content-markdown/src/path.ts new file mode 100644 index 0000000..c988c9a --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/path.ts @@ -0,0 +1,51 @@ +import type { Article } from "@sourcedraft/core"; +import { + resolveNuxtContentMarkdownOptions, + type FilenameConvention, +} from "./options.js"; + +export type NuxtContentMarkdownPathConfig = { + contentDir: string; + extension?: string; + adapterOptions?: Record; +}; + +export function slugFromFilename(filename: string): string { + const base = filename.replace(/\/index\.(mdx|md)$/iu, "").replace(/\.(mdx|md)$/iu, ""); + const dateMatch = base.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u); + if (dateMatch?.[1]) { + return dateMatch[1]; + } + + const segments = base.split("/"); + return segments[segments.length - 1] ?? base; +} + +function buildFilename( + article: Article, + extension: string, + convention: FilenameConvention, +): string { + switch (convention) { + case "date-slug": + return `${article.pubDate}-${article.slug}.${extension}`; + case "index": + return `${article.slug}/index.${extension}`; + default: + return `${article.slug}.${extension}`; + } +} + +export function getNuxtContentMarkdownPath( + article: Article, + config: NuxtContentMarkdownPathConfig, +): string { + const contentDir = config.contentDir.replace(/\/+$/u, ""); + const rawExtension = config.extension ?? "md"; + const extension = rawExtension.startsWith(".") + ? rawExtension.slice(1) + : rawExtension; + const options = resolveNuxtContentMarkdownOptions(config.adapterOptions); + + return `${contentDir}/${buildFilename(article, extension, options.filenameConvention)}`; +} diff --git a/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.test.ts b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.test.ts new file mode 100644 index 0000000..9e4fbb2 --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { getNuxtContentMarkdownPath } from "./path.js"; +import { toNuxtContentMarkdown } from "./toNuxtContentMarkdown.js"; + +const article: Article = { + title: "Hello: World", + slug: "hello-world", + description: "A post with special: chars", + pubDate: "2024-06-01", + category: "Guides", + tags: ["alpha", "beta"], + draft: true, + body: "## Intro\n\nParagraph one.", + metaTitle: "SEO Title", + socialImage: "/images/social.png", +}; + +describe("toNuxtContentMarkdown", () => { + it("renders YAML frontmatter with Nuxt Content field names", () => { + const output = toNuxtContentMarkdown(article, { navigation: true }); + + assert.match(output, /^---\n/); + assert.match(output, /title: "Hello: World"\n/); + assert.match(output, /date: 2024-06-01\n/); + assert.match(output, /draft: true\n/); + assert.match(output, /navigation: true\n/); + assert.match(output, /category: Guides\n/); + assert.match(output, /metaTitle: SEO Title\n/); + assert.match(output, /socialImage: \/images\/social\.png\n/); + }); + + it("uses custom navigation label when configured", () => { + const output = toNuxtContentMarkdown(article, { + navigation: "Sidebar label", + }); + assert.match(output, /navigation: Sidebar label\n/); + }); + + it("defaults navigation to article title", () => { + const output = toNuxtContentMarkdown(article); + assert.match(output, /navigation: "Hello: World"\n/); + }); + + it("omits SEO fields when absent", () => { + const output = toNuxtContentMarkdown({ + ...article, + metaTitle: undefined, + socialImage: undefined, + }); + assert.doesNotMatch(output, /metaTitle:/); + assert.doesNotMatch(output, /socialImage:/); + }); + + it("generates paths under content/blog", () => { + assert.equal( + getNuxtContentMarkdownPath(article, { contentDir: "content/blog" }), + "content/blog/hello-world.md", + ); + assert.equal( + getNuxtContentMarkdownPath(article, { + contentDir: "content/blog", + adapterOptions: { filenameConvention: "index" }, + }), + "content/blog/hello-world/index.md", + ); + }); +}); diff --git a/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts new file mode 100644 index 0000000..9d0ebf7 --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/toNuxtContentMarkdown.ts @@ -0,0 +1,75 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import { resolveNuxtContentMarkdownOptions } from "./options.js"; +import { formatYamlTags, yamlScalar } from "./yaml.js"; + +function pushOptional( + frontmatter: string[], + key: string, + value: string | undefined, +): void { + if (value !== undefined) { + frontmatter.push(`${key}: ${yamlScalar(value)}`); + } +} + +function formatNavigation( + article: Article, + navigation: string | boolean | undefined, +): string | undefined { + if (navigation === true) { + return "navigation: true"; + } + + if (typeof navigation === "string") { + return `navigation: ${yamlScalar(navigation)}`; + } + + return `navigation: ${yamlScalar(article.title)}`; +} + +export function toNuxtContentMarkdown( + article: Article, + options?: Record, +): string { + const resolved = resolveNuxtContentMarkdownOptions(options); + const frontmatter: string[] = [ + "---", + `title: ${yamlScalar(article.title)}`, + `description: ${yamlScalar(article.description)}`, + `date: ${yamlScalar(article.pubDate)}`, + `draft: ${article.draft}`, + formatNavigation(article, resolved.navigation) as string, + `category: ${yamlScalar(article.category)}`, + ...formatYamlTags(article.tags), + ]; + + pushOptional(frontmatter, "metaTitle", article.metaTitle); + pushOptional(frontmatter, "metaDescription", article.metaDescription); + pushOptional(frontmatter, "canonicalUrl", article.canonicalUrl); + pushOptional(frontmatter, "socialImage", article.socialImage); + frontmatter.push("---"); + + return `${frontmatter.join("\n")}\n\n${article.body}`; +} + +export function nuxtContentMarkdownFromFrontmatter( + path: string, + frontmatter: Record, + 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, + }; +} diff --git a/packages/adapter-nuxt-content-markdown/src/yaml.ts b/packages/adapter-nuxt-content-markdown/src/yaml.ts new file mode 100644 index 0000000..b525f0d --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/src/yaml.ts @@ -0,0 +1,23 @@ +const YAML_NEEDS_QUOTES = + /^$|^[\s#>|@[`%&*!?{[\]},]|:\s|[\n\r]|^['"]|['"]$|^(true|false|null|yes|no|on|off)$/iu; + +export function yamlScalar(value: string): string { + if (!YAML_NEEDS_QUOTES.test(value)) { + return value; + } + + return `"${value + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t")}"`; +} + +export function formatYamlTags(tags: string[]): string[] { + if (tags.length === 0) { + return ["tags: []"]; + } + + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} diff --git a/packages/adapter-nuxt-content-markdown/tsconfig.json b/packages/adapter-nuxt-content-markdown/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapter-nuxt-content-markdown/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/adapters/package.json b/packages/adapters/package.json new file mode 100644 index 0000000..e2b7512 --- /dev/null +++ b/packages/adapters/package.json @@ -0,0 +1,36 @@ +{ + "name": "@sourcedraft/adapters", + "version": "0.0.1", + "private": true, + "description": "Built-in adapter registry 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/adapter-astro-mdx": "workspace:*", + "@sourcedraft/adapter-docusaurus-mdx": "workspace:*", + "@sourcedraft/adapter-eleventy-jekyll-markdown": "workspace:*", + "@sourcedraft/adapter-hugo-markdown": "workspace:*", + "@sourcedraft/adapter-markdown": "workspace:*", + "@sourcedraft/adapter-mkdocs-markdown": "workspace:*", + "@sourcedraft/adapter-nextjs-mdx": "workspace:*", + "@sourcedraft/adapter-nuxt-content-markdown": "workspace:*", + "@sourcedraft/core": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/adapters/src/adapterRegistry.ts b/packages/adapters/src/adapterRegistry.ts new file mode 100644 index 0000000..f81fa89 --- /dev/null +++ b/packages/adapters/src/adapterRegistry.ts @@ -0,0 +1,97 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; +import type { + Adapter, + AdapterId, + AdapterPathConfig, + AdapterPreviewMeta, +} from "./types.js"; + +const adapters = new Map(); + +export function registerAdapter(adapter: Adapter): void { + adapters.set(adapter.id, adapter); +} + +export function listAdapterIds(): AdapterId[] { + return [...adapters.keys()]; +} + +export function isAdapterId(value: string): value is AdapterId { + return adapters.has(value as AdapterId); +} + +export function getAdapter(adapterId: AdapterId): Adapter { + const adapter = adapters.get(adapterId); + if (adapter === undefined) { + throw new Error(`Adapter "${adapterId}" is not registered.`); + } + + return adapter; +} + +export function getAdapterPreviewMeta(adapterId: AdapterId): AdapterPreviewMeta { + return getAdapter(adapterId).previewMeta; +} + +export function getAdapterPreviewNavHint( + adapterId: AdapterId, + article: Article, + path: string, + adapterOptions?: Record, +): string | undefined { + const adapter = getAdapter(adapterId); + return adapter.previewNavHint?.(article, path, adapterOptions); +} + +export function renderAdapterOutput( + adapterId: AdapterId, + article: Article, + adapterOptions?: Record, +): string { + return getAdapter(adapterId).render(article, adapterOptions); +} + +export function getAdapterPostPath( + adapterId: AdapterId, + article: Article, + config: AdapterPathConfig, +): string { + return getAdapter(adapterId).getPath(article, config); +} + +export function frontmatterToArticleInputWithSlug( + adapterId: AdapterId, + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +): ArticleInput { + return getAdapter(adapterId).fromFrontmatter( + path, + frontmatter, + body, + slugFromPath, + ); +} + +export function supportedAdapterSummary(): string { + return listAdapterIds().join(", "); +} + +/** @deprecated Use `getAdapter` */ +export function getAdapterDefinition(adapterId: AdapterId): Adapter { + return getAdapter(adapterId); +} + +export const adapterRegistry = { + register: registerAdapter, + listIds: listAdapterIds, + isKnown: isAdapterId, + get: getAdapter, + previewMeta: getAdapterPreviewMeta, + previewNavHint: getAdapterPreviewNavHint, + render: renderAdapterOutput, + getPath: getAdapterPostPath, + fromFrontmatterWithSlug: frontmatterToArticleInputWithSlug, + supportedSummary: supportedAdapterSummary, +}; diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts new file mode 100644 index 0000000..783cef8 --- /dev/null +++ b/packages/adapters/src/index.ts @@ -0,0 +1,24 @@ +export { + adapterRegistry, + frontmatterToArticleInput, + frontmatterToArticleInputWithSlug, + getAdapter, + getAdapterDefinition, + getAdapterPostPath, + getAdapterPreviewMeta, + getAdapterPreviewNavHint, + isAdapterId, + listAdapterIds, + registerAdapter, + renderAdapterOutput, + supportedAdapterSummary, +} from "./registry.js"; + +export { + ADAPTER_IDS, + type Adapter, + type AdapterDefinition, + type AdapterId, + type AdapterPathConfig, + type AdapterPreviewMeta, +} from "./types.js"; diff --git a/packages/adapters/src/registerBuiltInAdapters.ts b/packages/adapters/src/registerBuiltInAdapters.ts new file mode 100644 index 0000000..c8bd38f --- /dev/null +++ b/packages/adapters/src/registerBuiltInAdapters.ts @@ -0,0 +1,165 @@ +import { getAstroMdxPath, toAstroMdx } from "@sourcedraft/adapter-astro-mdx"; +import { + docusaurusMdxFromFrontmatter, + getDocusaurusMdxPath, + toDocusaurusMdx, +} from "@sourcedraft/adapter-docusaurus-mdx"; +import { + eleventyJekyllMarkdownFromFrontmatter, + getEleventyJekyllMarkdownPath, + toEleventyJekyllMarkdown, +} from "@sourcedraft/adapter-eleventy-jekyll-markdown"; +import { + getHugoMarkdownPath, + hugoMarkdownFromFrontmatter, + toHugoMarkdown, +} from "@sourcedraft/adapter-hugo-markdown"; +import { getMarkdownPath, toMarkdown } from "@sourcedraft/adapter-markdown"; +import { + buildMkdocsNavHint, + getMkdocsMarkdownPath, + mkdocsMarkdownFromFrontmatter, + toMkdocsMarkdown, +} from "@sourcedraft/adapter-mkdocs-markdown"; +import { + getNextjsMdxPath, + nextjsMdxFromFrontmatter, + toNextjsMdx, +} from "@sourcedraft/adapter-nextjs-mdx"; +import { + getNuxtContentMarkdownPath, + nuxtContentMarkdownFromFrontmatter, + toNuxtContentMarkdown, +} from "@sourcedraft/adapter-nuxt-content-markdown"; +import { registerAdapter } from "./adapterRegistry.js"; + +function astroFromFrontmatter( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, +) { + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? 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, + }; +} + +export function registerBuiltInAdapters(): void { + registerAdapter({ + id: "astro-mdx", + previewMeta: { label: "MDX preview", extension: "mdx" }, + render: toAstroMdx, + getPath: (article, config) => getAstroMdxPath(article, config), + fromFrontmatter: astroFromFrontmatter, + }); + + registerAdapter({ + id: "markdown", + previewMeta: { label: "Markdown preview", extension: "md" }, + render: toMarkdown, + getPath: (article, config) => getMarkdownPath(article, config), + fromFrontmatter: astroFromFrontmatter, + }); + + registerAdapter({ + id: "nextjs-mdx", + previewMeta: { label: "Next.js MDX preview", extension: "mdx" }, + render: toNextjsMdx, + getPath: (article, config) => getNextjsMdxPath(article, config), + fromFrontmatter: nextjsMdxFromFrontmatter, + }); + + registerAdapter({ + id: "hugo-markdown", + previewMeta: { label: "Hugo Markdown preview", extension: "md" }, + render: toHugoMarkdown, + getPath: (article, config) => getHugoMarkdownPath(article, config), + fromFrontmatter: hugoMarkdownFromFrontmatter, + }); + + registerAdapter({ + id: "eleventy-jekyll-markdown", + previewMeta: { + label: "Eleventy/Jekyll Markdown preview", + extension: "md", + }, + render: toEleventyJekyllMarkdown, + getPath: (article, config) => + getEleventyJekyllMarkdownPath(article, { + contentDir: config.contentDir, + ...(config.adapterOptions !== undefined + ? { adapterOptions: config.adapterOptions } + : {}), + }), + fromFrontmatter: eleventyJekyllMarkdownFromFrontmatter, + }); + + registerAdapter({ + id: "docusaurus-mdx", + previewMeta: { label: "Docusaurus MDX preview", extension: "mdx" }, + render: toDocusaurusMdx, + getPath: (article, config) => + getDocusaurusMdxPath(article, { + contentDir: config.contentDir, + ...(config.adapterOptions !== undefined + ? { adapterOptions: config.adapterOptions } + : {}), + }), + fromFrontmatter: docusaurusMdxFromFrontmatter, + }); + + registerAdapter({ + id: "mkdocs-markdown", + previewMeta: { + label: "MkDocs Markdown preview", + extension: "md", + navHint: "Published files must be wired into mkdocs.yml nav manually.", + }, + render: toMkdocsMarkdown, + getPath: (article, config) => + getMkdocsMarkdownPath(article, { + contentDir: config.contentDir, + ...(config.adapterOptions !== undefined + ? { adapterOptions: config.adapterOptions } + : {}), + }), + fromFrontmatter: mkdocsMarkdownFromFrontmatter, + previewNavHint: (article, path, adapterOptions) => { + const navSection = + typeof adapterOptions?.navSection === "string" + ? adapterOptions.navSection + : undefined; + return buildMkdocsNavHint(article.title, path, navSection); + }, + }); + + registerAdapter({ + id: "nuxt-content-markdown", + previewMeta: { label: "Nuxt Content Markdown preview", extension: "md" }, + render: toNuxtContentMarkdown, + getPath: (article, config) => + getNuxtContentMarkdownPath(article, { + contentDir: config.contentDir, + ...(config.adapterOptions !== undefined + ? { adapterOptions: config.adapterOptions } + : {}), + }), + fromFrontmatter: nuxtContentMarkdownFromFrontmatter, + }); +} + +registerBuiltInAdapters(); diff --git a/packages/adapters/src/registry.test.ts b/packages/adapters/src/registry.test.ts new file mode 100644 index 0000000..2e09dec --- /dev/null +++ b/packages/adapters/src/registry.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Article } from "@sourcedraft/core"; +import { + adapterRegistry, + getAdapterPostPath, + getAdapterPreviewMeta, + isAdapterId, + listAdapterIds, + renderAdapterOutput, +} from "./registry.js"; + +const article: Article = { + title: "Registry test", + slug: "registry-test", + description: "Adapter registry smoke test", + pubDate: "2024-06-01", + category: "Guides", + tags: ["test"], + draft: false, + body: "Body text.", +}; + +describe("adapter registry", () => { + it("lists all built-in adapters", () => { + assert.equal(listAdapterIds().length, 8); + assert.equal(isAdapterId("docusaurus-mdx"), true); + assert.equal(isAdapterId("mkdocs-markdown"), true); + assert.equal(isAdapterId("nuxt-content-markdown"), true); + }); + + it("validates adapter ids", () => { + assert.equal(isAdapterId("nextjs-mdx"), true); + assert.equal(isAdapterId("wordpress"), false); + }); + + it("exposes adapterRegistry helpers", () => { + assert.equal(adapterRegistry.isKnown("markdown"), true); + assert.match(adapterRegistry.supportedSummary(), /astro-mdx/); + }); + + it("renders and resolves paths through registry", () => { + const output = renderAdapterOutput("nextjs-mdx", article); + assert.match(output, /date: 2024-06-01/); + + const path = getAdapterPostPath("nextjs-mdx", article, { + contentDir: "content/posts", + }); + assert.equal(path, "content/posts/registry-test.mdx"); + + const preview = getAdapterPreviewMeta("hugo-markdown"); + assert.equal(preview.extension, "md"); + assert.match(preview.label, /Hugo/); + }); +}); diff --git a/packages/adapters/src/registry.ts b/packages/adapters/src/registry.ts new file mode 100644 index 0000000..8dbb187 --- /dev/null +++ b/packages/adapters/src/registry.ts @@ -0,0 +1,43 @@ +import { slugFromFilename } from "@sourcedraft/adapter-eleventy-jekyll-markdown"; +import type { ArticleInput } from "@sourcedraft/core"; +import { + frontmatterToArticleInputWithSlug, +} from "./adapterRegistry.js"; +import type { AdapterId } from "./types.js"; + +import "./registerBuiltInAdapters.js"; + +export { + adapterRegistry, + frontmatterToArticleInputWithSlug, + getAdapter, + getAdapterDefinition, + getAdapterPostPath, + getAdapterPreviewMeta, + getAdapterPreviewNavHint, + isAdapterId, + listAdapterIds, + registerAdapter, + renderAdapterOutput, + supportedAdapterSummary, +} from "./adapterRegistry.js"; + +function defaultSlugFromPath(path: string): string { + const filename = path.split("/").pop() ?? ""; + return slugFromFilename(filename); +} + +export function frontmatterToArticleInput( + adapterId: AdapterId, + path: string, + frontmatter: Record, + body: string, +): ArticleInput { + return frontmatterToArticleInputWithSlug( + adapterId, + path, + frontmatter, + body, + defaultSlugFromPath, + ); +} diff --git a/packages/adapters/src/types.ts b/packages/adapters/src/types.ts new file mode 100644 index 0000000..2a1a936 --- /dev/null +++ b/packages/adapters/src/types.ts @@ -0,0 +1,47 @@ +import type { Article, ArticleInput } from "@sourcedraft/core"; + +export const ADAPTER_IDS = [ + "astro-mdx", + "markdown", + "nextjs-mdx", + "hugo-markdown", + "eleventy-jekyll-markdown", + "docusaurus-mdx", + "mkdocs-markdown", + "nuxt-content-markdown", +] as const; + +export type AdapterId = (typeof ADAPTER_IDS)[number]; + +export type AdapterPathConfig = { + contentDir: string; + adapterOptions?: Record; +}; + +export type AdapterPreviewMeta = { + label: string; + extension: string; + navHint?: string; +}; + +/** Converts a validated article into target file content and paths. */ +export type Adapter = { + id: AdapterId; + previewMeta: AdapterPreviewMeta; + render: (article: Article, adapterOptions?: Record) => string; + getPath: (article: Article, config: AdapterPathConfig) => string; + fromFrontmatter: ( + path: string, + frontmatter: Record, + body: string, + slugFromPath: (path: string) => string, + ) => ArticleInput; + previewNavHint?: ( + article: Article, + path: string, + adapterOptions?: Record, + ) => string | undefined; +}; + +/** @deprecated Use `Adapter` */ +export type AdapterDefinition = Adapter; diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/adapters/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/config/src/loadConfig.ts b/packages/config/src/loadConfig.ts index 719307a..3c2ac9d 100644 --- a/packages/config/src/loadConfig.ts +++ b/packages/config/src/loadConfig.ts @@ -47,10 +47,27 @@ export function normalizeSourceDraftConfig( ? normalizePublicMediaPath(input.publicMediaPath.trim()) : undefined; + const adapterOptions = + input.adapterOptions !== null && + typeof input.adapterOptions === "object" && + !Array.isArray(input.adapterOptions) + ? (input.adapterOptions as Record) + : undefined; + + const publisherOptions = + input.publisherOptions !== null && + typeof input.publisherOptions === "object" && + !Array.isArray(input.publisherOptions) + ? (input.publisherOptions as Record) + : undefined; + return { adapter: isNonEmptyString(input.adapter) ? input.adapter.trim() : DEFAULT_SOURCEDRAFT_CONFIG.adapter, + publisher: isNonEmptyString(input.publisher) + ? input.publisher.trim() + : DEFAULT_SOURCEDRAFT_CONFIG.publisher, contentDir: isNonEmptyString(input.contentDir) ? input.contentDir.trim() : DEFAULT_SOURCEDRAFT_CONFIG.contentDir, @@ -64,6 +81,8 @@ export function normalizeSourceDraftConfig( ? input.defaultBranch.trim() : DEFAULT_SOURCEDRAFT_CONFIG.defaultBranch, categories: categories ?? DEFAULT_SOURCEDRAFT_CONFIG.categories, + ...(adapterOptions !== undefined ? { adapterOptions } : {}), + ...(publisherOptions !== undefined ? { publisherOptions } : {}), }; } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 6303d93..28e2cad 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -2,6 +2,7 @@ import { derivePublicMediaPath } from "./publicMediaPath.js"; export type SourceDraftConfig = { adapter: string; + publisher: string; contentDir: string; mediaDir: string; publicMediaPath: string; @@ -9,10 +10,13 @@ export type SourceDraftConfig = { publicMediaPathExplicit?: string; defaultBranch: string; categories: string[]; + adapterOptions?: Record; + publisherOptions?: Record; }; export const DEFAULT_SOURCEDRAFT_CONFIG: SourceDraftConfig = { adapter: "astro-mdx", + publisher: "github", contentDir: "src/content/blog", mediaDir: "src/assets/images", publicMediaPath: derivePublicMediaPath("src/assets/images"), diff --git a/packages/core/src/article.ts b/packages/core/src/article.ts index 355ce24..7b0e154 100644 --- a/packages/core/src/article.ts +++ b/packages/core/src/article.ts @@ -9,6 +9,11 @@ export type ArticleInput = { draft?: unknown; heroImage?: unknown; body?: unknown; + author?: unknown; + metaTitle?: unknown; + metaDescription?: unknown; + canonicalUrl?: unknown; + socialImage?: unknown; }; export type Article = { @@ -22,6 +27,11 @@ export type Article = { body: string; updatedDate?: string; heroImage?: string; + author?: string; + metaTitle?: string; + metaDescription?: string; + canonicalUrl?: string; + socialImage?: string; }; export type ValidationIssue = { diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index a9a06b7..39c4b49 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -139,6 +139,19 @@ export function validateArticle(input: ArticleInput): ValidationResult { issues.push(issue("heroImage", "Hero image must be a non-empty string.")); } + for (const [field, label] of [ + ["author", "Author"], + ["metaTitle", "Meta title"], + ["metaDescription", "Meta description"], + ["canonicalUrl", "Canonical URL"], + ["socialImage", "Social image"], + ] as const) { + const value = input[field]; + if (value !== undefined && value !== null && !isNonEmptyString(value)) { + issues.push(issue(field, `${label} must be a non-empty string.`)); + } + } + if (!isNonEmptyString(input.body)) { issues.push(issue("body", "Body is required.")); } @@ -183,5 +196,18 @@ export function normalizeArticle(input: ArticleInput): Article { article.heroImage = input.heroImage.trim(); } + for (const field of [ + "author", + "metaTitle", + "metaDescription", + "canonicalUrl", + "socialImage", + ] as const) { + const value = input[field]; + if (isNonEmptyString(value)) { + article[field] = value.trim(); + } + } + return article; } diff --git a/packages/publishers/package.json b/packages/publishers/package.json new file mode 100644 index 0000000..24aa383 --- /dev/null +++ b/packages/publishers/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sourcedraft/publishers", + "version": "0.0.1", + "private": true, + "description": "Built-in publisher registry 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/github-publisher": "workspace:*" + }, + "devDependencies": { + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/publishers/src/githubPublisherAdapter.ts b/packages/publishers/src/githubPublisherAdapter.ts new file mode 100644 index 0000000..92c7ffd --- /dev/null +++ b/packages/publishers/src/githubPublisherAdapter.ts @@ -0,0 +1,155 @@ +import { createGitHubPublisher } from "@sourcedraft/github-publisher"; +import type { + Publisher, + PublisherFactory, + PublisherRuntimeConfig, + PublishArticleInput, + PublishArticleResult, + ReadPostInput, + ReadPostResult, + ListPostsInput, + ListPostsResult, + UploadMediaInput, + UploadMediaResult, +} from "./types.js"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; + +const GITHUB_CAPABILITIES = { + publishArticle: true, + uploadMedia: true, + listPosts: true, + readPost: true, +} as const; + +function createGitHubPublisherInstance(config: PublisherRuntimeConfig): Publisher { + const github = createGitHubPublisher({ + token: config.token, + owner: config.owner, + repo: config.repo, + branch: config.branch, + }); + + return { + id: "github", + capabilities: GITHUB_CAPABILITIES, + async publishArticle(input: PublishArticleInput): Promise { + const result = await github.publishFile({ + path: input.path, + content: input.content, + message: input.message, + purpose: "post", + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async uploadMedia(input: UploadMediaInput): Promise { + const result = await github.publishFile({ + path: input.repoPath, + contentBase64: input.contentBase64, + message: input.message, + purpose: "media", + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async listPosts(input: ListPostsInput): Promise { + const result = await github.listFiles({ + path: input.contentDir, + contentDir: input.contentDir, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { ok: true, files: result.files }; + }, + async readPost(input: ReadPostInput): Promise { + const result = await github.readFile({ path: input.path }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + content: result.content, + sha: result.sha, + }; + }, + }; +} + +function wrapPublisherWithCapabilities( + factory: PublisherFactory, + publisher: Publisher, +): Publisher { + return { + id: factory.id, + capabilities: factory.capabilities, + publishArticle: factory.capabilities.publishArticle + ? (input) => publisher.publishArticle(input) + : () => Promise.resolve(unsupportedPublishArticle(factory.id)), + uploadMedia: factory.capabilities.uploadMedia + ? (input) => publisher.uploadMedia(input) + : () => Promise.resolve(unsupportedUploadMedia(factory.id)), + listPosts: factory.capabilities.listPosts + ? (input) => publisher.listPosts(input) + : () => Promise.resolve(unsupportedListPosts(factory.id)), + readPost: factory.capabilities.readPost + ? (input) => publisher.readPost(input) + : () => Promise.resolve(unsupportedReadPost(factory.id)), + }; +} + +export const githubPublisherFactory: PublisherFactory = { + id: "github", + capabilities: GITHUB_CAPABILITIES, + createPublisher(config: PublisherRuntimeConfig): Publisher { + return wrapPublisherWithCapabilities( + githubPublisherFactory, + createGitHubPublisherInstance(config), + ); + }, +}; diff --git a/packages/publishers/src/index.ts b/packages/publishers/src/index.ts new file mode 100644 index 0000000..b189679 --- /dev/null +++ b/packages/publishers/src/index.ts @@ -0,0 +1,29 @@ +import "./registerBuiltInPublishers.js"; + +export { + createPublisher, + getPublisherFactory, + isPublisherId, + listPublisherIds, + publisherRegistry, + registerPublisher, + supportedPublisherSummary, +} from "./publisherRegistry.js"; + +export { + PUBLISHER_IDS, + type ListPostsInput, + type ListPostsResult, + type ListedPostFile, + type PublishArticleInput, + type PublishArticleResult, + type Publisher, + type PublisherCapabilities, + type PublisherFactory, + type PublisherId, + type PublisherRuntimeConfig, + type ReadPostInput, + type ReadPostResult, + type UploadMediaInput, + type UploadMediaResult, +} from "./types.js"; diff --git a/packages/publishers/src/publisherRegistry.ts b/packages/publishers/src/publisherRegistry.ts new file mode 100644 index 0000000..5ad18d7 --- /dev/null +++ b/packages/publishers/src/publisherRegistry.ts @@ -0,0 +1,49 @@ +import type { + Publisher, + PublisherFactory, + PublisherId, + PublisherRuntimeConfig, +} from "./types.js"; + +const publishers = new Map(); + +export function registerPublisher(factory: PublisherFactory): void { + publishers.set(factory.id, factory); +} + +export function listPublisherIds(): PublisherId[] { + return [...publishers.keys()]; +} + +export function isPublisherId(value: string): value is PublisherId { + return publishers.has(value as PublisherId); +} + +export function getPublisherFactory(publisherId: PublisherId): PublisherFactory { + const factory = publishers.get(publisherId); + if (factory === undefined) { + throw new Error(`Publisher "${publisherId}" is not registered.`); + } + + return factory; +} + +export function createPublisher( + publisherId: PublisherId, + config: PublisherRuntimeConfig, +): Publisher { + return getPublisherFactory(publisherId).createPublisher(config); +} + +export function supportedPublisherSummary(): string { + return listPublisherIds().join(", "); +} + +export const publisherRegistry = { + register: registerPublisher, + listIds: listPublisherIds, + isKnown: isPublisherId, + getFactory: getPublisherFactory, + create: createPublisher, + supportedSummary: supportedPublisherSummary, +}; diff --git a/packages/publishers/src/registerBuiltInPublishers.ts b/packages/publishers/src/registerBuiltInPublishers.ts new file mode 100644 index 0000000..8a83ef3 --- /dev/null +++ b/packages/publishers/src/registerBuiltInPublishers.ts @@ -0,0 +1,8 @@ +import { githubPublisherFactory } from "./githubPublisherAdapter.js"; +import { registerPublisher } from "./publisherRegistry.js"; + +export function registerBuiltInPublishers(): void { + registerPublisher(githubPublisherFactory); +} + +registerBuiltInPublishers(); diff --git a/packages/publishers/src/registry.test.ts b/packages/publishers/src/registry.test.ts new file mode 100644 index 0000000..ebc0b0f --- /dev/null +++ b/packages/publishers/src/registry.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createPublisher, + isPublisherId, + listPublisherIds, + publisherRegistry, +} from "./publisherRegistry.js"; +import type { PublisherRuntimeConfig } from "./types.js"; + +import "./registerBuiltInPublishers.js"; + +const runtimeConfig: PublisherRuntimeConfig = { + token: "test-token", + owner: "owner", + repo: "repo", + branch: "main", + contentDir: "src/content/blog", + mediaDir: "public/images", +}; + +describe("publisher registry", () => { + it("defaults to github publisher", () => { + assert.deepEqual(listPublisherIds(), ["github"]); + assert.equal(isPublisherId("github"), true); + assert.equal(isPublisherId("wordpress"), false); + }); + + it("creates github publisher with full capabilities", () => { + const publisher = createPublisher("github", runtimeConfig); + + assert.equal(publisher.id, "github"); + assert.equal(publisher.capabilities.publishArticle, true); + assert.equal(publisher.capabilities.uploadMedia, true); + assert.equal(publisher.capabilities.listPosts, true); + assert.equal(publisher.capabilities.readPost, true); + }); + + it("exposes registry helpers", () => { + assert.equal(publisherRegistry.isKnown("github"), true); + assert.match(publisherRegistry.supportedSummary(), /github/); + }); +}); diff --git a/packages/publishers/src/types.ts b/packages/publishers/src/types.ts new file mode 100644 index 0000000..95d2cb7 --- /dev/null +++ b/packages/publishers/src/types.ts @@ -0,0 +1,122 @@ +export const PUBLISHER_IDS = ["github"] as const; + +export type PublisherId = (typeof PUBLISHER_IDS)[number]; + +export type PublisherRuntimeConfig = { + token: string; + owner: string; + repo: string; + branch: string; + contentDir: string; + mediaDir: string; + publisherOptions?: Record; +}; + +export type PublisherCapabilities = { + publishArticle: boolean; + uploadMedia: boolean; + listPosts: boolean; + readPost: boolean; +}; + +export type PublishArticleInput = { + path: string; + content: string; + message: string; +}; + +export type PublishArticleSuccess = { + ok: true; + path: string; + created: boolean; + sha: string; + commitSha: string; +}; + +export type PublishArticleError = { + ok: false; + error: string; + status?: number; +}; + +export type PublishArticleResult = PublishArticleSuccess | PublishArticleError; + +export type UploadMediaInput = { + repoPath: string; + contentBase64: string; + message: string; +}; + +export type UploadMediaSuccess = { + ok: true; + path: string; + sha: string; + commitSha: string; +}; + +export type UploadMediaError = { + ok: false; + error: string; + status?: number; +}; + +export type UploadMediaResult = UploadMediaSuccess | UploadMediaError; + +export type ListPostsInput = { + contentDir: string; +}; + +export type ListedPostFile = { + path: string; + name: string; + sha: string; + size: number; +}; + +export type ListPostsSuccess = { + ok: true; + files: ListedPostFile[]; +}; + +export type ListPostsError = { + ok: false; + error: string; + status?: number; +}; + +export type ListPostsResult = ListPostsSuccess | ListPostsError; + +export type ReadPostInput = { + path: string; +}; + +export type ReadPostSuccess = { + ok: true; + path: string; + content: string; + sha: string; +}; + +export type ReadPostError = { + ok: false; + error: string; + status?: number; +}; + +export type ReadPostResult = ReadPostSuccess | ReadPostError; + +/** Sends rendered content and media to a publishing target. */ +export type Publisher = { + id: PublisherId; + capabilities: PublisherCapabilities; + publishArticle(input: PublishArticleInput): Promise; + uploadMedia(input: UploadMediaInput): Promise; + listPosts(input: ListPostsInput): Promise; + readPost(input: ReadPostInput): Promise; +}; + +export type PublisherFactory = { + id: PublisherId; + capabilities: PublisherCapabilities; + createPublisher: (config: PublisherRuntimeConfig) => Publisher; +}; diff --git a/packages/publishers/src/unsupported.test.ts b/packages/publishers/src/unsupported.test.ts new file mode 100644 index 0000000..93e56d2 --- /dev/null +++ b/packages/publishers/src/unsupported.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; + +describe("unsupported publisher methods", () => { + it("returns clear errors for unsupported capabilities", () => { + const upload = unsupportedUploadMedia("github"); + assert.equal(upload.ok, false); + if (!upload.ok) { + assert.match(upload.error, /does not support media uploads/); + } + + const publish = unsupportedPublishArticle("github"); + assert.match( + publish.ok ? "" : publish.error, + /does not support article publishing/, + ); + + const list = unsupportedListPosts("github"); + assert.match(list.ok ? "" : list.error, /does not support listing posts/); + + const read = unsupportedReadPost("github"); + assert.match(read.ok ? "" : read.error, /does not support reading posts/); + }); +}); diff --git a/packages/publishers/src/unsupported.ts b/packages/publishers/src/unsupported.ts new file mode 100644 index 0000000..89f6739 --- /dev/null +++ b/packages/publishers/src/unsupported.ts @@ -0,0 +1,37 @@ +import type { PublisherId } from "./types.js"; +import type { + PublishArticleResult, + ReadPostResult, + ListPostsResult, + UploadMediaResult, +} from "./types.js"; + +export function unsupportedPublishArticle( + publisherId: PublisherId, +): PublishArticleResult { + return { + ok: false, + error: `Publisher "${publisherId}" does not support article publishing.`, + }; +} + +export function unsupportedUploadMedia(publisherId: PublisherId): UploadMediaResult { + return { + ok: false, + error: `Publisher "${publisherId}" does not support media uploads.`, + }; +} + +export function unsupportedListPosts(publisherId: PublisherId): ListPostsResult { + return { + ok: false, + error: `Publisher "${publisherId}" does not support listing posts.`, + }; +} + +export function unsupportedReadPost(publisherId: PublisherId): ReadPostResult { + return { + ok: false, + error: `Publisher "${publisherId}" does not support reading posts.`, + }; +} diff --git a/packages/publishers/tsconfig.json b/packages/publishers/tsconfig.json new file mode 100644 index 0000000..c68a6a8 --- /dev/null +++ b/packages/publishers/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1bfc34..efcfd8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,21 @@ importers: '@sourcedraft/adapter-astro-mdx': specifier: workspace:* version: link:../../packages/adapter-astro-mdx + '@sourcedraft/adapter-eleventy-jekyll-markdown': + specifier: workspace:* + version: link:../../packages/adapter-eleventy-jekyll-markdown + '@sourcedraft/adapter-hugo-markdown': + specifier: workspace:* + version: link:../../packages/adapter-hugo-markdown '@sourcedraft/adapter-markdown': specifier: workspace:* version: link:../../packages/adapter-markdown + '@sourcedraft/adapter-nextjs-mdx': + specifier: workspace:* + version: link:../../packages/adapter-nextjs-mdx + '@sourcedraft/adapters': + specifier: workspace:* + version: link:../../packages/adapters '@sourcedraft/config': specifier: workspace:* version: link:../../packages/config @@ -38,6 +50,9 @@ importers: '@sourcedraft/github-publisher': specifier: workspace:* version: link:../../packages/github-publisher + '@sourcedraft/publishers': + specifier: workspace:* + version: link:../../packages/publishers busboy: specifier: ^1.6.0 version: 1.6.0 @@ -119,6 +134,45 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/adapter-docusaurus-mdx: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/adapter-eleventy-jekyll-markdown: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/adapter-hugo-markdown: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/adapter-markdown: dependencies: '@sourcedraft/core': @@ -132,6 +186,82 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/adapter-mkdocs-markdown: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/adapter-nextjs-mdx: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/adapter-nuxt-content-markdown: + dependencies: + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/adapters: + dependencies: + '@sourcedraft/adapter-astro-mdx': + specifier: workspace:* + version: link:../adapter-astro-mdx + '@sourcedraft/adapter-docusaurus-mdx': + specifier: workspace:* + version: link:../adapter-docusaurus-mdx + '@sourcedraft/adapter-eleventy-jekyll-markdown': + specifier: workspace:* + version: link:../adapter-eleventy-jekyll-markdown + '@sourcedraft/adapter-hugo-markdown': + specifier: workspace:* + version: link:../adapter-hugo-markdown + '@sourcedraft/adapter-markdown': + specifier: workspace:* + version: link:../adapter-markdown + '@sourcedraft/adapter-mkdocs-markdown': + specifier: workspace:* + version: link:../adapter-mkdocs-markdown + '@sourcedraft/adapter-nextjs-mdx': + specifier: workspace:* + version: link:../adapter-nextjs-mdx + '@sourcedraft/adapter-nuxt-content-markdown': + specifier: workspace:* + version: link:../adapter-nuxt-content-markdown + '@sourcedraft/core': + specifier: workspace:* + version: link:../core + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/config: devDependencies: '@types/node': @@ -162,6 +292,19 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/publishers: + dependencies: + '@sourcedraft/github-publisher': + specifier: workspace:* + version: link:../github-publisher + devDependencies: + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages: '@babel/code-frame@7.29.7': diff --git a/sourcedraft.config.example.json b/sourcedraft.config.example.json index 02e9bf4..4a3c056 100644 --- a/sourcedraft.config.example.json +++ b/sourcedraft.config.example.json @@ -1,8 +1,11 @@ { "adapter": "astro-mdx", + "publisher": "github", "contentDir": "src/content/blog", "mediaDir": "public/images", "publicMediaPath": "/images", "defaultBranch": "main", - "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"] + "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], + "adapterOptions": {}, + "publisherOptions": {} }