@brett_lamy/docstream-editor 0.5.8 → 0.7.0

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
@@ -10,7 +10,7 @@ TipTap editor components for Docstream GitBook-style markdown documents.
10
10
  - TipTap extensions for common writing flows.
11
11
  - Conversion helpers between the Docstream AST and TipTap JSON.
12
12
  - Slash menu for inserting supported blocks.
13
- - Code block support through `lowlight`.
13
+ - Code block syntax highlighting through [`gpu-lexer`](https://gpu-lexer.vercel.app/) (language-agnostic, WebGPU; plain text where WebGPU is unavailable).
14
14
  - Table, task list, heading, quote, and inline formatting support.
15
15
  - Preserves Docstream/GitBook block semantics when converting back to markdown.
16
16
  - Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
@@ -109,6 +109,29 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
109
109
 
110
110
  Some blocks are intentionally represented as structured nodes rather than fully bespoke editing controls. They are preserved through parse, edit, and serialize flows so the document can continue to round-trip as GitBook-style markdown.
111
111
 
112
+ ## Mentions, channels, and codebases
113
+
114
+ Typing `@`, `#`, or `$` opens a picker and inserts a reference chip that serializes as
115
+ `@id`, `#id`, or `$id` (`$org/repo` works too). Feed each trigger a static list or an
116
+ async `(query) => options` function; options can carry a `label`, `description`, and
117
+ `group` (rendered as section headers). `$` is only enabled when `codebases` is set, so
118
+ dollar signs in ordinary prose never open a menu. Free-form entry is always offered.
119
+
120
+ ```tsx
121
+ <GitbookEditor
122
+ markdown={md}
123
+ onChange={setMd}
124
+ references={{
125
+ mentions: [
126
+ ...people.map((p) => ({ id: p.handle, label: p.name, description: p.email, group: "People" })),
127
+ ...agents.map((a) => ({ id: a.handle, label: a.name, description: a.harness, group: "Agents" })),
128
+ ],
129
+ tags: async (query) => searchChannels(query), // [{ id: "general", description: "channel" }]
130
+ codebases: async (query) => searchRepos(query), // [{ id: "reading-room" }]
131
+ }}
132
+ />
133
+ ```
134
+
112
135
  ## Edit a referenced source file
113
136
 
114
137
  `GitbookEditor` edits the Markdown composition, including a structured
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.5.8",
3
+ "version": "0.7.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,13 +33,13 @@
33
33
  "./styles.css": "./src/styles.css"
34
34
  },
