@brett_lamy/docstream 1.2.1 → 1.2.3
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 +36 -5
- package/package.json +1 -1
- package/src/demo/DemoViewer.tsx +52 -9
- package/src/demo/index.ts +1 -0
- package/src/demo/markdown.ts +17 -3
- package/src/demo/surface.ts +67 -0
- package/src/gitbook/ast.ts +43 -2
- package/src/gitbook/attrs.ts +39 -0
- package/src/gitbook/flatten.ts +1 -1
- package/src/gitbook/index.ts +2 -1
- package/src/gitbook/inline.ts +502 -163
- package/src/gitbook/parse.ts +43 -22
- package/src/gitbook/serialize.ts +52 -43
- package/src/index.ts +2 -0
- package/src/styles.css +29 -19
package/README.md
CHANGED
|
@@ -107,6 +107,18 @@ Helpful context for the reader.
|
|
|
107
107
|
|
|
108
108
|
Supported styles are `info`, `success`, `warning`, and `danger`.
|
|
109
109
|
|
|
110
|
+
### Code fences
|
|
111
|
+
|
|
112
|
+
````md
|
|
113
|
+
```ts title="client.ts"
|
|
114
|
+
const client = createClient()
|
|
115
|
+
```
|
|
116
|
+
````
|
|
117
|
+
|
|
118
|
+
Titled (file) fences show line numbers; untitled fences don't. `lineNumbers="true"`
|
|
119
|
+
or `lineNumbers="false"` overrides either way, and an explicit attribute is kept on
|
|
120
|
+
serialize even when it matches the default.
|
|
121
|
+
|
|
110
122
|
### Tabs
|
|
111
123
|
|
|
112
124
|
````md
|
|
@@ -289,11 +301,30 @@ examples/button/sizes/
|
|
|
289
301
|
```
|
|
290
302
|
|
|
291
303
|
`meta.height` is a minimum for single-file previews (in-page components may grow
|
|
292
|
-
past it
|
|
293
|
-
|
|
294
|
-
`
|
|
295
|
-
`
|
|
296
|
-
|
|
304
|
+
past it; without a height they size to their content — hosts can set a floor with
|
|
305
|
+
`--docs-demo-preview-min-height`) and the stage height for the multi-file viewer.
|
|
306
|
+
`className` / `surface` (a style object, CSS custom properties welcome) style the
|
|
307
|
+
preview canvas, `bleed` drops its padding, `status` shows a small label in the
|
|
308
|
+
header ("Live", "Needs network"), and `variantsWidth` sizes the variant switch.
|
|
309
|
+
The canvas is block flow: block-level demo roots fill its width and lay out as on a
|
|
310
|
+
page (`max-width: 480px; margin: 0 auto` centres at 480px); intrinsically sized
|
|
311
|
+
(inline-level) ones — a button, a badge, an image — are centred.
|
|
312
|
+
|
|
313
|
+
Every `meta.json` field has a tag attribute of the same name:
|
|
314
|
+
|
|
315
|
+
```md
|
|
316
|
+
{% demo src="button/sizes" title="Sizes \"S\" to \"L\"" status="Live" bleed="true" className="brand"
|
|
317
|
+
surface="background: var(--brand-50); --demo-gap: 12px" variantsWidth="220" %}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
(one line in practice). `surface` is written as CSS declarations; `bleed="false"`
|
|
321
|
+
switches off a `meta.json` `bleed: true`. **Precedence:** a tag attribute wins,
|
|
322
|
+
then `meta.json`, then the built-in default — field by field. Copy page folds
|
|
323
|
+
`meta.json` into the tag, so the copied page renders the same without the resolver.
|
|
324
|
+
|
|
325
|
+
Attribute values are double-quoted; inside them a double quote is written `\"`, a
|
|
326
|
+
backslash `\\`, and `%}` as `%\}`. The serializer escapes, the parser unescapes
|
|
327
|
+
(other backslashes are kept as written).
|
|
297
328
|
|
|
298
329
|
#### Demos that carry their files (block form)
|
|
299
330
|
|
package/package.json
CHANGED
package/src/demo/DemoViewer.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import { PillTabs, Segmented, rovingKeyDown } from "../docs/controls"
|
|
|
38
38
|
import { useDemoResolver, useDemoRuntime } from "./context"
|
|
39
39
|
import { languageForPath } from "./glob"
|
|
40
40
|
import { inlineDemoMarkdown } from "./markdown"
|
|
41
|
+
import { cssToSurface, surfaceStyle, surfaceToCss } from "./surface"
|
|
41
42
|
import type { DemoComponent, DemoFile, DemoMeta, DemoResolver, DemoVariant, InlineDemoRuntime } from "./types"
|
|
42
43
|
|
|
43
44
|
export interface DemoViewerProps {
|
|
@@ -66,7 +67,23 @@ export interface DemoViewerProps {
|
|
|
66
67
|
runtime?: InlineDemoRuntime
|
|
67
68
|
/** Extra npm dependencies for the runtime, merged over `demoDependencies` from context. */
|
|
68
69
|
dependencies?: Record<string, string>
|
|
70
|
+
/** Class on the viewer's outer element. (The preview canvas's class is `canvasClassName`.) */
|
|
69
71
|
className?: string
|
|
72
|
+
/*
|
|
73
|
+
* Look. Each falls back to the resolver's `meta.json` field of the same name when unset —
|
|
74
|
+
* props (a `{% demo %}` tag's attributes) win over `meta.json`, which wins over the
|
|
75
|
+
* defaults — so a copied page renders the same with or without the resolver.
|
|
76
|
+
*/
|
|
77
|
+
/** Status shown in the header (`meta.status`). */
|
|
78
|
+
status?: string
|
|
79
|
+
/** Full-bleed canvas without the padded surface (`meta.bleed`). */
|
|
80
|
+
bleed?: boolean
|
|
81
|
+
/** Extra class on the preview canvas (`meta.className`). */
|
|
82
|
+
canvasClassName?: string
|
|
83
|
+
/** Canvas inline style: an object like `meta.surface`, or CSS declarations as in the tag's `surface`. */
|
|
84
|
+
surface?: Record<string, string | number> | string
|
|
85
|
+
/** Width of the variant switch (`meta.variantsWidth`). */
|
|
86
|
+
variantsWidth?: number | string
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
/* ---------- data ---------- */
|
|
@@ -249,7 +266,11 @@ function DemoCanvas({ src, preview, variant, bleed, look }: {
|
|
|
249
266
|
}
|
|
250
267
|
if (preview.kind === "runtime") {
|
|
251
268
|
return (
|
|
252
|
-
<div
|
|
269
|
+
<div
|
|
270
|
+
className={["docs-demo-canvas docs-demo-canvas-bleed docs-demo-canvas-runtime", look?.className ?? ""].filter(Boolean).join(" ")}
|
|
271
|
+
style={surfaceStyle(look?.surface)}
|
|
272
|
+
data-variant={variant}
|
|
273
|
+
>
|
|
253
274
|
<DemoErrorBoundary src={src || "demo"}>{preview.render(variant)}</DemoErrorBoundary>
|
|
254
275
|
</div>
|
|
255
276
|
)
|
|
@@ -258,7 +279,7 @@ function DemoCanvas({ src, preview, variant, bleed, look }: {
|
|
|
258
279
|
return (
|
|
259
280
|
<div
|
|
260
281
|
className={["docs-demo-canvas", bleed ? "docs-demo-canvas-bleed" : "", look?.className ?? ""].filter(Boolean).join(" ")}
|
|
261
|
-
style={look?.surface
|
|
282
|
+
style={surfaceStyle(look?.surface)}
|
|
262
283
|
data-variant={variant}
|
|
263
284
|
>
|
|
264
285
|
{component.status === "ready" ? (
|
|
@@ -498,7 +519,7 @@ function VariantSwitch({ variants, value, onChange, title, width }: {
|
|
|
498
519
|
onChange={onChange}
|
|
499
520
|
label={`${title} variant`}
|
|
500
521
|
className={width === undefined ? "docs-demo-variants" : "docs-demo-variants docs-demo-variants-sized"}
|
|
501
|
-
style={width === undefined ? undefined : { width:
|
|
522
|
+
style={width === undefined ? undefined : { width: cssLength(width, "auto") }}
|
|
502
523
|
/>
|
|
503
524
|
)
|
|
504
525
|
}
|
|
@@ -600,10 +621,15 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
600
621
|
const layout: "single" | "multi" | null =
|
|
601
622
|
layoutPref !== "auto" ? layoutPref : fileList ? (fileList.length > 1 ? "multi" : "single") : null
|
|
602
623
|
const heightValue = props.height ?? metaValue?.height
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
const
|
|
624
|
+
// Look: the tag's attributes (props) first, then `meta.json`.
|
|
625
|
+
const bleed = props.bleed ?? metaValue?.bleed
|
|
626
|
+
const propSurface = typeof props.surface === "string" ? cssToSurface(props.surface) : props.surface
|
|
627
|
+
const look: CanvasLook = {
|
|
628
|
+
className: props.canvasClassName ?? metaValue?.className,
|
|
629
|
+
surface: propSurface ?? metaValue?.surface,
|
|
630
|
+
}
|
|
631
|
+
const variantsWidth = props.variantsWidth ?? metaValue?.variantsWidth
|
|
632
|
+
const status = props.status ?? metaValue?.status
|
|
607
633
|
const [fullscreen, setFullscreen] = useState(false)
|
|
608
634
|
const href = mode === "resolver" ? resolver?.href?.(src, variant ? { variant } : undefined) : undefined
|
|
609
635
|
const baseId = useId()
|
|
@@ -637,6 +663,11 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
637
663
|
...(props.layout ? { layout: props.layout } : {}),
|
|
638
664
|
...(props.variants?.length ? { variants: props.variants } : {}),
|
|
639
665
|
...(props.viewport ? { viewport: props.viewport } : {}),
|
|
666
|
+
...(props.status ? { status: props.status } : {}),
|
|
667
|
+
...(props.bleed !== undefined ? { bleed: props.bleed } : {}),
|
|
668
|
+
...(props.canvasClassName ? { className: props.canvasClassName } : {}),
|
|
669
|
+
...(props.surface ? { surface: typeof props.surface === "string" ? props.surface : surfaceToCss(props.surface) } : {}),
|
|
670
|
+
...(props.variantsWidth !== undefined && props.variantsWidth !== "" ? { variantsWidth: String(props.variantsWidth) } : {}),
|
|
640
671
|
}
|
|
641
672
|
const copyDemo = () => inlineDemoMarkdown(node, fileList ?? [], metaValue)
|
|
642
673
|
|
|
@@ -676,8 +707,15 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
676
707
|
{runnable ? (
|
|
677
708
|
<div
|
|
678
709
|
className="docs-react-demo-preview docs-demo-single-preview"
|
|
679
|
-
// A hint, not a cap: in-page components may grow past it
|
|
680
|
-
|
|
710
|
+
// A hint, not a cap: in-page components may grow past it (no height: size to content;
|
|
711
|
+
// hosts may set a floor with --docs-demo-preview-min-height). An iframe needs a real height.
|
|
712
|
+
style={
|
|
713
|
+
preview.kind === "runtime"
|
|
714
|
+
? { height: cssLength(heightValue, "360px") }
|
|
715
|
+
: heightValue === undefined || heightValue === ""
|
|
716
|
+
? undefined
|
|
717
|
+
: { minHeight: cssLength(heightValue, "0px") }
|
|
718
|
+
}
|
|
681
719
|
>
|
|
682
720
|
{near ? <DemoCanvas src={src} preview={preview} variant={variant} bleed={bleed} look={look} /> : <Spinner />}
|
|
683
721
|
</div>
|
|
@@ -844,6 +882,11 @@ export function DemoBlock({ node }: { node: DemoNode }) {
|
|
|
844
882
|
files={node.files}
|
|
845
883
|
entry={node.entry}
|
|
846
884
|
streaming={node.open}
|
|
885
|
+
status={node.status}
|
|
886
|
+
bleed={node.bleed}
|
|
887
|
+
canvasClassName={node.className}
|
|
888
|
+
surface={node.surface}
|
|
889
|
+
variantsWidth={node.variantsWidth}
|
|
847
890
|
/>
|
|
848
891
|
)
|
|
849
892
|
}
|
package/src/demo/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
} from "./glob"
|
|
12
12
|
export type { GlobDemoResolverOptions } from "./glob"
|
|
13
13
|
export { defaultDemoMarkdown, inlineDemoMarkdown, inlineDemoNode, resolveDemosToMarkdown, toInlineFiles } from "./markdown"
|
|
14
|
+
export { cssToSurface, surfaceToCss } from "./surface"
|
|
14
15
|
export type { DemoMarkdownFormat, ResolveDemosOptions, ResolvedDemo } from "./markdown"
|
|
15
16
|
export type {
|
|
16
17
|
DemoComponent,
|
package/src/demo/markdown.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { DemoInlineFile, DemoNode } from "../gitbook/ast"
|
|
2
|
+
import { TAG_ATTRS } from "../gitbook/attrs"
|
|
2
3
|
import { flattenForPlainMarkdown } from "../gitbook/flatten"
|
|
3
4
|
import { demoBody, fenceTracker, parseBlocks, parseMarkdown } from "../gitbook/parse"
|
|
4
5
|
import { fenceFor, serializeBlocks, serializeMarkdown } from "../gitbook/serialize"
|
|
5
6
|
import type { DemoFile, DemoMeta, DemoResolver } from "./types"
|
|
6
7
|
import { languageForPath } from "./glob"
|
|
8
|
+
import { surfaceToCss } from "./surface"
|
|
7
9
|
|
|
8
10
|
export { fenceFor }
|
|
9
11
|
|
|
@@ -39,7 +41,7 @@ export interface ResolvedDemo {
|
|
|
39
41
|
title: string
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const TAG_RE =
|
|
44
|
+
const TAG_RE = new RegExp(String.raw`^(\s*)\{%\s*demo(\s${TAG_ATTRS})?\s*\/?%\}\s*$`)
|
|
43
45
|
|
|
44
46
|
function titleFor(src: string, node: DemoNode, meta: DemoMeta | undefined) {
|
|
45
47
|
return node.title ?? meta?.title ?? (src.split("/").pop() || src || "Demo")
|
|
@@ -77,7 +79,9 @@ export function toInlineFiles(files: DemoFile[]): DemoInlineFile[] {
|
|
|
77
79
|
/**
|
|
78
80
|
* A demo as a self-contained block-form `{% demo %}`: the node's attributes, with the
|
|
79
81
|
* folder's `meta.json` defaults folded in (so the block renders the same without the
|
|
80
|
-
* resolver), and
|
|
82
|
+
* resolver) — title, description, height, layout, variants, entry and the look fields
|
|
83
|
+
* `status`, `bleed`, `className`, `surface` (as CSS declarations) and `variantsWidth` —
|
|
84
|
+
* and `files` as inline fences. Tag attributes win over `meta.json` where both exist.
|
|
81
85
|
*/
|
|
82
86
|
export function inlineDemoNode(node: DemoNode, files: DemoFile[], meta?: DemoMeta): DemoNode {
|
|
83
87
|
const inline = toInlineFiles(files)
|
|
@@ -87,6 +91,11 @@ export function inlineDemoNode(node: DemoNode, files: DemoFile[], meta?: DemoMet
|
|
|
87
91
|
const layout = node.layout ?? meta?.layout
|
|
88
92
|
const variants = node.variants?.length ? node.variants : meta?.variants?.length ? meta.variants : undefined
|
|
89
93
|
const entry = node.entry ?? (meta?.entry && inline[0]?.path !== meta.entry ? meta.entry : undefined)
|
|
94
|
+
const status = node.status ?? meta?.status
|
|
95
|
+
const bleed = node.bleed ?? meta?.bleed
|
|
96
|
+
const className = node.className ?? meta?.className
|
|
97
|
+
const surface = node.surface ?? (meta?.surface ? surfaceToCss(meta.surface) : undefined)
|
|
98
|
+
const variantsWidth = node.variantsWidth ?? (meta?.variantsWidth === undefined || meta.variantsWidth === "" ? undefined : String(meta.variantsWidth))
|
|
90
99
|
return {
|
|
91
100
|
type: "demo",
|
|
92
101
|
src: node.src,
|
|
@@ -97,6 +106,11 @@ export function inlineDemoNode(node: DemoNode, files: DemoFile[], meta?: DemoMet
|
|
|
97
106
|
...(variants ? { variants } : {}),
|
|
98
107
|
...(node.viewport ? { viewport: node.viewport } : {}),
|
|
99
108
|
...(entry ? { entry } : {}),
|
|
109
|
+
...(status ? { status } : {}),
|
|
110
|
+
...(bleed === undefined ? {} : { bleed }),
|
|
111
|
+
...(className ? { className } : {}),
|
|
112
|
+
...(surface ? { surface } : {}),
|
|
113
|
+
...(variantsWidth ? { variantsWidth } : {}),
|
|
100
114
|
...(inline.length ? { files: inline } : {}),
|
|
101
115
|
}
|
|
102
116
|
}
|
|
@@ -123,7 +137,7 @@ export async function resolveDemosToMarkdown(
|
|
|
123
137
|
options: ResolveDemosOptions = {},
|
|
124
138
|
): Promise<string> {
|
|
125
139
|
// Plain output also flattens docstream-only presentation blocks (command boxes, titled tabs).
|
|
126
|
-
if (options.format === "plain" && /\{%\s*(?:command\b|tabs\s[
|
|
140
|
+
if (options.format === "plain" && /\{%\s*(?:command\b|tabs\s(?:[^%\n]|%(?!\}))*\btitle=)/.test(markdown)) {
|
|
127
141
|
markdown = serializeMarkdown(flattenForPlainMarkdown(parseMarkdown(markdown)))
|
|
128
142
|
}
|
|
129
143
|
const lines = markdown.split("\n")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CSSProperties } from "react"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A demo canvas's `surface` (inline style) has two spellings:
|
|
5
|
+
* - `meta.json`: an object, `{ "background": "var(--brand-50)", "--demo-gap": "12px", "padding": 12 }`;
|
|
6
|
+
* - the `{% demo surface="…" %}` attribute: CSS declarations,
|
|
7
|
+
* `background: var(--brand-50); --demo-gap: 12px; padding: 12`.
|
|
8
|
+
* Keys are kept as written (camelCase, kebab-case or custom properties).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A `meta.json` surface object as the declaration string the demo tag carries. */
|
|
12
|
+
export function surfaceToCss(surface: Record<string, string | number>): string {
|
|
13
|
+
return Object.entries(surface)
|
|
14
|
+
.filter(([key, value]) => key.trim() && value !== undefined && value !== null && String(value).trim() !== "")
|
|
15
|
+
.map(([key, value]) => `${key.trim()}: ${String(value).trim()}`)
|
|
16
|
+
.join("; ")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Split on `;` outside parentheses and quotes (`url("a;b")` stays whole). */
|
|
20
|
+
function declarations(css: string): string[] {
|
|
21
|
+
const out: string[] = []
|
|
22
|
+
let depth = 0
|
|
23
|
+
let quote = ""
|
|
24
|
+
let current = ""
|
|
25
|
+
for (const ch of css) {
|
|
26
|
+
if (quote) {
|
|
27
|
+
if (ch === quote) quote = ""
|
|
28
|
+
} else if (ch === '"' || ch === "'") quote = ch
|
|
29
|
+
else if (ch === "(") depth++
|
|
30
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
31
|
+
else if (ch === ";" && depth === 0) {
|
|
32
|
+
out.push(current)
|
|
33
|
+
current = ""
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
current += ch
|
|
37
|
+
}
|
|
38
|
+
out.push(current)
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tag's declaration string as a React style object. Custom properties keep their
|
|
44
|
+
* name; `kebab-case` becomes camelCase; plain numbers on ordinary properties become
|
|
45
|
+
* numbers (so `padding: 12` means 12px, as it does in `meta.json`).
|
|
46
|
+
*/
|
|
47
|
+
export function cssToSurface(css: string): Record<string, string | number> {
|
|
48
|
+
const style: Record<string, string | number> = {}
|
|
49
|
+
for (const declaration of declarations(css)) {
|
|
50
|
+
const colon = declaration.indexOf(":")
|
|
51
|
+
if (colon < 0) continue
|
|
52
|
+
const key = declaration.slice(0, colon).trim()
|
|
53
|
+
const value = declaration.slice(colon + 1).trim()
|
|
54
|
+
if (!key || !value) continue
|
|
55
|
+
if (key.startsWith("--")) {
|
|
56
|
+
style[key] = value
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const name = key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
|
|
60
|
+
style[name] = /^-?\d+(?:\.\d+)?$/.test(value) ? Number(value) : value
|
|
61
|
+
}
|
|
62
|
+
return style
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function surfaceStyle(surface: Record<string, string | number> | undefined): CSSProperties | undefined {
|
|
66
|
+
return surface && Object.keys(surface).length ? (surface as CSSProperties) : undefined
|
|
67
|
+
}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -16,8 +16,24 @@ export interface TextNode {
|
|
|
16
16
|
* (`[**x**](url)`, the default). Kept so the source round-trips byte-for-byte.
|
|
17
17
|
*/
|
|
18
18
|
linkInner?: true
|
|
19
|
+
/**
|
|
20
|
+
* How the source spelled this run's formatting, outermost → innermost, when that differs from the default
|
|
21
|
+
* output (`_italic_`, `**bold**`, `~~strike~~`, link around its emphasis, bare autolinks). Each entry is an
|
|
22
|
+
* emphasis marker, or a link form: `"link"` (`[x](url)`), `"<>"` (`<url>`), `"url"` (bare autolink).
|
|
23
|
+
* E.g. `**_x_**` → `["**", "_"]`, `***x***` → `["*", "**"]`, `*x*` → `["*"]`.
|
|
24
|
+
*
|
|
25
|
+
* A hint only — the boolean marks and `link` stay authoritative: entries for marks the run no longer has are
|
|
26
|
+
* ignored, marks it has without an entry get the default spelling and position. Absent on runs that
|
|
27
|
+
* serialize the default way and on programmatically created nodes.
|
|
28
|
+
*/
|
|
29
|
+
delims?: InlineDelimiter[]
|
|
19
30
|
}
|
|
20
31
|
|
|
32
|
+
/** An emphasis marker as written in the source. */
|
|
33
|
+
export type EmphasisDelimiter = "**" | "__" | "*" | "_" | "~~"
|
|
34
|
+
/** An entry of {@link TextNode.delims}: an emphasis marker or a link form. */
|
|
35
|
+
export type InlineDelimiter = EmphasisDelimiter | "link" | "<>" | "url"
|
|
36
|
+
|
|
21
37
|
/** Inline HTML image, optionally wrapped in a link — GitHub README badge style. */
|
|
22
38
|
export interface InlineImageNode {
|
|
23
39
|
type: "image"
|
|
@@ -67,7 +83,16 @@ export interface CodeBlockNode {
|
|
|
67
83
|
type: "code"
|
|
68
84
|
language: string | null
|
|
69
85
|
title: string | null
|
|
86
|
+
/**
|
|
87
|
+
* Show line numbers. Defaults (when the fence doesn't say) to on for titled fences and
|
|
88
|
+
* off for untitled ones; `lineNumbers="true"` / `"false"` overrides.
|
|
89
|
+
*/
|
|
70
90
|
lineNumbers: boolean
|
|
91
|
+
/**
|
|
92
|
+
* The source wrote `lineNumbers` (or `showLineNumbers`) explicitly: the serializer keeps
|
|
93
|
+
* the attribute even when it matches the default, so pages round-trip byte-for-byte.
|
|
94
|
+
*/
|
|
95
|
+
lineNumbersExplicit?: boolean
|
|
71
96
|
code: string
|
|
72
97
|
/** Run a React/JSX/TSX entry file in the optional almost-node preview. */
|
|
73
98
|
live?: boolean
|
|
@@ -201,8 +226,11 @@ export interface DemoInlineFile {
|
|
|
201
226
|
* A live demo: `{% demo src="<page>/<example>" %}`. With a host `DemoResolver`
|
|
202
227
|
* that knows `src`, the example folder is the authority for the component, its
|
|
203
228
|
* source files and its defaults; attributes here override the folder's
|
|
204
|
-
* `meta.json
|
|
205
|
-
*
|
|
229
|
+
* `meta.json` field by field (tag attribute first, then `meta.json`, then the built-in
|
|
230
|
+
* default) — including the look attributes `status`, `bleed`, `className`, `surface` and
|
|
231
|
+
* `variantsWidth`, which Copy page writes so the block renders the same without the resolver.
|
|
232
|
+
* The block form carries the files inline so the Markdown renders anywhere, resolver
|
|
233
|
+
* or not:
|
|
206
234
|
*
|
|
207
235
|
* {% demo src="composer/scroll-fab" title="Scroll → FAB" %}
|
|
208
236
|
* ```tsx title="index.tsx"
|
|
@@ -225,6 +253,19 @@ export interface DemoNode {
|
|
|
225
253
|
viewport?: DemoViewport
|
|
226
254
|
/** Entry file among the inline files (`entry="App.tsx"`). Defaults to the first file. */
|
|
227
255
|
entry?: string
|
|
256
|
+
/** Short status shown in the demo header (`status="Live"`). */
|
|
257
|
+
status?: string
|
|
258
|
+
/** Drop the padded preview surface (`bleed="true"`) for demos with their own full-bleed frame. */
|
|
259
|
+
bleed?: boolean
|
|
260
|
+
/** Extra class on the preview canvas (`className="brand-surface"`). */
|
|
261
|
+
className?: string
|
|
262
|
+
/**
|
|
263
|
+
* Inline style for the preview canvas as CSS declarations, kept verbatim:
|
|
264
|
+
* `surface="background: var(--brand-50); --demo-gap: 12px"`.
|
|
265
|
+
*/
|
|
266
|
+
surface?: string
|
|
267
|
+
/** Width of the variant switch, kept verbatim (`"240"`, `"16rem"`). */
|
|
268
|
+
variantsWidth?: string
|
|
228
269
|
/** Files carried inline by the block form, in document order. */
|
|
229
270
|
files?: DemoInlineFile[]
|
|
230
271
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tag and fence attribute values (`{% demo title="…" %}`, ```` ```ts title="…" ````).
|
|
3
|
+
*
|
|
4
|
+
* Values are double-quoted. On serialize a backslash becomes `\\`, a double quote `\"`,
|
|
5
|
+
* and the tag terminator `%}` becomes `%\}` — so a value can never end its tag early or
|
|
6
|
+
* truncate at a quote. On parse `\\`, `\"` and `\}` are unescaped; any other backslash is
|
|
7
|
+
* kept literally (so `C:\temp` written by hand still reads as written).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Escape one attribute value for a double-quoted `name="…"` pair. */
|
|
11
|
+
export function escapeAttr(value: string): string {
|
|
12
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%\}/g, "%\\}")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Undo `escapeAttr`. */
|
|
16
|
+
export function unescapeAttr(raw: string): string {
|
|
17
|
+
return raw.replace(/\\(["\\}])/g, "$1")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ` name="value"` with the value escaped (leading space included). */
|
|
21
|
+
export function attr(name: string, value: string | number | boolean): string {
|
|
22
|
+
return ` ${name}="${escapeAttr(String(value))}"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every `name="value"` pair in `raw` (escapes honoured, values unescaped). */
|
|
26
|
+
export function parseAttrs(raw: string | undefined): Record<string, string> {
|
|
27
|
+
const attrs: Record<string, string> = {}
|
|
28
|
+
if (!raw) return attrs
|
|
29
|
+
for (const m of raw.matchAll(/([\w-]+)="((?:[^"\\]|\\.)*)"/g)) {
|
|
30
|
+
attrs[m[1]] = unescapeAttr(m[2])
|
|
31
|
+
}
|
|
32
|
+
return attrs
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The attribute part of a `{% name … %}` tag, as a regex source: anything up to the
|
|
37
|
+
* closing `%}` (a lone `%` is fine; `%}` inside a value is always escaped as `%\}`).
|
|
38
|
+
*/
|
|
39
|
+
export const TAG_ATTRS = String.raw`(?:[^%\n]|%(?!\}))*?`
|
package/src/gitbook/flatten.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function flattenBlocks(blocks: Block[]): Block[] {
|
|
|
19
19
|
return blocks.flatMap((b): Block[] => {
|
|
20
20
|
switch (b.type) {
|
|
21
21
|
case "command":
|
|
22
|
-
return [{ type: "code", language: "sh", title: null, lineNumbers:
|
|
22
|
+
return [{ type: "code", language: "sh", title: null, lineNumbers: false, code: b.command }]
|
|
23
23
|
case "tabs":
|
|
24
24
|
if (b.title) {
|
|
25
25
|
return [
|
package/src/gitbook/index.ts
CHANGED
|
@@ -1,9 +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 { escapeAttr, unescapeAttr } from "./attrs"
|
|
4
5
|
export { closesFence, demoBody, fenceOpener, fenceTracker, parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
|
|
5
6
|
export { fenceFor, serializeBlocks, serializeDemoFile, serializeMarkdown } from "./serialize"
|
|
6
|
-
export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
|
|
7
|
+
export { defaultInlineDelims, footnoteDefinitions, inlineDelims, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
|
|
7
8
|
export { PACKAGE_MANAGERS, packageManagerCommands } from "./package-managers"
|
|
8
9
|
export { flattenBlocks, flattenForPlainMarkdown } from "./flatten"
|
|
9
10
|
export { documentOutline, slugify } from "./outline"
|