A framework-agnostic web component that renders LLM-generated UI from
Streaming UI Script — a compact, declarative language designed for chat
assistants. Drop one <script> tag and one <streaming-ui-script> tag into
any HTML page — React, Vue, Angular, Svelte, plain HTML, or no framework at
all — and you have a streaming, interactive renderer for an LLM's response.
The library bundles everything needed at runtime:
@Count, @Filter, @Sort, @Push, @Concat, @Each, @Sum, @Avg, @Min, @Max, @Round, @Floor, @Ceil, @Abs, …) plus array shortcuts (.length, .first, .last, and field pluck like $rows.title).<details>.open, and stateful primitives like Tabs are all preserved across renders. Components that need to hold UI state get a helpers.useInstanceState(...) slot keyed by their position in the tree.Slider, NumberInput, DatePicker, FileUpload, Combobox), tables, charts, feedback & media (Avatar, Progress, Tooltip, HoverCard, Popover, Toast, Toasts, Rating, ProgressRing, ChatBubble, …), navigation (Breadcrumb, Pagination, Navbar, NavbarItem), menus (DropdownMenu, MenuItem, MenuSeparator, MenuLabel), hierarchical data (Tree, TreeNode), chat composites, high-level pattern composites (Hero, Cover, PageHeader, MetricGrid, Toolbar, EmptyState, Timeline, KanbanBoard, Testimonial, PricingTable, MediaCard, …) and app-shell composites (AppShell, Sidebar, SplitView) that render a full SaaS layout in one statement.Script(...) (lifecycle-managed, useEffect-style) and @Js(body, args?) (one-shot click handlers with per-item arg capture). Always available.Routes(...), Route(path, content), NavLink(label, to), @Navigate("/path"), and reactive $route + params. Hash-based, framework-agnostic, always on.light, dark, neon, pastel, glass, brutalist, skyline) plus full custom-token support via CSS custom properties.system_prompt.txt (full — every feature) and system_prompt_chat.txt (compact — only the surfaces a chat reply needs).Everything lives inside a Shadow DOM, so the renderer's styles never leak into your application — and your application's styles never leak into the renderer.
LLMs are great at writing structured text, and a small DSL lets them describe a full UI in 60–70% fewer tokens than JSON. This project ships that idea as a single web component, so any framework — or no framework at all — can render generative UI without extra wiring.
<script type="module" src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js"></script>
For non-module setups use the IIFE build:
<script src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.iife.js" defer></script>
The CSS is bundled inside the JS and injected into each instance's shadow root, so you do not need a separate stylesheet.
<streaming-ui-script id="reply" theme="light"></streaming-ui-script>
There are three equivalent ways to set the program text:
<!-- as an attribute -->
<streaming-ui-script response='root = Card([CardHeader("Hi")])'></streaming-ui-script>
<!-- as inner text -->
<streaming-ui-script>
root = Card([CardHeader("Hi")])
</streaming-ui-script>
<!-- as a property / method -->
<script>
const el = document.querySelector("streaming-ui-script");
el.setResponse(`root = Stack([greeting])
greeting = Card([CardHeader("Hello", "Generative UI in plain HTML")])`);
</script>
const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ system: systemPrompt, messages }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
el.streaming = true;
el.clear();
while (true) {
const { value, done } = await reader.read();
if (done) break;
el.appendChunk(decoder.decode(value, { stream: true }));
}
el.streaming = false;
Either fetch the auto-generated system_prompt.txt from the CDN:
const systemPrompt = await fetch(
"https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txt",
).then((r) => r.text());
…or build a richer prompt programmatically (with custom rules, tool descriptions, examples, etc.):
const prompt = el.getSystemPrompt({
preamble: "You are an analytics assistant.",
additionalRules: ["Always end with a FollowUpBlock of 2 prompts."],
tools: [{ name: "list_orders", description: "Return recent orders.", argsExample: { limit: 10 } }],
});
el.setTools({
list_orders: async ({ limit }) => fetch(`/api/orders?limit=${limit}`).then(r => r.json()),
update_order: async ({ id, status }) => fetch(`/api/orders/${id}`, { method: "PATCH", body: JSON.stringify({ status }) }).then(r => r.json()),
});
el.addEventListener("assistant-message", (event) => {
appendUserMessageToChat(event.detail.message);
});
All members live on the <streaming-ui-script> element.
| Attribute | Values | Description |
|---|---|---|
theme |
light, dark, or a JSON object literal |
Switches the theme. JSON objects are merged with the default light token map. |
streaming |
true / unset |
Hint that text is still being appended. Useful for status indicators in your app. |
response |
Streaming UI Script text | Sets the program declaratively. Re-renders whenever the attribute changes. |
showerrors |
true / unset |
If present and true, displays parse errors in the rendered UI. Defaults to off. |
Script(...) / @Js(...) and hash-based routing (Routes, Route, NavLink, @Navigate) are always on. To skip them in the generated prompt, build the chat-flavoured prompt via getSystemPrompt({ mode: "chat" }).
| Property | Type | Description |
|---|---|---|
response |
string |
Equivalent to setResponse. |
tools |
Record<string, Function> |
Getter returns the currently-registered tools; setter is equivalent to setTools(...). |
streaming |
boolean |
Reflects the streaming attribute. |
showErrors |
boolean |
Reflects the showerrors attribute. |
route |
string (read-only) |
Current path tracked by the router (e.g. "/users/42"). |
| Method | Description |
|---|---|
setResponse(text) |
Replace the program (one-shot rendering). Resets state and queries. |
appendChunk(chunk) |
Append a streaming chunk and re-render. |
clear() |
Reset state, queries, and the rendered output. |
setTheme(name | tokens) |
Apply a built-in theme by name or a partial token map. |
setTools(tools) |
Register tools used by Query() and Mutation(). |
registerComponents(specs, root?) |
Extend the built-in library with your own components. |
getSystemPrompt(options?) |
Build a system prompt that matches the current library and tools. |
navigate(path) |
Programmatically navigate when routing is enabled (updates window.location.hash). |
| Event | Detail | When it fires |
|---|---|---|
assistant-message |
{ message: string } |
When @ToAssistant("...") runs (e.g. a follow-up button). |
error |
{ errors: ParseError[] } |
After each render whose source had parse errors. |
route-change |
{ path, previousPath, params, pattern } |
When the current hash path changes. |
The error event always fires regardless of showerrors, so host apps can
log or report errors even when the in-page banner is suppressed.
Seven themes are built in. Pick one with theme="..." or pass a custom token map.
| Theme | Vibe |
|---|---|
light |
Crisp default, indigo accent. |
dark |
Standard dark surface, indigo accent. |
neon |
Cyberpunk-inspired dark mode with magenta/cyan glow, monospace headings, sharp corners. |
pastel |
Soft, friendly, light & rounded. Lavender + mint palette, generous radii, gentle shadows. |
glass |
Modern glassmorphism — vivid gradient backdrop, frosted translucent surfaces, indigo→cyan accent. |
brutalist |
Neo-brutalism — hard 2px black borders, chunky offset shadows, loud primary, zero gradients. |
skyline |
Enterprise cloud-console aesthetic — deep navy primary, cyan accents, calm pale blue bg. |
Custom token maps:
el.setTheme({
colorPrimary: "#16a34a",
colorPrimaryHover: "#15803d",
colorBg: "#f0fdf4",
radiusMd: "14px",
});
You can also style the host element from outside:
streaming-ui-script {
--rui-color-primary: #16a34a;
--rui-radius-md: 14px;
}
A full list of tokens lives in docs/themes.html and src/theme/index.ts.
The runtime auto-loads Font Awesome 6.7.2
from the public CDN — once into document.head and once into each instance's
shadow root. Host apps do not need to add a stylesheet.
fa- prefix:
"house", "chart-line", "star", "cart-shopping", "circle-check",
"triangle-exclamation", "sack-dollar"."regular:star", "brands:github". The default
variant is solid.Icon(name, variant?, size?) component to render a
standalone glyph (size ∈ xs, sm, md, lg, xl).icon — NavLink, SidebarItem, Banner,
Notification, FeatureItem, Tag, StatCard, ListItem,
TimelineItem, DescriptionItem, Tile, EmptyState, … — now expects a
Font Awesome name (or a variant:name string).ProgressRing(value, max?, label?, …) renders label as an icon when it
resolves to a Font Awesome name (e.g. "circle-check"), and as plain text
otherwise — perfect for completion rings."triangle-exclamation\uFE0F" (a legacy emoji leftover) still
resolves to the proper icon instead of falling back to inline text.brandIcon = Icon("rocket", "solid", "lg")
homeIcon = Icon("house")
profileTab = NavLink("Profile", "/profile", "ghost", false, "user")
kpis = MetricGrid([
StatCard("Revenue", "$48k", "up", "+12%", "sack-dollar"),
StatCard("Orders", "1,284","up", "+8%", "cart-shopping"),
StatCard("Refunds", "12", "down", "-3", "rotate-left")
])
The CDN URL and a tiny ensureFontAwesomeLoaded(shadow) helper live in
src/icons/index.ts and are re-exported as
FONT_AWESOME_CDN_URL. The custom element calls the helper from its
connectedCallback (idempotent — safe to mount multiple instances).
$days = "7"
data = Query("get_metrics", {days: $days}, {events: 0, daily: []})
filter = FormControl("Range", Select("days", [SelectItem("7","7d"), SelectItem("30","30d")], null, null, $days))
kpi = StatCard("Events", "" + data.events, "up")
chart = LineChart(data.daily.day, [Series("Events", data.daily.events)])
root = Stack([CardHeader("Analytics"), filter, kpi, chart])
Highlights:
name = Expression.$variables are reactive — passing one to an Input or Select two-way-binds."double", 'single', and `backtick` (multi-line, no escaping required — perfect for JS bodies).// line, # line (shell-style), and /* block */.Query("tool", {args}, {defaults}, refreshSec?) runs immediately and re-runs when its $variable args change.Mutation("tool", {...}) only runs from @Run(name) inside an Action([...]).@Each(arr, "row", template) iterates inline. The loop variable is scoped strictly to template.@Filter, @Sort, @Count, @Sum, @Avg, @Round, @Push, @Concat and more are available.$rows.length, $rows.first, $rows.last, plus pluck ($rows.title → [title1, title2, …]).root = Stack([...]) first and let the children stream in beneath it.$todos = [{id: 1, text: "Welcome — try editing", done: false}]
$draft = ""
addBtn = Button("Add", Action([
@Set($todos, @Push($todos, {id: $todos.length + 1, text: $draft, done: false})),
@Reset($draft)
]), "primary")
row = Card([Stack([
TextContent(t.text),
Button("Delete", Action([@Set($todos, @Filter($todos, "id", "!=", t.id))]), "ghost")
])])
list = @Each($todos, "t", row)
root = Stack([Input("draft-input", "What needs doing?", "text", null, $draft), addBtn, list])
The full reference is on the docs site (docs/language.html).
Script(...) and @Js(...) ship with every renderer — no attribute to flip.
The LLM can author two surfaces whenever the full prompt is in use:
Script("id", body, deps?) — behaviour-only node that runs on mount and
re-runs whenever any listed $variable changes. Lifecycle matches
useEffect: cleanup before re-run, disposal on unmount, AbortSignal exposed
as ctx.signal.@Js(body, args?) — action step you drop inside Action([...]). The
optional args object is evaluated at render time and exposed inside the
body as ctx.args. This is the canonical way to feed per-row data from an
@Each loop into a click handler.<streaming-ui-script></streaming-ui-script>
list = @Each($todos, "t", row)
row = Card([Stack([
TextContent(t.text),
Button("Toggle", Action([
@Js(`
const todos = ctx.state.get('todos') || [];
ctx.state.set('todos', todos.map(x => x.id === ctx.args.id ? Object.assign({}, x, {done: !x.done}) : x));
`, {id: t.id})
]))
])])
body is a regular string. Use double quotes for one-liners (escape inner
" as \" and newlines as \n) or backticks for multi-line code (real
newlines and unescaped " are fine). The default (full) system prompt
teaches the LLM about these features; for chat-style replies where you want
to keep the LLM purely declarative, build the prompt via
getSystemPrompt({ mode: "chat" }) — the chat flavour omits the JS section.
See the JavaScript interactions guide
or the deeper coding-gen-skill.md for a full
end-to-end app walkthrough.
Hash-based routing is built into the runtime. The LLM may emit routes that
stay in sync with the URL (#/dashboard, #/users/42). Browser
back/forward, bookmarks, and deep links all work — and the host page never
reloads.
<streaming-ui-script></streaming-ui-script>
root = Stack([nav, main])
nav = Stack([
NavLink("Home", "/", "ghost", true),
NavLink("Dashboard","/dashboard", "ghost"),
NavLink("Users", "/users", "ghost")
], "row", "s")
main = Routes([
Route("/", homePage),
Route("/dashboard", dashboardPage),
Route("/users/:id", userPage),
Route("*", notFoundPage)
], "/")
homePage = Card([CardHeader("Welcome")])
dashboardPage = Card([CardHeader("Dashboard")])
userPage = Card([CardHeader("User " + params.id)])
notFoundPage = Callout("warning", "Not found", "We couldn't find " + $route + ".")
Routes(items, default?) picks the matching Route based on the current
hash path. First match wins; default is the fallback when nothing else
matches.Route(path, content) supports literal segments ("/about"), parameter
segments ("/users/:id" → params.id), and trailing wildcards
("/docs/*" → params._).NavLink(label, to, variant?, exact?, icon?) is a router-aware anchor
that intercepts clicks and reflects data-active="true" for the current
path.@Navigate("/path") is an action step you can drop into any
Action([...]) for programmatic navigation. From JS, call
el.navigate("/path").$route. Inside a matched
route's content, the captured params land in the params loop variable.The default (full) system prompt teaches the LLM about routing. To skip the
routing section (e.g. for chat-style replies where you don't want the model
inventing URLs), build the prompt via getSystemPrompt({ mode: "chat" }).
See the routing guide and the live routing demo for a full end-to-end walkthrough.
| Group | Components |
|---|---|
| Layout | Stack, Grid, Section, Container, Spacer, Card, CardHeader, CardBody, CardFooter, Divider, Separator, Tabs, TabItem, Accordion, AccordionItem, Modal, Sheet, Steps, StepsItem, AspectRatio, ScrollArea |
| Content | TextContent, Header, Image, Icon, Link, Badge, Tag, TagBlock, Alert, Callout, Note, Quote, CodeBlock, Skeleton, Markdown, Kbd |
| Forms | Form, FormControl, Input, TextArea, Select, SelectItem, Checkbox, CheckBoxGroup, CheckBoxItem, Radio, Switch, Toggle, ToggleGroup, Button, Buttons, SearchBar, Slider, NumberInput, DatePicker, FileUpload, Combobox |
| Data | Table, Col, List, ListItem, StatCard, Stats, Tile, Progress, ProgressRing, Pagination, Tree, TreeNode |
| Charts | BarChart, LineChart, PieChart, Series |
| Feedback & Media | Avatar, AvatarGroup, PersonChip, Tooltip, HoverCard, Popover, Rating, Toast, Toasts |
| Navigation | Breadcrumb, BreadcrumbItem, Navbar, NavbarItem |
| Menus | DropdownMenu, MenuItem, MenuSeparator, MenuLabel |
| Chat | SectionBlock, ListBlock, FollowUpBlock, FollowUpItem, ActionLink, ChatBubble |
| Patterns | Hero, Cover, PageHeader, SectionHeader, MetricGrid, Toolbar, EmptyState, Timeline, TimelineItem, FeatureGrid, FeatureItem, MediaCard, Testimonial, ProfileCard, Comment, Banner, Notification, KanbanBoard, KanbanColumn, KanbanCard, DescriptionList, DescriptionItem, StatusDot, PricingTable, PricingCard |
| App shell | AppShell, Sidebar, SidebarSection, SidebarItem, SplitView |
| Scripting | Script (always available; chat prompt mode omits it) |
| Routing | Routes, Route, NavLink (always available; chat prompt mode omits them) |
The Patterns group is the secret sauce for getting beautiful, dense UI out
of an LLM with minimal tokens. Each composite packs an entire idiom (hero,
page header, KPI strip, empty state, timeline, kanban, …) into one positional
call. Reach for these before composing the equivalent layout from
Card + Stack:
root = Stack([dashHeader, kpis, board, follow])
dashHeader = PageHeader("Engineering Q3", "12 active · 4 at risk", ["Workspace", "Engineering"], dashActions, Badge("On track", "success"))
dashActions = [Button("Export", Action([@Run(export_q3)]), "secondary"), Button("New project", Action([@Run(new_project)]), "primary")]
kpis = MetricGrid([StatCard("Active", "12", "flat"), StatCard("At risk", "4", "up", "+2"), StatCard("Shipped", "8", "up", "+3"), StatCard("On-time", "87%", "down", "-3%")])
board = KanbanBoard([
KanbanColumn("To do", [KanbanCard("Migrate auth", "Roll out new SDK.", ["auth"], "Asha")]),
KanbanColumn("Doing", [KanbanCard("Streaming UI v2", "20 new components.", ["frontend"], "Alex", "primary")]),
KanbanColumn("Review", [KanbanCard("Mobile onboarding", "Awaiting design.", ["mobile"], "Wren", "warning")]),
KanbanColumn("Done", [KanbanCard("Activity timeline", "Shipped to 100%.", ["shipped"], "Mira", "success")])
])
follow = FollowUpBlock(["Show at-risk projects", "Compare to Q2", "Who needs help?"])
The generated system prompt teaches the LLM about every component, and the
## Design principles + ## Composition recipes sections steer the model
toward dashboard / landing / detail-page layouts that look like a shadcn site
out of the box.
Add your own with registerComponents:
const ProductCard = {
name: "ProductCard",
description: "Product tile with title and price.",
props: [
{ name: "title", type: "string" },
{ name: "price", type: "number" },
],
render: (_node, props) => {
const div = document.createElement("div");
div.textContent = `${props.title} — $${props.price}`;
return div;
},
};
el.registerComponents([ProductCard]);
The next call to getSystemPrompt() automatically includes the new component.
If you're driving the renderer from an agentic LLM (Cursor, Claude Code, etc.) one companion document is kept in sync with the bundle:
coding-gen-skill.md — an extensive knowledge
base for building complete applications in Streaming UI Script: mental
model, every component group, state management, queries/mutations, actions,
loops, JavaScript interactions, routing, app patterns (todo list,
dashboard, wizard, chat, settings, real-time, status page, checkout flow,
file manager, calendar, docs portal), and anti-patterns. Treat it as the
"deep dive" the model can read once and then author full apps unaided. It
also opens with a short "When to reach for this library" section so
agents can decide quickly whether <streaming-ui-script> is the right
tool for the job.The docs/ folder is the source for the live documentation site published at
https://asfand-dev.github.io/streaming-ui-script/. Every page is a static
HTML file that loads the same bundle the rest of the world consumes from the
CDN.
| Page | What's on it |
|---|---|
index.html |
Overview, drop-in install, live theme picker. |
get-started.html |
Step-by-step integration walkthrough. |
frameworks.html |
Integration recipes for React, Next.js, Vue, Angular, Svelte, plain HTML. |
language.html |
Full Streaming UI Script language reference. |
components.html |
Every built-in component with a live preview, positional signatures, prop tables, and enum values. |
javascript-interactions.html |
Script(...) + @Js(...) guide — always available at runtime. |
routing.html |
Hash-based routing guide — always available at runtime. |
themes.html |
Built-in themes gallery, live picker, side-by-side compare, and the token customization studio (formerly theme-customization.html, now redirected). |
examples.html |
Curated showcase of real-world block UIs (auth, products, FAQ, cart, todos, …). |
playground.html |
CodeMirror 6 editor with custom Streaming UI Script highlighting + autocomplete, live preview, share links, persistent layout modes (drag the splitter, collapse the docs sidebar), hover-over component info, signature/argument tooltips with allowed enum values, and an inspection mode that maps rendered DOM back to the source. Powered by src/language/. |
live-examples.html |
Catalog page linking out to every standalone demo below. |
Every standalone demo is a single HTML page that you can open directly. They
double as integration recipes — view source on any of them to see how a real
host page wires setResponse, appendChunk, setTools, and setTheme.
| Demo page | Highlights |
|---|---|
chat-bot.html |
OpenAI-powered chat that streams replies into the renderer. Toggle between the chat-flavoured and full system prompt live. |
tools-example.html |
Read / write / poll patterns wired to in-page setTools() handlers. |
external-data-example.html |
Live GitHub repository explorer powered by a single tool function. |
support-agent.html |
AI triage workspace: pick a ticket, the agent suggests priority + draft reply. |
analytics-assistant.html |
Natural-language → charts/KPIs/breakdown. |
javascript-todo-app.html |
Reactive todo list with filters + localStorage persistence via one Script(...). |
javascript-pomodoro.html |
Pomodoro timer with phases, audio chime, and notifications. |
javascript-stopwatch.html |
Sub-second stopwatch + laps using requestAnimationFrame and proper teardown. |
routing-demo.html |
A four-page app driven by Routes + NavLink + @Navigate, deep links, browser back/forward. |
app-workspace.html |
Full SaaS workspace: sticky Sidebar, topbar, MetricGrid, timeline, DescriptionList. |
project-dashboard.html |
Engineering program dashboard: banner, PageHeader, KPIs, kanban board, activity timeline. |
marketing-landing.html |
Hero, feature grid, pricing tiers, testimonials, team line-up, FAQ. |
team-directory.html |
Profile cards, avatar groups, search-with-pagination, empty state, slide-in Sheet. |
settings-app.html |
Tabs, Switch, ToggleGroup, Progress, Kbd, danger-zone confirmation Sheet. |
ecommerce-product.html |
Product page with Cover hero, MediaCard related items, Rating, ProgressRing, Stats, reviews. |
inbox-app.html |
Focused mail/chat inbox using SplitView, SearchBar, PersonChip, Notification, ChatBubble. |
pricing-page.html |
Pricing surface: Cover, Stats trust strip, PricingTable, FeatureGrid, Quote, FAQ, CTA. |
crm-contacts.html |
Directory: SearchBar, segmented filters, Tile quick stats, paginated cards, detail Sheet. |
status-page.html |
Public SRE status: incident Banner, latency LineChart, services with StatusDot, incident Timeline. |
checkout-flow.html |
Four-step checkout wizard: Steps, SplitView cart, promo codes, address + payment + review + confirmation. |
file-manager.html |
Cloud file browser: Tree sidebar, Toolbar, files Table, preview Sheet, storage ProgressRing. |
calendar-app.html |
Calendar & scheduler: DatePicker, category chips, busy-hours ring, agenda Timeline, event detail Sheet. |
docs-portal.html |
Help center / knowledge base: SearchBar, Tree categories, Markdown article, Rating, FAQ Accordion. |
The full catalog with tag filters lives at
docs/live-examples.html.
.
├── src/ # Library source
│ ├── parser/ # Lexer, parser, AST types
│ ├── runtime/ # Evaluator, reactive state, queries, actions,
│ │ ├── builtins.ts # builtin @-functions and action-step markers
│ │ ├── evaluator.ts # program planner + binding resolver
│ │ ├── state.ts # reactive store (subscribers, two-way binds)
│ │ ├── queries.ts # Query / Mutation registry + auto-refresh
│ │ ├── actions.ts # ActionRunner (@Run, @Set, @Reset, …)
│ │ ├── scripts.ts # ScriptRunner — useEffect-style Script(...)
│ │ └── router.ts # Hash-based router for Routes / NavLink
│ ├── library/ # Component specs and registry
│ │ └── components/ # layout / content / forms / data / charts /
│ │ # chat / feedback / navigation / menu /
│ │ # patterns / scripts / router
│ ├── renderer/ # Tree → DOM
│ │ ├── renderer.ts # walks the tree, calls component renderers,
│ │ │ # tracks per-instance state by tree path
│ │ └── morph.ts # React-like DOM reconciler — keeps focus,
│ │ # selection, scroll, and <details>.open
│ ├── theme/ # Token system + injected stylesheet
│ │ ├── index.ts # Seven built-in themes + custom token merge
│ │ └── styles.ts # Shadow-DOM stylesheet (theme-aware)
│ ├── prompt/ # System prompt generator
│ ├── language/ # Reusable language-support module
│ │ ├── grammar.ts # Pure-data tokens + CodeMirror StreamParser
│ │ ├── components.ts # Component catalog (derived from library)
│ │ ├── builtins.ts # @-builtin catalog (sourced from runtime)
│ │ ├── snippets.ts # Ready-to-insert templates for composites
│ │ └── index.ts # `getLanguageSpec()` barrel for editors
│ ├── element.ts # The custom element
│ └── index.ts # Public entry point
├── docs/ # Static documentation site (HTML + CSS + JS)
├── _docs/ # Internal design notes and inspirations (not shipped)
├── scripts/
│ ├── emit-prompt.mjs # Writes dist/system_prompt*.txt from the bundle
│ └── build-docs.mjs # Assembles ./site/ from docs/ + dist/
├── tests/ # Vitest unit + element regression tests
├── dist/ # Built artifacts (created by `npm run build`)
├── site/ # Deployable static docs (created by `npm run build:docs`)
├── .github/workflows/ # GitHub Pages deploy pipeline
├── README.md # This file
└── coding-gen-skill.md # Deep knowledge base for building full apps
Requirements: Node ≥ 18 and npm ≥ 9 (or pnpm/yarn — examples use npm).
git clone https://github.com/asfand-dev/streaming-ui-script.git
cd streaming-ui-script
npm install
npm run build
Produces:
dist/streaming-ui-script.js # ESM bundle
dist/streaming-ui-script.umd.cjs # UMD bundle for older bundlers
dist/streaming-ui-script.iife.js # IIFE for non-module <script> tags
dist/system_prompt.txt # Full prompt — every component, JS, routing
dist/system_prompt_chat.txt # Compact chat-focused prompt (lighter, OpenUI-Lang style)
The two prompt variants exist so host apps can pick the right flavour up
front: system_prompt.txt (or getSystemPrompt() with no options) for rich
generative UI surfaces that need JS and routing, and system_prompt_chat.txt
(or getSystemPrompt({ mode: "chat" })) for chat assistants whose replies
should stay purely declarative. Both are kept in lock-step with the library by
the build script.
npm test
Includes:
tests/parser.test.ts).tests/runtime.test.ts).Script(...) lifecycle + @Js(...) (tests/javascript-integration.test.ts).Routes / Route / NavLink (tests/router.test.ts).tests/theme.test.ts).tests/library.test.ts).tests/element.test.ts).tests/prompt.test.ts).tests/demos.test.ts).tests/language.test.ts).npm run build:docs
Assembles ./site/ from ./docs/ + ./dist/. Serve it with anything static:
npx http-server site -p 4321
# or
npx serve site
Then open http://localhost:4321/index.html.
The library treats every LLM-supplied attribute as untrusted and runs it through a small set of sanitisers before it lands on the DOM:
| Sink | Helper | Effect |
|---|---|---|
Anchor href (Link, BreadcrumbItem, NavbarItem, Markdown links) |
sanitiseHref |
Allow-lists http(s):, mailto:, tel:, fragments, root-relative paths. Rejects javascript:, vbscript:, data:text/html, control-char bypasses (java\tscript:), protocol-relative //host/.... Unsafe URLs collapse to #. |
Image src (Image, Avatar, MediaCard, Hero, Testimonial, ChatBubble) |
sanitiseImageSrc |
Allow-lists http(s):, data:image/*, blob:, plus relative paths. Anything else falls back to an empty string so callers render a placeholder. |
Inline style lengths (Container.maxWidth, Skeleton.height, …) |
sanitiseCssLength |
Restricts the alphabet so semicolons / quotes cannot inject extra declarations. |
background-image: url(...) (Cover.imageSrc) |
sanitiseCssUrl |
Drops characters that would close the url() literal. |
@OpenUrl("...") action step |
sanitiseHref (runtime) |
The action runner sanitises the URL before calling window.open (or any consumer onOpenUrl override). External windows are opened with noopener,noreferrer. |
External links rendered by Link, NavbarItem, and the Markdown renderer
get rel="noopener noreferrer" so the destination cannot read the opener's
document.referrer.
If you embed <streaming-ui-script> behind a CSP, the bundle does not use
eval. Script(...) bodies are evaluated with new (Async)Function(...)
which requires 'unsafe-eval' if you want scripting to work; omit the
attribute (and @Js action steps) if you cannot relax CSP.
This repository ships its own copy of the bundle on GitHub Pages, so most users do not need to host anything themselves:
<script type="module" src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js"></script>
<streaming-ui-script theme="dark"></streaming-ui-script>
…and a fetch of system_prompt.txt server-side to build LLM messages:
curl https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txt
To ship your own copy, run npm run build and serve the dist/ folder from
any static host — every artifact in dist/ is self-contained.
GitHub Pages deployment for this repo is automated via
.github/workflows/deploy-pages.yml. Push
to main and the workflow will build, test, assemble site/, and publish.
Contributions are very welcome. The fastest path is:
npm install && npm test — make sure the suite is green on main first.feat/inline-charts).tests/. Aim for good edge-case coverage.npm run build to confirm the bundle and the system prompt still build.Issues, design discussions, and bug reports are tracked at https://github.com/asfand-dev/streaming-ui-script/issues.
By contributing you agree that your work will be released under the project's MIT license.
MIT — see LICENSE.