@brett_lamy/docstream-editor 1.0.1 → 1.2.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 +62 -2
- package/package.json +3 -2
- package/src/editor/Editor.tsx +58 -8
- package/src/editor/convert.ts +146 -10
- package/src/editor/extensions.ts +25 -1
- package/src/editor/nodes.tsx +400 -7
- package/src/editor/runtime.tsx +4 -0
- package/src/editor/slash-menu.tsx +14 -0
- package/src/index.ts +3 -3
- package/src/styles.css +198 -0
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
|
|
@@ -80,6 +83,10 @@ export interface GitbookEditorProps {
|
|
|
80
83
|
onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
|
|
81
84
|
onAttachmentRemove?: (ids: string[]) => void
|
|
82
85
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
86
|
+
demoResolver?: DemoResolver
|
|
87
|
+
demoRuntime?: InlineDemoRuntime
|
|
88
|
+
demoDependencies?: Record<string, string>
|
|
89
|
+
markdownPaste?: boolean
|
|
83
90
|
}
|
|
84
91
|
```
|
|
85
92
|
|
|
@@ -89,6 +96,14 @@ export interface GitbookEditorProps {
|
|
|
89
96
|
return `true` when the application handled the event.
|
|
90
97
|
- `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
|
|
91
98
|
how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
|
|
99
|
+
- `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
|
|
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).
|
|
92
107
|
|
|
93
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.
|
|
94
109
|
|
|
@@ -98,18 +113,23 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
|
|
|
98
113
|
|
|
99
114
|
- Headings
|
|
100
115
|
- Paragraphs
|
|
101
|
-
- 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)`)
|
|
102
118
|
- Bullet, ordered, and task lists
|
|
103
119
|
- Blockquotes
|
|
104
120
|
- Code blocks
|
|
105
121
|
- Tables
|
|
106
122
|
- Hints
|
|
107
|
-
- Tabs
|
|
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`)
|
|
108
127
|
- Expandables
|
|
109
128
|
- Steppers
|
|
110
129
|
- Embeds
|
|
111
130
|
- Content references
|
|
112
131
|
- Component and Storybook source references
|
|
132
|
+
- Live demos (`{% demo %}`), resolver-backed or carrying their files inline
|
|
113
133
|
- Columns
|
|
114
134
|
- Figures and images
|
|
115
135
|
- OpenAPI operation placeholders
|
|
@@ -201,6 +221,45 @@ provenance, and refreshes the live component/story preview. Configure the mount
|
|
|
201
221
|
with `docstreamSources()` from `@brett_lamy/docstream/vite` as shown in the
|
|
202
222
|
Docstream README.
|
|
203
223
|
|
|
224
|
+
## Demo blocks
|
|
225
|
+
|
|
226
|
+
`{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
|
|
227
|
+
attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
|
|
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
|
+
|
|
233
|
+
The node shows a compact header with editable `src` and `title` inputs (insert
|
|
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.
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
|
|
247
|
+
import { GitbookEditor } from "@brett_lamy/docstream-editor"
|
|
248
|
+
|
|
249
|
+
const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
|
|
250
|
+
|
|
251
|
+
<GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
|
|
252
|
+
```
|
|
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
|
+
|
|
204
263
|
## Slash Menu
|
|
205
264
|
|
|
206
265
|
The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
|
|
@@ -227,6 +286,7 @@ Exports:
|
|
|
227
286
|
- `PMNode`
|
|
228
287
|
- `EditorAttachment`
|
|
229
288
|
- `serializeEditorMarkdown`
|
|
289
|
+
- `markdownPasteContent` (the Markdown-paste parser behind `markdownPaste`)
|
|
230
290
|
- `SourceFileEditor`
|
|
231
291
|
- `SourceFileEditorProps`
|
|
232
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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.
|
|
37
|
+
"@brett_lamy/docstream": "1.2.1",
|
|
37
38
|
"gpu-lexer": "0.0.2",
|
|
38
39
|
"lucide-react": "^1.17.0"
|
|
39
40
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -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, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
|
|
14
15
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
15
16
|
import {
|
|
16
17
|
attachmentName,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
readFileAsDataURL,
|
|
21
22
|
type EditorAttachment,
|
|
22
23
|
} from "./attachments"
|
|
23
|
-
import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
|
|
24
|
+
import { astToTiptap, markdownPasteContent, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
|
|
24
25
|
import { createGitbookExtensions, type ReferenceSources } from "./extensions"
|
|
25
26
|
import { EditorRuntimeProvider } from "./runtime"
|
|
26
27
|
import type { SlashItem } from "./slash-menu"
|
|
@@ -86,6 +87,25 @@ 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
|
+
/**
|
|
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
|
+
*/
|
|
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
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
function ToolbarButton({
|
|
@@ -173,6 +193,10 @@ export function GitbookEditor({
|
|
|
173
193
|
sourceAutoSave = true,
|
|
174
194
|
onSourceSaved,
|
|
175
195
|
onSourceError,
|
|
196
|
+
demoResolver,
|
|
197
|
+
demoRuntime,
|
|
198
|
+
demoDependencies,
|
|
199
|
+
markdownPaste = true,
|
|
176
200
|
}: GitbookEditorProps) {
|
|
177
201
|
// Tracks the markdown the editor itself produced, so external updates
|
|
178
202
|
// (file switches) reset content but our own onChange echoes don't.
|
|
@@ -184,8 +208,8 @@ export function GitbookEditor({
|
|
|
184
208
|
// Ids of the attachment chips in the document, to report removals.
|
|
185
209
|
const attachmentIds = useRef<Set<string>>(new Set())
|
|
186
210
|
// Latest callbacks for handlers the editor captured at creation.
|
|
187
|
-
const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
|
|
188
|
-
latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
|
|
211
|
+
const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste })
|
|
212
|
+
latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste }
|
|
189
213
|
const editorRef = useRef<TiptapEditor | null>(null)
|
|
190
214
|
|
|
191
215
|
// Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
|
|
@@ -232,6 +256,27 @@ export function GitbookEditor({
|
|
|
232
256
|
})
|
|
233
257
|
}
|
|
234
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
|
+
|
|
235
280
|
const parseAndTrack = (md: string) => {
|
|
236
281
|
const doc = parseMarkdown(md)
|
|
237
282
|
citationsRef.current = doc.citations
|
|
@@ -254,10 +299,12 @@ export function GitbookEditor({
|
|
|
254
299
|
if (latest.current.onPaste?.(event)) return true
|
|
255
300
|
if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
|
|
256
301
|
const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
302
|
+
if (files.length) {
|
|
303
|
+
event.preventDefault()
|
|
304
|
+
insertImageFiles(files)
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
return latest.current.markdownPaste ? pasteMarkdown(event) : false
|
|
261
308
|
},
|
|
262
309
|
handleDrop: (view, event, _slice, moved) => {
|
|
263
310
|
if (moved || !view.editable) return false
|
|
@@ -312,9 +359,12 @@ export function GitbookEditor({
|
|
|
312
359
|
sourceAutoSave,
|
|
313
360
|
...(onSourceSaved ? { onSourceSaved } : {}),
|
|
314
361
|
...(onSourceError ? { onSourceError } : {}),
|
|
362
|
+
...(demoResolver ? { demoResolver } : {}),
|
|
363
|
+
...(demoRuntime ? { demoRuntime } : {}),
|
|
364
|
+
...(demoDependencies ? { demoDependencies } : {}),
|
|
315
365
|
...(attachments ? { attachments } : {}),
|
|
316
366
|
...(onAttachmentOpen ? { onAttachmentOpen } : {}),
|
|
317
|
-
}), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
367
|
+
}), [attachments, demoDependencies, demoResolver, demoRuntime, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
318
368
|
|
|
319
369
|
if (!editor) return null
|
|
320
370
|
|
package/src/editor/convert.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
parseMarkdown,
|
|
2
3
|
plainText,
|
|
3
4
|
serializeMarkdown,
|
|
4
5
|
type Block,
|
|
6
|
+
type CitationDef,
|
|
7
|
+
type DemoLayout,
|
|
8
|
+
type CommandNode,
|
|
9
|
+
type DemoInlineFile,
|
|
10
|
+
type DemoNode,
|
|
11
|
+
type DemoViewport,
|
|
5
12
|
type DocumentNode,
|
|
6
13
|
type Inline,
|
|
7
14
|
type ListItemNode,
|
|
@@ -56,7 +63,8 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
|
|
|
56
63
|
if (n.italic) marks.push({ type: "italic" })
|
|
57
64
|
if (n.strike) marks.push({ type: "strike" })
|
|
58
65
|
if (n.code) marks.push({ type: "code" })
|
|
59
|
-
|
|
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 } : {}) } })
|
|
60
68
|
return { type: "text", text: n.text, ...(marks.length ? { marks } : {}) }
|
|
61
69
|
})
|
|
62
70
|
}
|
|
@@ -93,20 +101,38 @@ function blockToPM(b: Block): PMNode {
|
|
|
93
101
|
lineNumbers: b.lineNumbers,
|
|
94
102
|
live: !!b.live,
|
|
95
103
|
entry: b.entry ?? null,
|
|
104
|
+
collapsedCodeLines: b.collapsedCodeLines ?? null,
|
|
105
|
+
expandedCodeLines: b.expandedCodeLines ?? null,
|
|
96
106
|
},
|
|
97
107
|
content: b.code ? [{ type: "text", text: b.code }] : [],
|
|
98
108
|
}
|
|
99
109
|
case "hint":
|
|
100
110
|
return { type: "gbHint", attrs: { style: b.style }, content: blocksPM(b.children) }
|
|
101
|
-
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
|
+
}
|
|
102
117
|
return {
|
|
103
118
|
type: "gbTabs",
|
|
119
|
+
...(Object.keys(attrs).length ? { attrs } : {}),
|
|
104
120
|
content: b.tabs.map((t) => ({
|
|
105
121
|
type: "gbTab",
|
|
106
122
|
attrs: { title: t.title },
|
|
107
123
|
content: blocksPM(t.children),
|
|
108
124
|
})),
|
|
109
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
|
+
}
|
|
110
136
|
case "expandable":
|
|
111
137
|
return {
|
|
112
138
|
type: "gbExpandable",
|
|
@@ -132,8 +158,13 @@ function blockToPM(b: Block): PMNode {
|
|
|
132
158
|
if (b.controls !== undefined) attrs.controls = b.controls
|
|
133
159
|
return { type: "gbEmbed", attrs }
|
|
134
160
|
}
|
|
135
|
-
case "content-ref":
|
|
136
|
-
|
|
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
|
+
}
|
|
137
168
|
case "source-ref":
|
|
138
169
|
return {
|
|
139
170
|
type: "gbSourceRef",
|
|
@@ -145,6 +176,21 @@ function blockToPM(b: Block): PMNode {
|
|
|
145
176
|
title: b.title ?? "",
|
|
146
177
|
},
|
|
147
178
|
}
|
|
179
|
+
case "demo":
|
|
180
|
+
return {
|
|
181
|
+
type: "gbDemo",
|
|
182
|
+
attrs: {
|
|
183
|
+
src: b.src,
|
|
184
|
+
title: b.title ?? "",
|
|
185
|
+
description: b.description ?? "",
|
|
186
|
+
height: b.height ?? "",
|
|
187
|
+
layout: b.layout ?? null,
|
|
188
|
+
variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
|
|
189
|
+
viewport: b.viewport ?? null,
|
|
190
|
+
entry: b.entry ?? null,
|
|
191
|
+
files: b.files?.length ? b.files.map((f) => ({ ...f })) : null,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
148
194
|
case "columns":
|
|
149
195
|
return {
|
|
150
196
|
type: "gbColumns",
|
|
@@ -262,8 +308,12 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
|
|
|
262
308
|
if (mark.type === "italic") inline.italic = true
|
|
263
309
|
if (mark.type === "strike") inline.strike = true
|
|
264
310
|
if (mark.type === "code") inline.code = true
|
|
265
|
-
if (mark.type === "link")
|
|
311
|
+
if (mark.type === "link") {
|
|
312
|
+
inline.link = String(mark.attrs?.href ?? "")
|
|
313
|
+
if (mark.attrs?.inner) inline.linkInner = true
|
|
314
|
+
}
|
|
266
315
|
}
|
|
316
|
+
if (inline.linkInner && !(inline.bold || inline.italic || inline.strike)) delete inline.linkInner
|
|
267
317
|
return inline
|
|
268
318
|
})
|
|
269
319
|
}
|
|
@@ -286,7 +336,9 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
286
336
|
level: (Number(n.attrs?.level) || 1) as 1 | 2 | 3 | 4 | 5 | 6,
|
|
287
337
|
children: pmTextToInline(n.content),
|
|
288
338
|
}
|
|
289
|
-
case "codeBlock":
|
|
339
|
+
case "codeBlock": {
|
|
340
|
+
const collapsed = Number(n.attrs?.collapsedCodeLines) || 0
|
|
341
|
+
const expanded = Number(n.attrs?.expandedCodeLines) || 0
|
|
290
342
|
return {
|
|
291
343
|
type: "code",
|
|
292
344
|
language: (n.attrs?.language as string) || null,
|
|
@@ -294,15 +346,19 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
294
346
|
lineNumbers: !!n.attrs?.lineNumbers,
|
|
295
347
|
live: !!n.attrs?.live,
|
|
296
348
|
entry: (n.attrs?.entry as string) || null,
|
|
349
|
+
...(collapsed ? { collapsedCodeLines: collapsed } : {}),
|
|
350
|
+
...(expanded ? { expandedCodeLines: expanded } : {}),
|
|
297
351
|
code: n.content?.map((c) => c.text ?? "").join("") ?? "",
|
|
298
352
|
}
|
|
353
|
+
}
|
|
299
354
|
case "gbHint":
|
|
300
355
|
return {
|
|
301
356
|
type: "hint",
|
|
302
357
|
style: (n.attrs?.style as never) ?? "info",
|
|
303
358
|
children: pmToBlocks(n.content),
|
|
304
359
|
}
|
|
305
|
-
case "gbTabs":
|
|
360
|
+
case "gbTabs": {
|
|
361
|
+
const level = Number(n.attrs?.level)
|
|
306
362
|
return {
|
|
307
363
|
type: "tabs",
|
|
308
364
|
tabs: (n.content ?? []).map((t) => ({
|
|
@@ -310,7 +366,13 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
310
366
|
title: String(t.attrs?.title ?? "Tab"),
|
|
311
367
|
children: pmToBlocks(t.content),
|
|
312
368
|
})),
|
|
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 } : {}),
|
|
313
372
|
}
|
|
373
|
+
}
|
|
374
|
+
case "gbCommand":
|
|
375
|
+
return pmToCommand(n.attrs ?? {})
|
|
314
376
|
case "gbExpandable":
|
|
315
377
|
return {
|
|
316
378
|
type: "expandable",
|
|
@@ -339,12 +401,16 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
339
401
|
if (typeof n.attrs?.controls === "boolean") embed.controls = n.attrs.controls
|
|
340
402
|
return embed
|
|
341
403
|
}
|
|
342
|
-
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
|
|
343
408
|
return {
|
|
344
409
|
type: "content-ref",
|
|
345
|
-
url
|
|
346
|
-
children: [{ type: "text", text:
|
|
410
|
+
url,
|
|
411
|
+
children: label ? [{ type: "text", text: label, ...(linked ? { link: url } : {}) }] : [],
|
|
347
412
|
}
|
|
413
|
+
}
|
|
348
414
|
case "gbSourceRef":
|
|
349
415
|
return {
|
|
350
416
|
type: "source-ref",
|
|
@@ -354,6 +420,8 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
354
420
|
kind: n.attrs?.kind === "story" ? "story" : "component",
|
|
355
421
|
...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
|
|
356
422
|
}
|
|
423
|
+
case "gbDemo":
|
|
424
|
+
return pmToDemo(n.attrs ?? {})
|
|
357
425
|
case "gbColumns":
|
|
358
426
|
return {
|
|
359
427
|
type: "columns",
|
|
@@ -423,6 +491,60 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
423
491
|
}
|
|
424
492
|
}
|
|
425
493
|
|
|
494
|
+
const DEMO_LAYOUTS: readonly string[] = ["auto", "single", "multi"] satisfies DemoLayout[]
|
|
495
|
+
const DEMO_VIEWPORTS: readonly string[] = ["desktop", "tablet", "phone"] satisfies DemoViewport[]
|
|
496
|
+
|
|
497
|
+
/** `gbDemo` attrs → `DemoNode`; also used by the node view to build the preview. */
|
|
498
|
+
export function pmToDemo(a: Record<string, unknown>): DemoNode {
|
|
499
|
+
const variants = Array.isArray(a.variants)
|
|
500
|
+
? (a.variants as Array<{ id?: unknown; label?: unknown }>)
|
|
501
|
+
.filter((v) => v && v.id)
|
|
502
|
+
.map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
|
|
503
|
+
: []
|
|
504
|
+
const layout = String(a.layout ?? "")
|
|
505
|
+
const files = pmDemoFiles(a.files)
|
|
506
|
+
const viewport = String(a.viewport ?? "")
|
|
507
|
+
return {
|
|
508
|
+
type: "demo",
|
|
509
|
+
src: String(a.src ?? ""),
|
|
510
|
+
...(a.title ? { title: String(a.title) } : {}),
|
|
511
|
+
...(a.description ? { description: String(a.description) } : {}),
|
|
512
|
+
...(a.height ? { height: String(a.height) } : {}),
|
|
513
|
+
...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
|
|
514
|
+
...(variants.length ? { variants } : {}),
|
|
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) } : {}),
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
426
548
|
function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
|
|
427
549
|
return (nodes ?? []).map(pmToBlock).filter((b): b is Block => b !== null)
|
|
428
550
|
}
|
|
@@ -446,3 +568,17 @@ export function serializeEditorMarkdown(doc: DocumentNode): string {
|
|
|
446
568
|
alt.includes("]") ? img : ``
|
|
447
569
|
)
|
|
448
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
|
+
}
|
package/src/editor/extensions.ts
CHANGED
|
@@ -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
|
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -10,11 +10,13 @@ import {
|
|
|
10
10
|
} from "@tiptap/react"
|
|
11
11
|
import {
|
|
12
12
|
AlertTriangle,
|
|
13
|
+
AppWindow,
|
|
13
14
|
AtSign,
|
|
14
15
|
CheckCircle2,
|
|
15
16
|
ChevronDown,
|
|
16
17
|
FolderGit2,
|
|
17
18
|
Hash,
|
|
19
|
+
Heading,
|
|
18
20
|
Info,
|
|
19
21
|
Link2,
|
|
20
22
|
FileCode2,
|
|
@@ -26,10 +28,12 @@ import {
|
|
|
26
28
|
import { DocsRenderer } from "@brett_lamy/docstream"
|
|
27
29
|
import { resolveAsset } from "@brett_lamy/docstream/assets"
|
|
28
30
|
import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
|
|
29
|
-
import type
|
|
31
|
+
import { packageManagerCommands, type DemoInlineFile, type DocumentNode, type HintStyle, type SourceRefNode } from "@brett_lamy/docstream/gitbook"
|
|
30
32
|
import { ReactCodePreview } from "@brett_lamy/docstream/playground"
|
|
31
33
|
import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
|
|
32
34
|
import { VideoEmbed } from "@brett_lamy/docstream/video"
|
|
35
|
+
import { DemoViewer } from "@brett_lamy/docstream/demo"
|
|
36
|
+
import { pmToCommand, pmToDemo } from "./convert"
|
|
33
37
|
import { SourceFileEditor } from "./SourceFileEditor"
|
|
34
38
|
import { useEditorRuntime } from "./runtime"
|
|
35
39
|
import { GbAttachment } from "./attachments"
|
|
@@ -132,9 +136,14 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
|
|
|
132
136
|
updateAttributes({ active: Math.max(0, active - (index <= active ? 1 : 0)) })
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
<>
|
|
138
147
|
{titles.map((t, idx) => (
|
|
139
148
|
<div
|
|
140
149
|
key={idx}
|
|
@@ -158,11 +167,67 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
|
|
|
158
167
|
)}
|
|
159
168
|
</div>
|
|
160
169
|
))}
|
|
161
|
-
{
|
|
170
|
+
{editable && (
|
|
162
171
|
<button className="gb-icon-btn" onClick={addTab} title="Add tab">
|
|
163
172
|
<Plus className="size-3.5" />
|
|
164
173
|
</button>
|
|
165
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}
|
|
166
231
|
</div>
|
|
167
232
|
<NodeViewContent className="gb-tabs-content" data-active={active} />
|
|
168
233
|
</NodeViewWrapper>
|
|
@@ -176,7 +241,28 @@ export const GbTabs = Node.create({
|
|
|
176
241
|
defining: true,
|
|
177
242
|
isolating: true,
|
|
178
243
|
addAttributes() {
|
|
179
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
active: { default: 0, rendered: false },
|
|
246
|
+
// `{% tabs sync="key" %}` — tab sets sharing a key follow the reader's last choice.
|
|
247
|
+
sync: {
|
|
248
|
+
default: null,
|
|
249
|
+
parseHTML: (element) => element.getAttribute("data-sync") || null,
|
|
250
|
+
renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
|
|
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 },
|
|
265
|
+
}
|
|
180
266
|
},
|
|
181
267
|
parseHTML() {
|
|
182
268
|
return [{ tag: "div[data-gb-tabs]" }]
|
|
@@ -432,7 +518,8 @@ export const GbContentRef = Node.create({
|
|
|
432
518
|
group: "block",
|
|
433
519
|
atom: true,
|
|
434
520
|
addAttributes() {
|
|
435
|
-
|
|
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 } }
|
|
436
523
|
},
|
|
437
524
|
parseHTML() {
|
|
438
525
|
return [{ tag: "div[data-gb-content-ref]" }]
|
|
@@ -515,6 +602,308 @@ export const GbSourceRef = Node.create({
|
|
|
515
602
|
},
|
|
516
603
|
})
|
|
517
604
|
|
|
605
|
+
// ---------- Demo ----------
|
|
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
|
+
|
|
710
|
+
function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
711
|
+
const editable = editor.isEditable
|
|
712
|
+
const { demoResolver, demoRuntime, demoDependencies } = useEditorRuntime()
|
|
713
|
+
const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
|
|
714
|
+
const [filesOpen, setFilesOpen] = useState(false)
|
|
715
|
+
// Typing into `src` or a file would otherwise re-resolve / re-run every keystroke.
|
|
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
|
+
}
|
|
729
|
+
return (
|
|
730
|
+
<NodeViewWrapper className={preview || (editable && filesOpen) ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
|
|
731
|
+
<div className="gb-demo">
|
|
732
|
+
<AppWindow className="size-4 shrink-0" />
|
|
733
|
+
{editable ? (
|
|
734
|
+
<>
|
|
735
|
+
<input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
|
|
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>
|
|
747
|
+
</>
|
|
748
|
+
) : (
|
|
749
|
+
<>
|
|
750
|
+
<code className="gb-demo-src">{demo.src || "demo"}</code>
|
|
751
|
+
{demo.title ? <span className="gb-demo-title">{demo.title}</span> : null}
|
|
752
|
+
</>
|
|
753
|
+
)}
|
|
754
|
+
</div>
|
|
755
|
+
{editable && filesOpen ? (
|
|
756
|
+
<DemoFilesEditor files={demo.files ?? []} entry={demo.entry ?? ""} onChange={setFiles} />
|
|
757
|
+
) : null}
|
|
758
|
+
{preview ? (
|
|
759
|
+
<div className="gb-demo-preview">
|
|
760
|
+
<DemoViewer
|
|
761
|
+
key={src}
|
|
762
|
+
{...(demoResolver ? { resolver: demoResolver } : {})}
|
|
763
|
+
src={src}
|
|
764
|
+
title={demo.title}
|
|
765
|
+
description={demo.description}
|
|
766
|
+
height={demo.height}
|
|
767
|
+
layout={demo.layout}
|
|
768
|
+
variants={demo.variants}
|
|
769
|
+
viewport={demo.viewport}
|
|
770
|
+
files={files}
|
|
771
|
+
entry={entry}
|
|
772
|
+
{...(demoRuntime ? { runtime: demoRuntime } : {})}
|
|
773
|
+
{...(demoDependencies ? { dependencies: demoDependencies } : {})}
|
|
774
|
+
/>
|
|
775
|
+
</div>
|
|
776
|
+
) : null}
|
|
777
|
+
</NodeViewWrapper>
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
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
|
+
*/
|
|
785
|
+
export const GbDemo = Node.create({
|
|
786
|
+
name: "gbDemo",
|
|
787
|
+
group: "block",
|
|
788
|
+
atom: true,
|
|
789
|
+
addAttributes() {
|
|
790
|
+
return {
|
|
791
|
+
src: { default: "" },
|
|
792
|
+
title: { default: "" },
|
|
793
|
+
description: { default: "" },
|
|
794
|
+
height: { default: "" },
|
|
795
|
+
layout: { default: null },
|
|
796
|
+
variants: { default: null, rendered: false },
|
|
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
|
+
},
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
parseHTML() {
|
|
817
|
+
return [{ tag: "div[data-gb-demo]" }]
|
|
818
|
+
},
|
|
819
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
820
|
+
return ["div", mergeAttributes(HTMLAttributes, { "data-gb-demo": node.attrs.src })]
|
|
821
|
+
},
|
|
822
|
+
addNodeView() {
|
|
823
|
+
return ReactNodeViewRenderer(DemoView)
|
|
824
|
+
},
|
|
825
|
+
})
|
|
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
|
+
|
|
518
907
|
// ---------- Columns ----------
|
|
519
908
|
|
|
520
909
|
export const GbColumns = Node.create({
|
|
@@ -728,6 +1117,8 @@ export const GbCodeBlock = CodeBlock.extend({
|
|
|
728
1117
|
lineNumbers: { default: false },
|
|
729
1118
|
live: { default: false },
|
|
730
1119
|
entry: { default: null },
|
|
1120
|
+
collapsedCodeLines: { default: null, rendered: false },
|
|
1121
|
+
expandedCodeLines: { default: null, rendered: false },
|
|
731
1122
|
}
|
|
732
1123
|
},
|
|
733
1124
|
addNodeView() {
|
|
@@ -975,6 +1366,8 @@ export const gitbookNodes = [
|
|
|
975
1366
|
GbEmbed,
|
|
976
1367
|
GbContentRef,
|
|
977
1368
|
GbSourceRef,
|
|
1369
|
+
GbDemo,
|
|
1370
|
+
GbCommand,
|
|
978
1371
|
GbColumns,
|
|
979
1372
|
GbColumn,
|
|
980
1373
|
GbFigure,
|
package/src/editor/runtime.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react"
|
|
2
2
|
|
|
3
|
+
import type { DemoResolver, InlineDemoRuntime } 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,9 @@ export interface EditorRuntimeOptions {
|
|
|
9
10
|
sourceAutoSave?: boolean | number
|
|
10
11
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
11
12
|
onSourceError?: (error: Error) => void
|
|
13
|
+
demoResolver?: DemoResolver
|
|
14
|
+
demoRuntime?: InlineDemoRuntime
|
|
15
|
+
demoDependencies?: Record<string, string>
|
|
12
16
|
attachments?: EditorAttachment[]
|
|
13
17
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
14
18
|
}
|
|
@@ -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,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
SquareChevronDown,
|
|
23
24
|
Superscript,
|
|
24
25
|
Table as TableIcon,
|
|
26
|
+
Terminal,
|
|
25
27
|
Webhook,
|
|
26
28
|
Workflow,
|
|
27
29
|
type LucideIcon,
|
|
@@ -71,6 +73,12 @@ export const SLASH_ITEMS: SlashItem[] = [
|
|
|
71
73
|
],
|
|
72
74
|
}),
|
|
73
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
|
+
},
|
|
74
82
|
{
|
|
75
83
|
title: "Expandable",
|
|
76
84
|
keywords: "details accordion collapse",
|
|
@@ -101,6 +109,12 @@ export const SLASH_ITEMS: SlashItem[] = [
|
|
|
101
109
|
icon: Link2,
|
|
102
110
|
run: insertBlock({ type: "gbContentRef", attrs: { url: "", label: "Page link" } }),
|
|
103
111
|
},
|
|
112
|
+
{
|
|
113
|
+
title: "Demo",
|
|
114
|
+
keywords: "example live preview component playground",
|
|
115
|
+
icon: AppWindow,
|
|
116
|
+
run: insertBlock({ type: "gbDemo", attrs: { src: "", title: "" } }),
|
|
117
|
+
},
|
|
104
118
|
{
|
|
105
119
|
title: "Columns",
|
|
106
120
|
keywords: "two column layout",
|
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
|
@@ -88,6 +88,126 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
91
211
|
.gb-source-file-editor {
|
|
92
212
|
border: 1px solid var(--gb-border);
|
|
93
213
|
border-radius: var(--gb-radius);
|
|
@@ -369,6 +489,84 @@
|
|
|
369
489
|
.gb-tabs-content .gb-tab > :first-child { margin-top: 0; }
|
|
370
490
|
.gb-tabs-content .gb-tab > :last-child { margin-bottom: 0; }
|
|
371
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
|
+
|
|
372
570
|
/* ── Expandable (mirrors .docs-expandable) ──────────────── */
|
|
373
571
|
.gb-expandable {
|
|
374
572
|
border: 1px solid var(--gb-border);
|