@brett_lamy/docstream 1.2.1 → 1.2.2

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.2",
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
+ }
@@ -67,7 +67,16 @@ export interface CodeBlockNode {
67
67
  type: "code"
68
68
  language: string | null
69
69
  title: string | null
70
+ /**
71
+ * Show line numbers. Defaults (when the fence doesn't say) to on for titled fences and
72
+ * off for untitled ones; `lineNumbers="true"` / `"false"` overrides.
73
+ */
70
74
  lineNumbers: boolean
75
+ /**
76
+ * The source wrote `lineNumbers` (or `showLineNumbers`) explicitly: the serializer keeps
77
+ * the attribute even when it matches the default, so pages round-trip byte-for-byte.
78
+ */
79
+ lineNumbersExplicit?: boolean
71
80
  code: string
72
81
  /** Run a React/JSX/TSX entry file in the optional almost-node preview. */
73
82
  live?: boolean
@@ -201,8 +210,11 @@ export interface DemoInlineFile {
201
210
  * A live demo: `{% demo src="<page>/<example>" %}`. With a host `DemoResolver`
202
211
  * that knows `src`, the example folder is the authority for the component, its
203
212
  * 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:
213
+ * `meta.json` field by field (tag attribute first, then `meta.json`, then the built-in
214
+ * default) — including the look attributes `status`, `bleed`, `className`, `surface` and
215
+ * `variantsWidth`, which Copy page writes so the block renders the same without the resolver.
216
+ * The block form carries the files inline so the Markdown renders anywhere, resolver
217
+ * or not:
206
218
  *
207
219
  * {% demo src="composer/scroll-fab" title="Scroll → FAB" %}
208
220
  * ```tsx title="index.tsx"
@@ -225,6 +237,19 @@ export interface DemoNode {
225
237
  viewport?: DemoViewport
226
238
  /** Entry file among the inline files (`entry="App.tsx"`). Defaults to the first file. */
227
239
  entry?: string
240
+ /** Short status shown in the demo header (`status="Live"`). */
241
+ status?: string
242
+ /** Drop the padded preview surface (`bleed="true"`) for demos with their own full-bleed frame. */
243
+ bleed?: boolean
244
+ /** Extra class on the preview canvas (`className="brand-surface"`). */
245
+ className?: string
246
+ /**
247
+ * Inline style for the preview canvas as CSS declarations, kept verbatim:
248
+ * `surface="background: var(--brand-50); --demo-gap: 12px"`.
249
+ */
250
+ surface?: string
251
+ /** Width of the variant switch, kept verbatim (`"240"`, `"16rem"`). */
252
+ variantsWidth?: string
228
253
  /** Files carried inline by the block form, in document order. */
229
254
  files?: DemoInlineFile[]
230
255
  /**
@@ -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,6 +1,7 @@
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
7
  export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Block,
3
+ CodeBlockNode,
3
4
  ColumnNode,
4
5
  CommandNode,
5
6
  DemoInlineFile,
@@ -15,6 +16,7 @@ import type {
15
16
  TabNode,
16
17
  UpdateNode,
17
18
  } from "./ast"
19
+ import { TAG_ATTRS, parseAttrs } from "./attrs"
18
20
  import { footnoteDefinitions, parseInline, plainText, refDefinitions } from "./inline"
19
21
 
20
22
  // Minimal HTML-inline → markdown-inline bridge for HTML table cells.
@@ -61,22 +63,23 @@ function splitTableRow(row: string): string[] {
61
63
  return cells
62
64
  }
63
65
 
64
- const TEMPLATE_RE = /^\s*\{%\s*(\S+?)(\s+[^%]*?)?\s*%\}\s*$/
65
-
66
- function parseAttrs(raw: string | undefined): Record<string, string> {
67
- const attrs: Record<string, string> = {}
68
- if (!raw) return attrs
69
- for (const m of raw.matchAll(/([\w-]+)="([^"]*)"/g)) {
70
- attrs[m[1]] = m[2]
71
- }
72
- return attrs
73
- }
66
+ const TEMPLATE_RE = new RegExp(String.raw`^\s*\{%\s*(\S+?)(\s+${TAG_ATTRS})?\s*%\}\s*$`)
74
67
 
75
68
  function booleanAttr(attrs: Record<string, string>, key: string): boolean | undefined {
76
69
  if (!(key in attrs)) return undefined
77
70
  return attrs[key] !== "false"
78
71
  }
79
72
 
73
+ /**
74
+ * A fence's line numbers: `lineNumbers` / `showLineNumbers` when written (remembered as
75
+ * explicit so the attribute round-trips even when it equals the default), otherwise on for
76
+ * titled (file) fences and off for untitled ones.
77
+ */
78
+ function lineNumbersFrom(attrs: Record<string, string>, titled: boolean): Pick<CodeBlockNode, "lineNumbers" | "lineNumbersExplicit"> {
79
+ const written = booleanAttr(attrs, "lineNumbers") ?? booleanAttr(attrs, "showLineNumbers")
80
+ return written === undefined ? { lineNumbers: titled } : { lineNumbers: written, lineNumbersExplicit: true }
81
+ }
82
+
80
83
  function positiveNumberAttr(attrs: Record<string, string>, key: string): number | undefined {
81
84
  if (!(key in attrs)) return undefined
82
85
  const value = Number(attrs[key])
@@ -224,7 +227,7 @@ export function parseDemoVariants(raw: string): DemoVariantOption[] {
224
227
  }
225
228
 
226
229
  /** `{% command %}npm install x{% endcommand %}` on one line. */
227
- const COMMAND_ONE_LINE_RE = /^\s*\{%\s*command(\s[^%]*?)?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$/
230
+ const COMMAND_ONE_LINE_RE = new RegExp(String.raw`^\s*\{%\s*command(\s${TAG_ATTRS})?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$`)
228
231
 
229
232
  function commandNode(attrs: Record<string, string>, raw: string): CommandNode {
230
233
  const overrides: NonNullable<CommandNode["overrides"]> = {}
@@ -326,6 +329,11 @@ function parseDemoTag(attrs: Record<string, string>): DemoNode {
326
329
  ...(variants.length ? { variants } : {}),
327
330
  ...((DEMO_VIEWPORTS as string[]).includes(attrs.viewport) ? { viewport: attrs.viewport as DemoViewport } : {}),
328
331
  ...(attrs.entry ? { entry: attrs.entry } : {}),
332
+ ...(attrs.status ? { status: attrs.status } : {}),
333
+ ...(booleanAttr(attrs, "bleed") === undefined ? {} : { bleed: booleanAttr(attrs, "bleed") }),
334
+ ...(attrs.className ? { className: attrs.className } : {}),
335
+ ...(attrs.surface?.trim() ? { surface: attrs.surface } : {}),
336
+ ...(attrs.variantsWidth ? { variantsWidth: attrs.variantsWidth } : {}),
329
337
  }
330
338
  }
331
339
 
@@ -435,7 +443,7 @@ export function parseBlocks(lines: string[]): Block[] {
435
443
  const code = inner.find((b) => b.type === "code")
436
444
  if (code && code.type === "code") {
437
445
  code.title = tag.attrs.title ?? null
438
- code.lineNumbers = booleanAttr(tag.attrs, "lineNumbers") ?? booleanAttr(tag.attrs, "showLineNumbers") ?? true
446
+ Object.assign(code, lineNumbersFrom(tag.attrs, code.title !== null))
439
447
  code.live = tag.attrs.live === "true"
440
448
  code.entry = tag.attrs.entry ?? null
441
449
  blocks.push(code)
@@ -655,7 +663,9 @@ export function parseBlocks(lines: string[]): Block[] {
655
663
  const fence = trimmed.match(/^(`{3,}|~{3,})([^\s`]*)?(?:\s+(.*?))?\s*$/)
656
664
  if (fence) {
657
665
  flushParagraph()
658
- const attrs = parseAttrs(fence[3])
666
+ // ```` ```title="x" ```` (attributes, no language): the first word is not a language.
667
+ const bareAttrs = !!fence[2]?.includes("=")
668
+ const attrs = parseAttrs(bareAttrs ? [fence[2], fence[3]].filter(Boolean).join(" ") : fence[3])
659
669
  const code: string[] = []
660
670
  i++
661
671
  while (i < lines.length && !lines[i].trim().startsWith(fence[1])) {
@@ -665,9 +675,9 @@ export function parseBlocks(lines: string[]): Block[] {
665
675
  i++
666
676
  blocks.push({
667
677
  type: "code",
668
- language: fence[2] || null,
678
+ language: (!bareAttrs && fence[2]) || null,
669
679
  title: attrs.title ?? null,
670
- lineNumbers: booleanAttr(attrs, "lineNumbers") ?? booleanAttr(attrs, "showLineNumbers") ?? true,
680
+ ...lineNumbersFrom(attrs, attrs.title !== undefined),
671
681
  code: code.join("\n"),
672
682
  ...(booleanAttr(attrs, "live") === undefined ? {} : { live: booleanAttr(attrs, "live") }),
673
683
  ...(attrs.entry ? { entry: attrs.entry } : {}),
@@ -691,7 +701,7 @@ export function parseBlocks(lines: string[]): Block[] {
691
701
  code.push(lines[i].slice(4))
692
702
  i++
693
703
  }
694
- blocks.push({ type: "code", language: null, title: null, lineNumbers: true, code: code.join("\n") })
704
+ blocks.push({ type: "code", language: null, title: null, lineNumbers: false, code: code.join("\n") })
695
705
  continue
696
706
  }
697
707
 
@@ -1,4 +1,5 @@
1
1
  import type { Block, DemoInlineFile, DemoNode, DocumentNode, Inline, ListItemNode } from "./ast"
2
+ import { attr } from "./attrs"
2
3
  import { serializeInline, serializeReference } from "./inline"
3
4
 
4
5
  export function serializeMarkdown(doc: DocumentNode): string {
@@ -34,36 +35,38 @@ function serializeBlock(b: Block): string {
34
35
  }
35
36
 
36
37
  case "code": {
38
+ // Line numbers default to on for titled fences, off for untitled ones.
39
+ const numbersByDefault = b.title !== null && b.title !== undefined && b.title !== ""
37
40
  const attrs = [
38
- b.title ? `title="${b.title}"` : "",
39
- !b.lineNumbers ? `lineNumbers="false"` : "",
40
- b.live ? `live="true"` : "",
41
- b.entry ? `entry="${b.entry}"` : "",
42
- b.collapsedCodeLines ? `collapsedCodeLines="${b.collapsedCodeLines}"` : "",
43
- b.expandedCodeLines ? `expandedCodeLines="${b.expandedCodeLines}"` : "",
44
- ].filter(Boolean)
45
- const info = [b.language ?? "", ...attrs].filter(Boolean).join(" ")
41
+ b.title ? attr("title", b.title) : "",
42
+ b.lineNumbers !== numbersByDefault || b.lineNumbersExplicit ? attr("lineNumbers", b.lineNumbers) : "",
43
+ b.live ? attr("live", true) : "",
44
+ b.entry ? attr("entry", b.entry) : "",
45
+ b.collapsedCodeLines ? attr("collapsedCodeLines", b.collapsedCodeLines) : "",
46
+ b.expandedCodeLines ? attr("expandedCodeLines", b.expandedCodeLines) : "",
47
+ ].join("")
48
+ const info = `${b.language ?? ""}${b.language ? attrs : attrs.trimStart()}`
46
49
  return `\`\`\`${info}\n${b.code}\n\`\`\``
47
50
  }
48
51
 
49
52
  case "hint":
50
- return `{% hint style="${b.style}" %}\n${serializeBlocks(b.children)}\n{% endhint %}`
53
+ return `{% hint${attr("style", b.style)} %}\n${serializeBlocks(b.children)}\n{% endhint %}`
51
54
 
52
55
  case "tabs": {
53
56
  const attrs = [
54
- b.title ? ` title="${b.title}"` : "",
55
- b.title && b.level && b.level !== 2 ? ` level="${b.level}"` : "",
56
- b.sync ? ` sync="${b.sync}"` : "",
57
+ b.title ? attr("title", b.title) : "",
58
+ b.title && b.level && b.level !== 2 ? attr("level", b.level) : "",
59
+ b.sync ? attr("sync", b.sync) : "",
57
60
  ].join("")
58
61
  return `{% tabs${attrs} %}\n${b.tabs
59
- .map((t) => `{% tab title="${t.title}" %}\n${serializeBlocks(t.children)}\n{% endtab %}`)
62
+ .map((t) => `{% tab${attr("title", t.title)} %}\n${serializeBlocks(t.children)}\n{% endtab %}`)
60
63
  .join("\n\n")}\n{% endtabs %}`
61
64
  }
62
65
 
63
66
  case "command": {
64
67
  const attrs = [
65
- ...(["pnpm", "yarn", "bun"] as const).map((pm) => (b.overrides?.[pm] ? ` ${pm}="${b.overrides[pm]}"` : "")),
66
- b.sync && b.sync !== "pm" ? ` sync="${b.sync}"` : "",
68
+ ...(["pnpm", "yarn", "bun"] as const).map((pm) => (b.overrides?.[pm] ? attr(pm, b.overrides[pm]) : "")),
69
+ b.sync && b.sync !== "pm" ? attr("sync", b.sync) : "",
67
70
  ].join("")
68
71
  return b.command.includes("\n")
69
72
  ? `{% command${attrs} %}\n${b.command}\n{% endcommand %}`
@@ -83,43 +86,48 @@ function serializeBlock(b: Block): string {
83
86
 
84
87
  case "embed": {
85
88
  const attrs = [
86
- ` url="${b.url}"`,
87
- b.title ? ` title="${b.title}"` : "",
88
- b.poster ? ` poster="${b.poster}"` : "",
89
- b.autoplay === undefined ? "" : ` autoplay="${b.autoplay}"`,
90
- b.loop === undefined ? "" : ` loop="${b.loop}"`,
91
- b.muted === undefined ? "" : ` muted="${b.muted}"`,
92
- b.controls === undefined ? "" : ` controls="${b.controls}"`,
89
+ attr("url", b.url),
90
+ b.title ? attr("title", b.title) : "",
91
+ b.poster ? attr("poster", b.poster) : "",
92
+ b.autoplay === undefined ? "" : attr("autoplay", b.autoplay),
93
+ b.loop === undefined ? "" : attr("loop", b.loop),
94
+ b.muted === undefined ? "" : attr("muted", b.muted),
95
+ b.controls === undefined ? "" : attr("controls", b.controls),
93
96
  ].join("")
94
97
  return `{% embed${attrs} %}`
95
98
  }
96
99
 
97
100
  case "content-ref":
98
- return `{% content-ref url="${b.url}" %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
101
+ return `{% content-ref${attr("url", b.url)} %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
99
102
 
100
103
  case "source-ref": {
101
104
  const attrs = [
102
- ` mount="${b.mount}"`,
103
- ` path="${b.path}"`,
104
- ` export="${b.exportName}"`,
105
- ` kind="${b.kind}"`,
106
- b.title ? ` title="${b.title}"` : "",
105
+ attr("mount", b.mount),
106
+ attr("path", b.path),
107
+ attr("export", b.exportName),
108
+ attr("kind", b.kind),
109
+ b.title ? attr("title", b.title) : "",
107
110
  ].join("")
108
111
  return `{% source-ref${attrs} %}`
109
112
  }
110
113
 
111
114
  case "demo": {
112
115
  const attrs = [
113
- ` src="${b.src}"`,
114
- b.title ? ` title="${b.title}"` : "",
115
- b.description ? ` description="${b.description}"` : "",
116
- b.height ? ` height="${b.height}"` : "",
117
- b.layout ? ` layout="${b.layout}"` : "",
116
+ attr("src", b.src),
117
+ b.title ? attr("title", b.title) : "",
118
+ b.description ? attr("description", b.description) : "",
119
+ b.height ? attr("height", b.height) : "",
120
+ b.layout ? attr("layout", b.layout) : "",
118
121
  b.variants?.length
119
- ? ` variants="${b.variants.map((v) => (v.label === v.id ? v.id : `${v.id}:${v.label}`)).join(",")}"`
122
+ ? attr("variants", b.variants.map((v) => (v.label === v.id ? v.id : `${v.id}:${v.label}`)).join(","))
120
123
  : "",
121
- b.viewport ? ` viewport="${b.viewport}"` : "",
122
- b.entry ? ` entry="${b.entry}"` : "",
124
+ b.viewport ? attr("viewport", b.viewport) : "",
125
+ b.entry ? attr("entry", b.entry) : "",
126
+ b.status ? attr("status", b.status) : "",
127
+ b.bleed === undefined ? "" : attr("bleed", b.bleed),
128
+ b.className ? attr("className", b.className) : "",
129
+ b.surface ? attr("surface", b.surface) : "",
130
+ b.variantsWidth ? attr("variantsWidth", b.variantsWidth) : "",
123
131
  ].join("")
124
132
  return `{% demo${attrs} %}${serializeDemoFiles(b)}`
125
133
  }
@@ -167,18 +175,18 @@ function serializeBlock(b: Block): string {
167
175
  return `$$\n${b.formula}\n$$`
168
176
 
169
177
  case "updates":
170
- return `{% updates${b.format ? ` format="${b.format}"` : ""} %}\n${b.updates
178
+ return `{% updates${b.format ? attr("format", b.format) : ""} %}\n${b.updates
171
179
  .map(
172
180
  (u) =>
173
- `{% update date="${u.date}" %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
181
+ `{% update${attr("date", u.date)} %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
174
182
  )
175
183
  .join("\n\n")}\n{% endupdates %}`
176
184
 
177
185
  case "openapi-operation": {
178
186
  const attrs = [
179
- b.spec ? ` spec="${b.spec}"` : "",
180
- b.path ? ` path="${b.path}"` : "",
181
- b.method ? ` method="${b.method}"` : "",
187
+ b.spec ? attr("spec", b.spec) : "",
188
+ b.path ? attr("path", b.path) : "",
189
+ b.method ? attr("method", b.method) : "",
182
190
  ].join("")
183
191
  const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
184
192
  return `{% openapi-operation${attrs} %}${inner}\n{% endopenapi-operation %}`
@@ -225,7 +233,7 @@ export function fenceFor(content: string): string {
225
233
  /** One inline demo file as a titled fence. */
226
234
  export function serializeDemoFile(file: DemoInlineFile): string {
227
235
  const fence = fenceFor(file.content)
228
- return `${fence}${file.language ?? ""} title="${file.path}"\n${file.content}\n${fence}`
236
+ return `${fence}${file.language ?? ""}${attr("title", file.path)}\n${file.content}\n${fence}`
229
237
  }
230
238
 
231
239
  /** The body of a block-form `{% demo %}`: files as titled fences, then `{% enddemo %}`. Empty without files. */
package/src/styles.css CHANGED
@@ -1530,9 +1530,13 @@
1530
1530
  margin-bottom: 12px;
1531
1531
  }
1532
1532
 
1533
- :is([data-docstream], .docs-article) .docs-tabs-section-head .docs-tabs-section-title {
1533
+ /* The heading sits centred on the switch's row: no block margin, padding or rule of its own
1534
+ (page heading rules — docstream's and a host's `h2 { padding-top }` — would push it off
1535
+ centre; 0,4,0 outranks them). */
1536
+ :is([data-docstream], .docs-article) .docs-tabs-section > .docs-tabs-section-head > .docs-tabs-section-title {
1537
+ align-self: center;
1534
1538
  margin: 0;
1535
- padding-bottom: 0;
1539
+ padding-block: 0;
1536
1540
  border-bottom: 0;
1537
1541
  scroll-margin-top: 80px;
1538
1542
  }
@@ -1543,15 +1547,18 @@
1543
1547
  gap: 10px;
1544
1548
  }
1545
1549
 
1546
- .docs-tabs-section-body > [data-docstream-blocks] > * {
1550
+ /* One 10px rhythm inside a section. These must outrank the generic block spacing
1551
+ (`:is([data-docstream], .docs-article) [data-docstream-blocks] > :is(.docs-code, …)`,
1552
+ headings and demos, specificity 0,3,0), hence the extra `.docs-tabs-section >` (0,4,0). */
1553
+ :is([data-docstream], .docs-article) .docs-tabs-section > .docs-tabs-section-body > [data-docstream-blocks] > * {
1547
1554
  margin-block: 0 10px;
1548
1555
  }
1549
1556
 
1550
- .docs-tabs-section-body > [data-docstream-blocks] > :last-child {
1557
+ :is([data-docstream], .docs-article) .docs-tabs-section > .docs-tabs-section-body > [data-docstream-blocks] > :last-child {
1551
1558
  margin-bottom: 0;
1552
1559
  }
1553
1560
 
1554
- .docs-tabs-section-body > [data-docstream-blocks] > p {
1561
+ :is([data-docstream], .docs-article) .docs-tabs-section > .docs-tabs-section-body > [data-docstream-blocks] > p {
1555
1562
  color: var(--gb-muted-foreground);
1556
1563
  font-size: 0.93em;
1557
1564
  }
@@ -1945,35 +1952,36 @@
1945
1952
  opacity: 1;
1946
1953
  }
1947
1954
 
1948
- /* Block-level demo roots fill the width (place-items: center would shrink them to their
1949
- content); content is centred vertically, and intrinsically sized roots horizontally. */
1955
+ /* The canvas is block flow, so a block-level demo root lays out exactly as it would on a
1956
+ page: it fills the width, and `max-width: 480px; margin: 0 auto` centres it at 480px (a
1957
+ grid or flex canvas would shrink it to its content). Intrinsically sized roots — inline /
1958
+ inline-block / inline-flex elements such as a lone button, badge or image — sit on the
1959
+ canvas's centred line box. Content is centred vertically (`align-content` on a block
1960
+ container). The canvas's `text-align: center` stops at its children: the reset below has
1961
+ zero specificity, so any rule of the demo's own wins. */
1950
1962
  .docs-demo-canvas {
1951
- display: grid;
1963
+ display: block;
1952
1964
  align-content: center;
1953
- justify-items: stretch;
1954
1965
  box-sizing: border-box;
1955
1966
  min-height: 100%;
1956
1967
  padding: 28px 24px;
1957
1968
  color: var(--foreground, var(--gb-panel-foreground));
1969
+ text-align: center;
1958
1970
  }
1959
1971
 
1960
- .docs-demo-canvas > * {
1961
- min-width: 0;
1972
+ :where(.docs-demo-canvas) > * {
1962
1973
  max-width: 100%;
1963
- }
1964
-
1965
- .docs-demo-canvas > :where(button, a, img, svg, video, canvas, input, select, textarea, label, span, code) {
1966
- justify-self: center;
1974
+ text-align: start;
1967
1975
  }
1968
1976
 
1969
1977
  .docs-demo-canvas-bleed {
1970
- display: block;
1971
- place-items: normal;
1978
+ align-content: normal;
1972
1979
  height: 100%;
1973
1980
  padding: 0;
1981
+ text-align: start;
1974
1982
  }
1975
1983
 
1976
- .docs-demo-canvas-bleed > * {
1984
+ :where(.docs-demo-canvas-bleed) > * {
1977
1985
  max-width: none;
1978
1986
  }
1979
1987
 
@@ -2241,9 +2249,11 @@
2241
2249
  background: var(--gb-hover);
2242
2250
  }
2243
2251
 
2252
+ /* Sizes to its content; a `height` in meta or on the tag sets a minimum (inline style), and
2253
+ hosts may set a floor for every single-file preview with --docs-demo-preview-min-height. */
2244
2254
  .docs-demo-single-preview {
2245
2255
  display: grid;
2246
- min-height: 120px;
2256
+ min-height: var(--docs-demo-preview-min-height, 0px);
2247
2257
  background: var(--background, var(--gb-panel));
2248
2258
  }
2249
2259