MDZip Editor Developer Guide
@mdzip/editor can run as a complete standalone editor or as an embedded
workspace inside a larger web or desktop application. The same workspace view
is available through the raw browser API and the Angular, React, and Vue
wrappers.
Packages
npm install @mdzip/editor
Install the wrapper for your framework when needed:
npm install @mdzip/editor-ng
npm install @mdzip/editor-react
npm install @mdzip/editor-vue
Your host element must have a definite height. The editor fills that height:
.editor-host {
height: 100vh;
min-height: 0;
}
Mode And Controls
Mode and controls have different responsibilities:
mode: 'read-only' | 'editable'determines which operations are permitted.controlsdetermines which built-in UI the host exposes.- Hiding a control does not weaken mode enforcement. A read-only workspace remains read-only.
Presets
preview: document preview without toolbar or navigation.viewer: read-only-oriented navigation, title, layout, zoom, and theme UI.standalone-editor: complete editing UI, including Save.hosted-editor: complete editing UI without Save, for hosts that own persistence.custom: standalone defaults with explicit host overrides.
Granular Controls
Start from a preset and override only what the host owns:
const controls = {
preset: 'hosted-editor',
navigation: false,
title: {
visible: true,
editable: false
},
lineNumbers: false,
layout: {
source: true,
split: false,
preview: true
},
formatting: {
enabled: false,
bold: true,
italic: true,
headings: [2, 3, 4],
bulletList: true,
orderedList: true,
inlineCode: true,
blockquote: true,
lineBreak: true,
link: true
},
save: false,
zoom: true,
colorScheme: true,
orphanActions: false,
fileActions: false
} as const;
fileActions (default true in the editor presets, false in preview/
viewer) gates the navigation pane's file-management surface: the right-click
context menu (new .md file, new folder, rename/move, duplicate, replace,
download, copy markdown link/image embed, set entry point, set/remove cover
image, delete) and drag-and-drop (move files between folders, drop OS files
onto the pane). Copy and Download are non-mutating and stay available whenever
the menu is shown, including read-only mode. Dragging a tree file onto the
editor surface inserts a markdown link/embed at the pointer and only requires
an editable workspace, not fileActions.
The same operations are exposed programmatically on the view and the framework
wrappers: removeFile(), renameFile() (also moves; rewrites markdown
references), setEntryPoint(), and setCoverImage().
For plain-markdown sources, the onConversionRequested(action) option lets a
host take over the markdown→MDZ conversion flow (nav button, Insert Image, or
image paste/drop): return or resolve true to suppress the built-in dialog.
Image insertion can also be customized directly. imageInsertMode controls the
built-in markup flow ('markdown', 'html', or 'ask'), while
imageInsertHandler(request) lets a host return Markdown, sized/aligned HTML,
null to cancel, or undefined to fall back to the built-in mode. The demo
page includes an Image insert selector so the default, ask dialog, and host
hook paths can be tested without writing a separate host app.
The built-in dialog asks for width, height, or percent scaling and preserves
aspect ratio instead of asking for width and height independently.
The built-in HTML path uses portable align attributes for positioning because
the default preview sanitizer strips inline style attributes.
Broad boolean forms remain supported:
const controls = {
preset: 'standalone-editor',
title: false,
layout: false,
formatting: false
} as const;
Within formatting or layout, use enabled: false to build an allowlist.
Without enabled, omitted nested values inherit from the selected preset.
The title is displayed in a compact document strip above the command toolbar.
Its information button shows the source filename, format, displayed title,
first Markdown heading, package timestamps, and entry point. The existing
title.visible and title.editable settings control this strip and its
click-to-edit title behavior.
Controls can also be changed after construction with view.setControls().
The view updates its toolbar and layout policy in place; lineNumbers changes
reconfigure the existing CodeMirror editor, preserving the current document and
selection. Angular, React, and Vue wrapper controls changes use the same
in-place path.
Hosts that need a denser embedded surface can use semantic density options instead of private class overrides:
const view = new MdzipWorkspaceView(container, {
controls,
toolbarDensity: 'compact', // 'comfortable' | 'compact' | 'dense'
contentDensity: 'compact' // 'comfortable' | 'compact'
});
view.setDensityOptions({ toolbarDensity: 'dense', contentDensity: 'compact' });
The same settings are available as Angular inputs and React/Vue props. For exact sizing, set stable CSS variables on an ancestor of the workspace:
.hosted-editor {
--mdzip-toolbar-button-size: 28px;
--mdzip-toolbar-compact-button-size: 26px;
--mdzip-toolbar-icon-size: 14px;
--mdzip-format-button-size: 26px;
--mdzip-format-icon-size: 14px;
--mdzip-toolbar-padding: 2px 8px;
--mdzip-toolbar-gap: 4px;
--mdzip-editor-content-padding: 16px 20px;
--mdzip-preview-content-padding: 16px 20px 24px;
}
Raw Browser API
import { MdzipWorkspaceView } from '@mdzip/editor';
const container = document.querySelector<HTMLElement>('#editor')!;
const view = new MdzipWorkspaceView(container, {
controls,
initialLayout: 'source',
initialColorScheme: 'dark',
navigationMode: 'host',
onChanged(bytes, snapshot) {
console.log(snapshot.currentPath, bytes.byteLength);
},
async onSaved(bytes) {
await persist(bytes);
view.markPersisted();
},
onColorSchemeChanged(colorScheme) {
console.log(colorScheme);
},
onFailed(error) {
console.error(error);
}
});
await view.open(archiveBytes, {
mode: 'editable',
fileName: 'document.mdz'
});
With standalone-editor, omitting onSaved makes the built-in Save button
download the current .mdz or Markdown file. Supplying onSaved transfers
persistence ownership to the host. The editor keeps dirty state set until the
host calls markPersisted() after a successful write.
The same API opens regular Markdown files. The source format is inferred from the filename:
await view.open(markdownBytes, {
mode: 'editable',
fileName: 'notes.md'
});
Use sourceFormat: 'markdown' or sourceFormat: 'mdz' when the filename is
missing or ambiguous. Markdown sources are saved, serialized, and emitted by
onChanged as UTF-8 Markdown bytes. MDZ sources remain archive bytes.
Standalone Markdown has no manifest or packaged assets. Its navigation pane
starts closed and the internal workspace used to edit it is not exposed as
document contents. Opening package navigation or inserting an image prompts the
user to convert the document to MDZ. After conversion, save callbacks and
serialization return MDZ archive bytes, so hosts should change the output
filename extension to .mdz. The workspace snapshot does this automatically:
for example, notes.md becomes notes.mdz immediately after conversion.
Hosts can also initiate conversion directly:
const converted = await view.convertToMdz();
The Angular component, React imperative handle, and Vue exposed instance provide
the same convertToMdz() method.
Useful host methods:
const snapshot = await view.getCurrentSnapshot();
const blob = await view.serialize();
const flushed = await view.flush();
view.markPersisted();
await view.executeCommand('bold');
view.focus();
view.destroy();
flush() commits pending editor text and returns current bytes and validation,
but it does not clear dirty state. Call markPersisted() only after the host
successfully writes those bytes. A failed native save therefore leaves the
editor dirty.
The raw view also accepts a normalized core workspace directly:
await view.openWorkspace(workspace, {
mode: 'editable',
fileName: 'document.mdz'
});
This path does not rebuild an MDZ archive during open. Serialization happens when the host requests bytes.
Use navigationMode: 'host' when the application supplies its own document and
asset navigation. Use navigationMode: 'none' when neither the editor nor host
should display package navigation.
Preview Lifecycle
Use onPreviewRendered, onAssetsHydrated, or whenRendered() when a host
needs to reveal a preview only after the current render is ready:
const view = new MdzipWorkspaceView(container, {
controls: 'preview',
onPreviewRendered: (snapshot) => { /* preview HTML is mounted */ },
onAssetsHydrated: (snapshot) => { /* images are resolved */ }
});
await view.whenRendered();
Archive images hydrate progressively: preview text mounts first, image slots
reserve space from sniffed dimensions, and resolved images are swapped in as
their bytes arrive. In live-editing hosts where repeating that reveal on every
keystroke is noisy, pass imageHydrationAnimation: 'initial' or update it
later with view.setImageHydrationAnimation('initial'). The first render for a
document path still animates, while same-document text edits snap image slots
open immediately. Use 'off' to disable the loading pulse and slide animation
entirely. Angular, React, and Vue expose the same setting as
imageHydrationAnimation.
Custom Host Toolbars
Control visibility and command availability are intentionally separate. A host can hide the built-in formatting toolbar and invoke the same editor operations from its own UI:
const view = new MdzipWorkspaceView(container, {
controls: {
preset: 'hosted-editor',
formatting: false
}
});
boldButton.addEventListener('click', () => {
void view.executeCommand('bold');
});
headingButton.addEventListener('click', () => {
void view.executeCommand('heading-2');
});
imageInput.addEventListener('change', () => {
const file = imageInput.files?.[0];
if (file) {
void view.executeCommand('insert-image', file);
}
});
executeCommand() returns true when the command can run. It returns false
when no document is open, the workspace is read-only, or the selected entry is
not editable Markdown. Use canExecuteCommand() to set host button states:
boldButton.disabled = !view.canExecuteCommand('bold');
Supported commands are bold, italic, strikethrough, paragraph,
heading-1 through heading-6, bullet-list, ordered-list, inline-code,
code-block, blockquote, insert-line-break, link, and insert-image.
Calling insert-image without a File opens the built-in image file picker.
Angular
import { Component } from '@angular/core';
import { MdzipWorkspaceComponent } from '@mdzip/editor-ng';
import type {
MdzipControlPolicy,
MdzipWorkspaceChange
} from '@mdzip/editor';
const editorControls: MdzipControlPolicy = {
preset: 'hosted-editor',
navigation: false,
formatting: { image: false }
};
@Component({
standalone: true,
imports: [MdzipWorkspaceComponent],
template: `
<button (click)="bold(workspace)">Bold</button>
<mdzip-workspace
#workspace
class="editor-host"
[bytes]="bytes"
mode="editable"
fileName="document.mdz"
[controls]="controls"
initialLayout="source"
navigationMode="host"
(changed)="onChanged($event)"
(failed)="onFailed($event)"
/>
`,
styles: [`.editor-host { display: block; height: 100vh; }`]
})
export class EditorPage {
bytes: Uint8Array | null = null;
controls = editorControls;
bold(workspace: MdzipWorkspaceComponent): void {
void workspace.executeCommand('bold');
}
onChanged(event: MdzipWorkspaceChange): void {
this.bytes = event.bytes;
}
onFailed(error: unknown): void {
console.error(error);
}
}
Angular accepts either [bytes] or [workspace]. Its public component methods
include flush(), serialize(), getCurrentSnapshot(), markPersisted(),
and the asset operations, all callable through a template reference variable as
above or through a view query (viewChild.required(MdzipWorkspaceComponent) or
the classic @ViewChild decorator) when no template event is involved. Outputs
include changed, saved, workspaceChanged, documentChanged,
assetChanged, manifestChanged, snapshotChanged, selectionChanged,
dirtyChanged, validationChanged, colorSchemeChanged, and failed.
React
import { useRef } from 'react';
import {
MdzipWorkspace,
type MdzipWorkspaceHandle
} from '@mdzip/editor-react';
import type { MdzipControlPolicy } from '@mdzip/editor';
const controls: MdzipControlPolicy = {
preset: 'hosted-editor',
navigation: false,
formatting: { image: false }
};
export function EditorPage({ bytes }: { bytes: Uint8Array }) {
const workspace = useRef<MdzipWorkspaceHandle>(null);
return (
<div style={{ height: '100vh' }}>
<button onClick={() => void workspace.current?.executeCommand('bold')}>
Bold
</button>
<MdzipWorkspace
ref={workspace}
bytes={bytes}
mode="editable"
fileName="document.mdz"
controls={controls}
initialLayout="source"
navigationMode="host"
onChanged={({ bytes: nextBytes }) => persist(nextBytes)}
onFailed={console.error}
/>
</div>
);
}
Keep callback and object props stable with useCallback and useMemo in
frequently rendering parents to avoid unnecessary wrapper recreation.
Vue
<script setup lang="ts">
import { ref } from 'vue';
import { MdzipWorkspace } from '@mdzip/editor-vue';
import type { MdzipWorkspaceExposed } from '@mdzip/editor-vue';
import type { MdzipControlPolicy } from '@mdzip/editor';
defineProps<{ bytes: Uint8Array }>();
const workspace = ref<MdzipWorkspaceExposed | null>(null);
const controls: MdzipControlPolicy = {
preset: 'hosted-editor',
navigation: false,
formatting: { image: false }
};
function onChanged(event: { bytes: Uint8Array }) {
persist(event.bytes);
}
function onFailed(error: unknown) {
console.error(error);
}
</script>
<template>
<div class="editor-host">
<button @click="workspace?.executeCommand('bold')">Bold</button>
<MdzipWorkspace
ref="workspace"
:bytes="bytes"
mode="editable"
file-name="document.mdz"
:controls="controls"
initial-layout="source"
navigation-mode="host"
@changed="onChanged"
@failed="onFailed"
/>
</div>
</template>
<style scoped>
.editor-host {
height: 100vh;
}
</style>
React and Vue accept either archive bytes or a normalized workspace and expose the same persistence and asset methods through their imperative handles. Vue emits the same structured event set exposed by the Angular and React wrappers.
Host Persistence
Use standalone-editor when the built-in Save button should download the file
or call onSaved. A host-provided onSaved handler must call
markPersisted() after persistence succeeds.
Use hosted-editor when the containing application owns Save, Save As, and
keyboard shortcuts. The raw view exposes flush(), serialize(), and
getCurrentSnapshot() for that workflow. After a successful native write, call
markPersisted().
Safe Rendering
MdzipRenderingService converts Markdown into an HTML string. When no renderer
is supplied, it uses the exported defaultSafeMarkdownRenderer:
import { MdzipRenderingService } from '@mdzip/editor';
const rendering = new MdzipRenderingService();
const result = rendering.render({
markdown: '# Hello\n\n```ts\nconst answer = 42;\n```'
});
previewElement.innerHTML = result.html;
The default renderer uses marked, applies highlight.js syntax highlighting,
and sanitizes the resulting HTML with DOMPurify. Its policy removes scripts,
inline event handlers, unsafe URL schemes, frames and embedded objects, forms,
style elements, and inline style attributes. Use it for ordinary Markdown
preview surfaces when the source may not be trusted.
The renderer is also exported for direct use:
import { defaultSafeMarkdownRenderer } from '@mdzip/editor';
const html = defaultSafeMarkdownRenderer.render(markdown);
Custom Renderers
Supply an object implementing MdzipMarkdownRenderer to the service
constructor:
import {
MdzipRenderingService,
type MdzipMarkdownRenderer
} from '@mdzip/editor';
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
const customRenderer: MdzipMarkdownRenderer = {
render(markdown) {
const rendered = marked.parse(markdown, { async: false });
const html = typeof rendered === 'string' ? rendered : '';
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['script', 'iframe', 'object']
});
}
};
const rendering = new MdzipRenderingService(customRenderer);
const { html } = rendering.render({ markdown: '# Custom preview' });
A custom renderer replaces the default renderer completely. Renderers may
return a Promise<string>; the pipeline keeps a fully synchronous fast path
when they do not.
Sanitization depends on the entry point:
- The workspace view pipeline (
markdownRendererinMdzipWorkspaceViewOptions, orMdzipRenderingService.renderMarkdown()) sanitizes the renderer's string output with the default DOMPurify policy before insertion. A renderer that sanitizes internally can declaresanitizesOutput: trueto skip the duplicate pass — this is the explicit bypass and should only be set when the renderer really does sanitize. - The legacy
MdzipRenderingService.render()method keeps its original contract: the renderer's return value is trusted as-is, so renderers used that way must sanitize before returning.
The renderer receives an MdzipMarkdownRenderContext with the current path,
source format, color scheme, mode, manifest, an optional asset resolver, and
an AbortSignal that fires when the render becomes stale:
import { defaultSafeMarkdownRenderer } from '@mdzip/editor';
const customRenderer: MdzipMarkdownRenderer = {
render(markdown, context) {
const source = context?.colorScheme === 'dark'
? markdown
: markdown.replace(/^\s{0,3}#{1,6}\s+/gm, '');
return defaultSafeMarkdownRenderer.render(source);
}
};
Archive Asset URLs
Pass an assetResolver when Markdown image paths need to be converted to
browser URLs or data URIs before rendering:
const { html } = rendering.render({
markdown: '',
assetResolver: {
resolveAssetUrl(path) {
return assetUrls.get(path);
}
}
});
When the resolver returns undefined, the original Markdown image path is left
unchanged. Hosts should only return URL schemes permitted by their rendering
policy.
Rendering Extensibility
MdzipWorkspaceView (and every framework wrapper) accepts three rendering
options. All are additive — with none supplied, behavior is identical to
previous releases, and the no-extensions render path performs no extra work.
const view = new MdzipWorkspaceView(host, {
markdownRenderer, // replace the markdown renderer
markdownExtensions, // compose over the default pipeline
entryRenderers // replace the content area for selected entries
});
The view memoizes preview rendering on (path, content, colorScheme, images):
unrelated state changes (dialogs, navigation, layout toggles) never re-run the
pipeline or reset preview DOM. Each render generation carries an
AbortSignal; stale asynchronous results are dropped when the user switches
entries or keeps typing.
Markdown Extensions
Extensions compose over the default pipeline instead of replacing it:
markdown -> transformMarkdown* -> renderer -> transformHtml*
-> sanitize -> DOM insertion -> mount*
const mermaidExtension: MdzipMarkdownRenderExtension = {
name: 'mermaid',
transformHtml: (html) =>
html.replace(/<pre><code class="hljs language-mermaid">/g,
'<div class="mermaid-placeholder"><code hidden>'),
mount(container, context) {
hydrateMermaid(container.querySelectorAll('.mermaid-placeholder'), context);
return { destroy: () => disposeMermaid(container) };
}
};
transformMarkdown and transformHtml output always passes through
sanitization, so extensions cannot inject unsafe markup. The sanitizer strips
data attributes and inline styles — use class or id markers to find
placeholders again in mount(), and carry payloads out-of-band (for example
a module-level map keyed by marker id). mount() runs against the live
preview DOM and is privileged host code; handles returned from it are
destroyed before the preview re-renders and when the view is destroyed.
Entry Renderers
Entry renderers claim the full pane stack (edit and preview panes included)
for selected archive entries. The first match by descending priority wins;
without a match, built-in rendering is unchanged.
import { mdzipPathMatcher, type MdzipEntryRenderer } from '@mdzip/editor';
const manifestRenderer: MdzipEntryRenderer = {
id: 'studio-manifest',
priority: 100,
matches: mdzipPathMatcher('manifest.json'),
mount(container, context) {
const editor = mountManifestEditor(container, {
manifest: context.manifest,
editable: context.mode === 'editable',
onChange: (manifest) => context.updateManifest(manifest)
});
return {
update: (next) => editor.setManifest(next.manifest),
destroy: () => editor.dispose()
};
}
};
The context exposes supported operations instead of view internals:
readBytes() reads the selected entry's raw bytes, and updateManifest()
replaces the manifest wholesale (canonicalized) and routes through the
workspace 'manifest' edit event — onManifestChanged host-delegated
persistence keeps working. mdzipPathMatcher(...paths) and
mdzipExtensionMatcher(...exts) build common predicates.
Lifecycle: renders are keyed by (path, pathType, mode, sourceFormat). While
the key is stable the handle stays mounted, receiving update() when the
color scheme or manifest changes. A changed key — including rename, move, or
delete of the backing entry — destroys the handle and re-runs matching.
Asynchronous mount() results that arrive after the selection moved on are
destroyed immediately instead of applied.
Framework Wrappers
All three wrappers accept markdownRenderer, markdownExtensions, and
entryRenderers as inputs/props with identical behavior. Renderer prop
changes apply in place via setRenderingOptions() — never by recreating the
workspace view. Extension and entry renderer arrays are diffed by their
stable name/id (and priority), so inline array literals with equivalent
contents are safe. Keep the markdownRenderer reference stable (module scope
or useMemo); its identity changes apply via a cheap preview re-render.
Each wrapper additionally offers an idiomatic way to mount native framework
content without bootstrapping the framework into an HTMLElement manually.
All three adapt onto the same MdzipEntryRenderer contract, so matching,
priority, fallback, lifecycle, and stale-result behavior are identical to
collection-registered renderers. At equal priority, explicit entryRenderers
win over the native catch-all.
Angular: template directives
<mdzip-workspace [bytes]="bytes" mode="editable">
<ng-template mdzipEntryRenderer="manifest.json" let-context>
<app-internals
[manifest]="context.manifest"
[editable]="context.mode === 'editable'"
(manifestChange)="context.updateManifest($event)"
/>
</ng-template>
<ng-template [mdzipEntryRendererMatch]="isDrawioEntry" let-context>
<app-drawio-viewer [entry]="context" />
</ng-template>
</mdzip-workspace>
mdzipEntryRenderer matches exact archive paths (string or array,
case-insensitive); [mdzipEntryRendererMatch] takes a predicate. Optional
mdzipEntryRendererId and mdzipEntryRendererPriority inputs control
diffing and ordering. The embedded view is created in the component's view
container — change detection and dependency injection work normally — its
DOM is moved into the entry pane, the template context is updated in place
on update(), and the view is destroyed on selection change.
React: renderEntry
<MdzipWorkspace
bytes={bytes}
renderEntry={(context) =>
context.path.toLowerCase() === 'manifest.json'
? <Internals
manifest={context.manifest}
editable={context.mode === 'editable'}
onManifestChange={context.updateManifest}
/>
: undefined}
/>
Returning undefined delegates to entryRenderers and the built-in
rendering. The wrapper owns the React root: it mounts on claim, re-renders
on context updates and on every parent commit (entry content stays live
with parent state — inline closures are safe and never re-mount), and
unmounts on selection change. renderEntryPriority orders it against
entryRenderers.
Vue: #entry scoped slot
<MdzipWorkspace :bytes="bytes">
<template #entry="{ context }">
<Internals
v-if="context.path.toLowerCase() === 'manifest.json'"
:manifest="context.manifest"
:editable="context.mode === 'editable'"
@manifest-change="context.updateManifest"
/>
</template>
</MdzipWorkspace>
A slot render that produces no content (its v-if is false) delegates to
the next renderer or the built-in rendering. The slot mounts in a detached
tree that shares the host app context (plugins, provide/inject), the context
is reactive (updates flow through on update()), and the tree unmounts on
selection change. entrySlotPriority orders it against entryRenderers.
Theme Integration
The built-in light and dark controls apply complete editor token sets. A host
can set initialColorScheme, listen to onColorSchemeChanged, or hide those
buttons with colorScheme: false.
Custom themes define --theme-* variables on :root, the editor container, or
an ancestor. See the Theming Guide for complete theme examples,
token precedence, exported CSS constants, and the full variable reference.
This page mirrors mdzip-editor/docs/developer-guide.md — the canonical, versioned source in its home repository. It is re-fetched on every site build.