@brett_lamy/docstream 1.1.0 → 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 +147 -9
- package/package.json +3 -1
- package/src/demo/DemoViewer.tsx +213 -46
- package/src/demo/context.ts +17 -1
- package/src/demo/glob.ts +12 -4
- package/src/demo/index.ts +15 -5
- package/src/demo/markdown.ts +138 -41
- package/src/demo/types.ts +46 -2
- package/src/docs/DocsRenderer.tsx +147 -34
- package/src/docs/PageActions.tsx +10 -6
- package/src/docs/controls.tsx +4 -1
- package/src/gitbook/ast.ts +63 -4
- package/src/gitbook/flatten.ts +47 -0
- package/src/gitbook/index.ts +6 -2
- package/src/gitbook/inline.ts +5 -2
- package/src/gitbook/outline.ts +45 -0
- package/src/gitbook/package-managers.ts +98 -0
- package/src/gitbook/parse.ts +179 -8
- package/src/gitbook/serialize.ts +40 -4
- package/src/index.ts +23 -1
- package/src/playground/InlineDemoPreview.tsx +77 -0
- package/src/playground/PlaygroundStreamdown.tsx +12 -2
- package/src/playground/ReactCodePreview.tsx +67 -36
- package/src/playground/filesystem.ts +83 -1
- package/src/playground/index.ts +7 -2
- package/src/streamdown.tsx +6 -2
- package/src/styles.css +117 -8
package/src/docs/controls.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Small accessible controls shared by the demo viewer, tabs and page actions: a pill tab strip
|
|
2
2
|
(tabs semantics, roving focus) and a segmented radio group. */
|
|
3
|
-
import { useId, type KeyboardEvent, type ReactNode } from "react"
|
|
3
|
+
import { useId, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"
|
|
4
4
|
import { motion, useReducedMotion } from "framer-motion"
|
|
5
5
|
|
|
6
6
|
export interface PillOption<T extends string> {
|
|
@@ -96,18 +96,21 @@ export function Segmented<T extends string>({
|
|
|
96
96
|
onChange,
|
|
97
97
|
label,
|
|
98
98
|
className,
|
|
99
|
+
style,
|
|
99
100
|
}: {
|
|
100
101
|
options: readonly PillOption<T>[]
|
|
101
102
|
value: T
|
|
102
103
|
onChange: (id: T) => void
|
|
103
104
|
label: string
|
|
104
105
|
className?: string
|
|
106
|
+
style?: CSSProperties
|
|
105
107
|
}) {
|
|
106
108
|
const layoutId = useId()
|
|
107
109
|
const reduced = useReducedMotion()
|
|
108
110
|
return (
|
|
109
111
|
<div
|
|
110
112
|
className={className ? `docs-pills docs-segmented ${className}` : "docs-pills docs-segmented"}
|
|
113
|
+
style={style}
|
|
111
114
|
role="radiogroup"
|
|
112
115
|
aria-label={label}
|
|
113
116
|
onKeyDown={(event) => rovingKeyDown(event, options.map((o) => o.id), value, onChange)}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface TextNode {
|
|
|
11
11
|
strike?: boolean
|
|
12
12
|
code?: boolean
|
|
13
13
|
link?: string
|
|
14
|
+
/**
|
|
15
|
+
* The link sits inside the emphasis (`**[x](url)**`) rather than around it
|
|
16
|
+
* (`[**x**](url)`, the default). Kept so the source round-trips byte-for-byte.
|
|
17
|
+
*/
|
|
18
|
+
linkInner?: true
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/** Inline HTML image, optionally wrapped in a link — GitHub README badge style. */
|
|
@@ -94,6 +99,33 @@ export interface TabsNode {
|
|
|
94
99
|
* the reader's last choice (matched by tab title), remembered across pages.
|
|
95
100
|
*/
|
|
96
101
|
sync?: string
|
|
102
|
+
/**
|
|
103
|
+
* Section title (`{% tabs title="Installation" %}`). The tab set renders as a
|
|
104
|
+
* section: this title as a heading (with an anchor id), the tab switch as pill
|
|
105
|
+
* tabs right-aligned in that header, and the active tab's blocks below.
|
|
106
|
+
*/
|
|
107
|
+
title?: string
|
|
108
|
+
/** Heading level of `title` (`level="3"`). Defaults to 2. */
|
|
109
|
+
level?: 2 | 3 | 4
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A package-manager command box: `{% command %}npm install x{% endcommand %}`.
|
|
116
|
+
* The author writes one canonical npm / npx command; renderers derive the pnpm,
|
|
117
|
+
* yarn and bun forms (see `packageManagerCommands`), unless overridden per
|
|
118
|
+
* manager (`pnpm="…"`). The reader's manager is synced page-wide (and across
|
|
119
|
+
* pages) under `sync` — `"pm"` by default, the same key install `{% tabs %}` use.
|
|
120
|
+
*/
|
|
121
|
+
export interface CommandNode {
|
|
122
|
+
type: "command"
|
|
123
|
+
/** The npm / npx command (may span lines). */
|
|
124
|
+
command: string
|
|
125
|
+
/** Explicit commands for other managers, used verbatim instead of the derived ones. */
|
|
126
|
+
overrides?: Partial<Record<Exclude<PackageManager, "npm">, string>>
|
|
127
|
+
/** Sync key. Omitted means `"pm"`. */
|
|
128
|
+
sync?: string
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
export interface ExpandableNode {
|
|
@@ -156,11 +188,27 @@ export interface DemoVariantOption {
|
|
|
156
188
|
label: string
|
|
157
189
|
}
|
|
158
190
|
|
|
191
|
+
/** One source file carried inline by a `{% demo %}` block (a titled fence). */
|
|
192
|
+
export interface DemoInlineFile {
|
|
193
|
+
/** Path relative to the demo folder, from the fence's `title` (`index.tsx`, `parts/Card.tsx`). */
|
|
194
|
+
path: string
|
|
195
|
+
content: string
|
|
196
|
+
/** The fence's info-string language, when it has one. */
|
|
197
|
+
language?: string
|
|
198
|
+
}
|
|
199
|
+
|
|
159
200
|
/**
|
|
160
|
-
* A
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
201
|
+
* A live demo: `{% demo src="<page>/<example>" %}`. With a host `DemoResolver`
|
|
202
|
+
* that knows `src`, the example folder is the authority for the component, its
|
|
203
|
+
* source files and its defaults; attributes here override the folder's
|
|
204
|
+
* `meta.json`. The block form carries the files inline so the Markdown renders
|
|
205
|
+
* anywhere, resolver or not:
|
|
206
|
+
*
|
|
207
|
+
* {% demo src="composer/scroll-fab" title="Scroll → FAB" %}
|
|
208
|
+
* ```tsx title="index.tsx"
|
|
209
|
+
* …
|
|
210
|
+
* ```
|
|
211
|
+
* {% enddemo %}
|
|
164
212
|
*/
|
|
165
213
|
export interface DemoNode {
|
|
166
214
|
type: "demo"
|
|
@@ -175,6 +223,16 @@ export interface DemoNode {
|
|
|
175
223
|
variants?: DemoVariantOption[]
|
|
176
224
|
/** Initial preview viewport for the multi-file viewer. */
|
|
177
225
|
viewport?: DemoViewport
|
|
226
|
+
/** Entry file among the inline files (`entry="App.tsx"`). Defaults to the first file. */
|
|
227
|
+
entry?: string
|
|
228
|
+
/** Files carried inline by the block form, in document order. */
|
|
229
|
+
files?: DemoInlineFile[]
|
|
230
|
+
/**
|
|
231
|
+
* Set while streaming when the block's `{% enddemo %}` has not arrived yet: the
|
|
232
|
+
* files may still be growing, so renderers show code but hold off running it.
|
|
233
|
+
* Never serialized.
|
|
234
|
+
*/
|
|
235
|
+
open?: true
|
|
178
236
|
}
|
|
179
237
|
|
|
180
238
|
export interface ColumnNode {
|
|
@@ -256,6 +314,7 @@ export type Block =
|
|
|
256
314
|
| CodeBlockNode
|
|
257
315
|
| HintNode
|
|
258
316
|
| TabsNode
|
|
317
|
+
| CommandNode
|
|
259
318
|
| ExpandableNode
|
|
260
319
|
| StepperNode
|
|
261
320
|
| EmbedNode
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Block, DocumentNode } from "./ast"
|
|
2
|
+
import { text } from "./ast"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* docstream-only presentation blocks as plain Markdown, for LLM prompts and tools that don't
|
|
6
|
+
* know docstream (`resolveDemosToMarkdown(…, { format: "plain" })`):
|
|
7
|
+
*
|
|
8
|
+
* - `{% command %}` → a ```` ```sh ```` fence with the npm command;
|
|
9
|
+
* - titled `{% tabs title="Installation" %}` → a `## Installation` heading, then each tab as a
|
|
10
|
+
* bold label followed by its blocks.
|
|
11
|
+
*
|
|
12
|
+
* Everything else is kept (GitBook tags included — they're GitBook, not docstream).
|
|
13
|
+
*/
|
|
14
|
+
export function flattenForPlainMarkdown(doc: DocumentNode): DocumentNode {
|
|
15
|
+
return { ...doc, children: flattenBlocks(doc.children) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function flattenBlocks(blocks: Block[]): Block[] {
|
|
19
|
+
return blocks.flatMap((b): Block[] => {
|
|
20
|
+
switch (b.type) {
|
|
21
|
+
case "command":
|
|
22
|
+
return [{ type: "code", language: "sh", title: null, lineNumbers: true, code: b.command }]
|
|
23
|
+
case "tabs":
|
|
24
|
+
if (b.title) {
|
|
25
|
+
return [
|
|
26
|
+
{ type: "heading", level: b.level ?? 2, children: [text(b.title)] },
|
|
27
|
+
...b.tabs.flatMap((t): Block[] => [{ type: "paragraph", children: [text(t.title, { bold: true })] }, ...flattenBlocks(t.children)]),
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
return [{ ...b, tabs: b.tabs.map((t) => ({ ...t, children: flattenBlocks(t.children) })) }]
|
|
31
|
+
case "hint":
|
|
32
|
+
case "expandable":
|
|
33
|
+
case "blockquote":
|
|
34
|
+
return [{ ...b, children: flattenBlocks(b.children) }]
|
|
35
|
+
case "stepper":
|
|
36
|
+
return [{ ...b, steps: b.steps.map((s) => ({ ...s, children: flattenBlocks(s.children) })) }]
|
|
37
|
+
case "columns":
|
|
38
|
+
return [{ ...b, columns: b.columns.map((c) => ({ ...c, children: flattenBlocks(c.children) })) }]
|
|
39
|
+
case "list":
|
|
40
|
+
return [{ ...b, items: b.items.map((item) => ({ ...item, children: flattenBlocks(item.children) })) }]
|
|
41
|
+
case "updates":
|
|
42
|
+
return [{ ...b, updates: b.updates.map((u) => ({ ...u, children: flattenBlocks(u.children) })) }]
|
|
43
|
+
default:
|
|
44
|
+
return [b]
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
package/src/gitbook/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Pure GitBook markdown engine (no React, no mermaid/streamdown). Safe to import
|
|
2
2
|
// in non-DOM environments such as a server-side AI agent or a Cloudflare Worker.
|
|
3
3
|
export type * from "./ast"
|
|
4
|
-
export { parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
|
|
5
|
-
export { serializeBlocks, serializeMarkdown } from "./serialize"
|
|
4
|
+
export { closesFence, demoBody, fenceOpener, fenceTracker, parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
|
|
5
|
+
export { fenceFor, serializeBlocks, serializeDemoFile, serializeMarkdown } from "./serialize"
|
|
6
6
|
export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
|
|
7
|
+
export { PACKAGE_MANAGERS, packageManagerCommands } from "./package-managers"
|
|
8
|
+
export { flattenBlocks, flattenForPlainMarkdown } from "./flatten"
|
|
9
|
+
export { documentOutline, slugify } from "./outline"
|
|
10
|
+
export type { OutlineEntry } from "./outline"
|
package/src/gitbook/inline.ts
CHANGED
|
@@ -155,7 +155,8 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
|
|
|
155
155
|
const m = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/)
|
|
156
156
|
if (m) {
|
|
157
157
|
flush()
|
|
158
|
-
|
|
158
|
+
const inner = marks.bold || marks.italic || marks.strike ? { linkInner: true as const } : {}
|
|
159
|
+
out.push(...parseInline(m[1], { ...marks, link: m[2], ...inner }))
|
|
159
160
|
i += m[0].length
|
|
160
161
|
continue
|
|
161
162
|
}
|
|
@@ -240,10 +241,12 @@ export function serializeInline(nodes: Inline[]): string {
|
|
|
240
241
|
}
|
|
241
242
|
let s = n.code ? n.text : escapeText(n.text)
|
|
242
243
|
if (n.code) s = `\`${s}\``
|
|
244
|
+
const linkInner = n.link && n.linkInner && (n.bold || n.italic || n.strike)
|
|
245
|
+
if (linkInner) s = `[${s}](${n.link})`
|
|
243
246
|
if (n.bold) s = `**${s}**`
|
|
244
247
|
if (n.italic) s = `_${s}_`
|
|
245
248
|
if (n.strike) s = `~~${s}~~`
|
|
246
|
-
if (n.link) s = `[${s}](${n.link})`
|
|
249
|
+
if (n.link && !linkInner) s = `[${s}](${n.link})`
|
|
247
250
|
return s
|
|
248
251
|
})
|
|
249
252
|
.join("")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Block, DocumentNode } from "./ast"
|
|
2
|
+
import { plainText } from "./inline"
|
|
3
|
+
|
|
4
|
+
/** Anchor id for a heading: `"Installation"` → `"installation"`. */
|
|
5
|
+
export function slugify(text: string): string {
|
|
6
|
+
return (
|
|
7
|
+
text
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.normalize("NFKD")
|
|
10
|
+
.replace(/[̀-ͯ]/g, "")
|
|
11
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
12
|
+
.replace(/^-+|-+$/g, "") || "section"
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OutlineEntry {
|
|
17
|
+
level: number
|
|
18
|
+
text: string
|
|
19
|
+
/** The anchor id docstream renders for titled tab sets; hosts may reuse it for headings. */
|
|
20
|
+
id: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The page's headings for a table of contents — Markdown headings plus the titles of
|
|
25
|
+
* `{% tabs title="…" %}` sections — in document order (top level and inside hints,
|
|
26
|
+
* tabs, steppers, columns and expandables).
|
|
27
|
+
*/
|
|
28
|
+
export function documentOutline(doc: DocumentNode | Block[]): OutlineEntry[] {
|
|
29
|
+
const out: OutlineEntry[] = []
|
|
30
|
+
const walk = (blocks: Block[]) => {
|
|
31
|
+
for (const b of blocks) {
|
|
32
|
+
if (b.type === "heading") {
|
|
33
|
+
const text = plainText(b.children)
|
|
34
|
+
if (text) out.push({ level: b.level, text, id: slugify(text) })
|
|
35
|
+
} else if (b.type === "tabs") {
|
|
36
|
+
if (b.title) out.push({ level: b.level ?? 2, text: b.title, id: slugify(b.title) })
|
|
37
|
+
for (const t of b.tabs) walk(t.children)
|
|
38
|
+
} else if (b.type === "hint" || b.type === "expandable") walk(b.children)
|
|
39
|
+
else if (b.type === "stepper") for (const s of b.steps) walk(s.children)
|
|
40
|
+
else if (b.type === "columns") for (const c of b.columns) walk(c.children)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
walk(Array.isArray(doc) ? doc : doc.children)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CommandNode, PackageManager } from "./ast"
|
|
2
|
+
|
|
3
|
+
/** Display order of the command box's manager tabs. The first is the default choice. */
|
|
4
|
+
export const PACKAGE_MANAGERS: readonly PackageManager[] = ["pnpm", "npm", "yarn", "bun"]
|
|
5
|
+
|
|
6
|
+
type Others = Exclude<PackageManager, "npm">
|
|
7
|
+
|
|
8
|
+
const INSTALL = new Set(["install", "i", "add"])
|
|
9
|
+
const UNINSTALL = new Set(["uninstall", "un", "remove", "rm", "r"])
|
|
10
|
+
const LIFECYCLE = new Set(["test", "start", "stop", "restart"])
|
|
11
|
+
|
|
12
|
+
/** npm install flags → per-manager flags. Unknown flags pass through unchanged. */
|
|
13
|
+
const FLAGS: Record<string, Record<Others, string | null>> = {
|
|
14
|
+
"-D": { pnpm: "-D", yarn: "-D", bun: "-d" },
|
|
15
|
+
"--save-dev": { pnpm: "-D", yarn: "-D", bun: "-d" },
|
|
16
|
+
"-E": { pnpm: "-E", yarn: "-E", bun: "--exact" },
|
|
17
|
+
"--save-exact": { pnpm: "-E", yarn: "-E", bun: "--exact" },
|
|
18
|
+
"-O": { pnpm: "-O", yarn: "-O", bun: "--optional" },
|
|
19
|
+
"--save-optional": { pnpm: "-O", yarn: "-O", bun: "--optional" },
|
|
20
|
+
"-P": { pnpm: "-P", yarn: null, bun: null },
|
|
21
|
+
"--save-prod": { pnpm: "-P", yarn: null, bun: null },
|
|
22
|
+
"-S": { pnpm: null, yarn: null, bun: null },
|
|
23
|
+
"--save": { pnpm: null, yarn: null, bun: null },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flags(args: string[], pm: Others): string[] {
|
|
27
|
+
return args.flatMap((arg) => {
|
|
28
|
+
const mapped = FLAGS[arg]
|
|
29
|
+
if (!mapped) return [arg]
|
|
30
|
+
const next = mapped[pm]
|
|
31
|
+
return next ? [next] : []
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const join = (...parts: (string | string[])[]) => parts.flat().filter(Boolean).join(" ")
|
|
36
|
+
|
|
37
|
+
/** One npm / npx invocation (already split into words) for another manager, or null if it isn't one. */
|
|
38
|
+
function convertWords(words: string[], pm: Others): string | null {
|
|
39
|
+
const [head, sub, ...rest] = words
|
|
40
|
+
if (head === "npx") {
|
|
41
|
+
const args = words.slice(1).filter((w) => w !== "-y" && w !== "--yes")
|
|
42
|
+
return join(pm === "bun" ? "bunx" : `${pm} dlx`, args)
|
|
43
|
+
}
|
|
44
|
+
if (head !== "npm" || sub === undefined) return head === "npm" ? pm : null
|
|
45
|
+
if (sub === "exec" || sub === "x") {
|
|
46
|
+
const args = rest[0] === "--" ? rest.slice(1) : rest
|
|
47
|
+
return join(pm === "bun" ? "bunx" : `${pm} dlx`, args.filter((w) => w !== "-y" && w !== "--yes"))
|
|
48
|
+
}
|
|
49
|
+
if (INSTALL.has(sub)) {
|
|
50
|
+
const global = rest.some((w) => w === "-g" || w === "--global")
|
|
51
|
+
const args = rest.filter((w) => w !== "-g" && w !== "--global")
|
|
52
|
+
const packages = args.filter((w) => !w.startsWith("-"))
|
|
53
|
+
if (!packages.length && !global) return join(`${pm} install`, flags(args, pm))
|
|
54
|
+
if (global) return join(pm === "yarn" ? "yarn global add" : `${pm} add -g`, flags(args, pm))
|
|
55
|
+
return join(`${pm} add`, flags(args, pm))
|
|
56
|
+
}
|
|
57
|
+
if (sub === "ci") return `${pm} install --frozen-lockfile`
|
|
58
|
+
if (UNINSTALL.has(sub)) {
|
|
59
|
+
const global = rest.some((w) => w === "-g" || w === "--global")
|
|
60
|
+
const args = rest.filter((w) => w !== "-g" && w !== "--global")
|
|
61
|
+
return join(global ? (pm === "yarn" ? "yarn global remove" : `${pm} remove -g`) : `${pm} remove`, flags(args, pm))
|
|
62
|
+
}
|
|
63
|
+
if (sub === "run" || sub === "run-script") {
|
|
64
|
+
const [script, ...args] = rest
|
|
65
|
+
const tail = args[0] === "--" && pm !== "bun" ? args.slice(1) : args
|
|
66
|
+
return join(pm === "bun" ? "bun run" : pm, script ?? "", tail)
|
|
67
|
+
}
|
|
68
|
+
if (LIFECYCLE.has(sub)) return join(pm === "bun" ? "bun run" : pm, sub, rest[0] === "--" && pm !== "bun" ? rest.slice(1) : rest)
|
|
69
|
+
if (sub === "create" || (sub === "init" && rest.length && !rest[0].startsWith("-"))) return join(`${pm} create`, rest)
|
|
70
|
+
if (sub === "update" || sub === "up" || sub === "upgrade") return join(pm === "yarn" ? "yarn upgrade" : `${pm} update`, rest)
|
|
71
|
+
return join(pm, sub, rest)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One line (possibly `a && b`) for another manager. Lines that aren't npm/npx commands are kept. */
|
|
75
|
+
function convertLine(line: string, pm: Others): string {
|
|
76
|
+
const indent = line.match(/^\s*/)![0]
|
|
77
|
+
return indent + line.trim().split(/\s+&&\s+/).map((segment) => {
|
|
78
|
+
const prompt = segment.match(/^\$\s+/)?.[0] ?? ""
|
|
79
|
+
const words = segment.slice(prompt.length).split(/\s+/).filter(Boolean)
|
|
80
|
+
const converted = words.length ? convertWords(words, pm) : null
|
|
81
|
+
return converted === null ? segment : prompt + converted
|
|
82
|
+
}).join(" && ")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The pnpm / yarn / bun forms of an npm / npx command:
|
|
87
|
+
* `npm install x` → `pnpm add x`, `yarn add x`, `bun add x` (with `-D`, `-E`, `-g`, …);
|
|
88
|
+
* `npx x` → `pnpm dlx x`, `yarn dlx x`, `bunx x`; `npm run s` → `pnpm s`, `yarn s`, `bun run s`;
|
|
89
|
+
* `npm create x` → `<pm> create x`. Other lines (comments, `cd`, …) are kept as they are.
|
|
90
|
+
* `overrides` replace a manager's derived command verbatim.
|
|
91
|
+
*/
|
|
92
|
+
export function packageManagerCommands(
|
|
93
|
+
npmCommand: string,
|
|
94
|
+
overrides: CommandNode["overrides"] = {},
|
|
95
|
+
): Record<PackageManager, string> {
|
|
96
|
+
const derive = (pm: Others) => overrides[pm] ?? npmCommand.split("\n").map((line) => convertLine(line, pm)).join("\n")
|
|
97
|
+
return { npm: npmCommand, pnpm: derive("pnpm"), yarn: derive("yarn"), bun: derive("bun") }
|
|
98
|
+
}
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Block,
|
|
3
3
|
ColumnNode,
|
|
4
|
+
CommandNode,
|
|
5
|
+
DemoInlineFile,
|
|
4
6
|
DemoLayout,
|
|
5
7
|
DemoNode,
|
|
6
8
|
DemoVariantOption,
|
|
@@ -92,13 +94,57 @@ function templateTag(line: string): TemplateTag | null {
|
|
|
92
94
|
return { name: m[1], attrs: parseAttrs(m[2]) }
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
interface Fence {
|
|
98
|
+
char: "`" | "~"
|
|
99
|
+
length: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A fence opener (```` ```ts title="x" ```` or `~~~`), or null. Backtick info strings cannot contain backticks. */
|
|
103
|
+
export function fenceOpener(line: string): Fence | null {
|
|
104
|
+
const m = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
|
|
105
|
+
if (!m) return null
|
|
106
|
+
if (m[1][0] === "`" && m[2].includes("`")) return null
|
|
107
|
+
return { char: m[1][0] as Fence["char"], length: m[1].length }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when `line` closes `fence`: the same character, at least as many, nothing else. */
|
|
111
|
+
export function closesFence(line: string, fence: Fence): boolean {
|
|
112
|
+
const trimmed = line.trim()
|
|
113
|
+
return trimmed.length >= fence.length && trimmed === fence.char.repeat(trimmed.length)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Tracks whether a line-by-line scan is inside a fenced code block. */
|
|
117
|
+
export function fenceTracker() {
|
|
118
|
+
let open: Fence | null = null
|
|
119
|
+
return {
|
|
120
|
+
/** Feed the next line; returns true if the line is fence content or a fence delimiter. */
|
|
121
|
+
step(line: string): boolean {
|
|
122
|
+
if (open) {
|
|
123
|
+
if (closesFence(line, open)) open = null
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
open = fenceOpener(line)
|
|
127
|
+
return open !== null
|
|
128
|
+
},
|
|
129
|
+
get inside() {
|
|
130
|
+
return open !== null
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
95
135
|
// Collects the lines between an opening {% name %} and its matching
|
|
96
|
-
// {% endname %}, honoring nesting of the same tag.
|
|
136
|
+
// {% endname %}, honoring nesting of the same tag. Tags inside fenced code
|
|
137
|
+
// (a demo file, a Markdown sample) are content, not structure.
|
|
97
138
|
function collectUntil(lines: string[], start: number, name: string): { body: string[]; next: number } {
|
|
98
139
|
const body: string[] = []
|
|
99
140
|
let depth = 1
|
|
100
141
|
let i = start
|
|
142
|
+
const fences = fenceTracker()
|
|
101
143
|
for (; i < lines.length; i++) {
|
|
144
|
+
if (fences.step(lines[i])) {
|
|
145
|
+
body.push(lines[i])
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
102
148
|
const tag = templateTag(lines[i])
|
|
103
149
|
if (tag?.name === name) depth++
|
|
104
150
|
if (tag?.name === `end${name}`) {
|
|
@@ -131,11 +177,10 @@ export function parseMarkdown(src: string): DocumentNode {
|
|
|
131
177
|
refDefinitions.clear()
|
|
132
178
|
footnoteDefinitions.clear()
|
|
133
179
|
const content: string[] = []
|
|
134
|
-
|
|
180
|
+
const fences = fenceTracker()
|
|
135
181
|
for (const line of lines) {
|
|
136
182
|
// Definition lines inside code fences are content, not definitions.
|
|
137
|
-
if (
|
|
138
|
-
if (!inFence) {
|
|
183
|
+
if (!fences.step(line)) {
|
|
139
184
|
// Footnote citation definitions first: [^id]: url "Optional Label"
|
|
140
185
|
const foot = line.match(/^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/)
|
|
141
186
|
if (foot) {
|
|
@@ -178,6 +223,97 @@ export function parseDemoVariants(raw: string): DemoVariantOption[] {
|
|
|
178
223
|
.filter((v) => v.id)
|
|
179
224
|
}
|
|
180
225
|
|
|
226
|
+
/** `{% command %}npm install x{% endcommand %}` on one line. */
|
|
227
|
+
const COMMAND_ONE_LINE_RE = /^\s*\{%\s*command(\s[^%]*?)?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$/
|
|
228
|
+
|
|
229
|
+
function commandNode(attrs: Record<string, string>, raw: string): CommandNode {
|
|
230
|
+
const overrides: NonNullable<CommandNode["overrides"]> = {}
|
|
231
|
+
for (const pm of ["pnpm", "yarn", "bun"] as const) if (attrs[pm]) overrides[pm] = attrs[pm]
|
|
232
|
+
return {
|
|
233
|
+
type: "command",
|
|
234
|
+
command: raw.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, "").trim(),
|
|
235
|
+
...(Object.keys(overrides).length ? { overrides } : {}),
|
|
236
|
+
...(attrs.sync && attrs.sync !== "pm" ? { sync: attrs.sync } : {}),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const ENDDEMO_RE = /^\s*\{%\s*enddemo\s*%\}\s*$/
|
|
241
|
+
|
|
242
|
+
interface DemoBody {
|
|
243
|
+
files: DemoInlineFile[]
|
|
244
|
+
/** First line after the block. */
|
|
245
|
+
next: number
|
|
246
|
+
/** The input ended before `{% enddemo %}` (a block still streaming in). */
|
|
247
|
+
open: boolean
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function fallbackPath(index: number, language: string | undefined): string {
|
|
251
|
+
const ext = language || "txt"
|
|
252
|
+
return index === 0 ? `index.${ext}` : `file-${index + 1}.${ext}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The inline files of a `{% demo %}` block starting at `start` (the line after the tag).
|
|
257
|
+
*
|
|
258
|
+
* - `{% enddemo %}` right away: an empty block.
|
|
259
|
+
* - A fence right away: the block form. Fences (and blank lines) are files until
|
|
260
|
+
* `{% enddemo %}`; any other line ends the block too. Reaching the end of input
|
|
261
|
+
* first marks the block `open` (still streaming).
|
|
262
|
+
* - Blank lines, then fences: the block form only if `{% enddemo %}` closes it with
|
|
263
|
+
* nothing but fences in between — otherwise the tag is self-closing and the fences
|
|
264
|
+
* are ordinary code blocks, exactly as before 1.2.
|
|
265
|
+
* - Anything else: no body (self-closing tag).
|
|
266
|
+
*/
|
|
267
|
+
export function demoBody(lines: string[], start: number, selfClosing = false): DemoBody | null {
|
|
268
|
+
let j = start
|
|
269
|
+
if (j < lines.length && ENDDEMO_RE.test(lines[j])) return { files: [], next: j + 1, open: false }
|
|
270
|
+
// `{% demo … /%}` takes no files (a directly following `{% enddemo %}` is tolerated above).
|
|
271
|
+
if (selfClosing) return null
|
|
272
|
+
const immediate = j < lines.length && fenceOpener(lines[j]) !== null
|
|
273
|
+
if (!immediate) {
|
|
274
|
+
while (j < lines.length && !lines[j].trim()) j++
|
|
275
|
+
if (j >= lines.length) return null
|
|
276
|
+
if (ENDDEMO_RE.test(lines[j])) return { files: [], next: j + 1, open: false }
|
|
277
|
+
if (!fenceOpener(lines[j])) return null
|
|
278
|
+
}
|
|
279
|
+
const files: DemoInlineFile[] = []
|
|
280
|
+
while (j < lines.length) {
|
|
281
|
+
const line = lines[j]
|
|
282
|
+
if (!line.trim()) {
|
|
283
|
+
j++
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if (ENDDEMO_RE.test(line)) return { files, next: j + 1, open: false }
|
|
287
|
+
const fence = fenceOpener(line)
|
|
288
|
+
if (!fence) return immediate ? { files, next: j, open: false } : null
|
|
289
|
+
const info = line.trim().slice(fence.length).trim()
|
|
290
|
+
// `tsx title="index.tsx"` or just `title="index.tsx"` (no language).
|
|
291
|
+
const first = info.match(/^[^\s`]*/)?.[0] ?? ""
|
|
292
|
+
const language = first.includes("=") ? "" : first
|
|
293
|
+
const rest = language ? info.slice(language.length) : info
|
|
294
|
+
const attrs = parseAttrs(rest)
|
|
295
|
+
const content: string[] = []
|
|
296
|
+
j++
|
|
297
|
+
let closed = false
|
|
298
|
+
while (j < lines.length) {
|
|
299
|
+
if (closesFence(lines[j], fence)) {
|
|
300
|
+
closed = true
|
|
301
|
+
j++
|
|
302
|
+
break
|
|
303
|
+
}
|
|
304
|
+
content.push(lines[j])
|
|
305
|
+
j++
|
|
306
|
+
}
|
|
307
|
+
files.push({
|
|
308
|
+
path: (attrs.title ?? "").trim() || fallbackPath(files.length, language || undefined),
|
|
309
|
+
content: content.join("\n"),
|
|
310
|
+
...(language ? { language } : {}),
|
|
311
|
+
})
|
|
312
|
+
if (!closed) break
|
|
313
|
+
}
|
|
314
|
+
return immediate ? { files, next: j, open: true } : null
|
|
315
|
+
}
|
|
316
|
+
|
|
181
317
|
function parseDemoTag(attrs: Record<string, string>): DemoNode {
|
|
182
318
|
const variants = attrs.variants ? parseDemoVariants(attrs.variants) : []
|
|
183
319
|
return {
|
|
@@ -189,6 +325,7 @@ function parseDemoTag(attrs: Record<string, string>): DemoNode {
|
|
|
189
325
|
...((DEMO_LAYOUTS as string[]).includes(attrs.layout) ? { layout: attrs.layout as DemoLayout } : {}),
|
|
190
326
|
...(variants.length ? { variants } : {}),
|
|
191
327
|
...((DEMO_VIEWPORTS as string[]).includes(attrs.viewport) ? { viewport: attrs.viewport as DemoViewport } : {}),
|
|
328
|
+
...(attrs.entry ? { entry: attrs.entry } : {}),
|
|
192
329
|
}
|
|
193
330
|
}
|
|
194
331
|
|
|
@@ -201,6 +338,12 @@ export function trimPartialInlineToken(md: string): string {
|
|
|
201
338
|
return md
|
|
202
339
|
// A block tag still arriving (`{% demo src="butt`) would flash as a paragraph.
|
|
203
340
|
.replace(/(^|\n)[ \t]*\{(?:%(?:(?!%\})[^\n])*)?$/, "$1")
|
|
341
|
+
// A one-line command still arriving (`{% command %}npm i x{% endcomm`): hold the line back
|
|
342
|
+
// until it closes. (The multi-line form, `{% command %}` alone on its line, renders as it grows.)
|
|
343
|
+
.replace(/(^|\n)[ \t]*\{%\s*command\b[^\n]*?%\}[ \t]*\S[^\n]*$/, (line: string, lead: string) =>
|
|
344
|
+
/\{%\s*endcommand\s*%\}\s*$/.test(line) ? line : lead)
|
|
345
|
+
// A fence still arriving (a lone ` or ``) would end a demo block and flash as text.
|
|
346
|
+
.replace(/(^|\n)[ \t]*(?:`{1,2}|~{1,2})$/, "$1")
|
|
204
347
|
.replace(/\[\^[^\]]*$/, "")
|
|
205
348
|
.replace(/(^|[\s([{])[@#][\w.-]*$/, "$1")
|
|
206
349
|
.replace(/\\$/, "")
|
|
@@ -229,6 +372,14 @@ export function parseBlocks(lines: string[]): Block[] {
|
|
|
229
372
|
continue
|
|
230
373
|
}
|
|
231
374
|
|
|
375
|
+
const oneLine = line.match(COMMAND_ONE_LINE_RE)
|
|
376
|
+
if (oneLine) {
|
|
377
|
+
flushParagraph()
|
|
378
|
+
blocks.push(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
|
|
379
|
+
i++
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
|
|
232
383
|
const tag = templateTag(line)
|
|
233
384
|
if (tag) {
|
|
234
385
|
flushParagraph()
|
|
@@ -245,7 +396,21 @@ export function parseBlocks(lines: string[]): Block[] {
|
|
|
245
396
|
|
|
246
397
|
if (tag.name === "tabs") {
|
|
247
398
|
const { body, next } = collectUntil(lines, i + 1, "tabs")
|
|
248
|
-
|
|
399
|
+
const level = Number(tag.attrs.level)
|
|
400
|
+
blocks.push({
|
|
401
|
+
type: "tabs",
|
|
402
|
+
tabs: parseTabs(body),
|
|
403
|
+
...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}),
|
|
404
|
+
...(tag.attrs.title ? { title: tag.attrs.title } : {}),
|
|
405
|
+
...(tag.attrs.title && (level === 3 || level === 4) ? { level: level as 3 | 4 } : {}),
|
|
406
|
+
})
|
|
407
|
+
i = next
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (tag.name === "command") {
|
|
412
|
+
const { body, next } = collectUntil(lines, i + 1, "command")
|
|
413
|
+
blocks.push(commandNode(tag.attrs, body.join("\n")))
|
|
249
414
|
i = next
|
|
250
415
|
continue
|
|
251
416
|
}
|
|
@@ -328,10 +493,16 @@ export function parseBlocks(lines: string[]): Block[] {
|
|
|
328
493
|
}
|
|
329
494
|
|
|
330
495
|
if (tag.name === "demo") {
|
|
331
|
-
|
|
496
|
+
const node = parseDemoTag(tag.attrs)
|
|
332
497
|
i++
|
|
333
|
-
//
|
|
334
|
-
|
|
498
|
+
// `{% demo … /%}` never has a body; otherwise look for inline files / {% enddemo %}.
|
|
499
|
+
const body = demoBody(lines, i, /\/\s*%\}\s*$/.test(line))
|
|
500
|
+
if (body) {
|
|
501
|
+
if (body.files.length) node.files = body.files
|
|
502
|
+
if (body.open) node.open = true
|
|
503
|
+
i = body.next
|
|
504
|
+
}
|
|
505
|
+
blocks.push(node)
|
|
335
506
|
continue
|
|
336
507
|
}
|
|
337
508
|
|