@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,123 @@
|
|
|
1
|
+
# ADR-082: `examples/demo` Is the Public Pages Site, and Its Documentation Is Generated from `README.md`
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-15
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #3 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
This package's `README.md` is adequate for someone who has already decided to
|
|
12
|
+
adopt it; it does nothing for someone deciding *whether* to. Because
|
|
13
|
+
`@liminis/editor`'s distinguishing claim — that what you type round-trips
|
|
14
|
+
byte-for-byte to markdown on disk — cannot be shown in prose, this issue adds
|
|
15
|
+
a public GitHub Pages site built around a live editor.
|
|
16
|
+
|
|
17
|
+
verveguy/liminis-editor#2 had already grown `examples/demo` into exactly that
|
|
18
|
+
shape: a fixture picker over the shared round-trip corpus, live-editing with
|
|
19
|
+
`onChange` markdown state, importing only the package's public entry points.
|
|
20
|
+
Two questions followed from reusing it:
|
|
21
|
+
|
|
22
|
+
1. Does the Pages site wrap `examples/demo` as a new top-level app (e.g.
|
|
23
|
+
`site/`), or does `examples/demo` become the deployed artifact directly?
|
|
24
|
+
2. FR-005–FR-009 require the site to document the seven subpath exports,
|
|
25
|
+
fourteen peer dependencies, the `sideEffects` landmine (ADR-075 §4), the
|
|
26
|
+
`0.x` versioning policy, and the annotation mechanism (ADR-077) — where
|
|
27
|
+
does that content live, given `README.md` already states five of those six
|
|
28
|
+
points accurately in prose?
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
### 1. `examples/demo` *is* the Pages site, not a wrapper around it
|
|
33
|
+
|
|
34
|
+
`scripts/build-site.mjs` builds `examples/demo` and only `examples/demo` —
|
|
35
|
+
the same directory `pnpm demo` and `scripts/build-examples.mjs` already
|
|
36
|
+
build — and its output (`examples/demo/dist`) is uploaded directly as the
|
|
37
|
+
Pages artifact by `.github/workflows/pages.yml`.
|
|
38
|
+
|
|
39
|
+
A new `site/` app importing the demo shell as a component was considered and
|
|
40
|
+
rejected: it would either duplicate the fixture-picker UI or add an
|
|
41
|
+
indirection layer for no functional gain, and it would turn FR-004's "one
|
|
42
|
+
shared artifact, not a duplicated implementation" into something to police
|
|
43
|
+
by discipline rather than something structurally true. With this decision, a
|
|
44
|
+
contributor editing "the demo" is, by construction, also editing the public
|
|
45
|
+
site — there is no second file tree that can silently drift from it.
|
|
46
|
+
|
|
47
|
+
Two additions were made directly to `examples/demo` to carry the site's own
|
|
48
|
+
requirements: a version badge (`App.jsx`, sourced from
|
|
49
|
+
`VITE_LIMINIS_EDITOR_VERSION`, set by the Pages workflow from the triggering
|
|
50
|
+
release's `tag_name` rather than `package.json`'s checked-in, pre-publish
|
|
51
|
+
version) and a Demo/Documentation view toggle.
|
|
52
|
+
|
|
53
|
+
### 2. The Documentation view renders `README.md`, generated, not re-authored
|
|
54
|
+
|
|
55
|
+
`scripts/generate-demo-docs.mjs` reads the root `README.md` at build time and
|
|
56
|
+
writes a gitignored `examples/demo/src/docs-content.generated.js`, which
|
|
57
|
+
`examples/demo/src/Docs.jsx` renders with `react-markdown` + `remark-gfm`.
|
|
58
|
+
This mirrors the pattern `scripts/generate-demo-fixtures.mjs` established for
|
|
59
|
+
the fixture corpus (ADR-081): a build-time codegen step, not a runtime import
|
|
60
|
+
reaching outside `examples/demo`, and a gitignored output regenerated by
|
|
61
|
+
`pnpm demo`, `pnpm build:examples`, and `pnpm build:site` alike so no code
|
|
62
|
+
path can consume a stale copy.
|
|
63
|
+
|
|
64
|
+
A hand-written `site/docs/*.md` was considered and rejected. Five of the six
|
|
65
|
+
required documentation points (exports, peer dependencies, annotations) were
|
|
66
|
+
already accurate prose in `README.md`; re-deriving them by hand in a second
|
|
67
|
+
location would have been exactly the duplication ADR-081 avoided for
|
|
68
|
+
fixtures, with the same drift risk. The two genuine content gaps — a
|
|
69
|
+
Lexical-free column on the exports table, and the `sideEffects` landmine and
|
|
70
|
+
versioning-policy sections — were instead written into `README.md` itself,
|
|
71
|
+
so the published npm tarball's own shipped documentation improves along with
|
|
72
|
+
the site, rather than the site accumulating content the tarball doesn't
|
|
73
|
+
carry.
|
|
74
|
+
|
|
75
|
+
`tests/adr-citations.test.ts`'s scan of `README.md` + `docs/**` (excluding
|
|
76
|
+
`docs/decisions/`) is not extended to also cover
|
|
77
|
+
`docs-content.generated.js`. Because the generated file is a verbatim copy of
|
|
78
|
+
`README.md`, a dangling `ADR-NNN` citation is caught at the source before it
|
|
79
|
+
ever reaches the generated copy; scanning the copy too would just be a second
|
|
80
|
+
check of the same string.
|
|
81
|
+
|
|
82
|
+
## Consequences
|
|
83
|
+
|
|
84
|
+
**Good:**
|
|
85
|
+
|
|
86
|
+
- FR-004/SC-004 ("one shared artifact") holds by construction: there is one
|
|
87
|
+
`App.jsx`, one `package.json`, one build output, used by `pnpm demo`,
|
|
88
|
+
`pnpm build:examples`, and the Pages deploy alike.
|
|
89
|
+
- FR-005–FR-009's six documentation points have exactly one copy each, in
|
|
90
|
+
`README.md`, already covered by the existing ADR-citation guard.
|
|
91
|
+
- Editing `README.md` to keep it accurate for npm/GitHub browsing
|
|
92
|
+
automatically keeps the Pages site's Documentation tab accurate too, with
|
|
93
|
+
no second edit to remember.
|
|
94
|
+
|
|
95
|
+
**Bad / accepted:**
|
|
96
|
+
|
|
97
|
+
- `examples/demo`'s `README.md` and source now serve two audiences (a local
|
|
98
|
+
package-demo reader and a public site visitor) rather than one; a change
|
|
99
|
+
made for one has to be considered against the other, though in practice
|
|
100
|
+
the two have stayed aligned so far.
|
|
101
|
+
- Rendering all of `README.md` on the Documentation tab means any section
|
|
102
|
+
that assumes an npm/GitHub reading context (for example, the "This package
|
|
103
|
+
is not published" install-warning language, current as of this decision)
|
|
104
|
+
reads on the public site exactly as written — no site-specific editing
|
|
105
|
+
layer exists to soften it. This self-corrects whenever `README.md` itself
|
|
106
|
+
is updated for the eventual publish flip; it is not something this
|
|
107
|
+
decision defers solving, since no site-only content divergence was wanted
|
|
108
|
+
in the first place.
|
|
109
|
+
- `docs-content.generated.js`, like `fixtures.generated.js`, is gitignored,
|
|
110
|
+
so a reviewer checking "does the Documentation tab still render correctly"
|
|
111
|
+
after a `README.md` edit has to run the generator locally rather than
|
|
112
|
+
reading it in the PR diff.
|
|
113
|
+
|
|
114
|
+
## References
|
|
115
|
+
|
|
116
|
+
- Issue #3 (this decision), verveguy/liminis-editor#2 (the web shell this
|
|
117
|
+
decision reuses as the site)
|
|
118
|
+
- `docs/decisions/adr-081.md` (the codegen pattern this decision applies a
|
|
119
|
+
second time, to `README.md` instead of the fixture corpus)
|
|
120
|
+
- `docs/decisions/adr-075.md` §4 (the `sideEffects` landmine now documented
|
|
121
|
+
in `README.md` and, by extension, on the site)
|
|
122
|
+
- `docs/decisions/adr-077.md` (the annotation mechanism now documented in
|
|
123
|
+
`README.md` and, by extension, on the site)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ADR-083: Backslash-Escape Provenance Is Captured at Parse Time and Carried Through as a Per-Character Style Hint
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-16
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #17 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
A backslash-escaped underscore lost its escape on a no-edit round trip
|
|
12
|
+
(`A backslash\_underscore` → `A backslash_underscore`), while a backslash-escaped
|
|
13
|
+
asterisk on the same line did not. This is not cosmetic: the escape exists to stop
|
|
14
|
+
`_` from being read as an emphasis delimiter, and dropping it silently, on a save
|
|
15
|
+
where the user changed nothing, is the same failure class as #973 (markdown
|
|
16
|
+
serializer non-idempotence).
|
|
17
|
+
|
|
18
|
+
The root cause is structural, not a stray regex bug. `fromMarkdown` (standard
|
|
19
|
+
CommonMark parsing) discards escape provenance at parse time: `\_underscore` and
|
|
20
|
+
`_underscore` both decode to the identical mdast `text` value
|
|
21
|
+
(`"_underscore"`) — there is no flag anywhere in mdast or Lexical recording "this
|
|
22
|
+
character had a backslash in front of it in the original file." Two existing
|
|
23
|
+
`stringify.ts` post-processes (intraword-underscore-unescape, added for #901;
|
|
24
|
+
bracket-preservation, added for #921) strip mdast-util-to-markdown's own
|
|
25
|
+
conservative default escaping of `_`/`[`/`]` back off, because CommonMark doesn't
|
|
26
|
+
always require escaping those characters and the library over-escapes them by
|
|
27
|
+
default. Both post-processes operate purely on the shape of the final string, so
|
|
28
|
+
they cannot distinguish "the library added this escape and it's unneeded" from
|
|
29
|
+
"the user wrote this escape and meant it" — the two cases are byte-identical once
|
|
30
|
+
parsed. `\*` never had this problem only because CommonMark's own rule requires
|
|
31
|
+
escaping `*` unconditionally in the position it appears, so mdast-util-to-markdown
|
|
32
|
+
re-escapes it regardless, and no Liminis post-process ever touches it.
|
|
33
|
+
|
|
34
|
+
Two designs were available for recovering the lost provenance:
|
|
35
|
+
|
|
36
|
+
1. Embed an invisible Private-Use-Area marker character directly in Lexical's
|
|
37
|
+
live text content at parse/import time, stripped back off at stringify time —
|
|
38
|
+
mirroring `annotate-sentinels.ts`'s existing PUA-sentinel mechanism.
|
|
39
|
+
2. Capture provenance at parse time, before anything else touches the tree, and
|
|
40
|
+
carry it through Lexical as metadata attached to the `TextNode` rather than
|
|
41
|
+
embedded in its text content — restoring the real backslash only inside a
|
|
42
|
+
single, transient `stringifyMarkdown` call.
|
|
43
|
+
|
|
44
|
+
## Decision
|
|
45
|
+
|
|
46
|
+
### 1. Capture at parse time; never re-derive downstream
|
|
47
|
+
|
|
48
|
+
`splitTextNodeEscapes` (called by `splitEscapedPunctuation`, the last step of
|
|
49
|
+
`parseMarkdown`, after `annotateEmphasisMarkers`) replay-decodes each `text`
|
|
50
|
+
node's raw source span character-by-character — consuming a backslash plus one
|
|
51
|
+
of CommonMark's escapable ASCII punctuation characters as a single decoded
|
|
52
|
+
character, exactly as `fromMarkdown` itself does — and compares the result
|
|
53
|
+
against `node.value`. Whenever they don't match exactly (an HTML entity or other
|
|
54
|
+
untracked transform sharing the span), the node is left untouched rather than
|
|
55
|
+
split at a possibly-wrong offset: a missed protection reproduces today's
|
|
56
|
+
pre-existing defect, but a wrong split would corrupt text. This mirrors
|
|
57
|
+
`recordOffsetSpan`'s own width-mismatch bail-out in `mdastToLexical.ts`, an
|
|
58
|
+
existing precedent for the same "decline rather than guess" trade-off.
|
|
59
|
+
|
|
60
|
+
Otherwise, the node is split into siblings at every backslash-escaped instance
|
|
61
|
+
of the seven characters this issue scopes (`* _ \` [ ] # \`, per the issue's own
|
|
62
|
+
named set): untouched runs stay ordinary `text` nodes, and each protected
|
|
63
|
+
character becomes its own single-character `text` node carrying
|
|
64
|
+
`data._forceEscape = true`. This is deliberately uniform across all seven
|
|
65
|
+
characters, not special-cased to the two or three Research suspected were
|
|
66
|
+
actually broken — a character the library already escapes correctly (`*`,
|
|
67
|
+
backtick, line-start `#`, a lone `\`) simply flows through the same mechanism
|
|
68
|
+
with no behavior change, since it only ever *preserves* an escape the user
|
|
69
|
+
actually wrote and never invents one. That by-construction property is what
|
|
70
|
+
satisfies the "must not over-escape" requirement, rather than a per-character
|
|
71
|
+
heuristic that could be wrong for an untested case.
|
|
72
|
+
|
|
73
|
+
### 2. Carry as a per-character `TextNode` style hint, not a live-text sentinel
|
|
74
|
+
|
|
75
|
+
Design (1) above — a PUA marker spliced into editable Lexical text — was
|
|
76
|
+
rejected. Unlike `annotate-sentinels.ts`'s existing use, which is scoped to a
|
|
77
|
+
single, transient export call, this marker would persist in the actual
|
|
78
|
+
*editable* document for the whole session whenever the user opens and edits the
|
|
79
|
+
file, not just a one-shot round trip: risk of a stray PUA character surviving
|
|
80
|
+
into cursor placement, selection, copy/paste, spellcheck, or a newly-typed
|
|
81
|
+
document abutting it.
|
|
82
|
+
|
|
83
|
+
Instead, `setForceEscape` (`mdastToLexical.ts`'s `convertText`) sets a
|
|
84
|
+
`--md-force-escape:1` style hint on the `TextNode`, mirroring the established
|
|
85
|
+
`--md-emphasis-marker`/`--md-strong-marker` precedent that already carries the
|
|
86
|
+
source's original `_`/`*` marker character through the same round trip. This has
|
|
87
|
+
zero footprint on editable content. Both `convertTextNode` (the single-node
|
|
88
|
+
export path) and `convertFormattedRun` (the merged-run path taken when a node
|
|
89
|
+
sits inside emphasis/strong, or when a live annotation mark forces adjacent
|
|
90
|
+
same-format siblings to merge) read the hint in `lexicalToMdast.ts` — an escaped
|
|
91
|
+
character inside emphasis/strong (e.g. `*text \_x\_*`) always takes the
|
|
92
|
+
merged-run path even on a pure no-edit round trip, so reading the hint in only
|
|
93
|
+
one of the two would leave that adjacency case, called out explicitly in the
|
|
94
|
+
issue's edge cases, unfixed.
|
|
95
|
+
|
|
96
|
+
### 3. Restore via a transient placeholder and a dedicated node type, not by editing the existing regexes
|
|
97
|
+
|
|
98
|
+
Making the two existing blind-strip post-processes provenance-aware — teaching
|
|
99
|
+
them to recognize a real backslash as "leave alone" — was considered and
|
|
100
|
+
rejected. Their patterns have no per-node knowledge; they operate on the final
|
|
101
|
+
concatenated string. Testing confirmed that simply restoring the real backslash
|
|
102
|
+
before those regexes run still lets the bracket-preservation regex (which has no
|
|
103
|
+
adjacency/flanking guard, unlike the underscore one) misfire and strip a
|
|
104
|
+
genuinely user-escaped `\[...\]` pair — stacking two independent escape
|
|
105
|
+
decisions that can disagree.
|
|
106
|
+
|
|
107
|
+
Instead, `convertForceEscapeTextNodes` (a `stringify.ts` pre-process) converts
|
|
108
|
+
every `{type: 'text', data: {_forceEscape: true}}` node into a dedicated
|
|
109
|
+
`escapedChar` node, whose custom `toMarkdown` handler emits a transient,
|
|
110
|
+
backslash-free placeholder (`\u{E004}<char>\u{E004}`, distinct from
|
|
111
|
+
`annotate-sentinels.ts`'s `E000`-`E003` range, module-local to `stringify.ts`
|
|
112
|
+
and never exported) instead of running the character through the normal
|
|
113
|
+
text-escaping path. Being backslash-free, the placeholder is provably invisible
|
|
114
|
+
to both existing post-processes during their pass, so **neither one needed to
|
|
115
|
+
change**. Only after every other post-process has already run does a final step
|
|
116
|
+
replace each placeholder-wrapped character with a real `\<char>`, unconditionally
|
|
117
|
+
restoring the backslash regardless of whatever the default handler emitted
|
|
118
|
+
underneath. The placeholder never touches Lexical or live document state — like
|
|
119
|
+
`annotate-sentinels.ts`'s own tokens, it exists only inside a single
|
|
120
|
+
`stringifyMarkdown` call, on a mdast tree reconstructed fresh from Lexical at
|
|
121
|
+
export time and discarded immediately after.
|
|
122
|
+
|
|
123
|
+
Two adjacent force-escaped characters in the source (e.g. `\*\*`) can reach this
|
|
124
|
+
pre-process already fused into one multi-character node, because Lexical's own
|
|
125
|
+
reconciliation merges adjacent `TextNode`s sharing identical format and style,
|
|
126
|
+
and two independent single-character force-escape nodes share the same
|
|
127
|
+
`--md-force-escape:1` style. Since only single-character force-escaped nodes are
|
|
128
|
+
ever produced upstream, every character of a fused node was independently
|
|
129
|
+
force-escaped, so the pre-process expands such a node back into one `escapedChar`
|
|
130
|
+
node per character rather than assuming length 1.
|
|
131
|
+
|
|
132
|
+
An annotate-serialize sentinel token (see `annotate-sentinels.ts`, ADR-077's
|
|
133
|
+
underlying mechanism) can also land inside a force-escaped node's text, if that
|
|
134
|
+
exact `TextNode` instance happens to be a live annotation mark's boundary leaf.
|
|
135
|
+
The pre-process uses a new `splitOnSentinelTokens` helper (added to
|
|
136
|
+
`annotate-sentinels.ts`, alongside its existing `stripAnnotateSentinels`) to
|
|
137
|
+
split those parts out first and leave them as plain, unescaped text — otherwise
|
|
138
|
+
the token's own id characters would themselves come back out individually
|
|
139
|
+
backslash-escaped, breaking annotate mode's "differs from a plain export only by
|
|
140
|
+
its sentinel tokens" invariant (#970) for any fixture containing a force-escaped
|
|
141
|
+
character.
|
|
142
|
+
|
|
143
|
+
## Consequences
|
|
144
|
+
|
|
145
|
+
**Good:**
|
|
146
|
+
|
|
147
|
+
- `\*`, `\_`, `` \` ``, `\[`, `\]`, `\#`, and `\\` all round-trip byte-identically
|
|
148
|
+
in a meaningful context, and the fix is a fixed point on a second pass (ADR-076)
|
|
149
|
+
— see the `17-*` fixture group in
|
|
150
|
+
`src/app/mapper/__tests__/fixtures/roundtrip/README.md`.
|
|
151
|
+
- The fix is provably non-over-escaping by construction: it only ever restores a
|
|
152
|
+
backslash the source actually had, never adds one, so `898-underscore-link.md`
|
|
153
|
+
(`mac_onboarding.sh`) and the bracket-related `known-defects/` fixtures needed
|
|
154
|
+
no changes.
|
|
155
|
+
- Neither existing blind-strip post-process was modified, eliminating the risk
|
|
156
|
+
(flagged during planning) of two independent escape-decision mechanisms
|
|
157
|
+
disagreeing on the same character.
|
|
158
|
+
- `recordOffsetSpan`'s width-mismatch bail-out in `mdastToLexical.ts` needed no
|
|
159
|
+
code change — splitting a text node at escape boundaries is exactly the kind
|
|
160
|
+
of source-width/decoded-length mismatch it was already designed to decline
|
|
161
|
+
gracefully on, one sub-span at a time instead of one whole node at a time. This
|
|
162
|
+
is a net improvement to `offset-spans.test.ts`'s existing coverage: a plain
|
|
163
|
+
run of text beside an escape now gets a valid, usable span where previously the
|
|
164
|
+
entire containing node was dropped.
|
|
165
|
+
|
|
166
|
+
**Bad / accepted:**
|
|
167
|
+
|
|
168
|
+
- The escaped-backslash-then-bare-underscore edge case (`\\_`) is not
|
|
169
|
+
byte-identical on the first pass: the restored literal backslash is not
|
|
170
|
+
alphanumeric, so the pipeline's own pre-existing, pre-#17 conservative default
|
|
171
|
+
(intraword-underscore-unescape only fires when *both* flanking characters are
|
|
172
|
+
alphanumeric) leaves the following bare underscore escaped too. This is not a
|
|
173
|
+
new over-escape introduced by this fix — `898-underscore-link.md` already
|
|
174
|
+
accepts the same conservative policy for other non-alphanumeric-flanked cases
|
|
175
|
+
— so `17-escaped-backslash-then-underscore.md` carries an `.expected.md`
|
|
176
|
+
sidecar rather than asserting byte-identity, per ADR-076's normalization
|
|
177
|
+
allowance. The second pass is still a fixed point.
|
|
178
|
+
- A character reference (e.g. `&`) anywhere in the same `fromMarkdown`-merged
|
|
179
|
+
text run as an escaped target character suppresses protection for the whole
|
|
180
|
+
run, not just the entity reference's own span, because `fromMarkdown` merges
|
|
181
|
+
entity references and surrounding literal text into one `text` node before
|
|
182
|
+
`splitTextNodeEscapes` ever sees it. This is a narrow, defect-shaped
|
|
183
|
+
limitation — not a regression, since no protection existed there before this
|
|
184
|
+
fix either — and is documented at the bail-out site in `parse.ts` and covered
|
|
185
|
+
by a dedicated test in `parse.test.ts`.
|
|
186
|
+
- `convertTextNode` and `convertFormattedRun` in `lexicalToMdast.ts` now
|
|
187
|
+
duplicate a third piece of marker-reading logic (alongside the existing
|
|
188
|
+
`--md-strong-marker`/`--md-emphasis-marker` reads), increasing the surface
|
|
189
|
+
area that could drift out of sync in a future change to one but not the other.
|
|
190
|
+
Accepted because it follows an established, already-reviewed pattern in this
|
|
191
|
+
codebase rather than inventing a new one.
|
|
192
|
+
|
|
193
|
+
## References
|
|
194
|
+
|
|
195
|
+
- Issue #17 (this decision)
|
|
196
|
+
- `docs/decisions/adr-076.md` (the fixed-point round-trip guarantee this fix
|
|
197
|
+
upholds, and the `.expected.md`/normalization allowance used for the `\\_`
|
|
198
|
+
edge case)
|
|
199
|
+
- `docs/decisions/adr-077.md` (the annotation mechanism whose sentinel tokens
|
|
200
|
+
this fix's placeholder must stay distinguishable from)
|
|
201
|
+
- `src/app/mapper/__tests__/fixtures/roundtrip/README.md`, `## The 973-* fixture
|
|
202
|
+
set` section (the `--md-emphasis-marker`/`--md-strong-marker` style-hint
|
|
203
|
+
precedent this fix's `--md-force-escape` hint mirrors) and `## The 17-*
|
|
204
|
+
fixture set` section (this fix's own as-built documentation)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# ADR-084: Ordered Task-Item Checkboxes Are Rendered by `CustomListItemNode` Itself, Never by Lexical's `listType === 'check'` Gate
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-16
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #12 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
An ordered task item (`1. [ ] Task one`) used to render its checkbox marker
|
|
12
|
+
as literal `[ ] `/`[x] ` text prepended to the item's first paragraph by
|
|
13
|
+
`convertListItem` (`src/app/mapper/mdastToLexical.ts`), because
|
|
14
|
+
`@lexical/list`'s checklist rendering — DOM attributes/classes, click
|
|
15
|
+
handling (`@lexical/react`'s `CheckListPlugin`), and keyboard handling — is
|
|
16
|
+
entirely gated on the *parent list's* `listType` being `'check'`, and
|
|
17
|
+
Lexical has no `'check'`-typed list that is also numbered. The per-item
|
|
18
|
+
checked state was already stored correctly and independently on
|
|
19
|
+
`CustomListItemNode.__taskChecked` (added for #905/#914's mixed-list
|
|
20
|
+
problem); only the *rendering* declined to use it.
|
|
21
|
+
|
|
22
|
+
Reading the pinned `@lexical/list@0.44.0`/`@lexical/react@0.44.0` source
|
|
23
|
+
directly (rather than trusting its public API surface) confirmed every
|
|
24
|
+
mechanism a checkbox needs is private and individually keyed off
|
|
25
|
+
`listType === 'check'`:
|
|
26
|
+
|
|
27
|
+
- `ListItemNode.updateListItemDOM`/`updateListItemChecked`/
|
|
28
|
+
`$setListItemThemeClassNames` (DOM `role`/`aria-checked`/theme classes) —
|
|
29
|
+
all unexported.
|
|
30
|
+
- `registerCheckList`'s `handleCheckItemEvent`/`handleClick`/
|
|
31
|
+
`getActiveCheckListItem` (click and keyboard handling) — gated on the DOM
|
|
32
|
+
list element's `__lexicalListType === 'check'` property, unexported.
|
|
33
|
+
- `ListNode`'s own `$config().$transform` (`updateChildrenListItemValue`)
|
|
34
|
+
actively **resets** any list item's raw `__checked` back to `undefined`
|
|
35
|
+
whenever the parent list isn't `'check'`-typed — a live transform, not a
|
|
36
|
+
one-time check.
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
### 1. `CustomListItemNode` renders its own ordered-task checkbox, from `__taskChecked`
|
|
41
|
+
|
|
42
|
+
`CustomListItemNode.createDOM`/`updateDOM` call `super.createDOM`/
|
|
43
|
+
`super.updateDOM` first (stock behavior already strips `role`/`tabIndex`/
|
|
44
|
+
`aria-checked` for a non-`'check'` parent), then layer on a checkbox
|
|
45
|
+
affordance — `role="checkbox"`, `aria-checked`, and new
|
|
46
|
+
`editor-listitem-ordered-checked`/`-unchecked` CSS classes (applied
|
|
47
|
+
directly via `classList`, not wired through Lexical's `editorTheme` object
|
|
48
|
+
the way stock's own `listitemChecked`/`listitemUnchecked` are) — driven
|
|
49
|
+
directly by `this.__taskChecked`, gated on the parent list's `listType`
|
|
50
|
+
being `'number'` and the item being a leaf (mirroring stock's own
|
|
51
|
+
`!$isListNode(getFirstChild())` guard). This reuses the "dedicated
|
|
52
|
+
node-owned mechanism over a pattern hack" precedent already established by
|
|
53
|
+
ADR-070, applied here to a private-library gate rather than a markdown
|
|
54
|
+
pattern.
|
|
55
|
+
|
|
56
|
+
The alternative — finding some way to make Lexical's own `'check'`-typed
|
|
57
|
+
rendering also apply to a `'number'`-typed list — was not pursued: every
|
|
58
|
+
mechanism above is private, unexported, and would need forking, not
|
|
59
|
+
extending, and `ListNode`'s reset transform would still need to be defeated
|
|
60
|
+
regardless (see below).
|
|
61
|
+
|
|
62
|
+
New CSS classes (`editor-listitem-ordered-checked`/`-unchecked` in
|
|
63
|
+
`styles.css`) were required rather than reusing the existing
|
|
64
|
+
`editor-listitem-checked`/`-unchecked`: those set `list-style-type: none`
|
|
65
|
+
and pull the marker box back with a negative `margin-left`, which would
|
|
66
|
+
suppress `<ol>`'s native decimal numbering. The new classes instead claim
|
|
67
|
+
space via the item's own `padding-left`, leaving the native marker
|
|
68
|
+
undisturbed in the `<ol>`'s own padding area.
|
|
69
|
+
|
|
70
|
+
### 2. Toggling an ordered item's checkbox must call `setTaskChecked()` exclusively, never `setChecked()`/`toggleChecked()`
|
|
71
|
+
|
|
72
|
+
`OrderedTaskListPlugin` (`src/app/editor/OrderedTaskListPlugin.tsx`) — a new
|
|
73
|
+
root-element click listener, since `CheckListPlugin`'s own listener never
|
|
74
|
+
fires for a `'number'`-typed list — replicates `@lexical/list`'s own
|
|
75
|
+
`handleCheckItemEvent` hit-box geometry (`clientX` vs. the rendered
|
|
76
|
+
`::before` pseudo-element's width, via `calculateZoomLevel` from
|
|
77
|
+
`@lexical/utils`) so clicking anywhere across the visible checkbox toggles
|
|
78
|
+
it, matching the feel of the unordered checklist. On a hit, it calls
|
|
79
|
+
`node.setTaskChecked(!node.getTaskChecked())` inside `editor.update()`.
|
|
80
|
+
|
|
81
|
+
This is a hard rule, not a style preference. `ListNode`'s own transform
|
|
82
|
+
(`updateChildrenListItemValue`) resets any list item's *raw* `__checked`
|
|
83
|
+
field back to `undefined` on every reconciliation pass, for any list whose
|
|
84
|
+
`listType !== 'check'` — which every ordered list is. `CustomListItemNode`
|
|
85
|
+
overrides `setChecked()` to sync `__taskChecked` from the same argument (to
|
|
86
|
+
support the #905/#914 live-promotion case, where clicking an *unordered*
|
|
87
|
+
checklist's checkbox on a previously-plain item must promote it to a real
|
|
88
|
+
task). If an ordered item's toggle handler called `setChecked()` or
|
|
89
|
+
`toggleChecked()`, it would look correct on the triggering render and then
|
|
90
|
+
be silently reset — `__checked` back to `undefined`, cascading through the
|
|
91
|
+
override into `__taskChecked` too — on the very next reconciliation. The
|
|
92
|
+
importer (`convertListItem`) already avoided this by never writing raw
|
|
93
|
+
`__checked` for ordered items (`useChecked` stays `undefined`); the click
|
|
94
|
+
handler must preserve that invariant symmetrically. Because `__checked`
|
|
95
|
+
stays permanently `undefined` for ordered items under this rule, the reset
|
|
96
|
+
transform's own guard (`__checked != null`) never fires — no other code
|
|
97
|
+
path needed to change to avoid it.
|
|
98
|
+
|
|
99
|
+
### 3. `stringify.ts`'s `addCheckboxTextToOrderedLists` is left in place, unmodified, despite being redundant
|
|
100
|
+
|
|
101
|
+
The export side (`src/app/mapper/lexicalToMdast.ts`) already read
|
|
102
|
+
`getTaskChecked()` rather than the gated `getChecked()`, so once the
|
|
103
|
+
importer stopped prefixing marker text, `checkedForOutput` simply became
|
|
104
|
+
the item's real per-item state with no export-side code change needed.
|
|
105
|
+
|
|
106
|
+
Separately, `src/markdown/stringify.ts` contains its own workaround,
|
|
107
|
+
`addCheckboxTextToOrderedLists`, which manually rewrites an ordered list's
|
|
108
|
+
mdast tree to prepend literal `[ ]`/`[x] ` text before `toMarkdown()` runs,
|
|
109
|
+
on the documented premise that "GFM only outputs checkboxes for unordered
|
|
110
|
+
lists." That premise does not hold for this repo's pinned
|
|
111
|
+
`mdast-util-gfm-task-list-item@2.0.0` — its `listItemWithTaskListItem`
|
|
112
|
+
handler already matches `(?:[*+-]|\d+\.)` and emits a marker after either
|
|
113
|
+
kind of list marker natively. The function is therefore redundant, but not
|
|
114
|
+
incorrect: tracing a concrete example through the full pipeline confirmed
|
|
115
|
+
it still fires exactly once per checked/unchecked item and still produces
|
|
116
|
+
byte-identical output to the pre-fix behavior. Removing it was considered
|
|
117
|
+
and rejected for this issue — it has no required benefit within this
|
|
118
|
+
issue's scope (a rendering defect, not a serialization one) and would add
|
|
119
|
+
verification surface against a part of the pipeline this issue's spec
|
|
120
|
+
explicitly states does not need to change. It is left as a documented,
|
|
121
|
+
deliberate trap: a future contributor who finds it and "cleans it up"
|
|
122
|
+
without reading this ADR should not be surprised that doing so is safe on
|
|
123
|
+
its own (the GFM dependency already covers it) but was out of scope here.
|
|
124
|
+
|
|
125
|
+
## Consequences
|
|
126
|
+
|
|
127
|
+
**Good:**
|
|
128
|
+
|
|
129
|
+
- FR-001–FR-003 hold: an ordered task item renders a real checkbox with no
|
|
130
|
+
editable marker text, sourced from its own stored state, independent of
|
|
131
|
+
the parent list's rendering mode.
|
|
132
|
+
- FR-004/FR-005 hold: toggling works via a dedicated plugin and round-trips
|
|
133
|
+
to exactly one marker in the exported Markdown, with FR-006's fixed-point
|
|
134
|
+
guarantee unaffected — the state model and markdown layers needed no
|
|
135
|
+
changes.
|
|
136
|
+
- FR-007/FR-008 hold structurally, not by extra guarding: a plain item in a
|
|
137
|
+
mixed ordered list has `__taskChecked === null`, so
|
|
138
|
+
`CustomListItemNode`'s own gate already excludes it from checkbox
|
|
139
|
+
rendering, and unordered checklists are entirely untouched by any of this
|
|
140
|
+
(different classes, different plugin, same `CheckListPlugin` as before).
|
|
141
|
+
- The reset-transform trap is now a one-time, well-understood constraint
|
|
142
|
+
documented in one place (this ADR, plus inline comments at both
|
|
143
|
+
`CustomListItemNode.setChecked()` and `OrderedTaskListPlugin`) rather than
|
|
144
|
+
a bug waiting to be rediscovered by a future toggle-handling change.
|
|
145
|
+
|
|
146
|
+
**Bad / accepted:**
|
|
147
|
+
|
|
148
|
+
- Two independent checkbox rendering/interaction code paths now exist —
|
|
149
|
+
Lexical's own for unordered lists, this package's own for ordered lists —
|
|
150
|
+
rather than one. This is inherent to the constraint (Lexical's mechanisms
|
|
151
|
+
are private and gate-locked to `'check'`), not a choice that could be
|
|
152
|
+
designed away within this issue's scope.
|
|
153
|
+
- `OrderedTaskListPlugin`'s hit-box geometry duplicates `@lexical/list`'s
|
|
154
|
+
private `handleCheckItemEvent` formula by copying it, not importing it
|
|
155
|
+
(it isn't exported). A future `@lexical/list` upgrade that changes that
|
|
156
|
+
formula would need this plugin updated in step, silently, with no type
|
|
157
|
+
error to catch the drift.
|
|
158
|
+
- `addCheckboxTextToOrderedLists` remains dead-but-correct code with a
|
|
159
|
+
now-inaccurate docstring premise ("GFM only outputs checkboxes for
|
|
160
|
+
unordered lists"), left in place deliberately rather than fixed, which
|
|
161
|
+
future readers of that file alone (not this ADR) may find confusing.
|
|
162
|
+
|
|
163
|
+
## References
|
|
164
|
+
|
|
165
|
+
- Issue #12 (this decision)
|
|
166
|
+
- `docs/decisions/adr-070.md` ("Dedicated Lexical Node Types vs.
|
|
167
|
+
Paragraph-Pattern Detection") — the general precedent this decision
|
|
168
|
+
applies to a private-library rendering gate rather than a markdown
|
|
169
|
+
pattern.
|
|
170
|
+
- `docs/decisions/adr-076.md` ("Markdown Serialization Is a Fixed Point") —
|
|
171
|
+
the fixed-point guarantee FR-006 restates for this issue; no new
|
|
172
|
+
infrastructure was needed to satisfy it.
|
|
173
|
+
- `src/app/editor/nodes/CustomListItemNode.tsx` — `__taskChecked`, the
|
|
174
|
+
`createDOM`/`updateDOM` override, and the `setChecked()` override this
|
|
175
|
+
decision requires ordered-item toggling to route around.
|
|
176
|
+
- `src/app/editor/OrderedTaskListPlugin.tsx` — the click-to-toggle
|
|
177
|
+
implementation.
|
|
178
|
+
- `src/markdown/stringify.ts` — `addCheckboxTextToOrderedLists`, left
|
|
179
|
+
unmodified per this decision.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# `<Editor>` and the host seam
|
|
2
|
+
|
|
3
|
+
## `<Editor>`
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { Editor } from '@liminis/editor'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The editor takes markdown in and hands markdown back. Lexical state is an
|
|
10
|
+
implementation detail you never have to touch.
|
|
11
|
+
|
|
12
|
+
### Content
|
|
13
|
+
|
|
14
|
+
| Prop | Type | Notes |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `initialContent` | `string` | Markdown text. **Required.** |
|
|
17
|
+
| `onChange` | `(markdown: string) => void` | Fires on edit with the serialized markdown. **Required.** |
|
|
18
|
+
| `contentVersion` | `number` | Bump to force a re-import of `initialContent`. Without it, changing `initialContent` alone does not reload the document — that is deliberate, so a controlled `onChange` loop does not fight the user's cursor. |
|
|
19
|
+
| `editable` | `boolean` | Default `true`. `false` renders read-only. A `toolbar`-surfaced annotation create affordance (see the Annotations section of the package README) remains reachable via selection either way; formatting controls do not render when read-only. |
|
|
20
|
+
| `filePath` | `string` | The path of the file being edited. Drives file-type-specific UI (e.g. `.mdc`). |
|
|
21
|
+
|
|
22
|
+
### Cursor and selection
|
|
23
|
+
|
|
24
|
+
| Prop | Type | Notes |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `cursorToRestoreRef` | `RefObject<CursorState \| null>` | A ref, not a value — it is read inside effects, not during render, so restoring a cursor never triggers a render pass. |
|
|
27
|
+
| `onCursorChange` | `(cursor: CursorState) => void` | Fires as the caret moves. Pair with `cursorToRestoreRef` to persist cursor position across document switches. |
|
|
28
|
+
| `onSelectionContextMenu` | `(event: SelectionContextMenuEvent) => void` | Fires on context menu over a selection. Your app renders the menu. |
|
|
29
|
+
|
|
30
|
+
### Assets and links
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Notes |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `assetBaseUri` | `string` | Base URI images resolve against. |
|
|
35
|
+
| `documentDirUri` | `string` | URI of the directory containing the document, for relative-path resolution. |
|
|
36
|
+
| `imagePathResolution` | `ImagePathResolution` | How relative image paths are interpreted. |
|
|
37
|
+
| `resolveLocalAsset` | `(relativePath: string) => Promise<string \| null>` | Resolve a workspace-relative path to a data URL. Return `null` for "not found" — the editor renders a broken-image placeholder rather than throwing. |
|
|
38
|
+
|
|
39
|
+
### Corrections
|
|
40
|
+
|
|
41
|
+
| Prop | Type | Notes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `onSubstitutionDetected` | `(oldTerm: string, newTerm: string) => void` | Fires after a debounce when a single-word substitution is detected. |
|
|
44
|
+
| `sweepRef` | `MutableRefObject<SweepFn \| null>` | Populated with a sweep function while the ambient-correction plugin is active. |
|
|
45
|
+
|
|
46
|
+
### Annotations
|
|
47
|
+
|
|
48
|
+
All optional; supplying `annotationKinds` is what turns the mechanism on. See
|
|
49
|
+
[`annotations.md`](./annotations.md).
|
|
50
|
+
|
|
51
|
+
| Prop | Type | Notes |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `annotationKinds` | `AnnotationKindConfigs` | Per-kind configuration. Omit it and no annotation module is loaded. |
|
|
54
|
+
| `annotations` | `Annotation[]` | Host-supplied annotations to render markers for, already resolved. |
|
|
55
|
+
| `activeAnnotationId` | `string \| null` | Which annotation is active, for marker styling. |
|
|
56
|
+
| `scrollToAnnotation` | `{ id: string; nonce: number } \| null` | Host-driven scroll signal. Bump `nonce` to re-scroll to the same id. |
|
|
57
|
+
| `onCreateAnnotation` | `(event: AnnotationCreateEvent) => void` | Fires when a user creates one via a kind's create affordance. |
|
|
58
|
+
| `onActivateAnnotation` | `(id: string) => void` | Fires when a marker is activated. Your app opens its own panel. |
|
|
59
|
+
| `annotationEditorHandleRef` | `MutableRefObject<AnnotationEditorHandle \| null>` | Imperative handle for the mounted editor's live marks. |
|
|
60
|
+
| `annotationLogger` | `{ warn(message, ...args): void }` | Injected, so the package never imports a host logger. |
|
|
61
|
+
|
|
62
|
+
## The host seam
|
|
63
|
+
|
|
64
|
+
The package boundary is drawn at **persistence**: text ranges, marks, rendering
|
|
65
|
+
and in-document UX are the package's; storage, lifecycle, identity and
|
|
66
|
+
higher-level panels are yours. Everything the package needs from you crosses
|
|
67
|
+
that seam through `EditorHostServices`.
|
|
68
|
+
|
|
69
|
+
Every member is optional and has a safe default, so `<Editor>` works with no
|
|
70
|
+
provider at all. Wrap it when you want to supply real services:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { EditorHostProvider, Editor } from '@liminis/editor'
|
|
74
|
+
|
|
75
|
+
<EditorHostProvider services={{ logger, bridge, resolveWikiLinks, notifyError }}>
|
|
76
|
+
<Editor initialContent={md} onChange={setMd} />
|
|
77
|
+
</EditorHostProvider>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `EditorHostServices`
|
|
81
|
+
|
|
82
|
+
| Member | Type | Purpose |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `bridge` | `EditorHostBridge` | The channel to your environment. Two methods only: `postMessage(message)` and `addMessageHandler(handler)` returning an unsubscribe. Every higher-level helper (`requestInit`, `applyTextEdits`, `writeAsset`, `openLink`) is built on `postMessage` inside the package, so every host adapter emits byte-identical payloads. Defaults to a no-op bridge. |
|
|
85
|
+
| `logger` | `(namespace: string) => EditorLogger` | Namespaced logger factory. `EditorLogger` is `{ debug, info, warn, error }`, structurally compatible with most app loggers. |
|
|
86
|
+
| `resolveWikiLinks` | `(targets: string[]) => Promise<Record<string, string \| null>>` | Map wiki-link targets to existing paths; `null` for unresolved. Drives the "this page does not exist yet" styling. |
|
|
87
|
+
| `onScrollToAnchor` | `(cb: (anchor: string) => void) => () => void` | Subscribe to host-driven scroll requests. Returns an unsubscribe. |
|
|
88
|
+
| `notifyError` | `(message: string, description?: string) => void` | Surface a user-visible error. Defaults to a console warning. |
|
|
89
|
+
| `corrections` | `CorrectionHostServices` | Persistence and knowledge-graph services backing the correction feature: `readCorrections`, `writeCorrections`, `suggestEntities`, `suggestPassages`, `applyCorrections`. The in-editor correction UI is package-side; the file and the graph are yours. |
|
|
90
|
+
|
|
91
|
+
Read the resolved set inside your own components with `useEditorHost()`.
|
|
92
|
+
`resolveHostServices()` applies the defaults if you need them directly.
|
|
93
|
+
|
|
94
|
+
### The message contract
|
|
95
|
+
|
|
96
|
+
`@liminis/editor/contract` exports the `HostToUIMessage` / `UIToHostMessage`
|
|
97
|
+
shapes and nothing else. It exists so a boundary that must not pull renderer
|
|
98
|
+
code into its bundle — an Electron preload script is the canonical case — can
|
|
99
|
+
name the message types.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import type { HostToUIMessage, UIToHostMessage } from '@liminis/editor/contract'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `import type`. A value import from this entry pulls in `zod`, which is
|
|
106
|
+
exactly what the entry exists to avoid.
|