` | 3.0 — `createV2Client()` |
+|---|---|
+| `content.getContent` | `page.getPages` / `blogPost.getBlogPosts` / `customContent.getCustomContent` |
+| `content.getContentById` | `page.getPageById` / `blogPost.getBlogPostById` |
+| `content.getContentByTypeForSpace`, `content.getContentForSpace` | `page.getPagesInSpace` / `blogPost.getBlogPostsInSpace` |
+| `content.createContent` | `page.createPage` / `blogPost.createBlogPost` |
+| `content.updateContent` | `page.updatePage` / `blogPost.updateBlogPost` |
+| `content.deleteContent` | `page.deletePage` / `blogPost.deleteBlogPost` |
+| `contentChildrenAndDescendants.getContentChildren`, `…ByType` | `children.getPageChildren` / `descendants.getPageDescendants` |
+| `contentComments.getContentComments` | `comment.getPageFooterComments` / `comment.getPageInlineComments` |
+| `contentAttachments.getAttachments` | `attachment.getPageAttachments` |
+| `contentAttachments.downloadAttachment` | `attachment.getAttachmentById` then fetch `downloadLink` |
+| `contentVersions.getContentVersions`, `getContentVersion` | `version.getPageVersions` / `version.getBlogPostVersions` |
+| `content.getHistoryForContent` | `version.getPageVersions` |
+| `contentLabels.getLabelsForContent` | `label.getPageLabels` / `label.getBlogPostLabels` |
+| `contentProperties.*` | `contentProperties.getPageContentProperties` and siblings |
+| `space.getSpace`, `space.getSpaces` | `space.getSpaceById` / `space.getSpaces` |
+| `spaceProperties.*` | `spaceProperties.getSpaceProperties` and siblings |
+| `contentBody.convertContentBody` | *no v2 equivalent* — stay on 2.x for this one |
+| `inlineTasks.searchTasks`, `getTaskById`, `updateTaskById` | `task.getTasks` / `task.getTaskById` |
+| `group.addUserToGroup`, `group.removeMemberFromGroup` | *no v2 equivalent* — user management moved to the [Admin API](https://developer.atlassian.com/cloud/admin/user-management/rest/) |
+
+One of the 37 did not move to v2 but stayed in v1 under a new name:
+`contentAttachments.createAttachments` is now
+`contentAttachments.createAttachment` — see [Attachment upload](#attachment-upload).
+
+What stayed in v1 is what v2 has no answer for: attachment upload, content
+restrictions, watches, permission checks, content states, groups, relations, CQL
+search, templates, themes, settings, user properties, audit records and
+long-running tasks.
+
+This is a direction, not a one-off. `confluence.js` follows the published v1 spec
+rather than freezing a private copy of the old API, and that spec keeps narrowing
+as v2 grows. Expect v1 to lose more operations over time, and move each call to
+v2 as soon as v2 has an equivalent.
+
+### Attachment upload
+
+Upload is v1's, and v1 keeps it — v2 reads attachments but cannot create them.
+`createAttachments` is now `contentAttachments.createAttachment`, and it takes the
+file content directly rather than a hand-built `FormData`:
+
+```diff
+-await client.contentAttachments.createAttachments({
+- id: pageId,
+- attachment: { file: fs.createReadStream('report.pdf') },
+-});
++await confluence.contentAttachments.createAttachment({
++ id: pageId,
++ attachments: { filename: 'report.pdf', content: bytes },
++});
+```
+
+`content` accepts a `string`, `Buffer`, `Blob`/`File`, a Node stream or any async
+iterable of bytes, and `attachments` takes one attachment or an array of them.
+`contentAttachments.downloadAttatchment` (Atlassian's spelling, kept because it is
+the operation id) returns the bytes back.
+
+## Imports and entry points
+
+```diff
+-import { ConfluenceClient, Config } from 'confluence.js';
+-import { Content } from 'confluence.js/api/content';
++import { createV1Client, type ClientConfig } from 'confluence.js';
++import { getGroups } from 'confluence.js/v1';
++import { getPages } from 'confluence.js/v2';
++import { createClient } from 'confluence.js/core';
+```
+
+`BaseClient` and the per-namespace classes are gone. If you subclassed
+`BaseClient` to assemble a narrow client, compose flat functions instead —
+the result tree-shakes, which the subclass never did:
+
+```typescript
+import { createClient } from 'confluence.js/core';
+import { getGroups } from 'confluence.js/v1';
+import { getSpaces } from 'confluence.js/v2';
+
+const client = createClient({ host, auth });
+
+export const custom = {
+ groups: () => getGroups(client, {}),
+ spaces: () => getSpaces(client, {}),
+};
+```
+
+Deep imports such as `confluence.js/api/content` no longer resolve. The entry
+points are `confluence.js`, `confluence.js/v1`, `confluence.js/v2` and
+`confluence.js/core`.
+
+## Errors
+
+2.x rejected with an `AxiosError`. 3.0 throws a typed error per status:
+
+```diff
+-import type { Error as ConfluenceError } from 'confluence.js';
++import { isNotFoundError } from 'confluence.js';
+
+ try {
+ await client.group.getGroups({});
+ } catch (error) {
+- if (error.response?.status === 404) { … }
++ if (isNotFoundError(error)) { … }
+ }
+```
+
+`ApiError` carries `status`, `statusText` and `body` (the parsed response body).
+`AuthError` (401), `ForbiddenError` (403), `NotFoundError` (404),
+`RateLimitError` (429, with `retryAfterMs`) and `ServerError` (5xx) extend it, so
+catching `ApiError` still catches everything.
+
+Two failures that used to escape as something else now have types of their own:
+a transport fault is a `NetworkError` (with `code`, `transient` and the original
+error as `cause`) rather than a raw `TypeError` from `fetch`, and an OAuth 2.0
+failure is an `OAuthError` rather than a plain `Error`.
+
+Prefer the `isXxx` predicates over `instanceof`: they check a branded marker
+instead of the prototype chain, so they still work when two copies of the package
+end up installed together.
+
+New in 3.0: a response that does not match its schema throws a `ZodError`. That
+means Atlassian's API drifted from its own spec — please
+[open an issue](https://github.com/MrRefactoring/confluence.js/issues).
+
+## Still on 2.x?
+
+Staying is a legitimate choice. `confluence.js@2` keeps working and keeps its v1
+surface, including the 37 operations Atlassian has since removed from the spec.
+
+Stay on 2.x if you need Atlassian Connect JWT, or `convertContentBody`, or group
+membership management. Otherwise 3.0 gives you v2, one runtime dependency, and
+responses that are checked rather than assumed.
diff --git a/README.md b/README.md
index a77e639b..701d851c 100644
--- a/README.md
+++ b/README.md
@@ -1,248 +1,263 @@
## About
-Confluence.js is a powerful Node.js and browser-compatible module that provides seamless interaction with:
-- [Confluence Cloud REST API](https://developer.atlassian.com/cloud/confluence/rest/)
+`confluence.js` covers both Confluence Cloud REST APIs from one package:
+
+- **[Cloud REST API v2](https://developer.atlassian.com/cloud/confluence/rest/v2/)** — 30 namespaces, 218 methods
+- **[Cloud REST API v1](https://developer.atlassian.com/cloud/confluence/rest/v1/)** — 28 namespaces, 130 methods
+
+Both are first-class. Atlassian has not deprecated v1, and the two cover
+different ground: v1 still owns what v2 has no equivalent for, and v2 owns
+everything Atlassian has moved forward.
+
+Every response is validated at runtime against a [Zod 4](https://zod.dev) schema
+derived from Atlassian's OpenAPI spec, so API drift surfaces as a `ZodError`
+rather than as `undefined` three call frames later.
-Designed for developer experience and performance, it offers full API coverage and stays updated with new Confluence features.
+- ESM-only, Node ≥ 22, no polyfills — the transport is the built-in `fetch`
+- Tree-shakable: only the endpoints you import reach your bundle
+- One runtime dependency: `zod`
## Table of Contents
- [Getting Started](#getting-started)
- - [Installation](#installation)
- - [Quick Example](#quick-example)
-- [Documentation](#documentation)
-- [Usage](#usage)
- - [Authentication](#authentication)
- - [Basic Auth](#basic-authentication)
- - [OAuth 2.0](#oauth-20)
- - [JWT](#jwt)
- - [First Request](#first-request)
- - [API Structure](#api-structure)
- - [Custom API Prefix](#custom-api-prefix)
-- [Tree Shaking](#tree-shaking)
+- [Authentication](#authentication)
+- [Two ways to use it](#two-ways-to-use-it)
+- [Choosing between v1 and v2](#choosing-between-v1-and-v2)
+- [Error handling](#error-handling)
+- [Retries](#retries)
+- [Migrating from 2.x](#migrating-from-2x)
- [Other Products](#other-products)
- [License](#license)
## Getting Started
-### Installation
-
-**Requires Node.js 20.0.0 or newer**
-
```bash
-# npm
npm install confluence.js
-
-# yarn
-yarn add confluence.js
-
-# pnpm
-pnpm add confluence.js
```
-### Quick Example
-
-Create a Confluence space in 3 steps:
-
```typescript
-import { ConfluenceClient } from 'confluence.js';
+import { createV2Client } from 'confluence.js';
-const client = new ConfluenceClient({
+const confluence = createV2Client({
host: 'https://your-domain.atlassian.net',
- authentication: {
- basic: {
- email: 'your@email.com',
- apiToken: 'YOUR_API_TOKEN', // Create one: https://id.atlassian.com/manage-profile/security/api-tokens
- },
+ auth: {
+ type: 'basic',
+ email: 'YOUR@EMAIL.ORG',
+ apiToken: 'YOUR_API_TOKEN',
},
});
-async function createSpace() {
- const space = await client.space.createSpace({
- name: 'Project Galaxy',
- key: 'GALAXY',
- });
- console.log(`Space created: ${space.key}`);
-}
+const page = await confluence.page.createPage({
+ spaceId: '123456',
+ title: 'Project Overview',
+ body: {
+ representation: 'storage',
+ value: 'Welcome to our project documentation
',
+ },
+});
-createSpace();
+console.log(`Page created: ${page.title}`);
```
-## Documentation
-
-Full API reference and guides available at:
-[https://mrrefactoring.github.io/confluence.js/](https://mrrefactoring.github.io/confluence.js/)
-
-## Usage
+`host` is your bare site URL. The API path (`/wiki/api/v2`, `/wiki/rest/api`)
+belongs to the request, not to `host` — so one host serves both versions.
-### Authentication
+## Authentication
-#### Basic Authentication
+### Basic
-1. Create an API token: [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens)
-2. Configure the client:
+Create an API token in
+[Atlassian account settings](https://id.atlassian.com/manage-profile/security/api-tokens):
```typescript
-const client = new ConfluenceClient({
+const confluence = createV2Client({
host: 'https://your-domain.atlassian.net',
- authentication: {
- basic: {
- email: 'YOUR@EMAIL.ORG',
- apiToken: 'YOUR_API_TOKEN',
- },
- },
+ auth: { type: 'basic', email: 'YOUR@EMAIL.ORG', apiToken: 'YOUR_API_TOKEN' },
});
```
-#### OAuth 2.0
+### OAuth 2.0 (3LO)
-Implement OAuth 2.0 flow using Atlassian's [documentation](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/):
+Hand over your app credentials and a refresh token. The client refreshes before
+expiry, retries once on a 401, and resolves the cloud id itself:
```typescript
-const client = new ConfluenceClient({
- host: 'https://your-domain.atlassian.net',
- authentication: {
- oauth2: {
- accessToken: 'YOUR_ACCESS_TOKEN',
- },
+const confluence = createV2Client({
+ auth: {
+ type: 'oauth2',
+ clientId: 'YOUR_CLIENT_ID',
+ clientSecret: 'YOUR_CLIENT_SECRET',
+ refreshToken: 'YOUR_REFRESH_TOKEN',
+ // Atlassian rotates the refresh token on every refresh — persist the new one.
+ onTokenRefresh: ({ refreshToken }) => tokenStore.save(refreshToken),
},
});
```
-#### JWT
+No `host`: 3LO tokens only work through `api.atlassian.com`, never on your site's
+own domain, so the client builds that URL from the cloud id. Pass `cloudId` or
+`siteUrl` if the token can reach more than one site.
+
+`confluence.js` also exports the flow itself — `generateAuthorizationUrl`,
+`parseCallbackUrl`, `exchangeAuthorizationCode`, `refreshOAuth2Token` and
+`getAccessibleResources`. See the [OAuth 2.0 guide](https://mrrefactoring.github.io/confluence.js/guide/oauth2)
+— scopes differ between v1 and v2, and refresh tokens rotate.
-For server-to-server integration:
+For a bearer token from somewhere other than 3LO, hand over a resolver — called
+per request — and a hook to re-derive auth after a 401:
```typescript
-const client = new ConfluenceClient({
+const confluence = createV2Client({
host: 'https://your-domain.atlassian.net',
- authentication: {
- jwt: {
- issuer: 'your-client-id',
- secret: 'your-secret-key',
- expiryTimeSeconds: 180,
- },
- },
+ auth: { type: 'bearer', getToken: () => tokenStore.current() },
+ getAuthOn401: async () => ({ type: 'bearer', token: await refresh() }),
});
```
-### First Request
+> **JWT (Atlassian Connect) is not supported in 3.x.** See
+> [MIGRATION.md](./MIGRATION.md#jwt-authentication-is-gone).
+
+## Two ways to use it
-Create a page in an existing space:
+**A client object** — the whole API behind namespaces:
```typescript
-const page = await client.content.createContent({
- title: 'Project Overview',
- type: 'page',
- space: { key: 'GALAXY' },
- body: {
- storage: {
- value: 'Welcome to our project documentation
',
- representation: 'storage',
- },
- },
-});
+import { createV2Client } from 'confluence.js';
-console.log(`Page created: ${page.title}`);
-```
+const confluence = createV2Client({ host, auth });
-### API Structure
+await confluence.page.getPages({ spaceId: ['123'] });
+await confluence.space.getSpaces({ limit: 25 });
+```
-Access endpoints using `client..` pattern:
+**Flat functions** — the same endpoints as standalone functions taking a client.
+Nothing you do not import reaches your bundle:
```typescript
-// Get space details
-const space = await client.space.getSpace({ spaceKey: 'GALAXY' });
+import { createClient } from 'confluence.js/core';
+import { getPages } from 'confluence.js/v2';
+
+const client = createClient({ host, auth });
-// Search content
-const results = await client.search.search({ cql: 'title~"Project"' });
+await getPages(client, { spaceId: ['123'] });
```
-
- 🔽 Available API Groups
-
-- [audit](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-audit/#api-group-audit)
-- [analytics](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-analytics/#api-group-analytics)
-- [content](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content/#api-group-content)
-- [contentAttachments](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---attachments/#api-group-content---attachments)
-- [contentBody](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-body/#api-group-content-body)
-- [contentChildrenAndDescendants](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---children-and-descendants/#api-group-content---children-and-descendants)
-- contentComments - was deprecated
-- [contentMacroBody](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---macro-body/#api-group-content---macro-body)
-- [contentLabels](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/#api-group-content-labels)
-- [contentPermissions](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-permissions/#api-group-content-permissions)
-- contentProperties - was deprecated
-- [contentRestrictions](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/#api-group-content-restrictions)
-- [contentStates](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-states/#api-group-content-states)
-- [contentVersions](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-versions/#api-group-content-versions)
-- [contentWatches](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-watches/#api-group-content-watches)
-- [dynamicModules](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-dynamic-modules/#api-group-dynamic-modules)
-- [experimental](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-experimental/#api-group-experimental)
-- [group](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-group/#api-group-group)
-- inlineTasks - was deprecated
-- [labelInfo](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-label-info/#api-group-label-info)
-- [longRunningTask](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-long-running-task/#api-group-long-running-task)
-- [relation](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-relation/#api-group-relation)
-- [search](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-search/#api-group-search)
-- [settings](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-settings/#api-group-settings)
-- [space](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-space/#api-group-space)
-- [spacePermissions](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-space-permissions/#api-group-space-permissions)
-- spaceProperties - was deprecated
-- [spaceSettings](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-space-settings/#api-group-space-settings)
-- [template](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-template/#api-group-template)
-- [themes](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-themes/#api-group-themes)
-- [users](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-group-users)
-- [userProperties](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-user-properties/#api-group-user-properties)
-
-
-
-### Custom API Prefix
-
-For custom API endpoints:
+One client drives both versions:
```typescript
-const client = new ConfluenceClient({
- host: 'https://custom-domain.com',
- apiPrefix: '/confluence-api', // Default: '/wiki/rest/api'
-});
+import { createClient } from 'confluence.js/core';
+import { getPages } from 'confluence.js/v2';
+import { getGroups } from 'confluence.js/v1';
+
+const client = createClient({ host, auth });
+
+await getPages(client, {}); // → /wiki/api/v2/pages
+await getGroups(client, {}); // → /wiki/rest/api/group
```
-## Tree Shaking
+### Entry points
+
+| Import | What it gives you |
+|---|---|
+| `confluence.js` | `createV1Client`, `createV2Client`, `ApiError`, config types |
+| `confluence.js/v2` | v2 flat functions, parameter and response types |
+| `confluence.js/v1` | v1 flat functions, parameter and response types |
+| `confluence.js/core` | `createClient`, transport types, OAuth helpers |
+
+`confluence.js/v1` and `confluence.js/v2` are not re-exported from the root: the
+two versions collide on a few names (`createSpace`, `getTasks`).
+
+## Choosing between v1 and v2
+
+Reach for **v2** by default — it is where Atlassian is investing. It covers
+pages, blog posts, custom content, whiteboards, databases, folders, comments,
+attachments, labels, tasks, versions, spaces, space permissions and space roles.
+
+Reach for **v1** only for what v2 has no equivalent for: attachment *upload* (v2
+reads attachments but cannot create them), content restrictions, watches,
+permission checks, content states, groups, relations, CQL search, templates,
+themes, settings, user properties, audit records and long-running tasks.
+
+v1 is a complement to v2, not the old API kept alive. This package tracks
+Atlassian's v1 spec as published, and that spec is shrinking: 37 operations that
+2.x exposed — `getContent`, `createContent`, `getSpace` among them — are already
+gone from it, and 33 of those have a v2 equivalent
+([the map](./MIGRATION.md#the-v1-surface-follows-atlassians-current-spec)).
+Expect it to narrow further, and treat a v1 call as something to move to v2 once
+v2 covers it.
+
+## Error handling
-Optimize bundle size by importing only needed modules:
+Every failure is one of this package's own error types — nothing leaks from
+`fetch`. Non-2xx responses throw an `ApiError` subclass carrying the status and
+the parsed body; transport faults throw a `NetworkError`; the OAuth flow throws
+an `OAuthError`.
```typescript
-// custom-client.ts
-import { BaseClient } from 'confluence.js';
-import { Content } from 'confluence.js/api/content';
-import { Space } from 'confluence.js/api/space';
-
-export class CustomClient extends BaseClient {
- content = new Content(this);
- space = new Space(this);
+import { isNotFoundError, isRateLimitError } from 'confluence.js';
+
+try {
+ await confluence.page.getPageById({ id: 42 });
+} catch (error) {
+ if (isNotFoundError(error)) return null;
+ if (isRateLimitError(error)) await sleep(error.retryAfterMs ?? 60_000);
+ throw error;
}
+```
+
+`AuthError` (401), `ForbiddenError` (403), `NotFoundError` (404),
+`RateLimitError` (429) and `ServerError` (5xx) all extend `ApiError`, so
+`instanceof` works — but prefer the `isXxx` predicates: they survive two copies
+of the package in one `node_modules`, where `instanceof` silently returns false.
+
+A response that does not match its schema throws a `ZodError` instead. That
+means Atlassian's API drifted from its own spec, and it is worth
+[an issue](https://github.com/MrRefactoring/confluence.js/issues).
+
+## Retries
-// Usage
-const client = new CustomClient({ /* config */ });
-await client.space.getSpace({ spaceKey: 'GALAXY' });
+Off by default. When enabled, retries cover transport-layer failures only —
+network errors and 502/503/504:
+
+```typescript
+const confluence = createV2Client({
+ host,
+ auth,
+ retry: { maxAttempts: 3, initialDelayMs: 500, backoffFactor: 2 },
+});
+```
+
+4xx is never retried, 429 included: rate limiting is a signal to slow down, not
+a transient fault to paper over.
+
+## Migrating from 2.x
+
+3.0 reworks the public surface — class clients became factories, `authentication`
+became `auth`, callbacks and middlewares are gone, and the v1 surface now follows
+Atlassian's current spec. See **[MIGRATION.md](./MIGRATION.md)** for the full map;
+a codemod handles the mechanical parts:
+
+```bash
+npx jscodeshift -t node_modules/confluence.js/tools/codemod/v2-to-v3.ts src/
```
## Other Products
-Explore our other Atlassian integration libraries:
-- [Jira.js](https://github.com/MrRefactoring/jira.js) - Jira API wrapper
-- [Trello.js](https://github.com/MrRefactoring/trello.js) - Trello API integration
+- [jira.js](https://github.com/MrRefactoring/jira.js) — Jira REST API client
+- [trello.js](https://github.com/MrRefactoring/trello.js) — Trello REST API client
## License
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
new file mode 100644
index 00000000..f45d86c8
--- /dev/null
+++ b/docs/.vitepress/config.ts
@@ -0,0 +1,205 @@
+import { defineConfig } from 'vitepress';
+import typedocSidebar from '../api/typedoc-sidebar.json' with { type: 'json' };
+import typedocSidebarRu from '../api/typedoc-sidebar.ru.json' with { type: 'json' };
+
+const SITE_URL = 'https://mrrefactoring.github.io/confluence.js';
+const SITE_TITLE = 'confluence.js';
+const SITE_TAGLINE = 'Type-safe Confluence Cloud REST API client';
+const SITE_DESCRIPTION_EN =
+ 'Type-safe Confluence Cloud REST API client for TypeScript and JavaScript. Both API versions — v1 and v2 — from one package. ESM-only, tree-shakable, validated by Zod 4.';
+const SITE_DESCRIPTION_RU =
+ 'Типобезопасный клиент Confluence Cloud REST API для TypeScript и JavaScript. Обе версии API — v1 и v2 — в одном пакете. Только ESM, tree-shaking, валидация через Zod 4.';
+const OG_IMAGE = `${SITE_URL}/og-image.png`;
+
+const guideSidebar = (ru = false) => {
+ const p = ru ? '/ru' : '';
+
+ return [
+ {
+ text: ru ? 'Руководство' : 'Guide',
+ items: [
+ { text: ru ? 'Быстрый старт' : 'Getting Started', link: `${p}/guide/getting-started` },
+ { text: ru ? 'Установка' : 'Installation', link: `${p}/guide/installation` },
+ { text: ru ? 'Аутентификация' : 'Authentication', link: `${p}/guide/authentication` },
+ { text: 'OAuth 2.0 (3LO)', link: `${p}/guide/oauth2` },
+ { text: ru ? 'Выбор версии API' : 'Choosing a version', link: `${p}/guide/choosing-a-version` },
+ { text: ru ? 'Обработка ошибок' : 'Error Handling', link: `${p}/guide/error-handling` },
+ { text: ru ? 'Повторы запросов' : 'Retries', link: `${p}/guide/retries` },
+ { text: ru ? 'Браузеры' : 'Browsers', link: `${p}/guide/browser` },
+ { text: 'Tree-Shaking', link: `${p}/guide/tree-shaking` },
+ { text: 'TypeScript', link: `${p}/guide/typescript` },
+ ],
+ },
+ {
+ text: ru ? 'Рецепты' : 'Recipes',
+ items: [
+ { text: ru ? 'Страницы' : 'Pages', link: `${p}/recipes/pages` },
+ { text: ru ? 'Пространства' : 'Spaces', link: `${p}/recipes/spaces` },
+ { text: ru ? 'Вложения' : 'Attachments', link: `${p}/recipes/attachments` },
+ { text: ru ? 'Поиск (CQL)' : 'Search (CQL)', link: `${p}/recipes/search` },
+ { text: ru ? 'Комментарии' : 'Comments', link: `${p}/recipes/comments` },
+ { text: ru ? 'Метки' : 'Labels', link: `${p}/recipes/labels` },
+ { text: ru ? 'Ограничения доступа' : 'Restrictions', link: `${p}/recipes/restrictions` },
+ ],
+ },
+ {
+ text: ru ? 'Миграция' : 'Migration',
+ items: [{ text: '2.x → 3.0', link: `${p}/migration/v2-to-v3` }],
+ },
+ ];
+};
+
+const apiSidebar = [{ text: 'API Reference', items: typedocSidebar }];
+const apiSidebarRu = [{ text: 'API Reference', items: typedocSidebarRu }];
+
+export default defineConfig({
+ title: SITE_TITLE,
+ description: SITE_DESCRIPTION_EN,
+ base: '/confluence.js/',
+ cleanUrls: true,
+ lastUpdated: false,
+
+ // Deliberately not ignoring anything under /api/: `docs:build` always rebuilds
+ // the reference first, so a dead link there is a real one worth failing on.
+ ignoreDeadLinks: ['localhostLinks'],
+
+ sitemap: { hostname: `${SITE_URL}/` },
+
+ head: [
+ // Locale auto-detection, inline so it runs before the first paint:
+ // - already under /ru/ → record the choice, do not redirect;
+ // - a Russian browser with no recorded choice → send to the RU twin of this
+ // exact path, preserving query and hash;
+ // - anything else → record 'en'.
+ // Once a choice is stored, this returns immediately: picking a language from
+ // the switcher is permanent, and a redirect must never override it.
+ [
+ 'script',
+ {},
+ `(function(){try{var K='confluence.js:locale',B='/confluence.js/',p=location.pathname,isRu=p.indexOf(B+'ru/')===0||p===B+'ru'||p===B+'ru/';var s=localStorage.getItem(K);if(s)return;if(isRu){localStorage.setItem(K,'ru');return;}var L=(navigator.language||'').toLowerCase();if(L.indexOf('ru')===0&&p.indexOf(B)===0){localStorage.setItem(K,'ru');location.replace(p.replace(B,B+'ru/')+location.search+location.hash);}else{localStorage.setItem(K,'en');}}catch(e){}})();`,
+ ],
+
+ ['link', { rel: 'icon', href: '/confluence.js/favicon.svg', type: 'image/svg+xml' }],
+ ['meta', { name: 'theme-color', content: '#0052cc' }],
+ ['meta', { name: 'author', content: 'Vladislav Tupikin' }],
+
+ ['meta', { property: 'og:type', content: 'website' }],
+ ['meta', { property: 'og:site_name', content: SITE_TITLE }],
+ ['meta', { property: 'og:title', content: `${SITE_TITLE} — ${SITE_TAGLINE}` }],
+ ['meta', { property: 'og:description', content: SITE_DESCRIPTION_EN }],
+ ['meta', { property: 'og:url', content: `${SITE_URL}/` }],
+ ['meta', { property: 'og:image', content: OG_IMAGE }],
+ ['meta', { property: 'og:image:width', content: '1200' }],
+ ['meta', { property: 'og:image:height', content: '630' }],
+ ['meta', { property: 'og:image:type', content: 'image/png' }],
+ ['meta', { property: 'og:image:alt', content: `${SITE_TITLE} — ${SITE_TAGLINE}` }],
+
+ ['meta', { name: 'twitter:card', content: 'summary_large_image' }],
+ ['meta', { name: 'twitter:title', content: `${SITE_TITLE} — ${SITE_TAGLINE}` }],
+ ['meta', { name: 'twitter:description', content: SITE_DESCRIPTION_EN }],
+ ['meta', { name: 'twitter:image', content: OG_IMAGE }],
+
+ [
+ 'script',
+ { type: 'application/ld+json' },
+ JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'SoftwareSourceCode',
+ name: SITE_TITLE,
+ description: SITE_DESCRIPTION_EN,
+ codeRepository: 'https://github.com/MrRefactoring/confluence.js',
+ programmingLanguage: 'TypeScript',
+ runtimePlatform: 'Node.js',
+ license: 'https://opensource.org/licenses/MIT',
+ author: { '@type': 'Person', name: 'Vladislav Tupikin' },
+ keywords: 'confluence, confluence-api, atlassian, typescript, esm, zod, tree-shaking, type-safe, wiki',
+ url: `${SITE_URL}/`,
+ }),
+ ],
+ ],
+
+ transformHead: ({ pageData }) => {
+ const head: [string, Record][] = [];
+
+ const relativePath = pageData.relativePath
+ .replace(/\.md$/, '')
+ .replace(/(^|\/)index$/, '$1')
+ .replace(/\/$/, '');
+
+ const isRu = relativePath === 'ru' || relativePath.startsWith('ru/');
+ const enRelative = isRu ? relativePath.replace(/^ru\/?/, '') : relativePath;
+ const ruRelative = isRu ? relativePath : relativePath ? `ru/${relativePath}` : 'ru';
+
+ const url = (path: string) => `${SITE_URL}/${path ? `${path}/` : ''}`.replace(/\/+$/, '/');
+
+ head.push(['link', { rel: 'canonical', href: url(relativePath) }]);
+ head.push(['link', { rel: 'alternate', hreflang: 'en', href: url(enRelative) }]);
+ head.push(['link', { rel: 'alternate', hreflang: 'ru', href: url(ruRelative) }]);
+ head.push(['link', { rel: 'alternate', hreflang: 'x-default', href: url(enRelative) }]);
+
+ return head;
+ },
+
+ themeConfig: {
+ logo: '/favicon.svg',
+ search: { provider: 'local' },
+ socialLinks: [
+ { icon: 'github', link: 'https://github.com/MrRefactoring/confluence.js' },
+ { icon: 'npm', link: 'https://www.npmjs.com/package/confluence.js' },
+ ],
+ },
+
+ locales: {
+ root: {
+ label: 'English',
+ lang: 'en-US',
+ description: SITE_DESCRIPTION_EN,
+ themeConfig: {
+ nav: [
+ { text: 'Guide', link: '/guide/getting-started' },
+ { text: 'Recipes', link: '/recipes/pages' },
+ { text: 'API', link: '/api/' },
+ { text: 'Migration', link: '/migration/v2-to-v3' },
+ ],
+ sidebar: {
+ '/guide/': guideSidebar(),
+ '/recipes/': guideSidebar(),
+ '/migration/': guideSidebar(),
+ '/api/': apiSidebar,
+ },
+ editLink: {
+ pattern: 'https://github.com/MrRefactoring/confluence.js/edit/master/docs/:path',
+ text: 'Edit this page on GitHub',
+ },
+ outline: { label: 'On this page', level: [2, 3] },
+ docFooter: { prev: 'Previous', next: 'Next' },
+ },
+ },
+ ru: {
+ label: 'Русский',
+ lang: 'ru-RU',
+ link: '/ru/',
+ description: SITE_DESCRIPTION_RU,
+ themeConfig: {
+ nav: [
+ { text: 'Руководство', link: '/ru/guide/getting-started' },
+ { text: 'Рецепты', link: '/ru/recipes/pages' },
+ { text: 'API', link: '/ru/api/' },
+ { text: 'Миграция', link: '/ru/migration/v2-to-v3' },
+ ],
+ sidebar: {
+ '/ru/guide/': guideSidebar(true),
+ '/ru/recipes/': guideSidebar(true),
+ '/ru/migration/': guideSidebar(true),
+ '/ru/api/': apiSidebarRu,
+ },
+ editLink: {
+ pattern: 'https://github.com/MrRefactoring/confluence.js/edit/master/docs/:path',
+ text: 'Редактировать на GitHub',
+ },
+ outline: { label: 'На этой странице', level: [2, 3] },
+ docFooter: { prev: 'Назад', next: 'Далее' },
+ },
+ },
+ },
+});
diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md
new file mode 100644
index 00000000..39f45e59
--- /dev/null
+++ b/docs/guide/authentication.md
@@ -0,0 +1,82 @@
+---
+title: Authentication
+description: Authenticate confluence.js with an Atlassian API token or OAuth 2.0 (3LO), including automatic token refresh and cloud id resolution.
+---
+
+# Authentication
+
+Both factories take the same `auth` object, and so does `createClient` from `confluence.js/core`.
+
+## Basic (API token)
+
+The quickest way in, and the right one for scripts and back-office jobs. Create a token in [Atlassian account settings](https://id.atlassian.com/manage-profile/security/api-tokens) and pair it with the email of the same account:
+
+```ts
+import { createV2Client } from 'confluence.js';
+
+const confluence = createV2Client({
+ host: 'https://your-domain.atlassian.net',
+ auth: {
+ type: 'basic',
+ email: process.env.CONFLUENCE_EMAIL!,
+ apiToken: process.env.CONFLUENCE_API_TOKEN!,
+ },
+});
+```
+
+The client sends these as an HTTP Basic header. The token carries **your** permissions — anything it can reach, the script can reach.
+
+## OAuth 2.0 (3LO)
+
+For acting on behalf of other users. Hand over your app credentials and a refresh token, and the client refreshes before expiry, retries once on a `401`, and finds the right site:
+
+```ts
+const confluence = createV2Client({
+ auth: {
+ type: 'oauth2',
+ clientId: process.env.OAUTH_CLIENT_ID!,
+ clientSecret: process.env.OAUTH_CLIENT_SECRET!,
+ refreshToken: await tokenStore.refreshToken(),
+ onTokenRefresh: async ({ refreshToken, expiresAt }) => {
+ await tokenStore.save({ refreshToken, expiresAt });
+ },
+ },
+});
+```
+
+There is no `host`: 3LO tokens are rejected on your site's own domain and only work through the Atlassian gateway, so the client derives that URL itself.
+
+3LO has enough moving parts — scope families that differ between v1 and v2, rotating refresh tokens, consent callbacks — that it gets [a guide of its own](./oauth2). Read it before wiring this up.
+
+## Expiring bearer tokens
+
+Outside 3LO — a gateway or a proxy that mints its own tokens — pass a resolver, called on **every request**:
+
+```ts
+const confluence = createV2Client({
+ host: 'https://your-domain.atlassian.net',
+ auth: { type: 'bearer', getToken: () => tokenStore.current() },
+});
+```
+
+And when a token expires mid-flight, `getAuthOn401` re-derives auth and replays the request:
+
+```ts
+const confluence = createV2Client({
+ host: 'https://your-domain.atlassian.net',
+ auth: { type: 'bearer', getToken: () => tokenStore.current() },
+ getAuthOn401: async () => ({ type: 'bearer', token: await refreshAccessToken() }),
+});
+```
+
+It fires only on a `401`, and only once per request — a second `401` is a real `ApiError`, not a retry loop.
+
+## JWT (Atlassian Connect) is not supported
+
+3.x dropped JWT. Connect apps authenticate as the *app*, with a shared secret and a signed JWT per request — a different model from the two above, and one that ties the client to the Connect lifecycle.
+
+If you are building a Connect app, stay on `confluence.js@2`. See [the migration guide](../migration/v2-to-v3).
+
+## Where the credentials go
+
+Your credentials reach two places and no others: the `Authorization` header of the requests you make, and — under OAuth 2.0 — Atlassian's own `auth.atlassian.com` and `api.atlassian.com` endpoints for token refresh and site lookup. There is no telemetry, no analytics and no third-party host in this package.
diff --git a/docs/guide/browser.md b/docs/guide/browser.md
new file mode 100644
index 00000000..1c385cd3
--- /dev/null
+++ b/docs/guide/browser.md
@@ -0,0 +1,88 @@
+---
+title: Browsers
+description: Run confluence.js in a browser — importing from a CDN, what attachments do differently there, and why calling Confluence straight from a page is usually the wrong shape.
+---
+
+# Browsers
+
+The package runs unchanged in a browser. There is one build, not a Node build and a web build: the code branches on what the runtime can do rather than on which runtime it is.
+
+## Read this before you ship it
+
+Calling Confluence directly from a page means the browser holds a credential.
+
+An API token in front-end code is readable by anyone who opens devtools, and it carries the full rights of the account that issued it. An OAuth 2.0 client secret is worse, because it also lets someone mint tokens. Neither belongs in a bundle.
+
+Confluence Cloud also does not send CORS headers for third-party origins, so a request from your own domain is refused by the browser before it reaches Atlassian.
+
+That leaves two shapes that actually work:
+
+- **A server in front.** Your page talks to your backend, the backend holds the credential and talks to Confluence. This is the right answer for almost every product.
+- **A first-party surface.** A Forge or Connect app, or an extension, where the platform supplies the origin and the credential.
+
+Browser support exists so those surfaces — and tooling, sandboxes, docs pages and tests — can use the same library. It is not an invitation to put a token in a web page.
+
+## Importing from a CDN
+
+Any CDN that resolves dependencies serves the package as it is published:
+
+```html
+
+```
+
+jsDelivr works the same way through its `+esm` endpoint:
+
+```js
+import { createV2Client } from 'https://cdn.jsdelivr.net/npm/confluence.js/+esm';
+```
+
+The published entry imports `zod` by bare specifier, which a browser cannot resolve on its own — those CDNs rewrite it before serving. A host that returns files untouched will fail on that import.
+
+For those, and for a plain `
+```
+
+jsDelivr делает то же через эндпоинт `+esm`:
+
+```js
+import { createV2Client } from 'https://cdn.jsdelivr.net/npm/confluence.js/+esm';
+```
+
+Опубликованная точка входа импортирует `zod` голым спецификатором, а его браузер сам разрешить не может — перечисленные CDN переписывают его перед отдачей. Хостинг, отдающий файлы как есть, на этом импорте упадёт.
+
+Для таких случаев и для простого `
+
+
+
+
+