@brett_lamy/docstream-editor 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,220 @@
1
+ # @brett_lamy/docstream-editor
2
+
3
+ TipTap editor components for Docstream GitBook-style markdown documents.
4
+
5
+ `@brett_lamy/docstream-editor` provides the editable layer for the same document model rendered by `@brett_lamy/docstream`. It is intended for docs apps that need rich editing while preserving GitBook-flavored markdown blocks for preview, storage, and AI stream rendering.
6
+
7
+ ## Features
8
+
9
+ - Controlled React editor component for GitBook-style markdown.
10
+ - TipTap extensions for common writing flows.
11
+ - Conversion helpers between the Docstream AST and TipTap JSON.
12
+ - Slash menu for inserting supported blocks.
13
+ - Code block support through `lowlight`.
14
+ - Live React/JSX/TSX code blocks backed by almost-node.
15
+ - `ReactDemoEditor` for editing and running a complete multi-file project.
16
+ - Table, task list, heading, quote, and inline formatting support.
17
+ - Preserves Docstream/GitBook block semantics when converting back to markdown.
18
+
19
+ ## Installation
20
+
21
+ ```sh
22
+ npm install @brett_lamy/docstream-editor @brett_lamy/docstream react
23
+ ```
24
+
25
+ React is a peer dependency. `@brett_lamy/docstream` is a runtime dependency because the editor uses the same parser, serializer, OpenAPI rendering, and asset helpers.
26
+
27
+ ## Basic Setup
28
+
29
+ Import both package styles near your app entrypoint:
30
+
31
+ ```ts
32
+ import "@brett_lamy/docstream/styles.css"
33
+ import "@brett_lamy/docstream-editor/styles.css"
34
+ ```
35
+
36
+ If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
37
+
38
+ ```ts
39
+ /// <reference types="vite/client" />
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ `GitbookEditor` is a controlled component. Pass the current markdown and receive updated markdown through `onChange`.
45
+
46
+ ```tsx
47
+ import { useState } from "react"
48
+ import { GitbookEditor } from "@brett_lamy/docstream-editor"
49
+ import "@brett_lamy/docstream/styles.css"
50
+ import "@brett_lamy/docstream-editor/styles.css"
51
+
52
+ const initialMarkdown = `# Getting started
53
+
54
+ {% hint style="info" %}
55
+ Type / to insert supported blocks.
56
+ {% endhint %}
57
+ `
58
+
59
+ export function EditorPage() {
60
+ const [markdown, setMarkdown] = useState(initialMarkdown)
61
+
62
+ return (
63
+ <GitbookEditor
64
+ markdown={markdown}
65
+ onChange={setMarkdown}
66
+ />
67
+ )
68
+ }
69
+ ```
70
+
71
+ ## Props
72
+
73
+ ```ts
74
+ export interface GitbookEditorProps {
75
+ markdown: string
76
+ onChange: (markdown: string) => void
77
+ }
78
+ ```
79
+
80
+ - `markdown`: Source markdown to load into the editor.
81
+ - `onChange`: Called with serialized markdown whenever TipTap content changes.
82
+
83
+ The editor tracks the last markdown it emitted so normal controlled updates do not continuously reset the TipTap document. Passing a different external `markdown` value replaces the editor content.
84
+
85
+ ## Supported Editing Surface
86
+
87
+ The editor supports common ProseMirror/TipTap content plus GitBook-flavored blocks from Docstream.
88
+
89
+ - Headings
90
+ - Paragraphs
91
+ - Bold, italic, strike, inline code, and links
92
+ - Bullet, ordered, and task lists
93
+ - Blockquotes
94
+ - Code blocks
95
+ - Tables
96
+ - Hints
97
+ - Tabs
98
+ - Expandables
99
+ - Steppers
100
+ - Embeds
101
+ - Content references
102
+ - Columns
103
+ - Figures and images
104
+ - OpenAPI operation placeholders
105
+ - Video and public Replay QA previews inside embed blocks
106
+
107
+ 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.
108
+
109
+ ### Video and Replay QA embeds
110
+
111
+ Insert an embed block from the slash menu, then enter a direct video URL or a
112
+ public Loop QA project URL. The editor renders video assets through Docstream's
113
+ `VideoEmbed` and converts Loop QA project paths to their chrome-free public
114
+ preview route. The serialized markdown remains portable:
115
+
116
+ ```md
117
+ {% embed url="https://loop-qa.replay.io/projects/project-id/tasks/task-id" /%}
118
+ ```
119
+
120
+ ### Live React code
121
+
122
+ Code blocks can opt into a live preview by setting `live="true"`. The block
123
+ must be a complete entry module (including its `createRoot` call); ordinary
124
+ code snippets remain read-only:
125
+
126
+ ````md
127
+ {% code language="jsx" live="true" entry="/src/main.jsx" %}
128
+ ```jsx
129
+ import { createRoot } from "react-dom/client"
130
+ createRoot(document.getElementById("root")).render(<h1>Hello</h1>)
131
+ ```
132
+ {% endcode %}
133
+ ````
134
+
135
+ For multi-file demos, use the standalone editor component:
136
+
137
+ ```tsx
138
+ import { ReactDemoEditor } from "@brett_lamy/docstream-editor"
139
+
140
+ <ReactDemoEditor
141
+ files={{
142
+ "/src/main.jsx": mainSource,
143
+ "/src/App.jsx": appSource,
144
+ }}
145
+ entry="/src/main.jsx"
146
+ />
147
+ ```
148
+
149
+ The host app must install `@agent-wasm/core` and expose its service worker with
150
+ `almostnodePlugin()` from `@agent-wasm/core/vite`.
151
+
152
+ ## Slash Menu
153
+
154
+ The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
155
+
156
+ ## Conversion Helpers
157
+
158
+ Use the conversion helpers when you need to inspect or transform editor state directly.
159
+
160
+ ```ts
161
+ import { astToTiptap, tiptapToAst } from "@brett_lamy/docstream-editor"
162
+ import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
163
+
164
+ const ast = parseMarkdown(markdown)
165
+ const tiptapJson = astToTiptap(ast)
166
+ const markdownAgain = serializeMarkdown(tiptapToAst(tiptapJson))
167
+ ```
168
+
169
+ Exports:
170
+
171
+ - `GitbookEditor`
172
+ - `GitbookEditorProps`
173
+ - `astToTiptap`
174
+ - `tiptapToAst`
175
+ - `PMNode`
176
+
177
+ ## Styling and Theming
178
+
179
+ The editor CSS is designed to sit beside `@brett_lamy/docstream/styles.css` and inherit the same application theme tokens. In a shadcn-style app, define your theme variables globally and import both CSS entrypoints once.
180
+
181
+ ```css
182
+ :root {
183
+ --background: 0 0% 100%;
184
+ --foreground: 222.2 84% 4.9%;
185
+ --border: 214.3 31.8% 91.4%;
186
+ --muted: 210 40% 96.1%;
187
+ --muted-foreground: 215.4 16.3% 46.9%;
188
+ --primary: 221.2 83.2% 53.3%;
189
+ }
190
+ ```
191
+
192
+ The editor exposes classes such as `gb`, `gb-toolbar`, `gb-tool`, `gb-tool-active`, and `gb-content` for host app overrides.
193
+
194
+ ## Bundler Notes
195
+
196
+ This release ships TypeScript and TSX source through ESM exports:
197
+
198
+ ```json
199
+ {
200
+ "exports": {
201
+ ".": {
202
+ "types": "./src/index.ts",
203
+ "import": "./src/index.ts"
204
+ },
205
+ "./styles.css": "./src/styles.css"
206
+ }
207
+ }
208
+ ```
209
+
210
+ It is validated with Vite and modern TypeScript `moduleResolution: "Bundler"`. Plain Node.js, CommonJS, or tooling that does not transpile TypeScript in dependencies may need a future precompiled JS build.
211
+
212
+ ## Renderer Pairing
213
+
214
+ For read-only previews or AI stream output, pair this package with `@brett_lamy/docstream`:
215
+
216
+ ```tsx
217
+ import { GitbookStreamdown } from "@brett_lamy/docstream"
218
+
219
+ <GitbookStreamdown markdown={markdown} isStreaming={false} />
220
+ ```
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "README.md"
10
11
  ],
11
12
  "sideEffects": [
12
13
  "**/*.css"
@@ -19,19 +20,20 @@
19
20
  "./styles.css": "./src/styles.css"
20
21
  },
21
22
  "dependencies": {
23
+ "@brett_lamy/docstream": "0.3.0",
24
+ "lowlight": "^3.3.0",
25
+ "lucide-react": "^1.17.0"
26
+ },
27
+ "peerDependencies": {
22
28
  "@tiptap/core": "^3.26.1",
23
29
  "@tiptap/extension-code-block-lowlight": "^3.26.1",
24
30
  "@tiptap/extension-list": "^3.26.1",
25
31
  "@tiptap/extension-placeholder": "^3.26.1",
26
32
  "@tiptap/extension-table": "^3.26.1",
33
+ "@tiptap/pm": "^3.26.1",
27
34
  "@tiptap/react": "^3.26.1",
28
35
  "@tiptap/starter-kit": "^3.26.1",
29
36
  "@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
37
  "react": ">=18"
36
38
  }
37
39
  }
@@ -1,9 +1,6 @@
1
1
  import { useEffect, useRef } from "react"
2
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"
3
+ import type { AnyExtension } from "@tiptap/core"
7
4
  import {
8
5
  Bold,
9
6
  Code as CodeIcon,
@@ -13,23 +10,39 @@ import {
13
10
  Strikethrough,
14
11
  } from "lucide-react"
15
12
 
16
- import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
13
+ import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream/gitbook"
17
14
  import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
18
- import { GbCodeBlock, gitbookNodes } from "./nodes"
19
- import { SlashMenu } from "./slash-menu"
15
+ import { createGitbookExtensions } from "./extensions"
16
+ import type { SlashItem } from "./slash-menu"
20
17
 
21
18
  export interface GitbookEditorProps {
22
- markdown: string
23
- onChange: (markdown: string) => void
19
+ /** Markdown source. When provided, the editor stays in sync with it (controlled). */
20
+ markdown?: string
21
+ /** Called with serialized GitBook markdown on every edit. */
22
+ onChange?: (markdown: string) => void
23
+ /** Show the built-in formatting toolbar (default true). */
24
+ toolbar?: boolean
25
+ /** Built-in "/" slash menu: true (default), false, or a custom item list. */
26
+ slashMenu?: boolean | { items?: SlashItem[] }
27
+ /** Whether the document is editable (default true). */
28
+ editable?: boolean
29
+ /** Placeholder shown in an empty document. */
30
+ placeholder?: string
31
+ /** Class applied to the editor wrapper. */
32
+ className?: string
33
+ /** Focus the editor on mount. */
34
+ autofocus?: boolean
35
+ /**
36
+ * Disable StarterKit's undo/redo — required when supplying a Yjs Collaboration
37
+ * extension via `extensions`, which manages shared history itself.
38
+ */
39
+ disableHistory?: boolean
40
+ /** Extra TipTap extensions appended last (e.g. Collaboration, CollaborationCursor). */
41
+ extensions?: AnyExtension[]
42
+ /** Receive the underlying TipTap editor instance (and null on teardown). */
43
+ onEditorReady?: (editor: TiptapEditor | null) => void
24
44
  }
25
45
 
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
46
  function ToolbarButton({
34
47
  onClick,
35
48
  active,
@@ -90,45 +103,60 @@ function Toolbar({ editor }: { editor: TiptapEditor }) {
90
103
  )
91
104
  }
92
105
 
93
- export function GitbookEditor({ markdown, onChange }: GitbookEditorProps) {
106
+ export function GitbookEditor({
107
+ markdown,
108
+ onChange,
109
+ toolbar = true,
110
+ slashMenu = true,
111
+ editable = true,
112
+ placeholder,
113
+ className,
114
+ autofocus = false,
115
+ disableHistory,
116
+ extensions,
117
+ onEditorReady,
118
+ }: GitbookEditorProps) {
94
119
  // Tracks the markdown the editor itself produced, so external updates
95
120
  // (file switches) reset content but our own onChange echoes don't.
96
121
  const lastEmitted = useRef<string | null>(null)
122
+ const controlled = markdown !== undefined
97
123
 
98
124
  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)),
125
+ editable,
126
+ autofocus,
127
+ extensions: createGitbookExtensions({
128
+ ...(placeholder !== undefined ? { placeholder } : {}),
129
+ slashMenu,
130
+ ...(disableHistory !== undefined ? { disableHistory } : {}),
131
+ ...(extensions ? { extensions } : {}),
132
+ }),
133
+ ...(controlled ? { content: astToTiptap(parseMarkdown(markdown as string)) } : {}),
113
134
  onUpdate({ editor }) {
135
+ if (!onChange) return
114
136
  const md = serializeMarkdown(tiptapToAst(editor.getJSON() as PMNode))
115
137
  lastEmitted.current = md
116
138
  onChange(md)
117
139
  },
118
140
  })
119
141
 
142
+ // Surface the editor instance to the host (for awareness, commands, etc.).
143
+ useEffect(() => {
144
+ onEditorReady?.(editor ?? null)
145
+ return () => onEditorReady?.(null)
146
+ }, [editor, onEditorReady])
147
+
120
148
  useEffect(() => {
121
- if (!editor) return
149
+ if (!editor || !controlled) return
122
150
  if (markdown === lastEmitted.current) return
123
- lastEmitted.current = markdown
124
- editor.commands.setContent(astToTiptap(parseMarkdown(markdown)), { emitUpdate: false })
125
- }, [editor, markdown])
151
+ lastEmitted.current = markdown as string
152
+ editor.commands.setContent(astToTiptap(parseMarkdown(markdown as string)), { emitUpdate: false })
153
+ }, [editor, controlled, markdown])
126
154
 
127
155
  if (!editor) return null
128
156
 
129
157
  return (
130
- <div className="gb">
131
- <Toolbar editor={editor} />
158
+ <div className={className ? `gb ${className}` : "gb"}>
159
+ {toolbar && <Toolbar editor={editor} />}
132
160
  <EditorContent editor={editor} className="gb-content" />
133
161
  </div>
134
162
  )
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from "react"
2
+ import { FileCode2, Play } from "lucide-react"
3
+
4
+ import { ReactCodePreview } from "@brett_lamy/docstream"
5
+
6
+ export interface ReactDemoEditorProps {
7
+ /** Source files keyed by their project-relative or absolute paths. */
8
+ files: Readonly<Record<string, string>>
9
+ /** Vite entry file. Defaults to `/src/main.jsx`. */
10
+ entry?: string
11
+ /** Preview height after the user runs the demo. */
12
+ height?: number | string
13
+ className?: string
14
+ /** Called whenever a file is edited. */
15
+ onChange?: (files: Record<string, string>) => void
16
+ }
17
+
18
+ function nextFile(files: Readonly<Record<string, string>>): string {
19
+ return Object.keys(files).sort()[0] ?? ""
20
+ }
21
+
22
+ /** A small multi-file source editor paired with the Docstream React preview. */
23
+ export function ReactDemoEditor({
24
+ files,
25
+ entry = "/src/main.jsx",
26
+ height = 320,
27
+ className,
28
+ onChange,
29
+ }: ReactDemoEditorProps) {
30
+ const [draftFiles, setDraftFiles] = useState<Record<string, string>>(() => ({ ...files }))
31
+ const [selectedFile, setSelectedFile] = useState(() => nextFile(files))
32
+ const [previewFiles, setPreviewFiles] = useState<Record<string, string> | null>(null)
33
+ const [previewRevision, setPreviewRevision] = useState(0)
34
+
35
+ useEffect(() => {
36
+ setDraftFiles({ ...files })
37
+ setSelectedFile((current) => (current && files[current] !== undefined ? current : nextFile(files)))
38
+ }, [files])
39
+
40
+ const updateFile = (content: string) => {
41
+ if (!selectedFile) return
42
+ const next = { ...draftFiles, [selectedFile]: content }
43
+ setDraftFiles(next)
44
+ onChange?.(next)
45
+ }
46
+
47
+ const wrapperClass = className ? `gb-react-demo-editor ${className}` : "gb-react-demo-editor"
48
+
49
+ return (
50
+ <section className={wrapperClass} data-docstream-react-demo-editor="">
51
+ <div className="gb-react-demo-editor-source">
52
+ <aside className="gb-react-demo-files" aria-label="Demo files">
53
+ <div className="gb-react-demo-files-header">
54
+ <span>Files</span>
55
+ <FileCode2 className="size-4" />
56
+ </div>
57
+ {Object.keys(draftFiles)
58
+ .sort()
59
+ .map((path) => (
60
+ <button
61
+ type="button"
62
+ key={path}
63
+ className={path === selectedFile ? "gb-react-demo-file gb-react-demo-file-active" : "gb-react-demo-file"}
64
+ onClick={() => setSelectedFile(path)}
65
+ >
66
+ {path.replace(/^\//, "")}
67
+ </button>
68
+ ))}
69
+ </aside>
70
+ <div className="gb-react-demo-editor-pane">
71
+ <div className="gb-react-demo-editor-toolbar">
72
+ <code>{selectedFile || "No file selected"}</code>
73
+ <button
74
+ type="button"
75
+ className="gb-react-demo-preview-button"
76
+ disabled={!selectedFile}
77
+ onClick={() => {
78
+ setPreviewFiles({ ...draftFiles })
79
+ setPreviewRevision((value) => value + 1)
80
+ }}
81
+ >
82
+ <Play className="size-3.5" /> Run demo
83
+ </button>
84
+ </div>
85
+ <textarea
86
+ className="gb-react-demo-textarea"
87
+ value={selectedFile ? draftFiles[selectedFile] ?? "" : ""}
88
+ onChange={(event) => updateFile(event.target.value)}
89
+ spellCheck={false}
90
+ disabled={!selectedFile}
91
+ aria-label={selectedFile ? `Edit ${selectedFile}` : "No file selected"}
92
+ />
93
+ </div>
94
+ </div>
95
+ {previewFiles ? (
96
+ <ReactCodePreview
97
+ key={previewRevision}
98
+ files={previewFiles}
99
+ entry={entry}
100
+ height={height}
101
+ title="React demo preview"
102
+ />
103
+ ) : (
104
+ <div className="gb-react-demo-editor-empty">Edit a file, then run the demo to preview the complete project.</div>
105
+ )}
106
+ </section>
107
+ )
108
+ }
@@ -1,4 +1,4 @@
1
- import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream"
1
+ import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream/gitbook"
2
2
 
3
3
  // TipTap/ProseMirror JSON shape (loosely typed on purpose)
4
4
  export interface PMNode {
@@ -63,7 +63,13 @@ function blockToPM(b: Block): PMNode {
63
63
  case "code":
64
64
  return {
65
65
  type: "codeBlock",
66
- attrs: { language: b.language, title: b.title, lineNumbers: b.lineNumbers },
66
+ attrs: {
67
+ language: b.language,
68
+ title: b.title,
69
+ lineNumbers: b.lineNumbers,
70
+ live: !!b.live,
71
+ entry: b.entry ?? null,
72
+ },
67
73
  content: b.code ? [{ type: "text", text: b.code }] : [],
68
74
  }
69
75
  case "hint":
@@ -215,6 +221,8 @@ function pmToBlock(n: PMNode): Block | null {
215
221
  language: (n.attrs?.language as string) || null,
216
222
  title: (n.attrs?.title as string) || null,
217
223
  lineNumbers: !!n.attrs?.lineNumbers,
224
+ live: !!n.attrs?.live,
225
+ entry: (n.attrs?.entry as string) || null,
218
226
  code: n.content?.map((c) => c.text ?? "").join("") ?? "",
219
227
  }
220
228
  case "gbHint":
@@ -0,0 +1,74 @@
1
+ import StarterKit from "@tiptap/starter-kit"
2
+ import Placeholder from "@tiptap/extension-placeholder"
3
+ import { TaskItem, TaskList } from "@tiptap/extension-list"
4
+ import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
5
+ import { getSchema, type AnyExtension } from "@tiptap/core"
6
+ import type { Schema } from "@tiptap/pm/model"
7
+ import { GbCodeBlock, gitbookNodes } from "./nodes"
8
+ import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
9
+
10
+ // Carries GitBook's data-view (e.g. "cards") through the editor untouched.
11
+ export const GbTable = Table.extend({
12
+ addAttributes() {
13
+ return { ...this.parent?.(), view: { default: null } }
14
+ },
15
+ })
16
+
17
+ export interface GitbookExtensionOptions {
18
+ placeholder?: string
19
+ /** Built-in "/" slash menu: true (default), false, or a custom item list. */
20
+ slashMenu?: boolean | { items?: SlashItem[] }
21
+ /**
22
+ * Disable StarterKit's built-in undo/redo. Required when wiring Yjs
23
+ * Collaboration, which provides its own shared history.
24
+ */
25
+ disableHistory?: boolean
26
+ /** Extra TipTap extensions appended last (e.g. Collaboration, CollaborationCursor). */
27
+ extensions?: AnyExtension[]
28
+ }
29
+
30
+ /**
31
+ * The full GitBook editing extension set, composable by host apps. Use this to
32
+ * build a custom `useEditor(...)` (e.g. with Yjs collaboration) instead of the
33
+ * batteries-included `GitbookEditor` component.
34
+ */
35
+ export function createGitbookExtensions(options: GitbookExtensionOptions = {}): AnyExtension[] {
36
+ const {
37
+ placeholder = "Write, or type / to insert a block…",
38
+ slashMenu = true,
39
+ disableHistory = false,
40
+ extensions = [],
41
+ } = options
42
+
43
+ const list: AnyExtension[] = [
44
+ StarterKit.configure({
45
+ codeBlock: false,
46
+ ...(disableHistory ? { undoRedo: false } : {}),
47
+ }),
48
+ GbCodeBlock,
49
+ TaskList,
50
+ TaskItem.configure({ nested: true }),
51
+ GbTable.configure({ resizable: false }),
52
+ TableRow,
53
+ TableHeader,
54
+ TableCell,
55
+ Placeholder.configure({ placeholder }),
56
+ ...gitbookNodes,
57
+ ]
58
+
59
+ if (slashMenu) {
60
+ list.push(slashMenu === true ? SlashMenu : createSlashMenu(slashMenu.items))
61
+ }
62
+
63
+ list.push(...extensions)
64
+ return list
65
+ }
66
+
67
+ /**
68
+ * The ProseMirror schema for GitBook documents — identical to what GitbookEditor
69
+ * uses. Hosts wiring Yjs collaboration (y-prosemirror) need this exact schema to
70
+ * convert between a Y.XmlFragment and ProseMirror/markdown safely.
71
+ */
72
+ export function getGitbookSchema(): Schema {
73
+ return getSchema(createGitbookExtensions({ slashMenu: false }))
74
+ }
@@ -18,7 +18,10 @@ import {
18
18
  XCircle,
19
19
  } from "lucide-react"
20
20
 
21
- import { OpenApiOperation, resolveAsset, type HintStyle } from "@brett_lamy/docstream"
21
+ import { resolveAsset } from "@brett_lamy/docstream/assets"
22
+ import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
23
+ import type { HintStyle } from "@brett_lamy/docstream/gitbook"
24
+ import { ReplayPreview, VideoEmbed, isReplayQaUrl } from "@brett_lamy/docstream"
22
25
 
23
26
  // ---------- Hint ----------
24
27
 
@@ -299,13 +302,19 @@ function embedPreview(url: string): string | null {
299
302
  return null
300
303
  }
301
304
 
305
+ function isDirectVideo(url: string): boolean {
306
+ return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(url)
307
+ }
308
+
302
309
  function EmbedView({ node, updateAttributes, editor }: NodeViewProps) {
303
310
  const url = node.attrs.url as string
304
311
  const preview = embedPreview(url)
305
312
  return (
306
313
  <NodeViewWrapper className="gb-embed" contentEditable={false}>
307
- {preview ? (
308
- <iframe className="gb-embed-frame" src={preview} title={url} allowFullScreen />
314
+ {isReplayQaUrl(url) ? (
315
+ <ReplayPreview source={url} title="Replay preview" />
316
+ ) : preview || isDirectVideo(url) ? (
317
+ <VideoEmbed src={preview ?? url} className="gb-embed-frame" title={url} />
309
318
  ) : (
310
319
  <a className="gb-embed-link" href={url} target="_blank" rel="noreferrer">
311
320
  <Link2 className="size-4" /> {url || "Embed URL…"}
@@ -534,11 +543,28 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
534
543
  />
535
544
  #
536
545
  </label>
546
+ <label className="gb-code-live">
547
+ <input
548
+ type="checkbox"
549
+ checked={!!node.attrs.live}
550
+ onChange={(e) => updateAttributes({ live: e.target.checked })}
551
+ />
552
+ Live
553
+ </label>
554
+ {node.attrs.live && (
555
+ <input
556
+ className="gb-inline-input gb-code-entry"
557
+ value={node.attrs.entry ?? ""}
558
+ placeholder="entry (e.g. src/main.jsx)"
559
+ onChange={(e) => updateAttributes({ entry: e.target.value || null })}
560
+ />
561
+ )}
537
562
  </>
538
563
  ) : (
539
564
  <>
540
565
  {node.attrs.title && <span className="gb-code-title">{node.attrs.title}</span>}
541
566
  {node.attrs.language && <span className="gb-code-lang">{node.attrs.language}</span>}
567
+ {node.attrs.live && <span className="gb-code-live-badge">Live</span>}
542
568
  </>
543
569
  )}
544
570
  </div>
@@ -555,6 +581,8 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
555
581
  ...this.parent?.(),
556
582
  title: { default: null },
557
583
  lineNumbers: { default: false },
584
+ live: { default: false },
585
+ entry: { default: null },
558
586
  }
559
587
  },
560
588
  addNodeView() {