@brett_lamy/docstream-editor 1.0.0 → 1.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/README.md CHANGED
@@ -80,6 +80,7 @@ export interface GitbookEditorProps {
80
80
  onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
81
81
  onAttachmentRemove?: (ids: string[]) => void
82
82
  onAttachmentOpen?: (attachment: EditorAttachment) => void
83
+ demoResolver?: DemoResolver
83
84
  }
84
85
  ```
85
86
 
@@ -89,6 +90,8 @@ export interface GitbookEditorProps {
89
90
  return `true` when the application handled the event.
90
91
  - `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
91
92
  how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
93
+ - `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
94
+ preview — see [Demo blocks](#demo-blocks).
92
95
 
93
96
  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.
94
97
 
@@ -104,12 +107,13 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
104
107
  - Code blocks
105
108
  - Tables
106
109
  - Hints
107
- - Tabs
110
+ - Tabs (including `{% tabs sync="key" %}` sync groups)
108
111
  - Expandables
109
112
  - Steppers
110
113
  - Embeds
111
114
  - Content references
112
115
  - Component and Storybook source references
116
+ - File-backed live demos (`{% demo %}`)
113
117
  - Columns
114
118
  - Figures and images
115
119
  - OpenAPI operation placeholders
@@ -201,6 +205,25 @@ provenance, and refreshes the live component/story preview. Configure the mount
201
205
  with `docstreamSources()` from `@brett_lamy/docstream/vite` as shown in the
202
206
  Docstream README.
203
207
 
208
+ ## Demo blocks
209
+
210
+ `{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
211
+ attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
212
+ `viewport` — is kept on the node, so the tag serializes back exactly as written.
213
+ 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.
217
+
218
+ ```tsx
219
+ import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
220
+ import { GitbookEditor } from "@brett_lamy/docstream-editor"
221
+
222
+ const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
223
+
224
+ <GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
225
+ ```
226
+
204
227
  ## Slash Menu
205
228
 
206
229
  The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "./styles.css": "./src/styles.css"
34
34
  },
