@aurorah/epub-studio
v1.2.1
Published
EPUB editor with reader, code view, TOC management, and multi-platform export — built on wMCP (Web Module Connection Protocol)
Maintainers
Readme
@aurorah/epub-studio
EPUB editor with reader, Monaco code view, TOC management, image management, and multi-platform export -- built on wMCP (Web Module Connection Protocol).
Current package version: 1.1.4.
v1.1.4 Notes
- Requires
@aurorah/wmcp >= 1.1.0for the new readiness contract. mount()now opts in to wMCP's readiness gating: host->module events received before the studio's first paint are buffered by wMCP (FIFO) and replayed onceclient._setReady()is called (after two chainedrequestAnimationFrames, when the deferred Reader / Editor / CodeEditor / Monaco-worker / Fonts init has drained).- Hosts may subscribe to the protocol-level
wmcp:readyevent (host.on("wmcp:ready", ...)) to coordinate post-mount UI such as loading spinners. - Hosts that previously worked around this race with a manual
await yieldForStudioInit()(or similarsetTimeout/requestAnimationFramewaits before emittingepub:openFile) can drop those workarounds. - Fixes the host-emit-before-module-ready race tracked in
cursor_button_switch_delay_issue.md-- TOC / 파일구조 used to populate while the viewer / editor / code panes stayed empty until a refresh, with about a 20% success rate on cache-warm CDN fetches.
v1.1.1 Notes
- Monaco code editor is bundled with an inline editor worker and uses Prettier plus
@prettier/plugin-xmlfor formatting. - The Next.js demo dynamically imports
@aurorah/epub-studioinside the client effect while keeping the stylesheet import static. - Host integrations can enable
translateButton; clicking it emitsepub:translate. - The wMCP manifest includes optional host requirements for
log:writeandexport:pdf.
Prerequisites
- Node.js >= 18
- pnpm
Project Structure
v1.1/
src/
code-editor.ts # Monaco HTML editor, formatting, linting, search, shortcuts, fullscreen
color-picker.ts # Shared color picker for reader colors and selected editor text/background
cursor-sync-indicator.ts # Maps editor/code cursor position to content blocks for sync indicators
dev.ts # Standalone Vite dev entry that mounts EPUB Studio directly
dom.ts # Static HTML template builder for the full EPUB Studio UI
editable-select.ts # Reusable editable select/dropdown helper
editor-history.ts # Undo/redo history tracking for WYSIWYG editor changes
editor-selection.ts # Selection save/restore helpers for toolbar and popup interactions
editor.ts # WYSIWYG chapter editor, metadata editing, links, images, TOC actions
engine.ts # EPUB ZIP/OPF/spine/TOC parser and open/drop file loading
exporter.ts # EPUB rebuild/export logic and platform presets
font-size-selection-input.ts # Selection-aware font-size input for editor formatting
fonts.ts # Reader font catalog, font loading, and font select rendering
format-toolbar.ts # Editor toolbar wiring and formatting command dispatch
format.ts # Pure formatting helpers and format action definitions
image-library.ts # Pure image path normalization, discovery, and asset helpers
image-manager.ts # Image panel UI, image import/remove/preview, cover/chapter image handling
index.ts # Public mount API, config handling, module initialization, wMCP wiring
link.ts # Internal link picker, link preview, link context menu, link editing
notify.ts # Toasts, button tooltips, and lightweight notification helpers
reader.ts # Reader view rendering, pagination, search, theme, font, writing mode
shortcuts.ts # Platform-aware shortcut labels and toolbar titles
state.ts # Legacy state compatibility exports
store.ts # AppState, reducer/store, preferences, and legacy bridge
styles/
index.css # EPUB Studio styles, themes, layout, panels, modals, editor, reader
structure-edit.ts # Structure tree edit operations: rename, move, promote/demote, TOC visibility
structure-rewrite.ts # Pure HTML/nav rewrite helpers for structure edits and ID/reference updates
structure-undo.ts # Snapshot/restore stack for structure editing undo
structure.ts # Chapter/TOC flat/tree rendering, selection, expansion, drag/drop structure UI
ui.ts # Sidebar/panel tabs, mode switching, modal handling, chapter/TOC list rendering
utils.ts # Shared DOM, HTML, file, status, preference, and download helpers
viewer-width.ts # Reader content width controls and persistence
viewer-zoom.ts # Reader zoom controls, presets, keyboard shortcuts, persistence
vite-env.d.ts # Vite and asset module type declarations
tests/
engine.test.ts # EPUB parsing behavior tests
exporter.test.ts # EPUB export/rebuild behavior tests
image-library.test.ts # Image helper and path normalization tests
state.test.ts # Store/state behavior tests
wmcp.test.ts # wMCP manifest/integration contract tests
demo/
nextjs/
src/
app/
api/
epub/
route.ts # Demo API route for EPUB upload/export flows
epub/
actions.ts # Demo server actions for save/export/audit logging
page.tsx # Client EPUB Studio mount page with dynamic package import
globals.css # Demo app global CSS
layout.tsx # Demo app root layout
page.tsx # Demo app home page
wmcp/
epub-studio.ts # Demo wMCP host setup, overrides, host listeners, close handlingGetting Started
1. Install dependencies
cd v1.1
pnpm install2. Dev mode (standalone, no wMCP host)
pnpm run devOpens at http://localhost:5173. Uses src/dev.ts which calls mount() directly.
3. Build the library
pnpm run buildOutputs to dist/:
epub-studio.es.js(ESM)epub-studio.cjs.js(CJS)epub-studio.cssindex.d.ts
4. Run the Next.js demo (with wMCP host)
cd demo/nextjs
pnpm install
pnpm run devOpens at http://localhost:3000/epub.
After rebuilding the library (
pnpm run buildinv1.1/), runpnpm installindemo/nextjs/to re-link the updated dist, then restart the Next.js dev server.
Usage
Standalone (no host)
import { mount } from "@aurorah/epub-studio";
import "@aurorah/epub-studio/style.css";
const instance = mount(document.getElementById("root")!, {
theme: "dark",
locale: "ko",
});
// Later: instance.destroy();With wMCP host
import { mount } from "@aurorah/epub-studio";
import "@aurorah/epub-studio/style.css";
import { WmcpHost } from "@aurorah/wmcp";
const instance = mount(container, {
theme: "dark",
locale: "ko",
openButton: false,
translateButton: true,
closeButton: true,
});
const host = new WmcpHost(instance.wmcpClient);
// Override capabilities to route through server APIs
host.override("epub:save", async (params, defaultSave) => {
const blob = await defaultSave(params);
return await saveToServer(blob);
});
// Listen for module events
host.on("epub:loaded", (data) => console.log(data));
host.on("epub:modified", (data) => console.log(data));
host.on("epub:translate", (data) => console.log(data));
host.on("epub:close", () => {
host.destroy();
instance.destroy();
});
// IMPORTANT: gate `epub:openFile` on `wmcp:ready`.
//
// `mount()` returns synchronously but several studio subsystems (Reader
// pane sizing, Monaco editor worker, Fonts, CodeEditor) only stabilize
// after the browser paints once. Emitting `epub:openFile` during that
// gap causes a race where TOC / 파일구조 populates but the
// viewer / editor / code panes silently stay on the placeholder.
//
// `@aurorah/[email protected]` (with `@aurorah/[email protected]`) emits the
// protocol-level `wmcp:ready` event once all subsystems have laid out.
// Do the fetch + `host.emit("epub:openFile", ...)` inside this listener
// to guarantee the studio is fully wired before the EPUB lands.
host.on("wmcp:ready", async () => {
const blob = await fetchEpubBlob();
host.emit("epub:openFile", { fileData: blob, fileName: "book.epub" });
});
// Other host->module commands can also be sent at any time; wMCP buffers
// them until `wmcp:ready` and replays FIFO.
host.emit("epub:setTheme", { theme: "sepia" });Config
mount(container, config?) accepts EpubStudioConfig:
| Property | Type | Default | Description |
| ----------------- | ----------------------------------------- | --------- | -------------------------------------------------------------- |
| theme | "dark" \| "sepia" \| "white" \| "paper" | "sepia" | Initial color theme |
| locale | string | "ko" | Locale hint |
| readOnly | boolean | false | Disable editing |
| openButton | boolean | false | Show an open-file button in the toolbar |
| translateButton | boolean | false | Show a translate button; emits epub:translate via wMCP |
| closeButton | boolean | true | Show a close button; emits epub:close event via wMCP |
| useCustomFonts | boolean | false | If true, skip auto-loading Google Fonts; host provides fonts |
Code Editor
The code tab uses monaco-editor for HTML editing. It supports undo/redo, find/replace, go to line, folding, format, lint markers, word wrap, invisibles, autocomplete, fullscreen, and image-aware cursor sync.
Formatting is powered by prettier and @prettier/plugin-xml. The Vite library build externalizes @aurorah/wmcp, prettier, and @prettier/plugin-xml, and bundles Monaco's editor worker inline for browser compatibility.
Fonts
Architecture
Font rendering has two layers:
- CSS variables (
--font,--r-font,--mono) -- defined insrc/styles/index.cssat:root. These declare font-family names only. Every UI element in the stylesheet references them viavar(). - Font file loading -- the actual
.woff2files that the browser needs to render those named families. This is handled byinjectFonts()insrc/index.ts.
mount() called
|
|- useCustomFonts: false (default)
| |
| +-> injectFonts() appends a Google Fonts <link> to <head>
| Google Fonts serves @font-face rules that load .woff2 files
| matching the font-family names in --font, --r-font, --mono
|
|- useCustomFonts: true
|
+-> injectFonts() is skipped
Host must load fonts itself (next/font, @font-face, CDN link)
Host can also override --font, --r-font, --mono on :root
to use entirely different font familiesCSS variables
| Variable | Used for | Default value |
| ---------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --font | UI -- header, sidebar, panels, buttons, inputs, modals | "Noto Sans KR", "Noto Sans JP", "Noto Sans SC", "Noto Sans TC", "Noto Sans Arabic", "Noto Sans Devanagari", "Noto Sans Thai", "Noto Sans Bengali", "Noto Sans", sans-serif |
| --r-font | Reader -- EPUB content viewer, chapter text | "Noto Serif KR", "Noto Serif JP", "Noto Serif SC", "Noto Serif TC", "Noto Serif", "Batang", serif |
| --mono | Code -- code editor, raw HTML view, metadata fields | "JetBrains Mono", monospace |
These variables are defined in src/styles/index.css and bundled into dist/epub-studio.css. The fallback chain ensures the browser picks the correct font per character (e.g. Korean glyphs from Noto Sans KR, Japanese from Noto Sans JP, Arabic from Noto Sans Arabic, etc.).
Default fonts loaded
| CSS Variable | Fonts loaded from Google Fonts | Coverage |
| ------------ | ------------------------------------------------------------- | ---------------------------------------------- |
| --font | Noto Sans + KR, JP, SC, TC, Arabic, Devanagari, Thai, Bengali | UI -- Latin, CJK, Arabic, Hindi, Thai, Bengali |
| --r-font | Noto Serif + KR, JP, SC, TC | Reader -- Latin, CJK |
| --mono | JetBrains Mono | Code editor |
Google Fonts only downloads glyphs actually used on the page (unicode-range subsetting), so the real network payload stays small.
Custom fonts
If your host app already loads fonts (e.g. via next/font, @font-face, or a different CDN), pass useCustomFonts: true to skip auto-injection:
const instance = mount(container, {
useCustomFonts: true,
});When useCustomFonts is true, you must ensure the font families referenced by --font, --r-font, and --mono are available in the document. You can either:
- Load the same font families that the defaults reference (e.g. Noto Sans KR)
- Override the CSS variables on
:rootor.epub-studio-rootto point to your own fonts
Next.js examples
Default (auto-inject) -- no font setup needed:
// app/epub/page.tsx
"use client";
import { useEffect, useRef } from "react";
import "@aurorah/epub-studio/style.css";
export default function EpubPage() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let cleanup: (() => void) | undefined;
let cancelled = false;
async function init() {
const { mount } = await import("@aurorah/epub-studio");
if (cancelled || !ref.current) return;
const instance = mount(ref.current, { theme: "dark" });
cleanup = () => instance.destroy();
}
init();
return () => {
cancelled = true;
cleanup?.();
};
}, []);
return <div ref={ref} style={{ width: "100vw", height: "100vh" }} />;
}With wMCP host integration:
Use this when the Next.js app should observe EPUB Studio events, override save/export behavior, or close/navigate from the host app.
// app/epub/page.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import "@aurorah/epub-studio/style.css";
import { setupHost } from "@/wmcp/epub-studio";
export default function EpubPage() {
const containerRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(false);
const [ready, setReady] = useState(false);
useEffect(() => {
setReady(true);
}, []);
useEffect(() => {
if (!ready || !containerRef.current || mountedRef.current) return;
mountedRef.current = true;
let cleanup: (() => void) | undefined;
let cancelled = false;
async function init() {
const { mount } = await import("@aurorah/epub-studio");
if (cancelled || !containerRef.current) return;
const instance = mount(containerRef.current, {
theme: "dark",
locale: "ko",
openButton: true,
translateButton: true,
closeButton: true,
});
const host = setupHost(instance.wmcpClient, {
onClose: () => {
cleanup?.();
window.location.href = "/";
},
});
// Gate the EPUB fetch + open on `wmcp:ready`. See the "Studio readiness"
// note below for why this matters — emitting `epub:openFile` before the
// studio's first paint causes a race where TOC populates but the
// viewer / editor / code panes stay empty.
host.on("wmcp:ready", async () => {
const blob = await fetchEpubBlob();
host.emit("epub:openFile", { fileData: blob, fileName: "book.epub" });
});
cleanup = () => {
host.destroy();
instance.destroy();
};
}
init();
return () => {
cancelled = true;
cleanup?.();
mountedRef.current = false;
};
}, [ready]);
return <div ref={containerRef} id="epub-studio-root" style={{ width: "100vw", height: "100vh" }} />;
}// src/wmcp/epub-studio.ts
import { WmcpHost } from "@aurorah/wmcp";
import type { WmcpClient } from "@aurorah/wmcp";
import { saveEpubToApi, exportEpubToApi, writeAuditLog } from "@/app/epub/actions";
export function setupHost(client: WmcpClient, opts?: { onClose?: () => void }): WmcpHost {
const host = new WmcpHost(client);
host.override("epub:open", async (params, defaultEpubOpen) => {
const result = await defaultEpubOpen(params);
await writeAuditLog({ action: "open", detail: { fileName: params.fileName } });
return result;
});
host.override("epub:save", async (params, defaultEpubSave) => {
const blob = await defaultEpubSave(params);
return await saveEpubToApi(blob as Blob);
});
host.override("epub:export", async (params, defaultEpubExport) => {
const blob = await defaultEpubExport(params);
return await exportEpubToApi(params.platformId as string, blob as Blob);
});
host.connectDirect({
"log:write": async (params) => writeAuditLog(params as { action: string; detail?: object }),
});
host.on("epub:loaded", (data) => console.log("[Host] EPUB loaded:", data));
host.on("epub:modified", (data) => console.log("[Host] EPUB modified:", data));
host.on("epub:translate", (data) => console.log("[Host] EPUB translate requested:", data));
if (opts?.onClose) {
host.on("epub:close", () => opts.onClose!());
}
return host;
}Key points:
- Keep
import "@aurorah/epub-studio/style.css"static so Next.js includes the CSS. - Dynamically import
mount()inside the client effect because EPUB Studio depends on browser APIs. - Use
mountedRefandcancelledto avoid duplicate mounts and async mount after unmount. setupHost(instance.wmcpClient)connects the module to the host app.host.override()lets the host wrap default module behavior and then forward blobs to server actions.host.connectDirect()exposes optional host requirements likelog:writeback to the module.- Always destroy both
hostandinstancein cleanup. - Always do
epub:openFile(and any other host-side EPUB load logic) insidehost.on("wmcp:ready", ...)— see "Studio readiness" below.
Studio readiness — wmcp:ready
mount() returns synchronously, but the Reader pane sizing, Monaco editor worker, CodeEditor, and Fonts only stabilize after the browser paints once. Emitting epub:openFile during that gap causes a race where TOC / 파일구조 populates (sync wMCP listeners on the store) but the viewer / editor / code panes silently stay on the placeholder.
Starting in @aurorah/[email protected] (with @aurorah/[email protected]), the studio emits the protocol-level wmcp:ready event once all subsystems have laid out (after two chained requestAnimationFrames inside mount()). Hosts MUST run their EPUB fetch + open logic inside this listener:
host.on("wmcp:ready", async () => {
const blob = await fetchEpubBlob();
host.emit("epub:openFile", { fileData: blob, fileName: "book.epub" });
});wMCP also buffers host->module events that arrive before the studio's first paint and replays them FIFO once readiness fires, so an early emit will not be lost. The wmcp:ready listener is still the recommended entry point because (1) it lets the host start its fetch lazily — only after the studio is actually mounted — and (2) any post-emit UI like a "loading EPUB" spinner can be coordinated against the same readiness signal.
Custom fonts with next/font (2 files needed):
Step 1 -- Load fonts in your root layout:
// app/layout.tsx
import { Noto_Sans_KR, Noto_Serif_KR } from "next/font/google";
const sans = Noto_Sans_KR({ subsets: ["latin"], variable: "--font" });
const serif = Noto_Serif_KR({ subsets: ["latin"], variable: "--r-font" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={`${sans.variable} ${serif.variable}`}>
<body>{children}</body>
</html>
);
}Step 2 -- Mount with useCustomFonts: true so the package uses the fonts from Step 1 instead of auto-injecting:
// app/epub/page.tsx
"use client";
import { useEffect, useRef } from "react";
import "@aurorah/epub-studio/style.css";
export default function EpubPage() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let cleanup: (() => void) | undefined;
let cancelled = false;
async function init() {
const { mount } = await import("@aurorah/epub-studio");
if (cancelled || !ref.current) return;
const instance = mount(ref.current, {
theme: "dark",
useCustomFonts: true,
});
cleanup = () => instance.destroy();
}
init();
return () => {
cancelled = true;
cleanup?.();
};
}, []);
return <div ref={ref} style={{ width: "100vw", height: "100vh" }} />;
}wMCP Contract
Defined in wmcp-manifest.json. Read it for the full protocol spec.
module:capabilities (module provides, host can override)
| Capability | Description |
| ------------- | ------------------------------------ |
| epub:open | Open and parse an EPUB file |
| epub:save | Build an EPUB Blob from editor state |
| epub:export | Export EPUB for a specific platform |
module:events (module emits, host listens)
| Event | Description |
| ---------------- | ---------------------------------- |
| epub:loaded | EPUB file was loaded and parsed |
| epub:modified | Content was modified in the editor |
| epub:translate | User clicked the translate button |
| epub:close | User clicked the close button |
In addition, the protocol-level wmcp:ready event (emitted by @aurorah/wmcp >= 1.1.0) fires once after mount() when the studio is fully wired. Hosts MUST gate epub:openFile (and other initial host->module commands that depend on a stable studio) on this event — see Studio readiness.
module:listeners (host emits, module listens)
| Listener | Description |
| --------------- | ------------------------------------- |
| epub:openFile | Host requests opening a specific EPUB |
| epub:setTheme | Host requests theme change |
host:requires (module needs from host, optional)
| Requirement | Description |
| ------------ | ------------------------------------------------------ |
| log:write | Write an audit log |
| export:pdf | Render book HTML to PDF through a host-side PDF engine |
Export Platforms
The exporter supports: standard, epub2, ridi, kakao, naver, kindle, apple, syosetu.
Tests
pnpm test