@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.
- package/LICENSE +22 -0
- package/README.md +68 -2
- package/components.json +22 -0
- package/package.json +231 -4
- package/src/components/audio-player.tsx +549 -0
- package/src/components/button.tsx +13 -0
- package/src/components/chart.tsx +403 -0
- package/src/components/circuit.tsx +276 -0
- package/src/components/code-block/code-block.tsx +260 -0
- package/src/components/code-block/index.ts +18 -0
- package/src/components/code-block/languages.ts +311 -0
- package/src/components/code-block/styles.ts +39 -0
- package/src/components/code-block/tokenizer.ts +290 -0
- package/src/components/code-block/types.ts +38 -0
- package/src/components/code-block/utils.ts +39 -0
- package/src/components/code.tsx +48 -0
- package/src/components/cookie-consent.tsx +137 -0
- package/src/components/copy-button.tsx +44 -0
- package/src/components/country-dropdown.tsx +188 -0
- package/src/components/glow-loader-fast.tsx +32 -0
- package/src/components/glow-loader-mono.tsx +33 -0
- package/src/components/glow-loader-sm-fast.tsx +32 -0
- package/src/components/glow-loader-sm-mono.tsx +34 -0
- package/src/components/glow-loader-sm.tsx +32 -0
- package/src/components/glow-loader.tsx +32 -0
- package/src/components/glow-logo.tsx +32 -0
- package/src/components/gradient.tsx +80 -0
- package/src/components/index.ts +45 -0
- package/src/components/inline-sidebar.tsx +351 -0
- package/src/components/input-color.tsx +349 -0
- package/src/components/logo-base.tsx +213 -0
- package/src/components/logo-color.tsx +23 -0
- package/src/components/logo-full.tsx +71 -0
- package/src/components/logo-svg.tsx +23 -0
- package/src/components/menu-navigation.tsx +359 -0
- package/src/components/multi-select.tsx +721 -0
- package/src/components/pastel-gradient.tsx +25 -0
- package/src/components/pixel-symmetry.tsx +151 -0
- package/src/components/progress.tsx +28 -0
- package/src/components/scroll-more.tsx +131 -0
- package/src/components/select.tsx +85 -0
- package/src/components/shimmering-text.tsx +108 -0
- package/src/components/sidebar-light.tsx +102 -0
- package/src/components/sonner.tsx +38 -0
- package/src/components/theme-switcher-sidebar.tsx +56 -0
- package/src/components/theme-switcher-simple.tsx +110 -0
- package/src/components/theme-switcher.tsx +47 -0
- package/src/components/toggle-group.tsx +55 -0
- package/src/components/tooltip-button.tsx +91 -0
- package/src/components/tooltip.tsx +71 -0
- package/src/components/tree-view.tsx +499 -0
- package/src/components/unglow-loader.tsx +33 -0
- package/src/components/waveform.tsx +1632 -0
- package/src/components/zoomable-image.tsx +76 -0
- package/src/docs/api-docs.tsx +763 -0
- package/src/docs/callout.tsx +160 -0
- package/src/docs/code-group.tsx +215 -0
- package/src/docs/copy-menu.tsx +143 -0
- package/src/docs/doc-tabs.tsx +84 -0
- package/src/docs/index.ts +16 -0
- package/src/docs/steps.tsx +105 -0
- package/src/docs/table-of-contents.tsx +174 -0
- package/src/examples/guide-markdown.ts +129 -0
- package/src/examples/index.ts +1153 -0
- package/src/global.d.ts +1 -0
- package/src/hooks/use-copy-to-clipboard.ts +36 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/display-name.ts +15 -0
- package/src/lib/example-input.ts +130 -0
- package/src/lib/plans.ts +44 -0
- package/src/lib/safe-content.ts +137 -0
- package/src/lib/schema.ts +320 -0
- package/src/lib/storage.ts +108 -0
- package/src/markdown/docs-markdown-renderer.tsx +518 -0
- package/src/markdown/helpers.ts +95 -0
- package/src/markdown/index.ts +24 -0
- package/src/markdown/markdown-renderer.tsx +232 -0
- package/src/markdown/youtube-embed.tsx +43 -0
- package/src/og/image.ts +312 -0
- package/src/og/index.tsx +430 -0
- package/src/og/query.ts +26 -0
- package/src/shadcn/accordion.tsx +71 -0
- package/src/shadcn/alert-dialog.tsx +187 -0
- package/src/shadcn/alert.tsx +75 -0
- package/src/shadcn/aspect-ratio.tsx +22 -0
- package/src/shadcn/attachment.tsx +207 -0
- package/src/shadcn/avatar.tsx +106 -0
- package/src/shadcn/badge.tsx +51 -0
- package/src/shadcn/breadcrumb.tsx +124 -0
- package/src/shadcn/bubble.tsx +127 -0
- package/src/shadcn/button-group.tsx +87 -0
- package/src/shadcn/button.tsx +57 -0
- package/src/shadcn/calendar.tsx +219 -0
- package/src/shadcn/card.tsx +102 -0
- package/src/shadcn/carousel.tsx +242 -0
- package/src/shadcn/chart.tsx +372 -0
- package/src/shadcn/checkbox.tsx +28 -0
- package/src/shadcn/collapsible.tsx +19 -0
- package/src/shadcn/combobox.tsx +297 -0
- package/src/shadcn/command.tsx +194 -0
- package/src/shadcn/context-menu.tsx +270 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/direction.tsx +4 -0
- package/src/shadcn/drawer.tsx +227 -0
- package/src/shadcn/dropdown-menu.tsx +265 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +238 -0
- package/src/shadcn/form.tsx +173 -0
- package/src/shadcn/hover-card.tsx +50 -0
- package/src/shadcn/index.ts +53 -0
- package/src/shadcn/input-group.tsx +156 -0
- package/src/shadcn/input-otp.tsx +84 -0
- package/src/shadcn/input.tsx +19 -0
- package/src/shadcn/item.tsx +201 -0
- package/src/shadcn/kbd.tsx +26 -0
- package/src/shadcn/label.tsx +19 -0
- package/src/shadcn/marker.tsx +70 -0
- package/src/shadcn/menubar.tsx +278 -0
- package/src/shadcn/message-scroller.tsx +131 -0
- package/src/shadcn/message.tsx +91 -0
- package/src/shadcn/native-select.tsx +60 -0
- package/src/shadcn/navigation-menu.tsx +167 -0
- package/src/shadcn/pagination.tsx +130 -0
- package/src/shadcn/popover.tsx +87 -0
- package/src/shadcn/progress.tsx +82 -0
- package/src/shadcn/radio-group.tsx +35 -0
- package/src/shadcn/resizable.tsx +49 -0
- package/src/shadcn/scroll-area.tsx +52 -0
- package/src/shadcn/select.tsx +200 -0
- package/src/shadcn/separator.tsx +22 -0
- package/src/shadcn/sheet.tsx +136 -0
- package/src/shadcn/sidebar.tsx +721 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/slider.tsx +51 -0
- package/src/shadcn/sonner.tsx +49 -0
- package/src/shadcn/spinner.tsx +10 -0
- package/src/shadcn/switch.tsx +29 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +79 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +89 -0
- package/src/shadcn/toggle.tsx +44 -0
- package/src/shadcn/tooltip.tsx +63 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/css/base.css +92 -0
- package/src/theme/css/bitboy.css +75 -0
- package/src/theme/css/dark.css +47 -0
- package/src/theme/css/designer.css +122 -0
- package/src/theme/css/fonts.css +33 -0
- package/src/theme/css/globals.css +14 -0
- package/src/theme/css/hackintosh.css +57 -0
- package/src/theme/css/llama.css +103 -0
- package/src/theme/css/safelist.css +39 -0
- package/src/theme/css/shadcn.css +77 -0
- package/src/theme/css/theme.css +216 -0
- package/src/theme/embed-theme.ts +146 -0
- package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
- package/src/theme/fonts/Hack-Regular.woff2 +0 -0
- package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
- package/src/theme/fonts/LICENSE-Hack.txt +45 -0
- package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
- package/src/theme/fonts/Mona-Sans.woff2 +0 -0
- package/src/theme/index.ts +15 -0
- package/src/utils/cn.ts +3 -0
- package/src/utils/index.ts +2 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from "cn"
|
|
4
|
+
import ZoomableImage from "../components/zoomable-image"
|
|
5
|
+
import React, { memo, useMemo } from 'react'
|
|
6
|
+
import Markdown from 'react-markdown'
|
|
7
|
+
import { safeUrlTransform } from '../lib/safe-content'
|
|
8
|
+
import remarkGfm from 'remark-gfm'
|
|
9
|
+
import { isCloudInferenceUrl, isVideoUrl, getYouTubeVideoId, stripHtmlComments } from './helpers'
|
|
10
|
+
import { YouTubeEmbed } from './youtube-embed'
|
|
11
|
+
import { CompactCodeBlock } from '../components/code-block'
|
|
12
|
+
|
|
13
|
+
export interface MarkdownRendererProps {
|
|
14
|
+
content: string
|
|
15
|
+
className?: string
|
|
16
|
+
/** Compact mode for dense UI like chat */
|
|
17
|
+
compact?: boolean
|
|
18
|
+
/** Custom file preview renderer for cloud URLs */
|
|
19
|
+
renderFilePreview?: (props: { uri: string; filename: string }) => React.ReactNode
|
|
20
|
+
/** Custom image renderer for cloud URLs */
|
|
21
|
+
renderCloudImage?: (props: { src: string; alt?: string }) => React.ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const textSizeMap = {
|
|
25
|
+
default: {
|
|
26
|
+
h1: 'text-xl',
|
|
27
|
+
h2: 'text-lg',
|
|
28
|
+
h3: 'text-md',
|
|
29
|
+
h4: 'text-base',
|
|
30
|
+
h5: 'text-sm',
|
|
31
|
+
h6: 'text-sm',
|
|
32
|
+
p: 'text-sm',
|
|
33
|
+
li: 'text-sm',
|
|
34
|
+
code: 'text-xs',
|
|
35
|
+
pre: 'text-xs',
|
|
36
|
+
blockquote: 'text-sm',
|
|
37
|
+
ul: 'text-sm',
|
|
38
|
+
ol: 'text-sm',
|
|
39
|
+
th: 'text-sm',
|
|
40
|
+
td: 'text-sm',
|
|
41
|
+
},
|
|
42
|
+
compact: {
|
|
43
|
+
h1: 'text-xl',
|
|
44
|
+
h2: 'text-lg',
|
|
45
|
+
h3: 'text-base',
|
|
46
|
+
h4: 'text-xs',
|
|
47
|
+
h5: 'text-xs',
|
|
48
|
+
h6: 'text-xs',
|
|
49
|
+
p: 'text-xs',
|
|
50
|
+
li: 'text-xs',
|
|
51
|
+
code: 'text-xs',
|
|
52
|
+
pre: 'text-xs',
|
|
53
|
+
blockquote: 'text-xs',
|
|
54
|
+
ul: 'text-xs',
|
|
55
|
+
ol: 'text-xs',
|
|
56
|
+
th: 'text-xs',
|
|
57
|
+
td: 'text-xs',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const MarkdownRenderer = memo(function MarkdownRenderer({
|
|
62
|
+
content,
|
|
63
|
+
className,
|
|
64
|
+
compact = false,
|
|
65
|
+
renderFilePreview,
|
|
66
|
+
renderCloudImage,
|
|
67
|
+
}: MarkdownRendererProps) {
|
|
68
|
+
const processedContent = useMemo(
|
|
69
|
+
() => stripHtmlComments(content),
|
|
70
|
+
[content]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const textSize = compact ? textSizeMap.compact : textSizeMap.default
|
|
74
|
+
const fontClass = 'legible'
|
|
75
|
+
const paragraphClass = cn(textSize.p, 'break-words whitespace-pre-wrap my-0 py-0 text-justify leading-relaxed', fontClass)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={className}>
|
|
79
|
+
<Markdown
|
|
80
|
+
urlTransform={safeUrlTransform}
|
|
81
|
+
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
|
82
|
+
components={{
|
|
83
|
+
// Block elements
|
|
84
|
+
h1: ({ children }) => <h1 className={cn(textSize.h1, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h1>,
|
|
85
|
+
h2: ({ children }) => <h2 className={cn(textSize.h2, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h2>,
|
|
86
|
+
h3: ({ children }) => <h3 className={cn(textSize.h3, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h3>,
|
|
87
|
+
h4: ({ children }) => <h4 className={cn(textSize.h4, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h4>,
|
|
88
|
+
h5: ({ children }) => <h5 className={cn(textSize.h5, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h5>,
|
|
89
|
+
h6: ({ children }) => <h6 className={cn(textSize.h6, 'font-bold my-0 [&:first-child]:mt-0 mt-8 py-0', fontClass)}>{children}</h6>,
|
|
90
|
+
|
|
91
|
+
p: ({ children }) => {
|
|
92
|
+
// Detect if paragraph contains block-level elements that can't be inside <p>
|
|
93
|
+
// This includes YouTube embeds, cloud file previews, and images
|
|
94
|
+
const hasBlockContent = React.Children.toArray(children).some((child) => {
|
|
95
|
+
if (!React.isValidElement(child)) return false
|
|
96
|
+
const props = child.props as { href?: string; src?: string }
|
|
97
|
+
// Check for YouTube links
|
|
98
|
+
if (props.href && getYouTubeVideoId(props.href)) return true
|
|
99
|
+
// Check for cloud URLs
|
|
100
|
+
if (props.href && isCloudInferenceUrl(props.href)) return true
|
|
101
|
+
// Check for images
|
|
102
|
+
if (props.src) return true
|
|
103
|
+
return false
|
|
104
|
+
})
|
|
105
|
+
// Use div for block content to avoid invalid <div> inside <p> nesting
|
|
106
|
+
return hasBlockContent && !compact ? (
|
|
107
|
+
<div className={paragraphClass}>{children}</div>
|
|
108
|
+
) : (
|
|
109
|
+
<p className={paragraphClass}>{children}</p>
|
|
110
|
+
)
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
blockquote: ({ children }) => (
|
|
114
|
+
<blockquote className={cn('border-l-4 pl-4 my-2 py-0', fontClass)}>{children}</blockquote>
|
|
115
|
+
),
|
|
116
|
+
|
|
117
|
+
// Lists
|
|
118
|
+
ul: ({ children }) => <ul className={cn('list-disc list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ul>,
|
|
119
|
+
ol: ({ children }) => <ol className={cn('list-decimal list-outside ml-5 my-0 mt-2 py-0', fontClass)}>{children}</ol>,
|
|
120
|
+
li: ({ children }) => <li className={cn(textSize.li, 'my-0 mt-2 py-0 pl-1', fontClass)}>{children}</li>,
|
|
121
|
+
|
|
122
|
+
// Inline elements
|
|
123
|
+
strong: ({ children }) => <strong className={cn('font-bold', fontClass)}>{children}</strong>,
|
|
124
|
+
em: ({ children }) => <em className={cn('italic', fontClass)}>{children}</em>,
|
|
125
|
+
|
|
126
|
+
code(props) {
|
|
127
|
+
const { children, className } = props
|
|
128
|
+
const hasLang = /language-(\w+)/.test(className || '')
|
|
129
|
+
const hasNewlines = String(children).includes('\n')
|
|
130
|
+
const isBlock = hasLang || hasNewlines
|
|
131
|
+
|
|
132
|
+
if (isBlock) {
|
|
133
|
+
return <code className={cn(textSize.code, fontClass, className)}>{children}</code>
|
|
134
|
+
}
|
|
135
|
+
// Inline code
|
|
136
|
+
return (
|
|
137
|
+
<span className="inline bg-muted/30 rounded px-1 text-brand-pink/80">
|
|
138
|
+
<code className={cn(textSize.code, fontClass)}>{children}</code>
|
|
139
|
+
</span>
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
pre: ({ children }) => (
|
|
144
|
+
<CompactCodeBlock textSize={textSize.pre} className={cn(textSize.pre, fontClass)}>
|
|
145
|
+
{children}
|
|
146
|
+
</CompactCodeBlock>
|
|
147
|
+
),
|
|
148
|
+
|
|
149
|
+
// Links and images
|
|
150
|
+
a: ({ href, children }) => {
|
|
151
|
+
if (href && !compact) {
|
|
152
|
+
// YouTube videos
|
|
153
|
+
const videoId = getYouTubeVideoId(href)
|
|
154
|
+
if (videoId) {
|
|
155
|
+
return <YouTubeEmbed videoId={videoId} />
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Cloud video URLs
|
|
159
|
+
if (isCloudInferenceUrl(href) && isVideoUrl(href)) {
|
|
160
|
+
return (
|
|
161
|
+
<video
|
|
162
|
+
src={href}
|
|
163
|
+
controls
|
|
164
|
+
playsInline
|
|
165
|
+
className="my-6 rounded-xl w-full max-w-2xl"
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cloud file previews
|
|
171
|
+
if (isCloudInferenceUrl(href) && renderFilePreview) {
|
|
172
|
+
return renderFilePreview({
|
|
173
|
+
uri: href,
|
|
174
|
+
filename: typeof children === 'string' ? children : 'file',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Default link
|
|
179
|
+
return (
|
|
180
|
+
<a
|
|
181
|
+
href={href}
|
|
182
|
+
className={cn('hover:underline', fontClass)}
|
|
183
|
+
target="_blank"
|
|
184
|
+
rel="noopener noreferrer"
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
</a>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
return <>{children}</>
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
img: ({ src, alt }) => {
|
|
194
|
+
if (!src || typeof src !== 'string') return null
|
|
195
|
+
|
|
196
|
+
if (isCloudInferenceUrl(src) && renderCloudImage) {
|
|
197
|
+
return renderCloudImage({ src, alt })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<ZoomableImage
|
|
202
|
+
src={src}
|
|
203
|
+
alt={alt}
|
|
204
|
+
className={cn('max-w-full h-auto my-0 py-0 rounded', fontClass)}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// Table elements
|
|
210
|
+
table: ({ children }) => (
|
|
211
|
+
<div className={cn('min-w-0 overflow-x-auto my-2 border border-border rounded-md', fontClass)}>
|
|
212
|
+
<table className="">{children}</table>
|
|
213
|
+
</div>
|
|
214
|
+
),
|
|
215
|
+
thead: ({ children }) => <thead className={cn('border-b border-border', fontClass)}>{children}</thead>,
|
|
216
|
+
tbody: ({ children }) => <tbody className={fontClass}>{children}</tbody>,
|
|
217
|
+
tr: ({ children }) => <tr className="border-b border-border last:border-0">{children}</tr>,
|
|
218
|
+
th: ({ children }) => <th className={cn('px-3 py-1.5 text-left text-xs text-muted-foreground', fontClass)}>{children}</th>,
|
|
219
|
+
td: ({ children }) => <td className={cn('px-3 py-1.5 text-sm', fontClass)}>{children}</td>,
|
|
220
|
+
|
|
221
|
+
// Horizontal rule
|
|
222
|
+
hr: () => <hr className="my-2 border-t" />,
|
|
223
|
+
|
|
224
|
+
// Strikethrough
|
|
225
|
+
s: ({ children }) => <s className="line-through">{children}</s>,
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{processedContent}
|
|
229
|
+
</Markdown>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import GlowLoaderSmMono from '../components/glow-loader-sm-mono'
|
|
6
|
+
|
|
7
|
+
interface YouTubeEmbedProps {
|
|
8
|
+
videoId: string
|
|
9
|
+
title?: string
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function YouTubeEmbed({ videoId, title, className }: YouTubeEmbedProps) {
|
|
14
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn(
|
|
18
|
+
'flex flex-col space-y-4 w-full h-full relative my-6 rounded-xl overflow-hidden border border-border',
|
|
19
|
+
className
|
|
20
|
+
)}>
|
|
21
|
+
{isLoading && (
|
|
22
|
+
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
|
23
|
+
<GlowLoaderSmMono width={32} height={32} className="opacity-60" />
|
|
24
|
+
</div>
|
|
25
|
+
)}
|
|
26
|
+
<iframe
|
|
27
|
+
src={`https://www.youtube.com/embed/${videoId}`}
|
|
28
|
+
title={title || 'YouTube video player'}
|
|
29
|
+
frameBorder="0"
|
|
30
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
31
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
32
|
+
allowFullScreen
|
|
33
|
+
loading="lazy"
|
|
34
|
+
onLoad={() => setIsLoading(false)}
|
|
35
|
+
className={cn(
|
|
36
|
+
'w-full aspect-video h-full bg-transparent transition-opacity duration-300',
|
|
37
|
+
isLoading ? 'opacity-0' : 'opacity-100'
|
|
38
|
+
)}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
package/src/og/image.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching artwork for OG cards, safely.
|
|
3
|
+
*
|
|
4
|
+
* Card backgrounds come from app metadata (`images.card`, `images.banner`),
|
|
5
|
+
* which any app owner can set to any string. INC-2026-11-02: card URLs aimed
|
|
6
|
+
* at internal services (redis, postgres, the metadata endpoint), at open
|
|
7
|
+
* redirectors, and at a 17000x17000 PNG decompression bomb that took the app
|
|
8
|
+
* container from 125 MiB to 1.34 GB inside satori/resvg.
|
|
9
|
+
*
|
|
10
|
+
* So an image is used only when all of these hold, and the card renders
|
|
11
|
+
* without it otherwise:
|
|
12
|
+
* - the URL is https on the default port, with a host on the allowlist (our
|
|
13
|
+
* storage and CDN hosts by default; never an IP literal);
|
|
14
|
+
* - the response is not a redirect, arrives within the timeout, and its body
|
|
15
|
+
* stays under the byte cap while it streams;
|
|
16
|
+
* - the declared content type and the file's own signature are PNG, JPEG,
|
|
17
|
+
* WebP or GIF (no SVG);
|
|
18
|
+
* - the dimensions read from the file header are within the pixel limits.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_OG_IMAGE_HOSTS: readonly string[] = ['cloud.inference.sh', 'cdn.inference.sh']
|
|
22
|
+
|
|
23
|
+
export const OG_IMAGE_LIMITS = {
|
|
24
|
+
timeoutMs: 5000,
|
|
25
|
+
maxBytes: 5 * 1024 * 1024,
|
|
26
|
+
maxDimension: 4096,
|
|
27
|
+
maxPixels: 4096 * 4096,
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
export type OgImageType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
31
|
+
|
|
32
|
+
const ALLOWED_TYPES: ReadonlySet<string> = new Set<OgImageType>([
|
|
33
|
+
'image/png',
|
|
34
|
+
'image/jpeg',
|
|
35
|
+
'image/webp',
|
|
36
|
+
'image/gif',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Host allowlist
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
|
|
44
|
+
|
|
45
|
+
let allowedHosts: ReadonlySet<string> = new Set(DEFAULT_OG_IMAGE_HOSTS)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse an allowlist from config, e.g. the OG_IMAGE_HOSTS env var: hostnames
|
|
49
|
+
* separated by commas or whitespace. Unset or blank gives the defaults. A
|
|
50
|
+
* malformed entry (scheme, port, path, IP literal) throws, so a bad value
|
|
51
|
+
* stops the server at startup instead of silently allowing nothing.
|
|
52
|
+
*/
|
|
53
|
+
export function parseOgImageHosts(value: string | undefined | null): string[] {
|
|
54
|
+
const entries = (value ?? '').split(/[\s,]+/).map((h) => h.trim().toLowerCase()).filter(Boolean)
|
|
55
|
+
if (entries.length === 0) return [...DEFAULT_OG_IMAGE_HOSTS]
|
|
56
|
+
for (const host of entries) {
|
|
57
|
+
if (!HOSTNAME_RE.test(host)) {
|
|
58
|
+
throw new Error(`OG_IMAGE_HOSTS: "${host}" is not a bare hostname`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return entries
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Replace the hosts card images may be fetched from (process-wide). */
|
|
65
|
+
export function configureOgImageHosts(hosts: readonly string[]): void {
|
|
66
|
+
allowedHosts = new Set(hosts.map((h) => h.toLowerCase()))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getOgImageHosts(): string[] {
|
|
70
|
+
return [...allowedHosts]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The URL if it may be fetched under the allowlist, else the reason it may not. */
|
|
74
|
+
export function checkOgImageUrl(
|
|
75
|
+
raw: string,
|
|
76
|
+
hosts: ReadonlySet<string> = allowedHosts,
|
|
77
|
+
): { ok: true; url: URL } | { ok: false; reason: string } {
|
|
78
|
+
let url: URL
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(raw)
|
|
81
|
+
} catch {
|
|
82
|
+
return { ok: false, reason: 'unparseable url' }
|
|
83
|
+
}
|
|
84
|
+
if (url.protocol !== 'https:') return { ok: false, reason: 'not https' }
|
|
85
|
+
if (url.username || url.password) return { ok: false, reason: 'credentials in url' }
|
|
86
|
+
if (url.port !== '') return { ok: false, reason: 'non-default port' }
|
|
87
|
+
// URL normalises IPv4 forms (decimal, hex, octal) to dotted quads and keeps
|
|
88
|
+
// IPv6 in brackets, so these catch every IP literal.
|
|
89
|
+
if (url.hostname.startsWith('[') || /^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) {
|
|
90
|
+
return { ok: false, reason: 'ip literal host' }
|
|
91
|
+
}
|
|
92
|
+
if (!hosts.has(url.hostname.toLowerCase())) return { ok: false, reason: 'host not allowed' }
|
|
93
|
+
return { ok: true, url }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Header parsing
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export interface ImageInfo {
|
|
101
|
+
type: OgImageType
|
|
102
|
+
width: number
|
|
103
|
+
height: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const u16be = (b: Uint8Array, o: number) => (b[o] << 8) | b[o + 1]
|
|
107
|
+
const u16le = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8)
|
|
108
|
+
const u24le = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8) | (b[o + 2] << 16)
|
|
109
|
+
const u32be = (b: Uint8Array, o: number) =>
|
|
110
|
+
((b[o] << 24) >>> 0) + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3])
|
|
111
|
+
const ascii = (b: Uint8Array, o: number, n: number) =>
|
|
112
|
+
String.fromCharCode(...b.subarray(o, o + n))
|
|
113
|
+
|
|
114
|
+
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
115
|
+
|
|
116
|
+
function pngInfo(b: Uint8Array): ImageInfo | null {
|
|
117
|
+
if (b.length < 24 || !PNG_SIG.every((v, i) => b[i] === v)) return null
|
|
118
|
+
if (ascii(b, 12, 4) !== 'IHDR') return null
|
|
119
|
+
return { type: 'image/png', width: u32be(b, 16), height: u32be(b, 20) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function jpegInfo(b: Uint8Array): ImageInfo | null {
|
|
123
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null
|
|
124
|
+
let o = 2
|
|
125
|
+
while (o + 4 <= b.length) {
|
|
126
|
+
if (b[o] !== 0xff) return null
|
|
127
|
+
const marker = b[o + 1]
|
|
128
|
+
if (marker === 0xff) {
|
|
129
|
+
o += 1 // fill byte
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
// Standalone markers carry no length.
|
|
133
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
134
|
+
o += 2
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (marker === 0xd9 || marker === 0xda) return null // EOI / SOS before any SOF
|
|
138
|
+
const len = u16be(b, o + 2)
|
|
139
|
+
if (len < 2) return null
|
|
140
|
+
// SOF0-SOF15, minus DHT (C4), JPG (C8) and DAC (CC).
|
|
141
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
142
|
+
if (o + 9 > b.length) return null
|
|
143
|
+
return { type: 'image/jpeg', height: u16be(b, o + 5), width: u16be(b, o + 7) }
|
|
144
|
+
}
|
|
145
|
+
o += 2 + len
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function webpInfo(b: Uint8Array): ImageInfo | null {
|
|
151
|
+
if (b.length < 30 || ascii(b, 0, 4) !== 'RIFF' || ascii(b, 8, 4) !== 'WEBP') return null
|
|
152
|
+
const chunk = ascii(b, 12, 4)
|
|
153
|
+
if (chunk === 'VP8 ') {
|
|
154
|
+
// Frame tag (3 bytes), then the start code 9d 01 2a.
|
|
155
|
+
if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null
|
|
156
|
+
return { type: 'image/webp', width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }
|
|
157
|
+
}
|
|
158
|
+
if (chunk === 'VP8L') {
|
|
159
|
+
if (b[20] !== 0x2f) return null
|
|
160
|
+
const bits = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24)
|
|
161
|
+
return { type: 'image/webp', width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }
|
|
162
|
+
}
|
|
163
|
+
if (chunk === 'VP8X') {
|
|
164
|
+
return { type: 'image/webp', width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* GIF: the logical screen size, and also every frame's own size, because a
|
|
171
|
+
* frame descriptor may claim more than the screen and decoders allocate for it.
|
|
172
|
+
* Reports the largest width and height seen.
|
|
173
|
+
*/
|
|
174
|
+
function gifInfo(b: Uint8Array): ImageInfo | null {
|
|
175
|
+
if (b.length < 13) return null
|
|
176
|
+
const sig = ascii(b, 0, 6)
|
|
177
|
+
if (sig !== 'GIF87a' && sig !== 'GIF89a') return null
|
|
178
|
+
let width = u16le(b, 6)
|
|
179
|
+
let height = u16le(b, 8)
|
|
180
|
+
let o = 13
|
|
181
|
+
if (b[10] & 0x80) o += 3 * (1 << ((b[10] & 0x07) + 1))
|
|
182
|
+
const skipSubBlocks = () => {
|
|
183
|
+
while (o < b.length) {
|
|
184
|
+
const n = b[o]
|
|
185
|
+
o += 1 + n
|
|
186
|
+
if (n === 0) return true
|
|
187
|
+
}
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
while (o < b.length) {
|
|
191
|
+
const block = b[o]
|
|
192
|
+
if (block === 0x3b) break // trailer
|
|
193
|
+
if (block === 0x21) {
|
|
194
|
+
o += 2 // introducer + label
|
|
195
|
+
if (!skipSubBlocks()) break
|
|
196
|
+
} else if (block === 0x2c) {
|
|
197
|
+
if (o + 10 > b.length) break
|
|
198
|
+
width = Math.max(width, u16le(b, o + 5))
|
|
199
|
+
height = Math.max(height, u16le(b, o + 7))
|
|
200
|
+
const flags = b[o + 9]
|
|
201
|
+
o += 10
|
|
202
|
+
if (flags & 0x80) o += 3 * (1 << ((flags & 0x07) + 1))
|
|
203
|
+
o += 1 // LZW minimum code size
|
|
204
|
+
if (!skipSubBlocks()) break
|
|
205
|
+
} else {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { type: 'image/gif', width, height }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Format and pixel size from the file header, or null if not a supported raster. */
|
|
213
|
+
export function readImageInfo(bytes: Uint8Array): ImageInfo | null {
|
|
214
|
+
return pngInfo(bytes) ?? jpegInfo(bytes) ?? webpInfo(bytes) ?? gifInfo(bytes)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Fetch
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export class OgImageRejected extends Error {}
|
|
222
|
+
|
|
223
|
+
export interface FetchOgImageOptions {
|
|
224
|
+
/** Overrides the configured allowlist for this call. */
|
|
225
|
+
allowedHosts?: readonly string[]
|
|
226
|
+
timeoutMs?: number
|
|
227
|
+
maxBytes?: number
|
|
228
|
+
maxDimension?: number
|
|
229
|
+
maxPixels?: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Read a body, aborting as soon as it passes maxBytes. */
|
|
233
|
+
async function readCapped(res: Response, maxBytes: number, controller: AbortController) {
|
|
234
|
+
const declared = Number(res.headers.get('content-length'))
|
|
235
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
236
|
+
throw new OgImageRejected(`content-length ${declared} over ${maxBytes}`)
|
|
237
|
+
}
|
|
238
|
+
if (!res.body) throw new OgImageRejected('empty body')
|
|
239
|
+
const reader = res.body.getReader()
|
|
240
|
+
const chunks: Uint8Array[] = []
|
|
241
|
+
let total = 0
|
|
242
|
+
for (;;) {
|
|
243
|
+
const { done, value } = await reader.read()
|
|
244
|
+
if (done) break
|
|
245
|
+
total += value.byteLength
|
|
246
|
+
if (total > maxBytes) {
|
|
247
|
+
controller.abort()
|
|
248
|
+
await reader.cancel().catch(() => {})
|
|
249
|
+
throw new OgImageRejected(`body over ${maxBytes} bytes`)
|
|
250
|
+
}
|
|
251
|
+
chunks.push(value)
|
|
252
|
+
}
|
|
253
|
+
const out = new Uint8Array(total)
|
|
254
|
+
let o = 0
|
|
255
|
+
for (const c of chunks) {
|
|
256
|
+
out.set(c, o)
|
|
257
|
+
o += c.byteLength
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fetch a card image under the rules at the top of this file. Returns the
|
|
264
|
+
* bytes and their verified format; throws OgImageRejected (or a fetch/abort
|
|
265
|
+
* error) otherwise.
|
|
266
|
+
*/
|
|
267
|
+
export async function fetchOgImage(
|
|
268
|
+
raw: string,
|
|
269
|
+
opts: FetchOgImageOptions = {},
|
|
270
|
+
): Promise<{ bytes: Uint8Array; info: ImageInfo }> {
|
|
271
|
+
const hosts = opts.allowedHosts ? new Set(opts.allowedHosts.map((h) => h.toLowerCase())) : allowedHosts
|
|
272
|
+
const checked = checkOgImageUrl(raw, hosts)
|
|
273
|
+
if (!checked.ok) throw new OgImageRejected(checked.reason)
|
|
274
|
+
|
|
275
|
+
const maxBytes = opts.maxBytes ?? OG_IMAGE_LIMITS.maxBytes
|
|
276
|
+
const maxDimension = opts.maxDimension ?? OG_IMAGE_LIMITS.maxDimension
|
|
277
|
+
const maxPixels = opts.maxPixels ?? OG_IMAGE_LIMITS.maxPixels
|
|
278
|
+
const controller = new AbortController()
|
|
279
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? OG_IMAGE_LIMITS.timeoutMs)
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch(checked.url, { redirect: 'error', signal: controller.signal })
|
|
282
|
+
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
|
283
|
+
throw new OgImageRejected(`redirect ${res.status}`)
|
|
284
|
+
}
|
|
285
|
+
if (!res.ok) throw new OgImageRejected(`status ${res.status}`)
|
|
286
|
+
const declaredType = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
|
|
287
|
+
if (!ALLOWED_TYPES.has(declaredType)) {
|
|
288
|
+
throw new OgImageRejected(`content-type ${declaredType || 'missing'}`)
|
|
289
|
+
}
|
|
290
|
+
const bytes = await readCapped(res, maxBytes, controller)
|
|
291
|
+
const info = readImageInfo(bytes)
|
|
292
|
+
if (!info) throw new OgImageRejected('unrecognised image header')
|
|
293
|
+
if (info.width === 0 || info.height === 0) throw new OgImageRejected('zero dimension')
|
|
294
|
+
if (info.width > maxDimension || info.height > maxDimension || info.width * info.height > maxPixels) {
|
|
295
|
+
throw new OgImageRejected(`dimensions ${info.width}x${info.height}`)
|
|
296
|
+
}
|
|
297
|
+
return { bytes, info }
|
|
298
|
+
} finally {
|
|
299
|
+
clearTimeout(timer)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The URL for a log line: origin and path only, never the query. */
|
|
304
|
+
export function redactUrl(raw: string): string {
|
|
305
|
+
try {
|
|
306
|
+
const u = new URL(raw)
|
|
307
|
+
const s = `${u.protocol}//${u.host}${u.pathname}`
|
|
308
|
+
return s.length > 160 ? `${s.slice(0, 160)}...` : s
|
|
309
|
+
} catch {
|
|
310
|
+
return '<unparseable>'
|
|
311
|
+
}
|
|
312
|
+
}
|