@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
package/src/og/index.tsx
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OG card chrome for inference shell and app.inference.sh.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists
|
|
5
|
+
* ---------------------
|
|
6
|
+
* These cards were hand-ported between js/web and js/app and then drifted:
|
|
7
|
+
* different description colour, size, casing and wrapping, a different footer
|
|
8
|
+
* accent, and a duplicated copy of fitTitle. Worse, both copies carried the
|
|
9
|
+
* SAME bug — a single Inter face registered at weight 700, so every string on
|
|
10
|
+
* every card rendered bold no matter what weight it asked for. Identical bugs
|
|
11
|
+
* in two files are invisible to a drift check; the only real fix is one file.
|
|
12
|
+
*
|
|
13
|
+
* What is deliberately NOT here
|
|
14
|
+
* ----------------------------
|
|
15
|
+
* The satori + resvg call. js/web runs on Node and returns a Response;
|
|
16
|
+
* js/app runs on Bun and returns a Uint8Array, and neither satori nor
|
|
17
|
+
* @resvg/resvg-js is installed in js/app outside its Docker runner. Keeping
|
|
18
|
+
* this module dependency-free (react only) means it imports cleanly in both
|
|
19
|
+
* runtimes and in vitest. Each consumer keeps its own three-line render call
|
|
20
|
+
* and passes OG_FONTS straight through — so the font set, which is what was
|
|
21
|
+
* actually wrong, still lives in exactly one place.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ReactNode } from 'react'
|
|
25
|
+
|
|
26
|
+
import { fetchOgImage, OgImageRejected, redactUrl, type FetchOgImageOptions } from './image'
|
|
27
|
+
export { canonicalOgSearch } from './query'
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_OG_IMAGE_HOSTS,
|
|
31
|
+
OG_IMAGE_LIMITS,
|
|
32
|
+
checkOgImageUrl,
|
|
33
|
+
configureOgImageHosts,
|
|
34
|
+
fetchOgImage,
|
|
35
|
+
getOgImageHosts,
|
|
36
|
+
parseOgImageHosts,
|
|
37
|
+
readImageInfo,
|
|
38
|
+
OgImageRejected,
|
|
39
|
+
type FetchOgImageOptions,
|
|
40
|
+
type ImageInfo,
|
|
41
|
+
} from './image'
|
|
42
|
+
|
|
43
|
+
export const OG_SIZE = { width: 1200, height: 630 }
|
|
44
|
+
|
|
45
|
+
export const OG_CACHE_HEADERS = {
|
|
46
|
+
'Content-Type': 'image/png',
|
|
47
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CATEGORY_COLORS: Record<string, string> = {
|
|
51
|
+
image: '#FF569D',
|
|
52
|
+
video: '#FF569D',
|
|
53
|
+
audio: '#FF569D',
|
|
54
|
+
chat: '#FF569D',
|
|
55
|
+
text: '#FF569D',
|
|
56
|
+
social: '#FF569D',
|
|
57
|
+
other: '#FF569D',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LOGO_URL = 'https://cloud.inference.sh/web/assets/logo.png'
|
|
61
|
+
// The logo is a constant of ours, so it does not depend on the configurable
|
|
62
|
+
// allowlist that guards app-supplied card images.
|
|
63
|
+
const LOGO_FETCH = { allowedHosts: [new URL(LOGO_URL).hostname] }
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Fonts
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// Satori needs static TTF/OTF, so these are instances of the same variable
|
|
70
|
+
// fonts the sites use (theme/fonts), cut with fontTools at the weights the
|
|
71
|
+
// type spec uses and served by js/web from public/fonts/og. Mona Sans is
|
|
72
|
+
// pinned to opsz 16 like the site CSS, and renamed inside the file because
|
|
73
|
+
// its licence reserves the name for unmodified copies.
|
|
74
|
+
//
|
|
75
|
+
// Satori picks a face by declared weight, so each cut is registered at the
|
|
76
|
+
// nearest hundred: 420 as 400, 520 as 500. Ask for 400 or 500 in styles.
|
|
77
|
+
const OG_FONT_BASE = 'https://inference.sh/fonts/og'
|
|
78
|
+
|
|
79
|
+
const OG_FONT_FILES = [
|
|
80
|
+
{ name: 'Mona Sans', weight: 400, file: 'og-sans-420.ttf' },
|
|
81
|
+
{ name: 'Mona Sans', weight: 500, file: 'og-sans-520.ttf' },
|
|
82
|
+
] as const
|
|
83
|
+
|
|
84
|
+
// The wordmark stays in Hack.
|
|
85
|
+
const HACK_FILE = 'Hack-Regular.ttf'
|
|
86
|
+
const HACK_URL = 'https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/ttf/Hack-Regular.ttf'
|
|
87
|
+
|
|
88
|
+
/** How long one font download may take before the card fails instead of hanging. */
|
|
89
|
+
export const OG_FONT_FETCH_TIMEOUT_MS = 10_000
|
|
90
|
+
|
|
91
|
+
/** Font stacks for card styles; names match OG_FONT_FILES. */
|
|
92
|
+
export const OG_DISPLAY_FONT = 'Mona Sans, sans-serif'
|
|
93
|
+
export const OG_SANS_FONT = 'Mona Sans, system-ui, sans-serif'
|
|
94
|
+
|
|
95
|
+
export interface OgFont {
|
|
96
|
+
name: string
|
|
97
|
+
data: ArrayBuffer
|
|
98
|
+
weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
99
|
+
style: 'normal'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reads a card font by file name (og-sans-420.ttf, og-sans-520.ttf, Hack-Regular.ttf). */
|
|
103
|
+
export type OgFontReader = (file: string) => Promise<ArrayBuffer>
|
|
104
|
+
|
|
105
|
+
let fontsPromise: Promise<OgFont[]> | null = null
|
|
106
|
+
let fontReader: OgFontReader | null = null
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Has loadOgFonts read the fonts with read (js/web: from its own build on
|
|
110
|
+
* disk) instead of downloading them from inference.sh and jsDelivr, which
|
|
111
|
+
* puts the site's own edge and a CDN on every cold render path. A file read
|
|
112
|
+
* fails over to the download. Call before the first render.
|
|
113
|
+
*/
|
|
114
|
+
export function configureOgFontReader(read: OgFontReader | null): void {
|
|
115
|
+
fontReader = read
|
|
116
|
+
fontsPromise = null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchFont(url: string): Promise<ArrayBuffer> {
|
|
120
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(OG_FONT_FETCH_TIMEOUT_MS) })
|
|
121
|
+
if (!res.ok) throw new Error(`Font fetch failed: ${res.status} ${url}`)
|
|
122
|
+
return res.arrayBuffer()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readFont(file: string, url: string): Promise<ArrayBuffer> {
|
|
126
|
+
if (fontReader) {
|
|
127
|
+
try {
|
|
128
|
+
return await fontReader(file)
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(`[og] font ${file} not readable (${err instanceof Error ? err.message : err}), downloading it`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return fetchFont(url)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function loadOgFonts(): Promise<OgFont[]> {
|
|
137
|
+
if (!fontsPromise) {
|
|
138
|
+
fontsPromise = Promise.all([
|
|
139
|
+
...OG_FONT_FILES.map(async (f) => ({
|
|
140
|
+
name: f.name,
|
|
141
|
+
data: await readFont(f.file, `${OG_FONT_BASE}/${f.file}`),
|
|
142
|
+
weight: f.weight,
|
|
143
|
+
style: 'normal' as const,
|
|
144
|
+
})),
|
|
145
|
+
readFont(HACK_FILE, HACK_URL).then((data) => ({
|
|
146
|
+
name: 'Hack',
|
|
147
|
+
data,
|
|
148
|
+
weight: 400 as const,
|
|
149
|
+
style: 'normal' as const,
|
|
150
|
+
})),
|
|
151
|
+
]).catch((err) => {
|
|
152
|
+
// Don't cache failures — allow the next call to retry
|
|
153
|
+
fontsPromise = null
|
|
154
|
+
throw err
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return fontsPromise
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Images
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
// Data URIs of fetched images, least recently used first out. Bounded by
|
|
165
|
+
// total size as well as count: at the 5 MB per-image cap, 100 entries could
|
|
166
|
+
// otherwise hold ~670 MB of base64.
|
|
167
|
+
const MAX_IMAGE_CACHE = 100
|
|
168
|
+
const MAX_IMAGE_CACHE_CHARS = 32 * 1024 * 1024
|
|
169
|
+
const imageCache = new Map<string, string>()
|
|
170
|
+
let imageCacheChars = 0
|
|
171
|
+
|
|
172
|
+
function cacheImage(url: string, dataUri: string) {
|
|
173
|
+
if (dataUri.length > MAX_IMAGE_CACHE_CHARS) return
|
|
174
|
+
while (
|
|
175
|
+
imageCache.size > 0 &&
|
|
176
|
+
(imageCache.size >= MAX_IMAGE_CACHE || imageCacheChars + dataUri.length > MAX_IMAGE_CACHE_CHARS)
|
|
177
|
+
) {
|
|
178
|
+
const [oldestKey, oldest] = imageCache.entries().next().value as [string, string]
|
|
179
|
+
imageCache.delete(oldestKey)
|
|
180
|
+
imageCacheChars -= oldest.length
|
|
181
|
+
}
|
|
182
|
+
imageCache.set(url, dataUri)
|
|
183
|
+
imageCacheChars += dataUri.length
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Card-image fetches in flight at once, and how many more may wait for a turn.
|
|
187
|
+
// Each one holds up to 5 MB of body plus its base64 copy until its card is
|
|
188
|
+
// drawn, so a burst of distinct cards must not fetch them all at once. A
|
|
189
|
+
// fetch that cannot get a turn is skipped: the card renders without it.
|
|
190
|
+
const MAX_IMAGE_FETCHES = 4
|
|
191
|
+
const MAX_QUEUED_IMAGE_FETCHES = 16
|
|
192
|
+
const MAX_IMAGE_FETCH_WAIT_MS = 5000
|
|
193
|
+
let activeImageFetches = 0
|
|
194
|
+
const imageFetchWaiters: Array<() => void> = []
|
|
195
|
+
// Requests for the same image while it is being fetched share that fetch.
|
|
196
|
+
const inFlightImages = new Map<string, Promise<string | null>>()
|
|
197
|
+
|
|
198
|
+
class ImageFetchBusy extends Error {}
|
|
199
|
+
|
|
200
|
+
function acquireImageFetch(): Promise<void> {
|
|
201
|
+
if (activeImageFetches < MAX_IMAGE_FETCHES) {
|
|
202
|
+
activeImageFetches++
|
|
203
|
+
return Promise.resolve()
|
|
204
|
+
}
|
|
205
|
+
if (imageFetchWaiters.length >= MAX_QUEUED_IMAGE_FETCHES) {
|
|
206
|
+
return Promise.reject(new ImageFetchBusy('busy'))
|
|
207
|
+
}
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const turn = () => {
|
|
210
|
+
clearTimeout(timer)
|
|
211
|
+
activeImageFetches++
|
|
212
|
+
resolve()
|
|
213
|
+
}
|
|
214
|
+
const timer = setTimeout(() => {
|
|
215
|
+
const i = imageFetchWaiters.indexOf(turn)
|
|
216
|
+
if (i >= 0) imageFetchWaiters.splice(i, 1)
|
|
217
|
+
reject(new ImageFetchBusy('busy, waited too long'))
|
|
218
|
+
}, MAX_IMAGE_FETCH_WAIT_MS)
|
|
219
|
+
imageFetchWaiters.push(turn)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function releaseImageFetch(): void {
|
|
224
|
+
activeImageFetches--
|
|
225
|
+
imageFetchWaiters.shift()?.()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* An image as a data URI for satori, or null when it may not or could not be
|
|
230
|
+
* used (see ./image.ts for the rules). Callers render without the image on
|
|
231
|
+
* null. Each refusal logs one line, without the URL's query.
|
|
232
|
+
*/
|
|
233
|
+
export async function fetchImageAsDataUri(
|
|
234
|
+
url: string,
|
|
235
|
+
opts?: FetchOgImageOptions,
|
|
236
|
+
): Promise<string | null> {
|
|
237
|
+
const cached = imageCache.get(url)
|
|
238
|
+
if (cached !== undefined) {
|
|
239
|
+
imageCache.delete(url)
|
|
240
|
+
imageCache.set(url, cached)
|
|
241
|
+
return cached
|
|
242
|
+
}
|
|
243
|
+
const pending = inFlightImages.get(url)
|
|
244
|
+
if (pending) return pending
|
|
245
|
+
const fetching = fetchImageUncached(url, opts).finally(() => inFlightImages.delete(url))
|
|
246
|
+
inFlightImages.set(url, fetching)
|
|
247
|
+
return fetching
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function fetchImageUncached(url: string, opts?: FetchOgImageOptions): Promise<string | null> {
|
|
251
|
+
let acquired = false
|
|
252
|
+
try {
|
|
253
|
+
await acquireImageFetch()
|
|
254
|
+
acquired = true
|
|
255
|
+
const { bytes, info } = await fetchOgImage(url, opts)
|
|
256
|
+
const dataUri = `data:${info.type};base64,${Buffer.from(bytes).toString('base64')}`
|
|
257
|
+
cacheImage(url, dataUri)
|
|
258
|
+
return dataUri
|
|
259
|
+
} catch (err) {
|
|
260
|
+
const reason =
|
|
261
|
+
err instanceof OgImageRejected || err instanceof ImageFetchBusy
|
|
262
|
+
? err.message
|
|
263
|
+
: err instanceof Error && err.name === 'AbortError'
|
|
264
|
+
? 'timeout'
|
|
265
|
+
: err instanceof Error
|
|
266
|
+
? `fetch failed: ${err.message}`
|
|
267
|
+
: 'fetch failed'
|
|
268
|
+
console.warn(`[og] image skipped (${reason}): ${redactUrl(url)}`)
|
|
269
|
+
return null
|
|
270
|
+
} finally {
|
|
271
|
+
if (acquired) releaseImageFetch()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Test hook: empty the image cache. */
|
|
276
|
+
export function clearOgImageCache(): void {
|
|
277
|
+
imageCache.clear()
|
|
278
|
+
imageCacheChars = 0
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Warm the font and logo caches at startup so the first card request does not
|
|
283
|
+
* pay for two network fetches. Rejects when a font fails to load (the loader
|
|
284
|
+
* does not cache the failure, so a later call or card request tries again);
|
|
285
|
+
* an unreachable logo resolves to null and is not cached either. Callers must
|
|
286
|
+
* handle the rejection: the OG server wraps it in prewarmWithRetry
|
|
287
|
+
* (js/app server/prewarm.ts).
|
|
288
|
+
*/
|
|
289
|
+
export function prewarmOg(): Promise<unknown> {
|
|
290
|
+
return Promise.all([loadOgFonts(), fetchImageAsDataUri(LOGO_URL, LOGO_FETCH)])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Layout
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/** Font size and truncation for OG card titles. */
|
|
298
|
+
export function fitTitle(text: string, maxLines: number = 2) {
|
|
299
|
+
const len = text.length
|
|
300
|
+
let fontSize: number
|
|
301
|
+
let maxChars: number
|
|
302
|
+
|
|
303
|
+
if (maxLines <= 2) {
|
|
304
|
+
fontSize = len <= 20 ? 82 : len <= 30 ? 72 : len <= 45 ? 60 : 52
|
|
305
|
+
maxChars = 60
|
|
306
|
+
} else {
|
|
307
|
+
fontSize = len <= 20 ? 82 : len <= 35 ? 68 : len <= 55 ? 56 : 48
|
|
308
|
+
maxChars = 100
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const truncated = text.length > maxChars ? text.slice(0, maxChars) + '...' : text
|
|
312
|
+
return { fontSize, text: truncated }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Truncate a card description to the length the layout can hold. */
|
|
316
|
+
export function fitDescription(text: string, maxChars = 120): string {
|
|
317
|
+
return text.length > maxChars ? text.slice(0, maxChars) + '...' : text
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Shared card description styling — regular weight, kept clear of the artwork. */
|
|
321
|
+
export const DESCRIPTION_STYLE = {
|
|
322
|
+
color: '#666666',
|
|
323
|
+
fontSize: 38,
|
|
324
|
+
fontWeight: 400,
|
|
325
|
+
fontFamily: OG_SANS_FONT,
|
|
326
|
+
lineHeight: 1.3,
|
|
327
|
+
maxWidth: 700,
|
|
328
|
+
} as const
|
|
329
|
+
|
|
330
|
+
/** Shared "namespace/" eyebrow styling. */
|
|
331
|
+
export const EYEBROW_STYLE = {
|
|
332
|
+
color: '#FF569D',
|
|
333
|
+
fontSize: 38,
|
|
334
|
+
fontWeight: 400,
|
|
335
|
+
fontFamily: OG_SANS_FONT,
|
|
336
|
+
} as const
|
|
337
|
+
|
|
338
|
+
/** Shared card title styling; pass the fontSize from fitTitle(). */
|
|
339
|
+
export const TITLE_STYLE = {
|
|
340
|
+
color: '#111111',
|
|
341
|
+
fontFamily: OG_DISPLAY_FONT,
|
|
342
|
+
fontWeight: 400,
|
|
343
|
+
letterSpacing: '-0.02em',
|
|
344
|
+
lineHeight: 1.1,
|
|
345
|
+
} as const
|
|
346
|
+
|
|
347
|
+
/** Black card, logo top-left, content in the middle, footer at the bottom. */
|
|
348
|
+
export async function ogLayout(opts: {
|
|
349
|
+
children: ReactNode
|
|
350
|
+
footer?: ReactNode
|
|
351
|
+
backgroundImageUrl?: string
|
|
352
|
+
}): Promise<ReactNode> {
|
|
353
|
+
const logoSrc = await fetchImageAsDataUri(LOGO_URL, LOGO_FETCH)
|
|
354
|
+
const bgSrc = opts.backgroundImageUrl
|
|
355
|
+
? await fetchImageAsDataUri(opts.backgroundImageUrl)
|
|
356
|
+
: null
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div
|
|
360
|
+
style={{
|
|
361
|
+
background: '#fafafa',
|
|
362
|
+
width: '100%',
|
|
363
|
+
height: '100%',
|
|
364
|
+
display: 'flex',
|
|
365
|
+
position: 'relative',
|
|
366
|
+
fontFamily: OG_SANS_FONT,
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
{bgSrc && (
|
|
370
|
+
<div
|
|
371
|
+
style={{
|
|
372
|
+
position: 'absolute',
|
|
373
|
+
top: 0,
|
|
374
|
+
right: 0,
|
|
375
|
+
width: '60%',
|
|
376
|
+
height: '100%',
|
|
377
|
+
display: 'flex',
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<img
|
|
381
|
+
src={bgSrc}
|
|
382
|
+
width={720}
|
|
383
|
+
height={630}
|
|
384
|
+
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
|
|
385
|
+
/>
|
|
386
|
+
<div
|
|
387
|
+
style={{
|
|
388
|
+
position: 'absolute',
|
|
389
|
+
top: 0,
|
|
390
|
+
left: 0,
|
|
391
|
+
width: '100%',
|
|
392
|
+
height: '100%',
|
|
393
|
+
background:
|
|
394
|
+
'linear-gradient(to right, #fafafa 0%, #fafafacc 30%, #fafafa33 70%, transparent 100%)',
|
|
395
|
+
}}
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
<div
|
|
400
|
+
style={{
|
|
401
|
+
position: 'relative',
|
|
402
|
+
width: '100%',
|
|
403
|
+
height: '100%',
|
|
404
|
+
display: 'flex',
|
|
405
|
+
flexDirection: 'column',
|
|
406
|
+
justifyContent: 'space-between',
|
|
407
|
+
padding: '48px 56px',
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
|
|
411
|
+
{logoSrc && <img src={logoSrc} width={48} height={48} />}
|
|
412
|
+
<span
|
|
413
|
+
style={{
|
|
414
|
+
color: '#111111',
|
|
415
|
+
fontSize: 36,
|
|
416
|
+
fontWeight: 400,
|
|
417
|
+
fontFamily: 'Hack, monospace',
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
inference shell
|
|
421
|
+
</span>
|
|
422
|
+
</div>
|
|
423
|
+
{opts.children}
|
|
424
|
+
{opts.footer && (
|
|
425
|
+
<div style={{ display: 'flex', alignItems: 'center' }}>{opts.footer}</div>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
)
|
|
430
|
+
}
|
package/src/og/query.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical query strings for OG image routes.
|
|
3
|
+
*
|
|
4
|
+
* The cards are served with a public s-maxage, and the edge keys its cache on
|
|
5
|
+
* the full query string, so `?namespace=x&name=y&r=<random>` misses the cache
|
|
6
|
+
* every time and forces a fresh render (and card-image fetch). Routes that
|
|
7
|
+
* take parameters redirect any other spelling to the one canonical query:
|
|
8
|
+
* the known keys only, first value each, in a fixed order.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The canonical search string ("" or "?a=1&b=2") for these keys, or null when
|
|
13
|
+
* `search` already is it. Idempotent: the result maps to null.
|
|
14
|
+
*/
|
|
15
|
+
export function canonicalOgSearch(search: string, keys: readonly string[]): string | null {
|
|
16
|
+
const params = new URLSearchParams(search)
|
|
17
|
+
const canonical = new URLSearchParams()
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
const value = params.get(key)
|
|
20
|
+
if (value !== null) canonical.set(key, value)
|
|
21
|
+
}
|
|
22
|
+
const query = canonical.toString()
|
|
23
|
+
const want = query ? `?${query}` : ''
|
|
24
|
+
const have = search === '?' ? '' : search
|
|
25
|
+
return have === want ? null : want
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
|
2
|
+
import { cn } from "cn"
|
|
3
|
+
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
|
6
|
+
return (
|
|
7
|
+
<AccordionPrimitive.Root
|
|
8
|
+
data-slot="accordion"
|
|
9
|
+
className={cn("flex w-full flex-col", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
|
16
|
+
return (
|
|
17
|
+
<AccordionPrimitive.Item
|
|
18
|
+
data-slot="accordion-item"
|
|
19
|
+
className={cn("not-last:border-b", className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function AccordionTrigger({
|
|
26
|
+
className,
|
|
27
|
+
children,
|
|
28
|
+
...props
|
|
29
|
+
}: AccordionPrimitive.Trigger.Props) {
|
|
30
|
+
return (
|
|
31
|
+
<AccordionPrimitive.Header className="flex">
|
|
32
|
+
<AccordionPrimitive.Trigger
|
|
33
|
+
data-slot="accordion-trigger"
|
|
34
|
+
className={cn(
|
|
35
|
+
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
|
36
|
+
className
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
|
42
|
+
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
|
43
|
+
</AccordionPrimitive.Trigger>
|
|
44
|
+
</AccordionPrimitive.Header>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function AccordionContent({
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: AccordionPrimitive.Panel.Props) {
|
|
53
|
+
return (
|
|
54
|
+
<AccordionPrimitive.Panel
|
|
55
|
+
data-slot="accordion-content"
|
|
56
|
+
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
className={cn(
|
|
61
|
+
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
62
|
+
className
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
</AccordionPrimitive.Panel>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|