35
35
  "dependencies": {
36
- "@brett_lamy/docstream": "1.0.0",
36
+ "@brett_lamy/docstream": "1.1.1",
37
37
  "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
@@ -11,6 +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
15
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
15
16
  import {
16
17
  attachmentName,
@@ -86,6 +87,8 @@ export interface GitbookEditorProps {
86
87
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
87
88
  /** Called when reading, writing, or previewing a file-backed source fails. */
88
89
  onSourceError?: (error: Error) => void
90
+ /** Resolves `{% demo src %}` ids; when set, demo blocks show a live (read-only) preview under their header. */
91
+ demoResolver?: DemoResolver
89
92
  }
90
93
 
91
94
  function ToolbarButton({
@@ -173,6 +176,7 @@ export function GitbookEditor({
173
176
  sourceAutoSave = true,
174
177
  onSourceSaved,
175
178
  onSourceError,
179
+ demoResolver,
176
180
  }: GitbookEditorProps) {
177
181
  // Tracks the markdown the editor itself produced, so external updates
178
182
  // (file switches) reset content but our own onChange echoes don't.
@@ -295,7 +299,8 @@ export function GitbookEditor({
295
299
  }, [editor, onEditorReady])
296
300
 
297
301
  useEffect(() => {
298
- if (!editor || !controlled) return
302
+ // StrictMode (and <Activity>) re-run effects against an editor useEditor has already destroyed.
303
+ if (!editor || editor.isDestroyed || !controlled) return
299
304
  if (markdown === lastEmitted.current) return
300
305
  lastEmitted.current = markdown as string
301
306
  const doc = parseMarkdown(markdown as string)
@@ -311,9 +316,10 @@ export function GitbookEditor({
311
316
  sourceAutoSave,
312
317
  ...(onSourceSaved ? { onSourceSaved } : {}),
313
318
  ...(onSourceError ? { onSourceError } : {}),
319
+ ...(demoResolver ? { demoResolver } : {}),
314
320
  ...(attachments ? { attachments } : {}),
315
321
  ...(onAttachmentOpen ? { onAttachmentOpen } : {}),
316
- }), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
322
+ }), [attachments, demoResolver, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
317
323
 
318
324
  if (!editor) return null
319
325
 
@@ -2,6 +2,9 @@ import {
2
2
  plainText,
3
3
  serializeMarkdown,
4
4
  type Block,
5
+ type DemoLayout,
6
+ type DemoNode,
7
+ type DemoViewport,
5
8
  type DocumentNode,
6
9
  type Inline,
7
10
  type ListItemNode,
@@ -101,6 +104,7 @@ function blockToPM(b: Block): PMNode {
101
104
  case "tabs":
102
105
  return {
103
106
  type: "gbTabs",
107
+ ...(b.sync ? { attrs: { sync: b.sync } } : {}),
104
108
  content: b.tabs.map((t) => ({
105
109
  type: "gbTab",
106
110
  attrs: { title: t.title },
@@ -145,6 +149,19 @@ function blockToPM(b: Block): PMNode {
145
149
  title: b.title ?? "",
146
150
  },
147
151
  }
152
+ case "demo":
153
+ return {
154
+ type: "gbDemo",
155
+ attrs: {
156
+ src: b.src,
157
+ title: b.title ?? "",
158
+ description: b.description ?? "",
159
+ height: b.height ?? "",
160
+ layout: b.layout ?? null,
161
+ variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
162
+ viewport: b.viewport ?? null,
163
+ },
164
+ }
148
165
  case "columns":
149
166
  return {
150
167
  type: "gbColumns",
@@ -310,6 +327,7 @@ function pmToBlock(n: PMNode): Block | null {
310
327
  title: String(t.attrs?.title ?? "Tab"),
311
328
  children: pmToBlocks(t.content),
312
329
  })),
330
+ ...(n.attrs?.sync ? { sync: String(n.attrs.sync) } : {}),
313
331
  }
314
332
  case "gbExpandable":
315
333
  return {
@@ -354,6 +372,8 @@ function pmToBlock(n: PMNode): Block | null {
354
372
  kind: n.attrs?.kind === "story" ? "story" : "component",
355
373
  ...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
356
374
  }
375
+ case "gbDemo":
376
+ return pmToDemo(n.attrs ?? {})
357
377
  case "gbColumns":
358
378
  return {
359
379
  type: "columns",
@@ -423,6 +443,30 @@ function pmToBlock(n: PMNode): Block | null {
423
443
  }
424
444
  }
425
445
 
446
+ const DEMO_LAYOUTS: readonly string[] = ["auto", "single", "multi"] satisfies DemoLayout[]
447
+ const DEMO_VIEWPORTS: readonly string[] = ["desktop", "tablet", "phone"] satisfies DemoViewport[]
448
+
449
+ /** `gbDemo` attrs → `DemoNode`; also used by the node view to build the preview. */
450
+ export function pmToDemo(a: Record<string, unknown>): DemoNode {
451
+ const variants = Array.isArray(a.variants)
452
+ ? (a.variants as Array<{ id?: unknown; label?: unknown }>)
453
+ .filter((v) => v && v.id)
454
+ .map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
455
+ : []
456
+ const layout = String(a.layout ?? "")
457
+ const viewport = String(a.viewport ?? "")
458
+ return {
459
+ type: "demo",
460
+ src: String(a.src ?? ""),
461
+ ...(a.title ? { title: String(a.title) } : {}),
462
+ ...(a.description ? { description: String(a.description) } : {}),
463
+ ...(a.height ? { height: String(a.height) } : {}),
464
+ ...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
465
+ ...(variants.length ? { variants } : {}),
466
+ ...(DEMO_VIEWPORTS.includes(viewport) ? { viewport: viewport as DemoViewport } : {}),
467
+ }
468
+ }
469
+
426
470
  function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
427
471
  return (nodes ?? []).map(pmToBlock).filter((b): b is Block => b !== null)
428
472
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "@tiptap/react"
11
11
  import {
12
12
  AlertTriangle,
13
+ AppWindow,
13
14
  AtSign,
14
15
  CheckCircle2,
15
16
  ChevronDown,
@@ -30,6 +31,8 @@ import type { DocumentNode, HintStyle, SourceRefNode } from "@brett_lamy/docstre
30
31
  import { ReactCodePreview } from "@brett_lamy/docstream/playground"
31
32
  import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
32
33
  import { VideoEmbed } from "@brett_lamy/docstream/video"
34
+ import { DemoViewer } from "@brett_lamy/docstream/demo"
35
+ import { pmToDemo } from "./convert"
33
36
  import { SourceFileEditor } from "./SourceFileEditor"
34
37
  import { useEditorRuntime } from "./runtime"
35
38
  import { GbAttachment } from "./attachments"
@@ -176,7 +179,15 @@ export const GbTabs = Node.create({
176
179
  defining: true,
177
180
  isolating: true,
178
181
  addAttributes() {
179
- return { active: { default: 0, rendered: false } }
182
+ return {
183
+ active: { default: 0, rendered: false },
184
+ // `{% tabs sync="key" %}` — tab sets sharing a key follow the reader's last choice.
185
+ sync: {
186
+ default: null,
187
+ parseHTML: (element) => element.getAttribute("data-sync") || null,
188
+ renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
189
+ },
190
+ }
180
191
  },
181
192
  parseHTML() {
182
193
  return [{ tag: "div[data-gb-tabs]" }]
@@ -515,6 +526,76 @@ export const GbSourceRef = Node.create({
515
526
  },
516
527
  })
517
528
 
529
+ // ---------- Demo ----------
530
+
531
+ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
532
+ const editable = editor.isEditable
533
+ const { demoResolver } = useEditorRuntime()
534
+ const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
535
+ // Typing into `src` would otherwise resolve every intermediate id.
536
+ const src = useDebouncedValue(demo.src)
537
+ return (
538
+ <NodeViewWrapper className={demoResolver && src ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
539
+ <div className="gb-demo">
540
+ <AppWindow className="size-4 shrink-0" />
541
+ {editable ? (
542
+ <>
543
+ <input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
544
+ <input className="gb-inline-input gb-demo-title" value={node.attrs.title} placeholder="Title" onChange={(event) => updateAttributes({ title: event.target.value })} />
545
+ </>
546
+ ) : (
547
+ <>
548
+ <code className="gb-demo-src">{demo.src || "demo"}</code>
549
+ {demo.title ? <span className="gb-demo-title">{demo.title}</span> : null}
550
+ </>
551
+ )}
552
+ </div>
553
+ {demoResolver && src ? (
554
+ <div className="gb-demo-preview">
555
+ <DemoViewer
556
+ key={src}
557
+ resolver={demoResolver}
558
+ src={src}
559
+ title={demo.title}
560
+ description={demo.description}
561
+ height={demo.height}
562
+ layout={demo.layout}
563
+ variants={demo.variants}
564
+ viewport={demo.viewport}
565
+ />
566
+ </div>
567
+ ) : null}
568
+ </NodeViewWrapper>
569
+ )
570
+ }
571
+
572
+ /** `{% demo %}` — every tag attribute is kept on the node so it round-trips losslessly. */
573
+ export const GbDemo = Node.create({
574
+ name: "gbDemo",
575
+ group: "block",
576
+ atom: true,
577
+ addAttributes() {
578
+ return {
579
+ src: { default: "" },
580
+ title: { default: "" },
581
+ description: { default: "" },
582
+ height: { default: "" },
583
+ layout: { default: null },
584
+ variants: { default: null, rendered: false },
585
+ viewport: { default: null },
586
+ }
587
+ },
588
+ parseHTML() {
589
+ return [{ tag: "div[data-gb-demo]" }]
590
+ },
591
+ renderHTML({ HTMLAttributes, node }) {
592
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-demo": node.attrs.src })]
593
+ },
594
+ addNodeView() {
595
+ return ReactNodeViewRenderer(DemoView)
596
+ },
597
+ })
598
+
518
599
  // ---------- Columns ----------
519
600
 
520
601
  export const GbColumns = Node.create({
@@ -975,6 +1056,7 @@ export const gitbookNodes = [
975
1056
  GbEmbed,
976
1057
  GbContentRef,
977
1058
  GbSourceRef,
1059
+ GbDemo,
978
1060
  GbColumns,
979
1061
  GbColumn,
980
1062
  GbFigure,
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from "react"
2
2
 
3
+ import type { DemoResolver } from "@brett_lamy/docstream/demo"
3
4
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
4
5
  import type { EditorAttachment } from "./attachments"
5
6
 
@@ -9,6 +10,7 @@ export interface EditorRuntimeOptions {
9
10
  sourceAutoSave?: boolean | number
10
11
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
11
12
  onSourceError?: (error: Error) => void
13
+ demoResolver?: DemoResolver
12
14
  attachments?: EditorAttachment[]
13
15
  onAttachmentOpen?: (attachment: EditorAttachment) => void
14
16
  }
@@ -1,6 +1,7 @@
1
1
  import { Extension, type Editor, type Range } from "@tiptap/core"
2
2
  import Suggestion from "@tiptap/suggestion"
3
3
  import {
4
+ AppWindow,
4
5
  Columns2,
5
6
  FileCode2,
6
7
  Heading1,
@@ -101,6 +102,12 @@ export const SLASH_ITEMS: SlashItem[] = [
101
102
  icon: Link2,
102
103
  run: insertBlock({ type: "gbContentRef", attrs: { url: "", label: "Page link" } }),
103
104
  },
105
+ {
106
+ title: "Demo",
107
+ keywords: "example live preview component playground",
108
+ icon: AppWindow,
109
+ run: insertBlock({ type: "gbDemo", attrs: { src: "", title: "" } }),
110
+ },
104
111
  {
105
112
  title: "Columns",
106
113
  keywords: "two column layout",
package/src/styles.css CHANGED
@@ -88,6 +88,43 @@
88
88
  width: 8rem;
89
89
  }
90
90
 
91
+ .gb-demo {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ border: 1px solid var(--gb-border);
96
+ border-radius: var(--gb-radius);
97
+ padding: 0.65rem 0.8rem;
98
+ background: var(--gb-muted);
99
+ }
100
+
101
+ .gb-demo-block {
102
+ margin: 0.85em 0;
103
+ }
104
+
105
+ .gb-demo-block-preview > .gb-demo {
106
+ border-radius: var(--gb-radius) var(--gb-radius) 0 0;
107
+ }
108
+
109
+ .gb-demo-src {
110
+ flex: 1;
111
+ }
112
+
113
+ .gb-demo-title {
114
+ width: 12rem;
115
+ }
116
+
117
+ .gb-demo-preview {
118
+ border: 1px solid var(--gb-border);
119
+ border-top: 0;
120
+ border-radius: 0 0 var(--gb-radius) var(--gb-radius);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .gb-demo-preview > * {
125
+ margin: 0;
126
+ }
127
+
91
128
  .gb-source-file-editor {
92
129
  border: 1px solid var(--gb-border);
93
130
  border-radius: var(--gb-radius);