@markupai/sidebar-adapter
v0.0.23
Published
Adapter package for MarkupAI Sidebar integration
Readme
@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
- Host integration checklist
- How the pieces fit together
- Installation
- Architecture options
- PluginAdapter (inside the sidebar app → calls the host)
- SidebarAdapter (host page → serves the sidebar)
- Host shell & HTML helpers
- createSidebarHost (iframe + overlay + adapter in one step)
- Sidebar load overlay (standalone)
- API reference · 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
PluginInterfaceto 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/buildSidebarHostHtmlDocumentso you don’t duplicate container markup and layout CSS - MIME helpers —
MimeTypeenum plus arbitrary MIME strings forContentInfo - Errors —
TextLookupErrorand 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.
- Get the sidebar URL from Markup AI — Base URL plus any required hash route (for example
#/agenticfor the agentic experience). The exact URL and routing are part of your integration agreement. - Add an iframe on your host page whose
srcis that URL (or letcreateSidebarHostcreate the iframe for you). - Implement
PluginInterface— Async functions that return document text, apply replacements, open/close dialogs, etc. The sidebar calls these overpostMessage; your code performs the real work in your editor model. - Create a
SidebarAdapter(or callcreateSidebarHost) with:- your
PluginInterfaceimplementation, and adapterOptions.targetOriginset tosidebarPostMessageTargetOrigin(sidebarUrl)so messages are scoped to the sidebar origin (safer than"*").
- your
- Test the basics — After load, the sidebar will request
getInitConfigandgetContent. Confirm your app returns sensible values before testing edits and dialogs. - Tear down on exit — Call
destroy()on the host returned bycreateSidebarHost, or on yourSidebarAdapter, 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).
How the pieces fit together
┌────────────────────────────── 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+PluginInterfacelive on the host. They answer requests from the iframe.PluginAdapterlives 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
npm install @markupai/sidebar-adapterArchitecture 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.
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:
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.

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 togetSidebarBaseUrlWithOverridewhen you have no env-specific default.SIDEBAR_URL_OVERRIDE_STORAGE_KEY—localStoragekey for the override; usegetSidebarBaseUrlWithOverride(defaultUrl)to resolve the base URL (reads override when set, otherwisedefaultUrl; safe whenlocalStorageis missing or throws).sidebarPostMessageTargetOrigin(sidebarUrl, options?)— derivesadapterOptions.targetOriginfrom the sidebar URL (falls back to"*"; optionalonInvalidUrlcallback).assertSidebarHostAdapter(host, message?)— type-narrowshostaftercreateSidebarHostwhen you passedplugin(throws ifadapterisnull).
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
iframeand omitiframeMount. - If you omit
loadOverlayContainer, no overlay is mounted (same as usingnew SidebarAdapteronly, plus optional iframe creation). - Split-context integration: omit
plugin, setloadOverlayContainer+iframeMount(oriframe), then forwardpostMessagetraffic betweenhost.iframeand the context where yourPluginInterfaceruns (see 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)
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:
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.
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.
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.
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 includedocumentReference;mimeTypeoptionalgetSelectedContent(): Promise<ContentInfo>— Current selection; sameContentInforules asgetContentselectContent(original: string, startIndex: number): Promise<void>— Focus/highlight a range in the editorreplaceContent(original: string, suggestion: string, range?: ContentRange): Promise<void>— Apply a single editreplaceMultipleContents(replacements: ContentReplacement[]): Promise<void>— Batch editsshowDialog(dialogHtml: string, width?: number, height?: number, title?: string): Promise<void>— Host-rendered dialog chrome around HTML from the sidebarcloseDialog(): Promise<void>— Dismiss that dialog
Optional (agentic / streaming flows):
onAgenticAgentResults?(agentName: string, issues: AgenticIssuePayload[]): Promise<void>— Per-agent results as they streamonAgenticStreamComplete?(): 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.
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:
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:
// 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
npm run buildTesting
npm test
npm run test:watch
npm run test:coverageFormatting
npm run format:fix
npm run format:check