@brett_lamy/docstream-editor 0.3.3 → 0.3.4

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,7 @@ TipTap editor components for Docstream GitBook-style markdown documents.
13
13
  - Code block support through `lowlight`.
14
14
  - Table, task list, heading, quote, and inline formatting support.
15
15
  - Preserves Docstream/GitBook block semantics when converting back to markdown.
16
+ - Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
16
17
 
17
18
  ## Installation
18
19
 
@@ -97,12 +98,39 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
97
98
  - Steppers
98
99
  - Embeds
99
100
  - Content references
101
+ - Component and Storybook source references
100
102
  - Columns
101
103
  - Figures and images
102
104
  - OpenAPI operation placeholders
103
105
 
104
106
  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.
105
107
 
108
+ ## Edit a referenced source file
109
+
110
+ `GitbookEditor` edits the Markdown composition, including a structured
111
+ `source-ref` node. `SourceFileEditor` edits the real file behind that node:
112
+
113
+ ```tsx
114
+ import { createViteSourceClient, type SourceRefNode } from "@brett_lamy/docstream"
115
+ import { SourceFileEditor } from "@brett_lamy/docstream-editor"
116
+
117
+ const client = createViteSourceClient()
118
+ const reference: SourceRefNode = {
119
+ type: "source-ref",
120
+ mount: "ui",
121
+ path: "Button.stories.tsx",
122
+ exportName: "Primary",
123
+ kind: "story",
124
+ }
125
+
126
+ <SourceFileEditor reference={reference} client={client} />
127
+ ```
128
+
129
+ Saving calls the Docstream Vite plugin, writes the mounted file, returns its
130
+ provenance, and refreshes the live component/story preview. Configure the mount
131
+ with `docstreamSources()` from `@brett_lamy/docstream/vite` as shown in the
132
+ Docstream README.
133
+
106
134
  ## Slash Menu
107
135
 
108
136
  The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
@@ -127,6 +155,8 @@ Exports:
127
155
  - `astToTiptap`
128
156
  - `tiptapToAst`
129
157
  - `PMNode`
158
+ - `SourceFileEditor`
159
+ - `SourceFileEditorProps`
130
160
 
131
161
  ## Styling and Theming
132
162
 
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "typecheck": "tsc -p tsconfig.json --noEmit",
8
+ "test": "vitest run"
9
+ },
6
10
  "main": "./src/index.ts",
7
11
  "types": "./src/index.ts",
8
12
  "files": [
@@ -20,7 +24,7 @@
20
24
  "./styles.css": "./src/styles.css"
21
25
  },
22
26
  "dependencies": {
23
- "@brett_lamy/docstream": "0.3.2",
27
+ "@brett_lamy/docstream": "0.3.7",
24
28
  "lowlight": "^3.3.0",
25
29
  "lucide-react": "^1.17.0"
26
30
  },
@@ -41,6 +45,7 @@
41
45
  "url": "git+https://github.com/BLamy/docstream-editor.git"
42
46
  },
43
47
  "devDependencies": {
48
+ "@agent-wasm/core": "^0.4.0",
44
49
  "@tiptap/core": "^3.26.1",
45
50
  "@tiptap/extension-code-block-lowlight": "^3.26.1",
46
51
  "@tiptap/extension-list": "^3.26.1",
@@ -50,7 +55,11 @@
50
55
  "@tiptap/react": "^3.26.1",
51
56
  "@tiptap/starter-kit": "^3.26.1",
52
57
  "@tiptap/suggestion": "^3.26.1",
58
+ "@types/react": "^19.0.0",
59
+ "@types/react-dom": "^19.0.0",
53
60
  "react": "^19",
54
- "react-dom": "^19"
61
+ "react-dom": "^19",
62
+ "typescript": "^5.6.0",
63
+ "vitest": "^3.2.4"
55
64
  }
56
65
  }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from "react"
2
2
  import { FileCode2, Play } from "lucide-react"
3
3
 
4
- import { ReactCodePreview } from "@brett_lamy/docstream"
4
+ import { ReactCodePreview } from "@brett_lamy/docstream/playground"
5
5
 
