@clidey/dory 1.0.5 → 1.0.7
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/bin/dist/dory.js +36 -2
- package/bin/dist/dory.js.map +1 -1
- package/index.html +0 -1
- package/package.json +16 -22
- package/src/components/hooks.tsx +2 -1
- package/src/components/store.ts +5 -4
- package/src/config/mdx.js +8 -3
- package/src/config/mdx.ts +8 -3
- package/src/embed/widget-iframe.ts +10 -3
- package/src/entry-server.tsx +11 -7
- package/src/main.tsx +5 -1
- package/src/mdx/accordion.tsx +11 -8
- package/src/mdx/api.tsx +2 -352
- package/src/mdx/async-api.tsx +124 -295
- package/src/mdx/callout.tsx +22 -24
- package/src/mdx/card.tsx +20 -22
- package/src/mdx/chip.tsx +14 -0
- package/src/mdx/code.tsx +133 -110
- package/src/mdx/expandable.tsx +8 -8
- package/src/mdx/fence-meta.test.ts +39 -0
- package/src/mdx/fence-meta.ts +61 -0
- package/src/mdx/fence.tsx +155 -44
- package/src/mdx/icon-grid.tsx +29 -0
- package/src/mdx/icon.tsx +2 -2
- package/src/mdx/latex.tsx +14 -7
- package/src/mdx/list.tsx +3 -3
- package/src/mdx/mdx.tsx +7 -1
- package/src/mdx/mermaid.tsx +81 -11
- package/src/mdx/open-api.tsx +396 -239
- package/src/mdx/source.tsx +148 -40
- package/src/mdx/specimen.tsx +69 -0
- package/src/mdx/step.tsx +22 -30
- package/src/mdx/table.tsx +4 -9
- package/src/mdx/tabs.tsx +39 -0
- package/src/mdx/tag.tsx +13 -21
- package/src/mdx/text.tsx +17 -9
- package/src/mdx/type-scale.tsx +28 -0
- package/src/mdx/websocket-playground.tsx +221 -704
- package/src/plugins/code-meta.ts +23 -0
- package/src/plugins/frontmatter-dev-server.ts +6 -2
- package/src/plugins/html-files-middleware.ts +9 -2
- package/src/plugins/html-metadata-injector.ts +4 -3
- package/src/plugins/llm-txt-dev-server.ts +5 -2
- package/src/plugins/llm-txt-generator.ts +2 -1
- package/src/plugins/prerender.ts +7 -4
- package/src/plugins/relative-assets.test.ts +26 -0
- package/src/plugins/relative-assets.ts +80 -0
- package/src/plugins/robots-generator.ts +6 -4
- package/src/plugins/sanitize.test.ts +5 -0
- package/src/plugins/sanitize.ts +2 -0
- package/src/plugins/shared.ts +17 -0
- package/src/plugins/sitemap-generator.ts +2 -1
- package/src/theme.scss +282 -3
- package/src/types/config.ts +3 -0
- package/src/ui/header.tsx +4 -3
- package/src/ui/layout.tsx +22 -4
- package/src/ui/page-meta.tsx +37 -0
- package/src/ui/prev-next-link.tsx +4 -3
- package/src/ui/table-of-content.tsx +11 -3
- package/src/utils/base-path.test.ts +46 -0
- package/src/utils/base-path.ts +41 -0
- package/vite.config.ssr.ts +3 -0
- 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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
83
|
-
'bg-
|
|
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-
|
|
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-
|
|
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-
|
|
133
|
+
<CheckIcon className="h-3.5 w-3.5" />
|
|
109
134
|
</span>
|
|
110
135
|
</Button>
|
|
111
136
|
)
|
|
112
137
|
}
|
|
113
138
|
|
|
114
|
-
|
|
115
|
-
|
|
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-
|
|
119
|
-
{
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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="
|
|
159
|
-
<
|
|
160
|
-
<
|
|
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:
|
|
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-
|
|
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
|
|
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
|
-
'
|
|
235
|
+
'border-b-2 pb-2.5 pt-3 transition data-selected:not-data-focus:outline-hidden',
|
|
201
236
|
index === selectedIndex
|
|
202
|
-
? 'border-
|
|
203
|
-
: 'border-transparent text-muted-foreground hover:text-
|
|
237
|
+
? 'border-[var(--primary)] text-foreground'
|
|
238
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
204
239
|
)}
|
|
205
240
|
>
|
|
206
|
-
{
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
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-
|
|
291
|
-
})}
|
|
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
|
}
|
package/src/mdx/expandable.tsx
CHANGED
|
@@ -14,18 +14,18 @@ export function Expandable({
|
|
|
14
14
|
<Accordion
|
|
15
15
|
type="single"
|
|
16
16
|
collapsible
|
|
17
|
-
className="my-
|
|
17
|
+
className="dory-expandable my-5 rounded-lg border border-[var(--border)] bg-muted"
|
|
18
18
|
>
|
|
19
|
-
<AccordionItem value="expandable">
|
|
20
|
-
<AccordionTrigger className="
|
|
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-
|
|
23
|
-
<span className="text-
|
|
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-
|
|
28
|
-
<div className="text-sm text-foreground
|
|
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
|
+
}
|