From dc47013946d6c904de3075001bba1ad2fe5646f6 Mon Sep 17 00:00:00 2001 From: Rabi Shanker Guha Date: Fri, 13 Mar 2026 19:10:36 +0530 Subject: [PATCH 1/4] Setup blog and add the rust-wasm blog --- docs/app/(home)/components/Navbar/Navbar.tsx | 3 +- docs/app/blog/[slug]/page.tsx | 68 ++++++ .../app/blog/components/BlogNavbar.module.css | 195 +++++++++++++++++ docs/app/blog/components/BlogNavbar.tsx | 200 ++++++++++++++++++ docs/app/blog/layout.tsx | 10 + docs/app/blog/page.tsx | 33 +++ docs/content/blog/rust-wasm-parser.mdx | 186 ++++++++++++++++ docs/generated/chat-system-prompt.txt | 2 +- docs/generated/playground-system-prompt.txt | 2 +- docs/lib/source.ts | 8 +- docs/source.config.ts | 12 +- 11 files changed, 714 insertions(+), 5 deletions(-) create mode 100644 docs/app/blog/[slug]/page.tsx create mode 100644 docs/app/blog/components/BlogNavbar.module.css create mode 100644 docs/app/blog/components/BlogNavbar.tsx create mode 100644 docs/app/blog/layout.tsx create mode 100644 docs/app/blog/page.tsx create mode 100644 docs/content/blog/rust-wasm-parser.mdx diff --git a/docs/app/(home)/components/Navbar/Navbar.tsx b/docs/app/(home)/components/Navbar/Navbar.tsx index 938b91f8c..5da6d286a 100644 --- a/docs/app/(home)/components/Navbar/Navbar.tsx +++ b/docs/app/(home)/components/Navbar/Navbar.tsx @@ -17,12 +17,13 @@ import styles from "./Navbar.module.css"; // Constants // --------------------------------------------------------------------------- -const NAV_TABS = ["OpenUI Lang", "Playground", "API Reference"] as const; +const NAV_TABS = ["OpenUI Lang", "Playground", "API Reference", "Blog"] as const; const TAB_URLS: Record = { "OpenUI Lang": "/docs/openui-lang", Playground: "/playground", "API Reference": "/docs/api-reference", Components: "/docs/components", + Blog: "/blog", }; const NAVBAR_BORDER_COLOR = "rgba(0,0,0,0.1)"; const MOBILE_GITHUB_BUTTON_STYLE = { diff --git a/docs/app/blog/[slug]/page.tsx b/docs/app/blog/[slug]/page.tsx new file mode 100644 index 000000000..e0122576f --- /dev/null +++ b/docs/app/blog/[slug]/page.tsx @@ -0,0 +1,68 @@ +import { blog } from "@/lib/source"; +import { getMDXComponents } from "@/mdx-components"; +import { TOCProvider, TOCScrollArea } from "fumadocs-ui/components/toc/index"; +import { TOCItems } from "fumadocs-ui/components/toc/default"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +export default async function BlogPostPage(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + const page = blog.getPage([params.slug]); + + if (!page) notFound(); + const Mdx = page.data.body; + + return ( + +
+ + +
+

{page.data.title}

+

+ {page.data.description} +

