@inferencesh/ui 0.0.0-stage → 0.1.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.
Files changed (165) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/components.json +22 -0
  4. package/package.json +231 -4
  5. package/src/components/audio-player.tsx +549 -0
  6. package/src/components/button.tsx +13 -0
  7. package/src/components/chart.tsx +403 -0
  8. package/src/components/circuit.tsx +276 -0
  9. package/src/components/code-block/code-block.tsx +260 -0
  10. package/src/components/code-block/index.ts +18 -0
  11. package/src/components/code-block/languages.ts +311 -0
  12. package/src/components/code-block/styles.ts +39 -0
  13. package/src/components/code-block/tokenizer.ts +290 -0
  14. package/src/components/code-block/types.ts +38 -0
  15. package/src/components/code-block/utils.ts +39 -0
  16. package/src/components/code.tsx +48 -0
  17. package/src/components/cookie-consent.tsx +137 -0
  18. package/src/components/copy-button.tsx +44 -0
  19. package/src/components/country-dropdown.tsx +188 -0
  20. package/src/components/glow-loader-fast.tsx +32 -0
  21. package/src/components/glow-loader-mono.tsx +33 -0
  22. package/src/components/glow-loader-sm-fast.tsx +32 -0
  23. package/src/components/glow-loader-sm-mono.tsx +34 -0
  24. package/src/components/glow-loader-sm.tsx +32 -0
  25. package/src/components/glow-loader.tsx +32 -0
  26. package/src/components/glow-logo.tsx +32 -0
  27. package/src/components/gradient.tsx +80 -0
  28. package/src/components/index.ts +45 -0
  29. package/src/components/inline-sidebar.tsx +351 -0
  30. package/src/components/input-color.tsx +349 -0
  31. package/src/components/logo-base.tsx +213 -0
  32. package/src/components/logo-color.tsx +23 -0
  33. package/src/components/logo-full.tsx +71 -0
  34. package/src/components/logo-svg.tsx +23 -0
  35. package/src/components/menu-navigation.tsx +359 -0
  36. package/src/components/multi-select.tsx +721 -0
  37. package/src/components/pastel-gradient.tsx +25 -0
  38. package/src/components/pixel-symmetry.tsx +151 -0
  39. package/src/components/progress.tsx +28 -0
  40. package/src/components/scroll-more.tsx +131 -0
  41. package/src/components/select.tsx +85 -0
  42. package/src/components/shimmering-text.tsx +108 -0
  43. package/src/components/sidebar-light.tsx +102 -0
  44. package/src/components/sonner.tsx +38 -0
  45. package/src/components/theme-switcher-sidebar.tsx +56 -0
  46. package/src/components/theme-switcher-simple.tsx +110 -0
  47. package/src/components/theme-switcher.tsx +47 -0
  48. package/src/components/toggle-group.tsx +55 -0
  49. package/src/components/tooltip-button.tsx +91 -0
  50. package/src/components/tooltip.tsx +71 -0
  51. package/src/components/tree-view.tsx +499 -0
  52. package/src/components/unglow-loader.tsx +33 -0
  53. package/src/components/waveform.tsx +1632 -0
  54. package/src/components/zoomable-image.tsx +76 -0
  55. package/src/docs/api-docs.tsx +763 -0
  56. package/src/docs/callout.tsx +160 -0
  57. package/src/docs/code-group.tsx +215 -0
  58. package/src/docs/copy-menu.tsx +143 -0
  59. package/src/docs/doc-tabs.tsx +84 -0
  60. package/src/docs/index.ts +16 -0
  61. package/src/docs/steps.tsx +105 -0
  62. package/src/docs/table-of-contents.tsx +174 -0
  63. package/src/examples/guide-markdown.ts +129 -0
  64. package/src/examples/index.ts +1153 -0
  65. package/src/global.d.ts +1 -0
  66. package/src/hooks/use-copy-to-clipboard.ts +36 -0
  67. package/src/hooks/use-mobile.ts +19 -0
  68. package/src/lib/display-name.ts +15 -0
  69. package/src/lib/example-input.ts +130 -0
  70. package/src/lib/plans.ts +44 -0
  71. package/src/lib/safe-content.ts +137 -0
  72. package/src/lib/schema.ts +320 -0
  73. package/src/lib/storage.ts +108 -0
  74. package/src/markdown/docs-markdown-renderer.tsx +518 -0
  75. package/src/markdown/helpers.ts +95 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/markdown/markdown-renderer.tsx +232 -0
  78. package/src/markdown/youtube-embed.tsx +43 -0
  79. package/src/og/image.ts +312 -0
  80. package/src/og/index.tsx +430 -0
  81. package/src/og/query.ts +26 -0
  82. package/src/shadcn/accordion.tsx +71 -0
  83. package/src/shadcn/alert-dialog.tsx +187 -0
  84. package/src/shadcn/alert.tsx +75 -0
  85. package/src/shadcn/aspect-ratio.tsx +22 -0
  86. package/src/shadcn/attachment.tsx +207 -0
  87. package/src/shadcn/avatar.tsx +106 -0
  88. package/src/shadcn/badge.tsx +51 -0
  89. package/src/shadcn/breadcrumb.tsx +124 -0
  90. package/src/shadcn/bubble.tsx +127 -0
  91. package/src/shadcn/button-group.tsx +87 -0
  92. package/src/shadcn/button.tsx +57 -0
  93. package/src/shadcn/calendar.tsx +219 -0
  94. package/src/shadcn/card.tsx +102 -0
  95. package/src/shadcn/carousel.tsx +242 -0
  96. package/src/shadcn/chart.tsx +372 -0
  97. package/src/shadcn/checkbox.tsx +28 -0
  98. package/src/shadcn/collapsible.tsx +19 -0
  99. package/src/shadcn/combobox.tsx +297 -0
  100. package/src/shadcn/command.tsx +194 -0
  101. package/src/shadcn/context-menu.tsx +270 -0
  102. package/src/shadcn/dialog.tsx +158 -0
  103. package/src/shadcn/direction.tsx +4 -0
  104. package/src/shadcn/drawer.tsx +227 -0
  105. package/src/shadcn/dropdown-menu.tsx +265 -0
  106. package/src/shadcn/empty.tsx +103 -0
  107. package/src/shadcn/field.tsx +238 -0
  108. package/src/shadcn/form.tsx +173 -0
  109. package/src/shadcn/hover-card.tsx +50 -0
  110. package/src/shadcn/index.ts +53 -0
  111. package/src/shadcn/input-group.tsx +156 -0
  112. package/src/shadcn/input-otp.tsx +84 -0
  113. package/src/shadcn/input.tsx +19 -0
  114. package/src/shadcn/item.tsx +201 -0
  115. package/src/shadcn/kbd.tsx +26 -0
  116. package/src/shadcn/label.tsx +19 -0
  117. package/src/shadcn/marker.tsx +70 -0
  118. package/src/shadcn/menubar.tsx +278 -0
  119. package/src/shadcn/message-scroller.tsx +131 -0
  120. package/src/shadcn/message.tsx +91 -0
  121. package/src/shadcn/native-select.tsx +60 -0
  122. package/src/shadcn/navigation-menu.tsx +167 -0
  123. package/src/shadcn/pagination.tsx +130 -0
  124. package/src/shadcn/popover.tsx +87 -0
  125. package/src/shadcn/progress.tsx +82 -0
  126. package/src/shadcn/radio-group.tsx +35 -0
  127. package/src/shadcn/resizable.tsx +49 -0
  128. package/src/shadcn/scroll-area.tsx +52 -0
  129. package/src/shadcn/select.tsx +200 -0
  130. package/src/shadcn/separator.tsx +22 -0
  131. package/src/shadcn/sheet.tsx +136 -0
  132. package/src/shadcn/sidebar.tsx +721 -0
  133. package/src/shadcn/skeleton.tsx +13 -0
  134. package/src/shadcn/slider.tsx +51 -0
  135. package/src/shadcn/sonner.tsx +49 -0
  136. package/src/shadcn/spinner.tsx +10 -0
  137. package/src/shadcn/switch.tsx +29 -0
  138. package/src/shadcn/table.tsx +115 -0
  139. package/src/shadcn/tabs.tsx +79 -0
  140. package/src/shadcn/textarea.tsx +17 -0
  141. package/src/shadcn/toggle-group.tsx +89 -0
  142. package/src/shadcn/toggle.tsx +44 -0
  143. package/src/shadcn/tooltip.tsx +63 -0
  144. package/src/theme/config.ts +48 -0
  145. package/src/theme/css/base.css +92 -0
  146. package/src/theme/css/bitboy.css +75 -0
  147. package/src/theme/css/dark.css +47 -0
  148. package/src/theme/css/designer.css +122 -0
  149. package/src/theme/css/fonts.css +33 -0
  150. package/src/theme/css/globals.css +14 -0
  151. package/src/theme/css/hackintosh.css +57 -0
  152. package/src/theme/css/llama.css +103 -0
  153. package/src/theme/css/safelist.css +39 -0
  154. package/src/theme/css/shadcn.css +77 -0
  155. package/src/theme/css/theme.css +216 -0
  156. package/src/theme/embed-theme.ts +146 -0
  157. package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
  158. package/src/theme/fonts/Hack-Regular.woff2 +0 -0
  159. package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
  160. package/src/theme/fonts/LICENSE-Hack.txt +45 -0
  161. package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
  162. package/src/theme/fonts/Mona-Sans.woff2 +0 -0
  163. package/src/theme/index.ts +15 -0
  164. package/src/utils/cn.ts +3 -0
  165. package/src/utils/index.ts +2 -0
