@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 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, not so you can complete one.
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, and ADR-087 for the rename and its
222
- compatibility design.
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: () => handle.scrollToHeading(entry.index), children: [_jsx("span", { className: "editor-outline-indicator", "aria-hidden": "true" }), _jsx("span", { className: "editor-outline-item-text", children: entry.text })] }) }, entry.index));
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")
@@ -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.0",
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"