diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4282300
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+NEXT_PUBLIC_SITE_URL=https://dripnex.app
+NEXT_PUBLIC_DOCS_URL=https://docs.dripnex.app
+NEXT_PUBLIC_DEVELOPERS_URL=https://developers.dripnex.app
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6ebe4e3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,24 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '22'
+ cache: 'pnpm'
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm typecheck
+ - run: pnpm build
+ env:
+ NEXT_PUBLIC_SITE_URL: https://dripnex.app
+ NEXT_PUBLIC_DOCS_URL: https://docs.dripnex.app
+ NEXT_PUBLIC_DEVELOPERS_URL: https://dripnex-developers.pages.dev
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b4d1521
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.next/
+.source/
+node_modules/
+out/
+.vercel
+.env
+.env.local
+*.tsbuildinfo
+.wrangler/
diff --git a/README.md b/README.md
index 8e8f86a..5be6a98 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,29 @@
-# developers
-Dripnex developer docs — plugin and theme API (Fumadocs). Mirrors developers.inkdrop.app.
+# Dripnex developers
+
+Plugin and theme API docs for Dripnex. Fumadocs + Next.js static export on Cloudflare Pages.
+
+Modeled on [developers.inkdrop.app](https://developers.inkdrop.app/). The user manual lives in [`dripnex/docs-site`](https://github.com/dripnex/docs-site) ([docs.dripnex.app](https://docs.dripnex.app)). Marketing lives in [`dripnex/marketing`](https://github.com/dripnex/marketing).
+
+The API described here is `@dripnex/plugin-api` in [`dripnex/app`](https://github.com/dripnex/app) (`packages/plugin-api`, default branch `develop`). `PLUGIN_API_VERSION` is `"1"`.
+
+## Develop
+
+```bash
+pnpm install
+pnpm dev
+```
+
+Content is MDX under `content/docs/`. The site is served at `/`.
+
+## Deploy
+
+```bash
+NEXT_PUBLIC_SITE_URL=https://dripnex.app \
+NEXT_PUBLIC_DOCS_URL=https://docs.dripnex.app \
+NEXT_PUBLIC_DEVELOPERS_URL=https://dripnex-developers.pages.dev \
+pnpm deploy
+```
+
+Pages project: `dripnex-developers` → https://dripnex-developers.pages.dev
+
+When DNS is ready, CNAME `developers` → `dripnex-developers.pages.dev` (proxied).
diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx
new file mode 100644
index 0000000..7ae8e29
--- /dev/null
+++ b/app/[[...slug]]/page.tsx
@@ -0,0 +1,61 @@
+import { notFound } from 'next/navigation';
+import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page';
+import defaultMdxComponents from 'fumadocs-ui/mdx';
+import { Card, Cards } from 'fumadocs-ui/components/card';
+import { Callout } from 'fumadocs-ui/components/callout';
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
+import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
+import { File, Folder, Files } from 'fumadocs-ui/components/files';
+import { TypeTable } from 'fumadocs-ui/components/type-table';
+import { source } from '@/lib/source';
+
+const mdxComponents = {
+ ...defaultMdxComponents,
+ Card,
+ Cards,
+ Callout,
+ Step,
+ Steps,
+ Tab,
+ Tabs,
+ Accordion,
+ Accordions,
+ File,
+ Folder,
+ Files,
+ TypeTable,
+};
+
+export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
+ const params = await props.params;
+ const page = source.getPage(params.slug);
+ if (!page) notFound();
+
+ const MDX = page.data.body;
+
+ return (
+
+ {page.data.title}
+ {page.data.description}
+
+
+
+
+ );
+}
+
+export function generateStaticParams() {
+ return source.generateParams();
+}
+
+export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
+ const params = await props.params;
+ const page = source.getPage(params.slug);
+ if (!page) notFound();
+
+ return {
+ title: page.data.title,
+ description: page.data.description,
+ };
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..5de9cf3
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,135 @@
+@import 'tailwindcss';
+@import 'fumadocs-ui/css/neutral.css';
+@import 'fumadocs-ui/css/preset.css';
+
+@theme {
+ --color-background: #09090b;
+ --color-surface: #111113;
+ --color-surface-elevated: #1a1a1f;
+ --color-inset: #0c0c0e;
+ --color-border: #27272a;
+ --color-border-accent: rgba(255, 255, 255, 0.12);
+
+ --color-text-primary: #fafafa;
+ --color-text-secondary: #a1a1aa;
+ --color-text-muted: #52525b;
+
+ --color-accent: #a1a1aa;
+ --color-accent-hover: #d4d4d8;
+ --color-accent-glow: rgba(255, 255, 255, 0.06);
+
+ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
+ --font-mono: 'JetBrains Mono Variable', ui-monospace, monospace;
+}
+
+:root,
+.dark {
+ --rd-violet: #8b5cf6;
+ --rd-violet-light: #a78bfa;
+ --rd-violet-lighter: #c4b5fd;
+ --rd-violet-glow-10: rgba(139, 92, 246, 0.1);
+ --rd-violet-glow-12: rgba(139, 92, 246, 0.12);
+ --rd-violet-glow-20: rgba(139, 92, 246, 0.2);
+ --rd-violet-glow-30: rgba(139, 92, 246, 0.3);
+ --rd-violet-glow-08: rgba(139, 92, 246, 0.08);
+ --rd-surface: #111113;
+ --rd-inset: #0c0c0e;
+ --rd-foreground: #fafafa;
+ --rd-muted-foreground: #a1a1aa;
+ --rd-subtle-foreground: #e4e4e7;
+ --rd-faint: #52525b;
+ --rd-border-subtle: rgba(255, 255, 255, 0.06);
+ --rd-border: rgba(255, 255, 255, 0.08);
+
+ --color-fd-background: #09090b;
+ --color-fd-foreground: var(--rd-foreground);
+ --color-fd-muted: var(--rd-surface);
+ --color-fd-muted-foreground: var(--rd-muted-foreground);
+ --color-fd-popover: var(--rd-surface);
+ --color-fd-popover-foreground: var(--rd-subtle-foreground);
+ --color-fd-card: var(--rd-surface);
+ --color-fd-card-foreground: var(--rd-foreground);
+ --color-fd-border: var(--rd-border);
+ --color-fd-primary: var(--rd-violet);
+ --color-fd-primary-foreground: #ffffff;
+ --color-fd-secondary: #1a1a1f;
+ --color-fd-secondary-foreground: var(--rd-subtle-foreground);
+ --color-fd-accent: var(--rd-violet-glow-12);
+ --color-fd-accent-foreground: var(--rd-subtle-foreground);
+ --color-fd-ring: var(--rd-violet);
+}
+
+.fd-sidebar {
+ --fd-sidebar-width: 260px;
+}
+
+.fd-sidebar [data-active='true'] {
+ color: var(--rd-violet) !important;
+}
+
+nav[data-fumadocs] {
+ border-bottom: 1px solid var(--rd-border-subtle);
+}
+
+pre:has(code) {
+ background: var(--rd-inset) !important;
+ border: 1px solid var(--rd-border-subtle);
+ border-radius: 0.75rem;
+}
+
+:not(pre) > code {
+ background: var(--rd-violet-glow-10) !important;
+ color: var(--rd-violet-lighter) !important;
+ border: 1px solid var(--rd-violet-glow-20);
+ border-radius: 0.375rem;
+ padding: 0.125rem 0.375rem;
+ font-size: 0.875em;
+}
+
+[data-toc] a[data-active='true'] {
+ color: var(--rd-violet);
+ border-left-color: var(--rd-violet);
+}
+
+.fd-card {
+ background: var(--rd-surface);
+ border-color: var(--rd-border);
+ transition:
+ border-color 0.2s,
+ box-shadow 0.2s;
+}
+
+.fd-card:hover {
+ border-color: var(--rd-violet-glow-30);
+ box-shadow: 0 0 30px var(--rd-violet-glow-08);
+}
+
+[data-fumadocs-search] {
+ --color-fd-background: var(--rd-inset);
+ --color-fd-popover: var(--rd-surface);
+}
+
+nav[aria-label='Breadcrumb'] {
+ color: var(--rd-faint);
+}
+
+nav[aria-label='Breadcrumb'] a:hover {
+ color: var(--rd-violet);
+}
+
+.fd-page h1,
+.fd-page h2,
+.fd-page h3,
+.fd-page h4 {
+ color: var(--rd-foreground);
+}
+
+.fd-page a:not([class]) {
+ color: var(--rd-violet-light);
+ text-decoration-color: var(--rd-violet-glow-30);
+}
+
+.fd-page a:not([class]):hover {
+ color: var(--rd-violet-lighter);
+ text-decoration-color: var(--rd-violet);
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..62ad604
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,76 @@
+import './globals.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
+import '@fontsource/inter/700.css';
+import '@fontsource-variable/jetbrains-mono';
+import { RootProvider } from 'fumadocs-ui/provider/next';
+import { DocsLayout } from 'fumadocs-ui/layouts/docs';
+import type { ReactNode } from 'react';
+import type { Metadata } from 'next';
+import { source } from '@/lib/source';
+import { baseOptions } from '@/lib/layout.shared';
+import { DEVELOPERS_URL, DOCS_URL } from '@/lib/site';
+
+export const metadata: Metadata = {
+ metadataBase: new URL(DEVELOPERS_URL),
+ title: {
+ default: 'Dripnex Developers',
+ template: '%s | Dripnex Developers',
+ },
+ description:
+ 'Plugin and theme API for Dripnex — extend the desktop editor with commands, CodeMirror, layout zones, and palettes.',
+ applicationName: 'Dripnex Developers',
+ icons: {
+ icon: [
+ { url: '/favicon.ico' },
+ { url: '/favicon.png', type: 'image/png', sizes: '32x32' },
+ { url: '/icon.png', type: 'image/png', sizes: '512x512' },
+ ],
+ apple: [{ url: '/apple-touch-icon.png', sizes: '180x180' }],
+ },
+ openGraph: {
+ type: 'website',
+ url: DEVELOPERS_URL,
+ siteName: 'Dripnex Developers',
+ images: [{ url: '/icon.png', width: 512, height: 512, alt: 'Dripnex' }],
+ },
+ twitter: {
+ card: 'summary',
+ images: ['/icon.png'],
+ },
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+ Desktop plugin API.{' '}
+
+ User manual
+
+
+
+ ),
+ }}
+ >
+ {children}
+
+
+
+
+ );
+}
diff --git a/app/not-found.tsx b/app/not-found.tsx
new file mode 100644
index 0000000..96a745f
--- /dev/null
+++ b/app/not-found.tsx
@@ -0,0 +1,19 @@
+import Link from 'next/link';
+
+export default function NotFound() {
+ return (
+
+
404
+
This page wandered off
+
+ That API page doesn't exist, or the URL changed.
+
+
+ Back to the API
+
+
+ );
+}
diff --git a/content/docs/getting-started/index.mdx b/content/docs/getting-started/index.mdx
new file mode 100644
index 0000000..cadc4a0
--- /dev/null
+++ b/content/docs/getting-started/index.mdx
@@ -0,0 +1,161 @@
+---
+title: Plugin structure
+description: How a Dripnex plugin repo is laid out — manifest, main, and package files
+---
+
+# Plugin structure
+
+A community plugin is **its own git repository**. It is not a folder inside `dripnex/app`. Version is the git tag. The install artifact is a tarball from `dripnex-plugin pack` attached to a GitHub release.
+
+## What Dripnex loads
+
+Two worlds share one `PluginHost`:
+
+| Kind | Where it lives | How it activates |
+| ---- | -------------- | ---------------- |
+| **Built-in** | Shipped in the desktop bundle | Enabled unless `plugin_registry.enabled === false` |
+| **Discovered** | `userData/plugins//` | Scanner → `loadPluginFromSource()` → same host |
+| **Hackable files** | Data directory (`init.js`, `styles.css`, `keybindings.json`) | `init.js` loads as plugin `user-init` |
+
+Discovered packs always live in user data. There is no extraResources plugin tree in the app bundle.
+
+### Plugins directory (packaged desktop)
+
+| OS | Path |
+| -- | ---- |
+| macOS | `~/Library/Application Support/Dripnex/plugins` |
+| Linux | `~/.config/Dripnex/plugins` |
+| Windows | `%APPDATA%/Dripnex/plugins` |
+
+Override with `DRIPNEX_DATA_DIR` or `--user-data-dir` if the CLI and the app disagree about the folder.
+
+## Repo layout
+
+`dripnex-plugin init "My Plugin"` writes:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+On disk after install, the scanner expects:
+
+```
+userData/plugins//
+ manifest.json # id, name, version, main?, configSchema?
+ # usually dist/index.js
+ keymaps/ menus/ styles/ themes/ # optional
+ theme.json # optional palette
+```
+
+A folder is skipped unless `manifest.main` exists **or** it is theme-only (`theme.json` / `themes/`).
+
+## Manifest
+
+`manifest.json` is the on-disk identity. The **evaluated** module must export the same `id` as `manifest.json`.
+
+```json
+{
+ "id": "stamp",
+ "name": "Stamp",
+ "version": "0.1.0",
+ "description": "Insert the current date or timestamp at the cursor.",
+ "main": "dist/index.js"
+}
+```
+
+| Field | Required | Rules |
+| ----- | -------- | ----- |
+| `id` | Yes | Kebab-case (`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`). Install folder name. |
+| `name` | Yes | Display name |
+| `version` | Yes | Semver `major.minor.patch` (optional pre-release / build) |
+| `description` | No | Short summary |
+| `main` | For JS packs | Path to the bundled CommonJS file |
+| `apiVersion` | No | Target plugin API major (current: `"1"`) |
+| `configSchema` | No | Settings fields rendered in Settings → Plugins |
+| `dependencies` | No | `pluginId` → semver range of other packs |
+
+`id`, `name`, `version`, and `activate` are also validated on the **exported** module (`validateManifest` in `@dripnex/plugin-api`).
+
+## The module must be CommonJS
+
+The renderer evaluates `main` with `new Function('module', 'exports', 'require', code)`. The export must be:
+
+```js
+module.exports = {
+ id: 'stamp',
+ name: 'Stamp',
+ version: '0.1.0',
+ activate(context) {
+ /* … */
+ return { dispose() { /* unregister */ } };
+ },
+};
+```
+
+If the filesystem `manifest.json` `id` and `module.exports.id` disagree, the pack is rejected.
+
+Host `require()` is a whitelist. Anything else must be **bundled into** `index.js`:
+
+- `react`, `react-dom`, `react/jsx-runtime`
+- `@codemirror/state`, `@codemirror/view`, `@codemirror/language`, `@codemirror/commands`, `@codemirror/search`
+- `@dripnex/plugin-api`
+
+That is why [dripnex/plugin-vim](https://github.com/dripnex/plugin-vim) bundles `@replit/codemirror-vim` instead of requiring it.
+
+## Declarative package files
+
+Applied **after** `activate()` from `packages/plugin-api` package-file parsers:
+
+| Path | Role |
+| ---- | ---- |
+| `keymaps/*.json` | Default chords for commands this pack already registered (`plugin::…` only) |
+| `menus/*.json` | Plugins menu + context menus |
+| `styles/*.css` | Injected stylesheets |
+| `theme.json` / `themes/*.json` | Token palettes |
+
+Keymap chords only bind if the command was registered in `activate()`. They cannot rebind core `app:` / `editor:` commands.
+
+## Plugin contract
+
+Keep markdown portable. These are product rules, not extra host checks:
+
+1. **Removable without breaking `.md` files** — uninstall and notes stay valid markdown.
+2. **No new syntax** other editors cannot read.
+3. **No automatic content mutation** — never rewrite the user's markdown without an explicit command.
+4. **Not required to interpret text** — notes must be readable without the pack.
+5. **No inter-note dependencies** that break if a note is moved or deleted.
+
+## Official satellites (verified)
+
+Install with `dripnex-plugin install owner/repo` or **Settings → Plugins → Connect**. Manifest id, GitHub repo, and any registry slug are **not** interchangeable.
+
+| Repo | Manifest id | What it actually does |
+| ---- | ----------- | --------------------- |
+| [dripnex/plugin-stamp](https://github.com/dripnex/plugin-stamp) | `stamp` | Insert date / timestamp at the cursor |
+| [dripnex/plugin-mermaid](https://github.com/dripnex/plugin-mermaid) | `mermaid` | Insert a ` ```mermaid ` fence. **No renderer.** Diagrams are the built-in `dripnex-mermaid` pack. |
+| [dripnex/plugin-math](https://github.com/dripnex/plugin-math) | `math` | Insert `$$…$$`. **No KaTeX.** Math rendering is the built-in `dripnex-math` pack. |
+| [dripnex/plugin-vim](https://github.com/dripnex/plugin-vim) | `dripnex-vim-mode` | Real `@replit/codemirror-vim`. Install spec is `dripnex/plugin-vim`, not the manifest id. |
+| [dripnex/theme-parchment](https://github.com/dripnex/theme-parchment) | `theme-parchment` | Official warm-paper palette. Many other palettes live in `dripnex/theme-*` repos. |
+
+Built-in mermaid, math, and tables **renderers** ship in the app. Do not treat the mermaid/math satellites as those renderers.
+
+Next: the [init file](/getting-started/init-file) and [style tweaks](/getting-started/style-tweaks), then [create a plugin](/guides/create-a-plugin).
diff --git a/content/docs/getting-started/init-file.mdx b/content/docs/getting-started/init-file.mdx
new file mode 100644
index 0000000..e46df66
--- /dev/null
+++ b/content/docs/getting-started/init-file.mdx
@@ -0,0 +1,86 @@
+---
+title: The init file
+description: Customize Dripnex on startup with init.js — the host PluginContext, not Inkdrop’s Atom APIs
+---
+
+# The init file
+
+`init.js` is a **user file** in the data directory, not a plugin repo. Open it from **Settings → Plugins**. Dripnex writes a template on first open.
+
+It is the same idea as Inkdrop's init file: run JavaScript at startup. The object you get is **Dripnex's** host API (`createInitApi` in `@dripnex/plugin-api`), not Inkdrop's Atom-style `inkdrop` global.
+
+## Two formats
+
+`loadInitScript()` accepts:
+
+1. **Free-form (default).** The file is wrapped as plugin `user-init` and runs at activate with a `dripnex` argument.
+2. **CommonJS `PluginManifest`.** If the file assigns `module.exports` / `exports.*` and looks like a manifest (`id`, `activate`, `name`, or `version`), it is validated and loaded as a normal pack.
+
+Free-form is what the default template uses (`dripnex.menu.add`, `dripnex.commands.add`). Do not assume a browser `window.dripnex` with a `.menu` — the host injects the init API as the `dripnex` function argument.
+
+```js
+dripnex.commands.add('paste-as-link', 'Paste as Link', () => {
+ const { from, to } = dripnex.editor.getSelection();
+ const text = dripnex.editor.getContent().slice(from, to);
+ dripnex.editor.replaceRange(from, to, '[' + text + '](url)');
+});
+```
+
+Reload plugins (**Settings → Plugins → Reload**) after saving.
+
+## The `dripnex` object (`InitApi`)
+
+| Surface | Notes |
+| ------- | ----- |
+| `editor`, `app`, `data`, `log`, `config`, `layout`, `decorations` | Same as `PluginContext` |
+| `store` | Read-only snapshot. **No** `store.dispatch`. |
+| `commands.add(id, name, execute, options?)` | Registers a command (palette) |
+| `commands.dispatch(id, payload?)` | Host or plugin command (`app:save-note`, `plugin:…`) |
+| `registerCommand`, `registerExtensions`, `registerAiCommand` | Same as context |
+| `registerCssVariables`, `registerTheme`, `themes` | Palettes |
+| `registerRemarkPlugin`, `registerRehypePlugin`, `registerPreviewComponent`, `registerCodeBlockRenderer` | Preview pipeline |
+| `menu`, `clipboard`, `notifications`, `contextMenu`, `preview`, `components`, `markdownRenderer` | Same as context |
+| `vim` | `@replit/codemirror-vim` `Vim` object **when** [plugin-vim](https://github.com/dripnex/plugin-vim) is loaded; otherwise unavailable |
+| `getActiveEditor()` | `{ editor, cm }` where `cm` is `editor.getView()` (live CodeMirror 6 view or `null`) |
+
+There is no Inkdrop `inkdrop.packages`, `CompositeDisposable`, or Atom command registry. Map keys with `dripnex.commands.add` / `registerCommand`, or with `Vim.map` after the Vim pack has called `registerVim`.
+
+## Read-only store
+
+```js
+const { editingNote, notes, navigation } = dripnex.store.getState();
+
+const unsub = dripnex.store.subscribe(() => {
+ const { editingNote: next } = dripnex.store.getState();
+ dripnex.log.debug(next.id, next.isDirty);
+});
+```
+
+- `notes.items` is the **visible list in this window**, not the whole library. Query with `dripnex.app.listNotes()` or `dripnex.data.getNotes()`.
+- Mutate through `dripnex.commands.dispatch`, `editor`, or `data`.
+- `settings` is appearance only (theme, accent, zoom). It never includes API keys.
+
+## Vim maps (after installing Vim)
+
+Vim is **not** built-in. Install `dripnex/plugin-vim` (manifest id `dripnex-vim-mode`) and enable it. The pack publishes `dripnex.vim` via `registerVim` so init can map keys:
+
+```js
+const Vim = dripnex.vim;
+if (Vim) {
+ Vim.map('jj', '', 'insert');
+ Vim.map('Y', 'y$');
+ Vim.defineEx('find', 'f', () => {
+ void dripnex.commands.dispatch('app:focus-search');
+ });
+}
+```
+
+## Also in the data directory
+
+| File | Role |
+| ---- | ---- |
+| `init.js` | This page |
+| `styles.css` | [Style tweaks](/getting-started/style-tweaks) |
+| `keybindings.json` | Command id → chord, or `null` to unbind. Contexts: `editor`, `note-list`, `app`, `global`. |
+
+`j` / `k` in `note-list` move to next/prev note and do **not** fire in the editor (so they stay free for Vim).
diff --git a/content/docs/getting-started/meta.json b/content/docs/getting-started/meta.json
new file mode 100644
index 0000000..1bbf0f6
--- /dev/null
+++ b/content/docs/getting-started/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Getting started",
+ "pages": ["index", "init-file", "style-tweaks"]
+}
diff --git a/content/docs/getting-started/style-tweaks.mdx b/content/docs/getting-started/style-tweaks.mdx
new file mode 100644
index 0000000..b12b40c
--- /dev/null
+++ b/content/docs/getting-started/style-tweaks.mdx
@@ -0,0 +1,59 @@
+---
+title: Style tweaks
+description: Personal CSS and theme tokens without shipping a full theme pack
+---
+
+# Style tweaks
+
+`styles.css` in the data directory is injected into every renderer window. Save to apply. Open it from **Settings → Plugins** (written on first open, same as `init.js`).
+
+Use this for personal chrome. To **share** a palette, ship a [theme pack](/guides/create-a-theme).
+
+## CSS variables the app already uses
+
+Themes (and `styles.css`) may set tokens from `CORE_THEME_TOKENS` in `@dripnex/plugin-api`. `--accent-primary` is the chrome accent (buttons, settings). A palette that only sets `--accent` gets `--accent-primary` copied in `validateThemeTokens`.
+
+**Core tokens**
+
+`--bg-base`, `--bg-surface`, `--bg-elevated`, `--bg-inset`, `--bg-hover`, `--bg-active`, `--accent`, `--accent-primary`, `--accent-hover`, `--accent-muted`, `--accent-subtle`, `--text-primary`, `--text-secondary`, `--text-muted`, `--text-faint`, `--border`, `--border-subtle`, `--border-strong`, `--glass-bg`, `--glass-border`, `--glass-bg-menu`, `--glass-border-menu`, `--danger`, `--danger-muted`, `--warning`, `--warning-muted`, `--success`, `--success-muted`, `--status-active`, `--status-on-hold`, `--status-completed`, `--status-dropped`
+
+**Extension scopes** (`THEME_EXTENSION_SCOPES`) — any token starting with:
+
+`--syntax-`, `--preview-`, `--ui-`, `--cm-`, `--md-`, `--mde-`
+
+Unknown names are rejected for **registered themes**. Your personal `styles.css` is injected as raw CSS, so you can also target existing classes — keep selectors specific so they survive app updates.
+
+```css
+:root {
+ --accent: #2a7d6f;
+ --accent-primary: #2a7d6f;
+}
+
+.cm-editor {
+ font-size: 15px;
+}
+```
+
+## From a plugin or init.js
+
+Packs can register variables without a `theme.json`:
+
+```js
+dripnex.registerCssVariables('my-tweaks', {
+ '--accent': '#8b5cf6',
+ '--cm-link': '#a78bfa',
+});
+```
+
+`registerTheme` / `theme.json` go through the whitelist. Invalid tokens are dropped with a console warning.
+
+## Theme packs vs style tweaks
+
+| | `styles.css` | Theme pack |
+| - | ------------ | ---------- |
+| Audience | You | Anyone who installs `owner/repo` |
+| Activation | Always, for your user data | Settings → Themes |
+| Token validation | Raw CSS | `CORE_THEME_TOKENS` + extension scopes |
+| Example | A larger editor font | [dripnex/theme-parchment](https://github.com/dripnex/theme-parchment) |
+
+Settings → Themes lists the default `tokens.css` plus installed plugin themes.
diff --git a/content/docs/guides/create-a-plugin.mdx b/content/docs/guides/create-a-plugin.mdx
new file mode 100644
index 0000000..6878381
--- /dev/null
+++ b/content/docs/guides/create-a-plugin.mdx
@@ -0,0 +1,162 @@
+---
+title: Create a plugin
+description: Scaffold, activate, bundle to CommonJS, and load a Dripnex plugin from disk
+---
+
+# Create a plugin
+
+This walkthrough builds a pack you can load locally. The real first-party example is [dripnex/plugin-stamp](https://github.com/dripnex/plugin-stamp): CommonJS, `menu.add`, `editor.insertAtCursor`.
+
+## 1. Scaffold
+
+```bash
+dripnex-plugin init "Hello Stamp"
+cd hello-stamp
+npm install
+```
+
+From the app monorepo you can also run `pnpm plugin init "Hello Stamp"`.
+
+`init` without `--type theme` creates `manifest.json`, `src/index.ts` (CJS export), `keymaps/`, `menus/`, and `styles/`.
+
+## 2. Write `activate`
+
+The host evaluates a **pre-bundled CommonJS** file. The CLI template already uses `module.exports`. Stamp's source (trimmed) is the shape you want:
+
+```js
+module.exports = {
+ id: 'stamp',
+ name: 'Stamp',
+ version: '0.1.0',
+ description: 'Insert the current date or timestamp at the cursor.',
+
+ activate(context) {
+ const removeDate = context.menu.add({
+ label: 'Insert Date',
+ accelerator: 'Mod+Shift+T',
+ click: () => {
+ const now = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ context.editor.insertAtCursor(
+ `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
+ );
+ return true;
+ },
+ });
+
+ return {
+ dispose() {
+ removeDate();
+ },
+ };
+ },
+};
+```
+
+Keep `id` / `name` / `version` in sync with `manifest.json`. Return `{ dispose() }` and unregister everything you added.
+
+TypeScript is fine if your bundler emits CJS `module.exports`. Named ESM `export const plugin` is **not** what `loadPluginFromSource` reads.
+
+## 3. Commands and layout (optional)
+
+Commands land in the palette as `plugin::`:
+
+```js
+const off = context.registerCommand(
+ {
+ id: 'hello',
+ name: 'Say Hello',
+ category: 'Hello Stamp',
+ keybinding: { key: 'H', modifiers: ['Mod', 'Shift'] },
+ icon: 'Smile',
+ },
+ () => {
+ context.notifications.addInfo('Hello from the plugin.');
+ return true;
+ }
+);
+```
+
+Mount UI with `context.layout.addComponent`. Zones are listed in [Layout zones](/reference/layout-zones). Status bar example:
+
+```js
+context.layout.addComponent('editor-status-bar', {
+ id: 'hello-stamp:status',
+ component: function Status() {
+ return 'Hello';
+ },
+ order: 30,
+});
+```
+
+Community packs conventionally use `order` 30–99.
+
+## 4. Config schema
+
+Fields in `configSchema` render under **Settings → Plugins**:
+
+```js
+module.exports = {
+ id: 'hello-stamp',
+ name: 'Hello Stamp',
+ version: '0.1.0',
+ configSchema: {
+ includeTime: {
+ type: 'boolean',
+ default: false,
+ description: 'Append local time',
+ },
+ },
+ activate(context) {
+ const includeTime = context.config.get('includeTime') ?? false;
+ const stop = context.config.observe('includeTime', (value) => {
+ context.log.info('includeTime', value);
+ });
+ return {
+ dispose() {
+ stop();
+ },
+ };
+ },
+};
+```
+
+`config.get` is sync. `config.set` persists. `config.observe` fires when Settings (or another window) changes the value.
+
+Field types: `string`, `number`, `boolean`, `enum` (`options: [{ value, label }]`), `range` (`min`, `max`, `step`).
+
+## 5. Build and install locally
+
+```bash
+npm run build
+dripnex-plugin install .
+```
+
+Or `dripnex-plugin link` / `pnpm plugin link` from a monorepo checkout. Then **Settings → Plugins → Reload**.
+
+The pack must appear under `…/Dripnex/plugins//`. If the CLI printed success but the app shows nothing, set `DRIPNEX_DATA_DIR` to the same userData the packaged app uses.
+
+CLI `install` **refuses** if `` is already installed. Settings overwrite is allowed. To refresh from CLI: uninstall, then install again.
+
+## 6. Lifecycle
+
+```
+scan manifest.json → eval main → activate(context) → apply keymaps/menus/styles/themes → dispose()
+```
+
+`PluginRegistry` unsubscribes leftover `editor.on*` / `app.on*` listeners on deactivate. Still call your own `dispose()`.
+
+## Five rules
+
+1. Removable without breaking `.md` files.
+2. No invented markdown other editors cannot read.
+3. No automatic content mutation.
+4. Notes stay readable without the pack.
+5. No brittle inter-note graph that the pack owns.
+
+## Next
+
+- [Create a theme](/guides/create-a-theme)
+- [Publish](/guides/publishing) (`dripnex-plugin pack` → git tag → GitHub release)
+- [PluginContext](/reference/plugin-context)
+- [Examples](/reference/examples)
diff --git a/content/docs/guides/create-a-theme.mdx b/content/docs/guides/create-a-theme.mdx
new file mode 100644
index 0000000..7dd0e28
--- /dev/null
+++ b/content/docs/guides/create-a-theme.mdx
@@ -0,0 +1,115 @@
+---
+title: Create a theme
+description: Ship a Dripnex palette with theme.json, CORE_THEME_TOKENS, and THEME_EXTENSION_SCOPES
+---
+
+# Create a theme
+
+A theme pack is a git repo that contributes CSS variables. Official palettes live in `dripnex/theme-*` repositories. [dripnex/theme-parchment](https://github.com/dripnex/theme-parchment) is the reference: pack id `theme-parchment`, palette id `dripnex-parchment`.
+
+You do **not** need JavaScript. The scanner treats a folder as theme-only when `theme.json` or `themes/` is present even if `manifest.main` is missing.
+
+## Scaffold
+
+```bash
+dripnex-plugin init "Harbor Dusk" --type theme
+cd harbor-dusk
+```
+
+That writes `manifest.json`, `theme.json`, and `styles/index.css` (no `src/`).
+
+## `manifest.json`
+
+```json
+{
+ "id": "theme-harbor-dusk",
+ "name": "Harbor Dusk",
+ "version": "0.1.0",
+ "description": "A Dripnex theme: Harbor Dusk"
+}
+```
+
+`id` is kebab-case. It is the install folder name. It does not have to equal the palette id inside `theme.json`.
+
+There is no `themeType` field on `PluginManifest`. Theme-ness is `theme.json` / `themes/*.json` (and optional `registerTheme` in JS).
+
+## `theme.json`
+
+Parsed by `parsePluginTheme`. Required: `colorScheme` (`"light"` | `"dark"`) and `tokens` (string map). Optional: `id`, `name`, `description`, `author`, `frosted`.
+
+If `id` / `name` are omitted, the pack id is used. `frosted: true` marks native window vibrancy; chrome tokens should be translucent.
+
+Tokens pass `validateThemeTokens`:
+
+- Names in `CORE_THEME_TOKENS`, or
+- Names starting with a `THEME_EXTENSION_SCOPES` prefix (`--syntax-`, `--preview-`, `--ui-`, `--cm-`, `--md-`, `--mde-`)
+
+Everything else is dropped with `[ThemeRegistry] Theme "": rejected invalid token "…"`. If `--accent` is set and `--accent-primary` is not, `--accent-primary` is copied from `--accent`.
+
+Parchment (light) — subset of the real file:
+
+```json
+{
+ "id": "dripnex-parchment",
+ "name": "Parchment",
+ "description": "Warm paper. Reading notes, long sessions.",
+ "author": "Dripnex",
+ "colorScheme": "light",
+ "tokens": {
+ "--bg-base": "#f3ead4",
+ "--bg-surface": "#ebe0c4",
+ "--bg-elevated": "#faf3e3",
+ "--bg-inset": "#e6d9b8",
+ "--text-primary": "#3a3224",
+ "--text-secondary": "rgba(58, 50, 36, 0.74)",
+ "--text-muted": "rgba(58, 50, 36, 0.52)",
+ "--border": "rgba(58, 50, 36, 0.12)",
+ "--accent": "#2a7d6f",
+ "--accent-hover": "#21675c",
+ "--glass-bg": "rgba(243, 234, 212, 0.9)",
+ "--status-active": "#2a7d6f",
+ "--status-on-hold": "#c27a1a",
+ "--status-completed": "#3d8b4a",
+ "--status-dropped": "#c44b4b"
+ }
+}
+```
+
+### Core tokens (`CORE_THEME_TOKENS`)
+
+`--bg-base`, `--bg-surface`, `--bg-elevated`, `--bg-inset`, `--bg-hover`, `--bg-active`, `--accent`, `--accent-primary`, `--accent-hover`, `--accent-muted`, `--accent-subtle`, `--text-primary`, `--text-secondary`, `--text-muted`, `--text-faint`, `--border`, `--border-subtle`, `--border-strong`, `--glass-bg`, `--glass-border`, `--glass-bg-menu`, `--glass-border-menu`, `--danger`, `--danger-muted`, `--warning`, `--warning-muted`, `--success`, `--success-muted`, `--status-active`, `--status-on-hold`, `--status-completed`, `--status-dropped`
+
+Editor / preview extras belong under `--cm-`, `--md-`, `--syntax-`, and so on — for example `--cm-heading`, `--cm-link` in the CLI theme template.
+
+## Optional CSS
+
+`styles/*.css` is registered as a plugin stylesheet after activate (theme-only packs use an empty `activate()`). Use it for selectors that are not tokens. Prefer tokens so Settings → Themes can switch palettes cleanly.
+
+## Registering from JavaScript
+
+If the pack has a `main` file, you can also call:
+
+```js
+context.registerTheme({
+ id: 'harbor-dusk',
+ name: 'Harbor Dusk',
+ colorScheme: 'dark',
+ tokens: {
+ '--bg-base': '#0c1117',
+ '--accent': '#5eead4',
+ },
+});
+```
+
+Same whitelist. `context.themes.list()`, `getActive()`, `setActive(id | null)`, and `onDidChange` read the registry.
+
+There is **no** `context.getTheme()` / `onThemeChanged()`. Appearance lives on `store.getState().settings.theme` (`'dark' | 'light' | 'system'`) and `themes.getActive()`.
+
+## Pack and try it
+
+```bash
+dripnex-plugin pack
+dripnex-plugin install .
+```
+
+Then Settings → Themes. To share it, follow [Publishing](/guides/publishing): tag `v0.1.0` and attach `theme-harbor-dusk-0.1.0.tar.gz`.
diff --git a/content/docs/guides/meta.json b/content/docs/guides/meta.json
new file mode 100644
index 0000000..a1b3af7
--- /dev/null
+++ b/content/docs/guides/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Guides",
+ "pages": ["create-a-plugin", "create-a-theme", "publishing"]
+}
diff --git a/content/docs/guides/publishing.mdx b/content/docs/guides/publishing.mdx
new file mode 100644
index 0000000..d993b15
--- /dev/null
+++ b/content/docs/guides/publishing.mdx
@@ -0,0 +1,79 @@
+---
+title: Publishing
+description: One git repo per pack. Version is the git tag. Artifact is the tarball from dripnex-plugin pack on a GitHub release.
+---
+
+# Publishing
+
+A community plugin or theme is **not** a folder inside Dripnex and **not** an upload to an app store. It is its own git repository.
+
+| Piece | What it is |
+| ----- | ---------- |
+| Repo | One pack, one repository |
+| Version | `manifest.json` `version` **and** git tag `vX.Y.Z` |
+| Artifact | `-.tar.gz` from `dripnex-plugin pack` |
+| Install | `dripnex-plugin install owner/repo[@tag]` or Settings → Plugins → Connect |
+
+There is **no public marketplace** and no store-publish API for third-party authors. [dripnex.app/plugins](https://dripnex.app/plugins) is a marketing catalog of packs people should know about, not an upload target.
+
+## Author loop
+
+```bash
+dripnex-plugin init "Mermaid Plus"
+cd mermaid-plus
+# write the pack, keep it useful
+npm install && npm run build
+dripnex-plugin pack
+```
+
+`pack` writes `{id}-{version}.tar.gz` next to the cwd (`manifest.id`, not the GitHub repo name). The archive includes `manifest.json`, `dist/` (or `main`), and any of `keymaps/`, `menus/`, `styles/`, `theme.json`, `themes/` that exist.
+
+Then:
+
+```bash
+git tag v1.2.3
+git push origin v1.2.3
+gh release create v1.2.3 mermaid-plus-1.2.3.tar.gz --title "Mermaid Plus 1.2.3"
+```
+
+The release **must** attach that `.tar.gz`. A git tag or GitHub's source zip is not enough — install looks for a built archive (`manifest.json` + `dist/` or `theme.json`).
+
+`dripnex-plugin publish` packs and, when `gh` works, creates that GitHub release. Treat the GitHub release as the publish step. Do not wait on a community registry.
+
+## Install from a repo
+
+```bash
+dripnex-plugin install dripnex/plugin-stamp
+dripnex-plugin install acme/mermaid-plus@v1.2.3
+dripnex-plugin install https://github.com/acme/mermaid-plus
+```
+
+No tag means the **latest GitHub release**, first `.tar.gz` / `.tgz` asset. Then reload plugins or restart the app.
+
+The same `owner/repo` spec works in **Settings → Plugins → Connect** (and the “Other package” field). Prefer the GitHub repo, not the manifest id, when they differ:
+
+| Role | Vim example |
+| ---- | ----------- |
+| Manifest id / install folder | `dripnex-vim-mode` |
+| GitHub repo (working install spec) | `dripnex/plugin-vim` |
+
+```bash
+dripnex-plugin install dripnex/plugin-vim
+```
+
+Typing `dripnex-vim-mode` as if it were `owner/repo` is the wrong spec.
+
+CLI install errors if that `manifest.id` is already present. Settings can overwrite. There is no auto-update; Settings → Plugins → Updates is a manual check against known GitHub releases / first-party rows.
+
+## First-party Browse
+
+Official `dripnex/plugin-*` and `dripnex/theme-*` repos whose **latest GitHub release** has a packed `{id}-{version}.tar.gz` can appear in Settings → Plugins → Browse. That is an index of first-party satellites, not a community store you submit to.
+
+Do not list a pack in any first-party catalog until the release has the packed tarball — Browse install would 404.
+
+## What does not belong here
+
+- Proof toys (word count, typewriter, reading time, focus, active line) stay off [dripnex.app/plugins](https://dripnex.app/plugins).
+- Do not open a plugin PR against `dripnex/app` unless it is becoming a **built-in**.
+- Do not invent a second version number besides the git tag.
+- Do not document or depend on a public marketplace publish endpoint. Phase 5 “plugin registry/marketplace” is explicitly later / not planned for this site.
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
new file mode 100644
index 0000000..9dc6a69
--- /dev/null
+++ b/content/docs/index.mdx
@@ -0,0 +1,61 @@
+---
+title: Start building with the Dripnex API
+description: Plugin and theme API for extending the Dripnex desktop app
+---
+
+# Start building with the Dripnex API
+
+The Dripnex plugin API lets you extend the desktop editor: commands, CodeMirror 6, layout zones, preview hooks, and color palettes. Plugins run in the renderer with a controlled `PluginContext`. There is no public marketplace — a pack is a git repo, a git tag, and a tarball on a GitHub release.
+
+Desktop only. Phone v1 has no plugin path. Current API major: `PLUGIN_API_VERSION = "1"`.
+
+End-user help lives in the [user manual](https://docs.dripnex.app). The product catalog is at [dripnex.app/plugins](https://dripnex.app/plugins).
+
+## Getting started
+
+Before you ship a pack, learn how Dripnex loads scripts and styles from the data directory:
+
+
+
+ Customize startup with JavaScript in `init.js`. Register commands, map Vim keys, or read the store — without a plugin repo.
+
+
+ Restyle the UI with `styles.css` and CSS variables. You do not need a full theme pack for a personal tweak.
+
+
+
+How a **plugin repo** is structured (manifest, `main`, package files) is in [Plugin structure](/getting-started).
+
+## Guides
+
+
+
+ Scaffold, activate, bundle to CommonJS, and load a pack from disk.
+
+
+ Ship a palette with `theme.json`, `CORE_THEME_TOKENS`, and extension scopes.
+
+
+ Tag `vX.Y.Z`, attach `{id}-{version}.tar.gz`, install with `dripnex-plugin install owner/repo`.
+
+
+
+## Resources
+
+
+
+ Everything passed to `activate()` — commands, editor, layout, data, themes.
+
+
+ `registerCommand`, `dispatchCommand`, menus, and keymaps.
+
+
+ CodeMirror 6, decorations, and the live `EditorView`.
+
+
+ Token whitelist, `registerTheme`, and `context.themes`.
+
+
+ Where plugins can mount React components.
+
+
diff --git a/content/docs/meta.json b/content/docs/meta.json
new file mode 100644
index 0000000..0d355ee
--- /dev/null
+++ b/content/docs/meta.json
@@ -0,0 +1,13 @@
+{
+ "title": "Dripnex API",
+ "root": true,
+ "pages": [
+ "index",
+ "---Getting started---",
+ "getting-started",
+ "---Guides---",
+ "guides",
+ "---Resources---",
+ "reference"
+ ]
+}
diff --git a/content/docs/reference/commands.mdx b/content/docs/reference/commands.mdx
new file mode 100644
index 0000000..38bfbc6
--- /dev/null
+++ b/content/docs/reference/commands.mdx
@@ -0,0 +1,90 @@
+---
+title: Commands
+description: registerCommand, dispatchCommand, menus, context menus, and package keymaps
+---
+
+# Commands
+
+## `registerCommand`
+
+```ts
+registerCommand(
+ options: PluginCommandOptions,
+ execute: (payload?: Record) => boolean | void | Promise
+): () => void
+```
+
+| Field | Type | Required | Notes |
+| ----- | ---- | -------- | ----- |
+| `id` | `string` | Yes | Id **within** the pack. Host qualifies it as `plugin::`. |
+| `name` | `string` | Yes | Palette label |
+| `category` | `string` | No | Palette grouping |
+| `keybinding` | `{ key, modifiers? }` | No | `modifiers`: `'Mod'` (Cmd on macOS, Ctrl elsewhere), `'Shift'`, `'Alt'` |
+| `icon` | `string` | No | Lucide icon name |
+| `showInPalette` | `boolean` | No | Default true |
+
+Return `true` from `execute` when the command handled the invocation.
+
+```js
+const off = context.registerCommand(
+ {
+ id: 'say-hello',
+ name: 'Say Hello',
+ keybinding: { key: 'H', modifiers: ['Mod', 'Shift'] },
+ icon: 'Smile',
+ },
+ () => {
+ context.notifications.addSuccess('Hello');
+ return true;
+ }
+);
+```
+
+From `init.js`, `dripnex.commands.add(id, name, execute, options?)` is the same registration without repeating `id`/`name` in the options object.
+
+## `dispatchCommand`
+
+```ts
+dispatchCommand(id: string, payload?: Record): Promise
+```
+
+Use a **full** id: host commands such as `app:save-note`, or `plugin:stamp:…`. Init equivalent: `dripnex.commands.dispatch`.
+
+Vim's `:cmd {id}` (from plugin-vim) is this dispatch, not an Inkdrop command bus.
+
+## Plugins menu
+
+```js
+context.menu.add({
+ label: 'Insert Date',
+ accelerator: 'Mod+Shift+T',
+ click: () => context.editor.insertAtCursor(localDate(new Date())),
+});
+
+// or reuse a command you already registered
+context.menu.add({
+ label: 'Say Hello',
+ command: 'say-hello',
+});
+```
+
+`click` is registered as a command. `command` reuses an existing command id (qualified to `plugin::…`).
+
+## Context menus
+
+```ts
+context.contextMenu.add(
+ 'note-list-item' | 'notebook-item' | 'tag-item' | 'editor',
+ { label, command?, click? }
+)
+```
+
+## Package files
+
+After `activate()`, `keymaps/*.json` and `menus/*.json` are applied.
+
+**Keymap** — Dripnex form `{ "say-hello": "Mod+Shift+H" }` or Inkdrop form `{ "body": { "ctrl-alt-n": "say-hello" } }`. Chords parse `Mod+Shift+K` or `ctrl-alt-n`. Only `plugin::…` commands this pack registered are bound; core `app:` / `editor:` ids are skipped.
+
+**Menus** — JSON items with `label` + `command`, optional `accelerator`, optional `submenu`. Context-menu selectors map aliases such as `note-list`, `.cm-editor`, `tag` onto the four targets above.
+
+Declarative keymaps cannot invent commands. Register them in `activate()` first.
diff --git a/content/docs/reference/data.mdx b/content/docs/reference/data.mdx
new file mode 100644
index 0000000..fa63b2e
--- /dev/null
+++ b/content/docs/reference/data.mdx
@@ -0,0 +1,96 @@
+---
+title: Data API
+description: Notes, notebooks, tags, links, and graph — DataAPI plus the slimmer AppAPI
+---
+
+# Data API
+
+Plugins do not talk to a local HTTP server. Reads and writes go through `context.data` (`DataAPI`) and a smaller `context.app` (`AppAPI`). Types: `packages/plugin-api/src/data/dataTypes.ts` and `createDataAPI.ts`.
+
+## `AppAPI` (`context.app`)
+
+Read-only convenience:
+
+| Method | Returns |
+| ------ | ------- |
+| `getCurrentNote()` | `NoteInfo \| null` (`id`, `title`, `content`) |
+| `searchNotes(query)` | `Promise<{ id, title }[]>` |
+| `getNoteById(id)` | `Promise` |
+| `getNoteTags(noteId)` | `Promise` |
+| `getBacklinks(noteId)` | `Promise<{ noteId, noteTitle }[]>` |
+| `listNotes()` | `Promise` |
+| `listNotebooks()` | `Promise` |
+| `listTags()` | `Promise` |
+| `onNoteSelected` / `onNoteCreated` / `onNoteDeleted` | unsubscribe |
+
+`NoteSummaryInfo`: `id`, `title`, `notebookId`, `tags`, `wordCount`, `createdAt`, `updatedAt`, `isPinned`, `status`.
+
+`NotebookInfo`: `id`, `name`, `parentId`, optional `icon`.
+
+## `DataAPI` (`context.data`)
+
+Richer queries. Failures throw `DataAccessError` (`[DataAPI.] …`).
+
+### Notes
+
+```ts
+getNotes(options?: NoteQueryOptions): Promise
+getNote(id: string): Promise
+searchNotes(query: string, options?: SearchOptions): Promise
+countNotes(options?: NoteQueryOptions): Promise
+createNote(input: { content: string; notebookId?: string }): Promise
+updateNote(id: string, content: string): Promise
+trashNote(id: string): Promise
+onNotesChanged(cb): () => void
+```
+
+`NoteQueryOptions`: `notebookId`, `tag`, `status`, `isPinned`, `sortBy` (`title` \| `createdAt` \| `updatedAt` \| `wordCount`), `sortOrder`, `limit`, `offset`.
+
+`NoteQueryResult`: `{ notes, total, hasMore }`.
+
+### Notebooks
+
+```ts
+getNotebooks(options?: { tree?: boolean; includeCounts?: boolean }): Promise
+getNotebook(id: string): Promise
+createNotebook(input: { name: string; parentId?: string | null }): Promise
+updateNotebook(id, patch: { name?, icon?, parentId? }): Promise
+deleteNotebook(id: string): Promise
+onNotebooksChanged(cb): () => void
+```
+
+### Tags
+
+```ts
+getTags(options?: TagQueryOptions): Promise
+setTagColor(name: string, color: string | null): Promise
+renameTag(oldName: string, newName: string): Promise
+onTagsChanged(cb): () => void
+```
+
+`TagInfo`: `{ name, color?, count? }`. Query options: `includeColors`, `includeCount`, `filter` (substring), `limit`, `offset`.
+
+### Links and graph
+
+```ts
+getBacklinks(noteId: string): Promise
+getOutgoingLinks(noteId: string): Promise
+getGraphData(options?: { notebookId?: string; depth?: number }): Promise
+```
+
+Outgoing links include `resolved: boolean` and `targetId` which may be `null`.
+
+### Change events
+
+```ts
+interface DataChangeEvent {
+ kind: T;
+ action: 'created' | 'updated' | 'deleted' | 'renamed';
+ id: string;
+ previousName?: string;
+}
+```
+
+## Store vs data
+
+`context.store.getState().notes.items` is the **visible list in this window**. Use `data.getNotes()` / `app.listNotes()` for library queries. The store has no `dispatch`.
diff --git a/content/docs/reference/editor.mdx b/content/docs/reference/editor.mdx
new file mode 100644
index 0000000..cd34f83
--- /dev/null
+++ b/content/docs/reference/editor.mdx
@@ -0,0 +1,79 @@
+---
+title: Editor extensions
+description: EditorAPI, CodeMirror 6 registerExtensions, decorations, and getView()
+---
+
+# Editor extensions
+
+## `EditorAPI` (`context.editor`)
+
+Controlled subset of editor operations:
+
+| Method | Returns | Notes |
+| ------ | ------- | ----- |
+| `getContent()` | `string` | Full buffer |
+| `getSelection()` | `{ from, to }` | Document offsets |
+| `setSelection(from, to?)` | `void` | |
+| `replaceRange(from, to, text)` | `void` | |
+| `insertAtCursor(text)` | `void` | |
+| `getWordCount()` / `getCharCount()` / `getLineCount()` | `number` | |
+| `onDocChanged(cb)` | unsubscribe | `cb(content: string)` |
+| `onSelectionChanged(cb)` | unsubscribe | `cb({ from, to })` |
+| `focus()` | `void` | |
+| `getView()` | `EditorView \| null` | Live CodeMirror 6 view, or `null` if unmounted |
+
+```js
+const content = context.editor.getContent();
+const { from, to } = context.editor.getSelection();
+context.editor.replaceRange(from, to, '[' + content.slice(from, to) + '](url)');
+
+const view = context.editor.getView();
+if (view) {
+ view.dispatch(/* … */);
+}
+```
+
+From init.js, `dripnex.getActiveEditor()` returns `{ editor, cm }` with `cm === editor.getView()`.
+
+## CodeMirror 6 extensions
+
+```ts
+registerExtensions(id: string, extensions: Extension[]): () => void
+```
+
+Extensions are installed in a compartment (`pluginExtensionCompartment`) so they can be swapped without rebuilding the editor. Host `require('@codemirror/view')` (and `state`, `language`, `commands`, `search`) is the **app's singleton** — do not bundle a second CodeMirror.
+
+```js
+const { keymap } = require('@codemirror/view');
+
+const off = context.registerExtensions('hello-keymap', [
+ keymap.of([
+ {
+ key: 'Mod-Shift-h',
+ run: () => {
+ context.editor.insertAtCursor('hello');
+ return true;
+ },
+ },
+ ]),
+]);
+```
+
+Anything not on the [host require whitelist](/getting-started#the-module-must-be-commonjs) (for example `@replit/codemirror-vim`) must be bundled. That is how [plugin-vim](https://github.com/dripnex/plugin-vim) works: `registerExtensions` + `registerVim` + status bar, not an Atom keymap.
+
+## Decorations
+
+`context.decorations` (`EditorDecorationAPI`):
+
+| Method | Notes |
+| ------ | ----- |
+| `addLineHighlight(line, className)` | **1-indexed** line. Returns remove. |
+| `addWidget(pos, dom)` | DOM widget at a document position. Returns remove. |
+| `clear()` | Drop all decorations owned by this instance |
+
+The decoration `StateField` is created with `createDecorationAPI` and must be present as a CM6 extension (the host wires this for plugins).
+
+```js
+const remove = context.decorations.addLineHighlight(1, 'cm-activeLine');
+remove();
+```
diff --git a/content/docs/reference/examples.mdx b/content/docs/reference/examples.mdx
new file mode 100644
index 0000000..1bc85ca
--- /dev/null
+++ b/content/docs/reference/examples.mdx
@@ -0,0 +1,144 @@
+---
+title: Examples
+description: Patterns from official satellites and valid PluginContext usage
+---
+
+# Examples
+
+## Stamp (official satellite)
+
+[dripnex/plugin-stamp](https://github.com/dripnex/plugin-stamp) — insert date/timestamp. CommonJS, `menu.add`, `insertAtCursor`. Install: `dripnex-plugin install dripnex/plugin-stamp`.
+
+```js
+function pad(n) {
+ return String(n).padStart(2, '0');
+}
+
+function localDate(now) {
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+}
+
+function insert(context, text) {
+ if (!context.editor || typeof context.editor.insertAtCursor !== 'function') {
+ return false;
+ }
+ context.editor.insertAtCursor(text);
+ return true;
+}
+
+module.exports = {
+ id: 'stamp',
+ name: 'Stamp',
+ version: '0.1.0',
+ description: 'Insert the current date or timestamp at the cursor.',
+
+ activate(context) {
+ const removeDate = context.menu.add({
+ label: 'Insert Date',
+ accelerator: 'Mod+Shift+T',
+ click: () => insert(context, localDate(new Date())),
+ });
+ const removeStamp = context.menu.add({
+ label: 'Insert Timestamp',
+ click: () =>
+ insert(
+ context,
+ `${localDate(new Date())} ${pad(new Date().getHours())}:${pad(new Date().getMinutes())}`
+ ),
+ });
+
+ return {
+ dispose() {
+ removeDate();
+ removeStamp();
+ },
+ };
+ },
+};
+```
+
+## Insert-only mermaid / math (not renderers)
+
+[plugin-mermaid](https://github.com/dripnex/plugin-mermaid) and [plugin-math](https://github.com/dripnex/plugin-math) insert a fence or `$$` block. Diagram and KaTeX **rendering** is built-in (`dripnex-mermaid`, `dripnex-math`). A pack that only inserts markdown should look like Stamp: `insertAtCursor('```mermaid\\n…\\n```')`, not `registerCodeBlockRenderer`.
+
+## Status bar + config
+
+Valid `PluginContext` usage (proof plugins like reading-time stay off the marketing catalog):
+
+```js
+const React = require('react');
+
+function ReadingTime({ meta }) {
+ const editor = meta && meta.editor;
+ const [minutes, setMinutes] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!editor) return;
+ const update = () => {
+ const words = editor.getWordCount();
+ setMinutes(Math.max(1, Math.ceil(words / 200)));
+ };
+ update();
+ return editor.onDocChanged(update);
+ }, [editor]);
+
+ if (!editor) return null;
+ return React.createElement('span', null, minutes + ' min read');
+}
+
+module.exports = {
+ id: 'reading-time',
+ name: 'Reading Time',
+ version: '1.0.0',
+ activate(context) {
+ context.layout.addComponent('editor-status-bar', {
+ id: 'reading-time:status',
+ component: ReadingTime,
+ order: 20,
+ meta: { editor: context.editor },
+ });
+ return {
+ dispose() {
+ context.layout.removeComponent('reading-time:status');
+ },
+ };
+ },
+};
+```
+
+## Clipboard command
+
+Use `context.clipboard`, not `navigator.clipboard`:
+
+```js
+module.exports = {
+ id: 'copy-note',
+ name: 'Copy note',
+ version: '1.0.0',
+ activate(context) {
+ const off = context.registerCommand(
+ { id: 'copy-markdown', name: 'Copy as Markdown', icon: 'Copy' },
+ async () => {
+ const content = context.editor.getContent();
+ if (!content) return false;
+ await context.clipboard.writeText(content);
+ context.notifications.addSuccess('Copied');
+ return true;
+ }
+ );
+ return { dispose: off };
+ },
+};
+```
+
+## Dispatch a host command
+
+```js
+await context.dispatchCommand('app:save-note');
+```
+
+Init.js equivalent: `void dripnex.commands.dispatch('app:save-note')`.
+
+## Vim (community)
+
+[dripnex/plugin-vim](https://github.com/dripnex/plugin-vim) — manifest id `dripnex-vim-mode`. Bundles `@replit/codemirror-vim`, calls `registerExtensions` and `registerVim`. After it is enabled, `init.js` may use `dripnex.vim` ([init file](/getting-started/init-file#vim-maps-after-installing-vim)).
diff --git a/content/docs/reference/layout-zones.mdx b/content/docs/reference/layout-zones.mdx
new file mode 100644
index 0000000..aeec236
--- /dev/null
+++ b/content/docs/reference/layout-zones.mdx
@@ -0,0 +1,89 @@
+---
+title: Layout zones
+description: LayoutZoneName values plugins can mount React components into
+---
+
+# Layout zones
+
+`context.layout` is a `LayoutManager`. Zone names are the `LayoutZoneName` union in `packages/plugin-api/src/layout/types.ts`.
+
+```ts
+addComponent(zone: LayoutZoneName, entry: Omit): void
+removeComponent(id: string): void
+removeAllForPlugin(pluginId: string): void
+```
+
+`pluginId` is filled by the host. You pass `id`, `component`, `order`, and optional `meta`.
+
+## Zones
+
+| Zone | Typical use |
+| ---- | ----------- |
+| `sidebar-section` | Left sidebar blocks |
+| `sidebar-footer` | Bottom of the sidebar |
+| `editor-toolbar` | Above the editor |
+| `editor-status-bar` | Bottom of the editor (Vim mode, timers) |
+| `editor-header-actions` | Note header buttons |
+| `editor-footer` | Below the editor |
+| `preview-toolbar` | Preview chrome |
+| `panel` | Side panel (large UI) |
+| `modal` | Overlay — pair with `context.components.Modal` / `Dialog` |
+| `settings-section` | Extra Settings UI (schema fields already render without this) |
+| `note-list-header` | Top of the note list |
+| `note-list-footer` | Bottom of the note list |
+| `note-list-item-suffix` | Per-row suffix in the list |
+| `command-palette-footer` | Below the palette |
+
+```
+┌─────────────────────────────────────────────────────┐
+│ sidebar-section │ editor-header-actions │
+│ ├───────────────────────────────┐ │
+│ │ editor-toolbar │ │
+│ ├───────────────────────────────┤ │
+│ │ [Editor] │panel│
+│ │ │ │
+│ ├───────────────────────────────┤ │
+│ note-list-footer│ editor-status-bar / footer │ │
+└─────────────────────────────────────────────────────┘
+```
+
+## Zone entry
+
+```js
+context.layout.addComponent('editor-status-bar', {
+ id: 'hello:status',
+ component: Status,
+ order: 30,
+ meta: { editor: context.editor },
+});
+```
+
+```ts
+function Status({ meta }: ZoneComponentProps) {
+ const editor = meta?.editor;
+ return editor ? String(editor.getWordCount()) : null;
+}
+```
+
+`order`: lower first (further left / further up). Convention: 1–9 core, 10–29 built-ins, **30–99 community**.
+
+Components should be React function components. Host `require('react')` is the app singleton so hooks work.
+
+## Cleanup
+
+```js
+activate(context) {
+ context.layout.addComponent('editor-status-bar', {
+ id: 'hello:status',
+ component: Status,
+ order: 30,
+ });
+ return {
+ dispose() {
+ context.layout.removeComponent('hello:status');
+ },
+ };
+}
+```
+
+`removeAllForPlugin` runs on unload; still remove explicitly when you can.
diff --git a/content/docs/reference/meta.json b/content/docs/reference/meta.json
new file mode 100644
index 0000000..d252b4c
--- /dev/null
+++ b/content/docs/reference/meta.json
@@ -0,0 +1,12 @@
+{
+ "title": "Resources",
+ "pages": [
+ "plugin-context",
+ "commands",
+ "editor",
+ "themes",
+ "layout-zones",
+ "data",
+ "examples"
+ ]
+}
diff --git a/content/docs/reference/plugin-context.mdx b/content/docs/reference/plugin-context.mdx
new file mode 100644
index 0000000..939b236
--- /dev/null
+++ b/content/docs/reference/plugin-context.mdx
@@ -0,0 +1,123 @@
+---
+title: PluginContext
+description: The object passed to activate() — methods and nested APIs from @dripnex/plugin-api
+---
+
+# PluginContext
+
+`activate(context: PluginContext)` receives this object. Types live in `packages/plugin-api/src/types.ts`. Current major: `PLUGIN_API_VERSION = "1"`.
+
+```ts
+activate(context: PluginContext): PluginDisposable | void
+```
+
+Return `{ dispose() }` and undo registrations. `deactivate()` on the manifest is optional.
+
+## Nested objects
+
+| Property | Type | Role |
+| -------- | ---- | ---- |
+| `layout` | `LayoutManager` | [Layout zones](/reference/layout-zones) |
+| `editor` | `EditorAPI` | [Editor](/reference/editor) |
+| `decorations` | `EditorDecorationAPI` | Line highlights and widgets |
+| `config` | `PluginConfigAPI` | `get` / `set` / `observe` |
+| `log` | `PluginLogger` | `debug`, `info`, `warn`, `error` |
+| `app` | `AppAPI` | Read-only notes, notebooks, tags, events |
+| `store` | `AppStore` | `getState()` / `subscribe()`. No `dispatch`. |
+| `data` | `DataAPI` | [Notes, notebooks, tags, graph](/reference/data) |
+| `menu` | `{ add(item) }` | Plugins menu |
+| `clipboard` | `{ readText, writeText }` | Async clipboard |
+| `notifications` | `{ addSuccess, addInfo, addWarning, addError }` | Toasts |
+| `contextMenu` | `{ add(target, item) }` | `note-list-item` \| `notebook-item` \| `tag-item` \| `editor` |
+| `components` | `PluginComponents` | Stock `Button`, `Modal`, `Dialog` |
+| `preview` | `{ on(event, handler) }` | `'a:click'` \| `'checkbox:change'` |
+| `themes` | `{ list, getActive, setActive, onDidChange }` | [Themes](/reference/themes) |
+| `markdownRenderer` | `MarkdownRenderer` | Inkdrop-shaped remark/rehype/React/fence maps |
+
+## Registration methods
+
+Each returns an unregister function unless noted.
+
+### `registerCommand(options, execute)`
+
+Palette command. See [Commands](/reference/commands).
+
+### `dispatchCommand(id, payload?)`
+
+Dispatch a **host or plugin** command by id (`app:save-note`, `plugin:…`). Returns `Promise`.
+
+### `registerExtensions(id, extensions)`
+
+CodeMirror 6 `Extension[]` in a compartment. See [Editor](/reference/editor).
+
+### `registerVim(api)`
+
+Publish a Vim API for `dripnex.vim` in `init.js`. Call at **module load**, not only inside `activate`, so init can `Vim.map` as soon as the pack is present.
+
+### `registerRemarkPlugin(id, plugin, options?)` / `registerRehypePlugin(id, plugin, options?)`
+
+Preview pipeline (appended after core). `PluginHookOptions`: `name?`, `version?`, `priority?` (lower runs first, default `100`).
+
+### `registerPreviewComponent(id, tagName, component)`
+
+Replace an HTML tag in the preview with a React component.
+
+### `registerCodeBlockRenderer(id, language, component)`
+
+Fence renderer. Props: `{ code, language, meta? }` (`CodeBlockRendererProps`). Built-in mermaid/math already register `mermaid` / `math` / `latex`. A satellite that only inserts a fence should not claim to be that renderer.
+
+### `registerAiCommand(options)`
+
+Appears in the AI panel and the command palette. Placeholders in `userPromptTemplate`: `{{selection}}`, `{{note}}`, `{{title}}`. `outputTarget`: `'replace'` \| `'insert'` \| `'panel'` (default `'panel'`).
+
+### `registerCssVariables(id, variables)` / `registerTheme(theme)`
+
+See [Themes](/reference/themes).
+
+## Config
+
+```ts
+context.config.get('workMinutes');
+context.config.set('workMinutes', 30);
+const stop = context.config.observe('workMinutes', (value) => { /* … */ });
+```
+
+## Logger
+
+`debug` / `info` / `warn` / `error`. Prefixed with the plugin id in the developer console.
+
+## Components and preview events
+
+```js
+const { Button, Modal, Dialog } = context.components;
+// aliases: components.get('Button'), components.getComponentClass('Button')
+
+const off = context.preview.on('a:click', (detail) => {
+ // detail.href, detail.text — return false to prevent default
+});
+context.preview.on('checkbox:change', (detail) => {
+ // detail.index, detail.checked
+});
+```
+
+Mount overlays with `layout.addComponent('modal', …)`.
+
+## Manifest (exported module)
+
+Besides on-disk `manifest.json`, the evaluated module must satisfy `PluginManifest`:
+
+| Field | Required | Notes |
+| ----- | -------- | ----- |
+| `id` | Yes | Kebab-case; must match `manifest.json` |
+| `name` | Yes | |
+| `version` | Yes | Semver |
+| `description` | No | |
+| `apiVersion` | No | e.g. `"1"` |
+| `dependencies` | No | Other `pluginId` → semver range |
+| `configSchema` | No | |
+| `activate` | Yes | Function |
+| `deactivate` | No | Function if present |
+
+There is **no** `themeType` on the type. There is **no** `getTheme` / `onThemeChanged` on `PluginContext`.
+
+`validateManifest` / `assertValidManifest` / `validateConfigValue` are exported from `@dripnex/plugin-api`.
diff --git a/content/docs/reference/themes.mdx b/content/docs/reference/themes.mdx
new file mode 100644
index 0000000..4bfbc43
--- /dev/null
+++ b/content/docs/reference/themes.mdx
@@ -0,0 +1,58 @@
+---
+title: Themes
+description: CORE_THEME_TOKENS, THEME_EXTENSION_SCOPES, registerTheme, and context.themes
+---
+
+# Themes
+
+Palettes are `ThemeDefinition` objects in `themeRegistryStore`. User-facing switcher: Settings → Themes.
+
+## Token whitelist
+
+From `packages/plugin-api/src/theme/themeTypes.ts`.
+
+`isValidThemeToken(token)` is true when the name is in `CORE_THEME_TOKENS` or starts with a `THEME_EXTENSION_SCOPES` prefix.
+
+`validateThemeTokens(tokens, themeId)` keeps valid entries, warns on rejects, and copies `--accent` → `--accent-primary` when the latter is missing.
+
+**`CORE_THEME_TOKENS`:** `--bg-base`, `--bg-surface`, `--bg-elevated`, `--bg-inset`, `--bg-hover`, `--bg-active`, `--accent`, `--accent-primary`, `--accent-hover`, `--accent-muted`, `--accent-subtle`, `--text-primary`, `--text-secondary`, `--text-muted`, `--text-faint`, `--border`, `--border-subtle`, `--border-strong`, `--glass-bg`, `--glass-border`, `--glass-bg-menu`, `--glass-border-menu`, `--danger`, `--danger-muted`, `--warning`, `--warning-muted`, `--success`, `--success-muted`, `--status-active`, `--status-on-hold`, `--status-completed`, `--status-dropped`
+
+**`THEME_EXTENSION_SCOPES`:** `--syntax-`, `--preview-`, `--ui-`, `--cm-`, `--md-`, `--mde-`
+
+## `registerTheme`
+
+```ts
+registerTheme(theme: {
+ id: string;
+ name: string;
+ description?: string;
+ author?: string;
+ colorScheme: 'dark' | 'light';
+ tokens: Record;
+}): () => void
+```
+
+`frosted?: boolean` exists on `ThemeDefinition` (native vibrancy). Package `theme.json` may set it; the `registerTheme` argument type on `PluginContext` does not list `frosted` — use `theme.json` for that flag.
+
+## `context.themes`
+
+```ts
+list(): ThemeInfo[]
+getActive(): ThemeInfo | null
+setActive(id: string | null): boolean
+onDidChange(callback: (id: string | null) => void): () => void
+```
+
+`ThemeInfo`: `{ id, name, colorScheme, description? }`. `setActive(null)` clears the plugin palette (back to the app default).
+
+Appearance (`'dark' | 'light' | 'system'`) is `store.getState().settings.theme`, not a `getTheme()` helper — that method is not on `PluginContext`.
+
+## CSS variables without a full palette
+
+```ts
+registerCssVariables(id: string, variables: Record): () => void
+```
+
+## Package files
+
+`theme.json` / `themes/*.json` are parsed after activate. See [Create a theme](/guides/create-a-theme). Official examples: [theme-parchment](https://github.com/dripnex/theme-parchment) and other `dripnex/theme-*` repos.
diff --git a/lib/layout.shared.tsx b/lib/layout.shared.tsx
new file mode 100644
index 0000000..8924357
--- /dev/null
+++ b/lib/layout.shared.tsx
@@ -0,0 +1,37 @@
+import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
+import { DOCS_URL, SITE_URL } from '@/lib/site';
+
+export function baseOptions(): BaseLayoutProps {
+ return {
+ nav: {
+ title: (
+
+
+
+ dripnex.
+
+ developers
+
+ ),
+ transparentMode: 'top',
+ },
+ githubUrl: 'https://github.com/dripnex/developers',
+ links: [
+ {
+ text: 'Product',
+ url: SITE_URL,
+ external: true,
+ },
+ {
+ text: 'User docs',
+ url: DOCS_URL,
+ external: true,
+ },
+ {
+ text: 'Plugins',
+ url: `${SITE_URL}/plugins`,
+ external: true,
+ },
+ ],
+ };
+}
diff --git a/lib/site.ts b/lib/site.ts
new file mode 100644
index 0000000..0c35f29
--- /dev/null
+++ b/lib/site.ts
@@ -0,0 +1,4 @@
+export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://dripnex.app';
+export const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL ?? 'https://docs.dripnex.app';
+export const DEVELOPERS_URL =
+ process.env.NEXT_PUBLIC_DEVELOPERS_URL ?? 'https://developers.dripnex.app';
diff --git a/lib/source.ts b/lib/source.ts
new file mode 100644
index 0000000..8a0b363
--- /dev/null
+++ b/lib/source.ts
@@ -0,0 +1,7 @@
+import { loader } from 'fumadocs-core/source';
+import { docs } from '@/.source/server';
+
+export const source = loader({
+ baseUrl: '/',
+ source: docs.toFumadocsSource(),
+});
diff --git a/mdx-components.tsx b/mdx-components.tsx
new file mode 100644
index 0000000..daed3fd
--- /dev/null
+++ b/mdx-components.tsx
@@ -0,0 +1,29 @@
+import type { MDXComponents } from 'mdx/types';
+import defaultComponents from 'fumadocs-ui/mdx';
+import { Card, Cards } from 'fumadocs-ui/components/card';
+import { Callout } from 'fumadocs-ui/components/callout';
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
+import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
+import { File, Folder, Files } from 'fumadocs-ui/components/files';
+import { TypeTable } from 'fumadocs-ui/components/type-table';
+
+export function useMDXComponents(components: MDXComponents): MDXComponents {
+ return {
+ ...defaultComponents,
+ Card,
+ Cards,
+ Callout,
+ Step,
+ Steps,
+ Tab,
+ Tabs,
+ Accordion,
+ Accordions,
+ File,
+ Folder,
+ Files,
+ TypeTable,
+ ...components,
+ };
+}
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..1b3be08
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..649691f
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,12 @@
+import { createMDX } from 'fumadocs-mdx/next';
+
+const withMDX = createMDX();
+
+/** @type {import('next').NextConfig} */
+const config = {
+ output: 'export',
+ reactStrictMode: true,
+ images: { unoptimized: true },
+};
+
+export default withMDX(config);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..46af6f1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@dripnex/developers",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "postinstall": "fumadocs-mdx",
+ "typecheck": "tsc --noEmit",
+ "deploy": "pnpm build && npx wrangler pages deploy out --project-name dripnex-developers --branch main"
+ },
+ "dependencies": {
+ "@fontsource-variable/jetbrains-mono": "^5.2.8",
+ "@fontsource/inter": "^5.2.8",
+ "fumadocs-core": "^16.9.3",
+ "fumadocs-mdx": "^15.0.11",
+ "fumadocs-ui": "^16.9.3",
+ "next": "^16.2.7",
+ "next-themes": "^0.4.6",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.3.0",
+ "@types/mdx": "^2.0.13",
+ "@types/node": "25.9.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "postcss": "^8.5.15",
+ "tailwindcss": "^4.3.0",
+ "typescript": "^6.0.3"
+ },
+ "packageManager": "pnpm@11.9.0"
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..ba30d5f
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+/** @type {import('postcss').Config} */
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+export default config;
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..e4be951
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..d766d84
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..b050d87
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..e892e77
Binary files /dev/null and b/public/icon.png differ
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 0000000..95710fe
Binary files /dev/null and b/public/logo.png differ
diff --git a/source.config.ts b/source.config.ts
new file mode 100644
index 0000000..0b6ee4d
--- /dev/null
+++ b/source.config.ts
@@ -0,0 +1,7 @@
+import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
+
+export const docs = defineDocs({
+ dir: 'content/docs',
+});
+
+export default defineConfig();
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8f128ae
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"],
+ "@/.source": ["./.source/index"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.mdx",
+ ".next/types/**/*.ts",
+ ".source/index.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}