@liminis/editor 0.2.0 → 0.2.2
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/README.md +25 -3
- package/dist/app/App.d.ts +9 -1
- package/dist/app/App.js +2 -2
- package/dist/app/editor/DocumentOutline.d.ts +11 -2
- package/dist/app/editor/DocumentOutline.js +5 -2
- package/dist/app/editor/documentOutlineHandle.d.ts +28 -0
- package/dist/app/editor/documentOutlineHandle.js +16 -1
- package/dist/app/editor/documentOutlineMarkdown.d.ts +35 -0
- package/dist/app/editor/documentOutlineMarkdown.js +82 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -1
- package/docs/decisions/adr-090.md +141 -0
- package/docs/decisions/adr-091.md +159 -0
- package/docs/decisions/adr-092.md +202 -0
- package/docs/editor-api.md +98 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -199,7 +199,24 @@ Neither is a package defect, but only one of them tells you what is wrong.
|
|
|
199
199
|
The editor's colors, borders and a few layout metrics are all CSS custom
|
|
200
200
|
properties under a single package-owned vocabulary, `--liminis-editor-*`.
|
|
201
201
|
Every one of them resolves with no host configuration at all — the table
|
|
202
|
-
below exists so you can *override* a palette,
|
|
202
|
+
below exists so you can *override* a palette, and also so you can *consume*
|
|
203
|
+
one: every property this package declares with a value anywhere in
|
|
204
|
+
`styles.css` — the pre-`0.2.0` names still carrying the real defaults
|
|
205
|
+
(`--vscode-*`, `--slashmd-*`, `--checkbox-*`, and a couple of standalone
|
|
206
|
+
legacy names) that the `--liminis-editor-*` names below fall back to — is
|
|
207
|
+
part of this package's public API, readable directly by a host, not only
|
|
208
|
+
overridable. (The `--liminis-editor-*` names themselves are never declared
|
|
209
|
+
with a value directly — see "Has a default" below — nor are legacy names
|
|
210
|
+
like `--color-*` that appear only as an inner link in a fallback chain
|
|
211
|
+
rather than a `:root`/`.dark` declaration; neither is part of the defined
|
|
212
|
+
set this paragraph describes.) Some hosts do exactly that — mapping their
|
|
213
|
+
own design tokens onto these definitions with `var(--x)` rather than
|
|
214
|
+
overriding them. Renaming or removing a definition is therefore a breaking
|
|
215
|
+
change, the same as any other change to a supported API surface described
|
|
216
|
+
under "Versioning policy" above, and is distinct from renaming a
|
|
217
|
+
*consumption* site (covered separately in "Migrating from the old names"
|
|
218
|
+
below) — this package's CI guards against a definition disappearing
|
|
219
|
+
unintentionally (ADR-092).
|
|
203
220
|
|
|
204
221
|
**Previous name** is the pre-`0.2.0` name this property replaced — the exact
|
|
205
222
|
name to look for if you're migrating a host that still overrides the old
|
|
@@ -218,8 +235,13 @@ This table is generated from `src/` by `node scripts/generate-theming-docs.mjs`
|
|
|
218
235
|
and checked for staleness in CI (`tests/theming-contract.test.ts`) — a `var(--x)`
|
|
219
236
|
added to the source without regenerating this block fails the build. Do not
|
|
220
237
|
hand-edit the rows between the markers below. See ADR-085 for how the
|
|
221
|
-
generation and drift guard work,
|
|
222
|
-
|
|
238
|
+
generation and drift guard work, ADR-087 for the rename and its compatibility
|
|
239
|
+
design, and ADR-092 for the separate guard that protects the full *defined*
|
|
240
|
+
set — every token declared with a value in `styles.css`, per the checked-in
|
|
241
|
+
baseline (`scripts/lib/theming-defined-tokens-baseline.json`), a different
|
|
242
|
+
and non-overlapping set of names from this table's rows — against an
|
|
243
|
+
unintentional rename or removal — run `pnpm docs:theming-baseline` after a
|
|
244
|
+
deliberate one.
|
|
223
245
|
|
|
224
246
|
<!-- theming-tokens:start -->
|
|
225
247
|
| Custom property | Previous name | Controls | Kind | Has a default |
|
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
|
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import type { DocumentOutlineHandle } from './documentOutlineHandle.js';
|
|
1
|
+
import type { DocumentOutlineHandle, OutlineEntry } from './documentOutlineHandle.js';
|
|
2
2
|
export interface DocumentOutlineProps {
|
|
3
3
|
/** The controller created by `createDocumentOutlineHandle()` and also passed to `<Editor documentOutlineHandle={…}>`. */
|
|
4
4
|
handle: DocumentOutlineHandle;
|
|
5
5
|
/** Additional class name on the outer `<nav>`, for host placement/layout (FR-008). */
|
|
6
6
|
className?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Fires with the clicked entry in addition to `handle.scrollToHeading`.
|
|
9
|
+
* `handle.scrollToHeading` no-ops when no Lexical editor is connected
|
|
10
|
+
* (the markdown-derived/raw-mode path, issue #84) — a raw-mode host has no
|
|
11
|
+
* other way to react to a click, since it doesn't render the entry list
|
|
12
|
+
* itself, so this is how it navigates its own (non-Lexical) editor via
|
|
13
|
+
* `entry.line`.
|
|
14
|
+
*/
|
|
15
|
+
onEntrySelect?: (entry: OutlineEntry) => void;
|
|
7
16
|
}
|
|
8
17
|
/**
|
|
9
18
|
* Renders a navigable "on this page" heading outline for one editor
|
|
@@ -14,4 +23,4 @@ export interface DocumentOutlineProps {
|
|
|
14
23
|
* placement, width-gating, and collapse/persistence are the host's concern
|
|
15
24
|
* (FR-008) — this component only renders the list and reacts to the handle.
|
|
16
25
|
*/
|
|
17
|
-
export declare function DocumentOutline({ handle, className }: DocumentOutlineProps): import("react").JSX.Element | null;
|
|
26
|
+
export declare function DocumentOutline({ handle, className, onEntrySelect }: DocumentOutlineProps): import("react").JSX.Element | null;
|
|
@@ -9,7 +9,7 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
|
|
|
9
9
|
* placement, width-gating, and collapse/persistence are the host's concern
|
|
10
10
|
* (FR-008) — this component only renders the list and reacts to the handle.
|
|
11
11
|
*/
|
|
12
|
-
export function DocumentOutline({ handle, className }) {
|
|
12
|
+
export function DocumentOutline({ handle, className, onEntrySelect }) {
|
|
13
13
|
// Wrapped rather than passed directly: `handle.subscribe`/`handle.getSnapshot`
|
|
14
14
|
// are unbound method references off a plain interface value.
|
|
15
15
|
const subscribe = useCallback((onStoreChange) => handle.subscribe(onStoreChange), [handle]);
|
|
@@ -27,6 +27,9 @@ export function DocumentOutline({ handle, className }) {
|
|
|
27
27
|
return null;
|
|
28
28
|
return (_jsx("nav", { className: className ? `editor-outline ${className}` : 'editor-outline', "aria-label": "Document outline", children: _jsx("ul", { className: "editor-outline-list", ref: listRef, children: snapshot.entries.map((entry) => {
|
|
29
29
|
const isActive = entry.index === snapshot.activeIndex;
|
|
30
|
-
return (_jsx("li", { "data-outline-index": entry.index, "data-outline-level": entry.level, className: isActive ? 'editor-outline-item editor-outline-item-active' : 'editor-outline-item', children: _jsxs("button", { type: "button", className: "editor-outline-item-button", "aria-current": isActive ? 'location' : undefined, onClick: () =>
|
|
30
|
+
return (_jsx("li", { "data-outline-index": entry.index, "data-outline-level": entry.level, className: isActive ? 'editor-outline-item editor-outline-item-active' : 'editor-outline-item', children: _jsxs("button", { type: "button", className: "editor-outline-item-button", "aria-current": isActive ? 'location' : undefined, onClick: () => {
|
|
31
|
+
handle.scrollToHeading(entry.index);
|
|
32
|
+
onEntrySelect?.(entry);
|
|
33
|
+
}, children: [_jsx("span", { className: "editor-outline-indicator", "aria-hidden": "true" }), _jsx("span", { className: "editor-outline-item-text", children: entry.text })] }) }, entry.index));
|
|
31
34
|
}) }) }));
|
|
32
35
|
}
|
|
@@ -23,6 +23,13 @@ export interface OutlineEntry {
|
|
|
23
23
|
level: 1 | 2 | 3 | 4 | 5;
|
|
24
24
|
/** Heading text with inline content (including inline code) reduced to plain text. */
|
|
25
25
|
text: string;
|
|
26
|
+
/**
|
|
27
|
+
* 1-based source line of the heading, matching mdast's
|
|
28
|
+
* `position.start.line`. Only populated on markdown-derived entries (see
|
|
29
|
+
* `documentOutlineMarkdown.ts`) — the live-Lexical path has no source-line
|
|
30
|
+
* mapping to supply, so it leaves this unset.
|
|
31
|
+
*/
|
|
32
|
+
line?: number;
|
|
26
33
|
}
|
|
27
34
|
export interface DocumentOutlineSnapshot {
|
|
28
35
|
entries: OutlineEntry[];
|
|
@@ -53,6 +60,21 @@ export interface DocumentOutlineHandle {
|
|
|
53
60
|
getSnapshot(): DocumentOutlineSnapshot;
|
|
54
61
|
/** Scroll the editor to the heading at `index`. No-ops if no editor is currently connected, or `index` is out of range. */
|
|
55
62
|
scrollToHeading(index: number): void;
|
|
63
|
+
/**
|
|
64
|
+
* Derive entries from `markdown` (see `deriveOutlineFromMarkdown` in
|
|
65
|
+
* `documentOutlineMarkdown.ts`) and publish them — the raw-mode
|
|
66
|
+
* counterpart to `OutlinePlugin` feeding entries from live Lexical state.
|
|
67
|
+
* Call this on every content change in a host with no mounted Lexical
|
|
68
|
+
* editor (issue #84).
|
|
69
|
+
*/
|
|
70
|
+
publishFromMarkdown(markdown: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Resolve `line` against the entries from the most recent
|
|
73
|
+
* `publishFromMarkdown` call and publish the corresponding active index —
|
|
74
|
+
* the raw-mode counterpart to `OutlinePlugin`'s scroll-spy. Pass `null`
|
|
75
|
+
* for "no active heading yet" (e.g. before first scroll).
|
|
76
|
+
*/
|
|
77
|
+
setActiveLine(line: number | null): void;
|
|
56
78
|
}
|
|
57
79
|
/**
|
|
58
80
|
* The concrete object `createDocumentOutlineHandle()` returns. Consumers only
|
|
@@ -66,9 +88,15 @@ export declare class OutlineHandleImpl implements DocumentOutlineHandle {
|
|
|
66
88
|
private snapshot;
|
|
67
89
|
private readonly listeners;
|
|
68
90
|
private scrollImpl;
|
|
91
|
+
/** Entries from the most recent `publishFromMarkdown` call, so `setActiveLine` can resolve against them independently. */
|
|
92
|
+
private markdownEntries;
|
|
93
|
+
/** Last line passed to `setActiveLine`, kept so a subsequent `publishFromMarkdown` re-resolves against it. */
|
|
94
|
+
private lastActiveLine;
|
|
69
95
|
subscribe: (onStoreChange: () => void) => (() => void);
|
|
70
96
|
getSnapshot: () => DocumentOutlineSnapshot;
|
|
71
97
|
scrollToHeading: (index: number) => void;
|
|
98
|
+
publishFromMarkdown: (markdown: string) => void;
|
|
99
|
+
setActiveLine: (line: number | null) => void;
|
|
72
100
|
/** Called by `OutlinePlugin` while its editor is mounted. */
|
|
73
101
|
connect(scrollImpl: (index: number) => void): void;
|
|
74
102
|
/** Called by `OutlinePlugin` on unmount, so a stale handle no-ops rather than scrolling a torn-down editor. */
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* instance — it is naturally scoped to that instance, so mounting more than
|
|
12
12
|
* one editor at once never aggregates headings across them.
|
|
13
13
|
*/
|
|
14
|
+
import { deriveOutlineFromMarkdown, resolveActiveOutlineIndex } from './documentOutlineMarkdown.js';
|
|
14
15
|
/**
|
|
15
16
|
* Not exported, so nothing outside this module can reference it. Its only
|
|
16
17
|
* purpose is to make `DocumentOutlineHandle` nominal instead of structural:
|
|
@@ -30,7 +31,7 @@ function snapshotsEqual(a, b) {
|
|
|
30
31
|
for (let i = 0; i < a.entries.length; i++) {
|
|
31
32
|
const x = a.entries[i];
|
|
32
33
|
const y = b.entries[i];
|
|
33
|
-
if (x.index !== y.index || x.level !== y.level || x.text !== y.text)
|
|
34
|
+
if (x.index !== y.index || x.level !== y.level || x.text !== y.text || x.line !== y.line)
|
|
34
35
|
return false;
|
|
35
36
|
}
|
|
36
37
|
return true;
|
|
@@ -47,6 +48,10 @@ export class OutlineHandleImpl {
|
|
|
47
48
|
snapshot = EMPTY_SNAPSHOT;
|
|
48
49
|
listeners = new Set();
|
|
49
50
|
scrollImpl = null;
|
|
51
|
+
/** Entries from the most recent `publishFromMarkdown` call, so `setActiveLine` can resolve against them independently. */
|
|
52
|
+
markdownEntries = [];
|
|
53
|
+
/** Last line passed to `setActiveLine`, kept so a subsequent `publishFromMarkdown` re-resolves against it. */
|
|
54
|
+
lastActiveLine = null;
|
|
50
55
|
subscribe = (onStoreChange) => {
|
|
51
56
|
this.listeners.add(onStoreChange);
|
|
52
57
|
return () => {
|
|
@@ -57,6 +62,16 @@ export class OutlineHandleImpl {
|
|
|
57
62
|
scrollToHeading = (index) => {
|
|
58
63
|
this.scrollImpl?.(index);
|
|
59
64
|
};
|
|
65
|
+
publishFromMarkdown = (markdown) => {
|
|
66
|
+
this.markdownEntries = deriveOutlineFromMarkdown(markdown);
|
|
67
|
+
const activeIndex = resolveActiveOutlineIndex(this.markdownEntries, this.lastActiveLine);
|
|
68
|
+
this.publish({ entries: this.markdownEntries, activeIndex });
|
|
69
|
+
};
|
|
70
|
+
setActiveLine = (line) => {
|
|
71
|
+
this.lastActiveLine = line;
|
|
72
|
+
const activeIndex = resolveActiveOutlineIndex(this.markdownEntries, line);
|
|
73
|
+
this.publish({ entries: this.markdownEntries, activeIndex });
|
|
74
|
+
};
|
|
60
75
|
/** Called by `OutlinePlugin` while its editor is mounted. */
|
|
61
76
|
connect(scrollImpl) {
|
|
62
77
|
this.scrollImpl = scrollImpl;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives `OutlineEntry` entries directly from markdown text, independent of
|
|
3
|
+
* any Lexical editor state — the raw-mode counterpart to `OutlinePlugin`'s
|
|
4
|
+
* live-tree derivation (issue #84). A host with no mounted Lexical editor
|
|
5
|
+
* (liminis#1022's raw markdown mode) has no heading DOM/tree to walk, but it
|
|
6
|
+
* does have the markdown text and, per this issue's spec, is expected to know
|
|
7
|
+
* which source line is currently in view — this module turns both into the
|
|
8
|
+
* same `OutlineEntry` shape `OutlinePlugin` produces, plus a source line
|
|
9
|
+
* neither Lexical nor the DOM can supply.
|
|
10
|
+
*/
|
|
11
|
+
import type { OutlineEntry } from './documentOutlineHandle.js';
|
|
12
|
+
/**
|
|
13
|
+
* Derive `OutlineEntry[]` from a markdown string alone — no Lexical editor,
|
|
14
|
+
* no DOM. Mirrors `OutlinePlugin.readHeadingEntries()`: only top-level nodes
|
|
15
|
+
* are inspected (headings nested inside blockquotes/lists are not walked
|
|
16
|
+
* into, matching `$getRoot().getChildren()`), levels are restricted to
|
|
17
|
+
* H1–H5 (mdast allows depth 6; excluded exactly like the Lexical path
|
|
18
|
+
* excludes H6), and identity is positional (`index`), not by `text`.
|
|
19
|
+
*
|
|
20
|
+
* Unlike the Lexical path, an empty-title heading is still included — see
|
|
21
|
+
* ADR for why this issue does not replicate the pre-extraction app's
|
|
22
|
+
* `if (!title) continue`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function deriveOutlineFromMarkdown(markdown: string): OutlineEntry[];
|
|
25
|
+
/**
|
|
26
|
+
* Resolve which entry is "active" for a supplied source `line` — the
|
|
27
|
+
* raw-mode counterpart to `OutlinePlugin`'s viewport-based scroll-spy. An
|
|
28
|
+
* entry is active from its own line up to (but not including) the next
|
|
29
|
+
* entry's line, matching the spec's literal FR-006 semantics (no tolerance/
|
|
30
|
+
* fudge factor — that is host-side scroll-spy tuning, per the plan).
|
|
31
|
+
*
|
|
32
|
+
* Returns `null` when `line` is `null` (no active heading yet, e.g. before
|
|
33
|
+
* first scroll) or falls before the first heading.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveActiveOutlineIndex(entries: OutlineEntry[], line: number | null): number | null;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives `OutlineEntry` entries directly from markdown text, independent of
|
|
3
|
+
* any Lexical editor state — the raw-mode counterpart to `OutlinePlugin`'s
|
|
4
|
+
* live-tree derivation (issue #84). A host with no mounted Lexical editor
|
|
5
|
+
* (liminis#1022's raw markdown mode) has no heading DOM/tree to walk, but it
|
|
6
|
+
* does have the markdown text and, per this issue's spec, is expected to know
|
|
7
|
+
* which source line is currently in view — this module turns both into the
|
|
8
|
+
* same `OutlineEntry` shape `OutlinePlugin` produces, plus a source line
|
|
9
|
+
* neither Lexical nor the DOM can supply.
|
|
10
|
+
*/
|
|
11
|
+
import { parseMarkdown, isHeading, isText, isInlineCode } from '../../markdown/parse.js';
|
|
12
|
+
/**
|
|
13
|
+
* Recursively reduces inline content to plain text, matching Lexical's
|
|
14
|
+
* `getTextContent()` semantics: every inline node's text is concatenated in
|
|
15
|
+
* order with no separator, bottoming out at `text`/`inlineCode` leaves. This
|
|
16
|
+
* mirrors `OutlinePlugin.test.tsx`'s inline-code fixture (`'See ' + 'inline
|
|
17
|
+
* code'` → `'See inline code'`) rather than enumerating every mdast inline
|
|
18
|
+
* type by name.
|
|
19
|
+
*/
|
|
20
|
+
function extractText(node) {
|
|
21
|
+
if (isText(node) || isInlineCode(node))
|
|
22
|
+
return node.value;
|
|
23
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
24
|
+
return node.children.map((child) => extractText(child)).join('');
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Derive `OutlineEntry[]` from a markdown string alone — no Lexical editor,
|
|
30
|
+
* no DOM. Mirrors `OutlinePlugin.readHeadingEntries()`: only top-level nodes
|
|
31
|
+
* are inspected (headings nested inside blockquotes/lists are not walked
|
|
32
|
+
* into, matching `$getRoot().getChildren()`), levels are restricted to
|
|
33
|
+
* H1–H5 (mdast allows depth 6; excluded exactly like the Lexical path
|
|
34
|
+
* excludes H6), and identity is positional (`index`), not by `text`.
|
|
35
|
+
*
|
|
36
|
+
* Unlike the Lexical path, an empty-title heading is still included — see
|
|
37
|
+
* ADR for why this issue does not replicate the pre-extraction app's
|
|
38
|
+
* `if (!title) continue`.
|
|
39
|
+
*/
|
|
40
|
+
export function deriveOutlineFromMarkdown(markdown) {
|
|
41
|
+
const { root } = parseMarkdown(markdown);
|
|
42
|
+
const entries = [];
|
|
43
|
+
let index = 0;
|
|
44
|
+
for (const node of root.children) {
|
|
45
|
+
if (!isHeading(node))
|
|
46
|
+
continue;
|
|
47
|
+
if (node.depth < 1 || node.depth > 5)
|
|
48
|
+
continue;
|
|
49
|
+
const text = node.children.map((child) => extractText(child)).join('');
|
|
50
|
+
entries.push({
|
|
51
|
+
index: index++,
|
|
52
|
+
level: node.depth,
|
|
53
|
+
text,
|
|
54
|
+
line: node.position?.start?.line,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve which entry is "active" for a supplied source `line` — the
|
|
61
|
+
* raw-mode counterpart to `OutlinePlugin`'s viewport-based scroll-spy. An
|
|
62
|
+
* entry is active from its own line up to (but not including) the next
|
|
63
|
+
* entry's line, matching the spec's literal FR-006 semantics (no tolerance/
|
|
64
|
+
* fudge factor — that is host-side scroll-spy tuning, per the plan).
|
|
65
|
+
*
|
|
66
|
+
* Returns `null` when `line` is `null` (no active heading yet, e.g. before
|
|
67
|
+
* first scroll) or falls before the first heading.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveActiveOutlineIndex(entries, line) {
|
|
70
|
+
if (line === null)
|
|
71
|
+
return null;
|
|
72
|
+
let active = null;
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.line === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
if (entry.line <= line)
|
|
77
|
+
active = entry.index;
|
|
78
|
+
else
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return active;
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { DocumentOutline } from './app/editor/DocumentOutline.js';
|
|
|
22
22
|
export type { DocumentOutlineProps } from './app/editor/DocumentOutline.js';
|
|
23
23
|
export { createDocumentOutlineHandle } from './app/editor/documentOutlineHandle.js';
|
|
24
24
|
export type { DocumentOutlineHandle, DocumentOutlineSnapshot, OutlineEntry, } from './app/editor/documentOutlineHandle.js';
|
|
25
|
+
export { deriveOutlineFromMarkdown, resolveActiveOutlineIndex } from './app/editor/documentOutlineMarkdown.js';
|
|
25
26
|
export { OPEN_ANNOTATION_COMPOSER_COMMAND } from './app/editor/annotationCommands.js';
|
|
26
27
|
export type { AnnotationCreateEvent } from './app/editor/AnnotationPlugin.js';
|
|
27
28
|
export type { Annotation, AnnotationKind, AnnotationKindConfig, AnnotationKindConfigs, AnnotationCreateAffordance, AnnotationMarkerStyle, AnnotationPresentation, AnnotationEditorHandle, MarkerTarget, } from './annotations/types.js';
|
package/dist/index.js
CHANGED
|
@@ -15,12 +15,18 @@ export { createHostMessageApi, useHostMessages } from './host/messages.js';
|
|
|
15
15
|
// --- Components ------------------------------------------------------------
|
|
16
16
|
export { App } from './app/App.js';
|
|
17
17
|
export { Editor } from './app/editor/index.js';
|
|
18
|
-
// --- Document outline (issue #69)
|
|
18
|
+
// --- Document outline (issue #69, markdown-derived path issue #84) --------
|
|
19
19
|
// `createDocumentOutlineHandle()` makes the controller shared between one
|
|
20
20
|
// `<Editor documentOutlineHandle={…}>` and one `<DocumentOutline handle={…}>`
|
|
21
21
|
// — create it once per editor instance and pass the same object to both.
|
|
22
|
+
// A host with no mounted Lexical editor (e.g. a raw markdown mode) can feed
|
|
23
|
+
// the same handle from markdown text alone via `handle.publishFromMarkdown`/
|
|
24
|
+
// `handle.setActiveLine`; `deriveOutlineFromMarkdown`/
|
|
25
|
+
// `resolveActiveOutlineIndex` are exported standalone for a host that wants
|
|
26
|
+
// the pure derivation without going through the handle.
|
|
22
27
|
export { DocumentOutline } from './app/editor/DocumentOutline.js';
|
|
23
28
|
export { createDocumentOutlineHandle } from './app/editor/documentOutlineHandle.js';
|
|
29
|
+
export { deriveOutlineFromMarkdown, resolveActiveOutlineIndex } from './app/editor/documentOutlineMarkdown.js';
|
|
24
30
|
// --- Annotations, React surface (ADR-077) ---------------------------------
|
|
25
31
|
// The kind-configuration types a host needs to turn the mechanism on, plus the
|
|
26
32
|
// create-event shape. The DOM-free anchor model, resolver and marker-target
|
|
@@ -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`
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# ADR-091: `DocumentOutlineHandle` Gets Two Independent, Non-Reconciled Entry Sources
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-19
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #84 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Issue #69 extracted `DocumentOutline` from liminis-app's `TableOfContents.tsx`
|
|
12
|
+
and deliberately rebuilt it on **live Lexical state**: `OutlinePlugin` walks
|
|
13
|
+
`HeadingNode`s on every editor update and feeds the shared
|
|
14
|
+
`DocumentOutlineHandle`. That decision avoided a second markdown parse and got
|
|
15
|
+
#69's "the outline updates as headings change" requirement for free — but it
|
|
16
|
+
also meant an outline is only possible where a Lexical `<Editor>` is mounted.
|
|
17
|
+
|
|
18
|
+
The pre-extraction `TableOfContents.tsx` had no such limitation: it derived
|
|
19
|
+
entries from **markdown**, using this package's own `parseMarkdown`/
|
|
20
|
+
`isHeading`/`isText`/`isInlineCode`, and carried each heading's mdast
|
|
21
|
+
`position.start.line` — a 1-based source line its raw-mode variant used to
|
|
22
|
+
scroll a non-Lexical editor directly. That capability did not survive
|
|
23
|
+
extraction, which is what stalled liminis-app's raw markdown mode
|
|
24
|
+
(verveguy/liminis#1022) from adopting `DocumentOutline` at all.
|
|
25
|
+
|
|
26
|
+
Three questions needed a considered answer:
|
|
27
|
+
|
|
28
|
+
1. **Does markdown replace the Lexical derivation, or sit alongside it?**
|
|
29
|
+
Unifying on markdown for both paths would give up #69's "no second parse"
|
|
30
|
+
property for the live-Lexical/WYSIWYG path, and risked introducing subtle
|
|
31
|
+
Lexical-state/markdown divergence bugs in a path that already works.
|
|
32
|
+
2. **How does markdown reach the handle?** `OutlineHandleImpl`'s `publish`/
|
|
33
|
+
`connect`/`disconnect` are reachable today only via a brand-gated cast
|
|
34
|
+
(`handle as OutlineHandleImpl`) that only `OutlinePlugin` — always
|
|
35
|
+
constructed from `createDocumentOutlineHandle()` — can perform. A raw-mode
|
|
36
|
+
host holds a plain `DocumentOutlineHandle` and cannot replicate that cast.
|
|
37
|
+
3. **Should the two paths be reconciled when both are somehow available on
|
|
38
|
+
one handle at once?** (E.g., a host that mounts both a Lexical editor and
|
|
39
|
+
also calls the markdown methods on the same handle.)
|
|
40
|
+
|
|
41
|
+
## Decision
|
|
42
|
+
|
|
43
|
+
**`DocumentOutlineHandle` gains two new public methods —
|
|
44
|
+
`publishFromMarkdown(markdown)` and `setActiveLine(line)` — that are a second,
|
|
45
|
+
independent entry source feeding the same `publish`/`subscribe`/`getSnapshot`
|
|
46
|
+
machinery `OutlinePlugin` already uses.** `DocumentOutline.tsx` renders
|
|
47
|
+
correctly regardless of which path produced its snapshot, with no branching
|
|
48
|
+
on the source — its only change is an optional `onEntrySelect` prop, fired
|
|
49
|
+
alongside the existing `scrollToHeading` call, since that call is a no-op on
|
|
50
|
+
the markdown path (no Lexical editor to scroll) and a raw-mode host needs
|
|
51
|
+
some way to react to a click without re-implementing the entry list itself
|
|
52
|
+
(see "Bad / accepted" below). `OutlinePlugin.tsx` needs zero changes: the
|
|
53
|
+
Lexical path keeps working exactly as it did in 0.2.1.
|
|
54
|
+
|
|
55
|
+
**The two paths are parallel and deliberately not reconciled.** A handle is
|
|
56
|
+
fed by one path or the other — `<Editor documentOutlineHandle>`/
|
|
57
|
+
`OutlinePlugin`, or `publishFromMarkdown`/`setActiveLine` — matching how the
|
|
58
|
+
pre-extraction app never ran both on the same instance either. Mounting both
|
|
59
|
+
on one handle at once is unsupported and produces whichever `publish()` call
|
|
60
|
+
happened most recently, the same "last write wins" behavior `publish()`
|
|
61
|
+
already has for any two callers.
|
|
62
|
+
|
|
63
|
+
**`publishFromMarkdown` and `setActiveLine` are split into two methods, not
|
|
64
|
+
one combined call**, because content changes and scroll events fire
|
|
65
|
+
independently and at different rates in a raw-mode host, which owns its own
|
|
66
|
+
scroll tracking (there is no package-side equivalent of `OutlinePlugin`'s
|
|
67
|
+
rAF-throttled viewport observer, since the package does not own a raw-mode
|
|
68
|
+
host's editor or its scroll container). `OutlineHandleImpl` stores the
|
|
69
|
+
last-derived markdown entries and last active line internally so either
|
|
70
|
+
method can be called alone.
|
|
71
|
+
|
|
72
|
+
**The pure derivation functions (`deriveOutlineFromMarkdown`,
|
|
73
|
+
`resolveActiveOutlineIndex`) live in a sibling file
|
|
74
|
+
(`documentOutlineMarkdown.ts`), not inlined into `documentOutlineHandle.ts`,
|
|
75
|
+
and are exported standalone from the package root** alongside the handle
|
|
76
|
+
methods that compose them — independently unit-testable with no handle/
|
|
77
|
+
publish machinery involved, and usable by a host that wants the derivation
|
|
78
|
+
without the handle's mutable-state class at all.
|
|
79
|
+
|
|
80
|
+
**`OutlineEntry` gains an optional `line?: number`**, populated only on
|
|
81
|
+
markdown-derived entries; the Lexical path leaves it unset since live Lexical
|
|
82
|
+
state has no source-line mapping to supply. `snapshotsEqual` compares it, so
|
|
83
|
+
a line-only change (same text/level, new line) still notifies subscribers.
|
|
84
|
+
|
|
85
|
+
## Consequences
|
|
86
|
+
|
|
87
|
+
**Good:**
|
|
88
|
+
|
|
89
|
+
- A raw-mode host with zero Lexical editors mounted can produce a populated,
|
|
90
|
+
navigable outline by calling two methods on a handle it already holds, and
|
|
91
|
+
render it with the shared `<DocumentOutline>` component — including
|
|
92
|
+
click-to-navigate via `onEntrySelect`, since `scrollToHeading` alone
|
|
93
|
+
no-ops on this path — satisfying #84's FR-008/SC-004 ("adopt without
|
|
94
|
+
reimplementing... logic") without liminis#1022 forking outline/
|
|
95
|
+
heading-derivation *or* list-rendering code.
|
|
96
|
+
- The existing Lexical/WYSIWYG path is untouched at the source level:
|
|
97
|
+
`OutlinePlugin.tsx` has no diff, and #69's existing test suites pass
|
|
98
|
+
unmodified, directly verifying FR-005/US3 ("WYSIWYG behavior is
|
|
99
|
+
unaffected").
|
|
100
|
+
- `DocumentOutline.tsx` has no branching on which path produced its
|
|
101
|
+
snapshot — the two entry sources are invisible to it — and its one
|
|
102
|
+
addition (`onEntrySelect`) is additive and optional, so existing WYSIWYG
|
|
103
|
+
consumers that don't pass it see no behavior change.
|
|
104
|
+
- The standalone-exported pure functions give a host an escape hatch if it
|
|
105
|
+
ever wants derivation without the handle/publish machinery (e.g. a
|
|
106
|
+
server-side outline preview), without that being the primary API shape.
|
|
107
|
+
|
|
108
|
+
**Bad / accepted:**
|
|
109
|
+
|
|
110
|
+
- **Text extraction is now implemented twice**: Lexical's built-in
|
|
111
|
+
`getTextContent()` on the WYSIWYG path, and a hand-written recursive mdast
|
|
112
|
+
walk (`extractText` in `documentOutlineMarkdown.ts`) on the markdown path.
|
|
113
|
+
A future inline mdast node type could silently produce different text on
|
|
114
|
+
the two paths with nothing to catch it beyond the shared fixture the two
|
|
115
|
+
paths' test suites both happen to exercise (`OutlinePlugin.test.tsx`'s and
|
|
116
|
+
`documentOutlineMarkdown.test.ts`'s equivalent inline-code case). No shared
|
|
117
|
+
test harness enforces parity mechanically; considered out of scope for this
|
|
118
|
+
issue.
|
|
119
|
+
- **The two paths are never reconciled**, so a host that (incorrectly) drives
|
|
120
|
+
both at once on the same handle gets undefined-in-practice "whichever
|
|
121
|
+
`publish()` ran last" behavior rather than an explicit error. Accepted
|
|
122
|
+
because detecting and rejecting that misuse would add complexity for a
|
|
123
|
+
configuration nothing in the spec calls for and the pre-extraction app
|
|
124
|
+
never did either.
|
|
125
|
+
- **Empty-title headings are included on the markdown path**, diverging from
|
|
126
|
+
the pre-extraction `TableOfContents.tsx`'s `if (!title) continue`. This
|
|
127
|
+
matches FR-002's literal requirement ("same rules as the existing Lexical
|
|
128
|
+
path", which does not skip them) over parity with the old app behavior —
|
|
129
|
+
liminis#1022 needs to be aware of this when it adopts the markdown path.
|
|
130
|
+
|
|
131
|
+
**Neutral:**
|
|
132
|
+
|
|
133
|
+
- No new export subpath was added; `deriveOutlineFromMarkdown`/
|
|
134
|
+
`resolveActiveOutlineIndex` are exported from the same root `.` entry as
|
|
135
|
+
the rest of the outline surface, not from `./markdown`'s Lexical-free
|
|
136
|
+
barrel. ADR-075 draws that barrel's isolation boundary around avoiding
|
|
137
|
+
Lexical/MathJax/Mermaid/C4 for a consumer that only parses markdown; this
|
|
138
|
+
issue's FR-008 requires only that liminis#1022 can adopt the capability
|
|
139
|
+
without forking logic, not that it be reachable without the root barrel's
|
|
140
|
+
full import graph. Revisit only if that assumption proves wrong during
|
|
141
|
+
#1022's own implementation.
|
|
142
|
+
|
|
143
|
+
## References
|
|
144
|
+
|
|
145
|
+
- Issue #84 (this decision)
|
|
146
|
+
- `src/app/editor/documentOutlineHandle.ts` — `OutlineEntry.line`,
|
|
147
|
+
`publishFromMarkdown`, `setActiveLine`, updated `snapshotsEqual`
|
|
148
|
+
- `src/app/editor/documentOutlineMarkdown.ts` — `deriveOutlineFromMarkdown`,
|
|
149
|
+
`resolveActiveOutlineIndex`
|
|
150
|
+
- `src/app/editor/OutlinePlugin.tsx` — the unchanged Lexical/WYSIWYG path
|
|
151
|
+
- `src/app/editor/DocumentOutline.tsx` — the `onEntrySelect` prop, its one
|
|
152
|
+
change, added so a raw-mode host can react to clicks
|
|
153
|
+
- `docs/editor-api.md` — "Raw-mode / markdown-derived entries" section
|
|
154
|
+
- `docs/decisions/adr-075.md` — the package-boundary/export-subpath decision
|
|
155
|
+
this ADR's "Neutral" section revisits
|
|
156
|
+
- `specs/69-extract-the-table-of/spec.md`, `docs/decisions/adr-088.md` —
|
|
157
|
+
issue #69's own decision to derive from live Lexical state, whose
|
|
158
|
+
WYSIWYG-only assumption this issue lifts without replacing it
|
|
159
|
+
- verveguy/liminis#1022 (external repo) — the consumer this decision unblocks
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# ADR-092: A Checked-In Baseline Guards `styles.css`'s Defined Tokens Against Silent Rename or Removal
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-20
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** none
|
|
6
|
+
**Amends:** none
|
|
7
|
+
**Issue:** #79 (verveguy/liminis-editor)
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`tests/theming-contract.test.ts` (ADR-085) already guards the *consumed*
|
|
12
|
+
token set — every `var(--x)` reference under `src/` must be documented in
|
|
13
|
+
the README's generated table, resolve without a host, carry a description,
|
|
14
|
+
and (per ADR-087) preserve a fallback to its pre-`0.2.0` name where renamed.
|
|
15
|
+
None of those four checks say anything about a token that is *defined*
|
|
16
|
+
(declared with a value in `styles.css`) but never itself consumed by
|
|
17
|
+
`src/` — the `--vscode-*`, `--slashmd-*`, `--color-*` and `--checkbox-*`
|
|
18
|
+
fallback targets `0.2.0` (#51) left in place specifically so a host still
|
|
19
|
+
supplying only the old name keeps working.
|
|
20
|
+
|
|
21
|
+
Zusammen, a downstream host, reads several of those definitions directly —
|
|
22
|
+
`var(--vscode-background)`, `var(--vscode-border)`, etc. — mapping its own
|
|
23
|
+
design-system tokens onto them, not overriding them. Nothing about that is
|
|
24
|
+
visible from inside this package: `src/` never references those names via
|
|
25
|
+
`var()`, so the existing consumed-token guard has nothing to check. `0.2.0`
|
|
26
|
+
happened to leave every defined token's name untouched (0 removed, 0 added,
|
|
27
|
+
comparing the published `0.1.1` and `0.2.0` packages) — but that was a
|
|
28
|
+
property of what `0.2.0` chose to do, not a guarantee enforced anywhere. A
|
|
29
|
+
future rename that touched definition sites as well as consumption sites
|
|
30
|
+
would drop Zusammen's palette silently: an unresolved CSS custom property
|
|
31
|
+
does not error, the declaration is just gone.
|
|
32
|
+
|
|
33
|
+
This is a genuinely different axis from every existing guard in this repo.
|
|
34
|
+
Two questions needed a considered answer:
|
|
35
|
+
|
|
36
|
+
1. **What should CI compare the live defined set against?** Every existing
|
|
37
|
+
contract test (`theming-contract`, `adr-citations`,
|
|
38
|
+
`package-manifest-contract`) computes both sides of its comparison
|
|
39
|
+
dynamically from current source — there is no prior example in this repo
|
|
40
|
+
of a checked-in snapshot a maintainer edits by hand as a deliberate,
|
|
41
|
+
reviewable act. A dynamic self-comparison can't work here: the whole
|
|
42
|
+
point is to catch names *disappearing* between one state and the next,
|
|
43
|
+
which requires a reference point independent of "whatever `styles.css`
|
|
44
|
+
currently says."
|
|
45
|
+
2. **Should an addition to the defined set ever fail CI?** The issue's
|
|
46
|
+
FR-006 and SC-002 are explicit that it must not — only a *disappearance*
|
|
47
|
+
of a previously-baselined name is a failure. (Acceptance Scenario 4's
|
|
48
|
+
prose reads, in isolation, as if new tokens need the baseline updated
|
|
49
|
+
before CI passes at all; FR-006/SC-002 are the more explicit,
|
|
50
|
+
cross-referencing pair on this exact point, so this decision follows
|
|
51
|
+
them: the guard is one-directional.)
|
|
52
|
+
|
|
53
|
+
## Decision
|
|
54
|
+
|
|
55
|
+
**A new checked-in baseline, `scripts/lib/theming-defined-tokens-baseline.json`,
|
|
56
|
+
records the flat, sorted array of every token name `defaultedTokens()`
|
|
57
|
+
(already existing, unchanged) finds declared in `src/styles.css`.**
|
|
58
|
+
`diffDefinedTokenBaseline(current, baseline)`, added to
|
|
59
|
+
`scripts/lib/theming-tokens.mjs` alongside the other pure extraction/compare
|
|
60
|
+
functions, returns `{ missing, added }` — both sorted, both computed by
|
|
61
|
+
name, never by count. `tests/theming-contract.test.ts` fails only when
|
|
62
|
+
`missing` is non-empty, and names the specific token(s) in the failure
|
|
63
|
+
message.
|
|
64
|
+
|
|
65
|
+
**The comparison is one-directional: `missing` fails CI, `added` never
|
|
66
|
+
does.** A token added to `styles.css` without a baseline update is not
|
|
67
|
+
itself an error — it's simply not yet protected by this guard until someone
|
|
68
|
+
runs the updater. This is deliberate (FR-006), not an oversight: the
|
|
69
|
+
baseline's job is to catch *disappearance*, not to force every addition
|
|
70
|
+
through an extra step before it can ship.
|
|
71
|
+
|
|
72
|
+
**A same-count rename still fails.** Because the diff is by name, a
|
|
73
|
+
declaration renamed in the same change (old name deleted, new name added,
|
|
74
|
+
total count unchanged) produces one entry in `missing` and one in `added` —
|
|
75
|
+
never a silent no-op. This reproduces, and would have caught, the exact
|
|
76
|
+
scenario the issue's Background describes: `0.2.0` was 42 defined tokens
|
|
77
|
+
before and 42 after, which a count-only check would have called clean.
|
|
78
|
+
|
|
79
|
+
**`scripts/update-theming-baseline.mjs`, a new script mirroring
|
|
80
|
+
`generate-theming-docs.mjs`'s shape, is the update mechanism**, exposed as
|
|
81
|
+
`pnpm docs:theming-baseline`. It reads `defaultedTokens('src/styles.css')`
|
|
82
|
+
and writes the sorted JSON array. A maintainer making a deliberate rename or
|
|
83
|
+
removal runs it in the same change that edits `styles.css`; that diff is
|
|
84
|
+
then a normal part of PR review, distinguishing "I meant to do this" from
|
|
85
|
+
"this disappeared by accident." This matches the repo's existing
|
|
86
|
+
`docs:theming` precedent rather than asking for a bare hand-edit of JSON.
|
|
87
|
+
|
|
88
|
+
**The guard evaluates `src/styles.css`, not a built `dist/`.** `pnpm test`
|
|
89
|
+
alone (no prior build) is what a local run and this guard's own CI job
|
|
90
|
+
invoke; `dist/` is gitignored and does not exist without a build step
|
|
91
|
+
first. `scripts/copy-assets.mjs` makes `dist/styles.css` a byte-for-byte
|
|
92
|
+
copy of `src/styles.css` with no transform, so evaluating the guard against
|
|
93
|
+
`src/` is representative of what ships (FR-008) without requiring a build
|
|
94
|
+
at test time — the same choice the pre-existing consumed-token guard
|
|
95
|
+
already made.
|
|
96
|
+
|
|
97
|
+
**The baseline lives in `scripts/lib/`, not a fixtures directory.** This
|
|
98
|
+
repo has no checked-in test-fixtures directory; the existing mutation tests
|
|
99
|
+
build throwaway fixtures under `os.tmpdir()`. The baseline is not test
|
|
100
|
+
scaffolding — it's permanent project data describing the current package
|
|
101
|
+
surface, the same category as `PREVIOUS_NAME` and `TOKEN_DESCRIPTIONS` in
|
|
102
|
+
the same module, so it's colocated with them.
|
|
103
|
+
|
|
104
|
+
## Consequences
|
|
105
|
+
|
|
106
|
+
**Good:**
|
|
107
|
+
|
|
108
|
+
- Removing or renaming a defined token without updating the baseline now
|
|
109
|
+
fails CI, naming the specific token(s) — the exact defence the issue's
|
|
110
|
+
Background describes as missing, closing the gap that let `0.2.0`'s
|
|
111
|
+
rename through by coincidence rather than by guarantee.
|
|
112
|
+
- The name-vs-count distinction is exercised directly by a mutation test
|
|
113
|
+
(`flags a same-count rename, not masked by an unchanged total`), so the
|
|
114
|
+
guard's core guarantee is demonstrated, not merely asserted.
|
|
115
|
+
- Adding a new token is a one-line, low-friction baseline update via
|
|
116
|
+
`pnpm docs:theming-baseline` — never a blocker on its own, per FR-006.
|
|
117
|
+
- The README now states, in the paragraph a first-time reader reaches
|
|
118
|
+
before the token table, that defined tokens (including pre-`0.2.0`
|
|
119
|
+
fallback names) are public API a host may read directly, and that
|
|
120
|
+
renaming or removing one is a breaking change under the existing
|
|
121
|
+
`0.x` versioning policy — answering SC-004 without requiring this ADR or
|
|
122
|
+
the issue as prerequisite reading.
|
|
123
|
+
|
|
124
|
+
**Bad / accepted:**
|
|
125
|
+
|
|
126
|
+
- The baseline is a plain hand-maintained (script-updated) JSON array with
|
|
127
|
+
no schema enforcement beyond `diffDefinedTokenBaseline`'s runtime
|
|
128
|
+
comparison — a malformed or hand-edited-incorrectly baseline would only
|
|
129
|
+
surface as an unexpected `missing`/`added` result at test time, not a
|
|
130
|
+
separate validation error. Accepted: the array is trivial enough (one
|
|
131
|
+
string per line) that this class of mistake is unlikely and, if it
|
|
132
|
+
happens, self-corrects on the next `pnpm docs:theming-baseline` run.
|
|
133
|
+
- Because the guard is one-directional by design, a token added to
|
|
134
|
+
`styles.css` and never baselined is not protected — if it is later
|
|
135
|
+
removed, that removal also goes uncaught (it was never in `missing`'s
|
|
136
|
+
input set to begin with). This is an accepted consequence of FR-006's
|
|
137
|
+
explicit choice, not a bug to "fix" by making the guard bidirectional;
|
|
138
|
+
a maintainer who wants a newly-added token protected needs to run the
|
|
139
|
+
updater, same as documenting it in the README table already requires a
|
|
140
|
+
separate `pnpm docs:theming` run today.
|
|
141
|
+
- `theming-tokens.d.mts` is hand-maintained with no automated sync check
|
|
142
|
+
against `theming-tokens.mjs`'s actual exports; forgetting to add
|
|
143
|
+
`diffDefinedTokenBaseline`'s declaration there would only surface as a
|
|
144
|
+
`pnpm typecheck` failure. Accepted as the same pre-existing tradeoff
|
|
145
|
+
ADR-085 already lives with for every other exported function in this
|
|
146
|
+
module.
|
|
147
|
+
|
|
148
|
+
**Neutral:**
|
|
149
|
+
|
|
150
|
+
- This ADR does not change which tokens are currently defined, consumed,
|
|
151
|
+
or documented, and does not decide whether `--liminis-editor-*` names
|
|
152
|
+
should eventually be defined natively with the legacy prefixes as an
|
|
153
|
+
alias layer (issue #79's Item 3) — that remains a follow-up candidate
|
|
154
|
+
issue, independent of this guard. See "Not decided here" below.
|
|
155
|
+
|
|
156
|
+
## Not decided here
|
|
157
|
+
|
|
158
|
+
Issue #79 named three things to do and ranked "Item 2 is the substance."
|
|
159
|
+
This decision delivers Item 1 (the README states the contract) and Item 2
|
|
160
|
+
(this baseline guard). **Item 3 — whether the package should eventually
|
|
161
|
+
*define* `--liminis-editor-*` names directly, with `--vscode-*`/
|
|
162
|
+
`--slashmd-*`/`--color-*`/`--checkbox-*` kept as an alias layer — is left
|
|
163
|
+
open.** It is a design decision (how an alias layer would interact with the
|
|
164
|
+
existing fallback chain from ADR-087), not a guard, and resolving it well is
|
|
165
|
+
independent of stating today's contract and protecting it against
|
|
166
|
+
shrinkage.
|
|
167
|
+
|
|
168
|
+
This gap is not just theoretical follow-up work: **a downstream consumer is
|
|
169
|
+
already blocked on it.** Zusammen currently reads this package's legacy
|
|
170
|
+
definitions directly (`var(--vscode-background)`, `var(--vscode-border)`,
|
|
171
|
+
etc., per this issue's Background) and wants to migrate those reads onto the
|
|
172
|
+
`--liminis-editor-*` vocabulary instead — but there is nothing to migrate
|
|
173
|
+
to, because this package defines zero `--liminis-editor-*` names today (see
|
|
174
|
+
"Has a default: No" for every row in the README's token table). That
|
|
175
|
+
migration is tracked at
|
|
176
|
+
[verveguy/zusammen#129](https://github.com/verveguy/zusammen/issues/129) and
|
|
177
|
+
cannot proceed until Item 3 is decided. Whoever picks up Item 3 should treat
|
|
178
|
+
that issue as a concrete acceptance case, not just this ADR's own
|
|
179
|
+
description of the gap.
|
|
180
|
+
|
|
181
|
+
## References
|
|
182
|
+
|
|
183
|
+
- Issue #79 (this decision)
|
|
184
|
+
- `scripts/lib/theming-tokens.mjs` — `defaultedTokens()` (unchanged, already
|
|
185
|
+
computed the "defined" set) and the new `diffDefinedTokenBaseline()`
|
|
186
|
+
- `scripts/lib/theming-defined-tokens-baseline.json` — the checked-in
|
|
187
|
+
baseline
|
|
188
|
+
- `scripts/update-theming-baseline.mjs` — the update mechanism
|
|
189
|
+
(`pnpm docs:theming-baseline`)
|
|
190
|
+
- `tests/theming-contract.test.ts` — the new "defined tokens are a public
|
|
191
|
+
API surface" `describe` block and its mutation tests
|
|
192
|
+
- `scripts/copy-assets.mjs` — establishes `dist/styles.css` as a
|
|
193
|
+
byte-for-byte copy of `src/styles.css`, the basis for FR-008
|
|
194
|
+
- `docs/decisions/adr-085.md` — the consumed/documented guard and the
|
|
195
|
+
shared-module, mutation-testing conventions this decision follows
|
|
196
|
+
- `docs/decisions/adr-087.md` — the `--liminis-editor-*` rename this
|
|
197
|
+
decision's guard would have caught had it also touched definition sites
|
|
198
|
+
- Issue #52 — the prior silent-drop incident that motivated the
|
|
199
|
+
resolves-without-host guard this new guard sits alongside
|
|
200
|
+
- [verveguy/zusammen#129](https://github.com/verveguy/zusammen/issues/129) —
|
|
201
|
+
the downstream migration blocked on issue #79's Item 3, deferred by this
|
|
202
|
+
decision (see "Not decided here")
|
package/docs/editor-api.md
CHANGED
|
@@ -95,6 +95,7 @@ function MyEditorWithOutline() {
|
|
|
95
95
|
| `<Editor>`'s `documentOutlineHandle` | `DocumentOutlineHandle` | Connects this editor instance to a `<DocumentOutline>`. Omit it and no outline plugin mounts at all — an outline-less consumer pays nothing for it. |
|
|
96
96
|
| `<DocumentOutline>`'s `handle` | `DocumentOutlineHandle` | The same object passed to `<Editor>`. |
|
|
97
97
|
| `<DocumentOutline>`'s `className` | `string` | Additional class on the outer `<nav>`, for your own placement/layout. |
|
|
98
|
+
| `<DocumentOutline>`'s `onEntrySelect` | `(entry: OutlineEntry) => void` | Optional. Fires with the clicked entry in addition to `handle.scrollToHeading`. Mainly useful on the markdown-derived path (below), where `scrollToHeading` no-ops. |
|
|
98
99
|
|
|
99
100
|
`createDocumentOutlineHandle()` returns the shared controller: a React
|
|
100
101
|
external store (`subscribe`/`getSnapshot`, so `<DocumentOutline>` re-renders
|
|
@@ -111,6 +112,103 @@ feeds it, `<DocumentOutline>` reads it.
|
|
|
111
112
|
- Visibility, placement, width-gating, and any collapse/persistence are
|
|
112
113
|
entirely up to you — `<DocumentOutline>` imposes no layout policy.
|
|
113
114
|
|
|
115
|
+
Most consumers embed this package via `<App>`, not `<Editor>` directly —
|
|
116
|
+
`<App>` forwards `documentOutlineHandle` straight through to its inner
|
|
117
|
+
`<Editor>`, so the same handle wires up an outline there too:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { useState } from 'react'
|
|
121
|
+
import { App, DocumentOutline, createDocumentOutlineHandle } from '@liminis/editor'
|
|
122
|
+
|
|
123
|
+
function MyAppWithOutline() {
|
|
124
|
+
const [markdown, setMarkdown] = useState('# Introduction')
|
|
125
|
+
const [outlineHandle] = useState(() => createDocumentOutlineHandle())
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div style={{ display: 'flex' }}>
|
|
129
|
+
<App
|
|
130
|
+
content={markdown}
|
|
131
|
+
onChange={setMarkdown}
|
|
132
|
+
documentOutlineHandle={outlineHandle}
|
|
133
|
+
/>
|
|
134
|
+
<aside>
|
|
135
|
+
<DocumentOutline handle={outlineHandle} />
|
|
136
|
+
</aside>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Raw-mode / markdown-derived entries (issue #84)
|
|
143
|
+
|
|
144
|
+
The Lexical path above requires a mounted `<Editor>`. A host with a raw
|
|
145
|
+
markdown mode — no `<Editor>`, no Lexical tree — can still drive the same
|
|
146
|
+
`<DocumentOutline>` component by feeding the handle from markdown text
|
|
147
|
+
directly, via `handle.publishFromMarkdown` and `handle.setActiveLine`:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { useEffect, useState } from 'react'
|
|
151
|
+
import { DocumentOutline, createDocumentOutlineHandle } from '@liminis/editor'
|
|
152
|
+
|
|
153
|
+
function MyRawModeWithOutline({ markdown }: { markdown: string }) {
|
|
154
|
+
const [outlineHandle] = useState(() => createDocumentOutlineHandle())
|
|
155
|
+
|
|
156
|
+
// Re-derive entries whenever the raw markdown text changes.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
outlineHandle.publishFromMarkdown(markdown)
|
|
159
|
+
}, [outlineHandle, markdown])
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div style={{ display: 'flex' }}>
|
|
163
|
+
<MyRawMarkdownEditor
|
|
164
|
+
markdown={markdown}
|
|
165
|
+
// Tell the outline which source line is at the top of your own
|
|
166
|
+
// (non-Lexical) editor's viewport as the reader scrolls, so it can
|
|
167
|
+
// resolve and highlight the active entry (FR-006).
|
|
168
|
+
onFirstVisibleLineChange={(line) => outlineHandle.setActiveLine(line)}
|
|
169
|
+
/>
|
|
170
|
+
<aside>
|
|
171
|
+
<DocumentOutline
|
|
172
|
+
handle={outlineHandle}
|
|
173
|
+
// `handle.scrollToHeading` no-ops on this path (no Lexical editor
|
|
174
|
+
// to scroll) — use `entry.line` against your own editor instead.
|
|
175
|
+
onEntrySelect={(entry) => myRawEditorRef.current?.scrollToLine(entry.line)}
|
|
176
|
+
/>
|
|
177
|
+
</aside>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `handle.publishFromMarkdown(markdown)` parses the markdown, derives H1–H5
|
|
184
|
+
entries using the same detection and text-extraction rules as the Lexical
|
|
185
|
+
path (order, nesting, inline formatting including inline code reduced to
|
|
186
|
+
plain text), and publishes them. Call it whenever your raw markdown text
|
|
187
|
+
changes.
|
|
188
|
+
- Each markdown-derived entry additionally carries a 1-based `line` —
|
|
189
|
+
matching mdast's `position.start.line` — since a raw-mode host has no
|
|
190
|
+
Lexical tree to scroll instead. `<DocumentOutline>`'s own click handling
|
|
191
|
+
calls `handle.scrollToHeading(entry.index)`, which is a no-op on this path
|
|
192
|
+
since there's no Lexical editor to scroll; pass `onEntrySelect` to receive
|
|
193
|
+
the clicked entry and scroll your own editor via `entry.line`, as shown
|
|
194
|
+
above.
|
|
195
|
+
- `handle.setActiveLine(line)` resolves which entry's heading line the
|
|
196
|
+
supplied line falls within or after (and before the next heading's line),
|
|
197
|
+
and publishes it as the active entry — the raw-mode equivalent of the
|
|
198
|
+
Lexical path's scroll-spy. Computing which line is "at the top" as the
|
|
199
|
+
reader scrolls is your responsibility, same as it is for a raw-mode host's
|
|
200
|
+
own scroll tracking; pass `null` before the first scroll for "no active
|
|
201
|
+
entry yet."
|
|
202
|
+
- One handle is fed by exactly one path at a time — the Lexical path (via
|
|
203
|
+
`<Editor documentOutlineHandle>`/`OutlinePlugin`) and the markdown path
|
|
204
|
+
(via `publishFromMarkdown`/`setActiveLine`) are independent and not
|
|
205
|
+
reconciled. Don't mount `<Editor documentOutlineHandle={outlineHandle}>`
|
|
206
|
+
and call `publishFromMarkdown` on the same handle at once.
|
|
207
|
+
- `deriveOutlineFromMarkdown(markdown)` and
|
|
208
|
+
`resolveActiveOutlineIndex(entries, line)` are also exported standalone,
|
|
209
|
+
for a host that wants the pure derivation without going through the
|
|
210
|
+
handle at all.
|
|
211
|
+
|
|
114
212
|
## The host seam
|
|
115
213
|
|
|
116
214
|
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.2",
|
|
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",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"test:coverage": "vitest run --coverage",
|
|
76
76
|
"verify:package": "node scripts/verify-package.mjs",
|
|
77
77
|
"docs:theming": "node scripts/generate-theming-docs.mjs",
|
|
78
|
+
"docs:theming-baseline": "node scripts/update-theming-baseline.mjs",
|
|
78
79
|
"demo": "node scripts/run-demo.mjs",
|
|
79
80
|
"build:examples": "node scripts/build-examples.mjs",
|
|
80
81
|
"build:site": "node scripts/build-site.mjs"
|