6
6
  export interface ReactDemoEditorProps {
7
7
  /** Source files keyed by their project-relative or absolute paths. */
@@ -0,0 +1,83 @@
1
+ import { useEffect, useState } from "react"
2
+ import { FileCode2, Save } from "lucide-react"
3
+ import { SourcePreview, type SourceFileSnapshot, type SourceReferenceClient } from "@brett_lamy/docstream/source"
4
+ import type { SourceRefNode } from "@brett_lamy/docstream/gitbook"
5
+
6
+ export interface SourceFileEditorProps {
7
+ reference: SourceRefNode
8
+ client: SourceReferenceClient
9
+ className?: string
10
+ preview?: boolean
11
+ onSaved?: (snapshot: SourceFileSnapshot) => void
12
+ onError?: (error: Error) => void
13
+ }
14
+
15
+ function asError(cause: unknown): Error {
16
+ return cause instanceof Error ? cause : new Error(String(cause))
17
+ }
18
+
19
+ /** Edit the real file behind a Markdown source reference and preview its export. */
20
+ export function SourceFileEditor({ reference, client, className, preview = true, onSaved, onError }: SourceFileEditorProps) {
21
+ const [content, setContent] = useState("")
22
+ const [savedContent, setSavedContent] = useState("")
23
+ const [revision, setRevision] = useState(0)
24
+ const [status, setStatus] = useState<"loading" | "ready" | "saving" | "error">("loading")
25
+ const [error, setError] = useState<string | null>(null)
26
+
27
+ useEffect(() => {
28
+ let cancelled = false
29
+ setStatus("loading")
30
+ setError(null)
31
+ void client.read(reference).then(
32
+ (snapshot) => {
33
+ if (cancelled) return
34
+ setContent(snapshot.content)
35
+ setSavedContent(snapshot.content)
36
+ setStatus("ready")
37
+ },
38
+ (cause: unknown) => {
39
+ if (cancelled) return
40
+ const next = asError(cause)
41
+ setError(next.message)
42
+ setStatus("error")
43
+ onError?.(next)
44
+ },
45
+ )
46
+ return () => {
47
+ cancelled = true
48
+ }
49
+ }, [client, onError, reference])
50
+
51
+ const save = async () => {
52
+ setStatus("saving")
53
+ setError(null)
54
+ try {
55
+ const snapshot = await client.write(reference, content)
56
+ setSavedContent(snapshot.content)
57
+ setStatus("ready")
58
+ setRevision((value) => value + 1)
59
+ onSaved?.(snapshot)
60
+ } catch (cause) {
61
+ const next = asError(cause)
62
+ setError(next.message)
63
+ setStatus("error")
64
+ onError?.(next)
65
+ }
66
+ }
67
+
68
+ const wrapper = className ? `gb-source-file-editor ${className}` : "gb-source-file-editor"
69
+ return (
70
+ <section className={wrapper} data-docstream-source-file-editor="">
71
+ <header className="gb-source-file-editor-header">
72
+ <FileCode2 className="size-4" />
73
+ <code>{reference.mount}:{reference.path}#{reference.exportName}</code>
74
+ <button type="button" disabled={status === "loading" || status === "saving" || content === savedContent} onClick={() => void save()}>
75
+ <Save className="size-3.5" /> {status === "saving" ? "Saving…" : "Save file"}
76
+ </button>
77
+ </header>
78
+ {error ? <pre className="gb-source-file-editor-error">{error}</pre> : null}
79
+ <textarea value={content} onChange={(event) => setContent(event.target.value)} disabled={status === "loading"} spellCheck={false} aria-label={`Edit ${reference.path}`} />
80
+ {preview ? <SourcePreview key={revision} reference={reference} client={client} /> : null}
81
+ </section>
82
+ )
83
+ }
@@ -110,6 +110,17 @@ function blockToPM(b: Block): PMNode {
110
110
  }
111
111
  case "content-ref":
112
112
  return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children) } }
113
+ case "source-ref":
114
+ return {
115
+ type: "gbSourceRef",
116
+ attrs: {
117
+ mount: b.mount,
118
+ path: b.path,
119
+ exportName: b.exportName,
120
+ kind: b.kind,
121
+ title: b.title ?? "",
122
+ },
123
+ }
113
124
  case "columns":
