@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 +30 -0
- package/package.json +12 -3
- package/src/editor/ReactDemoEditor.tsx +1 -1
- package/src/editor/SourceFileEditor.tsx +83 -0
- package/src/editor/convert.ts +20 -0
- package/src/editor/nodes.tsx +52 -1
- package/src/index.ts +2 -0
- package/src/styles.css +59 -0
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
|
+
"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.
|
|
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
|
+
}
|
package/src/editor/convert.ts
CHANGED
|
@@ -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",
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -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,
|
|
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; }
|