@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 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) and the stage height for the multi-file viewer. `className` / `surface`
293
- (a style object, CSS custom properties welcome) style the preview canvas,
294
- `status` shows a small label in the header ("Live", "Needs network"), and
295
- `variantsWidth` sizes the variant switch. Block-level demo roots fill the canvas
296
- width; intrinsically sized ones (a button, an image) are centred.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 className="docs-demo-canvas docs-demo-canvas-bleed docs-demo-canvas-runtime" data-variant={variant}>
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 as CSSProperties | undefined}
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: typeof width === "number" ? `${width}px` : 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
- const bleed = metaValue?.bleed
604
- const look: CanvasLook | undefined = metaValue
605
- const variantsWidth = metaValue?.variantsWidth
606
- const status = metaValue?.status
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. An iframe needs a real height.
680
- style={preview.kind === "runtime" ? { height: cssLength(heightValue, "360px") } : { minHeight: cssLength(heightValue, "120px") }}
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,
@@ -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 = /^(\s*)\{%\s*demo(\s[^%]*?)?\s*\/?%\}\s*$/
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 `files` as inline fences.
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[^%]*\btitle=)/.test(markdown)) {
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
+ }
@@ -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`. The block form carries the files inline so the Markdown renders
205
- * anywhere, resolver or not:
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]|%(?!\}))*?`
@@ -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: true, code: b.command }]
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 [
@@ -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"