35
35
  "dependencies": {
36
- "@brett_lamy/docstream": "0.5.7",
37
- "lowlight": "^3.3.0",
36
+ "@brett_lamy/docstream": "0.6.0",
37
+ "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tiptap/core": "^3.26.1",
42
- "@tiptap/extension-code-block-lowlight": "^3.26.1",
42
+ "@tiptap/extension-code-block": "^3.26.1",
43
43
  "@tiptap/extension-list": "^3.26.1",
44
44
  "@tiptap/extension-placeholder": "^3.26.1",
45
45
  "@tiptap/extension-table": "^3.26.1",
@@ -56,7 +56,7 @@
56
56
  "devDependencies": {
57
57
  "@agent-wasm/core": "^0.4.0",
58
58
  "@tiptap/core": "^3.26.1",
59
- "@tiptap/extension-code-block-lowlight": "^3.26.1",
59
+ "@tiptap/extension-code-block": "^3.26.1",
60
60
  "@tiptap/extension-list": "^3.26.1",
61
61
  "@tiptap/extension-placeholder": "^3.26.1",
62
62
  "@tiptap/extension-table": "^3.26.1",
@@ -13,7 +13,7 @@ import {
13
13
  import { parseMarkdown, serializeMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
14
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
15
15
  import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
16
- import { createGitbookExtensions } from "./extensions"
16
+ import { createGitbookExtensions, type ReferenceSources } from "./extensions"
17
17
  import { EditorRuntimeProvider } from "./runtime"
18
18
  import type { SlashItem } from "./slash-menu"
19
19
 
@@ -26,8 +26,11 @@ export interface GitbookEditorProps {
26
26
  toolbar?: boolean
27
27
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
28
28
  slashMenu?: boolean | { items?: SlashItem[] }
29
- /** The "@" / "#" reference chip pickers: true (default), false, or known id lists. */
30
- references?: boolean | { mentions?: string[]; tags?: string[] }
29
+ /**
30
+ * Reference chip pickers: true (default "@"/"#"), false, or per-trigger sources —
31
+ * static lists or async `(query) => options`. A `codebases` source enables "$".
32
+ */
33
+ references?: boolean | ReferenceSources
31
34
  /** Whether the document is editable (default true). */
32
35
  editable?: boolean
33
36
  /** Placeholder shown in an empty document. */
@@ -203,7 +203,8 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
203
203
  .map((n): Inline => {
204
204
  if (n.type === "gbReference") {
205
205
  const a = n.attrs ?? {}
206
- const kind = a.kind === "tag" ? "tag" : a.kind === "citation" ? "citation" : "mention"
206
+ const kind =
207
+ a.kind === "tag" || a.kind === "codebase" || a.kind === "citation" ? a.kind : "mention"
207
208
  return {
208
209
  type: "reference",
209
210
  kind,
@@ -6,7 +6,7 @@ import { getSchema, type AnyExtension } from "@tiptap/core"
6
6
  import type { Schema } from "@tiptap/pm/model"
7
7
  import { GbCodeBlock, gitbookNodes } from "./nodes"
8
8
  import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
9
- import { createReferenceSuggestion } from "./reference-menu"
9
+ import { createReferenceSuggestion, type ReferenceSource } from "./reference-menu"
10
10
 
11
11
  // Carries GitBook's data-view (e.g. "cards") through the editor untouched.
12
12
  export const GbTable = Table.extend({
@@ -15,15 +15,26 @@ export const GbTable = Table.extend({
15
15
  },
16
16
  })
17
17
 
18
+ export interface ReferenceSources {
19
+ /** "@" — people, agents, … */
20
+ mentions?: ReferenceSource
21
+ /** "#" — channels, tags, … */
22
+ tags?: ReferenceSource
23
+ /** "$" — codebases (`$repo` / `$org/repo`). Enables the "$" trigger. */
24
+ codebases?: ReferenceSource
25
+ }
26
+
18
27
  export interface GitbookExtensionOptions {
19
28
  placeholder?: string
20
29
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
21
30
  slashMenu?: boolean | { items?: SlashItem[] }
22
31
  /**
23
- * The "@" / "#" reference chip pickers: true (default), false, or lists of
24
- * known ids to offer. Free-form entry always works.
32
+ * The reference chip pickers: true (default: "@" and "#" with free-form entry),
33
+ * false, or per-trigger sources — static lists or async `(query) => options`.
34
+ * "$" (codebases) is only enabled when a `codebases` source is given, so a
35
+ * dollar sign in ordinary prose never opens a menu.
25
36
  */
26
- references?: boolean | { mentions?: string[]; tags?: string[] }
37
+ references?: boolean | ReferenceSources
27
38
  /**
28
39
  * Disable StarterKit's built-in undo/redo. Required when wiring Yjs
29
40
  * Collaboration, which provides its own shared history.
@@ -73,6 +84,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
73
84
  createReferenceSuggestion({ char: "@", ...(opts.mentions ? { items: opts.mentions } : {}) }),
74
85
  createReferenceSuggestion({ char: "#", ...(opts.tags ? { items: opts.tags } : {}) })
75
86
  )
87
+ if (opts.codebases) list.push(createReferenceSuggestion({ char: "$", items: opts.codebases }))
76
88
  }
77
89
 
78
90
  list.push(...extensions)
@@ -0,0 +1,139 @@
1
+ import type { Node as PmNode } from "@tiptap/pm/model"
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state"
3
+ import { Decoration, DecorationSet } from "@tiptap/pm/view"
4
+
5
+ /**
6
+ * Code-block syntax highlighting through gpu-lexer — the same language-agnostic WebGPU
7
+ * lexer docstream's renderer uses, so edit and preview agree. gpu-lexer is async, so
8
+ * spans are cached by block text: decorations are built synchronously from the cache,
9
+ * missing blocks are tokenized in the background, and a meta transaction re-decorates
10
+ * when results land. Without WebGPU `parse` rejects and blocks stay plain.
11
+ */
12
+
13
+ type SpanType =
14
+ | "plain"
15
+ | "comment"
16
+ | "string"
17
+ | "number"
18
+ | "keyword"
19
+ | "type"
20
+ | "function"
21
+ | "constant"
22
+ | "operator"
23
+
24
+ interface Span {
25
+ type: SpanType
26
+ start: number
27
+ end: number
28
+ }
29
+
30
+ const MAX_CODE_UNITS = 128 * 1024
31
+ const CACHE_LIMIT = 256
32
+
33
+ let lexer: Promise<(code: string) => Promise<Span[]>> | undefined
34
+ const cache = new Map<string, Span[] | null>()
35
+ const pending = new Map<string, Promise<void>>()
36
+
37
+ function remember(code: string, spans: Span[] | null) {
38
+ cache.set(code, spans)
39
+ if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value as string)
40
+ }
41
+
42
+ function tokenize(code: string): Promise<void> {
43
+ const inflight = pending.get(code)
44
+ if (inflight) return inflight
45
+ // Loaded on first code block so the model stays out of the editor's initial bundle.
46
+ lexer ??= import("gpu-lexer").then((module) => module.parse as (code: string) => Promise<Span[]>)
47
+ const run = lexer
48
+ .then((parse) => parse(code))
49
+ .then(
50
+ (spans) => remember(code, spans),
51
+ () => remember(code, null),
52
+ )
53
+ .finally(() => pending.delete(code))
54
+ pending.set(code, run)
55
+ return run
56
+ }
57
+
58
+ function highlightable(code: string) {
59
+ return code.length > 0 && code.length <= MAX_CODE_UNITS
60
+ }
61
+
62
+ /**
63
+ * Decorations from the span cache. A block whose new text is not tokenized yet keeps its
64
+ * previous decorations mapped through the edit, so typing never flickers to plain.
65
+ */
66
+ function decorationsFor(doc: PmNode, nodeName: string, previous?: DecorationSet): DecorationSet {
67
+ const decorations: Decoration[] = []
68
+ doc.descendants((node, pos) => {
69
+ if (node.type.name !== nodeName) return true
70
+ const from = pos + 1
71
+ const spans = cache.get(node.textContent)
72
+ if (!spans) {
73
+ if (previous) decorations.push(...previous.find(from, from + node.content.size))
74
+ return false
75
+ }
76
+ for (const span of spans) {
77
+ if (span.type === "plain" || span.end <= span.start) continue
78
+ decorations.push(
79
+ Decoration.inline(from + span.start, from + span.end, { class: `gb-syntax-${span.type}` }),
80
+ )
81
+ }
82
+ return false
83
+ })
84
+ return DecorationSet.create(doc, decorations)
85
+ }
86
+
87
+ function missingBlocks(doc: PmNode, nodeName: string): string[] {
88
+ const missing = new Set<string>()
89
+ doc.descendants((node) => {
90
+ if (node.type.name !== nodeName) return true
91
+ const code = node.textContent
92
+ if (highlightable(code) && !cache.has(code)) missing.add(code)
93
+ return false
94
+ })
95
+ return [...missing]
96
+ }
97
+
98
+ export function gpuHighlightPlugin(nodeName: string): Plugin {
99
+ const key = new PluginKey<DecorationSet>("gpuHighlight")
100
+ return new Plugin<DecorationSet>({
101
+ key,
102
+ state: {
103
+ init: (_, state) => decorationsFor(state.doc, nodeName),
104
+ apply: (tr, previous) =>
105
+ tr.docChanged || tr.getMeta(key)
106
+ ? decorationsFor(tr.doc, nodeName, previous.map(tr.mapping, tr.doc))
107
+ : previous,
108
+ },
109
+ props: {
110
+ decorations(state) {
111
+ return key.getState(state)
112
+ },
113
+ },
114
+ view(view) {
115
+ let destroyed = false
116
+ let timer: ReturnType<typeof setTimeout> | undefined
117
+ const request = () => {
118
+ for (const code of missingBlocks(view.state.doc, nodeName)) {
119
+ void tokenize(code).then(() => {
120
+ if (!destroyed) view.dispatch(view.state.tr.setMeta(key, true))
121
+ })
122
+ }
123
+ }
124
+ request()
125
+ return {
126
+ update: (_view, previous) => {
127
+ if (previous.doc.eq(view.state.doc)) return
128
+ // One tokenize per typing pause, not per keystroke.
129
+ clearTimeout(timer)
130
+ timer = setTimeout(request, 150)
131
+ },
132
+ destroy: () => {
133
+ destroyed = true
134
+ clearTimeout(timer)
135
+ },
136
+ }
137
+ },
138
+ })
139
+ }
@@ -1,6 +1,6 @@
1
1
  import { Node, mergeAttributes } from "@tiptap/core"
2
- import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
3
- import { common, createLowlight } from "lowlight"
2
+ import { CodeBlock } from "@tiptap/extension-code-block"
3
+ import { gpuHighlightPlugin } from "./gpu-highlight"
4
4
  import { useEffect, useMemo, useState } from "react"
5
5
  import {
6
6
  NodeViewContent,
@@ -13,6 +13,7 @@ import {
13
13
  AtSign,
14
14
  CheckCircle2,
15
15
  ChevronDown,
16
+ FolderGit2,
16
17
  Hash,
17
18
  Info,
18
19
  Link2,
@@ -718,7 +719,7 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
718
719
  )
719
720
  }
720
721
 
721
- export const GbCodeBlock = CodeBlockLowlight.extend({
722
+ export const GbCodeBlock = CodeBlock.extend({
722
723
  addAttributes() {
723
724
  return {
724
725
  ...this.parent?.(),
@@ -731,7 +732,10 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
731
732
  addNodeView() {
732
733
  return ReactNodeViewRenderer(CodeView)
733
734
  },
734
- }).configure({ lowlight: createLowlight(common) })
735
+ addProseMirrorPlugins() {
736
+ return [...(this.parent?.() ?? []), gpuHighlightPlugin(this.name)]
737
+ },
738
+ })
735
739
 
736
740
  // ---------- Inline image (GitHub badge style) ----------
737
741
 
@@ -789,7 +793,13 @@ function ReferenceView({ node }: NodeViewProps) {
789
793
  </span>
790
794
  ) : (
791
795
  <span className={`gb-ref gb-ref-${kind}`}>
792
- {kind === "mention" ? <AtSign className="gb-ref-icon" /> : <Hash className="gb-ref-icon" />}
796
+ {kind === "mention" ? (
797
+ <AtSign className="gb-ref-icon" />
798
+ ) : kind === "codebase" ? (
799
+ <FolderGit2 className="gb-ref-icon" />
800
+ ) : (
801
+ <Hash className="gb-ref-icon" />
802
+ )}
793
803
  {id}
794
804
  </span>
795
805
  )}
@@ -815,7 +825,14 @@ export const GbReference = Node.create({
815
825
  return [{ tag: "span[data-gb-ref]" }]
816
826
  },
817
827
  renderHTML({ HTMLAttributes, node }) {
818
- const sigil = node.attrs.kind === "mention" ? "@" : node.attrs.kind === "tag" ? "#" : "^"
828
+ const sigil =
829
+ node.attrs.kind === "mention"
830
+ ? "@"
831
+ : node.attrs.kind === "tag"
832
+ ? "#"
833
+ : node.attrs.kind === "codebase"
834
+ ? "$"
835
+ : "^"
819
836
  return [
820
837
  "span",
821
838
  mergeAttributes(HTMLAttributes, { "data-gb-ref": node.attrs.kind, "data-gb-ref-id": node.attrs.id }),
@@ -1,32 +1,88 @@
1
1
  import { Extension, type Editor, type Range } from "@tiptap/core"
2
2
  import Suggestion from "@tiptap/suggestion"
3
3
  import { PluginKey } from "@tiptap/pm/state"
4
- import { AtSign, Hash } from "lucide-react"
4
+ import { AtSign, FolderGit2, Hash, type LucideIcon } from "lucide-react"
5
5
 
6
6
  import { createSuggestionRender, type SuggestionMenuItem } from "./suggestion-menu"
7
7
 
8
- export type ReferenceTrigger = "@" | "#"
8
+ export type ReferenceTrigger = "@" | "#" | "$"
9
9
 
10
- interface ReferenceItem extends SuggestionMenuItem {
10
+ /** One pickable reference: the chip stores `id`; the rest only decorates the menu. */
11
+ export interface ReferenceOption {
11
12
  id: string
13
+ /** Display name shown next to the id (e.g. "Luna" for `@luna`). */
14
+ label?: string
15
+ /** Secondary line (e.g. an email, "agent · claude-code", a topic). */
16
+ description?: string
17
+ /** Section header; consecutive options with the same group are listed together. */
18
+ group?: string
12
19
  }
13
20
 
21
+ /**
22
+ * Where a trigger's options come from: a static list (filtered by the editor), or a
23
+ * function the host answers per query — sync or async, already filtered.
24
+ */
25
+ export type ReferenceSource =
26
+ | readonly (string | ReferenceOption)[]
27
+ | ((query: string) => readonly ReferenceOption[] | Promise<readonly ReferenceOption[]>)
28
+
14
29
  export interface ReferenceSuggestionOptions {
15
30
  char: ReferenceTrigger
16
- /** Known ids offered in the picker; free-form entry always works. */
17
- items?: string[]
31
+ /** Options offered in the picker; free-form entry works unless disabled. */
32
+ items?: ReferenceSource
33
+ /** Offer inserting the raw query when nothing matches exactly (default true). */
34
+ allowFreeForm?: boolean
35
+ }
36
+
37
+ interface ReferenceItem extends SuggestionMenuItem {
38
+ id: string
18
39
  }
19
40
 
20
- const KIND: Record<ReferenceTrigger, "mention" | "tag"> = { "@": "mention", "#": "tag" }
41
+ const KIND: Record<ReferenceTrigger, "mention" | "tag" | "codebase"> = {
42
+ "@": "mention",
43
+ "#": "tag",
44
+ $: "codebase",
45
+ }
46
+ const ICON: Record<ReferenceTrigger, LucideIcon> = { "@": AtSign, "#": Hash, $: FolderGit2 }
47
+ const EMPTY: Record<ReferenceTrigger, string> = {
48
+ "@": "Type a name to mention",
49
+ "#": "Type a channel or tag",
50
+ $: "Type a codebase",
51
+ }
52
+ const MAX_OPTIONS = 50
53
+
54
+ function normalize(option: string | ReferenceOption): ReferenceOption {
55
+ return typeof option === "string" ? { id: option } : option
56
+ }
57
+
58
+ function matches(option: ReferenceOption, query: string): boolean {
59
+ if (!query) return true
60
+ return [option.id, option.label ?? ""].some((value) => value.toLowerCase().includes(query))
61
+ }
62
+
63
+ /** Resolve a source for a query into options (exported for hosts' tests). */
64
+ export async function resolveReferenceOptions(
65
+ source: ReferenceSource | undefined,
66
+ query: string,
67
+ ): Promise<ReferenceOption[]> {
68
+ if (source === undefined) return []
69
+ if (typeof source === "function") return [...(await source(query))].slice(0, MAX_OPTIONS)
70
+ const q = query.toLowerCase()
71
+ return source.map(normalize).filter((option) => matches(option, q)).slice(0, MAX_OPTIONS)
72
+ }
21
73
 
22
74
  /**
23
- * An `@mention` / `#tag` picker built on @tiptap/suggestion. Filters the host's
24
- * known ids and always offers inserting the raw query, so typing `@anything⏎`
25
- * works with no list configured.
75
+ * An `@mention` / `#tag` / `$codebase` picker built on @tiptap/suggestion. Options come
76
+ * from the host (static or async) and the raw query is offered as a free-form entry,
77
+ * so `@anything⏎` works with no source configured.
26
78
  */
27
- export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggestionOptions) {
79
+ export function createReferenceSuggestion({
80
+ char,
81
+ items,
82
+ allowFreeForm = true,
83
+ }: ReferenceSuggestionOptions) {
28
84
  const kind = KIND[char]
29
- const icon = char === "@" ? AtSign : Hash
85
+ const icon = ICON[char]
30
86
  return Extension.create({
31
87
  name: `${kind}Suggestion`,
32
88
  addProseMirrorPlugins() {
@@ -39,13 +95,26 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
39
95
  // Each Suggestion plugin needs its own key — the default is shared
40
96
  // with the slash menu and would collide.
41
97
  pluginKey: new PluginKey(`${kind}Suggestion`),
42
- items: ({ query }) => {
43
- const q = query.toLowerCase()
44
- const matches = items
45
- .filter((id) => id.toLowerCase().includes(q))
46
- .map((id) => ({ id, title: `${char}${id}`, icon }))
47
- const freeForm = query && !items.some((id) => id.toLowerCase() === q)
48
- return freeForm ? [...matches, { id: query, title: `${char}${query}`, icon }] : matches
98
+ items: async ({ query }) => {
99
+ let options: ReferenceOption[]
100
+ try {
101
+ options = await resolveReferenceOptions(items, query)
102
+ } catch {
103
+ options = []
104
+ }
105
+ const menu: ReferenceItem[] = options.map((option) => ({
106
+ id: option.id,
107
+ title: `${char}${option.id}`,
108
+ icon,
109
+ ...(option.label || option.description
110
+ ? { description: [option.label, option.description].filter(Boolean).join(" · ") }
111
+ : {}),
112
+ ...(option.group ? { group: option.group } : {}),
113
+ }))
114
+ const exact = options.some((option) => option.id.toLowerCase() === query.toLowerCase())
115
+ return allowFreeForm && query && !exact
116
+ ? [...menu, { id: query, title: `${char}${query}`, icon }]
117
+ : menu
49
118
  },
50
119
  command: ({ editor, range, props }) => {
51
120
  ;(editor as Editor)
@@ -58,9 +127,7 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
58
127
  ])
59
128
  .run()
60
129
  },
61
- render: createSuggestionRender<ReferenceItem>(
62
- char === "@" ? "Type a name to mention" : "Type a tag"
63
- ),
130
+ render: createSuggestionRender<ReferenceItem>(EMPTY[char]),
64
131
  }),
65
132
  ]
66
133
  },
@@ -12,11 +12,15 @@ import type { SuggestionProps } from "@tiptap/suggestion"
12
12
  import { ReactRenderer } from "@tiptap/react"
13
13
  import type { LucideIcon } from "lucide-react"
14
14
 
15
- // Shared popup used by the "/" slash menu and the "@"/"#" reference menus.
15
+ // Shared popup used by the "/" slash menu and the "@"/"#"/"$" reference menus.
16
16
 
17
17
  export interface SuggestionMenuItem {
18
18
  title: string
19
19
  icon: LucideIcon
20
+ /** Secondary text shown after the title (e.g. a display name or email). */
21
+ description?: string
22
+ /** Section header; shown when it differs from the previous item's group. */
23
+ group?: string
20
24
  }
21
25
 
22
26
  export interface MenuProps<Item extends SuggestionMenuItem> {
@@ -97,21 +101,26 @@ export const SuggestionMenuView = forwardRef<MenuHandle, MenuProps<SuggestionMen
97
101
  >
98
102
  {items.length === 0 && <div className="slash-empty">{emptyLabel}</div>}
99
103
  {items.map((item, i) => (
100
- <button
101
- key={item.title}
102
- ref={(el) => {
103
- itemRefs.current[i] = el
104
- }}
105
- className={`slash-item ${i === index ? "slash-item-active" : ""}`}
106
- onMouseEnter={() => setIndex(i)}
107
- onMouseDown={(e) => {
108
- e.preventDefault()
109
- command(item)
110
- }}
111
- >
112
- <item.icon className="size-4" />
113
- <span>{item.title}</span>
114
- </button>
104
+ <div key={`${item.group ?? ""}:${item.title}:${i}`} className="slash-row">
105
+ {item.group && item.group !== items[i - 1]?.group && (
106
+ <div className="slash-group">{item.group}</div>
107
+ )}
108
+ <button
109
+ ref={(el) => {
110
+ itemRefs.current[i] = el
111
+ }}
112
+ className={`slash-item ${i === index ? "slash-item-active" : ""}`}
113
+ onMouseEnter={() => setIndex(i)}
114
+ onMouseDown={(e) => {
115
+ e.preventDefault()
116
+ command(item)
117
+ }}
118
+ >
119
+ <item.icon className="size-4" />
120
+ <span>{item.title}</span>
121
+ {item.description && <span className="slash-item-description">{item.description}</span>}
122
+ </button>
123
+ </div>
115
124
  ))}
116
125
  </div>
117
126
  )
package/src/index.ts CHANGED
@@ -6,12 +6,17 @@ export type { PMNode } from "./editor/convert"
6
6
  // Building blocks for composing a custom editor (e.g. with Yjs collaboration
7
7
  // or a bespoke slash menu) instead of the batteries-included GitbookEditor.
8
8
  export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
9
- export type { GitbookExtensionOptions } from "./editor/extensions"
9
+ export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
10
10
  export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
11
11
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
12
12
  export type { SlashItem } from "./editor/slash-menu"
13
- export { createReferenceSuggestion } from "./editor/reference-menu"
14
- export type { ReferenceSuggestionOptions, ReferenceTrigger } from "./editor/reference-menu"
13
+ export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
14
+ export type {
15
+ ReferenceOption,
16
+ ReferenceSource,
17
+ ReferenceSuggestionOptions,
18
+ ReferenceTrigger,
19
+ } from "./editor/reference-menu"
15
20
  export { SuggestionMenuView, createSuggestionRender } from "./editor/suggestion-menu"
16
21
  export type { SuggestionMenuItem } from "./editor/suggestion-menu"
17
22
  export { ReactDemoEditor } from "./editor/ReactDemoEditor"
package/src/styles.css CHANGED
@@ -582,31 +582,15 @@
582
582
  color: var(--gb-code-fg);
583
583
  }
584
584
  .gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
585
- .gb-code > pre code .hljs-comment { font-style: italic; }
586
- .gb-code .hljs-keyword,
587
- .gb-code .hljs-selector-tag,
588
- .gb-code .hljs-built_in,
589
- .gb-code .hljs-type,
590
- .gb-code .hljs-tag,
591
- .gb-code .hljs-name { color: #c792ea; }
592
- .gb-code .hljs-string,
593
- .gb-code .hljs-attr,
594
- .gb-code .hljs-symbol,
595
- .gb-code .hljs-bullet { color: #a5d6a7; }
596
- .gb-code .hljs-title,
597
- .gb-code .hljs-section,
598
- .gb-code .hljs-function .hljs-title { color: #82aaff; }
599
- .gb-code .hljs-number,
600
- .gb-code .hljs-literal,
601
- .gb-code .hljs-variable,
602
- .gb-code .hljs-template-variable { color: #f78c6c; }
603
- .gb-code .hljs-comment,
604
- .gb-code .hljs-quote,
605
- .gb-code .hljs-meta { color: #777784; }
606
- .gb-code .hljs-operator,
607
- .gb-code .hljs-punctuation { color: #89ddff; }
608
- .gb-code .hljs-deletion { color: #ff6b6b; }
609
- .gb-code .hljs-addition { color: #69db7c; }
585
+ /* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
586
+ .gb-code .gb-syntax-comment { color: #6b6b78; font-style: italic; }
587
+ .gb-code .gb-syntax-string { color: #a5d6a7; }
588
+ .gb-code .gb-syntax-number { color: #f78c6c; }
589
+ .gb-code .gb-syntax-keyword { color: #c792ea; }
590
+ .gb-code .gb-syntax-type { color: #ffcb6b; }
591
+ .gb-code .gb-syntax-function { color: #82aaff; }
592
+ .gb-code .gb-syntax-constant { color: #f07178; }
593
+ .gb-code .gb-syntax-operator { color: #89ddff; }
610
594
 
611
595
  /* ── Updates / changelog (mirrors .docs-updates) ────────── */
612
596
  .gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }
@@ -666,6 +650,23 @@
666
650
  .slash-item-active { background: var(--accent, #f4f4f5); }
667
651
  .slash-item svg { color: var(--muted-foreground, #6b7280); }
668
652
  .slash-empty { padding: 8px 10px; font-size: 13px; color: var(--muted-foreground, #6b7280); }
653
+ .slash-group {
654
+ padding: 8px 10px 3px;
655
+ font-size: 11px;
656
+ font-weight: 600;
657
+ letter-spacing: 0.04em;
658
+ text-transform: uppercase;
659
+ color: var(--muted-foreground, #6b7280);
660
+ }
661
+ .slash-item-description {
662
+ margin-left: auto;
663
+ padding-left: 12px;
664
+ overflow: hidden;
665
+ font-size: 12px;
666
+ white-space: nowrap;
667
+ text-overflow: ellipsis;
668
+ color: var(--muted-foreground, #6b7280);
669
+ }
669
670
 
670
671
  .gb-code-live {
671
672
  display: flex;
@@ -835,6 +836,12 @@
835
836
  color: var(--gb-muted-foreground);
836
837
  }
837
838
 
839
+ .gb-ref-codebase {
840
+ border: 1px solid rgba(34, 197, 94, 0.45);
841
+ background: rgba(34, 197, 94, 0.1);
842
+ color: rgb(22, 163, 74);
843
+ }
844
+
838
845
  .gb-cite {
839
846
  display: inline-block;
840
847
  padding: 1px 5px;