114
125
  return {
115
126
  type: "gbColumns",
@@ -282,6 +293,15 @@ function pmToBlock(n: PMNode): Block | null {
282
293
  url: String(n.attrs?.url ?? ""),
283
294
  children: [{ type: "text", text: String(n.attrs?.label ?? "") }],
284
295
  }
296
+ case "gbSourceRef":
297
+ return {
298
+ type: "source-ref",
299
+ mount: String(n.attrs?.mount ?? "source"),
300
+ path: String(n.attrs?.path ?? ""),
301
+ exportName: String(n.attrs?.exportName ?? "default"),
302
+ kind: n.attrs?.kind === "story" ? "story" : "component",
303
+ ...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
304
+ }
285
305
  case "gbColumns":
286
306
  return {
287
307
  type: "columns",
@@ -13,6 +13,7 @@ import {
13
13
  ChevronDown,
14
14
  Info,
15
15
  Link2,
16
+ FileCode2,
16
17
  Plus,
17
18
  X,
18
19
  XCircle,
@@ -21,7 +22,8 @@ import {
21
22
  import { resolveAsset } from "@brett_lamy/docstream/assets"
22
23
  import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
23
24
  import type { HintStyle } from "@brett_lamy/docstream/gitbook"
24
- import { ReplayPreview, VideoEmbed, isReplayQaUrl } from "@brett_lamy/docstream"
25
+ import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
26
+ import { VideoEmbed } from "@brett_lamy/docstream/video"
25
27
 
26
28
  // ---------- Hint ----------
27
29
 
@@ -425,6 +427,54 @@ export const GbContentRef = Node.create({
425
427
  },
426
428
  })
427
429
 
430
+ // ---------- Source ref ----------
431
+
432
+ function SourceRefView({ node, updateAttributes, editor }: NodeViewProps) {
433
+ const editable = editor.isEditable
434
+ return (
435
+ <NodeViewWrapper className="gb-source-ref" contentEditable={false}>
436
+ <FileCode2 className="size-4 shrink-0" />
437
+ {editable ? (
438
+ <>
439
+ <input className="gb-inline-input gb-source-ref-mount" value={node.attrs.mount} placeholder="mount" onChange={(event) => updateAttributes({ mount: event.target.value })} />
440
+ <input className="gb-inline-input gb-source-ref-path" value={node.attrs.path} placeholder="src/Button.tsx" onChange={(event) => updateAttributes({ path: event.target.value })} />
441
+ <input className="gb-inline-input gb-source-ref-export" value={node.attrs.exportName} placeholder="Button" onChange={(event) => updateAttributes({ exportName: event.target.value })} />
442
+ <select className="gb-source-ref-kind" value={node.attrs.kind} onChange={(event) => updateAttributes({ kind: event.target.value })}>
443
+ <option value="component">component</option>
444
+ <option value="story">story</option>
445
+ </select>
446
+ </>
447
+ ) : (
448
+ <span>{node.attrs.title || `${node.attrs.path}#${node.attrs.exportName}`}</span>
449
+ )}
450
+ </NodeViewWrapper>
451
+ )
452
+ }
453
+
454
+ export const GbSourceRef = Node.create({
455
+ name: "gbSourceRef",
456
+ group: "block",
457
+ atom: true,
458
+ addAttributes() {
459
+ return {
460
+ mount: { default: "source" },
461
+ path: { default: "" },
462
+ exportName: { default: "default" },
463
+ kind: { default: "component" },
464
+ title: { default: "" },
465
+ }
466
+ },
467
+ parseHTML() {
468
+ return [{ tag: "div[data-gb-source-ref]" }]
469
+ },
470
+ renderHTML({ HTMLAttributes, node }) {
471
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-source-ref": `${node.attrs.mount}:${node.attrs.path}` })]
472
+ },
473
+ addNodeView() {
474
+ return ReactNodeViewRenderer(SourceRefView)
475
+ },
476
+ })
477
+
428
478
  // ---------- Columns ----------
429
479
 
430
480
  export const GbColumns = Node.create({
@@ -790,6 +840,7 @@ export const gitbookNodes = [
790
840
  GbStep,
791
841
  GbEmbed,
792
842
  GbContentRef,
843
+ GbSourceRef,
793
844
  GbColumns,
794
845
  GbColumn,
795
846
  GbFigure,
package/src/index.ts CHANGED
@@ -12,3 +12,5 @@ export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
12
12
  export type { SlashItem } from "./editor/slash-menu"
13
13
  export { ReactDemoEditor } from "./editor/ReactDemoEditor"
14
14
  export type { ReactDemoEditorProps } from "./editor/ReactDemoEditor"
15
+ export { SourceFileEditor } from "./editor/SourceFileEditor"
16
+ export type { SourceFileEditorProps } from "./editor/SourceFileEditor"
package/src/styles.css CHANGED
@@ -48,6 +48,65 @@
48
48
  color: var(--gb-text);
49
49
  }
50
50
 
51
+ .gb-source-ref,
52
+ .gb-source-file-editor-header {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 0.5rem;
56
+ }
57
+
58
+ .gb-source-ref {
59
+ border: 1px solid var(--gb-border);
60
+ border-radius: var(--gb-radius);
61
+ padding: 0.65rem 0.8rem;
62
+ background: var(--gb-muted);
63
+ }
64
+
65
+ .gb-source-ref-path {
66
+ flex: 1;
67
+ }
68
+
69
+ .gb-source-ref-mount,
70
+ .gb-source-ref-export {
71
+ width: 8rem;
72
+ }
73
+
74
+ .gb-source-file-editor {
75
+ border: 1px solid var(--gb-border);
76
+ border-radius: var(--gb-radius);
77
+ overflow: hidden;
78
+ }
79
+
80
+ .gb-source-file-editor-header {
81
+ padding: 0.55rem 0.7rem;
82
+ background: var(--gb-muted);
83
+ }
84
+
85
+ .gb-source-file-editor-header button {
86
+ margin-left: auto;
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 0.35rem;
90
+ }
91
+
92
+ .gb-source-file-editor > textarea {
93
+ box-sizing: border-box;
94
+ width: 100%;
95
+ min-height: 16rem;
96
+ border: 0;
97
+ border-top: 1px solid var(--gb-border);
98
+ border-bottom: 1px solid var(--gb-border);
99
+ padding: 0.8rem;
100
+ resize: vertical;
101
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
102
+ }
103
+
104
+ .gb-source-file-editor-error {
105
+ color: var(--gb-danger-foreground, #b42318);
106
+ padding: 0.6rem 0.8rem;
107
+ white-space: pre-wrap;
108
+ }
109
+
51
110
  /* lucide icon sizing (the node views use size-* utility classes) */
52
111
  .gb .size-3 { width: 12px; height: 12px; }
53
112
  .gb .size-3\.5 { width: 14px; height: 14px; }