npm package: @markupai/sidebar-adapter
Description: Adapter package for MarkupAI Sidebar integration
Latest version: 0.0.23

# @markupai/sidebar-adapter

TypeScript library for **structured `postMessage` communication** between your **host application** (the page that embeds the sidebar) and the **Markup AI sidebar** (a web app loaded in an `<iframe>`).

You do **not** need prior knowledge of how the sidebar is built internally. As an integrator, you provide a **sidebar URL** (from Markup AI), an **iframe**, and a small set of **callbacks** that read and update the user’s document in your editor or viewer.

## Contents

1. [Host integration checklist](#host-integration-checklist)
2. [How the pieces fit together](#how-the-pieces-fit-together)
3. [Installation](#installation)
4. [Architecture options](#architecture-options)
5. [PluginAdapter](#pluginadapter-sidebar--editor) (inside the sidebar app → calls the host)
6. [SidebarAdapter](#sidebaradapter-editor--sidebar) (host page → serves the sidebar)
7. [Host shell & HTML helpers](#sidebar-host-shell-shared-htmldom)
8. [createSidebarHost](#createsidebarhost) (iframe + overlay + adapter in one step)
9. [Sidebar load overlay (standalone)](#sidebarloadoverlay-editor-ui)
10. [API reference](#api-reference) · [Development](#development)

## Features

- **Bidirectional IPC** — Promise-based calls with timeouts and typed method names
- **PluginAdapter** — Used **inside** the sidebar app to call into the host (`getContent`, `replaceContent`, …)
- **SidebarAdapter** — Used **in the host page** to expose `PluginInterface` to the iframe
- **createSidebarHost** — Optional one-shot setup: create/mount iframe, optional load-failed overlay, and `SidebarAdapter` (or iframe-only when your UI and document logic run in separate contexts)
- **Host shell helpers** — `ensureSidebarHostShell` / `buildSidebarHostHtmlDocument` so you don’t duplicate container markup and layout CSS
- **MIME helpers** — `MimeType` enum plus arbitrary MIME strings for `ContentInfo`
- **Errors** — `TextLookupError` and guards for editor lookup failures
- **Cleanup** — `destroy()` / teardown functions on adapters and hosts

## Host integration checklist

Use this if you are **embedding Markup AI’s sidebar** in your product for the first time.

1. **Get the sidebar URL from Markup AI** — Base URL plus any required **hash route** (for example `#/agentic` for the agentic experience). The exact URL and routing are part of your integration agreement.
2. **Add an iframe** on your host page whose `src` is that URL (or let **`createSidebarHost`** create the iframe for you).
3. **Implement `PluginInterface`** — Async functions that return document text, apply replacements, open/close dialogs, etc. The sidebar calls these over `postMessage`; your code performs the real work in your editor model.
4. **Create a `SidebarAdapter`** (or call **`createSidebarHost`**) with:
   - your `PluginInterface` implementation, and
   - **`adapterOptions.targetOrigin`** set to **`sidebarPostMessageTargetOrigin(sidebarUrl)`** so messages are scoped to the sidebar origin (safer than `"*"`).
5. **Test the basics** — After load, the sidebar will request **`getInitConfig`** and **`getContent`**. Confirm your app returns sensible values before testing edits and dialogs.
6. **Tear down on exit** — Call **`destroy()`** on the host returned by `createSidebarHost`, or on your `SidebarAdapter`, when the user leaves the screen or you remove the iframe.

If you use the **agentic** layout, implement the optional hooks **`onAgenticAgentResults`** and **`onAgenticStreamComplete`** on `PluginInterface` when you need streaming results or completion signals in your host UI (see [PluginInterface](#plugininterface)).

## How the pieces fit together

```text
┌────────────────────────────── Host page (your app) ──────────────────────────────┐
│  Your editor / viewer logic                                                     │
│         ▲                                                                        │
│         │  PluginInterface (getContent, replaceContent, …)                       │
│         │                                                                        │
│  SidebarAdapter  ◄──── postMessage ────►  PluginAdapter (inside iframe)        │
│         │                                                                        │
│         │              ┌──────────────────────────┐                            │
│         └──────────────│  <iframe src="sidebar URL"> │                            │
│                        │  Markup AI sidebar UI      │                            │
│                        └──────────────────────────┘                            │
└────────────────────────────────────────────────────────────────────────────────┘
```

- **`SidebarAdapter` + `PluginInterface`** live on the **host**. They answer requests from the iframe.
- **`PluginAdapter`** lives **inside the sidebar** (Markup’s app or a compatible build). It is what **calls** your host. You only import it if you maintain sidebar code.

## Installation

```bash
npm install @markupai/sidebar-adapter
```

## Architecture options

| Integration style             | Where the sidebar iframe runs                          | Where your document/editor code runs           | Typical approach                                                                                                                                                                                                                                                                   |
| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Same window** (most common) | Same browser tab as your host UI                       | Same tab / same JavaScript context             | Implement `PluginInterface` in that page; use **`createSidebarHost`** (or `SidebarAdapter` + your own iframe) and optional **`ensureSidebarHostShell`**.                                                                                                                           |
| **Split contexts** (advanced) | Often a separate UI surface that only hosts the iframe | Another realm (worker, native bridge, backend) | Use **`createSidebarHost`** **without** `plugin`, then **forward** raw `postMessage` events between the iframe and the realm where you implement **`PluginInterface`**. That realm can use **`IPCCore`** and related exports from this package to decode/encode the same protocol. |

**Split-context** setups are uncommon; most integrations use **same-window** (host UI and document logic together in one page).

## Usage

### PluginAdapter (Sidebar → Editor)

Use **`PluginAdapter` inside the sidebar application** (the code running in the iframe) to call into the host page. Host-only integrators can skip this section.

```typescript
import { PluginAdapter, type ContentInfo } from "@markupai/sidebar-adapter";

// Create the adapter (no interface needed - using PULL pattern)
const adapter = new PluginAdapter();

// Get initialization config from the editor
const config = await adapter.getInitConfig();
console.log("Sidebar initialized", config);

// Get content from the editor
// Integration provides content in its preferred format
const content = await adapter.getContent();
console.log("Received content:", content.content);
console.log("Document reference:", content.documentReference); // Required from integration
console.log("MIME type:", content.mimeType); // Optional from integration

// Get selected content from the editor
// No MIME type parameter - integration decides the format
const selectedContent = await adapter.getSelectedContent();
console.log("Selected content:", selectedContent.content);
console.log("Document reference:", selectedContent.documentReference);

// Select content in the editor
await adapter.selectContent("Hello world", 0);

// Replace content in the editor
await adapter.replaceContent("Hello world", "Hello universe", { start: 0 });

// Replace multiple contents in the editor
await adapter.replaceMultipleContents([
  { original: "Hello world", suggestion: "Hello universe", range: { start: 0 } },
  { original: "foo", suggestion: "bar", range: { start: 20 } },
]);

// Show a dialog
adapter.showDialog("<div>Custom dialog content</div>", 400, 300, "My Dialog");

// Request initialization
adapter.requestInit();

// Clean up when done
adapter.destroy();
```

### SidebarAdapter (Editor → Sidebar)

Use **`SidebarAdapter` on the host page** (parent of the iframe) to connect your `PluginInterface` implementation to the sidebar:

```typescript
import {
  SidebarAdapter,
  MimeType,
  type PluginInterface,
  type ContentReplacement,
  type ContentInfo,
} from "@markupai/sidebar-adapter";

// Implement the PluginInterface
const pluginInterface: PluginInterface = {
  // Get current document content
  // Return content in your preferred format with required documentReference
  getContent: async (): Promise<ContentInfo> => {
    const content = getContentFromEditor();
    return {
      content: content,
      documentReference: "my-document.md", // Required: unique identifier for the document
      mimeType: MimeType.TEXT_MARKDOWN, // Optional: can use MimeType enum or any string
    };
  },

  // Get selected content
  // Return selected content with required documentReference
  getSelectedContent: async (): Promise<ContentInfo> => {
    const selectedText = getSelectedContentFromEditor();
    return {
      content: selectedText,
      documentReference: "selection.txt", // Required
      mimeType: "text/plain", // Optional: can be omitted if not applicable
    };
  },

  // Get initialization configuration
  getInitConfig: async () => {
    return {
      integrationName: "My Editor",
      integrationVersion: "1.0.0",
      integrationId: "my-editor",
      useCheckPreviewDialog: true,
      supportCheckSelection: true,
      pluginOrigin: globalThis.location.origin,
    };
  },

  // Select content in the editor
  selectContent: async (original: string, startIndex: number) => {
    console.log("Select content:", original, "at index:", startIndex);
    selectContentInEditor(original, startIndex);
  },

  // Replace content in the editor
  replaceContent: async (original: string, suggestion: string, range?: ContentRange) => {
    console.log("Replace content:", original, "with:", suggestion);
    replaceContentInEditor(original, suggestion, range);
  },

  // Replace multiple contents in the editor
  replaceMultipleContents: async (replacements: ContentReplacement[]) => {
    console.log("Replace multiple contents:", replacements);
    replaceMultipleContentsInEditor(replacements);
  },

  // Show a dialog
  showDialog: async (dialogHtml: string, width?: number, height?: number, title?: string) => {
    console.log("Show dialog:", title);
    showDialogInEditor(dialogHtml, width, height, title);
  },

  // Close the dialog
  closeDialog: async () => {
    console.log("Close dialog");
    closeDialogInEditor();
  },
};

// Create the adapter with an iframe reference
const iframe = document.getElementById("sidebar-iframe") as HTMLIFrameElement;
const sidebarAdapter = new SidebarAdapter(pluginInterface, iframe);

// The adapter listens to sidebar requests and routes them to your plugin interface
// Your plugin interface methods (getContent, getSelectedContent, etc.) return the data

// Clean up when done
sidebarAdapter.destroy();
```

### SidebarLoadOverlay (Editor UI)

Use `setupSidebarLoadOverlay` when you manage the iframe yourself and only want the **failed-to-load / retry** UI. Styles are injected automatically.

![Sidebar failed to load overlay](./.docs/sidebar_failed_to_load_overlay.png)

```typescript
import { setupSidebarLoadOverlay } from "@markupai/sidebar-adapter";

const container = document.getElementById("sidebar-root") as HTMLElement;
const iframe = document.getElementById("sidebar-iframe") as HTMLIFrameElement;

const teardownLoadOverlay = setupSidebarLoadOverlay(container, iframe, {
  timeoutMs: 8000,
  retryIntervalMs: 12000,
});

// Later:
teardownLoadOverlay();
```

Prefer **`createSidebarHost`** if you also want **`SidebarAdapter`** and/or **`iframeMount`** in one place.

### createSidebarHost

Creates the sidebar iframe (or uses yours), optionally mounts the **load overlay**, and optionally constructs **`SidebarAdapter`**.

**Iframe:** pass exactly one of **`iframe`** or **`iframeMount`** (`container`, `src`, optional `allow`). Adapter-created iframes use a fixed accessible title (**`SIDEBAR_IFRAME_DEFAULT_TITLE`**) and set **`allow`** to **`SIDEBAR_IFRAME_DEFAULT_ALLOW`** (`clipboard-read; clipboard-write`) unless you pass **`allow: ""`** to omit it or override the string. **`destroy()`** removes a mounted iframe from the DOM.

**Return type:** `createSidebarHost` returns `{ iframe, adapter, destroy }`. When **`plugin`** is set, `adapter` is a `SidebarAdapter`; when **`plugin`** is omitted (split-context integration), `adapter` is `null` and only the overlay runs (if `loadOverlayContainer` is set).

**Shared helpers**

- **`SIDEBAR_DEFAULT_PRODUCTION_BASE_URL`** — canonical prod sidebar base URL (`https://sidebar.markup.ai/`); pass to **`getSidebarBaseUrlWithOverride`** when you have no env-specific default.
- **`SIDEBAR_URL_OVERRIDE_STORAGE_KEY`** — `localStorage` key for the override; use **`getSidebarBaseUrlWithOverride(defaultUrl)`** to resolve the base URL (reads override when set, otherwise `defaultUrl`; safe when `localStorage` is missing or throws).
- **`sidebarPostMessageTargetOrigin(sidebarUrl, options?)`** — derives **`adapterOptions.targetOrigin`** from the sidebar URL (falls back to `"*"`; optional **`onInvalidUrl`** callback).
- **`assertSidebarHostAdapter(host, message?)`** — type-narrows **`host`** after **`createSidebarHost`** when you passed **`plugin`** (throws if **`adapter`** is **`null`**).

```typescript
import {
  assertSidebarHostAdapter,
  createSidebarHost,
  sidebarPostMessageTargetOrigin,
  type PluginInterface,
} from "@markupai/sidebar-adapter";

const container = document.getElementById("sidebar-container") as HTMLElement;
// Use the URL Markup AI provides; append the agreed hash route (e.g. #/agentic) if required.
const sidebarUrl = "https://your-sidebar-host/#/agentic";

// `myPlugin` is your PluginInterface implementation (see SidebarAdapter example above).
const host = createSidebarHost({
  iframeMount: {
    container,
    src: sidebarUrl,
  },
  plugin: myPlugin,
  loadOverlayContainer: container,
  loadOverlayOptions: { timeoutMs: 8000, retryIntervalMs: 12000 },
  adapterOptions: { targetOrigin: sidebarPostMessageTargetOrigin(sidebarUrl) },
});

assertSidebarHostAdapter(host, "Expected adapter when plugin is set");

// Use host.iframe for extra wiring (e.g. message forwarders). Later:
host.destroy();
```

- If you already have an iframe, pass **`iframe`** and omit **`iframeMount`**.
- If you omit **`loadOverlayContainer`**, no overlay is mounted (same as using `new SidebarAdapter` only, plus optional iframe creation).
- **Split-context integration:** omit **`plugin`**, set **`loadOverlayContainer`** + **`iframeMount`** (or **`iframe`**), then forward **`postMessage`** traffic between `host.iframe` and the context where your `PluginInterface` runs (see [Architecture options](#architecture-options)).

### Sidebar host shell (shared HTML/DOM)

Avoid copying the same **`#sidebarContainer`** + full-height layout CSS into every host HTML file.

**Runtime (most hosts)**

```typescript
import {
  createSidebarHost,
  ensureSidebarHostShell,
  sidebarPostMessageTargetOrigin,
} from "@markupai/sidebar-adapter";

const container = ensureSidebarHostShell({ root: document });

createSidebarHost({
  iframeMount: { container, src: sidebarUrl },
  plugin: myPlugin,
  loadOverlayContainer: container,
  adapterOptions: { targetOrigin: sidebarPostMessageTargetOrigin(sidebarUrl) },
});
```

**Build time (single emitted HTML file)** — when your bundler produces one static HTML file for the host UI:

```typescript
import { buildSidebarHostHtmlDocument } from "@markupai/sidebar-adapter";

const html = buildSidebarHostHtmlDocument({
  title: "My app — Markup AI sidebar host",
  includePluginHostHead: true,
  bodyScriptsHtml: `<script type="module" src="./host-entry.js"></script>`,
});
```

Use **`includePluginHostHead: true`** for a sensible viewport `<meta>` block, or **`buildSidebarPluginHostHeadInnerHtml()`** if you only need that fragment for a template you control. Point **`bodyScriptsHtml`** at the script URL your build tool emits for the host page.

Also exported: **`SIDEBAR_HOST_CONTAINER_ID`** (default `sidebarContainer`), **`buildSidebarHostShellCss`**, **`SIDEBAR_HOST_SHELL_CSS`** (default id), **`SIDEBAR_DEFAULT_PRODUCTION_BASE_URL`**, **`getSidebarBaseUrlWithOverride`**, **`SIDEBAR_URL_OVERRIDE_STORAGE_KEY`**, **`sidebarPostMessageTargetOrigin`**, **`assertSidebarHostAdapter`**, **`SIDEBAR_IFRAME_DEFAULT_TITLE`**, **`SIDEBAR_IFRAME_DEFAULT_ALLOW`** (default `iframeMount` clipboard policy).

## API Reference

### Types

#### MimeType

Convenience enum for common MIME types. Integrations are not limited to these values and can use any valid MIME type string.

```typescript
enum MimeType {
  TEXT_PLAIN = "text/plain",
  TEXT_MARKDOWN = "text/markdown",
  TEXT_HTML = "text/html",
  APPLICATION_DITA_XML = "application/dita+xml",
}
```

#### ContentInfo

Content information returned by integration methods. The integration must provide `content` and `documentReference`, while `mimeType` is optional.

```typescript
interface ContentInfo {
  content: string | number[]; // Text content or binary content as byte array
  documentReference: string; // Required: Unique identifier for the document (e.g., filename, path, ID)
  mimeType?: string; // Optional: Any valid MIME type string (not limited to MimeType enum)
}
```

#### ContentReplacement

Structure for batch content replacement operations.

```typescript
type ContentReplacement = {
  original: string;
  suggestion: string;
  range: ContentRange;
};

type ContentRange = {
  start: number;
};
```

For agentic streaming callbacks, issue objects match the exported **`AgenticIssuePayload`** shape (stable `id` plus fields the sidebar sends per issue).

#### PluginInterface

Implement these **on the host** as async functions. The sidebar calls them over IPC and waits on the returned promises (pull style):

- `getInitConfig(): Promise<SidebarConfig>` — Integration name, version, feature flags, dialog closing script, etc.
- `getContent(): Promise<ContentInfo>` — Full document (or main buffer) text; must include `documentReference`; `mimeType` optional
- `getSelectedContent(): Promise<ContentInfo>` — Current selection; same `ContentInfo` rules as `getContent`
- `selectContent(original: string, startIndex: number): Promise<void>` — Focus/highlight a range in the editor
- `replaceContent(original: string, suggestion: string, range?: ContentRange): Promise<void>` — Apply a single edit
- `replaceMultipleContents(replacements: ContentReplacement[]): Promise<void>` — Batch edits
- `showDialog(dialogHtml: string, width?: number, height?: number, title?: string): Promise<void>` — Host-rendered dialog chrome around HTML from the sidebar
- `closeDialog(): Promise<void>` — Dismiss that dialog

Optional (agentic / streaming flows):

- `onAgenticAgentResults?(agentName: string, issues: AgenticIssuePayload[]): Promise<void>` — Per-agent results as they stream
- `onAgenticStreamComplete?(): Promise<void>` — Stream finished (success or terminal state)

#### Error control flows

When implementing `selectContent` or `replaceContent`, throw `TextLookupError` from this package when text lookup (e.g. locating the selection or target range) fails. The sidebar will treat it as a selection failure and can invalidate the issue card with the error message.

```typescript
import { TextLookupError } from "@markupai/sidebar-adapter";

// In selectContent or replaceContent:
if (!found) {
  throw new TextLookupError("Check text again to locate this issue.");
}
```

Errors sent over IPC are reconstructed on the sidebar, so you can type-check with `instanceof` or the `isTextLookupError` guard:

```typescript
import { isTextLookupError, type TextLookupError } from "@markupai/sidebar-adapter";

try {
  await adapter.selectContent(original, startIndex);
} catch (error) {
  if (isTextLookupError(error)) {
    // error is TextLookupError; e.g. invalidate issue card with error.message
    invalidateIssueWithError(issueId, error.message);
  } else {
    throw error;
  }
}
```

Note: We document thrown errors via JSDoc `@throws` on the plugin interface and export union types `SelectContentError` and `ReplaceContentError` for control-flow typing when catching.

### Message Format

The adapter uses a promise-based IPC system with automatic request/response correlation:

```typescript
// Call message (sidebar → editor)
{
  type: 'call';
  id: number;
  name: string; // Method name (e.g., 'getContent')
  data: any[]; // Method arguments
}

// Response message (editor → sidebar)
{
  type: 'response';
  id: number; // Matches the call id
  name: string;
  data: any; // Return value
}

// Error message
{
  type: 'error';
  id: number;
  name: string;
  error: string;
}
```

## Development

The commands below are for **contributors** working on this npm package’s source. If you only consume **`@markupai/sidebar-adapter`** from your app, you do not need to run them.

### Building

```bash
npm run build
```

### Testing

```bash
npm test
npm run test:watch
npm run test:coverage
```

### Formatting

```bash
npm run format:fix
npm run format:check
```