@@ -0,0 +1,518 @@
1
+ 'use client'
2
+
3
+ import { cn } from "cn"
4
+ import ZoomableImage from "../components/zoomable-image"
5
+ import { Hash, Link as LinkIcon } from 'lucide-react'
6
+ import React, { memo, useMemo } from 'react'
7
+ import Markdown from 'react-markdown'
8
+ import { safeUrlTransform } from '../lib/safe-content'
9
+ import remarkGfm from 'remark-gfm'
10
+ import rehypeRaw from 'rehype-raw'
11
+ import rehypeSanitize, { defaultSchema, type Options as SanitizeSchema } from 'rehype-sanitize'
12
+ import type { Root, Code } from 'mdast'
13
+
14
+ /** What raw HTML in markdown may keep. This renderer shows user-written
15
+ * content (skills, knowledge, agent output) on the app's own origin, so raw
16
+ * HTML goes through GitHub's allowlist (defaultSchema: no script, iframe,
17
+ * object, embed, style, event handlers or javascript: URLs), plus the
18
+ * attribute the docs components switch on (div data-type; title is already
19
+ * allowed everywhere) and the code-fence meta string.
20
+ *
21
+ * Ids: remark-rehype already prefixes the ids it generates (footnotes) with
22
+ * user-content-, so the schema does not clobber ids again (that would make
23
+ * user-content-user-content-fn-1 and break footnote links). Instead an id
24
+ * is kept only when it carries that prefix, which still stops raw HTML from
25
+ * naming elements after document or window properties (DOM clobbering). */
26
+ export const markdownSanitizeSchema: SanitizeSchema = {
27
+ ...defaultSchema,
28
+ clobber: ['name'],
29
+ attributes: {
30
+ ...defaultSchema.attributes,
31
+ '*': [...(defaultSchema.attributes?.['*'] ?? []).filter((a) => a !== 'id'), ['id', /^user-content-/]],
32
+ div: [...(defaultSchema.attributes?.div ?? []), 'dataType'],
33
+ code: [...(defaultSchema.attributes?.code ?? []), 'dataMeta'],
34
+ },
35
+ }
36
+
37
+ /** Remark plugin that preserves code-fence meta strings (e.g. ```text Python)
38
+ * as data-meta attributes so they survive the remark→rehype conversion. */
39
+ function remarkCodeMeta() {
40
+ return (tree: Root) => {
41
+ function walk(node: Root | Root['children'][number]) {
42
+ if (node.type === 'code') {
43
+ const code = node as Code
44
+ if (code.meta) {
45
+ ;(code as any).data = (code as any).data || {}
46
+ ;(code as any).data.hProperties = (code as any).data.hProperties || {}
47
+ ;(code as any).data.hProperties['data-meta'] = code.meta
48
+ }
49
+ }
50
+ if ('children' in node && Array.isArray(node.children)) {
51
+ for (const child of node.children) walk(child)
52
+ }
53
+ }
54
+ walk(tree)
55
+ }
56
+ }
57
+ import { Callout } from '../docs/callout'
58
+ import { CodeGroup } from '../docs/code-group'
59
+ import { DocsTabs, DocsTab } from '../docs/doc-tabs'
60
+ import { Steps, Step } from '../docs/steps'
61
+ import { isCloudInferenceUrl, isVideoUrl, getYouTubeVideoId, stripHtmlComments } from './helpers'
62
+ import { YouTubeEmbed } from './youtube-embed'
63
+ import { CodeBlock as MarkdownCodeBlock } from '../components/code-block'
64
+
65
+ // Heading with anchor link
66
+ function HeadingWithAnchor({
67
+ level,
68
+ children,
69
+ id,
70
+ }: {
71
+ level: 1 | 2 | 3 | 4 | 5 | 6
72
+ children: React.ReactNode
73
+ id?: string
74
+ }) {
75
+ const getTextContent = (node: React.ReactNode): string => {
76
+ if (typeof node === 'string') return node
77
+ if (typeof node === 'number') return String(node)
78
+ if (!node) return ''
79
+ if (Array.isArray(node)) return node.map(getTextContent).join('')
80
+ if (React.isValidElement(node)) {
81
+ return getTextContent((node.props as { children?: React.ReactNode }).children)
82
+ }
83
+ return ''
84
+ }
85
+
86
+ const text = getTextContent(children)
87
+ const headingId =
88
+ id ||
89
+ text
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9]+/g, '-')
92
+ .replace(/(^-|-$)/g, '')
93
+
94
+ const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
95
+ const sizeClasses = {
96
+ 1: 'text-3xl font-bold mt-0 mb-6',
97
+ 2: 'text-2xl font-bold mt-12 mb-4 pb-2 border-b border-border',
98
+ 3: 'text-xl font-semibold mt-10 mb-3',
99
+ 4: 'text-lg font-semibold mt-8 mb-2',
100
+ 5: 'text-base font-semibold mt-6 mb-2',
101
+ 6: 'text-base font-medium mt-4 mb-2',
102
+ }
103
+
104
+ return (
105
+ <Tag
106
+ id={headingId}
107
+ className={cn(
108
+ sizeClasses[level],
109
+ 'group scroll-mt-20 flex items-center gap-2 lowercase',
110
+ '[&:first-child]:mt-0'
111
+ )}
112
+ >
113
+ {children}
114
+ <a
115
+ href={`#${headingId}`}
116
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-brand-pink"
117
+ aria-label={`Link to ${text}`}
118
+ >
119
+ <Hash className="h-4 w-4" />
120
+ </a>
121
+ </Tag>
122
+ )
123
+ }
124
+
125
+ // Parse callout blocks from blockquotes
126
+ function parseCalloutFromBlockquote(children: React.ReactNode): {
127
+ type: 'note' | 'tip' | 'info' | 'warning' | 'danger' | 'example' | 'terminal'
128
+ title?: string
129
+ content: React.ReactNode
130
+ } | null {
131
+ const childArray = React.Children.toArray(children)
132
+ if (childArray.length === 0) return null
133
+
134
+ const firstChild = childArray[0]
135
+ if (!React.isValidElement(firstChild)) return null
136
+
137
+ const props = firstChild.props as { children?: React.ReactNode }
138
+ if (!props.children) return null
139
+
140
+ const innerChildren = React.Children.toArray(props.children)
141
+ if (innerChildren.length === 0) return null
142
+
143
+ const firstText = innerChildren[0]
144
+ if (typeof firstText !== 'string') return null
145
+
146
+ const match = firstText.match(
147
+ /^\[!(NOTE|TIP|INFO|WARNING|DANGER|EXAMPLE|TERMINAL)\](?:\s*(.*))?/
148
+ )
149
+ if (!match) return null
150
+
151
+ const type = match[1].toLowerCase() as
152
+ | 'note'
153
+ | 'tip'
154
+ | 'info'
155
+ | 'warning'
156
+ | 'danger'
157
+ | 'example'
158
+ | 'terminal'
159
+ const title = match[2]?.trim() || undefined
160
+
161
+ const restOfFirstText = firstText
162
+ .replace(
163
+ /^\[!(NOTE|TIP|INFO|WARNING|DANGER|EXAMPLE|TERMINAL)\](?:\s*(.*))?/,
164
+ ''
165
+ )
166
+ .trim()
167
+ const restOfInnerChildren = innerChildren.slice(1)
168
+ const restOfChildren = childArray.slice(1)
169
+
170
+ const content = (
171
+ <>
172
+ {restOfFirstText && <p>{restOfFirstText}</p>}
173
+ {restOfInnerChildren}
174
+ {restOfChildren}
175
+ </>
176
+ )
177
+
178
+ return { type, title, content }
179
+ }
180
+
181
+ // Parse YAML frontmatter from markdown content.
182
+ // Returns [frontmatter entries, body without frontmatter].
183
+ function parseFrontmatter(content: string): [Record<string, string>, string] {
184
+ if (!content.startsWith('---')) return [{}, content]
185
+ const lines = content.split('\n')
186
+ let endIndex = -1
187
+ for (let i = 1; i < lines.length; i++) {
188
+ if (lines[i].trim() === '---') {
189
+ endIndex = i
190
+ break
191
+ }
192
+ }
193
+ if (endIndex === -1) return [{}, content]
194
+
195
+ const entries: Record<string, string> = {}
196
+ let currentKey = ''
197
+ let currentValue = ''
198
+
199
+ for (let i = 1; i < endIndex; i++) {
200
+ const line = lines[i]
201
+ const match = line.match(/^(\w[\w-]*):\s*(.*)/)
202
+ if (match) {
203
+ if (currentKey) entries[currentKey] = currentValue.trim()
204
+ currentKey = match[1]
205
+ currentValue = match[2]
206
+ } else if (currentKey && (line.startsWith(' ') || line.startsWith('\t'))) {
207
+ currentValue += ' ' + line.trim()
208
+ }
209
+ }
210
+ if (currentKey) entries[currentKey] = currentValue.trim()
211
+
212
+ const body = lines.slice(endIndex + 1).join('\n').replace(/^\n/, '')
213
+ return [entries, body]
214
+ }
215
+
216
+ // Render frontmatter as a GitHub-style metadata table
217
+ function FrontmatterTable({ entries }: { entries: Record<string, string> }) {
218
+ const keys = Object.keys(entries).filter(k => entries[k])
219
+ if (keys.length === 0) return null
220
+
221
+ return (
222
+ <div className="my-4 w-full overflow-x-auto rounded-xl border border-border">
223
+ <table className="w-full text-sm">
224
+ <tbody>
225
+ {keys.map(key => (
226
+ <tr key={key} className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
227
+ <td className="px-4 py-2 font-medium text-muted-foreground w-1/4 bg-muted/30">{key}</td>
228
+ <td className="px-4 py-2 text-foreground/90">{entries[key]}</td>
229
+ </tr>
230
+ ))}
231
+ </tbody>
232
+ </table>
233
+ </div>
234
+ )
235
+ }
236
+
237
+ export interface DocsMarkdownRendererProps {
238
+ content: string
239
+ className?: string
240
+ /** Custom renderer for cloud.inference.sh file links */
241
+ renderFilePreview?: (props: { uri: string; filename: string }) => React.ReactNode
242
+ /** Custom renderer for cloud.inference.sh images */
243
+ renderCloudImage?: (props: { src: string; alt?: string }) => React.ReactNode
244
+ }
245
+
246
+ export const DocsMarkdownRenderer = memo(function DocsMarkdownRenderer({
247
+ content,
248
+ className,
249
+ renderFilePreview,
250
+ renderCloudImage,
251
+ }: DocsMarkdownRendererProps) {
252
+ const [frontmatter, bodyContent] = useMemo(
253
+ () => parseFrontmatter(content),
254
+ [content]
255
+ )
256
+ const processedContent = useMemo(
257
+ () => stripHtmlComments(bodyContent),
258
+ [bodyContent]
259
+ )
260
+
261
+ return (
262
+ <div className={cn('docs-content', className)}>
263
+ {Object.keys(frontmatter).length > 0 && (
264
+ <FrontmatterTable entries={frontmatter} />
265
+ )}
266
+ <Markdown
267
+ urlTransform={safeUrlTransform}
268
+ remarkPlugins={[remarkCodeMeta, [remarkGfm, { singleTilde: false }]]}
269
+ rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownSanitizeSchema]]}
270
+ components={{
271
+ // Headings with anchors
272
+ h1: ({ children }) => <HeadingWithAnchor level={1}>{children}</HeadingWithAnchor>,
273
+ h2: ({ children }) => <HeadingWithAnchor level={2}>{children}</HeadingWithAnchor>,
274
+ h3: ({ children }) => <HeadingWithAnchor level={3}>{children}</HeadingWithAnchor>,
275
+ h4: ({ children }) => <HeadingWithAnchor level={4}>{children}</HeadingWithAnchor>,
276
+ h5: ({ children }) => <HeadingWithAnchor level={5}>{children}</HeadingWithAnchor>,
277
+ h6: ({ children }) => <HeadingWithAnchor level={6}>{children}</HeadingWithAnchor>,
278
+
279
+ // Paragraphs
280
+ p: ({ children }) => {
281
+ const childArray = React.Children.toArray(children)
282
+ const hasBlockContent = childArray.some(
283
+ (child) => {
284
+ if (!React.isValidElement(child)) return false
285
+ const props = child.props as { href?: string; src?: string; children?: React.ReactNode }
286
+ if (props.src) return true
287
+ if (props.href && isCloudInferenceUrl(props.href)) return true
288
+ // YouTube embed only when it's a bare link (link text = URL)
289
+ if (props.href && getYouTubeVideoId(props.href)) {
290
+ const linkChildren = React.Children.toArray(props.children)
291
+ return linkChildren.length === 1 && typeof linkChildren[0] === 'string' && !!getYouTubeVideoId(linkChildren[0])
292
+ }
293
+ return false
294
+ }
295
+ )
296
+ if (hasBlockContent) {
297
+ return <div className="my-4">{children}</div>
298
+ }
299
+ return (
300
+ <p className="text-base leading-7 text-foreground/90 my-4 [&:first-child]:mt-0 [&:last-child]:mb-0">
301
+ {children}
302
+ </p>
303
+ )
304
+ },
305
+
306
+ // Blockquotes - with callout detection
307
+ blockquote: ({ children }) => {
308
+ const callout = parseCalloutFromBlockquote(children)
309
+ if (callout) {
310
+ return (
311
+ <Callout type={callout.type} title={callout.title}>
312
+ {callout.content}
313
+ </Callout>
314
+ )
315
+ }
316
+ return (
317
+ <blockquote className="my-6 border-l-4 border-muted-foreground/20 pl-4 text-muted-foreground italic">
318
+ {children}
319
+ </blockquote>
320
+ )
321
+ },
322
+
323
+ // Lists
324
+ ul: ({ children }) => (
325
+ <ul className="my-4 ml-6 list-disc space-y-2 marker:text-muted-foreground/50">
326
+ {children}
327
+ </ul>
328
+ ),
329
+ ol: ({ children }) => (
330
+ <ol className="my-4 ml-6 list-decimal space-y-2 marker:text-muted-foreground/50">
331
+ {children}
332
+ </ol>
333
+ ),
334
+ // id: footnote items (user-content-fn-N) are the targets of the
335
+ // footnote references.
336
+ li: ({ children, id }) => (
337
+ <li id={id} className="text-base leading-7 text-foreground/90 pl-1">
338
+ {children}
339
+ </li>
340
+ ),
341
+
342
+ // Inline elements
343
+ strong: ({ children }) => (
344
+ <strong className="font-semibold text-foreground">{children}</strong>
345
+ ),
346
+ em: ({ children }) => <em className="italic">{children}</em>,
347
+
348
+ // Code
349
+ code(props) {
350
+ const { children, className, node } = props as any
351
+ const match = /language-(\w+)/.exec(className || '')
352
+ const hasNewlines = String(children).includes('\n')
353
+ const isBlock = match || hasNewlines
354
+ const meta = node?.properties?.['dataMeta'] || undefined
355
+
356
+ if (isBlock) {
357
+ return (
358
+ <code className={cn('font-mono', className)} data-meta={meta}>{children}</code>
359
+ )
360
+ }
361
+ // Inline code
362
+ return (
363
+ <code className="relative rounded-md bg-muted px-[0.4rem] py-[0.2rem] font-mono text-sm text-brand-pink">
364
+ {children}
365
+ </code>
366
+ )
367
+ },
368
+
369
+ pre: ({ children }) => {
370
+ const codeChild = React.Children.toArray(children)[0]
371
+ let language: string | undefined
372
+ let meta: string | undefined
373
+ if (React.isValidElement(codeChild)) {
374
+ const codeProps = codeChild.props as { className?: string; 'data-meta'?: string }
375
+ const className = codeProps.className || ''
376
+ const match = /language-(\w+)/.exec(className)
377
+ if (match) {
378
+ language = match[1]
379
+ }
380
+ meta = codeProps['data-meta']
381
+ }
382
+ // textSize is explicit because the shared CodeBlock defaults to
383
+ // text-xs for the app's dense panels; docs prose wants text-sm.
384
+ return <MarkdownCodeBlock language={language} data-meta={meta} textSize="text-sm">{children}</MarkdownCodeBlock>
385
+ },
386
+
387
+ // Links
388
+ // id: footnote references (user-content-fnref-N) are the targets
389
+ // of the footnotes' back links.
390
+ a: ({ href, children, id }) => {
391
+ if (href) {
392
+ // YouTube videos — only embed when the link text is the URL itself
393
+ // (bare URL on its own line), not when it has a custom label like [text](url)
394
+ const videoId = getYouTubeVideoId(href)
395
+ const childArray = React.Children.toArray(children)
396
+ const isBareLink = childArray.length === 1 && typeof childArray[0] === 'string' && getYouTubeVideoId(childArray[0])
397
+ if (videoId && isBareLink) {
398
+ return <YouTubeEmbed videoId={videoId} />
399
+ }
400
+
401
+ // Cloud video URLs — embed as <video> player
402
+ if (isCloudInferenceUrl(href) && isVideoUrl(href)) {
403
+ return (
404
+ <video
405
+ src={href}
406
+ controls
407
+ playsInline
408
+ className="my-6 rounded-xl w-full max-w-2xl"
409
+ />
410
+ )
411
+ }
412
+
413
+ // Cloud file previews (custom renderer)
414
+ if (isCloudInferenceUrl(href) && renderFilePreview) {
415
+ return renderFilePreview({
416
+ uri: href,
417
+ filename: typeof children === 'string' ? children : 'file',
418
+ })
419
+ }
420
+
421
+ // External vs internal links
422
+ const isExternal = href.startsWith('http')
423
+ return (
424
+ <a
425
+ id={id}
426
+ href={href}
427
+ className="text-brand-pink font-medium hover:underline underline-offset-4 inline-flex items-center gap-1"
428
+ target={isExternal ? '_blank' : undefined}
429
+ rel={isExternal ? 'noopener noreferrer' : undefined}
430
+ >
431
+ {children}
432
+ {isExternal && <LinkIcon className="h-3 w-3" />}
433
+ </a>
434
+ )
435
+ }
436
+ return <>{children}</>
437
+ },
438
+
439
+ // Images
440
+ img: ({ src, alt }) => {
441
+ if (!src || typeof src !== 'string') return null
442
+
443
+ // Cloud images (custom renderer)
444
+ if (isCloudInferenceUrl(src) && renderCloudImage) {
445
+ return renderCloudImage({ src, alt })
446
+ }
447
+
448
+ return (
449
+ <ZoomableImage
450
+ src={src}
451
+ alt={alt || ''}
452
+ className="my-6 rounded-xl border border-border max-w-full h-auto"
453
+ />
454
+ )
455
+ },
456
+
457
+ // Tables
458
+ table: ({ children }) => (
459
+ <div className="my-6 w-full overflow-x-auto rounded-xl border border-border">
460
+ <table className="w-full text-sm">{children}</table>
461
+ </div>
462
+ ),
463
+ thead: ({ children }) => (
464
+ <thead className="bg-muted/50 border-b border-border">
465
+ {children}
466
+ </thead>
467
+ ),
468
+ tbody: ({ children }) => <tbody>{children}</tbody>,
469
+ tr: ({ children }) => (
470
+ <tr className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
471
+ {children}
472
+ </tr>
473
+ ),
474
+ th: ({ children }) => (
475
+ <th className="px-4 py-3 text-left font-semibold text-foreground">
476
+ {children}
477
+ </th>
478
+ ),
479
+ td: ({ children }) => (
480
+ <td className="px-4 py-3 text-foreground/90">{children}</td>
481
+ ),
482
+
483
+ // Horizontal rule
484
+ hr: () => <hr className="my-8 border-t border-border" />,
485
+
486
+ // Strikethrough
487
+ s: ({ children }) => (
488
+ <s className="line-through text-muted-foreground">{children}</s>
489
+ ),
490
+
491
+ // Custom components via data-type attribute
492
+ div: ({ children, node, ...props }) => {
493
+ const dataType = node?.properties?.['dataType'] as string | undefined
494
+
495
+ switch (dataType) {
496
+ case 'codegroup':
497
+ return <CodeGroup>{children}</CodeGroup>
498
+ case 'tabs':
499
+ return <DocsTabs>{children}</DocsTabs>
500
+ case 'tab':
501
+ const tabTitle = node?.properties?.['title'] as string | undefined
502
+ return <DocsTab title={tabTitle || 'Tab'}>{children}</DocsTab>
503
+ case 'steps':
504
+ return <Steps>{children}</Steps>
505
+ case 'step':
506
+ const stepTitle = node?.properties?.['title'] as string | undefined
507
+ return <Step title={stepTitle || ''}>{children}</Step>
508
+ default:
509
+ return <div {...props}>{children}</div>
510
+ }
511
+ },
512
+ }}
513
+ >
514
+ {processedContent}
515
+ </Markdown>
516
+ </div>
517
+ )
518
+ })
@@ -0,0 +1,95 @@
1
+ // Shared markdown utilities
2
+
3
+ /**
4
+ * Detect if URL is from cloud.inference.sh
5
+ */
6
+ export function isCloudInferenceUrl(url: string): boolean {
7
+ try {
8
+ const urlObj = new URL(url)
9
+ return urlObj.hostname === 'cloud.inference.sh'
10
+ } catch {
11
+ return false
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Extract YouTube video ID from various URL formats
17
+ */
18
+ export function getYouTubeVideoId(url: string): string | null {
19
+ try {
20
+ if (!url) return null
21
+ const urlObj = new URL(url)
22
+
23
+ // youtu.be format
24
+ if (urlObj.hostname === 'youtu.be') {
25
+ return urlObj.pathname.slice(1)
26
+ }
27
+
28
+ // youtube.com formats
29
+ if (urlObj.hostname === 'youtube.com' || urlObj.hostname === 'www.youtube.com') {
30
+ // Watch URLs: youtube.com/watch?v=VIDEO_ID
31
+ if (urlObj.searchParams.has('v')) {
32
+ return urlObj.searchParams.get('v')
33
+ }
34
+ // Embed URLs: youtube.com/embed/VIDEO_ID
35
+ if (urlObj.pathname.startsWith('/embed/')) {
36
+ return urlObj.pathname.split('/')[2]
37
+ }
38
+ }
39
+
40
+ return null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.ogg']
47
+
48
+ /**
49
+ * Check if a URL points to a video file
50
+ */
51
+ export function isVideoUrl(url: string): boolean {
52
+ try {
53
+ const pathname = new URL(url).pathname.toLowerCase()
54
+ return VIDEO_EXTENSIONS.some(ext => pathname.endsWith(ext))
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Strip HTML comments from markdown content
62
+ */
63
+ export function stripHtmlComments(content: string): string {
64
+ return content.replace(/<!--[\s\S]*?-->/g, '')
65
+ }
66
+
67
+ /**
68
+ * Heuristic: determine if a string is likely Markdown rather than plain text
69
+ */
70
+ export function isLikelyMarkdown(input: string): boolean {
71
+ if (!input) return false
72
+ let score = 0
73
+
74
+ // Headings (# ...)
75
+ if (/(?:^|\n)\s{0,3}#{1,6} \S/.test(input)) score += 2
76
+ // Bold text
77
+ if (/\*\*[\s\S]*?\*\*/.test(input)) score += 1
78
+ // Italic text
79
+ if (/(?:^|[^*])\*[^*\s][\s\S]*?\*(?:[^*]|$)/.test(input)) score += 1
80
+ // Lists (-, *, +, or ordered)
81
+ if (/(?:^|\n)\s{0,3}(?:[-*+] |\d+\. )\S/.test(input)) score += 1
82
+ // Links/images
83
+ if (/!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)/.test(input)) score += 2
84
+ // Code blocks or inline code
85
+ if (/(?:^|\n)```[\s\S]*?```/.test(input) || /`[^`\n]+`/.test(input)) score += 2
86
+ // Blockquote
87
+ if (/(?:^|\n)\s{0,3}>\s?\S/.test(input)) score += 1
88
+ // Tables (GFM)
89
+ if (/(?:^|\n)\|[^\n]*\|\s*(?:\n)\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?/.test(input)) score += 2
90
+ // Horizontal rule
91
+ if (/(?:^|\n)\s{0,3}(?:-{3,}|_{3,}|\*{3,})\s*(?:\n|$)/.test(input)) score += 1
92
+
93
+ return score > 0
94
+ }
95
+
@@ -0,0 +1,24 @@
1
+ // Markdown rendering components
2
+ // Shared between app and web
3
+
4
+ // Renderers
5
+ export { MarkdownRenderer } from './markdown-renderer'
6
+ export { DocsMarkdownRenderer } from './docs-markdown-renderer'
7
+ export type { MarkdownRendererProps } from './markdown-renderer'
8
+ export type { DocsMarkdownRendererProps } from './docs-markdown-renderer'
9
+
10
+ // Components
11
+ export { YouTubeEmbed } from './youtube-embed'
12
+
13
+ // Code blocks - re-export from the code-block module with backward-compatible names
14
+ export { CodeBlock as MarkdownCodeBlock, CompactCodeBlock } from '../components/code-block'
15
+ export type { CodeBlockProps as MarkdownCodeBlockProps } from '../components/code-block'
16
+
17
+ // Helpers
18
+ export {
19
+ isCloudInferenceUrl,
20
+ getYouTubeVideoId,
21
+ stripHtmlComments,
22
+ isLikelyMarkdown,
23
+ } from './helpers'
24
+