@clidey/dory 1.0.3 → 1.0.6

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.
Files changed (66) hide show
  1. package/bin/dist/dory.js +36 -2
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +0 -1
  4. package/package.json +27 -31
  5. package/scripts/check-trigger-children.mjs +60 -0
  6. package/scripts/trigger-children-lib.mjs +149 -0
  7. package/scripts/trigger-children.json +47 -0
  8. package/src/components/hooks.tsx +2 -1
  9. package/src/components/store.ts +5 -4
  10. package/src/config/mdx.js +8 -3
  11. package/src/config/mdx.ts +8 -3
  12. package/src/embed/widget-iframe.ts +10 -3
  13. package/src/entry-server.tsx +11 -7
  14. package/src/main.tsx +5 -1
  15. package/src/mdx/accordion.tsx +11 -8
  16. package/src/mdx/api.tsx +2 -352
  17. package/src/mdx/async-api.tsx +124 -295
  18. package/src/mdx/callout.tsx +22 -24
  19. package/src/mdx/card.tsx +20 -22
  20. package/src/mdx/chip.tsx +14 -0
  21. package/src/mdx/code.tsx +133 -110
  22. package/src/mdx/expandable.tsx +8 -8
  23. package/src/mdx/fence-meta.test.ts +39 -0
  24. package/src/mdx/fence-meta.ts +61 -0
  25. package/src/mdx/fence.tsx +155 -44
  26. package/src/mdx/icon-grid.tsx +29 -0
  27. package/src/mdx/icon.tsx +2 -2
  28. package/src/mdx/latex.tsx +14 -7
  29. package/src/mdx/list.tsx +3 -3
  30. package/src/mdx/mdx.tsx +7 -1
  31. package/src/mdx/mermaid.tsx +81 -11
  32. package/src/mdx/open-api.tsx +396 -239
  33. package/src/mdx/source.tsx +148 -40
  34. package/src/mdx/specimen.tsx +69 -0
  35. package/src/mdx/step.tsx +22 -30
  36. package/src/mdx/table.tsx +4 -9
  37. package/src/mdx/tabs.tsx +39 -0
  38. package/src/mdx/tag.tsx +13 -21
  39. package/src/mdx/text.tsx +17 -9
  40. package/src/mdx/type-scale.tsx +28 -0
  41. package/src/mdx/websocket-playground.tsx +221 -704
  42. package/src/plugins/code-meta.ts +23 -0
  43. package/src/plugins/frontmatter-dev-server.ts +6 -2
  44. package/src/plugins/html-files-middleware.ts +9 -2
  45. package/src/plugins/html-metadata-injector.ts +4 -3
  46. package/src/plugins/llm-txt-dev-server.ts +5 -2
  47. package/src/plugins/llm-txt-generator.ts +2 -1
  48. package/src/plugins/prerender.ts +7 -4
  49. package/src/plugins/relative-assets.test.ts +26 -0
  50. package/src/plugins/relative-assets.ts +80 -0
  51. package/src/plugins/robots-generator.ts +6 -4
  52. package/src/plugins/sanitize.test.ts +5 -0
  53. package/src/plugins/sanitize.ts +2 -0
  54. package/src/plugins/shared.ts +17 -0
  55. package/src/plugins/sitemap-generator.ts +2 -1
  56. package/src/theme.scss +282 -3
  57. package/src/types/config.ts +3 -0
  58. package/src/ui/header.tsx +4 -3
  59. package/src/ui/layout.tsx +22 -4
  60. package/src/ui/page-meta.tsx +37 -0
  61. package/src/ui/prev-next-link.tsx +4 -3
  62. package/src/ui/table-of-content.tsx +11 -3
  63. package/src/utils/base-path.test.ts +46 -0
  64. package/src/utils/base-path.ts +41 -0
  65. package/vite.config.ssr.ts +3 -0
  66. package/vite.config.ts +3 -0
package/src/mdx/code.tsx CHANGED
@@ -8,9 +8,11 @@ import {
8
8
  useEffect,
9
9
  useState
10
10
  } from 'react'
11
+ import { File, SquareTerminal } from 'lucide-react'
11
12
  import { Tag } from './tag'
13
+ import { diffBaseLanguage, isTerminalSession, parseFenceMeta, type FenceMeta } from './fence-meta'
12
14
  import { toTitleCase } from '../utils/functions'
