@brett_lamy/docstream-editor 0.6.0 → 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
@@ -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.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "./styles.css": "./src/styles.css"
34
34
  },
35
35
  "dependencies": {
36
- "@brett_lamy/docstream": "0.5.7",
36
+ "@brett_lamy/docstream": "0.6.0",
37
37
  "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
@@ -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)
@@ -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,
@@ -792,7 +793,13 @@ function ReferenceView({ node }: NodeViewProps) {
792
793
  </span>
793
794
  ) : (
794
795
  <span className={`gb-ref gb-ref-${kind}`}>
795
- {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
+ )}
796
803
  {id}
797
804
  </span>
798
805
  )}
@@ -818,7 +825,14 @@ export const GbReference = Node.create({
818
825
  return [{ tag: "span[data-gb-ref]" }]
819
826
  },
820
827
  renderHTML({ HTMLAttributes, node }) {
821
- 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
+ : "^"
822
836
  return [
823
837
  "span",
824
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
@@ -650,6 +650,23 @@
650
650
  .slash-item-active { background: var(--accent, #f4f4f5); }
651
651
  .slash-item svg { color: var(--muted-foreground, #6b7280); }
652
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
+ }
653
670
 
654
671
  .gb-code-live {
655
672
  display: flex;
@@ -819,6 +836,12 @@
819
836
  color: var(--gb-muted-foreground);
820
837
  }
821
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
+
822
845
  .gb-cite {
823
846
  display: inline-block;
824
847
  padding: 1px 5px;