Openstatus www.openstatus.dev

feat: web cmdk (#1671)

* wip:

* wip:

* wip:

* fix: small stuff

* wip:

* wip:

* chore: tanstack query

* wip:

* fix: build

* wip:

* fix: security

* chore: sanitize content

* fix: cmdk

* chore: header instead of footer

* wip:

* fix: kbd

* Potential fix for code scanning alert no. 55: Incomplete multi-character sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: search href

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

authored by

Maximilian Kaske
Copilot Autofix powered by AI
and committed by
GitHub
95f490e9 9f611918

+883 -66
+3
apps/web/package.json
··· 81 81 "reading-time": "1.5.0", 82 82 "recharts": "2.15.0", 83 83 "resend": "6.6.0", 84 + "sanitize-html": "2.17.0", 84 85 "schema-dts": "1.1.5", 85 86 "shiki": "0.14.4", 87 + "slugify": "1.6.6", 86 88 "sonner": "2.0.5", 87 89 "stripe": "13.8.0", 88 90 "sugar-high": "0.9.5", ··· 97 99 "@types/node": "24.0.8", 98 100 "@types/react": "19.2.2", 99 101 "@types/react-dom": "19.2.2", 102 + "@types/sanitize-html": "2.16.0", 100 103 "postcss": "8.4.38", 101 104 "rehype-autolink-headings": "7.1.0", 102 105 "rehype-slug": "5.1.0",
+1 -1
apps/web/src/app/(landing)/content-list.tsx
··· 30 30 </ContentListItemDate> 31 31 <ContentListItemTitle>{post.metadata.title}</ContentListItemTitle> 32 32 {withCategory ? ( 33 - <span className="text-muted-foreground"> 33 + <span className="text-muted-foreground md:ml-auto"> 34 34 [{post.metadata.category}] 35 35 </span> 36 36 ) : null}
+248
apps/web/src/app/api/search/route.ts
··· 1 + import { slugify } from "@/content/mdx"; 2 + import { 3 + type MDXData, 4 + getBlogPosts, 5 + getChangelogPosts, 6 + getComparePages, 7 + getHomePage, 8 + getProductPages, 9 + getToolsPages, 10 + } from "@/content/utils"; 11 + import sanitizeHtml from "sanitize-html"; 12 + import { z } from "zod"; 13 + 14 + const SearchSchema = z.object({ 15 + p: z 16 + .enum(["blog", "changelog", "tools", "compare", "product", "all"]) 17 + .nullish(), 18 + q: z.string().nullish(), 19 + }); 20 + 21 + export type SearchParams = z.infer<typeof SearchSchema>; 22 + 23 + export async function GET(request: Request) { 24 + const { searchParams } = new URL(request.url); 25 + const query = searchParams.get("q"); 26 + const page = searchParams.get("p"); 27 + 28 + const params = SearchSchema.safeParse({ 29 + p: page, 30 + q: query, 31 + }); 32 + 33 + if (!params.success) { 34 + console.error(params.error); 35 + return new Response(JSON.stringify({ error: params.error.message }), { 36 + status: 400, 37 + }); 38 + } 39 + 40 + if (!params.data.p) { 41 + return new Response(JSON.stringify([]), { 42 + status: 200, 43 + }); 44 + } 45 + 46 + const results = search(params.data).sort((a, b) => { 47 + return b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime(); 48 + }); 49 + 50 + return new Response(JSON.stringify(results), { 51 + status: 200, 52 + }); 53 + } 54 + 55 + function search(params: SearchParams) { 56 + const { p, q } = params; 57 + let results: MDXData[] = []; 58 + 59 + if (p === "blog") { 60 + results = getBlogPosts(); 61 + } else if (p === "changelog") { 62 + results = getChangelogPosts(); 63 + } else if (p === "tools") { 64 + results = getToolsPages().filter((tool) => tool.slug !== "checker-slug"); 65 + } else if (p === "compare") { 66 + results = getComparePages(); 67 + } else if (p === "product") { 68 + const home = getHomePage(); 69 + // NOTE: we override /home with / for the home.mdx file 70 + home.href = "/"; 71 + home.metadata.title = "Homepage"; 72 + results = [home, ...getProductPages()]; 73 + } else if (p === "all") { 74 + const home = getHomePage(); 75 + // NOTE: we override /home with / for the home.mdx file 76 + home.href = "/"; 77 + home.metadata.title = "Homepage"; 78 + results = [ 79 + ...getBlogPosts(), 80 + ...getChangelogPosts(), 81 + ...getToolsPages().filter((tool) => tool.slug !== "checker-slug"), 82 + ...getComparePages(), 83 + ...getProductPages(), 84 + home, 85 + ]; 86 + } 87 + 88 + const searchMap = new Map< 89 + string, 90 + { 91 + title: boolean; 92 + content: boolean; 93 + } 94 + >(); 95 + 96 + results = results 97 + .filter((result) => { 98 + if (!q) return true; 99 + 100 + const hasSearchTitle = result.metadata.title 101 + .toLowerCase() 102 + .includes(q.toLowerCase()); 103 + const hasSearchContent = result.content 104 + .toLowerCase() 105 + .includes(q.toLowerCase()); 106 + 107 + searchMap.set(result.slug, { 108 + title: hasSearchTitle, 109 + content: hasSearchContent, 110 + }); 111 + 112 + return hasSearchTitle || hasSearchContent; 113 + }) 114 + .map((result) => { 115 + const search = searchMap.get(result.slug); 116 + 117 + // Find the closest heading to the search match and add it as an anchor 118 + let href = result.href; 119 + 120 + // Add query parameter for highlighting 121 + if (q) { 122 + href = `${href}?q=${encodeURIComponent(q)}`; 123 + } 124 + 125 + if (q && search?.content) { 126 + const headingSlug = findClosestHeading(result.content, q); 127 + if (headingSlug) { 128 + href = `${href}#${headingSlug}`; 129 + } 130 + } 131 + 132 + const content = 133 + search?.content || !search?.title 134 + ? getContentSnippet(result.content, q) 135 + : ""; 136 + 137 + return { 138 + ...result, 139 + content, 140 + href, 141 + }; 142 + }); 143 + 144 + return results; 145 + } 146 + 147 + const WORKDS_BEFORE = 2; 148 + const WORKDS_AFTER = 20; 149 + 150 + function getContentSnippet( 151 + mdxContent: string, 152 + searchQuery: string | null | undefined, 153 + ): string { 154 + if (!searchQuery) { 155 + return `${mdxContent.slice(0, 100)}...`; 156 + } 157 + 158 + const content = sanitizeContent(mdxContent.toLowerCase()); 159 + const searchLower = searchQuery.toLowerCase(); 160 + const matchIndex = content.indexOf(searchLower); 161 + 162 + if (matchIndex === -1) { 163 + // No match found, return first 100 chars 164 + return `${content.slice(0, 100)}...`; 165 + } 166 + 167 + // Find start of snippet (go back N words) 168 + let start = matchIndex; 169 + for (let i = 0; i < WORKDS_BEFORE && start > 0; i++) { 170 + const prevSpace = content.lastIndexOf(" ", start - 2); 171 + if (prevSpace === -1) break; 172 + start = prevSpace + 1; 173 + } 174 + 175 + // Find end of snippet (go forward N words) 176 + let end = matchIndex + searchQuery.length; 177 + for (let i = 0; i < WORKDS_AFTER && end < content.length; i++) { 178 + const nextSpace = content.indexOf(" ", end + 1); 179 + if (nextSpace === -1) { 180 + end = content.length; 181 + break; 182 + } 183 + end = nextSpace; 184 + } 185 + 186 + // Extract snippet 187 + let snippet = content.slice(start, end).trim(); 188 + 189 + if (!snippet) return snippet; 190 + 191 + if (start > 0) snippet = `...${snippet}`; 192 + if (end < content.length) snippet = `${snippet}...`; 193 + 194 + return snippet; 195 + } 196 + 197 + export function sanitizeContent(input: string) { 198 + return sanitizeHtml(input) 199 + .replace(/<[^>]+>/g, "") // strip JSX tags 200 + .replace(/^#{1,6}\s+/gm, "") // strip markdown heading symbols, keep text 201 + .replace(/!\[.*?\]\(.*?\)/g, "") // strip images 202 + .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") // keep link text 203 + .replace(/\*\*(.*?)\*\*/g, "$1") // strip bold 204 + .replace(/__(.*?)__/g, "$1") // strip italic 205 + .replace(/_(.*?)_/g, "$1") // strip underline 206 + .replace(/[`*>~]/g, "") // strip most formatting 207 + .replace(/\s+/g, " ") // collapse whitespace 208 + .replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")) // escape any remaining angle brackets 209 + .trim(); 210 + } 211 + 212 + /** 213 + * Find the closest heading before the search match and return its slug 214 + */ 215 + function findClosestHeading( 216 + mdxContent: string, 217 + searchQuery: string | null | undefined, 218 + ): string | null { 219 + if (!searchQuery) return null; 220 + 221 + const searchLower = searchQuery.toLowerCase(); 222 + const contentLower = mdxContent.toLowerCase(); 223 + const matchIndex = contentLower.indexOf(searchLower); 224 + 225 + if (matchIndex === -1) return null; 226 + 227 + // Look for headings before the match (## Heading, ### Heading, etc.) 228 + const contentBeforeMatch = mdxContent.slice(0, matchIndex); 229 + const headingRegex = /^#{1,6}\s+(.+)$/gm; 230 + const headings: { text: string; index: number }[] = []; 231 + 232 + let match = headingRegex.exec(contentBeforeMatch); 233 + while (match !== null) { 234 + headings.push({ 235 + text: match[1].trim(), 236 + index: match.index, 237 + }); 238 + match = headingRegex.exec(contentBeforeMatch); 239 + } 240 + 241 + // Return the closest heading (last one before the match) 242 + if (headings.length > 0) { 243 + const closestHeading = headings[headings.length - 1]; 244 + return slugify(closestHeading.text); 245 + } 246 + 247 + return null; 248 + }
+452
apps/web/src/content/cmdk.tsx
··· 1 + "use client"; 2 + 3 + import type { MDXData } from "@/content/utils"; 4 + import { useDebounce } from "@/hooks/use-debounce"; 5 + import { cn } from "@/lib/utils"; 6 + import { 7 + Command, 8 + CommandEmpty, 9 + CommandGroup, 10 + CommandItem, 11 + CommandList, 12 + CommandLoading, 13 + CommandSeparator, 14 + CommandShortcut, 15 + Dialog, 16 + DialogContent, 17 + DialogTitle, 18 + } from "@openstatus/ui"; 19 + import { useQuery } from "@tanstack/react-query"; 20 + import { Command as CommandPrimitive } from "cmdk"; 21 + import { Loader2, Search } from "lucide-react"; 22 + import { useTheme } from "next-themes"; 23 + import { useRouter } from "next/navigation"; 24 + import * as React from "react"; 25 + 26 + type ConfigItem = { 27 + type: "item"; 28 + label: string; 29 + href: string; 30 + shortcut?: string; 31 + }; 32 + 33 + type ConfigGroup = { 34 + type: "group"; 35 + label: string; 36 + heading: string; 37 + page: string; 38 + }; 39 + 40 + type ConfigSection = { 41 + type: "group"; 42 + heading: string; 43 + items: (ConfigItem | ConfigGroup)[]; 44 + }; 45 + 46 + // TODO: missing shortcuts 47 + const CONFIG: ConfigSection[] = [ 48 + { 49 + type: "group", 50 + heading: "Resources", 51 + items: [ 52 + { 53 + type: "group", 54 + label: "Search in all pages...", 55 + heading: "All pages", 56 + page: "all", 57 + }, 58 + { 59 + type: "item", 60 + label: "Go to Pricing", 61 + href: "/pricing", 62 + }, 63 + { 64 + type: "item", 65 + label: "Go to Docs", 66 + href: "https://docs.openstatus.dev", 67 + }, 68 + { 69 + type: "item", 70 + label: "Go to Global Speed Checker", 71 + href: "/play/checker", 72 + shortcut: "⌘G", 73 + }, 74 + { 75 + type: "group", 76 + label: "Search in Products...", 77 + heading: "Products", 78 + page: "product", 79 + }, 80 + { 81 + type: "group", 82 + label: "Search in Blog...", 83 + heading: "Blog", 84 + page: "blog", 85 + }, 86 + { 87 + type: "group", 88 + label: "Search in Changelog...", 89 + heading: "Changelog", 90 + page: "changelog", 91 + }, 92 + { 93 + type: "group", 94 + label: "Search in Tools...", 95 + heading: "Tools", 96 + page: "tools", 97 + }, 98 + { 99 + type: "group", 100 + label: "Search in Compare...", 101 + heading: "Compare", 102 + page: "compare", 103 + }, 104 + { 105 + type: "item", 106 + label: "Go to About", 107 + href: "/about", 108 + }, 109 + { 110 + type: "item", 111 + label: "Book a call", 112 + href: "/cal", 113 + }, 114 + ], 115 + }, 116 + 117 + { 118 + type: "group", 119 + heading: "Community", 120 + items: [ 121 + { 122 + type: "item", 123 + label: "Discord", 124 + href: "/discord", 125 + }, 126 + { 127 + type: "item", 128 + label: "GitHub", 129 + href: "/github", 130 + }, 131 + { 132 + type: "item", 133 + label: "X", 134 + href: "/x", 135 + }, 136 + { 137 + type: "item", 138 + label: "BlueSky", 139 + href: "/bluesky", 140 + }, 141 + { 142 + type: "item", 143 + label: "YouTube", 144 + href: "/youtube", 145 + }, 146 + { 147 + type: "item", 148 + label: "LinkedIn", 149 + href: "/linkedin", 150 + }, 151 + ], 152 + }, 153 + ]; 154 + 155 + export function CmdK() { 156 + const [open, setOpen] = React.useState(false); 157 + const inputRef = React.useRef<HTMLInputElement | null>(null); 158 + const listRef = React.useRef<HTMLDivElement | null>(null); 159 + const resetTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined); 160 + const [search, setSearch] = React.useState(""); 161 + const [pages, setPages] = React.useState<string[]>([]); 162 + const debouncedSearch = useDebounce(search, 300); 163 + const router = useRouter(); 164 + 165 + const page = pages.length > 0 ? pages[pages.length - 1] : null; 166 + 167 + const { 168 + data: items = [], 169 + isLoading: loading, 170 + isFetching: fetching, 171 + } = useQuery({ 172 + queryKey: ["search", page, debouncedSearch], 173 + queryFn: async () => { 174 + if (!page) return []; 175 + const searchParams = new URLSearchParams(); 176 + searchParams.set("p", page); 177 + if (debouncedSearch) searchParams.set("q", debouncedSearch); 178 + const promise = fetch(`/api/search?${searchParams.toString()}`); 179 + // NOTE: artificial delay to avoid flickering 180 + const delay = new Promise((r) => setTimeout(r, 300)); 181 + const [res, _] = await Promise.all([promise, delay]); 182 + return res.json(); 183 + }, 184 + placeholderData: (previousData) => previousData, 185 + }); 186 + 187 + const resetSearch = React.useCallback(() => setSearch(""), []); 188 + 189 + React.useEffect(() => { 190 + const down = (e: KeyboardEvent) => { 191 + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 192 + e.preventDefault(); 193 + setOpen((open) => !open); 194 + } 195 + 196 + // Handle shortcuts when dialog is open 197 + if (open && (e.metaKey || e.ctrlKey)) { 198 + const key = e.key.toLowerCase(); 199 + 200 + // Find matching shortcut in CONFIG 201 + for (const section of CONFIG) { 202 + for (const item of section.items) { 203 + if (item.type === "item" && item.shortcut) { 204 + const shortcutKey = item.shortcut.replace("⌘", "").toLowerCase(); 205 + if (key === shortcutKey) { 206 + e.preventDefault(); 207 + router.push(item.href); 208 + setOpen(false); 209 + return; 210 + } 211 + } 212 + } 213 + } 214 + } 215 + }; 216 + 217 + document.addEventListener("keydown", down); 218 + return () => document.removeEventListener("keydown", down); 219 + }, [open, router]); 220 + 221 + React.useEffect(() => { 222 + inputRef.current?.focus(); 223 + }, []); 224 + 225 + // NOTE: Reset search and pages after dialog closes (with delay for animation) 226 + // - if within 1 second of closing, the dialog will not reset 227 + React.useEffect(() => { 228 + const DELAY = 1000; 229 + 230 + if (!open && items.length > 0) { 231 + if (resetTimerRef.current) { 232 + clearTimeout(resetTimerRef.current); 233 + } 234 + resetTimerRef.current = setTimeout(() => { 235 + setSearch(""); 236 + setPages([]); 237 + }, DELAY); 238 + } 239 + 240 + if (open && resetTimerRef.current) { 241 + clearTimeout(resetTimerRef.current); 242 + resetTimerRef.current = undefined; 243 + } 244 + 245 + return () => { 246 + if (resetTimerRef.current) { 247 + clearTimeout(resetTimerRef.current); 248 + } 249 + }; 250 + }, [open, items.length]); 251 + 252 + return ( 253 + <> 254 + <button 255 + type="button" 256 + className={cn( 257 + "flex w-full items-center text-left hover:bg-muted", 258 + open && "bg-muted!", 259 + )} 260 + onClick={() => setOpen(true)} 261 + > 262 + <span> 263 + Search<span className="text-muted-foreground text-xs">...</span> 264 + </span> 265 + <kbd className="ml-auto bg-muted text-muted-foreground pointer-events-none inline-flex h-5 items-center gap-1 border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none"> 266 + <span className="text-xs">⌘</span>K 267 + </kbd> 268 + </button> 269 + <Dialog open={open} onOpenChange={setOpen}> 270 + <DialogContent className="overflow-hidden rounded-none p-0 font-mono shadow-2xl"> 271 + <DialogTitle className="sr-only">Search</DialogTitle> 272 + <Command 273 + onKeyDown={(e) => { 274 + // e.key === "Escape" || 275 + if (e.key === "Backspace" && !search) { 276 + e.preventDefault(); 277 + setPages((pages) => pages.slice(0, -1)); 278 + } 279 + }} 280 + shouldFilter={!page} 281 + className="rounded-none" 282 + > 283 + <div 284 + className="flex items-center border-b px-3" 285 + cmdk-input-wrapper="" 286 + > 287 + {loading || fetching ? ( 288 + <Loader2 className="mr-2 h-4 w-4 shrink-0 animate-spin opacity-50" /> 289 + ) : ( 290 + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> 291 + )} 292 + <CommandPrimitive.Input 293 + className="flex h-11 w-full rounded-none bg-transparent py-3 text-sm outline-hidden placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50" 294 + placeholder="Type to search…" 295 + value={search} 296 + onValueChange={setSearch} 297 + /> 298 + </div> 299 + <CommandList ref={listRef} className="[&_[cmdk-item]]:rounded-none"> 300 + <CommandEmpty>No results found.</CommandEmpty> 301 + {loading && !items.length ? ( 302 + <CommandLoading>Searching...</CommandLoading> 303 + ) : null} 304 + {!page ? ( 305 + <Home 306 + setPages={setPages} 307 + resetSearch={resetSearch} 308 + setOpen={setOpen} 309 + /> 310 + ) : null} 311 + {items.length > 0 ? ( 312 + <SearchResults 313 + items={items} 314 + search={search} 315 + setOpen={setOpen} 316 + page={page} 317 + /> 318 + ) : null} 319 + </CommandList> 320 + </Command> 321 + </DialogContent> 322 + </Dialog> 323 + </> 324 + ); 325 + } 326 + 327 + function Home({ 328 + setPages, 329 + resetSearch, 330 + setOpen, 331 + }: { 332 + setPages: React.Dispatch<React.SetStateAction<string[]>>; 333 + resetSearch: () => void; 334 + setOpen: React.Dispatch<React.SetStateAction<boolean>>; 335 + }) { 336 + const router = useRouter(); 337 + const { resolvedTheme, setTheme } = useTheme(); 338 + 339 + return ( 340 + <> 341 + {CONFIG.map((group, groupIndex) => ( 342 + <React.Fragment key={group.heading}> 343 + {groupIndex > 0 && <CommandSeparator />} 344 + <CommandGroup heading={group.heading}> 345 + {group.items.map((item) => { 346 + if (item.type === "item") { 347 + return ( 348 + <CommandItem 349 + key={item.label} 350 + onSelect={() => { 351 + router.push(item.href); 352 + setOpen(false); 353 + }} 354 + > 355 + <span>{item.label}</span> 356 + {item.shortcut && ( 357 + <CommandShortcut>{item.shortcut}</CommandShortcut> 358 + )} 359 + </CommandItem> 360 + ); 361 + } 362 + if (item.type === "group") { 363 + return ( 364 + <CommandItem 365 + key={item.page} 366 + onSelect={() => { 367 + setPages((pages) => [...pages, item.page]); 368 + resetSearch(); 369 + }} 370 + > 371 + <span>{item.label}</span> 372 + </CommandItem> 373 + ); 374 + } 375 + return null; 376 + })} 377 + </CommandGroup> 378 + </React.Fragment> 379 + ))} 380 + <CommandSeparator /> 381 + <CommandGroup heading="Settings"> 382 + <CommandItem 383 + onSelect={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} 384 + > 385 + <span> 386 + Switch to {resolvedTheme === "dark" ? "light" : "dark"} theme 387 + </span> 388 + </CommandItem> 389 + </CommandGroup> 390 + </> 391 + ); 392 + } 393 + 394 + function SearchResults({ 395 + items, 396 + search, 397 + setOpen, 398 + page, 399 + }: { 400 + items: MDXData[]; 401 + search: string; 402 + setOpen: React.Dispatch<React.SetStateAction<boolean>>; 403 + page: string | null; 404 + }) { 405 + const router = useRouter(); 406 + 407 + const _page = CONFIG[0].items.find( 408 + (item) => item.type === "group" && item.page === page, 409 + ) as ConfigGroup | undefined; 410 + 411 + return ( 412 + <CommandGroup heading={_page?.heading ?? "Search Results"}> 413 + {items.map((item) => { 414 + // Highlight search term match in the title, case-insensitive 415 + const title = item.metadata.title.replace( 416 + new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 417 + (match) => `<mark>${match}</mark>`, 418 + ); 419 + const html = item.content.replace( 420 + new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 421 + (match) => `<mark>${match}</mark>`, 422 + ); 423 + 424 + return ( 425 + <CommandItem 426 + key={item.slug} 427 + keywords={[item.metadata.title, item.content, search]} 428 + onSelect={() => { 429 + router.push(item.href); 430 + setOpen(false); 431 + }} 432 + > 433 + <div className="grid min-w-0"> 434 + <span 435 + className="block truncate" 436 + // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 437 + dangerouslySetInnerHTML={{ __html: title }} 438 + /> 439 + {item.content && search ? ( 440 + <span 441 + className="block truncate text-muted-foreground text-xs" 442 + // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 443 + dangerouslySetInnerHTML={{ __html: html }} 444 + /> 445 + ) : null} 446 + </div> 447 + </CommandItem> 448 + ); 449 + })} 450 + </CommandGroup> 451 + ); 452 + }
+6 -8
apps/web/src/content/footer.tsx
··· 26 26 ))} 27 27 </div> 28 28 <div className="grid gap-px border border-border border-t-0 bg-border sm:grid-cols-2 md:grid-cols-3 [&>*]:bg-background"> 29 - <div> 30 - <Link 31 - href="/cal" 32 - className="flex w-full items-center gap-2 p-4 hover:bg-muted" 33 - > 34 - Talk to the founders 35 - </Link> 36 - </div> 29 + <Link 30 + href="/cal" 31 + className="flex w-full items-center gap-2 p-4 hover:bg-muted" 32 + > 33 + Talk to the founders 34 + </Link> 37 35 <div> 38 36 <FooterStatus /> 39 37 </div>
+2 -3
apps/web/src/content/header.tsx
··· 12 12 ContextMenuTrigger, 13 13 } from "@openstatus/ui"; 14 14 import Link from "next/link"; 15 + import { CmdK } from "./cmdk"; 15 16 16 17 export function Header() { 17 18 return ( ··· 86 87 <Link href="/pricing" className="truncate"> 87 88 Pricing 88 89 </Link> 89 - <Link href="https://docs.openstatus.dev" className="truncate"> 90 - Docs 91 - </Link> 90 + <CmdK /> 92 91 <Link 93 92 href="https://app.openstatus.dev/login" 94 93 className="truncate text-info"
+45
apps/web/src/content/highlight-text.tsx
··· 1 + "use client"; 2 + 3 + import { useSearchParams } from "next/navigation"; 4 + import { useEffect, useRef } from "react"; 5 + 6 + function highlight(root: HTMLElement, query: string) { 7 + if (!query) return; 8 + 9 + const regex = new RegExp(`(${query})`, "gi"); 10 + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); 11 + 12 + const textNodes: Text[] = []; 13 + let node = walker.nextNode(); 14 + 15 + while (node) { 16 + if (node instanceof Text) { 17 + textNodes.push(node); 18 + } 19 + node = walker.nextNode(); 20 + } 21 + 22 + for (const textNode of textNodes) { 23 + const nodeValue = textNode.nodeValue; 24 + if (!nodeValue || !regex.test(nodeValue)) continue; 25 + 26 + const span = document.createElement("span"); 27 + span.innerHTML = nodeValue.replace(regex, "<mark>$1</mark>"); 28 + 29 + textNode.parentNode?.replaceChild(span, textNode); 30 + } 31 + } 32 + 33 + export function HighlightText({ children }: { children: React.ReactNode }) { 34 + const ref = useRef<HTMLDivElement>(null); 35 + const searchParams = useSearchParams(); 36 + const q = searchParams.get("q"); 37 + 38 + useEffect(() => { 39 + if (ref.current && q) { 40 + highlight(ref.current, q); 41 + } 42 + }, [q]); 43 + 44 + return <div ref={ref}>{children}</div>; 45 + }
+15 -10
apps/web/src/content/mdx.tsx
··· 9 9 import { Tweet, type TweetProps } from "react-tweet"; 10 10 import { highlight } from "sugar-high"; 11 11 import { CopyButton } from "./copy-button"; 12 + import { HighlightText } from "./highlight-text"; 12 13 import { ImageZoom } from "./image-zoom"; 13 14 import { LatencyChartTable } from "./latency-chart-table"; 14 15 ··· 183 184 ); 184 185 } 185 186 186 - function slugify(str: string) { 187 + export function slugify(str: string) { 187 188 return str 188 189 .toString() 189 190 .toLowerCase() ··· 360 361 361 362 export function CustomMDX(props: MDXRemoteProps) { 362 363 return ( 363 - <MDXRemote 364 - {...props} 365 - components={ 366 - { 367 - ...components, 368 - ...(props.components || {}), 369 - } as MDXRemoteProps["components"] 370 - } 371 - /> 364 + <React.Suspense> 365 + <HighlightText> 366 + <MDXRemote 367 + {...props} 368 + components={ 369 + { 370 + ...components, 371 + ...(props.components || {}), 372 + } as MDXRemoteProps["components"] 373 + } 374 + /> 375 + </HighlightText> 376 + </React.Suspense> 372 377 ); 373 378 }
+30 -4
apps/web/src/content/utils.ts
··· 1 1 import fs from "node:fs"; 2 2 import path from "node:path"; 3 + import slugify from "slugify"; 3 4 import { z } from "zod"; 4 5 5 6 const metadataSchema = z.object({ ··· 47 48 return parseFrontmatter(rawContent); 48 49 } 49 50 50 - function getMDXDataFromDir(dir: string) { 51 + function getMDXDataFromDir(dir: string, prefix = "") { 51 52 const mdxFiles = getMDXFiles(dir); 52 53 return mdxFiles.map((file) => { 53 - return getMDXDataFromFile(path.join(dir, file)); 54 + return getMDXDataFromFile(path.join(dir, file), prefix); 54 55 }); 55 56 } 56 57 57 - function getMDXDataFromFile(filePath: string) { 58 + function getMDXDataFromFile(filePath: string, prefix = "") { 58 59 const { metadata, content } = readMDXFile(filePath); 59 - const slug = path.basename(filePath, path.extname(filePath)); 60 + const slugRaw = path.basename(filePath, path.extname(filePath)); 61 + const slug = slugify(slugRaw, { lower: true, strict: true }); 62 + const href = prefix ? `${prefix}/${slug}` : `/${slug}`; 60 63 return { 61 64 metadata, 62 65 slug, 63 66 content, 67 + href, 64 68 }; 65 69 } 66 70 ··· 69 73 export function getBlogPosts(): MDXData[] { 70 74 return getMDXDataFromDir( 71 75 path.join(process.cwd(), "src", "content", "pages", "blog"), 76 + "/blog", 72 77 ); 73 78 } 74 79 75 80 export function getChangelogPosts(): MDXData[] { 76 81 return getMDXDataFromDir( 77 82 path.join(process.cwd(), "src", "content", "pages", "changelog"), 83 + "/changelog", 78 84 ); 79 85 } 80 86 81 87 export function getProductPages(): MDXData[] { 82 88 return getMDXDataFromDir( 83 89 path.join(process.cwd(), "src", "content", "pages", "product"), 90 + "", 84 91 ); 85 92 } 86 93 87 94 export function getUnrelatedPages(): MDXData[] { 88 95 return getMDXDataFromDir( 89 96 path.join(process.cwd(), "src", "content", "pages", "unrelated"), 97 + "", 90 98 ); 91 99 } 92 100 ··· 100 108 "unrelated", 101 109 `${slug}.mdx`, 102 110 ), 111 + "", 103 112 ); 104 113 } 105 114 ··· 110 119 export function getComparePages(): MDXData[] { 111 120 return getMDXDataFromDir( 112 121 path.join(process.cwd(), "src", "content", "pages", "compare"), 122 + "/compare", 113 123 ); 114 124 } 115 125 116 126 export function getHomePage(): MDXData { 117 127 return getMDXDataFromFile( 118 128 path.join(process.cwd(), "src", "content", "pages", "home.mdx"), 129 + "", 119 130 ); 120 131 } 121 132 122 133 export function getToolsPages(): MDXData[] { 123 134 return getMDXDataFromDir( 124 135 path.join(process.cwd(), "src", "content", "pages", "tools"), 136 + "/play", 125 137 ); 126 138 } 127 139 128 140 export function getToolsPage(slug: string): MDXData { 129 141 return getMDXDataFromFile( 130 142 path.join(process.cwd(), "src", "content", "pages", "tools", `${slug}.mdx`), 143 + "/play", 131 144 ); 145 + } 146 + 147 + export function getCategories() { 148 + return [ 149 + ...new Set([ 150 + ...getBlogPosts().map((post) => post.metadata.category), 151 + ...getChangelogPosts().map((post) => post.metadata.category), 152 + ...getProductPages().map((post) => post.metadata.category), 153 + ...getUnrelatedPages().map((post) => post.metadata.category), 154 + ...getComparePages().map((post) => post.metadata.category), 155 + ...getToolsPages().map((post) => post.metadata.category), 156 + ]), 157 + ] as const; 132 158 } 133 159 134 160 export function formatDate(targetDate: Date, includeRelative = false) {
+4 -4
apps/web/src/data/content.ts
··· 49 49 label: "Resources", 50 50 items: [ 51 51 { 52 + label: "Docs", 53 + href: "https://docs.openstatus.dev", 54 + }, 55 + { 52 56 label: "Blog", 53 57 href: "/blog", 54 58 }, ··· 63 67 { 64 68 label: "Compare", 65 69 href: "/compare", 66 - }, 67 - { 68 - label: "Playground (Tools)", 69 - href: "/play", 70 70 }, 71 71 ], 72 72 };
+1 -1
packages/ui/package.json
··· 47 47 "@radix-ui/react-tooltip": "1.1.6", 48 48 "class-variance-authority": "0.7.1", 49 49 "clsx": "2.1.1", 50 - "cmdk": "1.0.4", 50 + "cmdk": "1.1.1", 51 51 "date-fns": "2.30.0", 52 52 "lucide-react": "0.525.0", 53 53 "luxon": "3.5.0",
+13
packages/ui/src/components/command.tsx
··· 142 142 }; 143 143 CommandShortcut.displayName = "CommandShortcut"; 144 144 145 + const CommandLoading = React.forwardRef< 146 + React.ElementRef<typeof CommandPrimitive.Loading>, 147 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Loading> 148 + >(({ className, ...props }, ref) => ( 149 + <CommandPrimitive.Loading 150 + ref={ref} 151 + className={cn("px-2 py-1.5 text-sm", className)} 152 + {...props} 153 + /> 154 + )); 155 + CommandLoading.displayName = CommandPrimitive.Loading.displayName; 156 + 145 157 export { 146 158 Command, 147 159 CommandDialog, 148 160 CommandInput, 149 161 CommandList, 162 + CommandLoading, 150 163 CommandEmpty, 151 164 CommandGroup, 152 165 CommandItem,
+63 -35
pnpm-lock.yaml
··· 991 991 resend: 992 992 specifier: 6.6.0 993 993 version: 6.6.0(@react-email/render@2.0.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2)) 994 + sanitize-html: 995 + specifier: 2.17.0 996 + version: 2.17.0 994 997 schema-dts: 995 998 specifier: 1.1.5 996 999 version: 1.1.5 997 1000 shiki: 998 1001 specifier: 0.14.4 999 1002 version: 0.14.4 1003 + slugify: 1004 + specifier: 1.6.6 1005 + version: 1.6.6 1000 1006 sonner: 1001 1007 specifier: 2.0.5 1002 1008 version: 2.0.5(react-dom@19.2.2(react@19.2.2))(react@19.2.2) ··· 1034 1040 '@types/react-dom': 1035 1041 specifier: 19.2.2 1036 1042 version: 19.2.2(@types/react@19.2.2) 1043 + '@types/sanitize-html': 1044 + specifier: 2.16.0 1045 + version: 2.16.0 1037 1046 postcss: 1038 1047 specifier: 8.4.38 1039 1048 version: 8.4.38 ··· 1126 1135 version: 2.6.2 1127 1136 drizzle-orm: 1128 1137 specifier: 0.44.4 1129 - version: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4) 1138 + version: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5) 1130 1139 effect: 1131 1140 specifier: 3.19.12 1132 1141 version: 3.19.12 ··· 1145 1154 version: link:../../packages/tsconfig 1146 1155 '@types/bun': 1147 1156 specifier: latest 1148 - version: 1.3.4 1157 + version: 1.3.5 1149 1158 typescript: 1150 1159 specifier: 5.9.3 1151 1160 version: 5.9.3 ··· 1301 1310 version: 0.7.1(typescript@5.9.3)(zod@3.25.76) 1302 1311 drizzle-orm: 1303 1312 specifier: 0.44.4 1304 - version: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4) 1313 + version: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5) 1305 1314 drizzle-zod: 1306 1315 specifier: 0.5.1 1307 - version: 0.5.1(drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4))(zod@3.25.76) 1316 + version: 0.5.1(drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5))(zod@3.25.76) 1308 1317 zod: 1309 1318 specifier: 3.25.76 1310 1319 version: 3.25.76 ··· 1933 1942 specifier: 2.1.1 1934 1943 version: 2.1.1 1935 1944 cmdk: 1936 - specifier: 1.0.4 1937 - version: 1.0.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) 1945 + specifier: 1.1.1 1946 + version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) 1938 1947 date-fns: 1939 1948 specifier: 2.30.0 1940 1949 version: 2.30.0 ··· 6224 6233 '@types/braces@3.0.5': 6225 6234 resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} 6226 6235 6227 - '@types/bun@1.3.4': 6228 - resolution: {integrity: sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA==} 6236 + '@types/bun@1.3.5': 6237 + resolution: {integrity: sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==} 6229 6238 6230 6239 '@types/caseless@0.12.5': 6231 6240 resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} ··· 6377 6386 '@types/request@2.48.13': 6378 6387 resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} 6379 6388 6389 + '@types/sanitize-html@2.16.0': 6390 + resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} 6391 + 6380 6392 '@types/sax@1.2.7': 6381 6393 resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} 6382 6394 ··· 6423 6435 '@upstash/kafka@1.3.3': 6424 6436 resolution: {integrity: sha512-CIr657FZuK+IMuwcxkj3oCB6xKO+LMlHd4BL4J/Lwbpj6+5YHO+5ZcpdMIQhbcemthJcRtE0gDUfZEnrfb3Rjg==} 6425 6437 engines: {node: '>=10'} 6438 + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. 6426 6439 6427 6440 '@upstash/qstash@2.6.2': 6428 6441 resolution: {integrity: sha512-aB/1yqMJTRyOt7Go2Db1ZIVnmTPpsc2KGY5jpLVcegNtjksaPTJF6fmITxos5HVvsQhS8IB3gvF/+gQfRQlPLQ==} ··· 6799 6812 peerDependencies: 6800 6813 '@types/react': ^19 6801 6814 6802 - bun-types@1.3.4: 6803 - resolution: {integrity: sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ==} 6815 + bun-types@1.3.5: 6816 + resolution: {integrity: sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==} 6804 6817 6805 6818 bundle-require@4.2.1: 6806 6819 resolution: {integrity: sha512-7Q/6vkyYAwOmQNRw75x+4yRtZCZJXUDmHHlFdkiV0wgv/reNjtJwpu1jPJ0w2kbEpIM0uoKI3S4/f39dU7AjSA==} ··· 6958 6971 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 6959 6972 engines: {node: '>=6'} 6960 6973 6961 - cmdk@1.0.4: 6962 - resolution: {integrity: sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==} 6963 - peerDependencies: 6964 - react: ^18 || ^19 || ^19.0.0-rc 6965 - react-dom: ^18 || ^19 || ^19.0.0-rc 6966 - 6967 6974 cmdk@1.1.1: 6968 6975 resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} 6969 6976 peerDependencies: ··· 8265 8272 is-plain-obj@4.1.0: 8266 8273 resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} 8267 8274 engines: {node: '>=12'} 8275 + 8276 + is-plain-object@5.0.0: 8277 + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} 8278 + engines: {node: '>=0.10.0'} 8268 8279 8269 8280 is-promise@4.0.0: 8270 8281 resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} ··· 9250 9261 parse-latin@7.0.0: 9251 9262 resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} 9252 9263 9264 + parse-srcset@1.0.2: 9265 + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} 9266 + 9253 9267 parse5@7.3.0: 9254 9268 resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} 9255 9269 ··· 9868 9882 9869 9883 safer-buffer@2.1.2: 9870 9884 resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 9885 + 9886 + sanitize-html@2.17.0: 9887 + resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} 9871 9888 9872 9889 sax@1.4.3: 9873 9890 resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} ··· 9975 9992 resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} 9976 9993 engines: {node: '>=8'} 9977 9994 9995 + slugify@1.6.6: 9996 + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} 9997 + engines: {node: '>=8.0.0'} 9998 + 9978 9999 smart-buffer@4.2.0: 9979 10000 resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} 9980 10001 engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} ··· 15807 15828 15808 15829 '@types/braces@3.0.5': {} 15809 15830 15810 - '@types/bun@1.3.4': 15831 + '@types/bun@1.3.5': 15811 15832 dependencies: 15812 - bun-types: 1.3.4 15833 + bun-types: 1.3.5 15813 15834 15814 15835 '@types/caseless@0.12.5': {} 15815 15836 ··· 15975 15996 '@types/node': 24.0.8 15976 15997 '@types/tough-cookie': 4.0.5 15977 15998 form-data: 2.5.5 15999 + 16000 + '@types/sanitize-html@2.16.0': 16001 + dependencies: 16002 + htmlparser2: 8.0.2 15978 16003 15979 16004 '@types/sax@1.2.7': 15980 16005 dependencies: ··· 16560 16585 '@types/node': 24.0.8 16561 16586 '@types/react': 19.2.2 16562 16587 16563 - bun-types@1.3.4: 16588 + bun-types@1.3.5: 16564 16589 dependencies: 16565 16590 '@types/node': 24.0.8 16566 16591 ··· 16715 16740 16716 16741 clsx@2.1.1: {} 16717 16742 16718 - cmdk@1.0.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): 16719 - dependencies: 16720 - '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) 16721 - '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.2) 16722 - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) 16723 - react: 19.2.2 16724 - react-dom: 19.2.2(react@19.2.2) 16725 - use-sync-external-store: 1.6.0(react@19.2.2) 16726 - transitivePeerDependencies: 16727 - - '@types/react' 16728 - - '@types/react-dom' 16729 - 16730 16743 cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): 16731 16744 dependencies: 16732 16745 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.2) ··· 17078 17091 transitivePeerDependencies: 17079 17092 - supports-color 17080 17093 17081 - drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4): 17094 + drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5): 17082 17095 optionalDependencies: 17083 17096 '@libsql/client': 0.15.15 17084 17097 '@opentelemetry/api': 1.9.0 17085 17098 '@types/pg': 8.15.6 17086 - bun-types: 1.3.4 17099 + bun-types: 1.3.5 17087 17100 17088 - drizzle-zod@0.5.1(drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4))(zod@3.25.76): 17101 + drizzle-zod@0.5.1(drizzle-orm@0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5))(zod@3.25.76): 17089 17102 dependencies: 17090 - drizzle-orm: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.4) 17103 + drizzle-orm: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5) 17091 17104 zod: 3.25.76 17092 17105 17093 17106 dset@3.1.4: {} ··· 18232 18245 is-path-inside@3.0.3: {} 18233 18246 18234 18247 is-plain-obj@4.1.0: {} 18248 + 18249 + is-plain-object@5.0.0: {} 18235 18250 18236 18251 is-promise@4.0.0: {} 18237 18252 ··· 19488 19503 unist-util-modify-children: 4.0.0 19489 19504 unist-util-visit-children: 3.0.0 19490 19505 vfile: 6.0.3 19506 + 19507 + parse-srcset@1.0.2: {} 19491 19508 19492 19509 parse5@7.3.0: 19493 19510 dependencies: ··· 20255 20272 20256 20273 safer-buffer@2.1.2: {} 20257 20274 20275 + sanitize-html@2.17.0: 20276 + dependencies: 20277 + deepmerge: 4.3.1 20278 + escape-string-regexp: 4.0.0 20279 + htmlparser2: 8.0.2 20280 + is-plain-object: 5.0.0 20281 + parse-srcset: 1.0.2 20282 + postcss: 8.4.38 20283 + 20258 20284 sax@1.4.3: {} 20259 20285 20260 20286 scheduler@0.27.0: {} ··· 20473 20499 sax: 1.4.3 20474 20500 20475 20501 slash@3.0.0: {} 20502 + 20503 + slugify@1.6.6: {} 20476 20504 20477 20505 smart-buffer@4.2.0: {} 20478 20506