@liminis/editor 0.1.0-rc.1
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 +34 -0
- package/README.md +376 -0
- package/dist/annotations/anchor-align.d.ts +50 -0
- package/dist/annotations/anchor-align.js +74 -0
- package/dist/annotations/anchor-model.d.ts +81 -0
- package/dist/annotations/anchor-model.js +80 -0
- package/dist/annotations/anchor-resolver.d.ts +92 -0
- package/dist/annotations/anchor-resolver.js +231 -0
- package/dist/annotations/block-structure.d.ts +65 -0
- package/dist/annotations/block-structure.js +113 -0
- package/dist/annotations/types.d.ts +159 -0
- package/dist/annotations/types.js +50 -0
- package/dist/annotations.d.ts +25 -0
- package/dist/annotations.js +27 -0
- package/dist/app/App.d.ts +64 -0
- package/dist/app/App.js +180 -0
- package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
- package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
- package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
- package/dist/app/editor/AnchorScrollPlugin.js +134 -0
- package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
- package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
- package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
- package/dist/app/editor/AnnotationPlugin.js +135 -0
- package/dist/app/editor/AnnotationSurface.d.ts +34 -0
- package/dist/app/editor/AnnotationSurface.js +115 -0
- package/dist/app/editor/AssetContext.d.ts +20 -0
- package/dist/app/editor/AssetContext.js +81 -0
- package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
- package/dist/app/editor/BlockClickPlugin.js +126 -0
- package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
- package/dist/app/editor/CodeBlockPlugin.js +219 -0
- package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
- package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
- package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
- package/dist/app/editor/DragHandlePlugin.js +300 -0
- package/dist/app/editor/Editor.d.ts +84 -0
- package/dist/app/editor/Editor.js +539 -0
- package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
- package/dist/app/editor/FrontmatterPlugin.js +301 -0
- package/dist/app/editor/ImageModal.d.ts +13 -0
- package/dist/app/editor/ImageModal.js +267 -0
- package/dist/app/editor/ImagePlugin.d.ts +3 -0
- package/dist/app/editor/ImagePlugin.js +187 -0
- package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
- package/dist/app/editor/LinkClickPlugin.js +90 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
- package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
- package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
- package/dist/app/editor/SearchPlugin.d.ts +1 -0
- package/dist/app/editor/SearchPlugin.js +379 -0
- package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
- package/dist/app/editor/SlashMenu.d.ts +11 -0
- package/dist/app/editor/SlashMenu.js +494 -0
- package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
- package/dist/app/editor/SlashMenuPlugin.js +168 -0
- package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
- package/dist/app/editor/TableActionsPlugin.js +398 -0
- package/dist/app/editor/TogglePlugin.d.ts +1 -0
- package/dist/app/editor/TogglePlugin.js +159 -0
- package/dist/app/editor/Toolbar.d.ts +10 -0
- package/dist/app/editor/Toolbar.js +263 -0
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
- package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
- package/dist/app/editor/annotation-marks.d.ts +177 -0
- package/dist/app/editor/annotation-marks.js +750 -0
- package/dist/app/editor/annotationCommands.d.ts +13 -0
- package/dist/app/editor/annotationCommands.js +11 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
- package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
- package/dist/app/editor/c4/edge-clipping.js +139 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
- package/dist/app/editor/c4/layout.d.ts +25 -0
- package/dist/app/editor/c4/layout.js +839 -0
- package/dist/app/editor/c4/parser.d.ts +19 -0
- package/dist/app/editor/c4/parser.js +410 -0
- package/dist/app/editor/c4/render-to-string.d.ts +24 -0
- package/dist/app/editor/c4/render-to-string.js +34 -0
- package/dist/app/editor/c4/renderer.d.ts +64 -0
- package/dist/app/editor/c4/renderer.js +569 -0
- package/dist/app/editor/c4/types.d.ts +203 -0
- package/dist/app/editor/c4/types.js +43 -0
- package/dist/app/editor/correction-yaml.d.ts +10 -0
- package/dist/app/editor/correction-yaml.js +40 -0
- package/dist/app/editor/editorNodes.d.ts +9 -0
- package/dist/app/editor/editorNodes.js +36 -0
- package/dist/app/editor/index.d.ts +1 -0
- package/dist/app/editor/index.js +1 -0
- package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
- package/dist/app/editor/mark-overlap-resolver.js +45 -0
- package/dist/app/editor/nodes/C4Component.d.ts +14 -0
- package/dist/app/editor/nodes/C4Component.js +284 -0
- package/dist/app/editor/nodes/C4Node.d.ts +32 -0
- package/dist/app/editor/nodes/C4Node.js +112 -0
- package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
- package/dist/app/editor/nodes/CalloutNode.js +94 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
- package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
- package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
- package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
- package/dist/app/editor/nodes/CustomListNode.js +54 -0
- package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
- package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
- package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
- package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
- package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
- package/dist/app/editor/nodes/EquationComponent.js +130 -0
- package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
- package/dist/app/editor/nodes/EquationNode.js +179 -0
- package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
- package/dist/app/editor/nodes/FootnoteNode.js +151 -0
- package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
- package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
- package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
- package/dist/app/editor/nodes/HtmlNode.js +121 -0
- package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
- package/dist/app/editor/nodes/ImageComponent.js +228 -0
- package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
- package/dist/app/editor/nodes/ImageNode.js +183 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
- package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
- package/dist/app/editor/nodes/MermaidComponent.js +216 -0
- package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
- package/dist/app/editor/nodes/MermaidNode.js +96 -0
- package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
- package/dist/app/editor/nodes/ToggleNode.js +237 -0
- package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
- package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
- package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
- package/dist/app/editor/nodes/diagram-utils.js +29 -0
- package/dist/app/editor/nodes/index.d.ts +30 -0
- package/dist/app/editor/nodes/index.js +17 -0
- package/dist/app/editor/utils.d.ts +2 -0
- package/dist/app/editor/utils.js +17 -0
- package/dist/app/mapper/index.d.ts +3 -0
- package/dist/app/mapper/index.js +2 -0
- package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
- package/dist/app/mapper/lexicalToMdast.js +1937 -0
- package/dist/app/mapper/mdastToLexical.d.ts +31 -0
- package/dist/app/mapper/mdastToLexical.js +1075 -0
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +8 -0
- package/dist/headless.d.ts +40 -0
- package/dist/headless.js +45 -0
- package/dist/host/context.d.ts +20 -0
- package/dist/host/context.js +24 -0
- package/dist/host/defaults.d.ts +21 -0
- package/dist/host/defaults.js +47 -0
- package/dist/host/messages.d.ts +26 -0
- package/dist/host/messages.js +47 -0
- package/dist/host/types.d.ts +75 -0
- package/dist/host/types.js +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51 -0
- package/dist/markdown/annotate-sentinels.d.ts +42 -0
- package/dist/markdown/annotate-sentinels.js +86 -0
- package/dist/markdown/parse.d.ts +56 -0
- package/dist/markdown/parse.js +516 -0
- package/dist/markdown/stringify.d.ts +7 -0
- package/dist/markdown/stringify.js +439 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.js +44 -0
- package/dist/mathjax-config.d.ts +89 -0
- package/dist/mathjax-config.js +133 -0
- package/dist/nodes.d.ts +34 -0
- package/dist/nodes.js +34 -0
- package/dist/stores/correctionStore.d.ts +15 -0
- package/dist/stores/correctionStore.js +12 -0
- package/dist/stores/editorStore.d.ts +16 -0
- package/dist/stores/editorStore.js +27 -0
- package/dist/styles.css +2477 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.js +91 -0
- package/dist/utils/file-types.d.ts +10 -0
- package/dist/utils/file-types.js +46 -0
- package/docs/annotations.md +134 -0
- package/docs/decisions/README.md +72 -0
- package/docs/decisions/adr-002.md +27 -0
- package/docs/decisions/adr-007.md +27 -0
- package/docs/decisions/adr-010.md +26 -0
- package/docs/decisions/adr-024.md +124 -0
- package/docs/decisions/adr-024b.md +0 -0
- package/docs/decisions/adr-025.md +103 -0
- package/docs/decisions/adr-027.md +66 -0
- package/docs/decisions/adr-057.md +67 -0
- package/docs/decisions/adr-070.md +131 -0
- package/docs/decisions/adr-075.md +410 -0
- package/docs/decisions/adr-076.md +183 -0
- package/docs/decisions/adr-077.md +410 -0
- package/docs/decisions/adr-078.md +243 -0
- package/docs/decisions/adr-079.md +167 -0
- package/docs/decisions/adr-080.md +325 -0
- package/docs/decisions/adr-081.md +108 -0
- package/docs/decisions/adr-082.md +123 -0
- package/docs/decisions/adr-083.md +204 -0
- package/docs/decisions/adr-084.md +179 -0
- package/docs/editor-api.md +106 -0
- package/docs/markdown-pipeline.md +282 -0
- package/docs/provenance.md +106 -0
- package/docs/zusammen-editor-capability-map.md +232 -0
- package/package.json +175 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# ADR-075: The `@liminis/editor` Package Boundary Is Drawn at Persistence
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-03
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Issue:** #938
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The Lexical markdown WYSIWYG editor — the controlled `<Editor>` component, ~18
|
|
10
|
+
custom node types, ~25 plugins, the C4 diagram subsystem, the mdast⇄Lexical
|
|
11
|
+
mappers, and a 100-fixture round-trip corpus — lived embedded in
|
|
12
|
+
`liminis-app/src/editor/**`.
|
|
13
|
+
|
|
14
|
+
A near-identical *copy* of that tree was grafted into the Zusammen project and
|
|
15
|
+
the two have been diverging: Zusammen accreted a review-commenting feature and
|
|
16
|
+
markdown idempotency fixes Liminis lacks, while Liminis accreted an AI
|
|
17
|
+
**correction** feature and a post-#896 fixture corpus Zusammen lacks. Every
|
|
18
|
+
future fix to the shared core had to be hand-ported in both directions or
|
|
19
|
+
silently lost.
|
|
20
|
+
|
|
21
|
+
Making the editor one package with a curated public API is what stops that. But
|
|
22
|
+
a move that leaves the host reach-ins in place is a directory rename, not an
|
|
23
|
+
extraction — and the reach-ins were substantial:
|
|
24
|
+
|
|
25
|
+
- `messaging-electron` imported directly by `App.tsx`, `ImagePlugin`,
|
|
26
|
+
`LinkClickPlugin` and `MermaidComponent`.
|
|
27
|
+
- `window.api.*` reached into by `AnchorScrollPlugin`,
|
|
28
|
+
`WikiLinkExistencePlugin`, and `CorrectionPanelPlugin` (7 sites).
|
|
29
|
+
- `shared/logger`, which branches on `process.type` and imports `node:fs`.
|
|
30
|
+
- `toast` from `sonner`.
|
|
31
|
+
|
|
32
|
+
The question this ADR settles is **where the seam goes**, because everything
|
|
33
|
+
else — which files move, what the public entry exposes, what gets injected —
|
|
34
|
+
follows from it.
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
**The package/app seam is drawn at persistence.**
|
|
39
|
+
|
|
40
|
+
Anything about text ranges, marks, rendering, and in-document UX is
|
|
41
|
+
**package-side**. Storage, lifecycle, identity, and higher-level panels are
|
|
42
|
+
**app-side**. The editor package reaches the host only through injected
|
|
43
|
+
services; it never reaches for a global.
|
|
44
|
+
|
|
45
|
+
Four consequences, each load-bearing:
|
|
46
|
+
|
|
47
|
+
### 1. A closed set of declared export subpaths — the `exports` map *is* the public surface
|
|
48
|
+
|
|
49
|
+
`packages/editor/package.json` declared exactly five entries when this ADR was
|
|
50
|
+
written. ADR-077 added a sixth, `./annotations`, and ADR-079 added a seventh,
|
|
51
|
+
`./nodes`, both as *considered* exports under the rule stated below; the table
|
|
52
|
+
is kept current here so this record stays the single inventory of the public
|
|
53
|
+
surface:
|
|
54
|
+
|
|
55
|
+
| Subpath | Purpose |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `.` | The React surface: `<App>`, `<Editor>`, host provider/hook, markdown pipeline, message contract, stores, `findSvgElement` |
|
|
58
|
+
| `./markdown` | Markdown parsing alone: `parseMarkdown`, the mdast type guards, `getFileType`. No DOM, no Lexical, no MathJax |
|
|
59
|
+
| `./headless` | DOM-free: C4 parse/layout/`renderC4DiagramToSVG`, the MathJax **lite** document factory, `getFileType` |
|
|
60
|
+
| `./annotations` | DOM-, React- and Lexical-free annotation mechanics: anchor model, resolver, block structure, span alignment, annotation/kind types (added by ADR-077) |
|
|
61
|
+
| `./contract` | The zod host-message contract alone, for `import type` at the preload boundary |
|
|
62
|
+
| `./nodes` | `editorNodes` (the exact Lexical node array `<Editor>` configures itself with) plus `importMarkdownToLexical`/`exportLexicalToMdast`, for building a headless Lexical editor (added by ADR-079) |
|
|
63
|
+
| `./styles.css` | The stylesheet |
|
|
64
|
+
|
|
65
|
+
FR-002's "single curated public entry" is read as *nothing may be imported that
|
|
66
|
+
is not declared in `exports`*. Several subpaths rather than one is forced, not
|
|
67
|
+
stylistic:
|
|
68
|
+
|
|
69
|
+
- The Electron **main** process declares `lib: ["ES2024"]` and no DOM
|
|
70
|
+
(`tsconfig.main.json`). It consumes `renderC4DiagramToSVG` (for the
|
|
71
|
+
`app_render_c4_diagram` MCP tool and the remote-session route) and the MathJax
|
|
72
|
+
lite adaptor. A single React barrel would make `tsc` follow the whole graph and
|
|
73
|
+
typecheck Lexical against a DOM-free lib set. **If `./headless` ever fails to
|
|
74
|
+
compile under `tsconfig.main.json`, prune that entry's import graph — never
|
|
75
|
+
widen main's `lib`.**
|
|
76
|
+
|
|
77
|
+
**The DOM-free contract is a convention, not a compiler-enforced invariant, and
|
|
78
|
+
reviewers should not assume otherwise.** Main's declared `lib` does not actually
|
|
79
|
+
make `Document`/`HTMLElement` unresolvable: something already in `src/main`'s own
|
|
80
|
+
dependency graph pulls the DOM lib in, so those globals resolve inside the main
|
|
81
|
+
program today. (Verified two ways: a `(d: Document) => d.body` probe compiles
|
|
82
|
+
under `tsconfig.main.json`, while the same probe against `types: ["node"]` alone
|
|
83
|
+
fails with `TS2304`. This predates the extraction — main's include list and
|
|
84
|
+
dependencies are unchanged by it.) The practical consequence is that a DOM-typed
|
|
85
|
+
export added to `./headless` would compile silently instead of failing the build.
|
|
86
|
+
For that reason `createBrowserAdaptorDocument` and `BrowserMathJaxInstance` —
|
|
87
|
+
typed over `Document` and `MathDocument<HTMLElement>` — are deliberately **not**
|
|
88
|
+
on this entry; their only consumer is `EquationComponent.tsx` inside the package,
|
|
89
|
+
which imports them by relative path. Restoring real enforcement (a separate
|
|
90
|
+
DOM-free tsconfig project over `./headless`) is still worth doing. It was
|
|
91
|
+
originally deferred to #940; #940 gave the package a build but deliberately
|
|
92
|
+
scoped itself to delivery and did not take this on. It needs a follow-up.
|
|
93
|
+
|
|
94
|
+
One **known exception** is worth naming rather than leaving to be discovered:
|
|
95
|
+
`./headless` re-exports `createLiteAdaptorDocument` from `mathjax-config.ts`,
|
|
96
|
+
and that module statically imports `browserAdaptor` alongside `liteAdaptor` at
|
|
97
|
+
top level. So loading this entry does evaluate a DOM-adaptor module — the
|
|
98
|
+
convention is already bent, not merely bendable. It is *not* a regression from
|
|
99
|
+
the extraction: before it, `main/remote-session/routes.ts` imported
|
|
100
|
+
`shared/mathjax-config` directly and pulled in the identical graph, so main's
|
|
101
|
+
runtime imports are unchanged. Splitting the module into lite and browser
|
|
102
|
+
halves is the real fix, alongside the enforcement above — doing it here would
|
|
103
|
+
change main's module graph under a parity constraint. Originally deferred to
|
|
104
|
+
#940, which scoped itself to delivery and did not take it on; it needs the same
|
|
105
|
+
follow-up.
|
|
106
|
+
- `preload/index.ts` and `types/window-api.d.ts` need only the message shapes.
|
|
107
|
+
`./contract` is often shorthanded as "the zod-free entry"; it is not. It
|
|
108
|
+
re-exports `./types`, which imports zod and uses it for real runtime validation
|
|
109
|
+
(`validateHostToUIMessage`). What actually keeps zod out of the preload bundle
|
|
110
|
+
is that both consumers use `import type`, which erases at build time — the
|
|
111
|
+
narrow subpath only limits how much would come along if one of them ever
|
|
112
|
+
didn't. `editor-package-wiring.test.ts` pins that `import type` discipline,
|
|
113
|
+
since nothing in the exports map can.
|
|
114
|
+
- `styles.css` is not code.
|
|
115
|
+
- `./markdown` exists because the root barrel exports `<App>`/`<Editor>`, so
|
|
116
|
+
importing *anything* from it drags Lexical, MathJax, Mermaid and the C4
|
|
117
|
+
subsystem into the importing window. `MarkdownSnippet` needs only
|
|
118
|
+
`parseMarkdown` and `getFileType`, and is reachable from the `graph` and
|
|
119
|
+
`workflow` renderer entry points, which host no editor at all. Measured on the
|
|
120
|
+
emitted bundles: barrel ≈ 4.76 MB / 4.35 MB of eagerly `modulepreload`ed
|
|
121
|
+
script for those two windows, versus 2.49 MB / 2.08 MB through `./markdown`.
|
|
122
|
+
`./headless` is *not* an adequate home for these helpers — it also re-exports
|
|
123
|
+
`mathjax-config`, whose ~90 bare `import '@mathjax/src/…Configuration.js'`
|
|
124
|
+
lines are genuinely side-effectful and unshakeable by any consumer, leaving a
|
|
125
|
+
~1.94 MB `mathjax-config` chunk in place. Enforced by
|
|
126
|
+
`editor-package-wiring.test.ts`, which fails if any app file's editor imports
|
|
127
|
+
are wholly covered by `./markdown` yet come from the barrel.
|
|
128
|
+
|
|
129
|
+
### 2. Host services are injected via React context, with a safe default for every member
|
|
130
|
+
|
|
131
|
+
`EditorHostServices` (in `src/host/types.ts`) carries an optional `bridge`,
|
|
132
|
+
`logger`, `resolveWikiLinks`, `onScrollToAnchor`, `notifyError`, and
|
|
133
|
+
`corrections`. It is supplied through `<EditorHostProvider services={…}>` and
|
|
134
|
+
read with `useEditorHost()`.
|
|
135
|
+
|
|
136
|
+
Context rather than prop drilling: there are five separate editor mount sites in
|
|
137
|
+
`liminis-app` and ~20 deeply nested plugins; threading a services object through
|
|
138
|
+
`App` → `Editor` → every plugin would be a large, error-prone diff. A
|
|
139
|
+
module-level `configureEditorHost()` singleton was rejected as untestable in
|
|
140
|
+
parallel and hostile to a second consumer.
|
|
141
|
+
|
|
142
|
+
Every member has a safe default (`src/host/defaults.ts`), so `<Editor>` renders
|
|
143
|
+
in a host that supplies nothing. Two default *shapes* are used deliberately:
|
|
144
|
+
|
|
145
|
+
- `bridge`, `logger` and `notifyError` fall back to a concrete no-op/console
|
|
146
|
+
implementation.
|
|
147
|
+
- `resolveWikiLinks`, `onScrollToAnchor` and `corrections` stay **undefined**
|
|
148
|
+
when absent, because their consumers treat "service missing" as "feature
|
|
149
|
+
unavailable, leave the document alone". That is exactly what the pre-extraction
|
|
150
|
+
`if (!window.api?…) return` guards did; substituting a stub that returns `{}`
|
|
151
|
+
would, for instance, mark every wiki-link broken.
|
|
152
|
+
|
|
153
|
+
### 3. The host bridge has two methods; the message helpers are package-side
|
|
154
|
+
|
|
155
|
+
`EditorHostBridge` is only `postMessage` + `addMessageHandler`. The helpers
|
|
156
|
+
(`requestInit`, `requestSettings`, `applyTextEdits`, `writeAsset`, `openLink`)
|
|
157
|
+
were moved *into* the package and rebuilt on `postMessage`.
|
|
158
|
+
|
|
159
|
+
This makes parity structural rather than hoped-for: every host adapter emits
|
|
160
|
+
byte-identical wire payloads because there is only one place that constructs
|
|
161
|
+
them. `liminis-app/src/renderer/editor-host.ts` is now a thin adapter over
|
|
162
|
+
`window.api.editor`, and it is the successor to the deleted
|
|
163
|
+
`src/editor/messaging-electron.ts`.
|
|
164
|
+
|
|
165
|
+
**One deliberate non-parity change came with that move.** The adapter's
|
|
166
|
+
handler-leak guard was carried over verbatim at first, and review found it
|
|
167
|
+
unsound in two ways that only bite at scale, so it was repaired rather than
|
|
168
|
+
preserved:
|
|
169
|
+
|
|
170
|
+
- It counted **unique function identities** in a `Set`, but every call opens its
|
|
171
|
+
own `onMessage` subscription. Two registrations of one reference were two
|
|
172
|
+
subscriptions counted as one, and the first disposer released both.
|
|
173
|
+
- Past the limit it **threw**. The only caller is a React effect, so the throw
|
|
174
|
+
propagated into render and unmounted the editor. This was reachable in ordinary
|
|
175
|
+
use, not just in a leak: each mounted `<App>` registers two handlers (`App.tsx`
|
|
176
|
+
and `ImagePlugin.tsx`), and `canvasStore.expandCard(…, multiExpand)` expands an
|
|
177
|
+
unbounded number of note cards simultaneously — so six expanded cards exceeded
|
|
178
|
+
the budget of ten and took the tree down.
|
|
179
|
+
|
|
180
|
+
A first repair replaced the throw with a log and a refused registration. Review
|
|
181
|
+
caught that this only traded a loud failure for a silent one: refusing means
|
|
182
|
+
returning a no-op disposer *without opening the subscription*, so that editor goes
|
|
183
|
+
deaf to host messages — including `ASSET_WRITTEN`, which makes image paste fail
|
|
184
|
+
with no error anywhere. The same six-expanded-cards path reaches it.
|
|
185
|
+
|
|
186
|
+
**The budget is therefore a diagnostic, not admission control.** The guard counts
|
|
187
|
+
live subscriptions, releases idempotently, holds no slot when there is no preload
|
|
188
|
+
API, and — past the budget — logs and *still registers*. A genuine leak is still
|
|
189
|
+
loud in the log; it just no longer degrades the editor to say so.
|
|
190
|
+
|
|
191
|
+
This is a behaviour change against FR-005's parity constraint, taken knowingly: the
|
|
192
|
+
prior behaviour was a crash, the defect is pre-existing on `main` rather than
|
|
193
|
+
introduced here, and the guard is diagnostic rather than a product behaviour.
|
|
194
|
+
Covered by `liminis-app/src/renderer/__tests__/editor-host.test.ts`, whose
|
|
195
|
+
delivery-past-the-budget and idempotent-release cases both fail against the refusing
|
|
196
|
+
implementation.
|
|
197
|
+
|
|
198
|
+
### 4. Reconciliation with existing ADRs
|
|
199
|
+
|
|
200
|
+
**ADR-057 (Knowledge Corrections — Renderer→Filesystem→MCP Pipeline).** The
|
|
201
|
+
correction *panel* is in-editor UI and therefore package-side; the pipeline
|
|
202
|
+
ADR-057 describes is persistence and therefore app-side. ADR-057's flow is
|
|
203
|
+
unchanged in behaviour but now **crosses the package boundary** via
|
|
204
|
+
`CorrectionHostServices`:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
interface CorrectionHostServices {
|
|
208
|
+
readCorrections(): Promise<string | null> // raw YAML text, or null
|
|
209
|
+
writeCorrections(yaml: string): Promise<void> // host owns mkdir + atomic write
|
|
210
|
+
suggestEntities(query: string, numResults: number): Promise<string[]>
|
|
211
|
+
suggestPassages(query: string, numResults: number, minScore: number): Promise<string[]>
|
|
212
|
+
applyCorrections(): Promise<boolean>
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`.liminis/knowledge-corrections.yaml`, the atomic write, and both
|
|
217
|
+
`knowledge-reader`/`knowledge-writer` MCP calls all still live in
|
|
218
|
+
`liminis-app` — read ADR-057's "renderer" as "`liminis-app`'s renderer host
|
|
219
|
+
adapter", not "the editor component". Two orderings ADR-057 and ADR-042 depend
|
|
220
|
+
on are preserved verbatim inside the package and must stay that way:
|
|
221
|
+
`suggestEntities` is awaited *before* `suggestPassages` (the MCP stdio invoker
|
|
222
|
+
rejects concurrent calls to one server), and the confirm path re-reads, merges,
|
|
223
|
+
then writes atomically.
|
|
224
|
+
|
|
225
|
+
**ADR-025 (Zustand for renderer state).** Two zustand stores now live inside a
|
|
226
|
+
package rather than under `liminis-app/src/renderer/stores/`. ADR-025's
|
|
227
|
+
substance is unchanged — state is still zustand, not window events — but the
|
|
228
|
+
store's *location* may now be a package. The ADR-024/025/032 guidance in
|
|
229
|
+
`.claude/rules/electron-renderer.md` is scoped to `liminis-app` renderer paths
|
|
230
|
+
the editor has left, so it needs to be carried across to `packages/editor/` as a
|
|
231
|
+
path-scoped rule per ADR-034 — see "Outstanding" below.
|
|
232
|
+
|
|
233
|
+
**ADR-002, ADR-010, ADR-070, ADR-007, ADR-027** are unaffected: the editor is
|
|
234
|
+
still Lexical, the mapper design is unchanged, the C4/node types are unchanged,
|
|
235
|
+
the contextBridge remains the app-side adapter, and the package stays inside the
|
|
236
|
+
repo's vitest story with its own config.
|
|
237
|
+
|
|
238
|
+
## Deviations from the spec's letter, and why
|
|
239
|
+
|
|
240
|
+
Three of the spec's file-placement instructions did not survive contact with the
|
|
241
|
+
code. Each deviation follows the persistence seam this ADR establishes, or the
|
|
242
|
+
curated-surface principle, which are the spec's own stated principles.
|
|
243
|
+
|
|
244
|
+
**`correctionStore` moves package-side** (FR-006 says it stays in
|
|
245
|
+
`liminis-app`). It holds only ephemeral panel UI state — `isOpen`, `position`,
|
|
246
|
+
`selectedText` — persists nothing, and has zero consumers outside the two editor
|
|
247
|
+
plugins. Injecting it would be prop-drilling purely to satisfy a file location.
|
|
248
|
+
FR-006's *substance* — that correction persistence and lifecycle stay app-side —
|
|
249
|
+
is honoured in full by `CorrectionHostServices`.
|
|
250
|
+
|
|
251
|
+
Because that justification rests entirely on "no consumer outside the two editor
|
|
252
|
+
plugins", `useCorrectionStore` is deliberately **not** re-exported from `.`.
|
|
253
|
+
Publishing it would invite the app-side consumer that invalidates the reasoning,
|
|
254
|
+
with nothing at the package boundary to catch it. `useEditorStore` *is* public —
|
|
255
|
+
`EditorColumn` drives the frontmatter tray from the app toolbar — so the two
|
|
256
|
+
stores are not symmetric, and that asymmetry is the point.
|
|
257
|
+
|
|
258
|
+
**`mathjax-config.ts` moves package-side but is exposed on `./headless`**, and
|
|
259
|
+
`fileTypes.ts` **splits**. `getFileType` (used by `mdastToLexical` and
|
|
260
|
+
`MarkdownShortcutsPlugin`) moved to `packages/editor/src/utils/file-types.ts`;
|
|
261
|
+
`toWorkspaceRelativePath` has no editor consumer at all and stayed in
|
|
262
|
+
`liminis-app/src/renderer/utils/fileTypes.ts`. `mathjax-config`'s existing test
|
|
263
|
+
moved with it — the spec's manifest omitted it, and dropping it would have been
|
|
264
|
+
silent coverage loss.
|
|
265
|
+
|
|
266
|
+
**`messaging.ts` was deleted rather than moved.** The spec listed the vestigial
|
|
267
|
+
VS Code webview adapter among the files that move, and the Plan kept it as a
|
|
268
|
+
notional second `EditorHostBridge` implementation. It is neither. It has no
|
|
269
|
+
importer anywhere in the repo, it implements the *old* ad-hoc API rather than
|
|
270
|
+
`EditorHostBridge`, it self-invokes `initMessageListener()` at module load
|
|
271
|
+
(attaching a `window` listener the moment anything imports it), and it carries
|
|
272
|
+
the very throw-on-overflow handler logic that decision 3 above deliberately
|
|
273
|
+
replaced with a diagnostic counter. Shipping it would have contradicted this
|
|
274
|
+
ADR's claim that `host/messages.ts` is the single place wire payloads are
|
|
275
|
+
constructed.
|
|
276
|
+
|
|
277
|
+
Its `VsCodeApi` interface and the `declare global { interface Window {
|
|
278
|
+
acquireVsCodeApi } }` augmentation in `types.ts` went with it. That augmentation
|
|
279
|
+
was the concrete harm: `export * from './types'` in both `src/index.ts` and
|
|
280
|
+
`src/contract.ts` published it, so every consumer of `.` **and** `./contract` —
|
|
281
|
+
including the Electron preload, which uses `./contract` precisely to avoid
|
|
282
|
+
unrelated surface — had `window.acquireVsCodeApi` typed as globally available in
|
|
283
|
+
a host where it never exists. Verified by probe: before the removal a consumer
|
|
284
|
+
calling `window.acquireVsCodeApi()` typechecked cleanly; after it, the same
|
|
285
|
+
probe fails with `TS2339`.
|
|
286
|
+
|
|
287
|
+
## Consequences
|
|
288
|
+
|
|
289
|
+
### Positive
|
|
290
|
+
|
|
291
|
+
- One implementation of the editor, with a curated API, ready for Zusammen to
|
|
292
|
+
adopt in place of its copy — which is what ends the divergence.
|
|
293
|
+
- The package can be reasoned about, and tested, with no knowledge of Electron.
|
|
294
|
+
`<Editor>` renders with zero host services supplied.
|
|
295
|
+
- The correction feature's persistence contract is now explicit and stubbable,
|
|
296
|
+
where previously it was seven scattered `window.api` calls.
|
|
297
|
+
- #939 (unified annotation mechanism) becomes a contained design problem rather
|
|
298
|
+
than a design problem tangled up with a 182-file relocation.
|
|
299
|
+
|
|
300
|
+
### Negative / costs
|
|
301
|
+
|
|
302
|
+
- **Four build-config landmines had to be defused, and they stay defused only
|
|
303
|
+
by intent:**
|
|
304
|
+
1. Tailwind v4 automatic source detection always skips `node_modules`, and the
|
|
305
|
+
package resolves through the `node_modules/@liminis/editor` symlink. Without
|
|
306
|
+
an explicit `@source "../../../../packages/editor/src"` in
|
|
307
|
+
`src/renderer/styles/main.css`, the editor's ~165 utility classes silently
|
|
308
|
+
stop being generated — with no typecheck, lint, build, or test signal.
|
|
309
|
+
2. `externalizeDepsPlugin()` on the `main`/`preload` targets must exclude
|
|
310
|
+
`@liminis/editor`, or the emitted bundle keeps a bare
|
|
311
|
+
`import '@liminis/editor/headless'` that resolves to unbuilt TypeScript at
|
|
312
|
+
runtime.
|
|
313
|
+
3. The package needs its own `typecheck`/`lint`/`test` scripts or `pnpm -r`
|
|
314
|
+
skips 74 source files, and CI needs its own test step or the 100 relocated
|
|
315
|
+
fixtures stop running while the suite stays green.
|
|
316
|
+
4. **The package must NOT declare `"sideEffects"`.** This one is a trap rather
|
|
317
|
+
than a chore, and it was walked into during review before being walked back
|
|
318
|
+
out. Barrel weight (see subpath `./markdown` above) makes a `sideEffects`
|
|
319
|
+
declaration look like the obvious one-line fix. It is not: declaring it
|
|
320
|
+
breaks the renderer outright.
|
|
321
|
+
|
|
322
|
+
Marking our modules side-effect-free does not merely prune our own graph —
|
|
323
|
+
it changes rolldown's whole-graph chunking. Under it, `prismjs` (reached
|
|
324
|
+
via `@lexical/code`) has its core separated from `prism-clike.js`, which
|
|
325
|
+
mutates a bare `Prism` global that is by then undefined:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
ReferenceError: Prism is not defined (prism-clike.js, renderer entry)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The renderer never mounts. `document.body.innerText` is empty, and e2e went
|
|
332
|
+
from green to 138 failures spanning chat, command palette, content editing
|
|
333
|
+
and semantic search — nothing editor-specific, because every locator misses
|
|
334
|
+
when nothing renders. Bisected to the commit that added the key; removing
|
|
335
|
+
only that key restores a green suite. The hazard lives in a **transitive
|
|
336
|
+
dependency**, so it cannot be expressed in our own `sideEffects` field at
|
|
337
|
+
all: `["**/*.css"]` and `false` fail identically. Reduce barrel weight with
|
|
338
|
+
a narrow entry point instead.
|
|
339
|
+
|
|
340
|
+
**Closed by #940, in favour of the prohibition.** The theory behind the
|
|
341
|
+
deferral was that a consumer of *built* output would have a different module
|
|
342
|
+
graph and might legitimately need the field. It does not: #940's build is a
|
|
343
|
+
structure-preserving `tsc` emit that does no chunking of its own, so an
|
|
344
|
+
external consumer's bundler sees the same graph liminis-app's does. The
|
|
345
|
+
hazard is identical, not different. The measurement the field was wanted for
|
|
346
|
+
targets the `React.lazy()` boundary on `AnnotationSurface` instead and does
|
|
347
|
+
not depend on it. See ADR-078 §5. This prohibition is permanent unless the
|
|
348
|
+
build strategy itself changes.
|
|
349
|
+
|
|
350
|
+
Landmines 1, 2 and 4 are guarded by
|
|
351
|
+
`liminis-app/src/shared/__tests__/editor-package-wiring.test.ts`. All three are
|
|
352
|
+
invisible to typecheck, lint, build and the rest of the suite — the build
|
|
353
|
+
reports success while the app degrades (landmine 4 being the exception that
|
|
354
|
+
proves the rule: it *is* caught, but only by e2e, an hour downstream) — which
|
|
355
|
+
is why they get a test that asserts the *configuration* rather than the
|
|
356
|
+
symptom.
|
|
357
|
+
- ~~The package is consumed as raw TypeScript in-workspace. A standalone ESM +
|
|
358
|
+
`.d.ts` build, peer dependencies, and resolving the patched
|
|
359
|
+
`mdast-util-wiki-link@0.1.2` are deferred to #940.~~ **Landed in #940 — see
|
|
360
|
+
ADR-078.** The package now builds to `dist/`; `publishConfig` points external
|
|
361
|
+
consumers at it while the checked-in manifest still resolves to `src/`, so
|
|
362
|
+
liminis-app's consumption — and both build guards above — are unchanged.
|
|
363
|
+
`react`/`react-dom`/`lexical` and all eleven `@lexical/*` are peers, and
|
|
364
|
+
`mdast-util-wiki-link` is vendored into the package with its patch applied
|
|
365
|
+
inline, so no pnpm patch survives.
|
|
366
|
+
- Ambient declarations (`jsx.d.ts` for React 19's non-global JSX namespace, and
|
|
367
|
+
the `micromark-extension-wiki-link` shim) are duplicated in
|
|
368
|
+
`packages/editor/src/ambient/` and `liminis-app/src/types/`, since both
|
|
369
|
+
programs need them and neither may depend on the other.
|
|
370
|
+
|
|
371
|
+
## Outstanding
|
|
372
|
+
|
|
373
|
+
**`.claude/rules/editor-package.md` has not been written.** The Fabrik worker
|
|
374
|
+
environment denies writes under `.claude/`, so the path-scoped rule that carries
|
|
375
|
+
ADR-024/025/032 across to `packages/editor/**` (per ADR-034) could not land with
|
|
376
|
+
this ADR. Its drafted content is in PR #941's body and needs a human to commit
|
|
377
|
+
it. Until it exists, the guidance lives only here and in this ADR's "Decision"
|
|
378
|
+
section.
|
|
379
|
+
|
|
380
|
+
## Alternatives Considered
|
|
381
|
+
|
|
382
|
+
**A single root barrel export.** Rejected: it forces the DOM-free main process
|
|
383
|
+
program to typecheck Lexical and `react-dom/client`.
|
|
384
|
+
|
|
385
|
+
**Leaving `messaging-electron.ts` in the package and stubbing `window.api` in
|
|
386
|
+
tests.** Rejected: it is precisely the coupling the extraction exists to remove,
|
|
387
|
+
and it would have kept the package's test setup mocking a host global.
|
|
388
|
+
|
|
389
|
+
**Keeping the message helpers app-side, with a six-method bridge.** Rejected:
|
|
390
|
+
six adapter methods per host is six opportunities to drift from the wire format,
|
|
391
|
+
where two methods plus package-side helpers cannot drift at all.
|
|
392
|
+
|
|
393
|
+
**A general-purpose third-party plugin API.** Explicitly out of scope. The
|
|
394
|
+
public entry is a curated export surface, not an extension mechanism; #939 bakes
|
|
395
|
+
annotations *into* the package rather than exposing hooks for hosts to extend.
|
|
396
|
+
See ADR-077, which carries that out and adds `./annotations` as a sixth,
|
|
397
|
+
considered subpath under the rule stated above.
|
|
398
|
+
|
|
399
|
+
## References
|
|
400
|
+
|
|
401
|
+
- ADR-079 (headless mapper/node export) — adds `./nodes` as a seventh,
|
|
402
|
+
considered subpath under the rule stated above
|
|
403
|
+
- ADR-077 (comments and corrections as two kinds of one annotation mechanism) —
|
|
404
|
+
extends this seam and adds the `./annotations` subpath
|
|
405
|
+
- Issue #938 (this phase), #939 (unified annotation mechanism), #940 (OSS hardening)
|
|
406
|
+
- `specs/938-extract-the-lexical-markdown/spec.md`
|
|
407
|
+
- ADR-002 (Lexical), ADR-010 (bidirectional markdown conversion), ADR-025
|
|
408
|
+
(zustand for renderer state), ADR-042 (MCP stdio serialization), ADR-057
|
|
409
|
+
(knowledge corrections pipeline), ADR-070 (dedicated Lexical node types)
|
|
410
|
+
- `.claude/rules/editor-package.md` (not yet written — see "Outstanding")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# ADR-076: Markdown Serialization Is a Fixed Point, at the Cost of a One-Time Canonicalization
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-04
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Issue:** #943
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Liminis's markdown pipeline —
|
|
10
|
+
`parseMarkdown → importMarkdownToLexical → exportLexicalToMdast → stringifyMarkdown` —
|
|
11
|
+
runs on every open and save. Until now the round-trip corpus asserted only that
|
|
12
|
+
one pass was faithful: input → output matched either the input itself or a
|
|
13
|
+
recorded `.expected.md` sidecar.
|
|
14
|
+
|
|
15
|
+
That guarantee is weaker than it looks. It says nothing about whether the
|
|
16
|
+
*output* is stable. A serializer can be perfectly faithful on the first pass and
|
|
17
|
+
still drift on the second, which in practice means a document nobody edited
|
|
18
|
+
keeps changing on disk every time it is opened and saved — invisible in the
|
|
19
|
+
WYSIWYG view, visible in git diffs, and cumulative.
|
|
20
|
+
|
|
21
|
+
Two genuine defects had been living in `stringify.ts` behind exactly that gap.
|
|
22
|
+
Both were found and fixed in Zusammen's forked copy of the file
|
|
23
|
+
([verveguy/zusammen#62](https://github.com/verveguy/zusammen/pull/62), closing
|
|
24
|
+
its #50) and were still live in Liminis:
|
|
25
|
+
|
|
26
|
+
1. **A blank line before a list was silently dropped.** A `join` override
|
|
27
|
+
implements a "label:" convention — a paragraph ending in `:` gets zero blank
|
|
28
|
+
lines before the block that follows, so `Fenced code:` hugs its fence.
|
|
29
|
+
`list` was wrongly in that set, so `Steps to reproduce:` followed by a blank
|
|
30
|
+
line and a bulleted list lost the blank line, unlike every other
|
|
31
|
+
preceding-block type.
|
|
32
|
+
|
|
33
|
+
2. **A post-process regex corrupted verbatim content.** `stringify.ts` ended
|
|
34
|
+
with `result.replace(/\n{3,}/g, '\n\n')`. `mdast-util-to-markdown`'s own join
|
|
35
|
+
logic already never emits 3+ newlines between sibling blocks, so this had no
|
|
36
|
+
legitimate job left. Its only real effect was destroying intentional
|
|
37
|
+
multi-blank-line runs *inside fenced code blocks and frontmatter YAML block
|
|
38
|
+
scalars* — content that must survive byte-for-byte.
|
|
39
|
+
|
|
40
|
+
ADR-075 drew the `@liminis/editor` package boundary and named "markdown
|
|
41
|
+
idempotency fixes Liminis lacks" as precisely the divergence the extraction
|
|
42
|
+
existed to stop. Because the serializer now has a single home, this is a
|
|
43
|
+
one-site fix rather than a hand-port to two repositories.
|
|
44
|
+
|
|
45
|
+
## Decision
|
|
46
|
+
|
|
47
|
+
**Serialization output is a fixed point.** Every fixture's own first-pass output
|
|
48
|
+
is round-tripped a second time and must be byte-identical. This applies to
|
|
49
|
+
fixtures whose first pass legitimately changes bytes (`.expected.md` sidecars)
|
|
50
|
+
as well as byte-identical ones — matching a sidecar says nothing about whether
|
|
51
|
+
that sidecar's content is itself stable.
|
|
52
|
+
|
|
53
|
+
**We accept a one-time canonicalization to get there.** Removing `list` from the
|
|
54
|
+
colon-join override is a user-visible behaviour change: a document containing a
|
|
55
|
+
colon-ending paragraph immediately followed by a list *gains* a blank line on its
|
|
56
|
+
first save after this ships.
|
|
57
|
+
|
|
58
|
+
**Exemptions are individually documented, never batched.** A fixture with a
|
|
59
|
+
known, accepted non-idempotency carries a `<name>.idempotence-exempt.txt`
|
|
60
|
+
sidecar whose contents are the written reason. An exemption file that exists but
|
|
61
|
+
is empty is a hard error in fixture discovery — the reason is mandatory, which
|
|
62
|
+
makes "skip it to get green" impossible to do silently.
|
|
63
|
+
|
|
64
|
+
**No repo-wide normalization pass.** Documents canonicalize as they are saved.
|
|
65
|
+
|
|
66
|
+
### Which document shapes change on first save
|
|
67
|
+
|
|
68
|
+
Only one, and only in one direction:
|
|
69
|
+
|
|
70
|
+
| Shape | Before | After |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Colon-ending paragraph immediately followed by a list (no blank line) | stays zero blank lines | gains one blank line |
|
|
73
|
+
| Colon-ending paragraph, blank line, then a list | blank line **dropped** | blank line preserved |
|
|
74
|
+
| Colon-ending paragraph before a table, fenced code, or block math | zero blank lines | **unchanged** — zero blank lines |
|
|
75
|
+
| 2+ consecutive blank lines inside a fenced code block or YAML block scalar | **collapsed to one** | preserved verbatim |
|
|
76
|
+
| 2+ consecutive blank lines between sibling blocks | collapsed to one | unchanged — still collapsed, by the library's own join logic |
|
|
77
|
+
|
|
78
|
+
The last row is the one worth stating explicitly: removing the regex did *not*
|
|
79
|
+
let 3+ newlines through. Blank-line runs between flow siblings are discarded at
|
|
80
|
+
*parse* time — mdast has no representation for them — so the collapse still
|
|
81
|
+
happens without any post-process help. This is pinned by fixtures
|
|
82
|
+
(`50-multiple-blank-lines-between-paragraphs`,
|
|
83
|
+
`50-multiple-blank-lines-before-list`, and the three-blank-line runs inside
|
|
84
|
+
`50-reference-readme`).
|
|
85
|
+
|
|
86
|
+
## Consequences
|
|
87
|
+
|
|
88
|
+
### Positive
|
|
89
|
+
|
|
90
|
+
- A saved document that nobody edits stops changing on disk. This is the
|
|
91
|
+
structural guarantee that would have caught both defects, and it now catches
|
|
92
|
+
the next one automatically for every fixture in the corpus.
|
|
93
|
+
- Code and configuration inside fenced blocks and YAML block scalars survive
|
|
94
|
+
verbatim. This was the more serious of the two defects: silently corrupting
|
|
95
|
+
the code a note documents is worse than a cosmetic diff.
|
|
96
|
+
- The fix lands in `@liminis/editor`, so when Zusammen adopts the package (#940)
|
|
97
|
+
it stops being something anyone has to hand-port.
|
|
98
|
+
|
|
99
|
+
### Negative
|
|
100
|
+
|
|
101
|
+
- **A visible one-time diff in users' notebooks.** Any real document with a
|
|
102
|
+
colon-ending paragraph immediately followed by a list gains a blank line the
|
|
103
|
+
first time it is saved after this ships. This is accepted deliberately: it
|
|
104
|
+
makes lists behave like every other preceding-block type, and the alternative
|
|
105
|
+
— preserving the defect to avoid the diff — means the drift never stops.
|
|
106
|
+
- The round-trip corpus is now the slowest suite in the workspace: the second
|
|
107
|
+
pass roughly doubles its work across the 100 non-exempt fixtures (105 inputs
|
|
108
|
+
less the 5 exemptions below). "Non-exempt" means exactly one thing — the
|
|
109
|
+
fixture runs the second-pass assertion. It is not a claim about fidelity: the
|
|
110
|
+
23 `known-defects/` fixtures are all non-exempt and all pass, because a round
|
|
111
|
+
trip that loses content still reaches a fixed point (see the Neutral note
|
|
112
|
+
below). The only other category outside the assertion is `.error.txt`
|
|
113
|
+
fixtures, which reject on the first pass and so have no output to feed back;
|
|
114
|
+
the corpus has none today.
|
|
115
|
+
|
|
116
|
+
### Neutral
|
|
117
|
+
|
|
118
|
+
- Five fixtures are exempt, all one pre-existing cause: #902's double-hard-break
|
|
119
|
+
marker serializes as a whitespace-only line, which CommonMark re-reads as
|
|
120
|
+
blank, splitting the paragraph on the second pass. Fixing that hard-break
|
|
121
|
+
normalization is out of scope here and needs its own issue. The exemption set
|
|
122
|
+
was determined by observation — the harness landed before the fixes so the
|
|
123
|
+
pre-fix failure set could be recorded — not by prediction.
|
|
124
|
+
- The `known-defects/` fixtures continue to assert current defective behaviour.
|
|
125
|
+
None of them needed an exemption: asserting a *defective* output does not stop
|
|
126
|
+
that output from being a stable fixed point.
|
|
127
|
+
- **A fixed point is a guarantee about stability, not fidelity.** This is the
|
|
128
|
+
limit of what the second pass buys, and it is worth stating plainly so the
|
|
129
|
+
guarantee is not over-read. A round trip that silently *drops* content reaches
|
|
130
|
+
a fixed point immediately and passes — `known-defects/other-blockquote-list-
|
|
131
|
+
content-lost`, found during this issue's review, is the live example: a list
|
|
132
|
+
inside a blockquote is lost entirely, and the lossy output is perfectly
|
|
133
|
+
stable. The first-pass `.expected.md` comparison is what catches fidelity
|
|
134
|
+
loss. That defect is pre-existing, lives in the mapper rather than the
|
|
135
|
+
serializer, and needs its own issue.
|
|
136
|
+
- `stringify.test.ts`'s "collapse excessive blank lines" case still passes with
|
|
137
|
+
the post-process deleted, since its input is two plain paragraphs. It was
|
|
138
|
+
retitled rather than removed — it now pins the `mdast-util-to-markdown` join
|
|
139
|
+
guarantee that removing the regex rests on, so a library upgrade that breaks
|
|
140
|
+
that guarantee fails loudly instead of silently letting blank-line runs
|
|
141
|
+
through. Review broadened it from that single paragraph pair to 14 sibling-
|
|
142
|
+
block pairs (heading, fenced code, list, table, blockquote, thematic break,
|
|
143
|
+
block math, HTML block, nested list, frontmatter), so the guarantee is pinned
|
|
144
|
+
where it is actually load-bearing.
|
|
145
|
+
- The colon detection only fires when the paragraph's final inline node is a
|
|
146
|
+
plain `text` node, so `**Steps:**` is not treated as colon-ending. After this
|
|
147
|
+
change that distinction is unobservable for lists — both shapes get one blank
|
|
148
|
+
line — but it remains observable for `table`/`code`/`math`, where
|
|
149
|
+
`**Fenced code:**` keeps its blank line and `Fenced code:` does not. That
|
|
150
|
+
asymmetry is pre-existing and deliberately unchanged; both sides are pinned by
|
|
151
|
+
the `943-non-text-colon-paragraph-*` fixtures.
|
|
152
|
+
|
|
153
|
+
## Alternatives Considered
|
|
154
|
+
|
|
155
|
+
**Make the regex verbatim-span-aware instead of removing it.** Rejected: it
|
|
156
|
+
would have to know about fenced code, inline code, math spans and frontmatter
|
|
157
|
+
block scalars — the same span-tracking complexity that already makes the
|
|
158
|
+
neighbouring post-processes fragile — to implement a guarantee the library
|
|
159
|
+
already provides for free.
|
|
160
|
+
|
|
161
|
+
**Keep `list` in the colon-join override and exempt the fixtures.** Rejected:
|
|
162
|
+
this is the reported defect, not an accepted normalization. Exempting it would
|
|
163
|
+
be exactly the "batch-exempt to get green" failure the sidecar convention exists
|
|
164
|
+
to prevent.
|
|
165
|
+
|
|
166
|
+
**A repo-wide normalization pass over existing `.md` documents.** Rejected: it
|
|
167
|
+
would bury the actual fix in a large, undifferentiated diff, and it forces the
|
|
168
|
+
one-time change on documents the user may not have opened in months. Letting
|
|
169
|
+
documents canonicalize as they are saved keeps each diff small and attributable.
|
|
170
|
+
|
|
171
|
+
**Amend ADR-075 rather than write a new ADR.** Rejected: ADR-075 is Accepted and
|
|
172
|
+
scoped to the package boundary. This is a distinct, user-visible behaviour
|
|
173
|
+
decision; folding it into an accepted structural ADR muddies both records.
|
|
174
|
+
|
|
175
|
+
## References
|
|
176
|
+
|
|
177
|
+
- Issue #943; [verveguy/zusammen#62](https://github.com/verveguy/zusammen/pull/62) (the original fix)
|
|
178
|
+
- ADR-075 — the `@liminis/editor` package boundary that made this a single-site fix
|
|
179
|
+
- ADR-070 — dedicated Lexical node types for lossless round-tripping; this extends
|
|
180
|
+
its guarantee from "one pass is faithful" to "one pass is a fixed point"
|
|
181
|
+
- `packages/editor/src/markdown/stringify.ts` — both fixes
|
|
182
|
+
- `packages/editor/src/app/mapper/__tests__/fixtures/roundtrip/README.md` — the
|
|
183
|
+
fixture, sidecar and exemption conventions
|