@ceros-dev/markup-sdk
v1.0.0-rc.1
Published
Browser SDK for embedding MarkUp commenting and collaboration features
Readme
@ceros-dev/markup-sdk
Browser SDK for embedding MarkUp commenting and collaboration features in web applications.
Browser-only. The SDK touches the DOM (
document) at module load, so it must run in a browser — it is not usable in a Node/SSR runtime. In frameworks like Next.js, import it only in client components (e.g. behind a"use client"boundary or a dynamicimport()), never during server rendering.
Stability. Follows semantic versioning from
1.0.0— no breaking changes to the public API within a major version. Published to public npm and thesdk.markup.ioCDN.For raw HTTP-client usage without managed auth or service helpers see
@ceros-dev/markup-sdk-core, the headless transport layer this package builds on top of.
Installation
npm
npm install @ceros-dev/markup-sdkCDN (script tag)
Every release is also published to the MarkUp CDN as self-contained browser bundles — no npm install or registry auth needed:
<!-- Full UI: toolbar, pins, thread popover, commenting mode -->
<script src="https://sdk.markup.io/v1.0.0/markup-sdk-ui.min.js"></script>
<script>
const markup = MarkUpSDK.init({
publicKey: "your-public-key",
markupId: "00000000-0000-0000-0000-000000000000"
});
markup.render();
</script>The bundle installs a single MarkUpSDK global — the same class this package exports — so
everything in the Quickstart below applies unchanged. The other runtime exports
(MarkUpSDKError, ConfigurationError, SDKStateError, SDKErrorCode, configureLogger, and
notify in the UI bundle) are attached to the global as properties.
Each prefix on the CDN serves five files: markup-sdk-ui.min.js (full UI, classic script),
markup-sdk.min.js (API-only, classic script), markup-sdk-ui.esm.js / markup-sdk.esm.js
(the same two as ES modules), and manifest.json (exact version, commit, and a sha384
Subresource Integrity
hash per file).
| URL prefix | Points at | Caching |
| --- | --- | --- |
| /v<version>/ (e.g. /v1.0.0/) | that exact release | immutable, 1 year |
| /latest/ | newest stable release | 5 minutes |
| /v<major>/ (e.g. /v1/) | newest stable release within that major | 5 minutes |
| /next/ | newest prerelease | 5 minutes |
/v1/ and /latest/ track the newest stable release and are convenient for development; /next/
serves the newest prerelease.
For production, pin an exact version and add the SRI hash from that version's manifest.json:
<script
src="https://sdk.markup.io/v1.0.0/markup-sdk-ui.min.js"
integrity="sha384-…from manifest.json…"
crossorigin="anonymous"
></script>The ESM bundles work the same way from <script type="module">:
<script type="module">
import {MarkUpSDK} from "https://sdk.markup.io/v1.0.0/markup-sdk-ui.esm.js";
MarkUpSDK.init({publicKey: "your-public-key", markupId: "…"}).render();
</script>Entry Points
The package exposes two subpaths so consumers can opt into the bundle weight they need:
@ceros-dev/markup-sdk/ui— full UI: toolbar, pins, thread popover, commenting mode. Bundles Preact and prebuilt components. The Quickstart below targets this entry.@ceros-dev/markup-sdk— API only: services, auth, events. No rendered UI (norender(), toolbar, or components). Use this when you render your own UI and only need typed access to the MarkUp API.markup.render()is unavailable on this entry; everything else (init,on/off,threads,comments,projects,uploads,destroy) is identical.
Quickstart
import {MarkUpSDK} from "@ceros-dev/markup-sdk/ui";
const markup = MarkUpSDK.init({
publicKey: "your-public-key",
markupId: "00000000-0000-0000-0000-000000000000",
onTokenNeeded: async () => {
const res = await fetch("/api/markup-token");
const {token} = await res.json();
return token;
}
});
markup.render();Lifecycle
MarkUp is a singleton. The flow is init → render → destroy, and destroy() must be called
before re-initializing (e.g. on SPA unmount or hot-reload).
const markup = MarkUpSDK.init({publicKey, markupId, onTokenNeeded});
markup.render({position: "bottom-right", keyboardShortcuts: true});
// Subscribe to events
const off = markup.on("thread:open", ({threadId}) => {
console.log("opened", threadId);
});
// Later, on teardown
off();
markup.destroy();init(options)— validates options, constructs the singleton. Callinginittwice withoutdestroyin between returns the existing instance and logs a warning.render(options?)— mounts the shadow-DOM container, toolbar, pins, and panels, then runs the authentication gate to resolve a session and load live data. Callingrendertwice is a no-op and logs a warning.destroy()— unmounts all UI, cancels in-flight work (including any pending silent sign-in attempt), clears auth + stores, removes the SPA navigation listener, and releases the singleton slot soinitcan be called again.
Authentication
MarkUp supports two authentication modes; you choose by whether you pass onTokenNeeded. The
SDK resolves a session in this order: onTokenNeeded → cached session → silent SSO → interactive
sign-in.
1. Bring your own token (onTokenNeeded)
Provide onTokenNeeded to authenticate users with your own backend. The SDK calls it when it needs
a token and exchanges the signed JWT for a MarkUp session (as in the Quickstart above):
const markup = MarkUpSDK.init({
publicKey: "your-public-key",
markupId: "00000000-0000-0000-0000-000000000000",
onTokenNeeded: async () => {
const res = await fetch("/api/markup-token");
const {token} = await res.json();
return token;
}
});2. SDK-managed "Sign in via MarkUp"
Omit onTokenNeeded and the SDK handles sign-in itself:
- Silent SSO — on
render()the SDK makes a single silent attempt through a hidden, origin-locked iframe. If the user already has a MarkUp session they're signed in with no interaction. - Interactive sign-in — if the silent attempt fails, the toolbar shows a Sign in button. Clicking it opens a MarkUp sign-in popup; on success the SDK loads data automatically.
Both flows work for any registered MarkUp user — the user does not need to be a member of the
MarkUp's workspace. If the MarkUp has public share-link access enabled ("Anyone with the share
link has access"), a signed-in non-member is granted comment access, mirroring guest access on the
MarkUp website. If the MarkUp is private and the user isn't a member, sign-in rejects with
WORKSPACE_FORBIDDEN.
Resolved sessions are cached (per the sessionStorage option) and refreshed automatically before
they expire. Interactive sign-in can reject with a MarkUpSDKError whose code is one of:
| SDKErrorCode | Meaning |
| --------------------- | ------------------------------------------------------------- |
| POPUP_BLOCKED | The browser blocked the sign-in popup (window.open failed). |
| POPUP_CLOSED | The user closed the popup before completing sign-in. |
| WORKSPACE_FORBIDDEN | The user can't access this MarkUp — not a workspace member and the MarkUp has no active public share link. |
| SDK_SIGNIN_FAILED | Sign-in failed for another reason. |
Origins are build-time. The MarkUp API origin and the sign-in website origin are frozen into the bundle at build time (from
MARKUP_API_URL/MARKUP_APP_URL) — there is no runtime URL option ininit. The website origin is also the only origin the SDK accepts sign-in messages from.
Configuration Options (MarkUpSDK.init)
| Option | Type | Required | Default | Notes |
| ---------------- | ------------------------------------------------ | -------- | ----------------- | ------------------------------------------------------------------ |
| publicKey | string | yes | — | SDK installation public key |
| markupId | string (UUID) | yes | — | MarkUp resource ID; must be a valid UUID |
| onTokenNeeded | () => Promise<string> | no | — | Returns a signed JWT. Omit to use the built-in Sign in via MarkUp flow |
| sessionStorage | "memory" \| "sessionStorage" \| "localStorage" | no | "memory" | Where to persist the resolved session |
| debug | boolean | no | false | Enables debug-level console logging |
Render Options (markup.render)
| Option | Type | Default | Notes |
| -------------------- | --------------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------ |
| position | "bottom-center" \| "bottom-right" \| "bottom-left" \| "top-center" \| "top-right" \| "top-left" | "bottom-center" | Toolbar anchor |
| offset | {x: number; y: number} | {x: 16, y: 16} | Pixel offset from the chosen anchor |
| theme | "light" \| "dark" \| "auto" \| "inverted" | "auto" | Color theme for SDK UI |
| collapsed | boolean | false | Start the toolbar collapsed |
| draggable | boolean | true | Allow the user to drag the toolbar |
| persistPosition | boolean | false | Persist the dragged position across reloads |
| container | HTMLElement \| string | document.body | Mount point for the shadow-DOM host; string is treated as a CSS selector |
| zIndex | number | very large | z-index for the shadow container (tune if the SDK covers host overlays) |
| keyboardShortcuts | boolean | true | Global shortcuts (e.g. press C to toggle commenting mode); set to false to disable |
| commentableContainer | HTMLElement \| string \| Array<HTMLElement \| string> | (unrestricted) | Restrict comment placement to one or more containers (CSS selector and/or element). See Restricting where comments can be placed |
Toggle shortcuts at runtime after
render()withmarkup.setKeyboardShortcutsEnabled(enabled).
Restricting where comments can be placed
By default a comment pin can be placed on any element on the page. Pass commentableContainer to
render() to confine commenting to one or more containers — useful when only a specific region
(e.g. a document body or a preview frame) should be annotatable.
// Single container by selector
markup.render({commentableContainer: "#main-content"});
// An element reference works too
const doc = document.querySelector<HTMLElement>(".doc");
if (doc) {
markup.render({commentableContainer: doc});
}
// Multiple containers — selectors and elements can be mixed
markup.render({commentableContainer: ["#doc", "#sidebar", someElement]});A pin may be placed on — or dragged onto — an element only if it is, or is inside, one of the configured containers. Clicking a placeable element outside the allowed area swallows the click and surfaces a snackbar hint instead of placing a pin; hovering there shows no highlight.
- Selectors are resolved at click-time, so the restriction tracks DOM changes and SPA navigation.
- It fails closed: if a restriction is configured but none of the containers currently exist in the DOM, commenting is blocked everywhere until one appears.
- The restriction also governs moving existing pins — a pin dragged outside the allowed area reverts.
- Blank strings and syntactically-invalid selectors are ignored (with a console warning); if nothing usable remains, commenting stays unrestricted.
When a restriction is active, commenting mode also gives visual feedback so users can see where they may comment:
- Spotlight — the page outside the allowed container(s) is dimmed (and lightly blurred where the
browser supports
backdrop-filter), leaving the commentable zone(s) bright and crisp. The effect tracks scrolling/resizing and updates live. - Cursor — the pin cursor is shown over commentable areas and a
not-allowedcursor over everything else (paired with the snackbar hint on a blocked click).
(With no restriction configured, neither affordance appears — commenting mode looks exactly as before.)
Events
Subscribe with markup.on(event, handler); the returned function unsubscribes. Use
markup.off(event, handler) to remove a specific listener, or markup.once(event, handler) to
auto-unsubscribe after the first emission.
| Event | Payload |
| -------------------------- | ----------------------------------------------------- |
| commenting:start | undefined |
| commenting:stop | undefined |
| pin:placed | PinPlacedData |
| pin:cancelled | undefined |
| thread:open | {threadId: string} |
| thread:close | undefined |
| thread:priority-changed | {threadId: string; priority: ThreadPriority \| null} |
| comment:reply | {threadId: string; message: string} |
| comment:resolve | {threadId: string} |
| comment:unresolve | {threadId: string} |
| comment:delete | {threadId: string} |
| comment:reply:delete | {threadId: string; replyId: string} |
| comment:edit | {threadId: string} |
| comment:reply:edit | {threadId: string; replyId: string} |
const offOpen = markup.on("thread:open", ({threadId}) => {
analytics.track("markup_thread_open", {threadId});
});
const offPlaced = markup.on("pin:placed", (data) => {
console.log("pin at", data.elementPath, data.offsetXPercent, data.offsetYPercent);
});
// Later
offOpen();
offPlaced();Notifications
The /ui entry exports a notify snackbar helper (it is not available on the headless entry):
import {notify} from "@ceros-dev/markup-sdk/ui";
notify.success("Comment posted");
notify.error("Couldn't load comments.", {description: err.message});notify.success / info / warning / error(message, opts?) render an in-overlay snackbar with
auto-dismiss (4s / 4s / 6s / 8s by default). NotifyOptions accepts description, an action
({label, onClick}), timeoutMs, and dismissible. The SDK uses these internally to surface
load, upload, and pin-move failures.
User identity
Once a user is signed in, their name, avatar, and derived initials appear in the toolbar. Identity comes from the decoded JWT (token mode) or from the MarkUp sign-in response (SDK-managed mode).
Single-page-app navigation
The UI build tracks client-side route changes automatically: render() patches
history.pushState / replaceState and listens for popstate, re-rendering the comment pins that
belong to the new path. Pins are page-scoped — only threads whose page matches the current URL are
shown. destroy() restores the original history methods.
Content Security Policy
The SDK renders its UI inside a shadow DOM and injects its styles as inline <style> elements. If
your page sets a strict Content-Security-Policy, allow inline styles so the SDK renders correctly:
style-src 'self' 'unsafe-inline';The SDK uses no eval or inline <script>, so no script-src relaxation is needed for it. When
loading the browser bundle from the CDN, allow the CDN host in script-src (e.g. https://sdk.markup.io).
Session persistence & security
By default the resolved session is held in memory only (sessionStorage: "memory"). The
"sessionStorage" and "localStorage" modes persist the session token where any script on your
page can read it — enable them only when you need the session to survive reloads, and only on
trusted origins.
Browser Support
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
License
BSD 3-Clause — see LICENSE.
