From f2e4c8f775a78fb947247c197c72ea51c3376d2f Mon Sep 17 00:00:00 2001 From: bnz183 Date: Tue, 9 Jun 2026 12:19:01 +0200 Subject: [PATCH] Plugin loader and open-source documentation polish --- .env.example | 3 + README.md | 28 +- apps/studio/package.json | 7 +- apps/studio/server/index.ts | 3 + apps/studio/server/plugins.ts | 46 ++++ docs/adapters.md | 29 +- docs/assets/screenshots/ATTRIBUTION.md | 43 +++ docs/assets/screenshots/connectors/README.md | 27 ++ docs/compatibility-roadmap.md | 2 +- docs/configuration.md | 4 + docs/deploy-hooks.md | 16 +- docs/getting-started.md | 18 +- docs/media.md | 12 +- docs/non-technical-overview.md | 51 ++-- docs/plugins.md | 120 +++++++++ docs/project-status.md | 102 +++---- docs/publishers.md | 14 + docs/quickstart-recipes.md | 253 ++++++++++++++++++ docs/security.md | 99 ++++--- examples/docusaurus-blog/README.md | 10 + examples/eleventy-jekyll-blog/README.md | 10 + examples/hugo-blog/README.md | 10 + examples/mkdocs-blog/README.md | 9 + examples/nextjs-mdx-blog/README.md | 11 +- examples/nuxt-content-blog/README.md | 9 + examples/plugins/plain-text-adapter/README.md | 23 ++ examples/plugins/plain-text-adapter/index.js | 55 ++++ package.json | 5 +- packages/adapters/src/types.ts | 3 +- packages/config/src/loadConfig.ts | 23 ++ packages/config/src/types.ts | 6 + packages/media-providers/src/types.ts | 3 +- packages/plugins/package.json | 31 +++ packages/plugins/src/context.ts | 25 ++ packages/plugins/src/discover.ts | 99 +++++++ packages/plugins/src/index.ts | 25 ++ packages/plugins/src/loader.test.ts | 153 +++++++++++ packages/plugins/src/loader.ts | 135 ++++++++++ packages/plugins/src/logger.ts | 17 ++ packages/plugins/src/manifest.test.ts | 59 ++++ packages/plugins/src/manifest.ts | 75 ++++++ packages/plugins/src/types.ts | 48 ++++ packages/plugins/src/version.ts | 35 +++ packages/plugins/tsconfig.json | 23 ++ packages/publishers/src/types.ts | 3 +- pnpm-lock.yaml | 25 ++ scripts/capture-doc-screenshots.ts | 121 +++++++++ sourcedraft.config.example.json | 5 +- 48 files changed, 1772 insertions(+), 161 deletions(-) create mode 100644 apps/studio/server/plugins.ts create mode 100644 docs/assets/screenshots/ATTRIBUTION.md create mode 100644 docs/assets/screenshots/connectors/README.md create mode 100644 docs/plugins.md create mode 100644 docs/quickstart-recipes.md create mode 100644 examples/plugins/plain-text-adapter/README.md create mode 100644 examples/plugins/plain-text-adapter/index.js create mode 100644 packages/plugins/package.json create mode 100644 packages/plugins/src/context.ts create mode 100644 packages/plugins/src/discover.ts create mode 100644 packages/plugins/src/index.ts create mode 100644 packages/plugins/src/loader.test.ts create mode 100644 packages/plugins/src/loader.ts create mode 100644 packages/plugins/src/logger.ts create mode 100644 packages/plugins/src/manifest.test.ts create mode 100644 packages/plugins/src/manifest.ts create mode 100644 packages/plugins/src/types.ts create mode 100644 packages/plugins/src/version.ts create mode 100644 packages/plugins/tsconfig.json create mode 100644 scripts/capture-doc-screenshots.ts diff --git a/.env.example b/.env.example index 9c9bd80..bd175f3 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,6 @@ CMS_CONTENT_DIR= CMS_MEDIA_DIR= CMS_PUBLIC_MEDIA_PATH= CMS_ADAPTER= + +# Optional plugins — configure in sourcedraft.config.json (plugins, requiredPlugins, discoverPlugins) +# See docs/plugins.md. Plugin paths are server-only; never expose plugin secrets to the browser. diff --git a/README.md b/README.md index a8aae05..dcfbb65 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SourceDraft -SourceDraft is a free, open-source editor for Markdown and MDX blogs backed by GitHub. You write in the browser, upload images, check your metadata, preview the generated file, and publish into your site repository. +SourceDraft is a free, open-source editor for Markdown and MDX blogs. You write in the browser, upload images, check SEO metadata, preview the generated file, and publish to a Git repository or remote CMS (WordPress, Ghost). **Project status:** SourceDraft is an early local/private MVP for Git-backed Markdown and MDX publishing. It is usable for solo writing and GitHub commits, but it is not a hosted CMS, multi-user product, or finished SaaS. See [docs/project-status.md](docs/project-status.md) and [CHANGELOG.md](CHANGELOG.md). @@ -18,9 +18,9 @@ More views (toolbar, autosave, media library, content quality, preview, setup he ## What is SourceDraft? -SourceDraft is not WordPress and not a hosted website builder. It is a local **Studio** (editor) plus a small **publish API** that commits content and media files to GitHub. +SourceDraft is not WordPress and not a hosted website builder. It is a local **Studio** (editor) plus a small **publish API** that commits content and media to your target — GitHub, GitLab, Bitbucket, WordPress, or Ghost. -Your static site — Astro today, others later — still builds and deploys exactly as before. SourceDraft creates or updates `.mdx` or `.md` files in the folder you configure, and can upload images to `mediaDir`. +Your static site still builds and deploys exactly as before. SourceDraft creates or updates `.mdx` or `.md` files (via adapters) in the folder you configure, or pushes posts to a remote CMS API. Images can commit to your repo, upload to Cloudinary, or (experimentally) target S3-compatible storage. ## Who is this for? @@ -48,10 +48,11 @@ Your static site — Astro today, others later — still builds and deploys exac - Host your website or run your Astro build - OAuth, user accounts, or role-based access -- Full S3/R2 media upload (config validation only today; Cloudinary supported) -- Adapters beyond `astro-mdx` and `markdown` +- Full S3/R2 media upload (`s3-compatible` validates config only; use Cloudinary or git media today) +- Post list in Studio for Bitbucket, WordPress, and Ghost publishers +- OAuth, team accounts, or hosted multi-tenant Studio -See [docs/project-status.md](docs/project-status.md). +Eight adapters ship today — see [docs/adapters.md](docs/adapters.md). See [docs/project-status.md](docs/project-status.md) for the full shipped vs experimental list. ## How publishing works @@ -65,12 +66,14 @@ API tokens never reach the browser. They are read from `.env` on the server when Details: [docs/publishers.md](docs/publishers.md) · [docs/git-publishers.md](docs/git-publishers.md) · [docs/wordpress.md](docs/wordpress.md) · [docs/ghost.md](docs/ghost.md) -**Compatibility** +**Compatibility (summary)** -| Kind | Publishers | Notes | -|------|------------|-------| -| Git file | GitHub, GitLab, Bitbucket | Commit `.md`/`.mdx` to a repo; list/edit in Studio for GitHub/GitLab | -| Remote CMS | WordPress, Ghost | Server-side API connectors; updates need `remoteId` from prior publish | +| | Adapters (8) | Publishers (5) | Media (3) | Deploy hooks (4) | +|---|--------------|----------------|-----------|------------------| +| **Shipped** | Astro MDX, Markdown, Next.js MDX, Hugo, Eleventy/Jekyll, Docusaurus, MkDocs, Nuxt Content | GitHub, GitLab, Bitbucket, WordPress, Ghost | Git repo, Cloudinary, S3-compatible† | Generic, Vercel, Netlify, Cloudflare Pages | +| **Notes** | Plugin loader for custom adapters | Git: list posts (GH/GL); Bitbucket publish only; WP/Ghost API publish | †S3 upload not implemented yet | Optional `DEPLOY_HOOK_URL` after publish | + +Full matrices: [adapters](docs/adapters.md) · [publishers](docs/publishers.md) · [media](docs/media.md) · [deploy-hooks](docs/deploy-hooks.md) · [quickstart recipes](docs/quickstart-recipes.md) ## Quickstart @@ -156,6 +159,9 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo ## Documentation - [Getting started](docs/getting-started.md) +- [Quickstart recipes](docs/quickstart-recipes.md) — Astro+GitHub, Hugo+GitLab, WordPress, Cloudinary, deploy hooks, … +- [Plugins](docs/plugins.md) +- [SEO fields](docs/seo-fields.md) - [Demo mode](docs/demo-mode.md) - [Non-technical overview](docs/non-technical-overview.md) — for writers - [Publishers overview](docs/publishers.md) diff --git a/apps/studio/package.json b/apps/studio/package.json index 54a8bc1..0ca154e 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "predev": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", - "prebuild": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", - "dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch server/index.ts\"", + "predev": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/plugins --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", + "prebuild": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/plugins --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter @sourcedraft/config build", + "dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch --ignore \\\"../../packages/**/dist\\\" --ignore \\\"node_modules\\\" --ignore \\\"dist\\\" server/index.ts\"", "dev:web": "vite", "dev:server": "tsx watch server/index.ts", "build": "tsc -b && vite build && tsc -p server/tsconfig.json", @@ -33,6 +33,7 @@ "@sourcedraft/core": "workspace:*", "@sourcedraft/github-publisher": "workspace:*", "@sourcedraft/media-providers": "workspace:*", + "@sourcedraft/plugins": "workspace:*", "@sourcedraft/setup": "workspace:*", "busboy": "^1.6.0", "dotenv": "^16.5.0", diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 8d7455c..a6dd681 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -23,6 +23,7 @@ import { listMedia } from "./listMedia.js"; import { listPosts, loadPost } from "./posts.js"; import { publishArticle, type PublishRequestBody } from "./publish.js"; import { requireSameSiteRequest } from "./requestProtection.js"; +import { initializePlugins } from "./plugins.js"; import { getSetupHealth } from "./setupHealth.js"; const envPaths = [ @@ -38,6 +39,8 @@ for (const envPath of envPaths) { } } +await initializePlugins(); + const port = Number(process.env.STUDIO_API_PORT ?? 8787); const app = express(); diff --git a/apps/studio/server/plugins.ts b/apps/studio/server/plugins.ts new file mode 100644 index 0000000..56f700a --- /dev/null +++ b/apps/studio/server/plugins.ts @@ -0,0 +1,46 @@ +import { dirname } from "node:path"; +import { loadSourceDraftConfig, resolveConfigPath } from "@sourcedraft/config"; +import { loadPlugins } from "@sourcedraft/plugins"; + +export async function initializePlugins(cwd = process.cwd()): Promise { + const config = loadSourceDraftConfig(cwd); + const configPath = resolveConfigPath(cwd); + const configDir = configPath ? dirname(configPath) : cwd; + + const hasExplicitPlugins = (config.plugins?.length ?? 0) > 0; + const discover = config.discoverPlugins === true; + + if (!hasExplicitPlugins && !discover) { + return; + } + + const report = await loadPlugins({ + configDir, + ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), + ...(config.requiredPlugins !== undefined + ? { requiredPlugins: config.requiredPlugins } + : {}), + ...(discover ? { discoverPlugins: true } : {}), + }); + + for (const failure of report.failures) { + const label = failure.name ?? failure.path; + if (failure.required) { + console.error(`[plugins] Required plugin failed (${label}): ${failure.error}`); + } else { + console.warn(`[plugins] Optional plugin skipped (${label}): ${failure.error}`); + } + } + + if (!report.ok) { + const summary = report.failures + .filter((failure) => failure.required) + .map((failure) => `${failure.name ?? failure.path}: ${failure.error}`) + .join("; "); + throw new Error(`Required plugin(s) failed to load: ${summary}`); + } + + if (report.loaded.length > 0) { + console.log(`[plugins] Loaded: ${report.loaded.join(", ")}`); + } +} diff --git a/docs/adapters.md b/docs/adapters.md index 2845e3f..61ceacd 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -15,16 +15,18 @@ Unknown adapter ids fail validation in `loadPublishEnv()` with a list of support ## 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 | +| Adapter key | Status | Extension | Default `contentDir` | Studio preview | SEO fields | Example | +|-------------|--------|-----------|----------------------|----------------|------------|---------| +| `astro-mdx` | Shipped | `.mdx` | `src/content/blog` | Yes | Yes | [astro-blog](../examples/astro-blog/) | +| `markdown` | Shipped | `.md` | `src/content/blog` | Yes | Yes | — | +| `nextjs-mdx` | Shipped | `.mdx` | `content/posts` | Yes | Yes | [nextjs-mdx-blog](../examples/nextjs-mdx-blog/) | +| `hugo-markdown` | Shipped | `.md` | `content/posts` | Yes | Yes | [hugo-blog](../examples/hugo-blog/) | +| `eleventy-jekyll-markdown` | Shipped | `.md` | `src/posts` / `_posts` | Yes | Yes | [eleventy-jekyll-blog](../examples/eleventy-jekyll-blog/) | +| `docusaurus-mdx` | Shipped | `.mdx` | `blog` | Yes | Yes | [docusaurus-blog](../examples/docusaurus-blog/) | +| `mkdocs-markdown` | Shipped | `.md` | `docs` | Yes (+ nav hint) | Yes (no `draft` in file) | [mkdocs-blog](../examples/mkdocs-blog/) | +| `nuxt-content-markdown` | Shipped | `.md` | `content/blog` | Yes | Yes | [nuxt-content-blog](../examples/nuxt-content-blog/) | + +Custom adapters can register via [plugins.md](plugins.md). SEO fields: `metaTitle`, `metaDescription`, `canonicalUrl`, `socialImage`, `coverImageAlt`, `noindex`, computed `readingTime` — see [seo-fields.md](seo-fields.md). Optional SEO fields (`metaTitle`, `metaDescription`, `canonicalUrl`, `socialImage`, `coverImageAlt`, `noindex`, `author`, computed `readingTime`) are emitted when present. Edit them in Studio under **SEO / Sharing**. See [seo-fields.md](seo-fields.md). @@ -105,9 +107,8 @@ Does not edit `mkdocs.yml`. Studio preview shows a **nav hint** with the path to | MkDocs | [examples/mkdocs-blog](../examples/mkdocs-blog/) | | Nuxt Content | [examples/nuxt-content-blog](../examples/nuxt-content-blog/) | -## Future (not in repo yet) +## Remote CMS publishers (WordPress, Ghost) -- WordPress REST API -- Ghost API +Adapters still control **preview** and optional file-shaped output in Studio. When `publisher` is `wordpress` or `ghost`, the publish API sends article fields to the remote CMS — not necessarily a git commit. Use `markdown` or your site's file adapter for preview consistency. -These are planned directions, not shipped packages. +Details: [publishers.md](publishers.md) · [wordpress.md](wordpress.md) · [ghost.md](ghost.md) diff --git a/docs/assets/screenshots/ATTRIBUTION.md b/docs/assets/screenshots/ATTRIBUTION.md new file mode 100644 index 0000000..be8915f --- /dev/null +++ b/docs/assets/screenshots/ATTRIBUTION.md @@ -0,0 +1,43 @@ +# Connector documentation screenshots + +This folder is for **optional** reference screenshots of third-party API documentation pages (GitLab, Bitbucket, WordPress, Ghost, Cloudinary, Cloudflare R2, etc.). + +## Current status + +**No third-party screenshots are committed by default.** Official documentation is linked below. Maintainers may capture screenshots locally using the capture script only after confirming the source site’s terms of use, robots policy, and brand guidelines allow it. + +Do **not** hotlink images from vendor sites in this repository. + +## Official documentation links + +| Connector | Official docs | Owner | +|-----------|---------------|-------| +| GitLab Repository Files API | https://docs.gitlab.com/ee/api/repository_files.html | GitLab Inc. | +| Bitbucket Cloud REST API (source) | https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/ | Atlassian | +| WordPress REST API (posts) | https://developer.wordpress.org/rest-api/reference/posts/ | WordPress Foundation | +| Ghost Admin API | https://docs.ghost.org/admin-api/ | Ghost Foundation | +| Cloudinary Upload API | https://cloudinary.com/documentation/image_upload_api_reference | Cloudinary Ltd. | +| Cloudflare R2 (S3-compatible) | https://developers.cloudflare.com/r2/ | Cloudflare, Inc. | + +## Capturing screenshots (maintainers) + +From the repository root: + +```bash +# Review legal/brand constraints first, then: +pnpm capture-doc-screenshots -- --confirm-attribution +``` + +Output directory: `docs/assets/screenshots/connectors/` + +When committing captures, update the table below. + +## Attribution log + +| File | Source URL | Capture date | Owner | Usage note | +|------|------------|--------------|-------|------------| +| *(none committed)* | — | — | — | Use official links above until captures are approved | + +## Placeholders in docs + +Until screenshots exist, connector docs link to the official URLs above. See [connectors/README.md](connectors/README.md). diff --git a/docs/assets/screenshots/connectors/README.md b/docs/assets/screenshots/connectors/README.md new file mode 100644 index 0000000..11dae81 --- /dev/null +++ b/docs/assets/screenshots/connectors/README.md @@ -0,0 +1,27 @@ +# Connector screenshot placeholders + +Screenshots of third-party API documentation are **not committed** by default. + +## Why placeholders? + +Official vendor documentation is copyrighted. SourceDraft docs link to authoritative URLs instead of hotlinking or embedding unlicensed captures. + +## Adding screenshots + +1. Read [../ATTRIBUTION.md](../ATTRIBUTION.md) and confirm the source site allows capture and redistribution in an open-source repo. +2. Run `pnpm capture-doc-screenshots -- --confirm-attribution` from the repo root (requires Playwright; see script help). +3. Record each file in the attribution log with URL, date, and owner. +4. Prefer cropped, readable regions — avoid logos unless brand guidelines permit. + +## Expected filenames (when captured) + +| Filename | Subject | +|----------|---------| +| `gitlab-repository-files-api.png` | GitLab Repository Files API overview | +| `bitbucket-source-api.png` | Bitbucket commit-upload / source API | +| `wordpress-rest-posts.png` | WordPress REST posts endpoint | +| `ghost-admin-api.png` | Ghost Admin API authentication | +| `cloudinary-upload-api.png` | Cloudinary upload API reference | +| `cloudflare-r2-s3.png` | Cloudflare R2 S3-compatible overview | + +Until then, use the official links in [../ATTRIBUTION.md](../ATTRIBUTION.md). diff --git a/docs/compatibility-roadmap.md b/docs/compatibility-roadmap.md index 6949bd2..b6e3617 100644 --- a/docs/compatibility-roadmap.md +++ b/docs/compatibility-roadmap.md @@ -9,7 +9,7 @@ Extension foundation for SourceDraft adapters and publishers. | `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()`. +Built-in connectors register on package load via `registerBuiltInAdapters()` and `registerBuiltInPublishers()`. Custom adapters, publishers, and media providers can register via server-side plugins — see [plugins.md](plugins.md). ### Adapter interface diff --git a/docs/configuration.md b/docs/configuration.md index 98fc488..1458f33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,10 @@ Studio **Settings → Compatibility & status** mirrors validation (adapter, publ Details: [setup-wizard.md](setup-wizard.md). +## Server-side plugins + +Optional `plugins`, `requiredPlugins`, and `discoverPlugins` load custom adapters/publishers/media providers when the Studio API starts. Server-only — never loaded in the browser. See [plugins.md](plugins.md). + ## Secrets vs project settings SourceDraft uses two files on purpose: diff --git a/docs/deploy-hooks.md b/docs/deploy-hooks.md index 5d0c6a9..93c0cde 100644 --- a/docs/deploy-hooks.md +++ b/docs/deploy-hooks.md @@ -50,17 +50,19 @@ By default, a deploy hook failure does **not** fail the publish — your content Set `DEPLOY_HOOK_STRICT=true` when you want publish to return an error if the hook fails. Use this only when you need atomic “publish + deploy” semantics. -## Provider notes +## Compatibility matrix -| Provider | Typical hook source | -|----------|---------------------| -| `vercel` | Project → Settings → Git → Deploy Hooks | -| `netlify` | Site → Build & deploy → Build hooks | -| `cloudflare-pages` | Pages project → Settings → Builds → Deploy hooks | -| `generic` | Any CI webhook that accepts `POST` + JSON | +| Provider | `DEPLOY_HOOK_PROVIDER` | Status | Typical hook source | Auth | +|----------|------------------------|--------|---------------------|------| +| Generic | `generic` | Shipped | Any CI webhook accepting `POST` + JSON | URL secret | +| Vercel | `vercel` | Shipped | Project → Settings → Git → Deploy Hooks | URL secret | +| Netlify | `netlify` | Shipped | Site → Build & deploy → Build hooks | URL secret | +| Cloudflare Pages | `cloudflare-pages` | Shipped | Pages project → Settings → Builds → Deploy hooks | URL secret | SourceDraft does not store provider API tokens for deploy hooks — the hook URL itself is the credential. +Recipe: [quickstart-recipes.md#deploy-hook-after-publish](quickstart-recipes.md#deploy-hook-after-publish) + ## Security - Treat `DEPLOY_HOOK_URL` like a password. Anyone with the URL can trigger builds. diff --git a/docs/getting-started.md b/docs/getting-started.md index a5300b7..0dd1285 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,8 @@ # Getting started -You need a GitHub repository for your **site** (for example an Astro blog) that reads posts from a folder such as `src/content/blog`. +You need a target for published content — usually a Git repository for your **site** (for example an Astro blog reading `src/content/blog`), or a WordPress/Ghost site for API publishing. + +**Fast path:** copy a recipe from [quickstart-recipes.md](quickstart-recipes.md) (Astro+GitHub, Hugo+GitLab, WordPress, Cloudinary, deploy hooks, …). ## 1. Install SourceDraft @@ -29,7 +31,9 @@ cp .env.example .env Edit paths, adapter, and categories to match your site. These values are safe to commit. -Use `astro-mdx` for `.mdx` output or `markdown` for `.md` output. See [adapters.md](adapters.md). +Pick an adapter for your stack (`astro-mdx`, `nextjs-mdx`, `hugo-markdown`, …). See the [adapters compatibility matrix](adapters.md#compatibility-matrix). + +Set `publisher` to `github`, `gitlab`, `bitbucket`, `wordpress`, or `ghost`. See [publishers.md](publishers.md). Validate anytime: @@ -53,8 +57,8 @@ GITHUB_BRANCH=main | File | Holds | |------|--------| -| `sourcedraft.config.json` | `contentDir`, `mediaDir`, `publicMediaPath`, categories, adapter | -| `.env` | Password, GitHub token, repo owner/name | +| `sourcedraft.config.json` | `contentDir`, `mediaDir`, `publicMediaPath`, categories, `adapter`, `publisher` | +| `.env` | Password, publisher credentials, optional `CMS_MEDIA_PROVIDER`, deploy hook | See [configuration.md](configuration.md) for the full split. @@ -96,11 +100,11 @@ The publish API also exposes `GET /api/health/setup` (authenticated) with the sa 1. **Posts** sidebar — open an existing post, or click **New post** 2. Fill title and description in the center canvas; set slug, dates, and category in **Post details**; upload a cover image if needed ([media.md](media.md)) 3. Check the Markdown or MDX preview and output path -4. **Publish to GitHub** +4. **Publish** — button label reflects your publisher (for example **Publish to GitHub**) -SourceDraft validates, builds the file with your adapter, and commits to `contentDir/.mdx` or `.md`. +SourceDraft validates, renders with your adapter, and sends to the configured publisher (git commit or remote CMS API). -How that commit works: [github-publishing.md](github-publishing.md) +Git publishers: [git-publishers.md](git-publishers.md) · GitHub: [github-publishing.md](github-publishing.md) · WordPress: [wordpress.md](wordpress.md) · Ghost: [ghost.md](ghost.md) ## Smoke tests (Playwright) diff --git a/docs/media.md b/docs/media.md index 89ef2a5..0a37ddb 100644 --- a/docs/media.md +++ b/docs/media.md @@ -48,11 +48,21 @@ Listing uses the same GitHub session as publish and post listing. Only allowed f Set `CMS_MEDIA_PROVIDER` in `.env` (default: `github-media`). +### Compatibility matrix + +| Provider | Status | Upload images | Upload PDF | Media library list | Requires git publisher | Official docs | +|----------|--------|---------------|------------|-------------------|------------------------|---------------| +| `github-media` | Shipped | Yes | Yes | Yes | Yes (GitHub/GitLab/Bitbucket) | [GitHub Contents](https://docs.github.com/en/rest/repos/contents) | +| `cloudinary` | Shipped | Yes | No | No (git list only) | No | [Cloudinary Upload API](https://cloudinary.com/documentation/image_upload_api_reference) | +| `s3-compatible` | Experimental | No | No | No | No | [Cloudflare R2](https://developers.cloudflare.com/r2/) | + +### Environment variables + | Provider | Env vars | Notes | |----------|----------|-------| | `github-media` | Uses publisher credentials | Commits to `mediaDir` via git publisher; supports images + PDF | | `cloudinary` | `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`, optional `CLOUDINARY_FOLDER` | Images only (PNG, JPEG, GIF, WebP); returns secure CDN URL | -| `s3-compatible` | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, optional `S3_PUBLIC_BASE_URL`, `S3_FORCE_PATH_STYLE` | **Experimental** — config validation only; upload not implemented yet | +| `s3-compatible` | `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, optional `S3_PUBLIC_BASE_URL`, `S3_FORCE_PATH_STYLE` | Config validation only; upload not implemented yet | Upload API response includes `url`, `provider`, and optional `metadata` in addition to legacy `publicPath` / `repoPath` fields. diff --git a/docs/non-technical-overview.md b/docs/non-technical-overview.md index d9fda8e..001541e 100644 --- a/docs/non-technical-overview.md +++ b/docs/non-technical-overview.md @@ -1,60 +1,69 @@ # Non-technical overview -SourceDraft is a writing tool for blogs whose posts live as files in GitHub — common with static site generators like Astro. +SourceDraft is a writing tool for blogs whose posts live as files in Git (Astro, Hugo, Next.js, …) or on platforms like WordPress and Ghost. ## The problem it solves -Each post is usually one file: a short metadata block at the top (title, date, category) and your article text below. That is reliable, but everyday writing can mean: +Each post is usually one file or one API record: metadata (title, date, category) plus your article text. That is reliable, but everyday writing can mean: - fixing slug and date mistakes by hand - guessing which file path will appear in the repo - switching between an editor, GitHub, and your build tool -SourceDraft keeps the writing and publish steps in one interface. +SourceDraft keeps writing, preview, and publish in one interface. ## What you do in Studio 1. Sign in with the admin password (set once by whoever installed SourceDraft) -2. Write your post — title, description, category, tags, body -3. Upload cover and inline images to your repo (optional) -4. Preview the exact output file SourceDraft will commit (MDX or Markdown) -5. Publish to GitHub when validation passes +2. Write your post — title, description, category, tags, body, optional SEO fields +3. Upload cover and inline images (to your git repo or Cloudinary, depending on setup) +4. Preview the exact output file or fields SourceDraft will send +5. Publish when validation passes There are no traffic charts, billing screens, or account tiers. ## What SourceDraft does not do - Host or serve your public website -- Replace Astro or your current site builder -- Host images on external CDNs (uploads go to your GitHub repo's media folder) -- Manage comments, email lists, or analytics +- Replace Astro, Hugo, or your current site builder +- Provide WordPress-style comments, plugins, or full media library for remote CMS targets +- Manage team accounts or OAuth login (one shared password today) -After publish, your normal site build and deploy process runs unchanged. +After publish to a **git** target, your normal site build and deploy runs unchanged. After publish to **WordPress/Ghost**, content appears in that CMS — your static site is unaffected unless you wire something else. -## How publishing reaches GitHub +## How publishing works SourceDraft does not log into GitHub in your browser. When you publish: 1. Your article is checked on the server -2. It is turned into an MDX file -3. A secure token (stored in `.env`, not shown to you in the page) commits the file to your site repository +2. The adapter turns it into Markdown/MDX (for preview and git publishers) or structured fields (for CMS APIs) +3. A secure token in `.env` (never shown in the page) commits the file or calls the remote API -You need a GitHub repo for your blog and a token with permission to add or update files there. Without that setup, you can still write and preview — publish will stay disabled. +Without publisher credentials, you can still write and preview — publish stays disabled or runs in **demo mode**. ## Two kinds of settings -**`sourcedraft.config.json`** — where posts go, which categories appear, which adapter is used. Safe to share with your team or commit to git. +**`sourcedraft.config.json`** — where posts go, which categories appear, which adapter and publisher. Safe to share or commit. -**`.env`** — password, GitHub token, and which repository to write to. Private; never commit. +**`.env`** — password, API tokens, repository targets, optional Cloudinary or deploy hook. Private; never commit. -Your technical contact can run **`pnpm setup`** once — a guided wizard that creates both files with plain-language questions — or edit them manually. Writers typically only need the Studio address and password. +Your technical contact can run **`pnpm setup`** once or edit files manually. Writers typically only need the Studio address and password. -In Studio **Settings**, a read-only status panel shows whether adapter, publisher, and credentials look complete (without showing secrets). +In Studio **Settings**, **Setup health** and **Compatibility & status** show whether configuration looks complete (without showing secrets). + +## Compared to other tools + +| You might know… | SourceDraft is… | +|-----------------|-----------------| +| Decap CMS | A local Studio + publish API; config in SourceDraft repo, not `admin/config.yml` in the site | +| TinaCMS | File-first with adapters; no Tina Cloud required | +| WordPress admin | Optional publisher only — not a full WP replacement | +| GitHub web editor | Validated fields, preview, media upload, SEO panel | ## Who sets it up? -Someone comfortable with GitHub tokens and environment files installs SourceDraft and points it at your blog repository. Writers use Studio after that. +Someone comfortable with API tokens and environment files installs SourceDraft and points it at your blog repository or CMS. Writers use Studio after that. -Steps: [getting-started.md](getting-started.md) +Steps: [getting-started.md](getting-started.md) · Recipes: [quickstart-recipes.md](quickstart-recipes.md) Astro folder layout reference: [examples/astro-blog](../examples/astro-blog/) (integration example, not a full website). diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..d78a8d0 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,120 @@ +# Plugins + +SourceDraft supports **server-side plugins** that register custom adapters, publishers, and media providers. There is no plugin marketplace, no frontend plugin loading, and no automatic npm installs. + +## Security warning + +Plugins are **executable JavaScript** loaded by the publish API on startup. Only load plugins you trust. Plugins cannot read `.env` or secrets unless you pass values explicitly through `publisherOptions` / provider config at runtime. Do not load remote URLs as plugins. + +## Plugin contract + +Each plugin module exports a manifest and `setup` function: + +```javascript +export const manifest = { + name: "my-plugin", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + description: "Optional human-readable summary", +}; + +export function setup(context) { + context.registerAdapter({ /* ... */ }); + // context.registerPublisher({ /* ... */ }); + // context.registerMediaProvider({ /* ... */ }); +} +``` + +### Manifest fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | yes | Stable plugin id (used in `requiredPlugins`) | +| `version` | yes | Plugin semver string | +| `requiresSourceDraft` | yes | Minimum SourceDraft version (`0.0.1` today) | +| `description` | no | Short explanation for logs/docs | + +### Setup context + +`setup(context)` receives only: + +| API | Purpose | +|-----|---------| +| `registerAdapter(adapter)` | Add a custom output adapter | +| `registerPublisher(factory)` | Add a publish target | +| `registerMediaProvider(factory)` | Add a media upload backend | +| `logger.info/warn/error` | Prefixed server logs | + +Plugins do **not** receive Express, Studio UI hooks, or full app internals. + +## Configuration + +In `sourcedraft.config.json`: + +```json +{ + "plugins": ["./plugins/my-adapter.js"], + "requiredPlugins": ["my-adapter-plugin"], + "discoverPlugins": false +} +``` + +| Field | Description | +|-------|-------------| +| `plugins` | Explicit entry paths, relative to the config file directory | +| `requiredPlugins` | Manifest `name` values that must load or startup fails | +| `discoverPlugins` | When `true`, also load `plugins/*.js` next to the config (one directory level, `.js`/`.mjs`/`.cjs` only) | + +Prefer explicit `plugins` paths over directory discovery. + +## Lifecycle + +1. **Discover** — collect explicit paths and optional `./plugins` files +2. **Validate manifest** — name, version, `requiresSourceDraft` +3. **Load** — dynamic `import()` of each file (server only) +4. **Setup** — call `setup(context)` to register adapters/publishers/providers +5. **Isolate failures** — optional plugins log a warning and continue; required plugins abort startup + +## Version compatibility + +`requiresSourceDraft` uses semver `major.minor.patch` comparison. A plugin requiring `0.0.1` runs on `0.0.1` and later patch releases with the same major/minor. + +When SourceDraft bumps versions, update your plugin manifest if you depend on new APIs. + +## Example + +See [examples/plugins/plain-text-adapter](../examples/plugins/plain-text-adapter/) — registers a `plain-text` adapter that writes `.txt` files. + +```json +{ + "adapter": "plain-text", + "plugins": ["./examples/plugins/plain-text-adapter/index.js"] +} +``` + +Restart the Studio API after plugin changes (`pnpm dev`). + +## Custom adapter plugin + +Implement the same `Adapter` shape as built-in adapters: + +- `id` — unique string (e.g. `my-format`) +- `previewMeta` — `{ label, extension }` +- `render(article)` — file contents +- `getPath(article, config)` — repo-relative path +- `fromFrontmatter(...)` — parse existing files for editing + +Register in `setup` via `context.registerAdapter(...)`. + +## Custom publisher / media provider + +Publishers implement `PublisherFactory` (`id`, `kind`, `capabilities`, `createPublisher`). Media providers implement `MediaProviderFactory`. See `@sourcedraft/publishers` and `@sourcedraft/media-providers` types in the monorepo — plugins use the same interfaces through `context.registerPublisher` / `registerMediaProvider`. + +Credentials belong in `.env` and `publisherOptions`; the factory receives `PublisherRuntimeConfig` when Studio creates a publisher instance. + +## Related docs + +- [adapters.md](adapters.md) +- [publishers.md](publishers.md) +- [media.md](media.md) +- [configuration.md](configuration.md) diff --git a/docs/project-status.md b/docs/project-status.md index aad6b85..f2a8ec5 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -1,63 +1,75 @@ # Project status -Early open-source MVP — usable for single-editor writing and GitHub publishing, not a full hosted CMS. - -## What works - -- Studio editor with universal article fields -- Post list and edit from GitHub (`contentDir`) for normal content folders -- Validation (`@sourcedraft/core`) -- Live preview for `astro-mdx` and `markdown` adapters -- GitHub file create/update for posts and media (`@sourcedraft/github-publisher`) -- Clearer GitHub API error messages for token, repo, path, and Contents API limits -- Image upload from Studio (PNG, JPEG, GIF, WebP; 5 MB max) with configurable `publicMediaPath` -- `sourcedraft.config.json` + `.env` configuration -- Server-side password auth for Studio and API -- **Demo mode** with sample posts and simulated publish/upload (no GitHub writes) -- **Setup health** checks in Settings and `GET /api/health/setup` -- CI: build, unit tests, and Playwright smoke tests (demo mode) on push/PR -- Release screenshots in `docs/assets/` (regenerate with `pnpm screenshots:generate`) - -## What does not work yet - -| Area | Today | +Early open-source MVP — usable for solo writing and publishing to Git or remote CMS APIs. Not a hosted multi-user CMS. + +**Honest summary:** SourceDraft ships eight file adapters, five publishers, two production media providers (plus experimental S3 config), deploy hooks, SEO fields, a setup wizard, demo mode, and a plugin loader for custom connectors. It does **not** ship OAuth, team roles, hosted Studio, or full S3/R2 uploads yet. + +## Shipped (production-ready for local/private use) + +| Area | Status | +|------|--------| +| **Studio** | Article editor, post list (git publishers), media library (git-backed), preview, SEO panel, setup/compatibility health | +| **Adapters** | `astro-mdx`, `markdown`, `nextjs-mdx`, `hugo-markdown`, `eleventy-jekyll-markdown`, `docusaurus-mdx`, `mkdocs-markdown`, `nuxt-content-markdown` | +| **Publishers** | `github`, `gitlab`, `bitbucket`, `wordpress`, `ghost` | +| **Media** | `github-media` (images + PDF), `cloudinary` (images) | +| **Deploy hooks** | `generic`, `vercel`, `netlify`, `cloudflare-pages` | +| **Config** | `sourcedraft.config.json` + `.env`, `pnpm setup`, `pnpm validate:config` | +| **Plugins** | Server-side loader for custom adapters/publishers/media providers | +| **SEO** | Optional frontmatter + Studio validation; WordPress/Ghost field mapping | +| **Auth** | Single shared password, HttpOnly session cookie (MVP) | +| **Demo mode** | Fixture posts, simulated publish/upload, Playwright smoke tests | +| **CI** | Build, typecheck, unit tests, e2e on push/PR | +| **Docs** | Getting started, recipes, per-connector guides, Studio screenshots in `docs/assets/` | + +## Experimental / partial + +| Area | Status | |------|--------| -| Auth | One shared password; no OAuth or accounts | -| Sessions | In-memory; lost when API restarts | -| 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`, `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 | +| `s3-compatible` media | Env validation only — **upload not implemented**; use `github-media` or `cloudinary` | +| Bitbucket publisher | Publish + media work; **no post list/read in Studio** yet | +| WordPress / Ghost | Publish + update with `remoteId`; **no post list in Studio**; body is Markdown/plain (Ghost uses `?source=html`) | +| Sessions | In-memory — lost when API restarts | +| Git listing scale | Contents API walk; ~1000 entries per folder; inline files ~1 MB | -## Demo mode (fixtures) +## Not shipped -Demo mode loads stable seed posts and media metadata from `apps/studio/server/demo/fixtures/`. On every API restart, the same fixtures are loaded again. Edits and simulated publishes during a session stay in memory only and are discarded when the process restarts. No GitHub commits are made. +| Area | Notes | +|------|-------| +| Hosted Studio SaaS | You run locally or on your own server | +| OAuth / user accounts / RBAC | One password for all Studio users | +| Markdown → HTML converter | Remote CMS publishers send body as-is | +| Media delete/rename in Studio | Upload + list only | +| Git Trees API indexer | Future improvement for very large repos | +| Hotlinked connector screenshots | Official doc links only; see [assets/screenshots/ATTRIBUTION.md](assets/screenshots/ATTRIBUTION.md) | -Details: [demo-mode.md](demo-mode.md) +## Comparison (why SourceDraft vs …) + +| Tool | SourceDraft today | Typical alternative | +|------|-------------------|---------------------| +| **Decap CMS** | Local Studio + your publish API; no `admin/config.yml` in the site repo | Git-backed editor embedded in static site | +| **TinaCMS** | File-first, adapter-driven output; no Tina Cloud required | Visual editing with Tina backend or self-hosted | +| **WordPress/Ghost admin** | Optional **publishers** — SourceDraft is not a full WP/Ghost replacement | Native CMS UI and media library | +| **Static dashboard** | Validates universal schema, previews exact file path, multi-adapter | Often framework-specific or hosted | -## Known limitations (demo mode) +SourceDraft fits when you want one editor for Markdown/MDX files **or** API publish to WP/Ghost, with secrets on the server only. -- Session edits and simulated uploads are in-memory only — restarting the API reloads fixtures. -- `SOURCEDRAFT_DEMO_MODE=true` disables all GitHub writes even if a token is configured. -- Demo mode is for exploration, smoke tests, and screenshots — not production publishing. +## Demo mode -## Known MVP limitations (GitHub) +Fixture-backed seed content from `apps/studio/server/demo/fixtures/`. Session edits are in-memory; API restart reloads fixtures. No remote commits when demo is active. -- **Contents API scale:** fine for small and medium blogs; very large content trees may be slow or hit listing limits. -- **No indexer:** post list walks the repo via the Contents API — no database or search index. -- **Future improvement:** Git Trees API or indexed content listing for large sites. +Details: [demo-mode.md](demo-mode.md) ## Intended use -Open-source tool for developers and technical bloggers who accept these limits. Setup required — not a turnkey WordPress replacement or hosted writing product. +Open-source tool for developers and technical bloggers who accept MVP auth and scale limits. Setup required — not a turnkey WordPress replacement. ## Origin -Built first for [QuBrite.com](https://qubrite.com). Core code stays generic; each site uses its own config and GitHub target. +Built first for [QuBrite.com](https://qubrite.com). Core stays generic; each site uses its own config and publisher target. -Publishing flow: [github-publishing.md](github-publishing.md) · Media: [media.md](media.md) +## References -Contributing: [../CONTRIBUTING.md](../CONTRIBUTING.md) · Releases: [../CHANGELOG.md](../CHANGELOG.md) +- Quickstart recipes: [quickstart-recipes.md](quickstart-recipes.md) +- Adapters matrix: [adapters.md](adapters.md) +- Publishers matrix: [publishers.md](publishers.md) +- Contributing: [../CONTRIBUTING.md](../CONTRIBUTING.md) · Changelog: [../CHANGELOG.md](../CHANGELOG.md) diff --git a/docs/publishers.md b/docs/publishers.md index ca453c6..7e017fb 100644 --- a/docs/publishers.md +++ b/docs/publishers.md @@ -11,6 +11,20 @@ SourceDraft publishers are **connectors** — they send validated article data t Set the active publisher in `sourcedraft.config.json` (`publisher`) or override with `CMS_PUBLISHER` in `.env`. +## Compatibility matrix + +| Publisher | Kind | Publish post | Upload media | List/read in Studio | Update existing | Official API docs | +|-----------|------|--------------|--------------|---------------------|-----------------|-------------------| +| `github` | Git | Yes | Yes (`github-media`) | Yes | Yes (`sourcePath`) | [GitHub Contents API](https://docs.github.com/en/rest/repos/contents) | +| `gitlab` | Git | Yes | Yes (`github-media`) | Yes | Yes (`sourcePath`) | [GitLab Repository Files](https://docs.gitlab.com/ee/api/repository_files.html) | +| `bitbucket` | Git | Yes | Yes (`github-media`) | No | Yes (`sourcePath`) | [Bitbucket Source API](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/) | +| `wordpress` | Remote CMS | Yes | No (use Cloudinary or git media with a git publisher) | No | Yes (`remoteId`) | [WP REST Posts](https://developer.wordpress.org/rest-api/reference/posts/) | +| `ghost` | Remote CMS | Yes | No | No | Yes (`remoteId`) | [Ghost Admin API](https://docs.ghost.org/admin-api/) | + +Connector doc screenshots (optional, maintainer-captured): [assets/screenshots/connectors/README.md](assets/screenshots/connectors/README.md) + +Quickstart copy-paste configs: [quickstart-recipes.md](quickstart-recipes.md) + ## Git file publishers - Render output comes from the selected [adapter](adapters.md) (frontmatter + body). diff --git a/docs/quickstart-recipes.md b/docs/quickstart-recipes.md new file mode 100644 index 0000000..ef4eae1 --- /dev/null +++ b/docs/quickstart-recipes.md @@ -0,0 +1,253 @@ +# Quickstart recipes + +Copy-paste starting points for common SourceDraft setups. Each recipe assumes you cloned SourceDraft, ran `pnpm install`, and configured `.env` (or used `pnpm setup`). + +See also: [getting-started.md](getting-started.md) · [configuration.md](configuration.md) · [examples/](../examples/) + +--- + +## Astro + GitHub + +**Best for:** Astro content collections with MDX posts. + +```json +{ + "adapter": "astro-mdx", + "publisher": "github", + "contentDir": "src/content/blog", + "mediaDir": "public/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Tutorials"] +} +``` + +```env +CMS_PUBLISHER=github +GITHUB_TOKEN=... +GITHUB_OWNER=your-org +GITHUB_REPO=your-astro-blog +CMS_MEDIA_PROVIDER=github-media +``` + +**Output path:** `src/content/blog/.mdx` + +**Example:** [examples/astro-blog](../examples/astro-blog/) + +--- + +## Next.js MDX + GitHub + +**Best for:** Next.js blogs using MDX files in `content/posts`. + +```json +{ + "adapter": "nextjs-mdx", + "publisher": "github", + "contentDir": "content/posts", + "mediaDir": "public/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "categories": ["Guides", "Tutorials"] +} +``` + +**Output path:** `content/posts/.mdx` (or `date-slug` / `index` via `adapterOptions`) + +**Example:** [examples/nextjs-mdx-blog](../examples/nextjs-mdx-blog/) + +--- + +## Hugo + GitLab + +**Best for:** Hugo sites with GitLab as the git remote. + +```json +{ + "adapter": "hugo-markdown", + "publisher": "gitlab", + "contentDir": "content/posts", + "mediaDir": "static/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "adapterOptions": { "frontmatterFormat": "yaml" } +} +``` + +```env +CMS_PUBLISHER=gitlab +GITLAB_TOKEN=... +GITLAB_PROJECT_PATH=group/your-hugo-site +GITLAB_BRANCH=main +``` + +**Output path:** `content/posts/.md` + +**Example:** [examples/hugo-blog](../examples/hugo-blog/) + +--- + +## Eleventy + Bitbucket + +**Best for:** Eleventy or Jekyll-style folders on Bitbucket Cloud. + +```json +{ + "adapter": "eleventy-jekyll-markdown", + "publisher": "bitbucket", + "contentDir": "src/posts", + "mediaDir": "src/assets/images", + "publicMediaPath": "/images", + "defaultBranch": "main", + "adapterOptions": { + "layout": "post", + "permalinkPrefix": "/blog/" + } +} +``` + +```env +CMS_PUBLISHER=bitbucket +BITBUCKET_TOKEN=... +BITBUCKET_WORKSPACE=your-workspace +BITBUCKET_REPO_SLUG=your-site +BITBUCKET_USERNAME=your-username +``` + +**Output path:** `src/posts/.md` + +**Note:** Bitbucket does not support listing posts in Studio yet — publish works; use GitHub/GitLab for the Posts sidebar. + +**Example:** [examples/eleventy-jekyll-blog](../examples/eleventy-jekyll-blog/) + +--- + +## WordPress publisher + +**Best for:** Publishing to an existing WordPress site via REST API (no git commits). + +```json +{ + "adapter": "markdown", + "publisher": "wordpress", + "contentDir": "content/posts", + "categories": ["News", "Guides"] +} +``` + +```env +CMS_PUBLISHER=wordpress +WORDPRESS_API_URL=https://example.com/wp-json +WORDPRESS_USERNAME=editor +WORDPRESS_APP_PASSWORD=... +WORDPRESS_DEFAULT_STATUS=draft +``` + +**Remote:** Creates/updates posts via `/wp/v2/posts`. Pass `remoteId` on update. SEO plugin meta requires explicit `publisherOptions.wordpressSeoMeta` mapping. + +Details: [wordpress.md](wordpress.md) + +--- + +## Ghost publisher + +**Best for:** Ghost(Pro) or self-hosted Ghost Admin API. + +```json +{ + "adapter": "markdown", + "publisher": "ghost", + "contentDir": "content/posts", + "categories": ["Guides"] +} +``` + +```env +CMS_PUBLISHER=ghost +GHOST_ADMIN_URL=https://your-site.com +GHOST_ADMIN_API_KEY=id:secret +GHOST_DEFAULT_STATUS=draft +``` + +**Remote:** HTML body via `?source=html`. Updates need `remoteId` from a prior publish. + +Details: [ghost.md](ghost.md) + +--- + +## Cloudinary media + +**Best for:** CDN-hosted images while still using a git publisher for posts. + +```json +{ + "adapter": "astro-mdx", + "publisher": "github", + "contentDir": "src/content/blog", + "mediaDir": "public/images", + "publicMediaPath": "/images" +} +``` + +```env +CMS_MEDIA_PROVIDER=cloudinary +CLOUDINARY_CLOUD_NAME=... +CLOUDINARY_API_KEY=... +CLOUDINARY_API_SECRET=... +CLOUDINARY_FOLDER=blog +``` + +Uploads return `https://res.cloudinary.com/...` URLs in article Markdown. Media library listing remains git-backed. + +Details: [media.md](media.md) + +--- + +## R2 / S3-compatible media (experimental) + +**Status:** Config validation only — upload not implemented yet. Use `github-media` or `cloudinary` for production. + +```env +CMS_MEDIA_PROVIDER=s3-compatible +S3_ENDPOINT=https://.r2.cloudflarestorage.com +S3_BUCKET=your-bucket +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... +S3_PUBLIC_BASE_URL=https://cdn.example.com +S3_FORCE_PATH_STYLE=true +``` + +Details: [media.md](media.md) · connector docs: [assets/screenshots/connectors/README.md](assets/screenshots/connectors/README.md) + +--- + +## Deploy hook after publish + +Trigger a static site rebuild when a git publish succeeds: + +```env +DEPLOY_HOOK_URL=https://api.vercel.com/v1/integrations/deploy/... +DEPLOY_HOOK_PROVIDER=vercel +DEPLOY_HOOK_METHOD=POST +DEPLOY_HOOK_STRICT=false +``` + +| Provider | `DEPLOY_HOOK_PROVIDER` | +|----------|------------------------| +| Vercel | `vercel` | +| Netlify | `netlify` | +| Cloudflare Pages | `cloudflare-pages` | +| Other CI | `generic` | + +Details: [deploy-hooks.md](deploy-hooks.md) + +--- + +## Validate before first publish + +```bash +pnpm validate:config +pnpm validate:config --connections +``` + +Open Studio → **Settings** → **Compatibility & status** for a read-only summary (no secrets). diff --git a/docs/security.md b/docs/security.md index 27c518c..9c89350 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,21 +2,31 @@ ## Secrets stay on the server -- `GITHUB_TOKEN` — used only in the publish API when committing posts, listing/reading files, and uploading media -- `SOURCEDRAFT_ADMIN_PASSWORD` — checked only on the server at login - -Studio stores a session cookie after login. It does not store the GitHub token or admin password in the browser. +All credentials are read from `.env` in the publish API only. Studio stores a session cookie after login — never tokens or passwords in the browser. + +| Secret | Used for | +|--------|----------| +| `SOURCEDRAFT_ADMIN_PASSWORD` | Studio login | +| `GITHUB_*` | GitHub Contents API (publish, list, media) | +| `GITLAB_*` | GitLab Repository Files API | +| `BITBUCKET_*` | Bitbucket commit-upload API | +| `WORDPRESS_*` | WordPress REST API | +| `GHOST_*` | Ghost Admin API | +| `CLOUDINARY_*` | Cloudinary upload API | +| `S3_*` | S3-compatible config validation (upload not implemented) | +| `DEPLOY_HOOK_URL` | Post-publish build webhook | + +Never commit `.env`. Never put secrets in `sourcedraft.config.json`. ## Demo mode -When `SOURCEDRAFT_DEMO_MODE=true` or GitHub is not fully configured: +When `SOURCEDRAFT_DEMO_MODE=true` or the active publisher is not fully configured: -- Studio serves sample posts from server memory — not your repository. -- `POST /api/publish` and `POST /api/media/upload` simulate success and **never call the GitHub API**. -- Forced demo mode (`SOURCEDRAFT_DEMO_MODE=true`) blocks GitHub writes even if `GITHUB_TOKEN` is set. -- Demo sessions use the same HttpOnly cookie as password login; no secrets are stored in the browser. +- Sample posts load from server fixtures — not your repository. +- `POST /api/publish` and `POST /api/media/upload` simulate success and **never call remote APIs**. +- Forced demo mode blocks all remote writes even if credentials are set. -Use demo mode for local exploration and smoke tests only. **MVP password auth is still intended for local/private use.** +Use demo mode for local exploration and smoke tests only. ## Session cookies (MVP) @@ -24,53 +34,58 @@ After login, the server sets an in-memory session cookie: | Attribute | Behavior | |-----------|----------| -| `HttpOnly` | JavaScript cannot read the cookie — reduces token theft via XSS | -| `SameSite=Lax` | Browser limits cross-site cookie use on unsafe requests | -| `Secure` | Set only when running under HTTPS (`NODE_ENV=production`, `X-Forwarded-Proto: https`, or `STUDIO_SECURE_COOKIES=true`) | +| `HttpOnly` | JavaScript cannot read the cookie | +| `SameSite=Lax` | Limits cross-site cookie use | +| `Secure` | When HTTPS (`NODE_ENV=production`, `X-Forwarded-Proto: https`, or `STUDIO_SECURE_COOKIES=true`) | | `Max-Age` | 24 hours | -This is MVP session handling, not durable account auth. Sessions are stored in server memory and reset when the process restarts. +Sessions reset when the API process restarts. This is not durable account auth. ## Request protection for state-changing routes -These routes use lightweight same-site checks before handling the request: +Protected routes include login, logout, publish, and media upload. Middleware checks `Sec-Fetch-Site` or `Origin`/`Referer` and rejects obvious cross-site POSTs. Optional `STUDIO_ALLOWED_ORIGINS` for reverse-proxy deployments. + +This is basic MVP hardening — not a substitute for CSRF tokens, rate limiting, or production auth on a public deployment. -- `POST /api/auth/login` -- `POST /api/auth/logout` -- `POST /api/publish` -- `POST /api/media/upload` +## Server-only publisher access -The middleware: +All publisher and media API calls run in `apps/studio/server`: -1. Uses `Sec-Fetch-Site` when the browser sends it — allows `same-origin`, `same-site`, and `none`; rejects `cross-site` -2. Falls back to `Origin` / `Referer` validation when Fetch Metadata is absent -3. Allows loopback origins during local development (`localhost`, `127.0.0.1`) -4. Does not enable CORS wildcards +| Endpoint | Credentials | +|----------|-------------| +| `POST /api/publish` | Active publisher token | +| `GET /api/posts` | Git publisher only (GitHub, GitLab) | +| `POST /api/media/upload` | Media provider + git publisher when needed | +| `GET /api/media` | Git-backed media list | +| `GET /api/health/setup` | Safe diagnostics (no secret values) | -Optional: set `STUDIO_ALLOWED_ORIGINS` (comma-separated full origins) when deploying behind a reverse proxy. +The client sends article JSON or multipart uploads. The server attaches credentials from `.env`. -Login uses the same middleware. It is safe for the local Studio UI because the browser issues same-origin requests through the Vite dev proxy (`/api` → publish API). Unauthenticated login still benefits from blocking obvious cross-site POST attempts. +Do not import publisher packages in browser code. -This is basic MVP hardening — not a substitute for CSRF tokens, rate limiting, or full production auth on a public deployment. +## Per-publisher notes -## Server-only GitHub access +**GitHub / GitLab / Bitbucket** — Tokens need repository write access for publish and media. Use fine-scoped tokens where your host allows. Rotate on leak. -All GitHub API calls run in `apps/studio/server`: +**WordPress** — Application passwords (not your main account password). REST API over HTTPS only. SEO plugin meta requires explicit opt-in via `publisherOptions`. -| Endpoint | Token use | -|----------|-----------| -| `POST /api/publish` | Create or update post files | -| `GET /api/posts` | List and load posts from `contentDir` | -| `POST /api/media/upload` | Commit image files to `mediaDir` | -| `GET /api/health/setup` | Safe setup diagnostics (authenticated; no secrets) | +**Ghost** — Admin API key (`id:secret`) is full admin access to content. Store only on the server. -The client sends article JSON, post path queries, or multipart uploads. The server attaches credentials from `.env`. +**Cloudinary** — API secret must not reach the browser. Uploaded images are public CDN URLs by default. -Do not import `@sourcedraft/github-publisher` in browser code. +**Deploy hooks** — Treat hook URLs like passwords; anyone with the URL can trigger builds. ## Media uploads -Uploads are validated for allowed image types, maximum size (5 MB), and file signature before commit. Filenames are sanitized. See [media.md](media.md). +Uploads validate allowed types, size limits (5 MB images, 10 MB PDF), and file signatures. Filenames are sanitized; path traversal is blocked. No SVG, HTML, executables, or ZIP. + +Details: [media.md](media.md) + +## Plugins + +Custom plugins load only on the server at API startup. Review third-party plugin code before enabling — plugins can register publishers with network access. + +Details: [plugins.md](plugins.md) ## Files @@ -82,10 +97,6 @@ Uploads are validated for allowed image types, maximum size (5 MB), and file sig Single shared password, in-memory sessions. -**MVP password auth is intended for local/private use. Do not expose Studio publicly without HTTPS, stronger auth, and deployment hardening.** - -This is not a multi-tenant production auth system yet. - -Report security concerns privately; do not include live tokens in reports or public issue templates. +**Intended for local/private use.** Do not expose Studio publicly without HTTPS, stronger auth, and deployment hardening. -When filing bugs, redact tokens, passwords, and private repository details. See [CONTRIBUTING.md](../CONTRIBUTING.md). +Report security concerns privately; redact tokens in bug reports. See [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/examples/docusaurus-blog/README.md b/examples/docusaurus-blog/README.md index fba8e29..62035dd 100644 --- a/examples/docusaurus-blog/README.md +++ b/examples/docusaurus-blog/README.md @@ -30,3 +30,13 @@ blog/2024-06-01-getting-started-with-sourcedraft.mdx 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. + +## How to publish + +1. Copy `adapter`, `contentDir`, and `adapterOptions` into SourceDraft’s `sourcedraft.config.json`. +2. Set `.env` for your git publisher (`GITHUB_*`, `GITLAB_*`, or `BITBUCKET_*`). +3. In Studio: **New post** → fill fields → confirm preview path under `blog/` → **Publish**. +4. Add the new file to Docusaurus if your setup requires manual registration (plugin usually picks up `blog/*.mdx` automatically). +5. Run your normal Docusaurus build or CI. + +Recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md) diff --git a/examples/eleventy-jekyll-blog/README.md b/examples/eleventy-jekyll-blog/README.md index 3692527..9b48c91 100644 --- a/examples/eleventy-jekyll-blog/README.md +++ b/examples/eleventy-jekyll-blog/README.md @@ -32,3 +32,13 @@ Set `layout` to match your site's layout name. Permalink defaults to `//`; ## Sample output See [`src/posts/getting-started-with-sourcedraft.md`](src/posts/getting-started-with-sourcedraft.md). + +## How to publish + +1. Choose Eleventy (`src/posts`) or Jekyll (`_posts`) config from this folder’s example JSON files. +2. Copy `adapter`, `contentDir`, and `adapterOptions` into SourceDraft’s `sourcedraft.config.json`. +3. For Bitbucket: set `publisher`: `bitbucket` and `BITBUCKET_*` in `.env` (publish works; post list in Studio is not available yet). +4. Publish from Studio → confirm filename (`slug` or `YYYY-MM-DD-slug` for Jekyll). +5. Run Eleventy/Jekyll build as usual. + +Bitbucket recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md#eleventy--bitbucket) diff --git a/examples/hugo-blog/README.md b/examples/hugo-blog/README.md index 1b495dc..c618496 100644 --- a/examples/hugo-blog/README.md +++ b/examples/hugo-blog/README.md @@ -31,3 +31,13 @@ content/posts/getting-started-with-sourcedraft.md ## Sample output See [`content/posts/getting-started-with-sourcedraft.md`](content/posts/getting-started-with-sourcedraft.md). + +## How to publish + +1. Copy config into SourceDraft’s `sourcedraft.config.json` (`adapter`: `hugo-markdown`). +2. Set `mediaDir` / `publicMediaPath` to match your Hugo `static/` layout. +3. Configure git publisher in `.env` (GitHub, GitLab, or Bitbucket). +4. Publish from Studio → `content/posts/.md` in your Hugo repo. +5. Run `hugo` or your CI deploy. + +GitLab recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md#hugo--gitlab) diff --git a/examples/mkdocs-blog/README.md b/examples/mkdocs-blog/README.md index 3cc4a9a..d0194de 100644 --- a/examples/mkdocs-blog/README.md +++ b/examples/mkdocs-blog/README.md @@ -29,3 +29,12 @@ nav: - Blog: - Getting started: docs/getting-started-with-sourcedraft.md ``` + +## How to publish + +1. Copy config into SourceDraft’s `sourcedraft.config.json` (`adapter`: `mkdocs-markdown`, `contentDir`: `docs`). +2. Configure git publisher credentials in `.env`. +3. Publish from Studio; check the **nav hint** in preview and add the path to `mkdocs.yml`. +4. Run `mkdocs build` or your usual docs pipeline. + +Recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md) diff --git a/examples/nextjs-mdx-blog/README.md b/examples/nextjs-mdx-blog/README.md index a347797..6564c8e 100644 --- a/examples/nextjs-mdx-blog/README.md +++ b/examples/nextjs-mdx-blog/README.md @@ -27,4 +27,13 @@ content/posts/getting-started-with-sourcedraft.mdx 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. +Your Next.js app must read these files from `contentDir` and render MDX as you already do. SourceDraft only writes files to your git remote. + +## How to publish + +1. Copy `sourcedraft.config.json` values into SourceDraft root config. +2. Set `GITHUB_*` (or GitLab/Bitbucket) in `.env` pointing at your Next.js blog repo. +3. **New post** in Studio → preview `content/posts/.mdx` → **Publish**. +4. Run `pnpm build` or your Next.js CI. + +Recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md#nextjs-mdx--github) diff --git a/examples/nuxt-content-blog/README.md b/examples/nuxt-content-blog/README.md index 4302bf2..283e4b1 100644 --- a/examples/nuxt-content-blog/README.md +++ b/examples/nuxt-content-blog/README.md @@ -27,3 +27,12 @@ content/blog/getting-started-with-sourcedraft.md 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. + +## How to publish + +1. Align `contentDir` with your Nuxt Content collection path (`content/blog` in this example). +2. Copy `sourcedraft.config.json` fields into SourceDraft root config; set `.env` for GitHub/GitLab/Bitbucket. +3. Publish from Studio → file appears at `content/blog/.md`. +4. Run your Nuxt build; Content picks up new files from the configured directory. + +Recipe: [docs/quickstart-recipes.md](../../docs/quickstart-recipes.md) diff --git a/examples/plugins/plain-text-adapter/README.md b/examples/plugins/plain-text-adapter/README.md new file mode 100644 index 0000000..a67c264 --- /dev/null +++ b/examples/plugins/plain-text-adapter/README.md @@ -0,0 +1,23 @@ +# Plain text adapter plugin + +Example server-side plugin that registers a `plain-text` adapter. Output files use `.txt` extension with a simple title + body layout. + +## Enable + +In `sourcedraft.config.json` at the repo root: + +```json +{ + "adapter": "plain-text", + "contentDir": "content/posts", + "plugins": ["./examples/plugins/plain-text-adapter/index.js"] +} +``` + +Restart the Studio API server after changing plugins. + +## Notes + +- Plugins run **only on the server** when the publish API starts. +- This plugin does not receive secrets — it only registers an adapter. +- See [docs/plugins.md](../../docs/plugins.md) for the full plugin contract. diff --git a/examples/plugins/plain-text-adapter/index.js b/examples/plugins/plain-text-adapter/index.js new file mode 100644 index 0000000..81486e2 --- /dev/null +++ b/examples/plugins/plain-text-adapter/index.js @@ -0,0 +1,55 @@ +/** + * Example SourceDraft plugin — registers a plain-text (.txt) adapter. + * Add to sourcedraft.config.json: + * "plugins": ["./examples/plugins/plain-text-adapter/index.js"], + * "adapter": "plain-text" + */ + +export const manifest = { + name: "plain-text-adapter", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + description: "Writes articles as simple .txt files with a title line and body.", +}; + +export function setup(context) { + context.registerAdapter({ + id: "plain-text", + previewMeta: { + label: "Plain text preview", + extension: "txt", + }, + render(article) { + const lines = [article.title, "", article.description, "", article.body]; + if (article.updatedDate) { + lines.push("", `Updated: ${article.updatedDate}`); + } + return `${lines.join("\n").trimEnd()}\n`; + }, + getPath(article, config) { + return `${config.contentDir}/${article.slug}.txt`; + }, + fromFrontmatter(path, frontmatter, body, slugFromPathFn) { + const slug = + typeof frontmatter.slug === "string" && frontmatter.slug.trim().length > 0 + ? frontmatter.slug.trim() + : slugFromPathFn(path); + + return { + title: typeof frontmatter.title === "string" ? frontmatter.title : slug, + slug, + description: + typeof frontmatter.description === "string" ? frontmatter.description : "", + pubDate: + typeof frontmatter.pubDate === "string" + ? frontmatter.pubDate + : new Date().toISOString().slice(0, 10), + category: + typeof frontmatter.category === "string" ? frontmatter.category : "Guides", + tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [], + draft: frontmatter.draft === true, + body, + }; + }, + }); +} diff --git a/package.json b/package.json index 6972193..0cf73b8 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "lint": "pnpm -r lint", "setup": "tsx packages/setup/src/cli.ts setup", "validate:config": "tsx packages/setup/src/cli.ts validate", - "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter studio test", + "test": "pnpm --filter @sourcedraft/core --filter @sourcedraft/adapter-astro-mdx --filter @sourcedraft/adapter-markdown --filter @sourcedraft/adapter-nextjs-mdx --filter @sourcedraft/adapter-hugo-markdown --filter @sourcedraft/adapter-eleventy-jekyll-markdown --filter @sourcedraft/adapter-docusaurus-mdx --filter @sourcedraft/adapter-mkdocs-markdown --filter @sourcedraft/adapter-nuxt-content-markdown --filter @sourcedraft/adapters --filter @sourcedraft/publishers --filter @sourcedraft/media-providers --filter @sourcedraft/plugins --filter @sourcedraft/setup --filter @sourcedraft/github-publisher --filter studio test", "test:e2e": "pnpm --filter studio test:e2e", - "screenshots:generate": "pnpm --filter studio screenshots:generate" + "screenshots:generate": "pnpm --filter studio screenshots:generate", + "capture-doc-screenshots": "cd apps/studio && pnpm exec tsx ../../scripts/capture-doc-screenshots.ts" }, "devDependencies": { "tsx": "^4.20.3" diff --git a/packages/adapters/src/types.ts b/packages/adapters/src/types.ts index 2a1a936..ce7e3ac 100644 --- a/packages/adapters/src/types.ts +++ b/packages/adapters/src/types.ts @@ -11,7 +11,8 @@ export const ADAPTER_IDS = [ "nuxt-content-markdown", ] as const; -export type AdapterId = (typeof ADAPTER_IDS)[number]; +/** Built-in adapter ids; plugins may register additional string ids at runtime. */ +export type AdapterId = string; export type AdapterPathConfig = { contentDir: string; diff --git a/packages/config/src/loadConfig.ts b/packages/config/src/loadConfig.ts index 3c2ac9d..93ff362 100644 --- a/packages/config/src/loadConfig.ts +++ b/packages/config/src/loadConfig.ts @@ -61,6 +61,10 @@ export function normalizeSourceDraftConfig( ? (input.publisherOptions as Record) : undefined; + const plugins = normalizeStringArray(input.plugins); + const requiredPlugins = normalizeStringArray(input.requiredPlugins); + const discoverPlugins = input.discoverPlugins === true; + return { adapter: isNonEmptyString(input.adapter) ? input.adapter.trim() @@ -83,9 +87,28 @@ export function normalizeSourceDraftConfig( categories: categories ?? DEFAULT_SOURCEDRAFT_CONFIG.categories, ...(adapterOptions !== undefined ? { adapterOptions } : {}), ...(publisherOptions !== undefined ? { publisherOptions } : {}), + ...(plugins !== undefined ? { plugins } : {}), + ...(requiredPlugins !== undefined ? { requiredPlugins } : {}), + ...(discoverPlugins ? { discoverPlugins } : {}), }; } +function normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const items: string[] = []; + for (const item of value) { + if (!isNonEmptyString(item)) { + return undefined; + } + items.push(item.trim()); + } + + return items.length > 0 ? items : undefined; +} + export function resolveConfigPath(cwd: string): string | null { const explicitPath = process.env.SOURCEDRAFT_CONFIG?.trim(); if (explicitPath && existsSync(explicitPath)) { diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 28e2cad..1ba18b9 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -12,6 +12,12 @@ export type SourceDraftConfig = { categories: string[]; adapterOptions?: Record; publisherOptions?: Record; + /** Server-side plugin entry paths, relative to sourcedraft.config.json */ + plugins?: string[]; + /** Plugin manifest names that must load successfully or startup fails */ + requiredPlugins?: string[]; + /** When true, also load *.js/*.mjs/*.cjs from ./plugins next to config */ + discoverPlugins?: boolean; }; export const DEFAULT_SOURCEDRAFT_CONFIG: SourceDraftConfig = { diff --git a/packages/media-providers/src/types.ts b/packages/media-providers/src/types.ts index d71e49e..c1d4cdc 100644 --- a/packages/media-providers/src/types.ts +++ b/packages/media-providers/src/types.ts @@ -1,6 +1,7 @@ export const MEDIA_PROVIDER_IDS = ["github-media", "cloudinary", "s3-compatible"] as const; -export type MediaProviderId = (typeof MEDIA_PROVIDER_IDS)[number]; +/** Built-in media provider ids; plugins may register additional string ids at runtime. */ +export type MediaProviderId = string; export type MediaUploadInput = { buffer: Buffer; diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 0000000..eb65ea2 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sourcedraft/plugins", + "version": "0.0.1", + "private": true, + "description": "Server-side plugin loader for SourceDraft adapters, publishers, and media providers.", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "test": "node --import tsx --test 'src/**/*.test.ts'" + }, + "dependencies": { + "@sourcedraft/adapters": "workspace:*", + "@sourcedraft/media-providers": "workspace:*", + "@sourcedraft/publishers": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "tsx": "^4.20.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/plugins/src/context.ts b/packages/plugins/src/context.ts new file mode 100644 index 0000000..4ca2415 --- /dev/null +++ b/packages/plugins/src/context.ts @@ -0,0 +1,25 @@ +import { registerAdapter } from "@sourcedraft/adapters"; +import { registerMediaProvider } from "@sourcedraft/media-providers"; +import { registerPublisher } from "@sourcedraft/publishers"; +import { createPluginLogger } from "./logger.js"; +import type { PluginContext } from "./types.js"; + +export function createPluginContext(pluginName: string): PluginContext { + const logger = createPluginLogger(pluginName); + + return { + registerAdapter(adapter) { + logger.info(`Registered adapter "${adapter.id}".`); + registerAdapter(adapter); + }, + registerPublisher(factory) { + logger.info(`Registered publisher "${factory.id}".`); + registerPublisher(factory); + }, + registerMediaProvider(factory) { + logger.info(`Registered media provider "${factory.id}".`); + registerMediaProvider(factory); + }, + logger, + }; +} diff --git a/packages/plugins/src/discover.ts b/packages/plugins/src/discover.ts new file mode 100644 index 0000000..82122e7 --- /dev/null +++ b/packages/plugins/src/discover.ts @@ -0,0 +1,99 @@ +import { existsSync, readdirSync } from "node:fs"; +import { extname, resolve } from "node:path"; + +const ALLOWED_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]); + +export function isPathInsideRoot(rootDir: string, candidatePath: string): boolean { + const root = resolve(rootDir); + const candidate = resolve(candidatePath); + return candidate === root || candidate.startsWith(`${root}/`); +} + +export function resolvePluginEntryPath( + configDir: string, + pluginPath: string, +): { ok: true; path: string } | { ok: false; error: string } { + const trimmed = pluginPath.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Plugin path is empty." }; + } + + if (trimmed.includes("\0")) { + return { ok: false, error: "Plugin path is invalid." }; + } + + const resolved = resolve(configDir, trimmed); + if (!isPathInsideRoot(configDir, resolved)) { + return { ok: false, error: `Plugin path escapes config directory: ${pluginPath}` }; + } + + if (!existsSync(resolved)) { + return { ok: false, error: `Plugin file not found: ${pluginPath}` }; + } + + return { ok: true, path: resolved }; +} + +export function discoverLocalPluginPaths(configDir: string): string[] { + const pluginsDir = resolve(configDir, "plugins"); + if (!existsSync(pluginsDir)) { + return []; + } + + const paths: string[] = []; + + for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) { + if (!entry.isFile()) { + continue; + } + + const extension = extname(entry.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(extension)) { + continue; + } + + if (entry.name.startsWith(".")) { + continue; + } + + paths.push(`plugins/${entry.name}`); + } + + return paths.sort(); +} + +export function collectPluginEntryPaths(options: { + configDir: string; + plugins?: string[]; + discoverPlugins?: boolean; +}): Array<{ path: string; source: string }> { + const entries: Array<{ path: string; source: string }> = []; + const seen = new Set(); + + for (const pluginPath of options.plugins ?? []) { + const resolved = resolvePluginEntryPath(options.configDir, pluginPath); + const key = resolved.ok ? resolved.path : pluginPath; + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push({ path: resolved.ok ? resolved.path : pluginPath, source: pluginPath }); + } + + if (options.discoverPlugins === true) { + for (const discoveredPath of discoverLocalPluginPaths(options.configDir)) { + const resolved = resolvePluginEntryPath(options.configDir, discoveredPath); + const key = resolved.ok ? resolved.path : discoveredPath; + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push({ + path: resolved.ok ? resolved.path : discoveredPath, + source: discoveredPath, + }); + } + } + + return entries; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 0000000..0029e87 --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,25 @@ +export { createPluginContext } from "./context.js"; +export { createPluginLogger } from "./logger.js"; +export { + collectPluginEntryPaths, + discoverLocalPluginPaths, + isPathInsideRoot, + resolvePluginEntryPath, +} from "./discover.js"; +export { extractPluginModule, validatePluginManifest } from "./manifest.js"; +export { loadPlugins, loadPluginsOrThrow } from "./loader.js"; +export { + SOURCEDRAFT_VERSION, + parseVersion, + satisfiesSourceDraftVersion, +} from "./version.js"; + +export type { + LoadPluginsOptions, + PluginContext, + PluginLoadFailure, + PluginLoadReport, + PluginLogger, + SourceDraftPluginManifest, + SourceDraftPluginModule, +} from "./types.js"; diff --git a/packages/plugins/src/loader.test.ts b/packages/plugins/src/loader.test.ts new file mode 100644 index 0000000..394159e --- /dev/null +++ b/packages/plugins/src/loader.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { isAdapterId, listAdapterIds } from "@sourcedraft/adapters"; +import { loadPlugins, loadPluginsOrThrow } from "./loader.js"; + +function writePlugin(dir: string, filename: string, body: string): string { + const filePath = join(dir, filename); + writeFileSync(filePath, body, "utf8"); + return filePath; +} + +describe("plugin loader", () => { + it("loads a plugin that registers an adapter", async () => { + const dir = mkdtempSync(join(tmpdir(), "sourcedraft-plugins-")); + const relativePath = "plugins/plain-text.js"; + mkdirSync(join(dir, "plugins"), { recursive: true }); + + writeFileSync( + join(dir, relativePath), + ` +export const manifest = { + name: "plain-text-adapter", + version: "1.0.0", + requiresSourceDraft: "0.0.1", +}; + +export function setup(context) { + context.registerAdapter({ + id: "plain-text", + previewMeta: { label: "Plain text", extension: "txt" }, + render(article) { + return article.title + "\\n\\n" + article.body; + }, + getPath(article, config) { + return config.contentDir + "/" + article.slug + ".txt"; + }, + fromFrontmatter(path, frontmatter, body, slugFromPath) { + return { + title: frontmatter.title ?? "Untitled", + slug: slugFromPath(path), + description: frontmatter.description ?? "", + pubDate: "2024-01-01", + category: "Guides", + tags: [], + draft: false, + body, + }; + }, + }); +} +`, + "utf8", + ); + + const beforeCount = listAdapterIds().length; + const report = await loadPlugins({ + configDir: dir, + plugins: [relativePath], + }); + + assert.equal(report.ok, true); + assert.deepEqual(report.loaded, ["plain-text-adapter"]); + assert.equal(isAdapterId("plain-text"), true); + assert.ok(listAdapterIds().length >= beforeCount); + }); + + it("isolates optional plugin setup failures", async () => { + const dir = mkdtempSync(join(tmpdir(), "sourcedraft-plugins-fail-")); + writePlugin( + dir, + "broken.js", + ` +export const manifest = { + name: "broken-plugin", + version: "1.0.0", + requiresSourceDraft: "0.0.1", +}; +export function setup() { + throw new Error("setup exploded"); +} +`, + ); + + const report = await loadPlugins({ + configDir: dir, + plugins: ["./broken.js"], + }); + + assert.equal(report.ok, true); + assert.equal(report.loaded.length, 0); + assert.equal(report.failures.length, 1); + assert.match(report.failures[0]?.error ?? "", /setup exploded/); + }); + + it("fails when a required plugin does not load", async () => { + const dir = mkdtempSync(join(tmpdir(), "sourcedraft-plugins-required-")); + writePlugin( + dir, + "invalid.js", + `export const manifest = { name: "bad" };`, + ); + + const report = await loadPlugins({ + configDir: dir, + plugins: ["./invalid.js"], + requiredPlugins: ["./invalid.js"], + }); + + assert.equal(report.ok, false); + assert.equal(report.failures[0]?.required, true); + + await assert.rejects( + () => + loadPluginsOrThrow({ + configDir: dir, + plugins: ["./invalid.js"], + requiredPlugins: ["./invalid.js"], + }), + /Required plugin/, + ); + }); + + it("discovers plugins from local plugins directory when enabled", async () => { + const dir = mkdtempSync(join(tmpdir(), "sourcedraft-plugins-discover-")); + mkdirSync(join(dir, "plugins"), { recursive: true }); + writePlugin( + join(dir, "plugins"), + "auto.js", + ` +export const manifest = { + name: "auto-discovered", + version: "1.0.0", + requiresSourceDraft: "0.0.1", +}; +export function setup(context) { + context.logger.info("discovered"); +} +`, + ); + + const report = await loadPlugins({ + configDir: dir, + discoverPlugins: true, + }); + + assert.equal(report.ok, true); + assert.deepEqual(report.loaded, ["auto-discovered"]); + }); +}); diff --git a/packages/plugins/src/loader.ts b/packages/plugins/src/loader.ts new file mode 100644 index 0000000..acb12d5 --- /dev/null +++ b/packages/plugins/src/loader.ts @@ -0,0 +1,135 @@ +import { pathToFileURL } from "node:url"; +import { collectPluginEntryPaths, resolvePluginEntryPath } from "./discover.js"; +import { createPluginContext } from "./context.js"; +import { extractPluginModule } from "./manifest.js"; +import type { LoadPluginsOptions, PluginLoadFailure, PluginLoadReport } from "./types.js"; +import { SOURCEDRAFT_VERSION, satisfiesSourceDraftVersion } from "./version.js"; + +function isPluginRequired( + pluginName: string | null, + sourceLabel: string, + requiredNames: Set, +): boolean { + if (pluginName && requiredNames.has(pluginName)) { + return true; + } + + return requiredNames.has(sourceLabel); +} + +async function loadPluginFromAbsolutePath( + absolutePath: string, + sourceLabel: string, + requiredNames: Set, + sourceDraftVersion: string, +): Promise<{ loadedName: string } | PluginLoadFailure> { + let imported: Record; + try { + const mod = await import(pathToFileURL(absolutePath).href); + imported = mod as Record; + } catch (error) { + const message = error instanceof Error ? error.message : "Plugin import failed."; + return { + path: sourceLabel, + name: null, + error: message, + required: isPluginRequired(null, sourceLabel, requiredNames), + }; + } + + const extracted = extractPluginModule(imported); + if (!extracted.ok) { + return { + path: sourceLabel, + name: null, + error: extracted.error, + required: isPluginRequired(null, sourceLabel, requiredNames), + }; + } + + const { plugin } = extracted; + const required = isPluginRequired(plugin.name, sourceLabel, requiredNames); + + if (!satisfiesSourceDraftVersion(plugin.requiresSourceDraft, sourceDraftVersion)) { + return { + path: sourceLabel, + name: plugin.name, + error: `Plugin requires SourceDraft ${plugin.requiresSourceDraft} but running ${sourceDraftVersion}.`, + required, + }; + } + + const context = createPluginContext(plugin.name); + + try { + await plugin.setup(context); + } catch (error) { + const message = error instanceof Error ? error.message : "Plugin setup failed."; + return { + path: sourceLabel, + name: plugin.name, + error: message, + required, + }; + } + + return { loadedName: plugin.name }; +} + +export async function loadPlugins(options: LoadPluginsOptions): Promise { + const sourceDraftVersion = options.sourceDraftVersion ?? SOURCEDRAFT_VERSION; + const requiredNames = new Set(options.requiredPlugins ?? []); + const entries = collectPluginEntryPaths({ + configDir: options.configDir, + ...(options.plugins !== undefined ? { plugins: options.plugins } : {}), + ...(options.discoverPlugins !== undefined + ? { discoverPlugins: options.discoverPlugins } + : {}), + }); + + const loaded: string[] = []; + const failures: PluginLoadFailure[] = []; + + for (const entry of entries) { + const resolved = resolvePluginEntryPath(options.configDir, entry.source); + if (!resolved.ok) { + failures.push({ + path: entry.source, + name: null, + error: resolved.error, + required: isPluginRequired(null, entry.source, requiredNames), + }); + continue; + } + + const result = await loadPluginFromAbsolutePath( + resolved.path, + entry.source, + requiredNames, + sourceDraftVersion, + ); + + if ("loadedName" in result) { + loaded.push(result.loadedName); + } else { + failures.push(result); + } + } + + const ok = failures.every((failure) => !failure.required); + + return { ok, loaded, failures }; +} + +export async function loadPluginsOrThrow(options: LoadPluginsOptions): Promise { + const report = await loadPlugins(options); + if (!report.ok) { + const summary = report.failures + .filter((failure) => failure.required) + .map((failure) => `${failure.name ?? failure.path}: ${failure.error}`) + .join("; "); + throw new Error(`Required plugin(s) failed to load: ${summary}`); + } + + return report; +} diff --git a/packages/plugins/src/logger.ts b/packages/plugins/src/logger.ts new file mode 100644 index 0000000..4c6a630 --- /dev/null +++ b/packages/plugins/src/logger.ts @@ -0,0 +1,17 @@ +import type { PluginLogger } from "./types.js"; + +export function createPluginLogger(pluginName: string): PluginLogger { + const prefix = `[plugin:${pluginName}]`; + + return { + info(message: string) { + console.log(`${prefix} ${message}`); + }, + warn(message: string) { + console.warn(`${prefix} ${message}`); + }, + error(message: string) { + console.error(`${prefix} ${message}`); + }, + }; +} diff --git a/packages/plugins/src/manifest.test.ts b/packages/plugins/src/manifest.test.ts new file mode 100644 index 0000000..1724a26 --- /dev/null +++ b/packages/plugins/src/manifest.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { extractPluginModule, validatePluginManifest } from "./manifest.js"; + +describe("plugin manifest", () => { + it("validates a complete manifest", () => { + const result = validatePluginManifest({ + name: "demo", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + description: "Demo plugin", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.manifest.name, "demo"); + assert.equal(result.manifest.description, "Demo plugin"); + } + }); + + it("rejects invalid manifest", () => { + const result = validatePluginManifest({ name: "demo" }); + assert.equal(result.ok, false); + }); + + it("extracts setup from module exports", () => { + const result = extractPluginModule({ + name: "demo", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + setup() {}, + }); + + assert.equal(result.ok, true); + }); + + it("extracts manifest and setup from separate exports", () => { + const result = extractPluginModule({ + manifest: { + name: "demo", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + }, + setup() {}, + }); + + assert.equal(result.ok, true); + }); + + it("rejects module without setup", () => { + const result = extractPluginModule({ + name: "demo", + version: "1.0.0", + requiresSourceDraft: "0.0.1", + }); + + assert.equal(result.ok, false); + }); +}); diff --git a/packages/plugins/src/manifest.ts b/packages/plugins/src/manifest.ts new file mode 100644 index 0000000..16c10eb --- /dev/null +++ b/packages/plugins/src/manifest.ts @@ -0,0 +1,75 @@ +import type { SourceDraftPluginManifest, SourceDraftPluginModule } from "./types.js"; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function validatePluginManifest( + value: unknown, +): { ok: true; manifest: SourceDraftPluginManifest } | { ok: false; error: string } { + if (value === null || typeof value !== "object") { + return { ok: false, error: "Plugin manifest must be an object." }; + } + + const record = value as Record; + + if (!isNonEmptyString(record.name)) { + return { ok: false, error: "Plugin manifest requires a non-empty name." }; + } + + if (!isNonEmptyString(record.version)) { + return { ok: false, error: "Plugin manifest requires a version string." }; + } + + if (!isNonEmptyString(record.requiresSourceDraft)) { + return { + ok: false, + error: "Plugin manifest requires requiresSourceDraft (min SourceDraft version).", + }; + } + + const manifest: SourceDraftPluginManifest = { + name: record.name.trim(), + version: record.version.trim(), + requiresSourceDraft: record.requiresSourceDraft.trim(), + }; + + if (isNonEmptyString(record.description)) { + manifest.description = record.description.trim(); + } + + return { ok: true, manifest }; +} + +export function extractPluginModule( + imported: Record, +): { ok: true; plugin: SourceDraftPluginModule } | { ok: false; error: string } { + let candidate: Record = imported; + + if (imported.plugin !== undefined && typeof imported.plugin === "object") { + candidate = imported.plugin as Record; + } else if (imported.manifest !== undefined && typeof imported.manifest === "object") { + candidate = { + ...(imported.manifest as Record), + setup: imported.setup, + }; + } + + const manifestResult = validatePluginManifest(candidate); + if (!manifestResult.ok) { + return manifestResult; + } + + const setup = candidate.setup ?? imported.setup; + if (typeof setup !== "function") { + return { ok: false, error: "Plugin module must export a setup function." }; + } + + return { + ok: true, + plugin: { + ...manifestResult.manifest, + setup: setup as SourceDraftPluginModule["setup"], + }, + }; +} diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts new file mode 100644 index 0000000..9c9dde0 --- /dev/null +++ b/packages/plugins/src/types.ts @@ -0,0 +1,48 @@ +import type { Adapter } from "@sourcedraft/adapters"; +import type { MediaProviderFactory } from "@sourcedraft/media-providers"; +import type { PublisherFactory } from "@sourcedraft/publishers"; + +export type PluginLogger = { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; +}; + +export type PluginContext = { + registerAdapter: (adapter: Adapter) => void; + registerPublisher: (factory: PublisherFactory) => void; + registerMediaProvider: (factory: MediaProviderFactory) => void; + logger: PluginLogger; +}; + +export type SourceDraftPluginManifest = { + name: string; + version: string; + requiresSourceDraft: string; + description?: string; +}; + +export type SourceDraftPluginModule = SourceDraftPluginManifest & { + setup: (context: PluginContext) => void | Promise; +}; + +export type PluginLoadFailure = { + path: string; + name: string | null; + error: string; + required: boolean; +}; + +export type PluginLoadReport = { + ok: boolean; + loaded: string[]; + failures: PluginLoadFailure[]; +}; + +export type LoadPluginsOptions = { + configDir: string; + plugins?: string[]; + requiredPlugins?: string[]; + discoverPlugins?: boolean; + sourceDraftVersion?: string; +}; diff --git a/packages/plugins/src/version.ts b/packages/plugins/src/version.ts new file mode 100644 index 0000000..b46e080 --- /dev/null +++ b/packages/plugins/src/version.ts @@ -0,0 +1,35 @@ +export const SOURCEDRAFT_VERSION = "0.0.1"; + +export function parseVersion(value: string): [number, number, number] | null { + const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(value.trim()); + if (!match) { + return null; + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function satisfiesSourceDraftVersion( + required: string, + actual: string, +): boolean { + const requiredParts = parseVersion(required); + const actualParts = parseVersion(actual); + + if (requiredParts === null || actualParts === null) { + return false; + } + + for (let index = 0; index < 3; index += 1) { + const requiredPart = requiredParts[index] ?? 0; + const actualPart = actualParts[index] ?? 0; + if (actualPart < requiredPart) { + return false; + } + if (actualPart > requiredPart) { + return true; + } + } + + return true; +} diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 0000000..03771a5 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "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/publishers/src/types.ts b/packages/publishers/src/types.ts index 61a570f..8c372d4 100644 --- a/packages/publishers/src/types.ts +++ b/packages/publishers/src/types.ts @@ -6,7 +6,8 @@ export const PUBLISHER_IDS = [ "ghost", ] as const; -export type PublisherId = (typeof PUBLISHER_IDS)[number]; +/** Built-in publisher ids; plugins may register additional string ids at runtime. */ +export type PublisherId = string; /** Git publishers commit files to a repository; remote CMS publishers call HTTP APIs. */ export type PublisherKind = "git" | "remote-cms"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0952a64..9cc1299 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@sourcedraft/media-providers': specifier: workspace:* version: link:../../packages/media-providers + '@sourcedraft/plugins': + specifier: workspace:* + version: link:../../packages/plugins '@sourcedraft/publishers': specifier: workspace:* version: link:../../packages/publishers @@ -310,6 +313,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/plugins: + dependencies: + '@sourcedraft/adapters': + specifier: workspace:* + version: link:../adapters + '@sourcedraft/media-providers': + specifier: workspace:* + version: link:../media-providers + '@sourcedraft/publishers': + specifier: workspace:* + version: link:../publishers + devDependencies: + '@types/node': + specifier: ^22.15.30 + version: 22.19.19 + tsx: + specifier: ^4.20.3 + version: 4.22.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/publishers: dependencies: '@sourcedraft/core': diff --git a/scripts/capture-doc-screenshots.ts b/scripts/capture-doc-screenshots.ts new file mode 100644 index 0000000..0e7cb03 --- /dev/null +++ b/scripts/capture-doc-screenshots.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Capture documentation screenshots from official third-party API docs. + * + * LEGAL: Only run with --confirm-attribution after verifying each site's + * terms of use, robots.txt, and brand guidelines allow saving screenshots + * in an open-source repository. Do not commit captures without updating + * docs/assets/screenshots/ATTRIBUTION.md. + * + * Usage: + * pnpm capture-doc-screenshots -- --confirm-attribution + * + * Requires Playwright (installed in apps/studio). + */ + +import { mkdirSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(__dirname, "../docs/assets/screenshots/connectors"); + +const TARGETS = [ + { + id: "gitlab-repository-files-api", + url: "https://docs.gitlab.com/ee/api/repository_files.html", + owner: "GitLab Inc.", + }, + { + id: "bitbucket-source-api", + url: "https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/", + owner: "Atlassian", + }, + { + id: "wordpress-rest-posts", + url: "https://developer.wordpress.org/rest-api/reference/posts/", + owner: "WordPress Foundation", + }, + { + id: "ghost-admin-api", + url: "https://docs.ghost.org/admin-api/", + owner: "Ghost Foundation", + }, + { + id: "cloudinary-upload-api", + url: "https://cloudinary.com/documentation/image_upload_api_reference", + owner: "Cloudinary Ltd.", + }, + { + id: "cloudflare-r2-s3", + url: "https://developers.cloudflare.com/r2/", + owner: "Cloudflare, Inc.", + }, +] as const; + +async function loadPlaywright(): Promise { + try { + return await import("@playwright/test"); + } catch { + console.error( + "Playwright not found. Install via apps/studio:\n pnpm --filter studio install\nThen run from repo root with NODE_PATH or:\n cd apps/studio && pnpm exec tsx ../../scripts/capture-doc-screenshots.ts --confirm-attribution", + ); + process.exit(1); + } +} + +async function main(): Promise { + const confirm = process.argv.includes("--confirm-attribution"); + + if (!confirm) { + console.log("Connector doc screenshot capture\n"); + console.log("This script saves PNG files from official third-party documentation."); + console.log("Screenshots are NOT captured by default.\n"); + console.log("Before capturing:"); + console.log(" 1. Verify each site's terms, robots.txt, and brand guidelines."); + console.log(" 2. Update docs/assets/screenshots/ATTRIBUTION.md after committing.\n"); + console.log("Targets:"); + for (const target of TARGETS) { + console.log(` - ${target.id}: ${target.url}`); + } + console.log("\nRun with: pnpm capture-doc-screenshots -- --confirm-attribution"); + process.exit(0); + } + + const { chromium } = await loadPlaywright(); + mkdirSync(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + }); + + const stamp = new Date().toISOString().slice(0, 10); + + for (const target of TARGETS) { + const outPath = resolve(OUT_DIR, `${target.id}.png`); + console.log(`Capturing ${target.url} → ${outPath}`); + + const page = await context.newPage(); + try { + await page.goto(target.url, { waitUntil: "networkidle", timeout: 60_000 }); + await page.screenshot({ path: outPath, fullPage: false }); + console.log(` OK (${target.owner}, ${stamp})`); + } catch (error) { + console.error( + ` Failed: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } finally { + await page.close(); + } + } + + await browser.close(); + + console.log("\nDone. Update docs/assets/screenshots/ATTRIBUTION.md before committing images."); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/sourcedraft.config.example.json b/sourcedraft.config.example.json index 4a3c056..9c1a44d 100644 --- a/sourcedraft.config.example.json +++ b/sourcedraft.config.example.json @@ -7,5 +7,8 @@ "defaultBranch": "main", "categories": ["Guides", "Notes", "Reviews", "Tutorials", "Reference"], "adapterOptions": {}, - "publisherOptions": {} + "publisherOptions": {}, + "plugins": [], + "requiredPlugins": [], + "discoverPlugins": false }