@liminis/editor 0.2.0 → 0.2.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/dist/app/App.d.ts +9 -1
- package/dist/app/App.js +2 -2
- package/docs/decisions/adr-090.md +141 -0
- package/docs/editor-api.md +27 -0
- package/package.json +1 -1
package/dist/app/App.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { SelectionContextMenuEvent } from './editor/SelectionContextMenuPlu
|
|
|
3
3
|
import type { SweepFn } from './editor/AmbientCorrectionPlugin.js';
|
|
4
4
|
import type { AnnotationCreateEvent } from './editor/AnnotationPlugin.js';
|
|
5
5
|
import type { Annotation, AnnotationEditorHandle, AnnotationKindConfigs } from '../annotations/types.js';
|
|
6
|
+
import type { DocumentOutlineHandle } from './editor/documentOutlineHandle.js';
|
|
6
7
|
import type { WikiLinkPromotionMode } from './mapper/lexicalToMdast.js';
|
|
7
8
|
export interface CursorState {
|
|
8
9
|
offset: number;
|
|
@@ -57,8 +58,15 @@ interface AppProps {
|
|
|
57
58
|
annotationLogger?: {
|
|
58
59
|
warn: (message: string, ...args: unknown[]) => void;
|
|
59
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Connects the inner `<Editor>` to a `<DocumentOutline handle={…}>` rendered
|
|
63
|
+
* anywhere the host chooses (issue #69/#80). Create with
|
|
64
|
+
* `createDocumentOutlineHandle()` and pass the same object to both — an
|
|
65
|
+
* unsupplied handle mounts no outline plugin at all.
|
|
66
|
+
*/
|
|
67
|
+
documentOutlineHandle?: DocumentOutlineHandle;
|
|
60
68
|
/** Override className on the root div (default: "editor-app-root"). */
|
|
61
69
|
className?: string;
|
|
62
70
|
}
|
|
63
|
-
export declare function App({ editable, autoFocus, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, className }: AppProps): import("react").JSX.Element;
|
|
71
|
+
export declare function App({ editable, autoFocus, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, documentOutlineHandle, className }: AppProps): import("react").JSX.Element;
|
|
64
72
|
export {};
|
package/dist/app/App.js
CHANGED
|
@@ -29,7 +29,7 @@ function computeMinimalEdits(oldText, newText) {
|
|
|
29
29
|
newText: newTextContent,
|
|
30
30
|
}];
|
|
31
31
|
}
|
|
32
|
-
export function App({ editable = true, autoFocus = false, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, className = 'editor-app-root' }) {
|
|
32
|
+
export function App({ editable = true, autoFocus = false, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, documentOutlineHandle, className = 'editor-app-root' }) {
|
|
33
33
|
const { bridge, logger } = useEditorHost();
|
|
34
34
|
const log = useMemo(() => logger('slashmd/App'), [logger]);
|
|
35
35
|
const { requestInit, applyTextEdits } = useHostMessages();
|
|
@@ -176,5 +176,5 @@ export function App({ editable = true, autoFocus = false, content: propContent,
|
|
|
176
176
|
if (content === null) {
|
|
177
177
|
return (_jsxs("div", { className: "editor-loading", children: [_jsx("div", { className: "loading-spinner" }), _jsx("span", { children: "Loading document..." })] }));
|
|
178
178
|
}
|
|
179
|
-
return (_jsxs("div", { className: className, children: [error && (_jsx("div", { className: "editor-error-banner", role: "alert", children: error })), _jsx(Editor, { initialContent: content, autoFocus: autoFocus, contentVersion: contentVersion, cursorToRestoreRef: cursorStateRef, onChange: handleChange, onCursorChange: handleCursorChange, assetBaseUri: assetBaseUri, documentDirUri: documentDirUri, imagePathResolution: settings?.imagePathResolution ?? 'document', wikiLinkPromotion: wikiLinkPromotion, resolveLocalAsset: resolveLocalAsset, editable: editable, filePath: filePath, onSelectionContextMenu: onSelectionContextMenu, onSubstitutionDetected: onSubstitutionDetected, sweepRef: sweepRef, annotationKinds: annotationKinds, annotations: annotations, activeAnnotationId: activeAnnotationId, scrollToAnnotation: scrollToAnnotation, onCreateAnnotation: onCreateAnnotation, onActivateAnnotation: onActivateAnnotation, annotationEditorHandleRef: annotationEditorHandleRef, annotationLogger: annotationLogger })] }));
|
|
179
|
+
return (_jsxs("div", { className: className, children: [error && (_jsx("div", { className: "editor-error-banner", role: "alert", children: error })), _jsx(Editor, { initialContent: content, autoFocus: autoFocus, contentVersion: contentVersion, cursorToRestoreRef: cursorStateRef, onChange: handleChange, onCursorChange: handleCursorChange, assetBaseUri: assetBaseUri, documentDirUri: documentDirUri, imagePathResolution: settings?.imagePathResolution ?? 'document', wikiLinkPromotion: wikiLinkPromotion, resolveLocalAsset: resolveLocalAsset, editable: editable, filePath: filePath, onSelectionContextMenu: onSelectionContextMenu, onSubstitutionDetected: onSubstitutionDetected, sweepRef: sweepRef, annotationKinds: annotationKinds, annotations: annotations, activeAnnotationId: activeAnnotationId, scrollToAnnotation: scrollToAnnotation, onCreateAnnotation: onCreateAnnotation, onActivateAnnotation: onActivateAnnotation, annotationEditorHandleRef: annotationEditorHandleRef, annotationLogger: annotationLogger, documentOutlineHandle: documentOutlineHandle })] }));
|
|
180
180
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ADR-090: A Text-Based Contract Test Guards `App`'s Forwarding of `EditorProps`, Checked Against JSX-Attribute Reachability
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-19
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #80 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Most consumers of `@liminis/editor` embed the package via `<App>`, not
|
|
12
|
+
`<Editor>` directly — Zusammen, the primary downstream consumer, mounts
|
|
13
|
+
`App`. `App` wraps `<Editor>` and forwards a curated, hand-maintained subset
|
|
14
|
+
of `EditorProps` as `AppProps`, passing each one straight through in its
|
|
15
|
+
render body's `<Editor .../>` call.
|
|
16
|
+
|
|
17
|
+
Issue #69 added `documentOutlineHandle` to `EditorProps`. It did not add the
|
|
18
|
+
same prop to `AppProps`, so `<App>` consumers had no way to reach the
|
|
19
|
+
document outline — a capability exported from the package yet structurally
|
|
20
|
+
unreachable through the component most consumers actually use. Nothing in
|
|
21
|
+
the code made this omission visible; it was discovered the way this class of
|
|
22
|
+
bug always is, one prop at a time, by a consumer hitting the gap.
|
|
23
|
+
|
|
24
|
+
Because `App`'s forwarding list is maintained by hand with no mechanical
|
|
25
|
+
link back to `EditorProps`, the same gap can recur for any future member
|
|
26
|
+
added to `EditorProps`. This decision is about what closes that class of
|
|
27
|
+
bug, not just the one prop: what the guard actually checks, and how it is
|
|
28
|
+
implemented.
|
|
29
|
+
|
|
30
|
+
Two questions needed a considered answer:
|
|
31
|
+
|
|
32
|
+
1. **What does "forwarded" mean for the guard to check?** `AppProps`
|
|
33
|
+
declaring a member with a matching name is necessary but not sufficient —
|
|
34
|
+
a member could be added to the `AppProps` interface and never wired into
|
|
35
|
+
the `<Editor .../>` JSX call in `App`'s render body, leaving it exactly as
|
|
36
|
+
unreachable as `documentOutlineHandle` was, while satisfying a check that
|
|
37
|
+
only inspects the type declaration.
|
|
38
|
+
2. **How should the guard extract that information?** The repo already has
|
|
39
|
+
an established contract-test convention (`tests/theming-contract.test.ts`,
|
|
40
|
+
`tests/package-manifest-contract.test.ts`), both of which parse source
|
|
41
|
+
files as text with targeted regexes rather than using a compiler API.
|
|
42
|
+
`typescript` is a devDependency (`^6.0.3`) but is not used
|
|
43
|
+
programmatically anywhere in the repo.
|
|
44
|
+
|
|
45
|
+
## Decision
|
|
46
|
+
|
|
47
|
+
**The guard checks JSX-attribute presence on the `<Editor .../>` element
|
|
48
|
+
inside `App.tsx`, not `AppProps`-interface-member presence.**
|
|
49
|
+
`scripts/lib/editor-app-prop-forwarding.mjs` extracts every top-level member
|
|
50
|
+
name from `EditorProps` (`src/app/editor/Editor.tsx`) and every
|
|
51
|
+
`attr={expr}` attribute name on the `<Editor .../>` JSX element in `App`'s
|
|
52
|
+
render body (`src/app/App.tsx`). `tests/editor-app-forwarding-contract.test.ts`
|
|
53
|
+
asserts every `EditorProps` member is one of those attribute names, or is
|
|
54
|
+
listed in `EDITOR_INTERNAL_PROPS` (in the same lib module) with a stated
|
|
55
|
+
reason. This directly encodes "forwarded" as "actually reaches the Editor
|
|
56
|
+
element," which is strictly stronger than, and implies, "declared on
|
|
57
|
+
`AppProps`."
|
|
58
|
+
|
|
59
|
+
**Some `EditorProps` members are legitimately App-internal and belong in the
|
|
60
|
+
allowlist, not the forwarding list.** `App` has two operating modes —
|
|
61
|
+
IPC-driven (the default, used by the Electron host) and inline
|
|
62
|
+
(`content`/`onChange` props, no IPC) — and several members are sourced from
|
|
63
|
+
`App`'s own IPC or bookkeeping state in both modes rather than being a value
|
|
64
|
+
a caller supplies: `contentVersion`, `cursorToRestoreRef`, `onCursorChange`,
|
|
65
|
+
`assetBaseUri`, `documentDirUri`, `imagePathResolution`. A seventh,
|
|
66
|
+
`initialContent`, is not internal in the same sense — it is reachable via
|
|
67
|
+
`AppProps.content`, which is then multiplexed with IPC-sourced state before
|
|
68
|
+
reaching `<Editor>` as `initialContent` — but it fails a same-name match, so
|
|
69
|
+
it is recorded in the same allowlist with a reason distinguishing "renamed
|
|
70
|
+
pass-through" from "genuinely internal." The guard's job is presence and
|
|
71
|
+
classification, not semantic tracking of renames, so both cases are handled
|
|
72
|
+
by the one allowlist mechanism rather than by building rename-detection
|
|
73
|
+
logic for a single instance.
|
|
74
|
+
|
|
75
|
+
**The guard is a text/regex-based contract test, matching the two existing
|
|
76
|
+
precedents, not a TypeScript-compiler-API-based one.** Both `EditorProps`
|
|
77
|
+
and `AppProps` are simple flat property lists today — every member is
|
|
78
|
+
declared on a single line, even where its type contains nested braces or
|
|
79
|
+
generics (`annotationEditorHandleRef?: MutableRefObject<AnnotationEditorHandle
|
|
80
|
+
| null>`, `scrollToAnnotation?: { id: string; nonce: number } | null`). A
|
|
81
|
+
brace-depth-tracking line scanner only needs to establish "am I at interface
|
|
82
|
+
top level," not parse the nested type, which is sufficient for both files as
|
|
83
|
+
they stand. Introducing `typescript`'s AST/type-checker API programmatically
|
|
84
|
+
for the first time in this repo would be more robust against future
|
|
85
|
+
syntactic variation (a multi-line declaration, a spread in the JSX call) but
|
|
86
|
+
disproportionate to interfaces this simple, and would set a new pattern
|
|
87
|
+
inconsistent with `theming-contract.test.ts` and
|
|
88
|
+
`package-manifest-contract.test.ts`.
|
|
89
|
+
|
|
90
|
+
## Consequences
|
|
91
|
+
|
|
92
|
+
**Good:**
|
|
93
|
+
|
|
94
|
+
- Adding a new `EditorProps` member without forwarding it on `<Editor .../>`
|
|
95
|
+
in `App.tsx`, and without an `EDITOR_INTERNAL_PROPS` entry, now fails CI
|
|
96
|
+
with a message naming the unclassified member — the exact recurrence this
|
|
97
|
+
issue exists to prevent.
|
|
98
|
+
- Checking JSX-attribute reachability rather than interface-member
|
|
99
|
+
declaration closes the specific loophole a weaker guard would leave open:
|
|
100
|
+
a prop added to the `AppProps` type but never wired into the render body.
|
|
101
|
+
- No new tooling dependency: the guard follows the same regex/text-parsing
|
|
102
|
+
shape as the two existing contract tests, so a maintainer who already
|
|
103
|
+
understands `theming-contract.test.ts` can read this one the same way.
|
|
104
|
+
- `EDITOR_INTERNAL_PROPS`'s reasons are colocated with the guard itself
|
|
105
|
+
(`scripts/lib/editor-app-prop-forwarding.mjs`), discoverable by reading the
|
|
106
|
+
guard rather than requiring a separate document.
|
|
107
|
+
|
|
108
|
+
**Bad / accepted:**
|
|
109
|
+
|
|
110
|
+
- The extractor assumes every interface member and every JSX attribute is
|
|
111
|
+
declared on a single line, and that the `<Editor .../>` call uses
|
|
112
|
+
`attr={expr}` syntax throughout (no `{...props}` spread). Both hold for
|
|
113
|
+
every current member and for `App.tsx`'s current render body; a future
|
|
114
|
+
multi-line declaration or a spread refactor would need the extractor
|
|
115
|
+
updated, and would silently under- or over-count until then. Accepted as
|
|
116
|
+
the same tradeoff `theming-contract.test.ts` already accepts for CSS custom
|
|
117
|
+
properties.
|
|
118
|
+
- `EDITOR_INTERNAL_PROPS` is a plain allowlist a contributor must remember to
|
|
119
|
+
update — the guard fails loudly if they forget, but nothing prevents an
|
|
120
|
+
entry from being added for the wrong reason (a prop that could have been
|
|
121
|
+
forwarded, allowlisted instead to make CI pass). The guard checks presence
|
|
122
|
+
and classification, not whether the classification is *correct* — that
|
|
123
|
+
remains a code-review concern, matching the spec's own stated edge case
|
|
124
|
+
that behavioral/type equivalence is out of the guard's scope.
|
|
125
|
+
|
|
126
|
+
**Neutral:**
|
|
127
|
+
|
|
128
|
+
- If `EditorProps` or `AppProps` later grow member declarations complex
|
|
129
|
+
enough that the single-line assumption breaks down, that is a signal to
|
|
130
|
+
revisit the AST-based approach this decision declined — not a reason to
|
|
131
|
+
build it preemptively now.
|
|
132
|
+
|
|
133
|
+
## References
|
|
134
|
+
|
|
135
|
+
- Issue #80 (this decision)
|
|
136
|
+
- `scripts/lib/editor-app-prop-forwarding.mjs` — the extraction logic and `EDITOR_INTERNAL_PROPS` allowlist
|
|
137
|
+
- `tests/editor-app-forwarding-contract.test.ts` — the contract test itself
|
|
138
|
+
- `src/app/App.tsx` — `AppProps`, and the `<Editor .../>` render-body call the guard reads
|
|
139
|
+
- `src/app/editor/Editor.tsx` — `EditorProps`, including `documentOutlineHandle` (added by #69, the prop this issue forwards)
|
|
140
|
+
- `tests/theming-contract.test.ts`, `tests/package-manifest-contract.test.ts` — the existing regex/text-based contract-test precedent this guard follows
|
|
141
|
+
- `docs/decisions/adr-088.md` — issue #69's own decision record, the origin of `documentOutlineHandle`
|
package/docs/editor-api.md
CHANGED
|
@@ -111,6 +111,33 @@ feeds it, `<DocumentOutline>` reads it.
|
|
|
111
111
|
- Visibility, placement, width-gating, and any collapse/persistence are
|
|
112
112
|
entirely up to you — `<DocumentOutline>` imposes no layout policy.
|
|
113
113
|
|
|
114
|
+
Most consumers embed this package via `<App>`, not `<Editor>` directly —
|
|
115
|
+
`<App>` forwards `documentOutlineHandle` straight through to its inner
|
|
116
|
+
`<Editor>`, so the same handle wires up an outline there too:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { useState } from 'react'
|
|
120
|
+
import { App, DocumentOutline, createDocumentOutlineHandle } from '@liminis/editor'
|
|
121
|
+
|
|
122
|
+
function MyAppWithOutline() {
|
|
123
|
+
const [markdown, setMarkdown] = useState('# Introduction')
|
|
124
|
+
const [outlineHandle] = useState(() => createDocumentOutlineHandle())
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div style={{ display: 'flex' }}>
|
|
128
|
+
<App
|
|
129
|
+
content={markdown}
|
|
130
|
+
onChange={setMarkdown}
|
|
131
|
+
documentOutlineHandle={outlineHandle}
|
|
132
|
+
/>
|
|
133
|
+
<aside>
|
|
134
|
+
<DocumentOutline handle={outlineHandle} />
|
|
135
|
+
</aside>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
114
141
|
## The host seam
|
|
115
142
|
|
|
116
143
|
The package boundary is drawn at **persistence**: text ranges, marks, rendering
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liminis/editor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"//publishing": "Publishing is deliberate, never incidental. `private: true` was this package's guard until verveguy/liminis-editor#39 took the publish decision; it is gone because that decision was taken, not because it was tidied away. The guard is now `prepublishOnly` -> scripts/guard-publish.mjs, which refuses unless LIMINIS_ALLOW_PUBLISH=1 is set explicitly. That variable is set at step scope in .github/workflows/publish.yml and nowhere else, so a release is the only path that publishes. Note that `npm publish --dry-run` does NOT report a private package as blocked (npm 10.8.2), which is why the guard is a script rather than a flag.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Lexical-based markdown WYSIWYG editor with mdast round-trip and a host-injection seam",
|