+
+ {page.data.author} + · + +
+
+ +
+
+
+
+ ); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const params = await props.params; + const page = blog.getPage([params.slug]); + + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + }; +} + +export function generateStaticParams(): { slug: string }[] { + return blog.getPages().map((page) => ({ + slug: page.slugs[0], + })); +} diff --git a/docs/app/blog/components/BlogNavbar.module.css b/docs/app/blog/components/BlogNavbar.module.css new file mode 100644 index 000000000..72746f857 --- /dev/null +++ b/docs/app/blog/components/BlogNavbar.module.css @@ -0,0 +1,195 @@ +.nav { + position: sticky; + top: 0; + z-index: 50; + width: 100%; + padding: 0.75rem 2rem; + background: var(--blog-nav-bg); + transition: border-color 0.2s ease; +} + +.navInner { + display: flex; + max-width: 75rem; + margin-inline: auto; + align-items: center; + justify-content: space-between; +} + +.logoCluster { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.logoDivider { + height: 1rem; + width: 1px; + background: var(--blog-nav-border); +} + +.desktopTabs, +.desktopGithub { + display: none; +} + +.desktopGithub { + align-items: center; + gap: 0.5rem; +} + +.desktopTabs { + align-items: center; + gap: 0.5rem; +} + +.desktopTabLink { + display: flex; + height: 2rem; + align-items: center; + padding-inline: 0.5rem; + border-radius: 0.375rem; + color: var(--blog-nav-text); + text-decoration: none; + transition: background-color 0.2s ease; + font-family: "Inter Display", sans-serif; + font-size: 15px; + line-height: 1.5rem; +} + +.desktopTabLink:hover, +.mobileTrayLink:hover, +.mobileMenuButton:hover { + background: var(--blog-nav-hover); +} + +.chevronIcon, +.hamburgerIcon { + height: 1.25rem; + width: 1.25rem; +} + +.mobileMenuButton { + display: flex; + height: 2.5rem; + width: 2.5rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--blog-nav-text); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.mobileBackdrop { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 40; + height: 100vh; + cursor: pointer; + background: rgb(0 0 0 / 60%); + backdrop-filter: blur(12px); +} + +.mobileTrayWrap { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 50; + pointer-events: none; +} + +.mobileTray { + pointer-events: auto; + border-top: 1px solid var(--blog-nav-border); + border-bottom-left-radius: 18px; + border-bottom-right-radius: 18px; + background: var(--blog-nav-bg); + box-shadow: 0 10px 20px rgb(0 0 0 / 10%); +} + +.mobileTrayInner { + display: flex; + max-width: 75rem; + margin-inline: auto; + flex-direction: column; + gap: 0; + padding: 0.75rem 1.75rem 1.25rem; +} + +.mobileTrayDivider { + height: 1px; + margin-inline: 0.75rem; + background: var(--blog-nav-border); +} + +.mobileTrayLink { + display: flex; + width: 100%; + height: 3.5rem; + align-items: center; + gap: 0.25rem; + padding-inline: 0.75rem; + border-radius: 0.375rem; + color: var(--blog-nav-text); + text-align: left; + text-decoration: none; + transition: background-color 0.2s ease; + font-family: "Inter", sans-serif; + font-size: 18px; + line-height: 1.5rem; +} + +.mobileGithubButtonWrap { + display: flex; + justify-content: center; + padding-top: 80px; + pointer-events: auto; +} + +.mobileGithubButton { + position: relative; + display: flex; + height: 38px; + align-items: center; + gap: 0.375rem; + padding-left: 0.75rem; + padding-right: 0.5rem; + border: 0; + border-radius: 999px; + background: var(--blog-nav-bg); + cursor: pointer; + transform: scale(1.17); +} + +.mobileGithubButton:hover .mobileGithubButtonOverlay { + box-shadow: none; +} + +.mobileGithubButtonOverlay { + position: absolute; + inset: 0; + pointer-events: none; + border: 1px solid var(--blog-nav-border); + border-radius: 999px; + box-shadow: var(--mobile-github-button-shadow, none); + transition: box-shadow 0.2s ease; +} + +@media (min-width: 1024px) { + .desktopTabs, + .desktopGithub { + display: flex; + } + + .mobileMenuButton, + .mobileBackdrop, + .mobileTrayWrap { + display: none; + } +} diff --git a/docs/app/blog/components/BlogNavbar.tsx b/docs/app/blog/components/BlogNavbar.tsx new file mode 100644 index 000000000..900e72d7f --- /dev/null +++ b/docs/app/blog/components/BlogNavbar.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { + GitHubIcon, + OpenUILogo, + StarCountBadge, + ThesysLogo, + useGitHubStarCount, +} from "@/components/brand-logo"; +// GitHubIcon & StarCountBadge used in mobile menu +import { ThemeToggle } from "@/components/theme-toggle"; +import { AnimatePresence, motion } from "motion/react"; +import { useTheme } from "next-themes"; +import { useCallback, useEffect, useState, type CSSProperties } from "react"; +import styles from "./BlogNavbar.module.css"; + +const BUTTON_SHADOW = "0px 1px 3px 0px rgba(22,34,51,0.08), 0px 12px 24px 0px rgba(22,34,51,0.04)"; + +const NAV_TABS = ["OpenUI Lang", "Playground", "API Reference", "Blog"] as const; +const TAB_URLS: Record = { + "OpenUI Lang": "/docs/openui-lang", + Playground: "/playground", + "API Reference": "/docs/api-reference", + Blog: "/blog", +}; + +function DesktopNavTabs() { + return ( +
+ {NAV_TABS.map((tab) => ( + + {tab} + + ))} +
+ ); +} + +function HamburgerIcon({ isOpen }: { isOpen: boolean }) { + return ( + + {isOpen ? ( + <> + + + + ) : ( + <> + + + + + )} + + ); +} + +function MobileMenu({ starCount, onClose }: { starCount: number; onClose: () => void }) { + const mobileGithubStyle = { + "--mobile-github-button-shadow": BUTTON_SHADOW, + } as CSSProperties; + + return ( + <> + + +
+
+ {NAV_TABS.map((tab, index) => ( +
+ {index > 0 && + ))} +
+
+
+ + + + + ); +} + +export function BlogNavbar() { + const [isLogoHovered, setIsLogoHovered] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const starCount = useGitHubStarCount("thesysdev/openui"); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleScroll = () => setIsScrolled(window.scrollY > 0); + window.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + const toggleMobileMenu = useCallback(() => { + setIsMobileMenuOpen((prev) => !prev); + }, []); + + const isDark = mounted && resolvedTheme === "dark"; + const logoVariant = isDark ? "dark" : "light"; + const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)"; + + const navStyle = { + "--blog-nav-bg": isDark ? "#0b0b0f" : "#fff", + "--blog-nav-text": isDark ? "#e2e1e6" : "#000", + "--blog-nav-border": isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.1)", + "--blog-nav-hover": isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", + borderBottom: `1px solid ${isScrolled ? borderColor : "transparent"}`, + } as CSSProperties; + + return ( + + ); +} diff --git a/docs/app/blog/layout.tsx b/docs/app/blog/layout.tsx new file mode 100644 index 000000000..a19a904d7 --- /dev/null +++ b/docs/app/blog/layout.tsx @@ -0,0 +1,10 @@ +import { BlogNavbar } from "./components/BlogNavbar"; + +export default function BlogLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/docs/app/blog/page.tsx b/docs/app/blog/page.tsx new file mode 100644 index 000000000..e3661d00e --- /dev/null +++ b/docs/app/blog/page.tsx @@ -0,0 +1,33 @@ +import { blog } from "@/lib/source"; +import Link from "next/link"; + +export default function BlogIndex() { + const posts = blog.getPages().sort((a, b) => { + return new Date(b.data.date).getTime() - new Date(a.data.date).getTime(); + }); + + return ( +
+

Blog

+
+ {posts.map((post) => ( + +
+

+ {post.data.title} +

+ +
+

{post.data.description}

+ + ))} +
+
+ ); +} diff --git a/docs/content/blog/rust-wasm-parser.mdx b/docs/content/blog/rust-wasm-parser.mdx new file mode 100644 index 000000000..2d27ce22d --- /dev/null +++ b/docs/content/blog/rust-wasm-parser.mdx @@ -0,0 +1,186 @@ +--- +title: Rewriting our Rust WASM Parser in TypeScript +description: We rewrote Our Rust WASM Parser in TypeScript - and it got 3x Faster +author: Thesys Engineering Team +date: 2026-03-13 +--- + +We built the openui-lang parser in Rust and compiled it to WASM. +The logic was sound: Rust is fast, WASM gives you near-native speed in the browser, +and our parser is a reasonably complex multi-stage pipeline. +Why wouldn't you want that in Rust? + +Turns out we were optimising the wrong thing. + +## The Pipeline + +The openui-lang parser converts a custom DSL emitted by an LLM into a React component tree. It runs on every streaming chunk — so latency matters a lot. The pipeline has six stages: + +```mermaid +autocloser → lexer → splitter → parser → resolver → mapper → ParseResult +``` + +- **Autocloser**: makes partial (mid-stream) text syntactically valid by appending minimal closing brackets/quotes +- **Lexer**: single-pass character scanner, emits typed tokens +- **Splitter**: cuts the token stream into `id = expression` statements +- **Parser**: recursive-descent expression parser, builds an AST +- **Resolver**: inline all variable references (hoisting support, circular ref detection) +- **Mapper**: converts internal AST into the public `OutputNode` format consumed by the React renderer + +## The WASM Boundary Tax + +Every call to the WASM parser pays a mandatory overhead regardless of how fast the Rust code itself runs: + +``` +JS world WASM world +──────────────────────────────────────────────────────── +wasmParse(input) + │ + ├─ copy string: JS heap → WASM linear memory (allocation + memcpy) + │ + │ Rust parses ✓ fast + │ serde_json::to_string() ← serialize result + │ + ├─ copy JSON string: WASM → JS heap (allocation + memcpy) + │ + JSON.parse(jsonString) ← deserialize result + │ + return ParseResult +``` + +The Rust parsing itself was never the slow part. The overhead was entirely in the boundary: copy string in, serialize result to JSON string, copy JSON string out, then V8 deserializes it back into a JS object. + +## Attempted Fix: Skip the JSON Round-Trip + +The natural question was: what if WASM returned a JS object directly, skipping the JSON serialization step? We integrated `serde-wasm-bindgen` which does exactly this — it converts the Rust struct into a `JsValue` and returns it directly. + +It was **30% slower**. + +Here's why. JS cannot read a Rust struct's bytes from WASM linear memory as a native JS object — the two runtimes use completely different memory layouts. To construct a JS object from Rust data, `serde-wasm-bindgen` must recursively materialise Rust data into real JS arrays and objects, which involves many fine-grained conversions across the runtime boundary per `parse()` invocation. + +Compare that to the JSON approach: `serde_json::to_string()` runs in pure Rust with zero boundary crossings, produces one string, one `memcpy` copies it to the JS heap, then V8's native C++ `JSON.parse` processes it in a single optimised pass. Fewer, larger, and more optimised operations win over many small ones. + +### Benchmark: JSON string vs direct JsValue (1000 runs, µs per call) + +| Fixture | JSON round-trip | serde-wasm-bindgen | Change | +|---|---|---|---| +| simple-table | 20.5 | 22.5 | -9% slower | +| contact-form | 61.4 | 79.4 | -29% slower | +| dashboard | 57.9 | 74.0 | -28% slower | + +We reverted this change immediately. + +## The Real Fix: Eliminate the Boundary Entirely + +We ported the full parser pipeline to TypeScript. Same six-stage architecture, same `ParseResult` output shape — no WASM, no boundary, runs entirely in the V8 heap. + +### Benchmark Method: One-Shot Parse + +**What is measured:** A single `parse(completeString)` call on the finished output string. This isolates per-call parser cost. + +**How it was run:** 30 warm-up iterations to stabilise JIT, then 1000 timed iterations using `performance.now()` (µs precision). The median is reported. Fixtures are real LLM-generated component trees serialised in each format's real streaming syntax. + +**Fixtures:** +- `simple-table` — root + one Table with 3 columns and 5 rows (~180 chars) +- `contact-form` — root + form layout with 6 input fields + submit button (~400 chars) +- `dashboard` — root + sidebar nav + 3 metric cards + chart + data table (~950 chars) + +### Results: One-Shot Parse (median µs, 1000 runs) + +| Fixture | TypeScript | WASM | Speedup | +|---|---|---|---| +| simple-table | 9.3 | 20.5 | **2.2x** | +| contact-form | 13.4 | 61.4 | **4.6x** | +| dashboard | 19.4 | 57.9 | **3.0x** | + +## The Algorithmic Problem: O(N²) Streaming + +Eliminating WASM fixed the per-call cost, but the streaming architecture still had a deeper inefficiency. + +The parser is called on every LLM chunk. The naïve approach accumulates chunks and re-parses the entire string from scratch each time: + +``` +Chunk 1: parse("root = Root([t") → 14 chars +Chunk 2: parse("root = Root([tbl])\ntbl = T") → 27 chars +Chunk 3: parse(full_accumulated_string) → ... +``` + +For a 1000-char output delivered in 20-char chunks: 50 parse calls processing a cumulative total of ~25,000 characters. O(N²) in the number of chunks. + +### The Fix: Statement-Level Incremental Caching + +Statements terminated by a depth-0 newline are **immutable** — the LLM will never come back and modify them. We added a streaming parser that caches completed statement ASTs: + +``` +State: { buf, completedEnd, completedSyms, firstId } + +On each push(chunk): + 1. Scan buf from completedEnd for depth-0 newlines + 2. For each complete statement found: parse + cache AST → advance completedEnd + 3. Pending (last, incomplete) statement: autoclose + parse fresh + 4. Merge cached + pending → resolve + map → return ParseResult +``` + +Completed statements are never re-parsed. Only the trailing in-progress statement is re-parsed per chunk. O(total_length) instead of O(N²). + +### Benchmark Method: Full-Stream Total Parse Cost + +**What is measured:** The **total parse overhead accumulated across every chunk call for one complete document**. This is different from the one-shot benchmark — it measures the sum of all parse calls during a real stream, not a single call. This is the number that affects actual user-perceived responsiveness. + +**How it was run:** Documents are replayed in 20-char chunks. Each chunk triggers a `parse()` (naïve) or `push()` (incremental) call. Total time across all calls is recorded. 100 full-stream replays, median taken. + +### Results: Full-Stream Total Parse Cost (median µs across all chunks) + +| Fixture | Naïve TS (re-parse every chunk) | Incremental TS (cache completed) | Speedup | +|---|---|---|---| +| simple-table | 69 | 77 | none (single statement, no cache benefit) | +| contact-form | 316 | 122 | **2.6x** | +| dashboard | 840 | 255 | **3.3x** | + +The `simple-table` fixture is a single statement — there's nothing to cache, so both approaches are equivalent. The benefit scales with the number of statements because more of the document gets cached and skipped on each chunk. + +### Why the two TS numbers look different + +The one-shot table shows 13.4µs for `contact-form`; the streaming table shows 316µs (naïve). These are not contradictory — they measure different things: + +- **13.4µs** = cost of one `parse()` call on the complete 400-char string +- **316µs** = total cost of ~20 `parse()` calls during the stream (chunk 1 parses 20 chars, chunk 2 parses 40 chars, ..., chunk 20 parses 400 chars — cumulative sum of all those growing calls) + +## Summary + +| Approach | Per-call cost | Full-stream total | Notes | +|---|---|---|---| +| WASM + JSON round-trip | 20-61µs | baseline | Copy overhead each call | +| WASM + serde-wasm-bindgen | 22-79µs | +9-29% slower | Hundreds of internal boundary crossings | +| TypeScript (naïve re-parse) | 9-19µs | 69-840µs | No boundary, but O(N²) streaming | +| TypeScript (incremental) | 9-19µs | 69-255µs | No boundary + O(N) streaming | + +**End result: 3-4.6x faster per call and 2.6-3.3x lower total streaming cost.** + +## When WASM Actually Helps + +This experience sharpened our thinking on the right use cases for WASM: + +✅ **Compute-bound with minimal interop**: image/video processing, cryptography, physics simulations, audio codecs. Large input → scalar output or in-place mutation. The boundary is crossed rarely. + +✅ **Portable native libraries**: shipping C/C++ libraries (SQLite, OpenCV, libpng) to the browser without a full JS rewrite. + +❌ **Parsing structured text into JS objects**: you pay the serialization cost either way. The parsing computation is fast enough that V8's JIT eliminates any Rust advantage. The boundary overhead dominates. + +❌ **Frequently-called functions on small inputs**: if the function is called 50 times per stream and the computation takes 5µs, you cannot amortise the boundary cost. + +## Key Takeaways + +1. **Profile where time is actually spent before choosing the implementation language.** + For us, the cost was never in the computation - it was always in data transfer across the WASM-JS boundary. + +2. **"Direct object passing" through `serde-wasm-bindgen` is not cheaper.** + Constructing a JS object field-by-field from Rust involves more boundary crossings than a single JSON string transfer, not fewer. + The boundary crossings happen inside the single FFI call, invisibly. + +3. **Algorithmic complexity improvements dominate language-level optimisations.** + Going from O(N²) to O(N) in the streaming case had a larger practical impact than switching from WASM to TypeScript. + +4. **WASM and JS do not share a heap.** + WASM has a flat linear memory (`WebAssembly.Memory`) that JS can read as raw bytes, but those bytes are Rust's internal layout - pointers, enum discriminants, alignment padding - completely opaque to the JS runtime. + Conversion is always required and always costs something. diff --git a/docs/generated/chat-system-prompt.txt b/docs/generated/chat-system-prompt.txt index b655c131e..9a444471c 100644 --- a/docs/generated/chat-system-prompt.txt +++ b/docs/generated/chat-system-prompt.txt @@ -61,7 +61,7 @@ TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) SelectItem(value: string, label: string) — Option for Select DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) -Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) diff --git a/docs/generated/playground-system-prompt.txt b/docs/generated/playground-system-prompt.txt index 382a5ecd8..d873ba87f 100644 --- a/docs/generated/playground-system-prompt.txt +++ b/docs/generated/playground-system-prompt.txt @@ -75,7 +75,7 @@ TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) SelectItem(value: string, label: string) — Option for Select DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) -Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) diff --git a/docs/lib/source.ts b/docs/lib/source.ts index f96c83c2d..bef5cc7e3 100644 --- a/docs/lib/source.ts +++ b/docs/lib/source.ts @@ -1,6 +1,7 @@ import { type InferPageType, loader } from "fumadocs-core/source"; import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons"; -import { docs } from "fumadocs-mdx:collections/server"; +import { toFumadocsSource } from "fumadocs-mdx/runtime/server"; +import { blogPosts, docs } from "fumadocs-mdx:collections/server"; export const BASE_URL = "https://www.openui.com"; @@ -11,6 +12,11 @@ export const source = loader({ plugins: [lucideIconsPlugin()], }); +export const blog = loader({ + baseUrl: "/blog", + source: toFumadocsSource(blogPosts, []), +}); + export function getPageImage(page: InferPageType) { const segments = [...page.slugs, "image.webp"]; diff --git a/docs/source.config.ts b/docs/source.config.ts index c026ff074..6f5903f2b 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -1,6 +1,7 @@ import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; -import { defineConfig, defineDocs } from "fumadocs-mdx/config"; +import { defineCollections, defineConfig, defineDocs } from "fumadocs-mdx/config"; import lastModified from "fumadocs-mdx/plugins/last-modified"; +import { z } from "zod"; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections @@ -17,6 +18,15 @@ export const docs = defineDocs({ }, }); +export const blogPosts = defineCollections({ + type: "doc", + dir: "content/blog", + schema: pageSchema.extend({ + author: z.string(), + date: z.string().date().or(z.date()), + }), +}); + export default defineConfig({ plugins: [lastModified()], }); From d68c8a4d6ba0b65dafc0191e8ee679af618e5e61 Mon Sep 17 00:00:00 2001 From: Rabi Shanker Guha Date: Fri, 13 Mar 2026 19:11:50 +0530 Subject: [PATCH 2/4] Fix minor content incosistencies --- docs/content/blog/rust-wasm-parser.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/blog/rust-wasm-parser.mdx b/docs/content/blog/rust-wasm-parser.mdx index 2d27ce22d..357b57370 100644 --- a/docs/content/blog/rust-wasm-parser.mdx +++ b/docs/content/blog/rust-wasm-parser.mdx @@ -1,6 +1,6 @@ --- title: Rewriting our Rust WASM Parser in TypeScript -description: We rewrote Our Rust WASM Parser in TypeScript - and it got 3x Faster +description: We rewrote our Rust WASM Parser in TypeScript - and it got 3x Faster author: Thesys Engineering Team date: 2026-03-13 --- @@ -16,7 +16,7 @@ Turns out we were optimising the wrong thing. The openui-lang parser converts a custom DSL emitted by an LLM into a React component tree. It runs on every streaming chunk — so latency matters a lot. The pipeline has six stages: -```mermaid +``` autocloser → lexer → splitter → parser → resolver → mapper → ParseResult ``` @@ -155,7 +155,7 @@ The one-shot table shows 13.4µs for `contact-form`; the streaming table shows 3 | TypeScript (naïve re-parse) | 9-19µs | 69-840µs | No boundary, but O(N²) streaming | | TypeScript (incremental) | 9-19µs | 69-255µs | No boundary + O(N) streaming | -**End result: 3-4.6x faster per call and 2.6-3.3x lower total streaming cost.** +**End result: 2.2-4.6x faster per call and 2.6-3.3x lower total streaming cost.** ## When WASM Actually Helps From c98eaf92f2239f6d56d2809eeeb491319d6f544e Mon Sep 17 00:00:00 2001 From: Rabi Shanker Guha Date: Fri, 13 Mar 2026 19:14:35 +0530 Subject: [PATCH 3/4] Add blog to sitemap.xml --- docs/app/sitemap.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/app/sitemap.ts b/docs/app/sitemap.ts index f27018ae8..8ab517728 100644 --- a/docs/app/sitemap.ts +++ b/docs/app/sitemap.ts @@ -1,6 +1,6 @@ -import { BASE_URL, source } from "@/lib/source"; +import { BASE_URL, blog, source } from "@/lib/source"; -const STATIC_PATHS = ["/", "/playground"]; +const STATIC_PATHS = ["/", "/playground", "/blog"]; export default async function sitemap() { const staticRoutes = STATIC_PATHS.map((path) => ({ @@ -15,5 +15,11 @@ export default async function sitemap() { changeFrequency: "weekly" as const, })); - return [...staticRoutes, ...docsRoutes]; + const blogRoutes = blog.getPages().map((page) => ({ + url: `${BASE_URL}${page.url}`, + lastModified: new Date(), + changeFrequency: "weekly" as const, + })); + + return [...staticRoutes, ...docsRoutes, ...blogRoutes]; } From 3b0a72c456a1120ee852369205f9920b7d74e8de Mon Sep 17 00:00:00 2001 From: Rabi Shanker Guha Date: Fri, 13 Mar 2026 19:38:55 +0530 Subject: [PATCH 4/4] Convert Blog navbar to Tailwind --- .../app/blog/components/BlogNavbar.module.css | 195 ------------------ docs/app/blog/components/BlogNavbar.tsx | 48 +++-- 2 files changed, 28 insertions(+), 215 deletions(-) delete mode 100644 docs/app/blog/components/BlogNavbar.module.css diff --git a/docs/app/blog/components/BlogNavbar.module.css b/docs/app/blog/components/BlogNavbar.module.css deleted file mode 100644 index 72746f857..000000000 --- a/docs/app/blog/components/BlogNavbar.module.css +++ /dev/null @@ -1,195 +0,0 @@ -.nav { - position: sticky; - top: 0; - z-index: 50; - width: 100%; - padding: 0.75rem 2rem; - background: var(--blog-nav-bg); - transition: border-color 0.2s ease; -} - -.navInner { - display: flex; - max-width: 75rem; - margin-inline: auto; - align-items: center; - justify-content: space-between; -} - -.logoCluster { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.logoDivider { - height: 1rem; - width: 1px; - background: var(--blog-nav-border); -} - -.desktopTabs, -.desktopGithub { - display: none; -} - -.desktopGithub { - align-items: center; - gap: 0.5rem; -} - -.desktopTabs { - align-items: center; - gap: 0.5rem; -} - -.desktopTabLink { - display: flex; - height: 2rem; - align-items: center; - padding-inline: 0.5rem; - border-radius: 0.375rem; - color: var(--blog-nav-text); - text-decoration: none; - transition: background-color 0.2s ease; - font-family: "Inter Display", sans-serif; - font-size: 15px; - line-height: 1.5rem; -} - -.desktopTabLink:hover, -.mobileTrayLink:hover, -.mobileMenuButton:hover { - background: var(--blog-nav-hover); -} - -.chevronIcon, -.hamburgerIcon { - height: 1.25rem; - width: 1.25rem; -} - -.mobileMenuButton { - display: flex; - height: 2.5rem; - width: 2.5rem; - align-items: center; - justify-content: center; - border: 0; - border-radius: 0.5rem; - background: transparent; - color: var(--blog-nav-text); - cursor: pointer; - transition: background-color 0.2s ease; -} - -.mobileBackdrop { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 40; - height: 100vh; - cursor: pointer; - background: rgb(0 0 0 / 60%); - backdrop-filter: blur(12px); -} - -.mobileTrayWrap { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 50; - pointer-events: none; -} - -.mobileTray { - pointer-events: auto; - border-top: 1px solid var(--blog-nav-border); - border-bottom-left-radius: 18px; - border-bottom-right-radius: 18px; - background: var(--blog-nav-bg); - box-shadow: 0 10px 20px rgb(0 0 0 / 10%); -} - -.mobileTrayInner { - display: flex; - max-width: 75rem; - margin-inline: auto; - flex-direction: column; - gap: 0; - padding: 0.75rem 1.75rem 1.25rem; -} - -.mobileTrayDivider { - height: 1px; - margin-inline: 0.75rem; - background: var(--blog-nav-border); -} - -.mobileTrayLink { - display: flex; - width: 100%; - height: 3.5rem; - align-items: center; - gap: 0.25rem; - padding-inline: 0.75rem; - border-radius: 0.375rem; - color: var(--blog-nav-text); - text-align: left; - text-decoration: none; - transition: background-color 0.2s ease; - font-family: "Inter", sans-serif; - font-size: 18px; - line-height: 1.5rem; -} - -.mobileGithubButtonWrap { - display: flex; - justify-content: center; - padding-top: 80px; - pointer-events: auto; -} - -.mobileGithubButton { - position: relative; - display: flex; - height: 38px; - align-items: center; - gap: 0.375rem; - padding-left: 0.75rem; - padding-right: 0.5rem; - border: 0; - border-radius: 999px; - background: var(--blog-nav-bg); - cursor: pointer; - transform: scale(1.17); -} - -.mobileGithubButton:hover .mobileGithubButtonOverlay { - box-shadow: none; -} - -.mobileGithubButtonOverlay { - position: absolute; - inset: 0; - pointer-events: none; - border: 1px solid var(--blog-nav-border); - border-radius: 999px; - box-shadow: var(--mobile-github-button-shadow, none); - transition: box-shadow 0.2s ease; -} - -@media (min-width: 1024px) { - .desktopTabs, - .desktopGithub { - display: flex; - } - - .mobileMenuButton, - .mobileBackdrop, - .mobileTrayWrap { - display: none; - } -} diff --git a/docs/app/blog/components/BlogNavbar.tsx b/docs/app/blog/components/BlogNavbar.tsx index 900e72d7f..f4823b52e 100644 --- a/docs/app/blog/components/BlogNavbar.tsx +++ b/docs/app/blog/components/BlogNavbar.tsx @@ -7,12 +7,10 @@ import { ThesysLogo, useGitHubStarCount, } from "@/components/brand-logo"; -// GitHubIcon & StarCountBadge used in mobile menu import { ThemeToggle } from "@/components/theme-toggle"; import { AnimatePresence, motion } from "motion/react"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import styles from "./BlogNavbar.module.css"; const BUTTON_SHADOW = "0px 1px 3px 0px rgba(22,34,51,0.08), 0px 12px 24px 0px rgba(22,34,51,0.04)"; @@ -26,9 +24,13 @@ const TAB_URLS: Record = { function DesktopNavTabs() { return ( -
+
{NAV_TABS.map((tab) => ( - + {tab} ))} @@ -39,7 +41,7 @@ function DesktopNavTabs() { function HamburgerIcon({ isOpen }: { isOpen: boolean }) { return ( return ( <> onClick={onClose} /> -