@brett_lamy/docstream-editor 1.1.0 → 1.2.1

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
@@ -13,6 +13,9 @@ TipTap editor components for Docstream GitBook-style markdown documents.
13
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
+ - Copy a docs page (docstream's "Copy page") and paste it in: the editor shows the same page and
17
+ serializes it back byte-for-byte — demos with their inline files, install command boxes, titled
18
+ tab sets and all.
16
19
  - Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
17
20
 
18
21
  ## Installation
@@ -81,6 +84,9 @@ export interface GitbookEditorProps {
81
84
  onAttachmentRemove?: (ids: string[]) => void
82
85
  onAttachmentOpen?: (attachment: EditorAttachment) => void
83
86
  demoResolver?: DemoResolver
87
+ demoRuntime?: InlineDemoRuntime
88
+ demoDependencies?: Record<string, string>
89
+ markdownPaste?: boolean
84
90
  }
85
91
  ```
86
92
 
@@ -92,6 +98,12 @@ export interface GitbookEditorProps {
92
98
  how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
93
99
  - `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
94
100
  preview — see [Demo blocks](#demo-blocks).
101
+ - `demoRuntime` / `demoDependencies`: run demos that carry their files inline and have no
102
+ resolver behind them — the same options as Docstream's `DocRenderOptions`.
103
+ - `markdownPaste` (default `true`): plain-text Markdown pasted into the editor that contains
104
+ blocks (a "Copy page" export, `{% … %}` tags, fences, lists, headings …) is parsed into
105
+ editor blocks instead of pasted as literal text. Plain prose pastes as usual; HTML clipboards
106
+ keep TipTap's own handling (except VS Code's, which is treated as plain text).
95
107
 
96
108
  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.
97
109
 
@@ -101,19 +113,23 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
101
113
 
102
114
  - Headings
103
115
  - Paragraphs
104
- - Bold, italic, strike, inline code, and links
116
+ - Bold, italic, strike, inline code, and links (a link inside emphasis, `**[x](url)**`, stays
117
+ distinct from emphasis inside a link, `[**x**](url)`)
105
118
  - Bullet, ordered, and task lists
106
119
  - Blockquotes
107
120
  - Code blocks
108
121
  - Tables
109
122
  - Hints
110
- - Tabs (including `{% tabs sync="key" %}` sync groups)
123
+ - Tabs (including `{% tabs sync="key" %}` sync groups, and titled sets
124
+ `{% tabs title="Installation" level="3" %}` rendered as a section heading with the switch on
125
+ its row — click the heading icon in the tab strip to add a title)
126
+ - Package-manager command boxes (`{% command %}npm install x{% endcommand %}`, `/install command`)
111
127
  - Expandables
112
128
  - Steppers
113
129
  - Embeds
114
130
  - Content references
115
131
  - Component and Storybook source references
116
- - File-backed live demos (`{% demo %}`)
132
+ - Live demos (`{% demo %}`), resolver-backed or carrying their files inline
117
133
  - Columns
118
134
  - Figures and images
119
135
  - OpenAPI operation placeholders
@@ -209,11 +225,22 @@ Docstream README.
209
225
 
210
226
  `{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
211
227
  attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
212
- `viewport` — is kept on the node, so the tag serializes back exactly as written.
228
+ `viewport`, `entry` — is kept on the node, and so are the files the block form
229
+ carries inline (the titled fences between `{% demo … %}` and `{% enddemo %}`, as
230
+ `files: { path, content, language? }[]`), so the block serializes back exactly as
231
+ written. Docstream's "Copy page" writes every demo this way.
232
+
213
233
  The node shows a compact header with editable `src` and `title` inputs (insert
214
- one with `/demo`). Pass the same `DemoResolver` you give the Docstream renderer
215
- to also show the live `DemoViewer` preview (read-only) under the header;
216
- without one, only the header is shown.
234
+ one with `/demo`) and a **Files** toggle that opens a small file editor: a tab
235
+ per file (rename it in place, mark it the entry, remove it), a code area, and
236
+ `+` to add a file. Removing every file gives the one-line tag form again.
237
+
238
+ Under the header, Docstream's read-only `DemoViewer` renders the demo: from the
239
+ `demoResolver` when it knows `src`, otherwise from the inline files — so a
240
+ pasted docs page looks like the docs page. Inline files run in the Preview
241
+ through `demoRuntime` (e.g. `createAlmostNodeDemoRuntime()` from
242
+ `@brett_lamy/docstream/playground`) with `demoDependencies`; without a runtime
243
+ the viewer shows their code.
217
244
 
218
245
  ```tsx
219
246
  import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
@@ -224,6 +251,15 @@ const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
224
251
  <GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
225
252
  ```
226
253
 
254
+ ## Command boxes
255
+
256
+ `{% command %}npm install @brett_lamy/ui{% endcommand %}` (or the block form, for
257
+ several lines) becomes a `gbCommand` atom node rendered with Docstream's own
258
+ command box: terminal glyph, pnpm / npm / yarn / bun switch (synced page-wide),
259
+ copy button. When editable, the npm command and per-manager overrides
260
+ (`pnpm="…"`, `yarn="…"`, `bun="…"`; the placeholder shows the derived command)
261
+ are edited under the box. `sync` is preserved. Insert one with `/install command`.
262
+
227
263
  ## Slash Menu
228
264
 
229
265
  The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
@@ -250,6 +286,7 @@ Exports:
250
286
  - `PMNode`
251
287
  - `EditorAttachment`
252
288
  - `serializeEditorMarkdown`
289
+ - `markdownPasteContent` (the Markdown-paste parser behind `markdownPaste`)
253
290
  - `SourceFileEditor`
254
291
  - `SourceFileEditorProps`
255
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "src",
14
14
  "!src/**/*.test.ts",
15
+ "!src/**/__fixtures__",
15
16
  "README.md"
16
17
  ],
17
18
  "sideEffects": [
@@ -33,7 +34,7 @@
33
34
  "./styles.css": "./src/styles.css"
34
35
  },
35
36
  "dependencies": {
36
- "@brett_lamy/docstream": "1.1.1",
37
+ "@brett_lamy/docstream": "1.2.1",
37
38
  "gpu-lexer": "0.0.2",
38
39
  "lucide-react": "^1.17.0"
39
40
  },
@@ -11,7 +11,7 @@ import {
11
11
  } from "lucide-react"
12
12
 
13
13
  import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
- import type { DemoResolver } from "@brett_lamy/docstream/demo"
14
+ import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
15
15
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
16
16
  import {
17
17
  attachmentName,
@@ -21,7 +21,7 @@ import {
21
21
  readFileAsDataURL,
22
22
  type EditorAttachment,
23
23
  } from "./attachments"
24
- import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
24
+ import { astToTiptap, markdownPasteContent, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
25
25
  import { createGitbookExtensions, type ReferenceSources } from "./extensions"
26
26
  import { EditorRuntimeProvider } from "./runtime"
27
27
  import type { SlashItem } from "./slash-menu"
@@ -87,8 +87,25 @@ export interface GitbookEditorProps {
87
87
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
88
88
  /** Called when reading, writing, or previewing a file-backed source fails. */
89
89
  onSourceError?: (error: Error) => void
90
- /** Resolves `{% demo src %}` ids; when set, demo blocks show a live (read-only) preview under their header. */
90
+ /**
91
+ * Resolves `{% demo src %}` ids. Demo blocks show docstream's (read-only) DemoViewer under
92
+ * their header: from the resolver when it knows `src`, otherwise from the files the block
93
+ * carries inline (a pasted Copy-page export), so the block looks like the docs page.
94
+ */
91
95
  demoResolver?: DemoResolver
96
+ /**
97
+ * Runs the Preview of demos that carry their files inline and have no resolver behind them
98
+ * (as `DocRenderOptions.demoRuntime`), e.g. `createAlmostNodeDemoRuntime()` from
99
+ * `@brett_lamy/docstream/playground`. Without one, such demos show their code with a note.
100
+ */
101
+ demoRuntime?: InlineDemoRuntime
102
+ /** Extra npm dependencies inline demos may import (as `DocRenderOptions.demoDependencies`). */
103
+ demoDependencies?: Record<string, string>
104
+ /**
105
+ * Parse pasted plain-text Markdown that contains blocks (a docs page's "Copy page", a
106
+ * `{% … %}` block, a fence, a list …) into editor blocks instead of literal text (default true).
107
+ */
108
+ markdownPaste?: boolean
92
109
  }
93
110
 
94
111
  function ToolbarButton({
@@ -177,6 +194,9 @@ export function GitbookEditor({
177
194
  onSourceSaved,
178
195
  onSourceError,
179
196
  demoResolver,
197
+ demoRuntime,
198
+ demoDependencies,
199
+ markdownPaste = true,
180
200
  }: GitbookEditorProps) {
181
201
  // Tracks the markdown the editor itself produced, so external updates
182
202
  // (file switches) reset content but our own onChange echoes don't.
@@ -188,8 +208,8 @@ export function GitbookEditor({
188
208
  // Ids of the attachment chips in the document, to report removals.
189
209
  const attachmentIds = useRef<Set<string>>(new Set())
190
210
  // Latest callbacks for handlers the editor captured at creation.
191
- const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
192
- latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
211
+ const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste })
212
+ latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste }
193
213
  const editorRef = useRef<TiptapEditor | null>(null)
194
214
 
195
215
  // Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
@@ -236,6 +256,27 @@ export function GitbookEditor({
236
256
  })
237
257
  }
238
258
 
259
+ // Plain-text Markdown (e.g. a docs page's "Copy page") becomes blocks, not literal text.
260
+ // Rich HTML clipboards keep ProseMirror's own handling; code editors (VS Code) also
261
+ // put styled HTML there, so their payload still counts as plain text.
262
+ const pasteMarkdown = (event: ClipboardEvent): boolean => {
263
+ const data = event.clipboardData
264
+ const ed = editorRef.current
265
+ if (!data || !ed) return false
266
+ const types = Array.from(data.types ?? [])
267
+ if (types.includes("text/html") && !types.includes("vscode-editor-data")) return false
268
+ const pasted = markdownPasteContent(data.getData("text/plain"))
269
+ if (!pasted?.content.length) return false
270
+ event.preventDefault()
271
+ if (pasted.citations) {
272
+ const known = new Map((citationsRef.current ?? []).map((c) => [c.id, c]))
273
+ for (const c of pasted.citations) known.set(c.id, c)
274
+ citationsRef.current = [...known.values()]
275
+ }
276
+ ed.chain().focus().insertContent(pasted.content).run()
277
+ return true
278
+ }
279
+
239
280
  const parseAndTrack = (md: string) => {
240
281
  const doc = parseMarkdown(md)
241
282
  citationsRef.current = doc.citations
@@ -258,10 +299,12 @@ export function GitbookEditor({
258
299
  if (latest.current.onPaste?.(event)) return true
259
300
  if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
260
301
  const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
261
- if (!files.length) return false
262
- event.preventDefault()
263
- insertImageFiles(files)
264
- return true
302
+ if (files.length) {
303
+ event.preventDefault()
304
+ insertImageFiles(files)
305
+ return true
306
+ }
307
+ return latest.current.markdownPaste ? pasteMarkdown(event) : false
265
308
  },
266
309
  handleDrop: (view, event, _slice, moved) => {
267
310
  if (moved || !view.editable) return false
@@ -317,9 +360,11 @@ export function GitbookEditor({
317
360
  ...(onSourceSaved ? { onSourceSaved } : {}),
318
361
  ...(onSourceError ? { onSourceError } : {}),
319
362
  ...(demoResolver ? { demoResolver } : {}),
363
+ ...(demoRuntime ? { demoRuntime } : {}),
364
+ ...(demoDependencies ? { demoDependencies } : {}),
320
365
  ...(attachments ? { attachments } : {}),
321
366
  ...(onAttachmentOpen ? { onAttachmentOpen } : {}),
322
- }), [attachments, demoResolver, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
367
+ }), [attachments, demoDependencies, demoResolver, demoRuntime, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
323
368
 
324
369
  if (!editor) return null
325
370
 
@@ -1,8 +1,12 @@
1
1
  import {
2
+ parseMarkdown,
2
3
  plainText,
3
4
  serializeMarkdown,
4
5
  type Block,
6
+ type CitationDef,
5
7
  type DemoLayout,
8
+ type CommandNode,
9
+ type DemoInlineFile,
6
10
  type DemoNode,
7
11
  type DemoViewport,
8
12
  type DocumentNode,
@@ -59,7 +63,8 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
59
63
  if (n.italic) marks.push({ type: "italic" })
60
64
  if (n.strike) marks.push({ type: "strike" })
61
65
  if (n.code) marks.push({ type: "code" })
62
- if (n.link) marks.push({ type: "link", attrs: { href: n.link } })
66
+ // `inner` keeps `**[x](url)**` (link inside the emphasis) apart from `[**x**](url)`.
67
+ if (n.link) marks.push({ type: "link", attrs: { href: n.link, ...(n.linkInner ? { inner: true } : {}) } })
63
68
  return { type: "text", text: n.text, ...(marks.length ? { marks } : {}) }
64
69
  })
65
70
  }
@@ -96,21 +101,38 @@ function blockToPM(b: Block): PMNode {
96
101
  lineNumbers: b.lineNumbers,
97
102
  live: !!b.live,
98
103
  entry: b.entry ?? null,
104
+ collapsedCodeLines: b.collapsedCodeLines ?? null,
105
+ expandedCodeLines: b.expandedCodeLines ?? null,
99
106
  },
100
107
  content: b.code ? [{ type: "text", text: b.code }] : [],
101
108
  }
102
109
  case "hint":
103
110
  return { type: "gbHint", attrs: { style: b.style }, content: blocksPM(b.children) }
104
- case "tabs":
111
+ case "tabs": {
112
+ const attrs = {
113
+ ...(b.sync ? { sync: b.sync } : {}),
114
+ ...(b.title ? { title: b.title } : {}),
115
+ ...(b.level ? { level: b.level } : {}),
116
+ }
105
117
  return {
106
118
  type: "gbTabs",
107
- ...(b.sync ? { attrs: { sync: b.sync } } : {}),
119
+ ...(Object.keys(attrs).length ? { attrs } : {}),
108
120
  content: b.tabs.map((t) => ({
109
121
  type: "gbTab",
110
122
  attrs: { title: t.title },
111
123
  content: blocksPM(t.children),
112
124
  })),
113
125
  }
126
+ }
127
+ case "command":
128
+ return {
129
+ type: "gbCommand",
130
+ attrs: {
131
+ command: b.command,
132
+ overrides: b.overrides && Object.keys(b.overrides).length ? { ...b.overrides } : null,
133
+ sync: b.sync ?? null,
134
+ },
135
+ }
114
136
  case "expandable":
115
137
  return {
116
138
  type: "gbExpandable",
@@ -136,8 +158,13 @@ function blockToPM(b: Block): PMNode {
136
158
  if (b.controls !== undefined) attrs.controls = b.controls
137
159
  return { type: "gbEmbed", attrs }
138
160
  }
139
- case "content-ref":
140
- return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children) } }
161
+ case "content-ref": {
162
+ // GitBook writes the label as a link to the page (`[Card](card.md)`); remember
163
+ // whether this one was, so a plain label stays plain.
164
+ const only = b.children.length === 1 ? b.children[0] : undefined
165
+ const linked = only?.type === "text" && only.link === b.url && !only.bold && !only.italic && !only.strike && !only.code
166
+ return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children), linked } }
167
+ }
141
168
  case "source-ref":
142
169
  return {
143
170
  type: "gbSourceRef",
@@ -160,6 +187,8 @@ function blockToPM(b: Block): PMNode {
160
187
  layout: b.layout ?? null,
161
188
  variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
162
189
  viewport: b.viewport ?? null,
190
+ entry: b.entry ?? null,
191
+ files: b.files?.length ? b.files.map((f) => ({ ...f })) : null,
163
192
  },
164
193
  }
165
194
  case "columns":
@@ -279,8 +308,12 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
279
308
  if (mark.type === "italic") inline.italic = true
280
309
  if (mark.type === "strike") inline.strike = true
281
310
  if (mark.type === "code") inline.code = true
282
- if (mark.type === "link") inline.link = String(mark.attrs?.href ?? "")
311
+ if (mark.type === "link") {
312
+ inline.link = String(mark.attrs?.href ?? "")
313
+ if (mark.attrs?.inner) inline.linkInner = true
314
+ }
283
315
  }
316
+ if (inline.linkInner && !(inline.bold || inline.italic || inline.strike)) delete inline.linkInner
284
317
  return inline
285
318
  })
286
319
  }
@@ -303,7 +336,9 @@ function pmToBlock(n: PMNode): Block | null {
303
336
  level: (Number(n.attrs?.level) || 1) as 1 | 2 | 3 | 4 | 5 | 6,
304
337
  children: pmTextToInline(n.content),
305
338
  }
306
- case "codeBlock":
339
+ case "codeBlock": {
340
+ const collapsed = Number(n.attrs?.collapsedCodeLines) || 0
341
+ const expanded = Number(n.attrs?.expandedCodeLines) || 0
307
342
  return {
308
343
  type: "code",
309
344
  language: (n.attrs?.language as string) || null,
@@ -311,15 +346,19 @@ function pmToBlock(n: PMNode): Block | null {
311
346
  lineNumbers: !!n.attrs?.lineNumbers,
312
347
  live: !!n.attrs?.live,
313
348
  entry: (n.attrs?.entry as string) || null,
349
+ ...(collapsed ? { collapsedCodeLines: collapsed } : {}),
350
+ ...(expanded ? { expandedCodeLines: expanded } : {}),
314
351
  code: n.content?.map((c) => c.text ?? "").join("") ?? "",
315
352
  }
353
+ }
316
354
  case "gbHint":
317
355
  return {
318
356
  type: "hint",
319
357
  style: (n.attrs?.style as never) ?? "info",
320
358
  children: pmToBlocks(n.content),
321
359
  }
322
- case "gbTabs":
360
+ case "gbTabs": {
361
+ const level = Number(n.attrs?.level)
323
362
  return {
324
363
  type: "tabs",
325
364
  tabs: (n.content ?? []).map((t) => ({
@@ -328,7 +367,12 @@ function pmToBlock(n: PMNode): Block | null {
328
367
  children: pmToBlocks(t.content),
329
368
  })),
330
369
  ...(n.attrs?.sync ? { sync: String(n.attrs.sync) } : {}),
370
+ ...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
371
+ ...(level === 2 || level === 3 || level === 4 ? { level } : {}),
331
372
  }
373
+ }
374
+ case "gbCommand":
375
+ return pmToCommand(n.attrs ?? {})
332
376
  case "gbExpandable":
333
377
  return {
334
378
  type: "expandable",
@@ -357,12 +401,16 @@ function pmToBlock(n: PMNode): Block | null {
357
401
  if (typeof n.attrs?.controls === "boolean") embed.controls = n.attrs.controls
358
402
  return embed
359
403
  }
360
- case "gbContentRef":
404
+ case "gbContentRef": {
405
+ const url = String(n.attrs?.url ?? "")
406
+ const label = String(n.attrs?.label ?? "")
407
+ const linked = n.attrs?.linked !== false && !!url && !!label
361
408
  return {
362
409
  type: "content-ref",
363
- url: String(n.attrs?.url ?? ""),
364
- children: [{ type: "text", text: String(n.attrs?.label ?? "") }],
410
+ url,
411
+ children: label ? [{ type: "text", text: label, ...(linked ? { link: url } : {}) }] : [],
365
412
  }
413
+ }
366
414
  case "gbSourceRef":
367
415
  return {
368
416
  type: "source-ref",
@@ -454,6 +502,7 @@ export function pmToDemo(a: Record<string, unknown>): DemoNode {
454
502
  .map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
455
503
  : []
456
504
  const layout = String(a.layout ?? "")
505
+ const files = pmDemoFiles(a.files)
457
506
  const viewport = String(a.viewport ?? "")
458
507
  return {
459
508
  type: "demo",
@@ -464,6 +513,35 @@ export function pmToDemo(a: Record<string, unknown>): DemoNode {
464
513
  ...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
465
514
  ...(variants.length ? { variants } : {}),
466
515
  ...(DEMO_VIEWPORTS.includes(viewport) ? { viewport: viewport as DemoViewport } : {}),
516
+ ...(a.entry ? { entry: String(a.entry) } : {}),
517
+ ...(files.length ? { files } : {}),
518
+ }
519
+ }
520
+
521
+ /** `gbDemo`'s `files` attr → inline demo files (malformed entries dropped). */
522
+ export function pmDemoFiles(value: unknown): DemoInlineFile[] {
523
+ if (!Array.isArray(value)) return []
524
+ return (value as Array<Record<string, unknown> | null>)
525
+ .filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
526
+ .map((f) => ({
527
+ path: String(f.path ?? ""),
528
+ content: String(f.content ?? ""),
529
+ ...(f.language ? { language: String(f.language) } : {}),
530
+ }))
531
+ }
532
+
533
+ const COMMAND_OVERRIDES = ["pnpm", "yarn", "bun"] as const
534
+
535
+ /** `gbCommand` attrs → `CommandNode`; also used by the node view to render the box. */
536
+ export function pmToCommand(a: Record<string, unknown>): CommandNode {
537
+ const raw = (a.overrides && typeof a.overrides === "object" ? a.overrides : {}) as Record<string, unknown>
538
+ const overrides: NonNullable<CommandNode["overrides"]> = {}
539
+ for (const pm of COMMAND_OVERRIDES) if (raw[pm]) overrides[pm] = String(raw[pm])
540
+ return {
541
+ type: "command",
542
+ command: String(a.command ?? ""),
543
+ ...(Object.keys(overrides).length ? { overrides } : {}),
544
+ ...(a.sync ? { sync: String(a.sync) } : {}),
467
545
  }
468
546
  }
469
547
 
@@ -490,3 +568,17 @@ export function serializeEditorMarkdown(doc: DocumentNode): string {
490
568
  alt.includes("]") ? img : `![${alt}](${src})`
491
569
  )
492
570
  }
571
+
572
+ /**
573
+ * Plain-text Markdown pasted into the editor (a docs page's "Copy page", a `{% … %}`
574
+ * block, a fence, a list, a heading…) as editor blocks, or null when the text is just
575
+ * prose — which the editor then pastes as text, as usual.
576
+ */
577
+ export function markdownPasteContent(text: string): { content: PMNode[]; citations?: CitationDef[] } | null {
578
+ if (!text.trim()) return null
579
+ const doc = parseMarkdown(text)
580
+ const blocks = doc.children.some((b) => b.type !== "paragraph") || /(^|\n)\s*\{%\s*[a-z-]+/.test(text)
581
+ if (!blocks && !doc.citations?.length) return null
582
+ const content = astToTiptap(doc).content ?? []
583
+ return { content, ...(doc.citations?.length ? { citations: doc.citations } : {}) }
584
+ }
@@ -2,7 +2,7 @@ import StarterKit from "@tiptap/starter-kit"
2
2
  import Placeholder from "@tiptap/extension-placeholder"
3
3
  import { TaskItem, TaskList } from "@tiptap/extension-list"
4
4
  import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
5
- import { getSchema, type AnyExtension } from "@tiptap/core"
5
+ import { Extension, 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"
@@ -15,6 +15,29 @@ export const GbTable = Table.extend({
15
15
  },
16
16
  })
17
17
 
18
+ /**
19
+ * `inner` on the link mark: the link sits inside its emphasis (`**[x](url)**`)
20
+ * rather than around it (`[**x**](url)`). Kept so pasted Markdown round-trips
21
+ * byte-for-byte; newly created links default to the outer form.
22
+ */
23
+ export const GbLinkInner = Extension.create({
24
+ name: "gbLinkInner",
25
+ addGlobalAttributes() {
26
+ return [
27
+ {
28
+ types: ["link"],
29
+ attributes: {
30
+ inner: {
31
+ default: null,
32
+ parseHTML: (element) => (element.hasAttribute("data-link-inner") ? true : null),
33
+ renderHTML: (attributes) => (attributes.inner ? { "data-link-inner": "" } : {}),
34
+ },
35
+ },
36
+ },
37
+ ]
38
+ },
39
+ })
40
+
18
41
  export interface ReferenceSources {
19
42
  /** "@" — people, agents, … */
20
43
  mentions?: ReferenceSource
@@ -71,6 +94,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
71
94
  TableHeader,
72
95
  TableCell,
73
96
  Placeholder.configure({ placeholder }),
97
+ GbLinkInner,
74
98
  ...gitbookNodes,
75
99
  ]
76
100
 
@@ -16,6 +16,7 @@ import {
16
16
  ChevronDown,
17
17
  FolderGit2,
18
18
  Hash,
19
+ Heading,
19
20
  Info,
20
21
  Link2,
21
22
  FileCode2,
@@ -27,12 +28,12 @@ import {
27
28
  import { DocsRenderer } from "@brett_lamy/docstream"
28
29
  import { resolveAsset } from "@brett_lamy/docstream/assets"
29
30
  import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
30
- import type { DocumentNode, HintStyle, SourceRefNode } from "@brett_lamy/docstream/gitbook"
31
+ import { packageManagerCommands, type DemoInlineFile, type DocumentNode, type HintStyle, type SourceRefNode } from "@brett_lamy/docstream/gitbook"
31
32
  import { ReactCodePreview } from "@brett_lamy/docstream/playground"
32
33
  import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
33
34
  import { VideoEmbed } from "@brett_lamy/docstream/video"
34
35
  import { DemoViewer } from "@brett_lamy/docstream/demo"
35
- import { pmToDemo } from "./convert"
36
+ import { pmToCommand, pmToDemo } from "./convert"
36
37
  import { SourceFileEditor } from "./SourceFileEditor"
37
38
  import { useEditorRuntime } from "./runtime"
38
39
  import { GbAttachment } from "./attachments"
@@ -135,9 +136,14 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
135
136
  updateAttributes({ active: Math.max(0, active - (index <= active ? 1 : 0)) })
136
137
  }
137
138
 
138
- return (
139
- <NodeViewWrapper className="gb-tabs">
140
- <div className="gb-tabs-header" contentEditable={false}>
139
+ const editable = editor.isEditable
140
+ const title = typeof node.attrs.title === "string" ? node.attrs.title : ""
141
+ const level = node.attrs.level === 3 || node.attrs.level === 4 ? node.attrs.level : 2
142
+ // `{% tabs title="…" %}`: the set renders as a section — heading, then the switch on its row.
143
+ const section = !!title || node.attrs.titled === true
144
+
145
+ const tabStrip = (
146
+ <>
141
147
  {titles.map((t, idx) => (
142
148
  <div
143
149
  key={idx}
@@ -161,11 +167,67 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
161
167
  )}
162
168
  </div>
163
169
  ))}
164
- {editor.isEditable && (
170
+ {editable && (
165
171
  <button className="gb-icon-btn" onClick={addTab} title="Add tab">
166
172
  <Plus className="size-3.5" />
167
173
  </button>
168
174
  )}
175
+ {editable && !section && (
176
+ <button
177
+ className="gb-icon-btn gb-tabs-add-title"
178
+ onClick={() => updateAttributes({ titled: true })}
179
+ title="Add a section title (renders the tabs as a titled section)"
180
+ >
181
+ <Heading className="size-3.5" />
182
+ </button>
183
+ )}
184
+ </>
185
+ )
186
+
187
+ if (section) {
188
+ const HeadingTag = `h${level}` as "h2" | "h3" | "h4"
189
+ return (
190
+ <NodeViewWrapper className="gb-tabs gb-tabs-section" data-level={level}>
191
+ <div className="gb-tabs-section-head" contentEditable={false}>
192
+ <HeadingTag className="gb-tabs-section-title">
193
+ {editable ? (
194
+ <input
195
+ className="gb-inline-input gb-tabs-section-input"
196
+ value={title}
197
+ placeholder="Section title…"
198
+ autoFocus={!title}
199
+ onChange={(e) => updateAttributes({ title: e.target.value || null })}
200
+ onBlur={(e) => {
201
+ if (!e.target.value) updateAttributes({ title: null, titled: false })
202
+ }}
203
+ />
204
+ ) : (
205
+ title
206
+ )}
207
+ </HeadingTag>
208
+ {editable && (
209
+ <select
210
+ className="gb-tabs-level"
211
+ value={level}
212
+ title="Heading level"
213
+ onChange={(e) => updateAttributes({ level: Number(e.target.value) === 2 ? null : Number(e.target.value) })}
214
+ >
215
+ <option value={2}>H2</option>
216
+ <option value={3}>H3</option>
217
+ <option value={4}>H4</option>
218
+ </select>
219
+ )}
220
+ <div className="gb-tabs-pills">{tabStrip}</div>
221
+ </div>
222
+ <NodeViewContent className="gb-tabs-content" data-active={active} />
223
+ </NodeViewWrapper>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <NodeViewWrapper className="gb-tabs">
229
+ <div className="gb-tabs-header" contentEditable={false}>
230
+ {tabStrip}
169
231
  </div>
170
232
  <NodeViewContent className="gb-tabs-content" data-active={active} />
171
233
  </NodeViewWrapper>
@@ -187,6 +249,19 @@ export const GbTabs = Node.create({
187
249
  parseHTML: (element) => element.getAttribute("data-sync") || null,
188
250
  renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
189
251
  },
252
+ // `{% tabs title="Installation" level="3" %}` — a titled set renders as a section heading.
253
+ title: {
254
+ default: null,
255
+ parseHTML: (element) => element.getAttribute("data-title") || null,
256
+ renderHTML: (attributes) => (attributes.title ? { "data-title": attributes.title } : {}),
257
+ },
258
+ level: {
259
+ default: null,
260
+ parseHTML: (element) => Number(element.getAttribute("data-level")) || null,
261
+ renderHTML: (attributes) => (attributes.level ? { "data-level": attributes.level } : {}),
262
+ },
263
+ // UI only: a title field is open but still empty.
264
+ titled: { default: false, rendered: false },
190
265
  }
191
266
  },
192
267
  parseHTML() {
@@ -443,7 +518,8 @@ export const GbContentRef = Node.create({
443
518
  group: "block",
444
519
  atom: true,
445
520
  addAttributes() {
446
- return { url: { default: "" }, label: { default: "" } }
521
+ // `linked`: the label is written as a link to the page (`[Card](card.md)`, GitBook's form).
522
+ return { url: { default: "" }, label: { default: "" }, linked: { default: true, rendered: false } }
447
523
  },
448
524
  parseHTML() {
449
525
  return [{ tag: "div[data-gb-content-ref]" }]
@@ -528,20 +604,146 @@ export const GbSourceRef = Node.create({
528
604
 
529
605
  // ---------- Demo ----------
530
606
 
607
+ /** Inline demo files editor: a tab per file (path editable), a code area, add / remove. */
608
+ function DemoFilesEditor({ files, entry, onChange }: {
609
+ files: DemoInlineFile[]
610
+ entry: string
611
+ onChange: (files: DemoInlineFile[], entry?: string | null) => void
612
+ }) {
613
+ const [selected, setSelected] = useState(0)
614
+ const index = Math.min(selected, Math.max(0, files.length - 1))
615
+ const file = files[index]
616
+ const update = (patch: Partial<DemoInlineFile>) => {
617
+ const next = files.map((f, i) => {
618
+ if (i !== index) return f
619
+ const merged = { ...f, ...patch }
620
+ if (!merged.language) delete merged.language
621
+ return merged
622
+ })
623
+ // Renaming the entry file keeps it the entry.
624
+ const renamed = patch.path !== undefined && file && entry === file.path ? patch.path : undefined
625
+ onChange(next, renamed)
626
+ }
627
+ const add = () => {
628
+ const taken = new Set(files.map((f) => f.path))
629
+ let n = files.length + 1
630
+ let path = files.length ? `file${n}.tsx` : "index.tsx"
631
+ while (taken.has(path)) path = `file${++n}.tsx`
632
+ onChange([...files, { path, content: "", language: "tsx" }])
633
+ setSelected(files.length)
634
+ }
635
+ const remove = (i: number) => {
636
+ const removed = files[i]
637
+ onChange(files.filter((_, k) => k !== i), removed && entry === removed.path ? null : undefined)
638
+ setSelected(Math.max(0, i - 1))
639
+ }
640
+ return (
641
+ <div className="gb-demo-files">
642
+ <div className="gb-demo-files-tabs" role="tablist" aria-label="Demo files">
643
+ {files.map((f, i) => (
644
+ <div key={i} className={i === index ? "gb-demo-file-tab gb-demo-file-tab-active" : "gb-demo-file-tab"} onClick={() => setSelected(i)}>
645
+ {i === index ? (
646
+ <input
647
+ className="gb-demo-file-path"
648
+ value={f.path}
649
+ size={Math.max(f.path.length, 6)}
650
+ placeholder="index.tsx"
651
+ aria-label="File path"
652
+ onChange={(e) => update({ path: e.target.value })}
653
+ />
654
+ ) : (
655
+ <span>{f.path || "untitled"}</span>
656
+ )}
657
+ {f.path && entry === f.path ? <span className="gb-demo-file-entry">entry</span> : null}
658
+ <button type="button" className="gb-icon-btn" title={`Remove ${f.path || "file"}`} onClick={(e) => { e.stopPropagation(); remove(i) }}>
659
+ <X className="size-3" />
660
+ </button>
661
+ </div>
662
+ ))}
663
+ <button type="button" className="gb-icon-btn" title="Add file" onClick={add}>
664
+ <Plus className="size-3.5" />
665
+ </button>
666
+ </div>
667
+ {file ? (
668
+ <div className="gb-demo-file-body">
669
+ <div className="gb-demo-file-meta">
670
+ <input
671
+ className="gb-inline-input gb-demo-file-lang"
672
+ value={file.language ?? ""}
673
+ placeholder="lang"
674
+ aria-label="Language"
675
+ onChange={(e) => update({ language: e.target.value })}
676
+ />
677
+ <label className="gb-demo-file-entry-toggle">
678
+ <input
679
+ type="radio"
680
+ checked={entry ? entry === file.path : index === 0}
681
+ onChange={() => onChange(files, index === 0 ? null : file.path)}
682
+ />
683
+ Entry
684
+ </label>
685
+ </div>
686
+ <textarea
687
+ className="gb-demo-file-code"
688
+ value={file.content}
689
+ spellCheck={false}
690
+ rows={Math.min(24, Math.max(4, file.content.split("\n").length + 1))}
691
+ aria-label={`Edit ${file.path}`}
692
+ onChange={(e) => update({ content: e.target.value })}
693
+ onKeyDown={(e) => {
694
+ if (e.key !== "Tab" || e.shiftKey) return
695
+ e.preventDefault()
696
+ const el = e.currentTarget
697
+ const { selectionStart: a, selectionEnd: b } = el
698
+ update({ content: `${el.value.slice(0, a)} ${el.value.slice(b)}` })
699
+ requestAnimationFrame(() => el.setSelectionRange(a + 2, a + 2))
700
+ }}
701
+ />
702
+ </div>
703
+ ) : (
704
+ <div className="gb-demo-files-empty">No inline files — the demo resolves from its <code>src</code>.</div>
705
+ )}
706
+ </div>
707
+ )
708
+ }
709
+
531
710
  function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
532
711
  const editable = editor.isEditable
533
- const { demoResolver } = useEditorRuntime()
712
+ const { demoResolver, demoRuntime, demoDependencies } = useEditorRuntime()
534
713
  const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
535
- // Typing into `src` would otherwise resolve every intermediate id.
714
+ const [filesOpen, setFilesOpen] = useState(false)
715
+ // Typing into `src` or a file would otherwise re-resolve / re-run every keystroke.
536
716
  const src = useDebouncedValue(demo.src)
717
+ const files = useDebouncedValue(demo.files)
718
+ const entry = useDebouncedValue(demo.entry)
719
+ const hasFiles = !!demo.files?.length
720
+ // The resolver is the authority when it knows `src`; inline files stand in otherwise
721
+ // (DemoViewer falls back to them), so a pasted docs page looks like the page.
722
+ const preview = (!!demoResolver && !!src) || !!files?.length
723
+ const setFiles = (next: DemoInlineFile[], nextEntry?: string | null) => {
724
+ updateAttributes({
725
+ files: next.length ? next : null,
726
+ ...(nextEntry !== undefined ? { entry: nextEntry || null } : {}),
727
+ })
728
+ }
537
729
  return (
538
- <NodeViewWrapper className={demoResolver && src ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
730
+ <NodeViewWrapper className={preview || (editable && filesOpen) ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
539
731
  <div className="gb-demo">
540
732
  <AppWindow className="size-4 shrink-0" />
541
733
  {editable ? (
542
734
  <>
543
735
  <input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
544
736
  <input className="gb-inline-input gb-demo-title" value={node.attrs.title} placeholder="Title" onChange={(event) => updateAttributes({ title: event.target.value })} />
737
+ <button
738
+ type="button"
739
+ className={filesOpen ? "gb-demo-files-toggle gb-demo-files-toggle-active" : "gb-demo-files-toggle"}
740
+ title="Edit the files carried inline by this demo"
741
+ aria-expanded={filesOpen}
742
+ onClick={() => setFilesOpen((open) => !open)}
743
+ >
744
+ <FileCode2 className="size-3.5" />
745
+ {hasFiles ? `${demo.files!.length} file${demo.files!.length === 1 ? "" : "s"}` : "Files"}
746
+ </button>
545
747
  </>
546
748
  ) : (
547
749
  <>
@@ -550,11 +752,14 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
550
752
  </>
551
753
  )}
552
754
  </div>
553
- {demoResolver && src ? (
755
+ {editable && filesOpen ? (
756
+ <DemoFilesEditor files={demo.files ?? []} entry={demo.entry ?? ""} onChange={setFiles} />
757
+ ) : null}
758
+ {preview ? (
554
759
  <div className="gb-demo-preview">
555
760
  <DemoViewer
556
761
  key={src}
557
- resolver={demoResolver}
762
+ {...(demoResolver ? { resolver: demoResolver } : {})}
558
763
  src={src}
559
764
  title={demo.title}
560
765
  description={demo.description}
@@ -562,6 +767,10 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
562
767
  layout={demo.layout}
563
768
  variants={demo.variants}
564
769
  viewport={demo.viewport}
770
+ files={files}
771
+ entry={entry}
772
+ {...(demoRuntime ? { runtime: demoRuntime } : {})}
773
+ {...(demoDependencies ? { dependencies: demoDependencies } : {})}
565
774
  />
566
775
  </div>
567
776
  ) : null}
@@ -569,7 +778,10 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
569
778
  )
570
779
  }
571
780
 
572
- /** `{% demo %}` — every tag attribute is kept on the node so it round-trips losslessly. */
781
+ /**
782
+ * `{% demo %}` — every tag attribute is kept on the node, and the block form's inline
783
+ * files (`files`, in document order) with it, so it round-trips losslessly.
784
+ */
573
785
  export const GbDemo = Node.create({
574
786
  name: "gbDemo",
575
787
  group: "block",
@@ -583,6 +795,22 @@ export const GbDemo = Node.create({
583
795
  layout: { default: null },
584
796
  variants: { default: null, rendered: false },
585
797
  viewport: { default: null },
798
+ entry: { default: null },
799
+ // `{ path, content, language? }[]` — the files between `{% demo %}` and `{% enddemo %}`.
800
+ files: {
801
+ default: null,
802
+ rendered: false,
803
+ parseHTML: (element) => {
804
+ const raw = element.getAttribute("data-files")
805
+ if (!raw) return null
806
+ try {
807
+ return JSON.parse(raw)
808
+ } catch {
809
+ return null
810
+ }
811
+ },
812
+ renderHTML: (attributes) => (attributes.files ? { "data-files": JSON.stringify(attributes.files) } : {}),
813
+ },
586
814
  }
587
815
  },
588
816
  parseHTML() {
@@ -596,6 +824,86 @@ export const GbDemo = Node.create({
596
824
  },
597
825
  })
598
826
 
827
+ // ---------- Command (package-manager install box) ----------
828
+
829
+ const COMMAND_MANAGERS = ["pnpm", "yarn", "bun"] as const
830
+
831
+ function CommandView({ node, updateAttributes, editor }: NodeViewProps) {
832
+ const editable = editor.isEditable
833
+ const command = useMemo(() => pmToCommand(node.attrs), [node.attrs])
834
+ const derived = useMemo(() => packageManagerCommands(command.command), [command.command])
835
+ // docstream's own command box, so the editor shows exactly what readers get.
836
+ const doc = useMemo<DocumentNode>(() => ({ type: "doc", children: [command] }), [command])
837
+ const setOverride = (pm: (typeof COMMAND_MANAGERS)[number], value: string) => {
838
+ const next = { ...(command.overrides ?? {}) }
839
+ if (value) next[pm] = value
840
+ else delete next[pm]
841
+ updateAttributes({ overrides: Object.keys(next).length ? next : null })
842
+ }
843
+ return (
844
+ <NodeViewWrapper className="gb-command" contentEditable={false}>
845
+ {command.command ? (
846
+ <div className="gb-command-preview">
847
+ <DocsRenderer doc={doc} />
848
+ </div>
849
+ ) : null}
850
+ {editable ? (
851
+ <div className="gb-command-fields">
852
+ <label className="gb-command-field gb-command-field-npm">
853
+ <span>npm</span>
854
+ <textarea
855
+ className="gb-command-input"
856
+ value={command.command}
857
+ placeholder="npm install <package>"
858
+ spellCheck={false}
859
+ rows={Math.max(1, command.command.split("\n").length)}
860
+ onChange={(e) => updateAttributes({ command: e.target.value })}
861
+ />
862
+ </label>
863
+ {COMMAND_MANAGERS.map((pm) => (
864
+ <label key={pm} className="gb-command-field">
865
+ <span>{pm}</span>
866
+ <input
867
+ className="gb-inline-input gb-command-override"
868
+ value={command.overrides?.[pm] ?? ""}
869
+ placeholder={derived[pm] || `${pm} (derived)`}
870
+ spellCheck={false}
871
+ title={`Override the derived ${pm} command (leave empty to derive it)`}
872
+ onChange={(e) => setOverride(pm, e.target.value)}
873
+ />
874
+ </label>
875
+ ))}
876
+ </div>
877
+ ) : null}
878
+ </NodeViewWrapper>
879
+ )
880
+ }
881
+
882
+ /** `{% command %}npm install x{% endcommand %}` — one npm command shown for the reader's package manager. */
883
+ export const GbCommand = Node.create({
884
+ name: "gbCommand",
885
+ group: "block",
886
+ atom: true,
887
+ addAttributes() {
888
+ return {
889
+ command: { default: "" },
890
+ // `{ pnpm?, yarn?, bun? }` — verbatim commands replacing the derived ones.
891
+ overrides: { default: null, rendered: false },
892
+ // Sync key; null means docstream's default ("pm").
893
+ sync: { default: null },
894
+ }
895
+ },
896
+ parseHTML() {
897
+ return [{ tag: "div[data-gb-command]" }]
898
+ },
899
+ renderHTML({ HTMLAttributes, node }) {
900
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-command": "" }), node.attrs.command]
901
+ },
902
+ addNodeView() {
903
+ return ReactNodeViewRenderer(CommandView)
904
+ },
905
+ })
906
+
599
907
  // ---------- Columns ----------
600
908
 
601
909
  export const GbColumns = Node.create({
@@ -809,6 +1117,8 @@ export const GbCodeBlock = CodeBlock.extend({
809
1117
  lineNumbers: { default: false },
810
1118
  live: { default: false },
811
1119
  entry: { default: null },
1120
+ collapsedCodeLines: { default: null, rendered: false },
1121
+ expandedCodeLines: { default: null, rendered: false },
812
1122
  }
813
1123
  },
814
1124
  addNodeView() {
@@ -1057,6 +1367,7 @@ export const gitbookNodes = [
1057
1367
  GbContentRef,
1058
1368
  GbSourceRef,
1059
1369
  GbDemo,
1370
+ GbCommand,
1060
1371
  GbColumns,
1061
1372
  GbColumn,
1062
1373
  GbFigure,
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from "react"
2
2
 
3
- import type { DemoResolver } from "@brett_lamy/docstream/demo"
3
+ import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
4
4
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
5
5
  import type { EditorAttachment } from "./attachments"
6
6
 
@@ -11,6 +11,8 @@ export interface EditorRuntimeOptions {
11
11
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
12
12
  onSourceError?: (error: Error) => void
13
13
  demoResolver?: DemoResolver
14
+ demoRuntime?: InlineDemoRuntime
15
+ demoDependencies?: Record<string, string>
14
16
  attachments?: EditorAttachment[]
15
17
  onAttachmentOpen?: (attachment: EditorAttachment) => void
16
18
  }
@@ -23,6 +23,7 @@ import {
23
23
  SquareChevronDown,
24
24
  Superscript,
25
25
  Table as TableIcon,
26
+ Terminal,
26
27
  Webhook,
27
28
  Workflow,
28
29
  type LucideIcon,
@@ -72,6 +73,12 @@ export const SLASH_ITEMS: SlashItem[] = [
72
73
  ],
73
74
  }),
74
75
  },
76
+ {
77
+ title: "Install command",
78
+ keywords: "command npm pnpm yarn bun npx package manager install terminal shell",
79
+ icon: Terminal,
80
+ run: insertBlock({ type: "gbCommand", attrs: { command: "", overrides: null, sync: null } }),
81
+ },
75
82
  {
76
83
  title: "Expandable",
77
84
  keywords: "details accordion collapse",
package/src/index.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  export { GitbookEditor } from "./editor/Editor"
2
2
  export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
3
- export { astToTiptap, tiptapToAst, serializeEditorMarkdown } from "./editor/convert"
3
+ export { astToTiptap, tiptapToAst, serializeEditorMarkdown, markdownPasteContent } from "./editor/convert"
4
4
  export type { PMNode } from "./editor/convert"
5
5
 
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
- export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
8
+ export { createGitbookExtensions, getGitbookSchema, GbTable, GbLinkInner } from "./editor/extensions"
9
9
  export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
10
- export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
10
+ export { gitbookNodes, GbCodeBlock, GbCommand, GbDemo, GbReference } from "./editor/nodes"
11
11
  export { GbAttachment, formatBytes } from "./editor/attachments"
12
12
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
13
13
  export type { SlashItem } from "./editor/slash-menu"
package/src/styles.css CHANGED
@@ -125,6 +125,89 @@
125
125
  margin: 0;
126
126
  }
127
127
 
128
+ .gb-demo-files-toggle {
129
+ display: inline-flex;
130
+ align-items: center;
131
+ gap: 4px;
132
+ padding: 2px 8px;
133
+ border: 1px solid var(--gb-border);
134
+ border-radius: 999px;
135
+ background: transparent;
136
+ color: var(--gb-muted-foreground);
137
+ font-size: 12px;
138
+ cursor: pointer;
139
+ white-space: nowrap;
140
+ }
141
+ .gb-demo-files-toggle-active { background: var(--gb-bg); color: var(--gb-text); }
142
+
143
+ /* Inline demo files: a tab per file and a code area. */
144
+ .gb-demo-files {
145
+ border: 1px solid var(--gb-border);
146
+ border-top: 0;
147
+ background: var(--gb-panel);
148
+ }
149
+ .gb-demo-block:not(.gb-demo-block-preview) .gb-demo-files,
150
+ .gb-demo-files:last-child {
151
+ border-radius: 0 0 var(--gb-radius) var(--gb-radius);
152
+ }
153
+ .gb-demo-files-tabs {
154
+ display: flex;
155
+ flex-wrap: wrap;
156
+ align-items: center;
157
+ gap: 2px;
158
+ padding: 6px 8px 0;
159
+ border-bottom: 1px solid var(--gb-border);
160
+ background: var(--gb-muted);
161
+ }
162
+ .gb-demo-file-tab {
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 4px;
166
+ padding: 5px 10px;
167
+ border-radius: 8px 8px 0 0;
168
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
169
+ font-size: 12px;
170
+ color: var(--gb-muted-foreground);
171
+ cursor: pointer;
172
+ }
173
+ .gb-demo-file-tab-active { background: var(--gb-panel); color: var(--gb-text); }
174
+ .gb-demo-file-path { font: inherit; color: inherit; background: transparent; border: none; outline: none; }
175
+ .gb-demo-file-entry {
176
+ padding: 0 5px;
177
+ border-radius: 4px;
178
+ background: var(--gb-accent);
179
+ font-size: 10px;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.04em;
182
+ }
183
+ .gb-demo-file-body { display: flex; flex-direction: column; }
184
+ .gb-demo-file-meta {
185
+ display: flex;
186
+ align-items: center;
187
+ gap: 12px;
188
+ padding: 4px 12px;
189
+ font-size: 12px;
190
+ color: var(--gb-muted-foreground);
191
+ }
192
+ .gb-demo-file-lang { width: 6rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
193
+ .gb-demo-file-entry-toggle { display: inline-flex; align-items: center; gap: 4px; }
194
+ .gb-demo-file-code {
195
+ display: block;
196
+ width: 100%;
197
+ box-sizing: border-box;
198
+ padding: 8px 12px 12px;
199
+ border: 0;
200
+ outline: none;
201
+ resize: vertical;
202
+ background: transparent;
203
+ color: var(--gb-code-fg);
204
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
205
+ font-size: 12.5px;
206
+ line-height: 1.55;
207
+ tab-size: 2;
208
+ }
209
+ .gb-demo-files-empty { padding: 10px 12px; font-size: 12.5px; color: var(--gb-muted-foreground); }
210
+
128
211
  .gb-source-file-editor {
129
212
  border: 1px solid var(--gb-border);
130
213
  border-radius: var(--gb-radius);
@@ -406,6 +489,84 @@
406
489
  .gb-tabs-content .gb-tab > :first-child { margin-top: 0; }
407
490
  .gb-tabs-content .gb-tab > :last-child { margin-bottom: 0; }
408
491
 
492
+ /* ── Titled tabs (mirrors .docs-tabs-section): heading + pill switch on one row ── */
493
+ .gb-tabs-section {
494
+ border: 0;
495
+ border-radius: 0;
496
+ overflow: visible;
497
+ margin: 1.6em 0 0.9em;
498
+ }
499
+ .gb-tabs-section-head {
500
+ display: flex;
501
+ flex-wrap: wrap;
502
+ align-items: center;
503
+ gap: 12px;
504
+ margin-bottom: 12px;
505
+ }
506
+ .gb-tabs-section-title {
507
+ flex: 1;
508
+ min-width: 10rem;
509
+ margin: 0;
510
+ padding: 0;
511
+ border: 0;
512
+ }
513
+ .gb-tabs-section-input { width: 100%; font-weight: inherit; font-size: inherit; letter-spacing: inherit; }
514
+ .gb-tabs-level {
515
+ font-size: 12px;
516
+ color: var(--gb-muted-foreground);
517
+ background: transparent;
518
+ border: 1px solid var(--gb-border);
519
+ border-radius: 6px;
520
+ }
521
+ .gb-tabs-pills {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ gap: 2px;
525
+ padding: 3px;
526
+ border-radius: 999px;
527
+ background: var(--gb-muted);
528
+ }
529
+ .gb-tabs-pills .gb-tabs-tab { padding: 4px 12px; border-radius: 999px; font-size: 13px; }
530
+ .gb-tabs-pills .gb-tabs-tab-active { background: var(--gb-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
531
+ .gb-tabs-section > .gb-tabs-content { padding: 0; }
532
+
533
+ /* ── Command box: docstream's own box, with the editable npm command + overrides below ── */
534
+ .gb-command { margin: 0.85em 0; }
535
+ .gb-command-preview .docs-article { margin: 0; padding: 0; max-width: none; }
536
+ .gb-command-preview .docs-command { margin: 0; }
537
+ .gb-command-fields {
538
+ display: grid;
539
+ grid-template-columns: repeat(3, minmax(0, 1fr));
540
+ gap: 6px 10px;
541
+ margin-top: 6px;
542
+ padding: 8px 10px;
543
+ border: 1px dashed var(--gb-border);
544
+ border-radius: 10px;
545
+ font-size: 12.5px;
546
+ }
547
+ .gb-command-field { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
548
+ .gb-command-field > span {
549
+ flex-shrink: 0;
550
+ width: 2.8rem;
551
+ color: var(--gb-muted-foreground);
552
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
553
+ }
554
+ .gb-command-field-npm { grid-column: 1 / -1; }
555
+ .gb-command-input,
556
+ .gb-command-override {
557
+ flex: 1;
558
+ min-width: 0;
559
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
560
+ font-size: 12.5px;
561
+ }
562
+ .gb-command-input {
563
+ resize: vertical;
564
+ background: transparent;
565
+ border: none;
566
+ outline: none;
567
+ color: inherit;
568
+ }
569
+
409
570
  /* ── Expandable (mirrors .docs-expandable) ──────────────── */
410
571
  .gb-expandable {
411
572
  border: 1px solid var(--gb-border);