@miragon/modeler-api 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Miragon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Temporary Holding Version
1
+ # @miragon/modeler-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ONE interface every Miragon modeler implements — the collaborative-modeler
4
+ contract and its conformance test kit.
5
+
6
+ ## Why
7
+
8
+ Embedding a modeler in a collaborative host (a shared Y.Text, re-imports on
9
+ every remote change) exposes behavioral details that are invisible in
10
+ standalone use. Before this contract existed, each drift had to be discovered
11
+ per modeler, against its engine internals:
12
+
13
+ - an `importDSL` that ran `commandStack.clear()` **emitting** a change event —
14
+ unsuppressed, opening a hand-authored file re-exported the canonical
15
+ serialization over it;
16
+ - an import that left the undo stack on stale, removed shapes — undo after a
17
+ co-editor's change silently no-op'd or deleted re-imported shapes by id
18
+ coincidence;
19
+ - a serializer that was not a byte fixpoint of its own parser;
20
+ - config/axis edits that fired their own event instead of the command stack —
21
+ a host observing only the stack lost them.
22
+
23
+ This package turns those lessons into **laws** (see the JSDoc on
24
+ `CollaborativeModeler`) and ships a **conformance kit** that makes every law
25
+ an executable test the modeler repo runs in its own CI. Drift fails the
26
+ modeler's build — not a downstream integration review.
27
+
28
+ ## The contract (1.x)
29
+
30
+ ```ts
31
+ import type { CollaborativeModeler } from "@miragon/modeler-api";
32
+ ```
33
+
34
+ | Law | Requirement |
35
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | L1 | `importText` is **silent**: no change events, undo history erased without emitting — also over existing history and foreign content |
37
+ | L2 | `exportText` is **canonical**: deterministic, a byte fixpoint on canonical input, idempotent otherwise |
38
+ | L3 | `onContentChanged` fires for **every** user-visible change — element commands, config/axis/title edits, undo/redo — and nothing else; inside a listener `exportText()` already returns the new document |
39
+ | L4 | undo directly after an import is a defined **no-op** — never a stale-history mutation |
40
+ | L5 | view state survives a re-import; an import leaves the view **fitted** to the content; the readback may be a normalised form of what was set |
41
+ | L6 | `exportText` is total after a successful import; foreign content is **preserved** through import → export, never dropped, never a throw |
42
+ | L7 | a REJECTED import leaves no trace — previous document still rendered and exported, no events, undo history untouched — and the modeler **stays usable** |
43
+
44
+ View state is never part of the document: `setViewState` fires no change
45
+ events and `exportText` is independent of it.
46
+
47
+ `meta.editable` tells a viewer mount from an editor: a package that ships a
48
+ `NavigatedViewer`/`Viewer` exposes it under the same contract with
49
+ `editable: false` — it imports, exports, reveals and reports view state like
50
+ the editor and never fires `onContentChanged`, so hosts mount it on read-only
51
+ surfaces without per-package knowledge. A viewer must import without a
52
+ command stack of its own, or register one: the kit's viewer case fails a
53
+ `NavigatedViewer` whose import clears a stack it never registered.
54
+
55
+ Optional `elements` surface (stable ids, `reveal`, a synchronous `selection()`
56
+ and selection events) powers host features like anchored todos, `?element=`
57
+ deep links and the selection half of live presence. Ids are ELEMENT ids
58
+ (a label shape resolves to its target) and survive edits to other elements —
59
+ positional ids that renumber when a line is inserted above break every anchor
60
+ below.
61
+
62
+ ## The conformance kit
63
+
64
+ ```ts
65
+ // e.g. test/conformance.browser.test.ts in a modeler repo (any (name, fn) runner)
66
+ import { attachedHost, registerConformance } from "@miragon/modeler-api/conformance";
67
+ import { test } from "vitest";
68
+
69
+ registerConformance(
70
+ {
71
+ create: async () => mountMyModeler(attachedHost()), // IN the document, sized — see below
72
+ createReadonly: async () => mountMyViewer(attachedHost()), // the viewer mount, if the package ships one
73
+ canonicalTexts: [CANONICAL_SAMPLE_A, CANONICAL_SAMPLE_B], // two DISTINCT, REVIEWED ones — the undo law needs them
74
+ looseTexts: [HAND_AUTHORED_SAMPLE],
75
+ invalidTexts: [HALF_TYPED_SAMPLE], // [] if NOTHING is non-importable (a lenient line DSL)
76
+ foreignTexts: [{ text: SAMPLE_WITH_VENDOR_EXTENSION, mustSurvive: ["x-vendor: keep-me"] }],
77
+ viewState: { x: 120, y: 80, width: 640, height: 480 }, // a panned/zoomed viewport, not the fitted one (the kit compares readbacks, never this literal)
78
+ mutate: (m) => addOneElement(m), // undoable; adds — never renames or deletes
79
+ configMutations: [{ name: "axis labels", run: (m) => renameEvolutionAxis(m) }], // [] if the notation has none
80
+ undo: (m) => triggerUndo(m),
81
+ redo: (m) => triggerRedo(m),
82
+ // engines that debounce change delivery: make this outlast the debounce
83
+ settle: () => new Promise((r) => setTimeout(r, 50)),
84
+ },
85
+ test,
86
+ );
87
+ ```
88
+
89
+ **Run it in a real browser.** diagram-js engines compute viewboxes from the
90
+ host's client size and use `getBBox`, which jsdom lacks and a detached or 0×0
91
+ element turns into NaN: mount into `attachedHost()` (a sized element appended
92
+ to `document.body`) under vitest browser mode / Playwright — the modeler
93
+ repos' `test:browser` project — not under the unit project.
94
+
95
+ **Canonical texts are yours to vouch for.** L2 proves `exportText` is a
96
+ fixpoint of the texts you provide. Obtain them by exporting a hand-authored
97
+ document and reviewing the bytes; pasting whatever the current build prints
98
+ proves only that it agrees with itself.
99
+
100
+ **Nothing skips silently.** A case whose harness field is `undefined` reports
101
+ a SKIP (`console.warn` by default, `registerConformance(h, test, { onSkip })`
102
+ to route it) instead of passing. An explicit `[]` (`invalidTexts`,
103
+ `foreignTexts`, `configMutations`, `looseTexts`) is a statement that the
104
+ notation has no such form and skips nothing.
105
+
106
+ **View state is compared by readback.** The kit never compares
107
+ `getViewState()` with your `viewState` literal: it sets it, reads the
108
+ modeler's own (possibly normalised) state back, and requires that readback
109
+ to survive a re-import — a diagram-js viewbox that comes back rounded with
110
+ `scale`/`inner`/`outer` is fine.
111
+
112
+ **Element identity is verified through names.** After an unrelated edit every
113
+ id must still exist and, where the notation has names, still name the same
114
+ element; a notation whose elements carry no names is verified for id
115
+ existence only.
116
+
117
+ The kit throws plain `Error`s — no assertion-library or runner dependency.
118
+ Its own CI proves it bites: a conforming reference modeler
119
+ ([`test/reference-modeler.ts`](test/reference-modeler.ts) — also the model
120
+ for a harness) passes every case, and one deliberately broken variant per
121
+ historical drift AND per kit case fails exactly the case set that encodes
122
+ the violated law (not every individual assertion has a saboteur; every case
123
+ does).
124
+
125
+ ## Versioning
126
+
127
+ One identity: `MODELER_API_VERSION` IS the npm package version — release-please
128
+ rewrites the constant on every release (`extra-files`) and a test pins it to
129
+ `package.json` exactly. Modelers pin the package and put the constant into
130
+ `meta.apiVersion`; hosts match on the major (`apiVersionsCompatible`).
131
+ Releases are tagged `modeler-api-vX.Y.Z`.
132
+
133
+ The contract is first published as 1.0.0, never 0.x: under 0.x,
134
+ `apiVersionsCompatible` matches on the minor too, which would turn every
135
+ additive surface into a lockstep release across all modelers and their
136
+ hosts. A new optional surface (a structured snapshot lane alongside the text
137
+ lane, richer element info) is a minor, and hosts feature-detect it. A new
138
+ required member or a stricter law is a major.
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,7 @@
1
+ // src/index.ts
2
+ var MODELER_API_VERSION = "1.0.0";
3
+
4
+ export {
5
+ MODELER_API_VERSION
6
+ };
7
+ //# sourceMappingURL=chunk-HWB2EYDZ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @miragon/modeler-api — ONE interface every Miragon modeler implements.\n *\n * The contract encodes, as REQUIREMENTS, the behavioral drift that had to be\n * discovered per modeler against engine internals before it existed:\n * an `importDSL` that emitted change events (rewriting hand-authored files on\n * open), an import that left the undo stack on stale removed shapes, a\n * serializer that was not a byte fixpoint. A host embedding a conforming\n * modeler needs no per-engine suppression tricks — the laws below hold.\n *\n * The conformance test kit (`@miragon/modeler-api/conformance`) turns each\n * law into an executable case the modeler repo runs in its own CI.\n */\n\n/** the semver of THIS contract — a modeler declares the version it implements\n * in `meta.apiVersion`; hosts check compatibility with `apiVersionsCompatible`\n * (same major; while 0.x, the same minor too). The constant IS the\n * package version: release-please rewrites the literal below on every\n * release (release-please-config.json `extra-files`), and the kit's own\n * test pins it to package.json exactly. Modelers pin the npm package; the\n * constant is what travels in `meta`. */\nexport const MODELER_API_VERSION = \"1.0.0\"; // x-release-please-version\n\nexport interface ModelerMeta {\n /** stable notation id, e.g. \"wardley\", \"team-topology\", \"event-storming\" */\n readonly notation: string;\n /** the MODELER_API_VERSION this modeler implements — compatible when the\n * major matches and, while the contract is 0.x, the minor too (pre-1.0\n * minors may break: bump-minor-pre-major) */\n readonly apiVersion: string;\n /**\n * true on an editing mount (the package's Modeler), false on a VIEWER\n * (its NavigatedViewer/Viewer): a viewer never fires `onContentChanged`,\n * accepts no user commands, and still imports, exports, reveals and\n * reports view state exactly like the editor — hosts mount it on\n * read-only surfaces (an MCP-App widget in read-only mode) without\n * per-package knowledge or a command-stack shim.\n */\n readonly editable: boolean;\n}\n\nexport interface ImportResult {\n /** non-fatal notes from the import (unknown lines kept, migrations run) */\n warnings: string[];\n}\n\nexport interface ElementInfo {\n /**\n * stable element id — identical across re-imports of the same text AND\n * untouched by edits to OTHER elements: hosts anchor todos, presence and\n * `?element=` deep links to it, so an id that renumbers when a line is\n * inserted above (positional edge ids) breaks every anchor below it. An\n * element without an id in the text gets one the serializer WRITES BACK.\n */\n id: string;\n /** human label, when the notation has one */\n name?: string;\n}\n\n/**\n * Optional element surface — what a host needs for anchored todos, `?element=`\n * deep links and the selection half of live presence. Absent = the notation\n * has no addressable element identity. Ids are ELEMENT ids throughout: a\n * shape that only labels another element (diagram-js external labels) is\n * reported and revealed as its target, never under its own label id.\n */\nexport interface ElementsSurface {\n /** every addressable element currently on the canvas */\n list(): ElementInfo[];\n /** select + scroll the element into view; false when the id is unknown.\n * Revealing is view behavior: it delivers a selection, never a change event */\n reveal(id: string): boolean;\n /** the current selection as stable element ids — synchronous, so a host\n * can read it at the moment of a click (a deep link carrying the\n * selection) without caching every delivery */\n selection(): string[];\n /** selection changes as STABLE element ids; returns the unsubscribe */\n onSelection(cb: (ids: string[]) => void): () => void;\n}\n\n/**\n * The collaborative-modeler contract. The LAWS (verified by the conformance\n * kit) are part of the interface:\n *\n * L1 SILENT IMPORT — `importText` never fires `onContentChanged` and erases\n * the undo history without emitting — also when history exists. Hosts\n * re-import remote states on every sync; an import echo would re-export\n * the canonical serialization over half-typed peer text.\n * L2 CANONICAL FIXPOINT — `exportText` is deterministic, and for any text t:\n * with c = exportText() after importText(t), importText(c) followed by\n * exportText() returns c byte-for-byte. Canonicalization is idempotent;\n * opening a file never rewrites it.\n * L3 ONE CHANGE EVENT — every user-visible atomic change (element commands\n * AND config/axis/title/style edits that live in the document, AND\n * undo/redo) fires `onContentChanged` at least once, and nothing else\n * does: not `importText`, not undo-history housekeeping, not view or\n * selection changes. When a listener runs, `exportText()` ALREADY\n * returns the post-change serialization — hosts export inside the\n * listener; an emit-before-apply would lose the last edit.\n * L4 DEFINED UNDO ACROSS IMPORTS — undo directly after an `importText` is a\n * no-op: it must never mutate or resurrect shapes from the pre-import\n * document (id coincidence included).\n * L5 VIEW-STATE ROUND-TRIP — `getViewState`/`setViewState` survive a\n * re-import: snapshot before, restore after, viewport unchanged. An\n * import leaves the view FITTED to the content (the defined post-import\n * view, the same for the same document and host — a host that wants to\n * keep the user's viewport across a remote re-import snapshots before\n * and restores after); before the first import `getViewState()` returns\n * `undefined` or a state `setViewState` accepts, and never throws. The\n * readback may be a NORMALISED form of what was set (a diagram-js\n * viewbox comes back rounded, with scale/inner/outer) — what must hold\n * is that setting a readback yields that readback again.\n * L6 EXPORT IS TOTAL AFTER IMPORT — after a successful import (and any\n * sequence of user commands and undos), `exportText` succeeds. Foreign or\n * auxiliary content the modeler does not understand is PRESERVED through\n * import → export (it comes back in the export, canonicalised like the\n * rest), never dropped and never a reason to throw.\n * L7 REJECTED IMPORTS LEAVE NO TRACE — `importText` on non-importable text\n * REJECTS (resolve-with-warnings means \"imported, with notes\"; rejection\n * means \"nothing imported\"): afterwards the previously imported document\n * is still rendered with its elements, `exportText` still returns it, no\n * change event has fired and the undo history is untouched.\n * The modeler stays USABLE: the next valid import renders, the next user\n * command applies. Hosts re-import half-typed peer text on every sync —\n * \"blank (or wedged) until healed\" is not an acceptable failure mode.\n *\n * View state is NEVER part of the document: `setViewState` fires no change\n * events and `exportText` is independent of it.\n */\nexport interface CollaborativeModeler {\n readonly meta: ModelerMeta;\n\n /** mount the canvas into a host element (once, before the first import).\n * The host must be attached to the document with a non-zero size — a\n * detached or 0×0 element gives diagram-js engines NaN viewboxes */\n attachTo(host: Element): void;\n\n /** parse + render `text` — SILENT per L1, the view fitted to the content\n * per L5; resolves when rendered, REJECTS when nothing was imported (L7) */\n importText(text: string): Promise<ImportResult>;\n\n /** the canonical serialization of the current document (L2, L6) */\n exportText(): string;\n\n /** subscribe to user-visible model changes (L3); returns the unsubscribe */\n onContentChanged(cb: () => void): () => void;\n\n /** opaque viewport snapshot (zoom/scroll) — JSON-serializable (L5) */\n getViewState(): unknown;\n setViewState(state: unknown): void;\n\n readonly elements?: ElementsSurface;\n\n /** tear down DOM + listeners; further calls on the instance are undefined\n * behavior, but a second destroy() must not throw */\n destroy(): void;\n}\n"],"mappings":";AAqBO,IAAM,sBAAsB;","names":[]}