@deutschemodelunitednations/munify-resolution-editor
v0.2.1
Published
UN Resolution Editor component for MUNify applications
Maintainers
Readme
@deutschemodelunitednations/munify-resolution-editor
A Svelte 5 component library for creating and editing UN-style resolutions. Built for Model United Nations conferences.
Features
- Store-based architecture — editor components consume a
ResolutionStoreinterface; swap between local-only JSON and Y.js-backed real-time collaboration without changing the UI - Full Resolution Editor — preamble + operative clauses, recursive sub-clauses up to 4 levels
- Real-time co-editing (optional) — character-level collaborative typing via the
/yjssubpath, including cursor-preserving CRDT bindings and remote-user awareness - Phrase Validation & Suggestions — validate clause openings against UN vocabulary, inline autocomplete
- Import from Text — parse plain text or LLM-formatted resolutions
- Preview & Print — official UN document format, page-broken via
pagedjs - Customizable — i18n, custom phrases, snippet extension points
- Type-safe — full TypeScript + Zod schema validation
Installation
bun add -d @deutschemodelunitednations/munify-resolution-editorNote: install as a dev dependency — SvelteKit bundles components at build time.
Peer Dependencies
| Peer | Required for | Optional? |
| --------------- | ------------------- | --------- |
| svelte ^5.0.0 | always | no |
| yjs ^13.6.0 | /yjs subpath only | yes |
| y-protocols | /yjs subpath only | yes |
If you only use the native (JSON) store you do not need to install yjs / y-protocols.
Styling Setup
The library uses Tailwind CSS utilities. Tailwind needs to scan the library's components.
Tailwind v4 (recommended)
@import 'tailwindcss';
@import '@deutschemodelunitednations/munify-resolution-editor/tailwind.css';
@plugin "daisyui";Tailwind v3
export default {
content: [
'./src/**/*.{html,js,svelte,ts}',
'./node_modules/@deutschemodelunitednations/munify-resolution-editor/dist/**/*.svelte'
]
};Architecture
The editor is UI-only. Persistence and collaboration are concerns of the application that hosts it. Two store implementations bridge that gap:
┌──────────────────────────────────────────────────────────────┐
│ <ResolutionEditor store={...} presence={...} … /> │
│ ClauseEditor / OperativeClauseEditor / SubClauseEditor │
│ (read store.snapshot, call store.addPreambleClause(), …) │
└────────────────────────┬─────────────────────────────────────┘
│ ResolutionStore interface
┌────────────┴────────────┐
▼ ▼
createNativeStore createYjsStore
───────────────── ────────────────
plain $state<Resolution> Y.Doc + Y.Map + per-clause Y.Text
onChange(snapshot) Y.transact() on every mutator
no peers awareness presence adapterA ResolutionStore exposes:
snapshot: Resolution— reactive Svelte 5$statevalue- typed mutators (
addPreambleClause,updateTextBlock,indentSubClause, …) getTextHandle(loc)— returns aTextHandlewhosebindTextarea(el)is a no-op for the native store and a CRDT binding for the Y.js storereplaceResolution(next)— bulk replace, preserving clause ids where possibledestroy()— release subscriptions
When to use which store
| Use case | Store |
| ------------------------------------------------------ | ------------------------------------------------------- |
| Single-user editor, save-on-blur to your DB | createNativeStore |
| Inline mini-editor in a modal (e.g. amendment compose) | createNativeStore |
| Live multi-user co-editing of a working paper | createYjsStore |
| Server-side mutations against a paper's canonical doc | createYjsStore (against the same Y.Doc you persist) |
Usage
Native (single-user)
<script lang="ts">
import {
ResolutionEditor,
createNativeStore,
createEmptyResolution,
type Resolution
} from '@deutschemodelunitednations/munify-resolution-editor';
import { germanLabels } from '@deutschemodelunitednations/munify-resolution-editor/i18n/de';
import {
germanPreamblePhrases,
germanOperativePhrases
} from '@deutschemodelunitednations/munify-resolution-editor/phrases/de';
let { initialContent }: { initialContent?: Resolution } = $props();
const store = createNativeStore(initialContent ?? createEmptyResolution('General Assembly'), {
onChange: (snapshot) => {
// Persist to your backend, debounced if you like.
void saveToServer(snapshot);
}
});
</script>
<ResolutionEditor
{store}
editable
labels={germanLabels}
preamblePhrases={germanPreamblePhrases}
operativePhrases={germanOperativePhrases}
/>The store owns a Svelte 5
$state, sostore.snapshotis reactive — components re-render automatically. Don't keep a separate$state<Resolution>outside the store.
Y.js (real-time collaboration)
<script lang="ts">
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { ResolutionEditor } from '@deutschemodelunitednations/munify-resolution-editor';
import {
createYjsStore,
createAwarenessPresence
} from '@deutschemodelunitednations/munify-resolution-editor/yjs';
import type {
ResolutionStore,
PresenceAdapter
} from '@deutschemodelunitednations/munify-resolution-editor';
let { paperId, currentUser }: { paperId: string; currentUser: { id: string; name: string } } =
$props();
let store = $state<ResolutionStore | null>(null);
let presence = $state<PresenceAdapter | null>(null);
let synced = $state(false);
$effect(() => {
const doc = new Y.Doc();
const provider = new WebsocketProvider(`wss://${location.host}/api/ws/yjs`, paperId, doc);
const s = createYjsStore(doc); // no `seed` — server delivers initial state
const p = createAwarenessPresence({ awareness: provider.awareness, user: currentUser });
const onSynced = (v: boolean) => (synced = v);
provider.on('synced', onSynced);
store = s;
presence = p;
return () => {
provider.off('synced', onSynced);
s.destroy();
provider.destroy();
doc.destroy();
store = null;
presence = null;
synced = false;
};
});
</script>
{#if !synced || !store}
<div>Connecting…</div>
{:else}
<ResolutionEditor {store} presence={presence ?? undefined} editable />
{/if}Important: gate the editor on
synced. Until the WS handshake completes the local Y.Doc has no root structure, and mutators that target it will silently no-op (e.g.addPreambleClausereturns without inserting).
Preview only
<script lang="ts">
import { ResolutionPreview } from '@deutschemodelunitednations/munify-resolution-editor';
</script>
<ResolutionPreview
{resolution}
headerData={{
conferenceName: 'Model United Nations',
committeeName: 'Security Council',
topic: 'International Peace and Security',
documentNumber: 'S/RES/2026/1'
}}
/>ResolutionPreview is a pure render — no store needed.
Server-side mutations (Y.js mode)
When the host application needs to mutate a paper from the server (e.g. applying an approved amendment, transitioning status), it should mutate the canonical Y.Doc directly, not the JSON projection. The library exports the conversion helpers:
import {
jsonToYDoc,
yDocToJson,
replaceResolution as replaceYDocResolution
} from '@deutschemodelunitednations/munify-resolution-editor/yjs';A typical server flow looks like:
import * as Y from 'yjs';
import {
yDocToJson,
replaceResolution
} from '@deutschemodelunitednations/munify-resolution-editor/yjs';
// inside an async server-side function with the doc in hand
doc.transact(() => {
const before = yDocToJson(doc);
const after = applyAmendment(before, amendment); // pure JSON transform
replaceResolution(doc, after); // structural diff, preserves ids
}, 'server');The 'server' origin tag lets WS sync-loop guards (which ignore their own echo) distinguish server mutations from peer broadcasts.
CHASE has a reference implementation at src/api/yjs/server.ts (ref-counted in-memory cache, debounced persistence to a bytea column, idle eviction). See MIGRATION.md for a porting checklist.
API Reference
Stores
import {
createNativeStore,
createEmptyNativeStore,
type ResolutionStore,
type TextHandle,
type TextLocation,
type ClausePath,
type SubclausesBlockPath,
type OutdentResult,
type PresenceAdapter,
type PresenceUser,
type PresenceInfo,
type NativeStoreOptions
} from '@deutschemodelunitednations/munify-resolution-editor';
import {
createYjsStore,
createAwarenessPresence,
jsonToYDoc,
yDocToJson,
replaceResolution,
bindYTextToTextarea,
ROOT_KEY,
type YjsStoreOptions,
type AwarenessPresenceOptions
} from '@deutschemodelunitednations/munify-resolution-editor/yjs';Components
import {
ResolutionEditor,
ResolutionPreview,
ResolutionDocumentHeader,
ResolutionDocumentFooter,
ClauseEditor,
OperativeClauseEditor,
SubClauseEditor,
PhraseLookupModal,
PhraseSuggestions,
ImportModal,
ResolutionPrintPreview
} from '@deutschemodelunitednations/munify-resolution-editor';Schema & Types
import {
type Resolution,
type PreambleClause,
type OperativeClause,
type SubClause,
type ClauseBlock,
type TextBlock,
type SubclausesBlock,
type ResolutionHeaderData,
type AmendmentOverlay,
ResolutionSchema,
createEmptyResolution,
createEmptyOperativeClause,
createEmptyPreambleClause,
createEmptySubClause,
createTextBlock,
createSubclausesBlock,
getSubClauseLabel,
isLegacyResolution,
migrateResolution,
validateResolution
} from '@deutschemodelunitednations/munify-resolution-editor/schema';Phrases
import {
germanPreamblePhrases,
germanOperativePhrases,
englishPreamblePhrases,
englishOperativePhrases
} from '@deutschemodelunitednations/munify-resolution-editor/phrases';i18n
import type { ResolutionEditorLabels } from '@deutschemodelunitednations/munify-resolution-editor/i18n';
import {
germanLabels,
englishLabels
} from '@deutschemodelunitednations/munify-resolution-editor/i18n/de';Extension Points
Editor render slots use Svelte 5 snippets:
<ResolutionEditor {store}>
{#snippet clauseToolbar({ clause, index })}
<button onclick={() => addAmendment(clause)}>Add Amendment</button>
{/snippet}
{#snippet clauseAnnotations({ clause, index })}
{#if hasAmendments(clause)}
<div class="badge badge-warning">Has amendments</div>
{/if}
{/snippet}
{#snippet previewHeader({ resolution, headerData })}
<div class="custom-header">…</div>
{/snippet}
{#snippet previewFooter({ resolution })}
<div class="signatures">…</div>
{/snippet}
</ResolutionEditor>Available snippets: clauseToolbar, preambleClauseToolbar, clauseAnnotations, preambleAnnotations, afterPreambleClause, afterOperativeClause, betweenOperativeClauses, previewHeader, previewFooter.
Migrating from 0.1.x
The 0.1 API took resolution + editable + onResolutionChange props directly on ResolutionEditor. In 0.2 the editor consumes a ResolutionStore and the consumer wires persistence into the store.
See MIGRATION.md for the full upgrade path including a removed-props table and search-and-replace recipes.
Development
bun install
bun run dev # demo SvelteKit app
bun run package # build the library
bun run check # svelte-check
bun testLicense
MIT — Deutsche Model United Nations e.V.
