@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.
@@ -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.
@@ -37,20 +39,31 @@ function splitTableRow(row: string): string[] {
37
39
  const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "")
38
40
  const cells: string[] = []
39
41
  let cur = ""
40
- let inCode = false
41
42
  for (let k = 0; k < trimmed.length; k++) {
42
43
  const ch = trimmed[k]
43
- if (ch === "\\" && trimmed[k + 1] === "|") {
44
- cur += "|"
44
+ if (ch === "\\") {
45
+ // `\|` is a literal pipe (in code spans too); any other escape passes through for the inline parser.
46
+ cur += trimmed[k + 1] === "|" ? "|" : trimmed.slice(k, k + 2)
45
47
  k++
46
48
  continue
47
49
  }
48
50
  if (ch === "`") {
49
- inCode = !inCode
50
- cur += ch
51
+ // A code span (a backtick run closed by one of the same length) keeps its pipes; a lone run is literal.
52
+ let n = 1
53
+ while (trimmed[k + n] === "`") n++
54
+ const fence = "`".repeat(n)
55
+ let end = trimmed.indexOf(fence, k + n)
56
+ while (end !== -1 && (trimmed[end + n] === "`" || trimmed[end - 1] === "`")) {
57
+ let e = end
58
+ while (trimmed[e] === "`") e++
59
+ end = trimmed.indexOf(fence, e)
60
+ }
61
+ const stop = end === -1 ? k + n : end + n
62
+ cur += trimmed.slice(k, stop).replace(/\\\|/g, "|")
63
+ k = stop - 1
51
64
  continue
52
65
  }
53
- if (ch === "|" && !inCode) {
66
+ if (ch === "|") {
54
67
  cells.push(cur)
55
68
  cur = ""
56
69
  continue
@@ -61,22 +74,23 @@ function splitTableRow(row: string): string[] {
61
74
  return cells
62
75
  }
63
76
 
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
- }
77
+ const TEMPLATE_RE = new RegExp(String.raw`^\s*\{%\s*(\S+?)(\s+${TAG_ATTRS})?\s*%\}\s*$`)
74
78
 
75
79
  function booleanAttr(attrs: Record<string, string>, key: string): boolean | undefined {
76
80
  if (!(key in attrs)) return undefined
77
81
  return attrs[key] !== "false"
78
82
  }
79
83
 
84
+ /**
85
+ * A fence's line numbers: `lineNumbers` / `showLineNumbers` when written (remembered as
86
+ * explicit so the attribute round-trips even when it equals the default), otherwise on for
87
+ * titled (file) fences and off for untitled ones.
88
+ */
89
+ function lineNumbersFrom(attrs: Record<string, string>, titled: boolean): Pick<CodeBlockNode, "lineNumbers" | "lineNumbersExplicit"> {
90
+ const written = booleanAttr(attrs, "lineNumbers") ?? booleanAttr(attrs, "showLineNumbers")
91
+ return written === undefined ? { lineNumbers: titled } : { lineNumbers: written, lineNumbersExplicit: true }
92
+ }
93
+
80
94
  function positiveNumberAttr(attrs: Record<string, string>, key: string): number | undefined {
81
95
  if (!(key in attrs)) return undefined
82
96
  const value = Number(attrs[key])
@@ -224,7 +238,7 @@ export function parseDemoVariants(raw: string): DemoVariantOption[] {
224
238
  }
225
239
 
226
240
  /** `{% command %}npm install x{% endcommand %}` on one line. */
227
- const COMMAND_ONE_LINE_RE = /^\s*\{%\s*command(\s[^%]*?)?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$/
241
+ const COMMAND_ONE_LINE_RE = new RegExp(String.raw`^\s*\{%\s*command(\s${TAG_ATTRS})?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$`)
228
242
 
229
243
  function commandNode(attrs: Record<string, string>, raw: string): CommandNode {
230
244
  const overrides: NonNullable<CommandNode["overrides"]> = {}
@@ -326,6 +340,11 @@ function parseDemoTag(attrs: Record<string, string>): DemoNode {
326
340
  ...(variants.length ? { variants } : {}),
327
341
  ...((DEMO_VIEWPORTS as string[]).includes(attrs.viewport) ? { viewport: attrs.viewport as DemoViewport } : {}),
328
342
  ...(attrs.entry ? { entry: attrs.entry } : {}),
343
+ ...(attrs.status ? { status: attrs.status } : {}),
344
+ ...(booleanAttr(attrs, "bleed") === undefined ? {} : { bleed: booleanAttr(attrs, "bleed") }),
345
+ ...(attrs.className ? { className: attrs.className } : {}),
346
+ ...(attrs.surface?.trim() ? { surface: attrs.surface } : {}),
347
+ ...(attrs.variantsWidth ? { variantsWidth: attrs.variantsWidth } : {}),
329
348
  }
330
349
  }
331
350
 
@@ -435,7 +454,7 @@ export function parseBlocks(lines: string[]): Block[] {
435
454
  const code = inner.find((b) => b.type === "code")
436
455
  if (code && code.type === "code") {
437
456
  code.title = tag.attrs.title ?? null
438
- code.lineNumbers = booleanAttr(tag.attrs, "lineNumbers") ?? booleanAttr(tag.attrs, "showLineNumbers") ?? true
457
+ Object.assign(code, lineNumbersFrom(tag.attrs, code.title !== null))
439
458
  code.live = tag.attrs.live === "true"
440
459
  code.entry = tag.attrs.entry ?? null
441
460
  blocks.push(code)
@@ -655,7 +674,9 @@ export function parseBlocks(lines: string[]): Block[] {
655
674
  const fence = trimmed.match(/^(`{3,}|~{3,})([^\s`]*)?(?:\s+(.*?))?\s*$/)
656
675
  if (fence) {
657
676
  flushParagraph()
658
- const attrs = parseAttrs(fence[3])
677
+ // ```` ```title="x" ```` (attributes, no language): the first word is not a language.
678
+ const bareAttrs = !!fence[2]?.includes("=")
679
+ const attrs = parseAttrs(bareAttrs ? [fence[2], fence[3]].filter(Boolean).join(" ") : fence[3])
659
680
  const code: string[] = []
660
681
  i++
661
682
  while (i < lines.length && !lines[i].trim().startsWith(fence[1])) {
@@ -665,9 +686,9 @@ export function parseBlocks(lines: string[]): Block[] {
665
686
  i++
666
687
  blocks.push({
667
688
  type: "code",
668
- language: fence[2] || null,
689
+ language: (!bareAttrs && fence[2]) || null,
669
690
  title: attrs.title ?? null,
670
- lineNumbers: booleanAttr(attrs, "lineNumbers") ?? booleanAttr(attrs, "showLineNumbers") ?? true,
691
+ ...lineNumbersFrom(attrs, attrs.title !== undefined),
671
692
  code: code.join("\n"),
672
693
  ...(booleanAttr(attrs, "live") === undefined ? {} : { live: booleanAttr(attrs, "live") }),
673
694
  ...(attrs.entry ? { entry: attrs.entry } : {}),
@@ -691,7 +712,7 @@ export function parseBlocks(lines: string[]): Block[] {
691
712
  code.push(lines[i].slice(4))
692
713
  i++
693
714
  }
694
- blocks.push({ type: "code", language: null, title: null, lineNumbers: true, code: code.join("\n") })
715
+ blocks.push({ type: "code", language: null, title: null, lineNumbers: false, code: code.join("\n") })
695
716
  continue
696
717
  }
697
718
 
@@ -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
  }
@@ -158,7 +166,8 @@ function serializeBlock(b: Block): string {
158
166
  }
159
167
  // Escape pipes inside cell text so they don't break the GFM row.
160
168
  const cell = (c: Inline[]) => serializeInline(c).replace(/\|/g, "\\|")
161
- const row = (cells: Inline[][]) => `| ${cells.map(cell).join(" | ")} |`
169
+ // An empty cell is written `| |`, as authors do.
170
+ const row = (cells: Inline[][]) => `|${cells.map((c) => ` ${cell(c)} `.replace(/^ $/, " ")).join("|")}|`
162
171
  const sep = `| ${b.header.map(() => "---").join(" | ")} |`
163
172
  return [row(b.header), sep, ...b.rows.map(row)].join("\n")
164
173
  }
@@ -167,18 +176,18 @@ function serializeBlock(b: Block): string {
167
176
  return `$$\n${b.formula}\n$$`
168
177
 
169
178
  case "updates":
170
- return `{% updates${b.format ? ` format="${b.format}"` : ""} %}\n${b.updates
179
+ return `{% updates${b.format ? attr("format", b.format) : ""} %}\n${b.updates
171
180
  .map(
172
181
  (u) =>
173
- `{% update date="${u.date}" %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
182
+ `{% update${attr("date", u.date)} %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
174
183
  )
175
184
  .join("\n\n")}\n{% endupdates %}`
176
185
 
177
186
  case "openapi-operation": {
178
187
  const attrs = [
179
- b.spec ? ` spec="${b.spec}"` : "",
180
- b.path ? ` path="${b.path}"` : "",
181
- b.method ? ` method="${b.method}"` : "",
188
+ b.spec ? attr("spec", b.spec) : "",
189
+ b.path ? attr("path", b.path) : "",
190
+ b.method ? attr("method", b.method) : "",
182
191
  ].join("")
183
192
  const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
184
193
  return `{% openapi-operation${attrs} %}${inner}\n{% endopenapi-operation %}`
@@ -225,7 +234,7 @@ export function fenceFor(content: string): string {
225
234
  /** One inline demo file as a titled fence. */
226
235
  export function serializeDemoFile(file: DemoInlineFile): string {
227
236
  const fence = fenceFor(file.content)
228
- return `${fence}${file.language ?? ""} title="${file.path}"\n${file.content}\n${fence}`
237
+ return `${fence}${file.language ?? ""}${attr("title", file.path)}\n${file.content}\n${fence}`
229
238
  }
230
239
 
231
240
  /** The body of a block-form `{% demo %}`: files as titled fences, then `{% enddemo %}`. Empty without files. */
package/src/index.ts CHANGED
@@ -99,7 +99,9 @@ export type {
99
99
  export { parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./gitbook/parse"
100
100
  export { fenceFor, serializeBlocks, serializeDemoFile, serializeMarkdown } from "./gitbook/serialize"
101
101
  export {
102
+ defaultInlineDelims,
102
103
  footnoteDefinitions,
104
+ inlineDelims,
103
105
  parseInline,
104
106
  plainText,
105
107
  refDefinitions,
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