13
- import { Button, Card } from '@clidey/ux'
15
+ import { Button } from '@clidey/ux'
14
16
  import { CheckIcon } from 'lucide-react'
15
17
 
16
18
  export const supportedLanguages = {
@@ -52,15 +54,37 @@ export const supportedLanguages = {
52
54
  'js-extras': 'JavaScript Extras',
53
55
  'reason': 'Reason',
54
56
  'graphql': 'GraphQL',
57
+ 'mdx': 'MDX',
58
+ 'sh': 'Shell',
59
+ 'sql': 'SQL',
55
60
  }
56
61
 
57
- function getPanelTitle(props: { className?: string }) {
58
- const { className } = props
59
- if (className) {
60
- const language = className.replace('language-', '') as keyof typeof supportedLanguages;
61
- return supportedLanguages[language] || toTitleCase(language)
62
+ /** Language, raw code and parsed meta for a fenced code block (an MDX `pre` element). */
63
+ export interface FenceProps {
64
+ language: string
65
+ code: string
66
+ meta: FenceMeta
67
+ }
68
+
69
+ /** Reads the fence a `pre` element wraps: `<pre><code className="language-x" metastring="...">`. */
70
+ export function fencePropsOf(pre: preact.JSX.Element | undefined): FenceProps {
71
+ return fencePropsOfCode(pre?.props?.children)
72
+ }
73
+
74
+ /** Reads a fence from its `code` element. */
75
+ export function fencePropsOfCode(code: preact.JSX.Element | undefined): FenceProps {
76
+ const codeProps = code?.props ?? {}
77
+ const className = typeof codeProps.className === 'string' ? codeProps.className : ''
78
+ return {
79
+ language: className.replace('language-', ''),
80
+ code: typeof codeProps.children === 'string' ? codeProps.children : '',
81
+ meta: parseFenceMeta(codeProps.metastring),
62
82
  }
63
- return 'Code'
83
+ }
84
+
85
+ function languageName(language: string) {
86
+ if (!language) return 'Code'
87
+ return supportedLanguages[language as keyof typeof supportedLanguages] || toTitleCase(language)
64
88
  }
65
89
 
66
90
  function CopyButton({ code }: { code: string }) {
@@ -76,11 +100,12 @@ function CopyButton({ code }: { code: string }) {
76
100
 
77
101
  return (
78
102
  <Button
103
+ aria-label="Copy code"
79
104
  className={classNames(
80
- 'group/button absolute top-1 right-2 overflow-hidden rounded-full py-1 pr-3 pl-2 text-2xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100 focus:opacity-100',
105
+ 'group/button absolute top-1.5 right-2 z-10 overflow-hidden rounded-md p-1.5 shadow-none transition',
81
106
  {
82
- 'bg-emerald-400/10 ring-1 ring-emerald-400/20 ring-inset': copied,
83
- 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5': !copied,
107
+ 'bg-[var(--success-muted)]': copied,
108
+ 'bg-transparent hover:bg-card': !copied,
84
109
  }
85
110
  )}
86
111
  onClick={() => {
@@ -96,71 +121,83 @@ function CopyButton({ code }: { code: string }) {
96
121
  copied && '-translate-y-1.5 opacity-0',
97
122
  )}
98
123
  >
99
- <ClipboardIcon className="h-4 w-4 fill-zinc-500/20 stroke-muted-foreground transition-colors group-hover/button:stroke-muted-foreground" />
124
+ <ClipboardIcon className="h-3.5 w-3.5 stroke-muted-foreground transition-colors group-hover/button:stroke-foreground" />
100
125
  </span>
101
126
  <span
102
127
  aria-hidden={!copied}
103
128
  className={classNames(
104
- 'pointer-events-none absolute inset-0 flex items-center justify-center text-emerald-400 transition duration-300 text-sm',
129
+ 'pointer-events-none absolute inset-0 flex items-center justify-center text-foreground transition duration-300 text-sm',
105
130
  !copied && 'translate-y-1.5 opacity-0',
106
131
  )}
107
132
  >
108
- <CheckIcon className="h-4 w-4" />
133
+ <CheckIcon className="h-3.5 w-3.5" />
109
134
  </span>
110
135
  </Button>
111
136
  )
112
137
  }
113
138
 
114
- function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
115
- if (!tag && !label) return null
139
+ /** Small mono pill used in code headers, e.g. JSON, SH, DIFF. */
140
+ function LanguagePill({ children }: { children: string }) {
141
+ return (
142
+ <span className="rounded-[5px] border border-[var(--border)] bg-card px-1.5 py-px font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
143
+ {children}
144
+ </span>
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Header bar for a standalone fence: icon + filename (or language) on the
150
+ * left; diff stats and a language pill on the right.
151
+ */
152
+ export function FenceHeader({ language, code, meta }: FenceProps) {
153
+ const diff = diffBaseLanguage(language) !== null
154
+ const terminal = isTerminalSession(language, code)
155
+ const title = meta.title ?? (terminal ? 'Terminal' : languageName(language))
156
+ const showPill = Boolean(meta.title) || terminal || diff
157
+ const lines = code.split('\n')
158
+ const additions = diff ? lines.filter((line) => line.startsWith('+')).length : 0
159
+ const deletions = diff ? lines.filter((line) => line.startsWith('-')).length : 0
116
160
 
117
161
  return (
118
- <div className="flex h-9 items-center gap-2 border-b border-t-transparent px-4" style={{ borderColor: 'var(--border)' }}>
119
- {tag && (
120
- <div className="dark flex">
121
- <Tag variant="small">{tag}</Tag>
122
- </div>
162
+ <div className="flex min-h-10 items-center gap-2 border-b border-[var(--border)] bg-muted pl-4 pr-12">
163
+ {terminal
164
+ ? <SquareTerminal aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
165
+ : meta.title && <File aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
166
+ <p className="mr-auto truncate font-mono text-xs font-medium text-foreground">{title}</p>
167
+ {diff && (
168
+ <span className="font-mono text-xs">
169
+ <span className="text-green-700 dark:text-green-400">+{additions}</span>{' '}
170
+ <span className="text-[var(--destructive)]">-{deletions}</span>
171
+ </span>
123
172
  )}
124
- {tag && label && <span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />}
125
- {label && <span className="font-mono text-xs text-muted-foreground">{label}</span>}
173
+ {showPill && <LanguagePill>{diff ? 'diff' : language}</LanguagePill>}
126
174
  </div>
127
175
  )
128
176
  }
129
177
 
130
- function CodePanel({
131
- children,
132
- tag,
133
- label,
134
- code,
135
- }: {
136
- children: React.ReactNode
137
- tag?: string
138
- label?: string
139
- code?: string
140
- }) {
141
- const child = Children.only(children)
178
+ /** Rounded frame shared by standalone fences and code groups. The copy button sits in the header row. */
179
+ export function CodeFrame({ header, code, children }: { header?: preact.ComponentChildren; code: string; children: preact.ComponentChildren }) {
180
+ return (
181
+ <div className="dory-code not-prose relative my-5 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
182
+ {header}
183
+ <CopyButton code={code} />
184
+ {children}
185
+ </div>
186
+ )
187
+ }
142
188
 
143
- if (isValidElement(child)) {
144
- // @ts-expect-error
145
- tag = child.props.tag ?? tag
146
- // @ts-expect-error
147
- label = child.props.label ?? label
148
- // @ts-expect-error
149
- code = child.props.code ?? code
150
- }
189
+ /** Padded, horizontally scrollable code area inside a CodeFrame. */
190
+ export function CodeBody({ children }: { children: preact.ComponentChildren }) {
191
+ return <div className="overflow-x-auto bg-card py-3 pl-4 pr-12 font-mono text-[12.5px] leading-[1.7]">{children}</div>
192
+ }
151
193
 
152
- if (!code) {
153
- // @ts-expect-error
154
- code = child.props.children
155
- }
194
+ function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
195
+ if (!tag && !label) return null
156
196
 
157
197
  return (
158
- <div className="group">
159
- <CodePanelHeader tag={tag} label={label} />
160
- <div className="relative">
161
- <pre className="overflow-x-auto p-4 text-xs">{children}</pre>
162
- <CopyButton code={code ?? ''} />
163
- </div>
198
+ <div className="flex h-9 items-center gap-2 border-b border-[var(--border)] bg-card px-4">
199
+ {tag && <Tag variant="small">{tag}</Tag>}
200
+ {label && <span className="font-mono text-xs text-muted-foreground">{label}</span>}
164
201
  </div>
165
202
  )
166
203
  }
@@ -171,7 +208,7 @@ function CodeGroupHeader({
171
208
  selectedIndex,
172
209
  }: {
173
210
  title: string
174
- children: React.ReactNode
211
+ children: preact.ComponentChildren
175
212
  selectedIndex: number
176
213
  }) {
177
214
  const hasTabs = Children.count(children) > 1
@@ -179,9 +216,9 @@ function CodeGroupHeader({
179
216
  if (!title && !hasTabs) return null
180
217
 
181
218
  return (
182
- <div className="flex flex-wrap items-start gap-x-4 px-4 pb-2">
219
+ <div className="flex min-h-10 flex-wrap items-end gap-x-4 border-b border-[var(--border)] bg-muted pl-4 pr-12">
183
220
  {title && (
184
- <p className="mr-auto pt-3 text-xs font-semibold">
221
+ <p className="mr-auto self-center py-2.5 font-mono text-xs font-medium text-foreground">
185
222
  {title}
186
223
  </p>
187
224
  )}
@@ -189,21 +226,19 @@ function CodeGroupHeader({
189
226
  <TabList className="-mb-px flex gap-4 text-xs font-medium">
190
227
  {Children.map(children, (child, index) => {
191
228
  if (!isValidElement(child)) return null
192
-
193
- const childProps = (child as preact.JSX.Element).props?.children?.props ?? {}
194
- const panelTitle = getPanelTitle(childProps)
229
+ const fence = fencePropsOf(child as preact.JSX.Element)
195
230
 
196
231
  return (
197
232
  <Tab
198
233
  key={index}
199
234
  className={classNames(
200
- 'pt-2 transition data-selected:not-data-focus:outline-hidden',
235
+ 'border-b-2 pb-2.5 pt-3 transition data-selected:not-data-focus:outline-hidden',
201
236
  index === selectedIndex
202
- ? 'border-emerald-500 text-emerald-400'
203
- : 'border-transparent text-muted-foreground hover:text-zinc-300',
237
+ ? 'border-[var(--primary)] text-foreground'
238
+ : 'border-transparent text-muted-foreground hover:text-foreground',
204
239
  )}
205
240
  >
206
- {panelTitle}
241
+ {fence.meta.title ?? languageName(fence.language)}
207
242
  </Tab>
208
243
  )
209
244
  })}
@@ -213,69 +248,57 @@ function CodeGroupHeader({
213
248
  )
214
249
  }
215
250
 
216
- function CodeGroupPanels({
217
- children,
218
- ...props
219
- }: React.ComponentPropsWithoutRef<typeof CodePanel>) {
220
- const hasTabs = Children.count(children) > 1
221
-
222
- if (hasTabs) {
223
- return (
224
- <TabPanels>
225
- {Children.map(children, (child, index) => (
226
- <TabPanel key={index}>
227
- <CodePanel {...props}>{child}</CodePanel>
228
- </TabPanel>
229
- ))}
230
- </TabPanels>
231
- )
232
- }
233
-
234
- return <CodePanel {...props}>{children}</CodePanel>
235
- }
236
-
237
251
  export const CodeGroupContext = createContext(false)
238
252
 
253
+ /**
254
+ * Groups fences into one frame. Several fences become tabs labelled by each
255
+ * fence's title (e.g. ```bash cURL) or language; `tag`/`label` meta adds a
256
+ * method badge row.
257
+ */
239
258
  export function CodeGroup({
240
259
  children,
241
260
  title,
242
- ...props
243
- }: React.ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
261
+ }: {
262
+ children: preact.ComponentChildren
263
+ title: string
264
+ }) {
244
265
  const [selectedIndex, setSelectedIndex] = useState(0)
245
-
246
- const childrenArray = Children.toArray(children)
247
-
266
+ const childrenArray = Children.toArray(children) as preact.JSX.Element[]
248
267
  const hasTabs = childrenArray.length > 1
268
+ const selected = fencePropsOf(childrenArray[hasTabs ? selectedIndex : 0])
249
269
 
250
270
  const header = (
251
271
  <CodeGroupHeader title={title} selectedIndex={selectedIndex}>
252
272
  {children}
253
273
  </CodeGroupHeader>
254
274
  )
255
- const panels = <CodeGroupPanels {...props}>{children}</CodeGroupPanels>
275
+
276
+ const panel = (child: preact.JSX.Element) => {
277
+ const { meta } = fencePropsOf(child)
278
+ return (
279
+ <>
280
+ <CodePanelHeader tag={meta.tag} label={meta.label} />
281
+ <CodeBody>{child}</CodeBody>
282
+ </>
283
+ )
284
+ }
256
285
 
257
286
  return (
258
287
  <CodeGroupContext.Provider value={true}>
259
288
  {hasTabs ? (
260
- <Card className="p-0 border-black/10 bg-none overflow-hidden mb-4">
261
- <TabGroup
262
- as="div"
263
- selectedIndex={selectedIndex}
264
- onChange={setSelectedIndex}
265
- >
266
- <div className="not-prose">
267
- {header}
268
- {panels}
269
- </div>
270
- </TabGroup>
271
- </Card>
289
+ <TabGroup as="div" selectedIndex={selectedIndex} onChange={setSelectedIndex}>
290
+ <CodeFrame header={header} code={selected.code}>
291
+ <TabPanels>
292
+ {childrenArray.map((child, index) => (
293
+ <TabPanel key={index}>{panel(child)}</TabPanel>
294
+ ))}
295
+ </TabPanels>
296
+ </CodeFrame>
297
+ </TabGroup>
272
298
  ) : (
273
- <Card className="p-0 border-black/10 bg-none overflow-hidden mb-4">
274
- <div className="not-prose">
275
- {header}
276
- {panels}
277
- </div>
278
- </Card>
299
+ <CodeFrame header={header} code={selected.code}>
300
+ {panel(childrenArray[0])}
301
+ </CodeFrame>
279
302
  )}
280
303
  </CodeGroupContext.Provider>
281
304
  )
@@ -286,7 +309,7 @@ export function Code({
286
309
  ...props
287
310
  }: React.ComponentPropsWithoutRef<'code'>) {
288
311
  const isBlock = typeof props.className === 'string' && props.className.includes('language-');
289
- return <code className={classNames(props.className, {
290
- 'dory-mdx-code rounded-md py-0.5 text-sm px-1.5': !isBlock,
291
- })} {...props}>{children}</code>
312
+ return <code {...props} className={classNames(props.className, {
313
+ 'dory-mdx-code rounded-md border border-[var(--border)] bg-muted py-0.5 text-[0.875em] px-1.5': !isBlock,
314
+ })}>{children}</code>
292
315
  }
@@ -14,18 +14,18 @@ export function Expandable({
14
14
  <Accordion
15
15
  type="single"
16
16
  collapsible
17
- className="my-6 rounded-xl border border-border"
17
+ className="dory-expandable my-5 rounded-lg border border-[var(--border)] bg-muted"
18
18
  >
19
- <AccordionItem value="expandable">
20
- <AccordionTrigger className="group flex w-full items-start justify-between text-left text-foreground dark:text-white p-6">
19
+ <AccordionItem value="expandable" className="border-b-0">
20
+ <AccordionTrigger className="flex w-full flex-row-reverse items-center justify-end gap-2.5 px-4 py-2.5 text-left text-foreground hover:no-underline [&>svg]:-rotate-90 [&[data-state=open]>svg]:rotate-0">
21
21
  <div className="flex items-center gap-2">
22
- {icon && <Icon icon={icon} className="h-8 w-8" />}
23
- <span className="text-base font-display text-foreground dark:text-white">{title}</span>
22
+ {icon && <Icon icon={icon} className="h-4 w-4" />}
23
+ <span className="text-sm font-medium text-foreground">{title}</span>
24
24
  </div>
25
25
  </AccordionTrigger>
26
26
  <AccordionContent asChild>
27
- <dd className="px-6 pb-6">
28
- <div className="text-sm text-foreground/80 leading-tight [&>p]:mb-0">
27
+ <dd className="px-4 pb-3">
28
+ <div className="text-sm text-muted-foreground leading-relaxed [&>p]:mb-0">
29
29
  {children}
30
30
  </div>
31
31
  </dd>
@@ -33,4 +33,4 @@ export function Expandable({
33
33
  </AccordionItem>
34
34
  </Accordion>
35
35
  );
36
- }
36
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { diffBaseLanguage, isTerminalSession, parseFenceMeta } from './fence-meta'
3
+
4
+ describe('parseFenceMeta', () => {
5
+ it('returns empty meta for no meta string', () => {
6
+ expect(parseFenceMeta()).toEqual({ highlight: new Set() })
7
+ })
8
+
9
+ it('reads a bare filename and highlighted line ranges', () => {
10
+ const meta = parseFenceMeta('dory.json {1,4-6}')
11
+ expect(meta.title).toBe('dory.json')
12
+ expect([...meta.highlight]).toEqual([1, 4, 5, 6])
13
+ })
14
+
15
+ it('reads key="value" pairs', () => {
16
+ const meta = parseFenceMeta('tag="GET" label="/api/users" title="List users"')
17
+ expect(meta).toMatchObject({ tag: 'GET', label: '/api/users', title: 'List users' })
18
+ })
19
+
20
+ it('prefers an explicit title over bare text', () => {
21
+ expect(parseFenceMeta('ignored title="Kept"').title).toBe('Kept')
22
+ })
23
+ })
24
+
25
+ describe('diffBaseLanguage', () => {
26
+ it('detects plain and language diffs', () => {
27
+ expect(diffBaseLanguage('diff')).toBe('')
28
+ expect(diffBaseLanguage('diff-json')).toBe('json')
29
+ expect(diffBaseLanguage('json')).toBeNull()
30
+ })
31
+ })
32
+
33
+ describe('isTerminalSession', () => {
34
+ it('needs a shell language and a $ prompt', () => {
35
+ expect(isTerminalSession('sh', '$ dory dev\n✓ Compiled')).toBe(true)
36
+ expect(isTerminalSession('bash', 'echo hi')).toBe(false)
37
+ expect(isTerminalSession('json', '$ not shell')).toBe(false)
38
+ })
39
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Options parsed from a code fence's meta string, e.g.
3
+ * ```json dory.json {4-6} or ```bash tag="GET" label="/api/users"
4
+ */
5
+ export interface FenceMeta {
6
+ title?: string
7
+ tag?: string
8
+ label?: string
9
+ highlight: Set<number>
10
+ }
11
+
12
+ const shellLanguages = new Set(['bash', 'sh', 'shell', 'zsh', 'console', 'terminal'])
13
+
14
+ /**
15
+ * Parses a fence meta string. `key="value"` pairs set title/tag/label,
16
+ * `{1,4-6}` lists highlighted lines, and any remaining text is the title.
17
+ */
18
+ export function parseFenceMeta(meta?: string): FenceMeta {
19
+ const result: FenceMeta = { highlight: new Set() }
20
+ if (!meta) return result
21
+
22
+ let rest = meta.replace(/(\w+)="([^"]*)"/g, (_, key: string, value: string) => {
23
+ if (key === 'title' || key === 'tag' || key === 'label') result[key] = value
24
+ return ''
25
+ })
26
+
27
+ rest = rest.replace(/\{([\d,\s-]+)\}/g, (_, ranges: string) => {
28
+ for (const part of ranges.split(',')) {
29
+ const [start, end] = part.split('-').map((n) => parseInt(n.trim(), 10))
30
+ if (Number.isNaN(start)) continue
31
+ for (let line = start; line <= (Number.isNaN(end) || end === undefined ? start : end); line++) {
32
+ result.highlight.add(line)
33
+ }
34
+ }
35
+ return ''
36
+ })
37
+
38
+ const title = rest.trim()
39
+ if (title && !result.title) result.title = title
40
+ return result
41
+ }
42
+
43
+ /** Returns true for languages whose `$ ` lines render as a terminal session. */
44
+ function isShellLanguage(language: string): boolean {
45
+ return shellLanguages.has(language.toLowerCase())
46
+ }
47
+
48
+ /** True when a shell fence reads as a terminal session (has `$ ` prompts). */
49
+ export function isTerminalSession(language: string, code: string): boolean {
50
+ return isShellLanguage(language) && code.split('\n').some((line) => line.trimStart().startsWith('$ '))
51
+ }
52
+
53
+ /**
54
+ * Returns the language to highlight a diff's lines with: `diff` gives '' (plain),
55
+ * `diff-json` gives 'json'. Returns null when the fence is not a diff.
56
+ */
57
+ export function diffBaseLanguage(language: string): string | null {
58
+ if (language === 'diff') return ''
59
+ if (language.startsWith('diff-')) return language.slice('diff-'.length)
60
+ return null
61
+ }