@brett_lamy/docstream-editor 0.1.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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@brett_lamy/docstream-editor",
3
+ "version": "0.1.0",
4
+ "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "sideEffects": [
12
+ "**/*.css"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "import": "./src/index.ts"
18
+ },
19
+ "./styles.css": "./src/styles.css"
20
+ },
21
+ "dependencies": {
22
+ "@tiptap/core": "^3.26.1",
23
+ "@tiptap/extension-code-block-lowlight": "^3.26.1",
24
+ "@tiptap/extension-list": "^3.26.1",
25
+ "@tiptap/extension-placeholder": "^3.26.1",
26
+ "@tiptap/extension-table": "^3.26.1",
27
+ "@tiptap/react": "^3.26.1",
28
+ "@tiptap/starter-kit": "^3.26.1",
29
+ "@tiptap/suggestion": "^3.26.1",
30
+ "@brett_lamy/docstream": "0.1.0",
31
+ "lowlight": "^3.3.0",
32
+ "lucide-react": "^1.17.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=18"
36
+ }
37
+ }
@@ -0,0 +1,135 @@
1
+ import { useEffect, useRef } from "react"
2
+ import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"
3
+ import StarterKit from "@tiptap/starter-kit"
4
+ import Placeholder from "@tiptap/extension-placeholder"
5
+ import { TaskItem, TaskList } from "@tiptap/extension-list"
6
+ import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
7
+ import {
8
+ Bold,
9
+ Code as CodeIcon,
10
+ Italic,
11
+ Link2,
12
+ SlashSquare,
13
+ Strikethrough,
14
+ } from "lucide-react"
15
+
16
+ import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
17
+ import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
18
+ import { GbCodeBlock, gitbookNodes } from "./nodes"
19
+ import { SlashMenu } from "./slash-menu"
20
+
21
+ export interface GitbookEditorProps {
22
+ markdown: string
23
+ onChange: (markdown: string) => void
24
+ }
25
+
26
+ // Carries GitBook's data-view (e.g. "cards") through the editor untouched.
27
+ const GbTable = Table.extend({
28
+ addAttributes() {
29
+ return { ...this.parent?.(), view: { default: null } }
30
+ },
31
+ })
32
+
33
+ function ToolbarButton({
34
+ onClick,
35
+ active,
36
+ title,
37
+ children,
38
+ }: {
39
+ onClick: () => void
40
+ active?: boolean
41
+ title: string
42
+ children: React.ReactNode
43
+ }) {
44
+ return (
45
+ <button
46
+ type="button"
47
+ title={title}
48
+ className={`gb-tool ${active ? "gb-tool-active" : ""}`}
49
+ onMouseDown={(e) => {
50
+ e.preventDefault()
51
+ onClick()
52
+ }}
53
+ >
54
+ {children}
55
+ </button>
56
+ )
57
+ }
58
+
59
+ function Toolbar({ editor }: { editor: TiptapEditor }) {
60
+ const chain = () => editor.chain().focus()
61
+ return (
62
+ <div className="gb-toolbar">
63
+ <ToolbarButton title="Bold" active={editor.isActive("bold")} onClick={() => chain().toggleBold().run()}>
64
+ <Bold className="size-4" />
65
+ </ToolbarButton>
66
+ <ToolbarButton title="Italic" active={editor.isActive("italic")} onClick={() => chain().toggleItalic().run()}>
67
+ <Italic className="size-4" />
68
+ </ToolbarButton>
69
+ <ToolbarButton title="Strike" active={editor.isActive("strike")} onClick={() => chain().toggleStrike().run()}>
70
+ <Strikethrough className="size-4" />
71
+ </ToolbarButton>
72
+ <ToolbarButton title="Inline code" active={editor.isActive("code")} onClick={() => chain().toggleCode().run()}>
73
+ <CodeIcon className="size-4" />
74
+ </ToolbarButton>
75
+ <ToolbarButton
76
+ title="Link"
77
+ active={editor.isActive("link")}
78
+ onClick={() => {
79
+ const url = window.prompt("URL")
80
+ if (url) chain().setLink({ href: url }).run()
81
+ else chain().unsetLink().run()
82
+ }}
83
+ >
84
+ <Link2 className="size-4" />
85
+ </ToolbarButton>
86
+ <span className="gb-toolbar-hint">
87
+ <SlashSquare className="size-3.5" /> Type <kbd>/</kbd> for blocks
88
+ </span>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export function GitbookEditor({ markdown, onChange }: GitbookEditorProps) {
94
+ // Tracks the markdown the editor itself produced, so external updates
95
+ // (file switches) reset content but our own onChange echoes don't.
96
+ const lastEmitted = useRef<string | null>(null)
97
+
98
+ const editor = useEditor({
99
+ extensions: [
100
+ StarterKit.configure({ codeBlock: false }),
101
+ GbCodeBlock,
102
+ TaskList,
103
+ TaskItem.configure({ nested: true }),
104
+ GbTable.configure({ resizable: false }),
105
+ TableRow,
106
+ TableHeader,
107
+ TableCell,
108
+ Placeholder.configure({ placeholder: "Write, or type / to insert a block…" }),
109
+ SlashMenu,
110
+ ...gitbookNodes,
111
+ ],
112
+ content: astToTiptap(parseMarkdown(markdown)),
113
+ onUpdate({ editor }) {
114
+ const md = serializeMarkdown(tiptapToAst(editor.getJSON() as PMNode))
115
+ lastEmitted.current = md
116
+ onChange(md)
117
+ },
118
+ })
119
+
120
+ useEffect(() => {
121
+ if (!editor) return
122
+ if (markdown === lastEmitted.current) return
123
+ lastEmitted.current = markdown
124
+ editor.commands.setContent(astToTiptap(parseMarkdown(markdown)), { emitUpdate: false })
125
+ }, [editor, markdown])
126
+
127
+ if (!editor) return null
128
+
129
+ return (
130
+ <div className="gb">
131
+ <Toolbar editor={editor} />
132
+ <EditorContent editor={editor} className="gb-content" />
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,333 @@
1
+ import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream"
2
+
3
+ // TipTap/ProseMirror JSON shape (loosely typed on purpose)
4
+ export interface PMNode {
5
+ type: string
6
+ attrs?: Record<string, unknown>
7
+ content?: PMNode[]
8
+ marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
9
+ text?: string
10
+ }
11
+
12
+ // ---------- AST → TipTap ----------
13
+
14
+ function inlineToPM(nodes: Inline[]): PMNode[] {
15
+ return nodes
16
+ .filter((n) => n.type === "image" || n.text.length > 0)
17
+ .map((n) => {
18
+ if (n.type === "image") {
19
+ return {
20
+ type: "gbInlineImage",
21
+ attrs: {
22
+ src: n.src,
23
+ alt: n.alt ?? "",
24
+ width: n.width ?? "",
25
+ height: n.height ?? "",
26
+ link: n.link ?? "",
27
+ },
28
+ }
29
+ }
30
+ const marks: PMNode["marks"] = []
31
+ if (n.bold) marks.push({ type: "bold" })
32
+ if (n.italic) marks.push({ type: "italic" })
33
+ if (n.strike) marks.push({ type: "strike" })
34
+ if (n.code) marks.push({ type: "code" })
35
+ if (n.link) marks.push({ type: "link", attrs: { href: n.link } })
36
+ return { type: "text", text: n.text, ...(marks.length ? { marks } : {}) }
37
+ })
38
+ }
39
+
40
+ const paragraphPM = (children: Inline[]): PMNode => {
41
+ const content = inlineToPM(children)
42
+ return content.length ? { type: "paragraph", content } : { type: "paragraph" }
43
+ }
44
+
45
+ // Containers require block+ content; empty GitBook blocks get a placeholder paragraph.
46
+ const blocksPM = (children: Block[]): PMNode[] =>
47
+ children.length ? children.map(blockToPM) : [{ type: "paragraph" }]
48
+
49
+ function listItemToPM(item: ListItemNode, task: boolean): PMNode {
50
+ return {
51
+ type: task ? "taskItem" : "listItem",
52
+ ...(task ? { attrs: { checked: !!item.checked } } : {}),
53
+ content: item.children.map(blockToPM),
54
+ }
55
+ }
56
+
57
+ function blockToPM(b: Block): PMNode {
58
+ switch (b.type) {
59
+ case "paragraph":
60
+ return paragraphPM(b.children)
61
+ case "heading":
62
+ return { type: "heading", attrs: { level: b.level }, content: inlineToPM(b.children) }
63
+ case "code":
64
+ return {
65
+ type: "codeBlock",
66
+ attrs: { language: b.language, title: b.title, lineNumbers: b.lineNumbers },
67
+ content: b.code ? [{ type: "text", text: b.code }] : [],
68
+ }
69
+ case "hint":
70
+ return { type: "gbHint", attrs: { style: b.style }, content: blocksPM(b.children) }
71
+ case "tabs":
72
+ return {
73
+ type: "gbTabs",
74
+ content: b.tabs.map((t) => ({
75
+ type: "gbTab",
76
+ attrs: { title: t.title },
77
+ content: blocksPM(t.children),
78
+ })),
79
+ }
80
+ case "expandable":
81
+ return {
82
+ type: "gbExpandable",
83
+ attrs: { summary: b.summary },
84
+ content: blocksPM(b.children),
85
+ }
86
+ case "stepper":
87
+ return {
88
+ type: "gbStepper",
89
+ content: b.steps.map((s) => ({
90
+ type: "gbStep",
91
+ attrs: { title: s.title },
92
+ content: blocksPM(s.children),
93
+ })),
94
+ }
95
+ case "embed":
96
+ return { type: "gbEmbed", attrs: { url: b.url } }
97
+ case "content-ref":
98
+ return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children) } }
99
+ case "columns":
100
+ return {
101
+ type: "gbColumns",
102
+ content: b.columns.map((c) => ({ type: "gbColumn", content: blocksPM(c.children) })),
103
+ }
104
+ case "figure":
105
+ return { type: "gbFigure", attrs: { src: b.src, alt: b.alt, caption: b.caption } }
106
+ case "list":
107
+ return {
108
+ type: b.task ? "taskList" : b.ordered ? "orderedList" : "bulletList",
109
+ content: b.items.map((item) => listItemToPM(item, b.task)),
110
+ }
111
+ case "blockquote":
112
+ return { type: "blockquote", content: blocksPM(b.children) }
113
+ case "divider":
114
+ return { type: "horizontalRule" }
115
+ case "table":
116
+ return {
117
+ type: "table",
118
+ ...(b.view ? { attrs: { view: b.view } } : {}),
119
+ content: [
120
+ {
121
+ type: "tableRow",
122
+ content: b.header.map((cell) => ({
123
+ type: "tableHeader",
124
+ content: [paragraphPM(cell)],
125
+ })),
126
+ },
127
+ ...b.rows.map((row) => ({
128
+ type: "tableRow",
129
+ content: row.map((cell) => ({ type: "tableCell", content: [paragraphPM(cell)] })),
130
+ })),
131
+ ],
132
+ }
133
+ case "math":
134
+ return { type: "gbMath", attrs: { formula: b.formula } }
135
+ case "updates":
136
+ return {
137
+ type: "gbUpdates",
138
+ attrs: { format: b.format },
139
+ content: b.updates.map((u) => ({
140
+ type: "gbUpdate",
141
+ attrs: { date: u.date },
142
+ content: blocksPM(u.children),
143
+ })),
144
+ }
145
+ case "openapi-operation":
146
+ return {
147
+ type: "gbOpenapi",
148
+ attrs: {
149
+ spec: b.spec,
150
+ path: b.path,
151
+ method: b.method,
152
+ specUrl: b.specUrl,
153
+ label: b.label,
154
+ },
155
+ }
156
+ }
157
+ }
158
+
159
+ export function astToTiptap(doc: DocumentNode): PMNode {
160
+ const content = doc.children.map(blockToPM)
161
+ return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] }
162
+ }
163
+
164
+ // ---------- TipTap → AST ----------
165
+
166
+ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
167
+ if (!nodes) return []
168
+ return nodes
169
+ .filter((n) => (n.type === "text" && n.text) || n.type === "gbInlineImage")
170
+ .map((n): Inline => {
171
+ if (n.type === "gbInlineImage") {
172
+ const a = n.attrs ?? {}
173
+ return {
174
+ type: "image",
175
+ src: String(a.src ?? ""),
176
+ ...(a.alt ? { alt: String(a.alt) } : {}),
177
+ ...(a.width ? { width: String(a.width) } : {}),
178
+ ...(a.height ? { height: String(a.height) } : {}),
179
+ ...(a.link ? { link: String(a.link) } : {}),
180
+ }
181
+ }
182
+ const inline: Inline = { type: "text", text: n.text! }
183
+ for (const mark of n.marks ?? []) {
184
+ if (mark.type === "bold") inline.bold = true
185
+ if (mark.type === "italic") inline.italic = true
186
+ if (mark.type === "strike") inline.strike = true
187
+ if (mark.type === "code") inline.code = true
188
+ if (mark.type === "link") inline.link = String(mark.attrs?.href ?? "")
189
+ }
190
+ return inline
191
+ })
192
+ }
193
+
194
+ function pmCellToInline(cell: PMNode): Inline[] {
195
+ // table cells contain paragraphs; flatten the first one
196
+ return pmTextToInline(cell.content?.[0]?.content)
197
+ }
198
+
199
+ function pmToBlock(n: PMNode): Block | null {
200
+ switch (n.type) {
201
+ case "paragraph": {
202
+ const children = pmTextToInline(n.content)
203
+ if (!children.length) return null // drop empty paragraphs from markdown
204
+ return { type: "paragraph", children }
205
+ }
206
+ case "heading":
207
+ return {
208
+ type: "heading",
209
+ level: (Number(n.attrs?.level) || 1) as 1 | 2 | 3 | 4 | 5 | 6,
210
+ children: pmTextToInline(n.content),
211
+ }
212
+ case "codeBlock":
213
+ return {
214
+ type: "code",
215
+ language: (n.attrs?.language as string) || null,
216
+ title: (n.attrs?.title as string) || null,
217
+ lineNumbers: !!n.attrs?.lineNumbers,
218
+ code: n.content?.map((c) => c.text ?? "").join("") ?? "",
219
+ }
220
+ case "gbHint":
221
+ return {
222
+ type: "hint",
223
+ style: (n.attrs?.style as never) ?? "info",
224
+ children: pmToBlocks(n.content),
225
+ }
226
+ case "gbTabs":
227
+ return {
228
+ type: "tabs",
229
+ tabs: (n.content ?? []).map((t) => ({
230
+ type: "tab",
231
+ title: String(t.attrs?.title ?? "Tab"),
232
+ children: pmToBlocks(t.content),
233
+ })),
234
+ }
235
+ case "gbExpandable":
236
+ return {
237
+ type: "expandable",
238
+ summary: String(n.attrs?.summary ?? ""),
239
+ children: pmToBlocks(n.content),
240
+ }
241
+ case "gbStepper":
242
+ return {
243
+ type: "stepper",
244
+ steps: (n.content ?? []).map((s) => ({
245
+ type: "step",
246
+ title: String(s.attrs?.title ?? ""),
247
+ children: pmToBlocks(s.content),
248
+ })),
249
+ }
250
+ case "gbEmbed":
251
+ return { type: "embed", url: String(n.attrs?.url ?? "") }
252
+ case "gbContentRef":
253
+ return {
254
+ type: "content-ref",
255
+ url: String(n.attrs?.url ?? ""),
256
+ children: [{ type: "text", text: String(n.attrs?.label ?? "") }],
257
+ }
258
+ case "gbColumns":
259
+ return {
260
+ type: "columns",
261
+ columns: (n.content ?? []).map((c) => ({ type: "column", children: pmToBlocks(c.content) })),
262
+ }
263
+ case "gbFigure":
264
+ return {
265
+ type: "figure",
266
+ src: String(n.attrs?.src ?? ""),
267
+ alt: String(n.attrs?.alt ?? ""),
268
+ caption: String(n.attrs?.caption ?? ""),
269
+ }
270
+ case "gbMath":
271
+ return { type: "math", formula: String(n.attrs?.formula ?? "") }
272
+ case "gbUpdates":
273
+ return {
274
+ type: "updates",
275
+ format: (n.attrs?.format as string) || null,
276
+ updates: (n.content ?? []).map((u) => ({
277
+ type: "update",
278
+ date: String(u.attrs?.date ?? ""),
279
+ children: pmToBlocks(u.content),
280
+ })),
281
+ }
282
+ case "gbOpenapi":
283
+ return {
284
+ type: "openapi-operation",
285
+ spec: String(n.attrs?.spec ?? ""),
286
+ path: String(n.attrs?.path ?? ""),
287
+ method: String(n.attrs?.method ?? ""),
288
+ specUrl: String(n.attrs?.specUrl ?? ""),
289
+ label: String(n.attrs?.label ?? ""),
290
+ }
291
+ case "bulletList":
292
+ case "orderedList":
293
+ case "taskList": {
294
+ const task = n.type === "taskList"
295
+ return {
296
+ type: "list",
297
+ ordered: n.type === "orderedList",
298
+ task,
299
+ items: (n.content ?? []).map((item) => {
300
+ const base: ListItemNode = { type: "listItem", children: pmToBlocks(item.content) }
301
+ if (task) base.checked = !!item.attrs?.checked
302
+ if (!base.children.length) base.children = [{ type: "paragraph", children: [] }]
303
+ return base
304
+ }),
305
+ }
306
+ }
307
+ case "blockquote":
308
+ return { type: "blockquote", children: pmToBlocks(n.content) }
309
+ case "horizontalRule":
310
+ return { type: "divider" }
311
+ case "table": {
312
+ const rows = n.content ?? []
313
+ const [head, ...body] = rows
314
+ const view = n.attrs?.view as string | undefined
315
+ return {
316
+ type: "table",
317
+ header: (head?.content ?? []).map(pmCellToInline),
318
+ rows: body.map((r) => (r.content ?? []).map(pmCellToInline)),
319
+ ...(view ? { view } : {}),
320
+ }
321
+ }
322
+ default:
323
+ return null
324
+ }
325
+ }
326
+
327
+ function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
328
+ return (nodes ?? []).map(pmToBlock).filter((b): b is Block => b !== null)
329
+ }
330
+
331
+ export function tiptapToAst(doc: PMNode): DocumentNode {
332
+ return { type: "doc", children: pmToBlocks(doc.content) }
333
+ }