shast - the semantic HTML abstract syntax tree. An AI-first constrained UI format and validated server-side renderer built on closed-world component registries. HTML structure and CSS are typed against each other and checked at compile time and runtime, constraining generated UI to the vocabulary and relationships your registry declares.
shast is not a client-side framework and doesn't compete with React or Vue
for the browser. It is a constrained target format for AI-generated,
server-rendered UI, with a renderer that validates components before HTML
leaves the server. It can replace JSX-as-template, Pug, or string builders
where that constraint is valuable. There is no client runtime, no hydration
story, and nothing to ship to the browser except the rendered html and
css.
Not affiliated with (or node-compatible with) hast from the unified ecosystem - the name is a nod, not an implementation.
For UI generated by an AI model: shast is a target format with
walls. Components are JSON-shaped (which models emit far more reliably than
JSX), and every emission is validated against a registry you define. A
model cannot invent a tag, attribute, design token, or custom property -
tsc rejects it with a pointed message ('colr' is not supported, '<p>' is not a permitted child of <ul>), and a runtime backstop catches anything
that slips past the types (as any, generated code, no tsc in the loop).
Because rendering is server-side, that backstop runs exactly where it
matters: invalid generated components fail on your server, before HTML is
ever sent - not in a user's browser. In an era where the bottleneck is
constraining what models produce, the registry isn't configuration
overhead - it's the guardrail itself.
For humans working in the same format: the secondary benefit is safer refactoring. The bug this kills is not writing CSS - it's refactoring HTML. You rename a wrapper, move a child, delete a node… and somewhere a selector silently stops matching. Nothing fails. The dead CSS just stays there. Name-integrity tools (CSS Modules, vanilla-extract) verify that a class you reference exists; none of them know the shape of your tree. Here, a component's CSS is typed against its own structure: change the structure and every rule that targeted the old structure becomes a compile error at that exact spot.
const { createComponent, renderComponent } = engine({
supportedKeywords: SUPPORTED_KEYWORDS,
htmlAttributesConfig: HTML_GLOBAL_ATTRIBUTES_CONFIG,
htmlTagConfig: HTML_TAGS_CONFIG,
cssSyntaxConfig: CSS_SYNTAX_CONFIG,
cssAttributesConfig: CSS_ATTRIBUTES_CONFIG,
cssPseudoClassConfig: CSS_GLOBAL_PSEUDO_CLASSES_CONFIG,
cssPropertiesConfig: CSS_GLOBAL_PROPERTIES,
});
const card = createComponent({
tag: "div",
innerHTML: {
title: { tag: "h1", innerHTML: "hello" },
},
css: {
width: "100%",
"> title": { color: "inherit" }, // typed as a key of innerHTML
},
});
const { html, css } = renderComponent(card);
// html: <div cid-x1y2z3><h1 cid-title>hello</h1></div>
// css: scoped rules for [cid-x1y2z3] and its > [cid-title]Now rename title to heading and forget the CSS:
innerHTML: { heading: { tag: "h1", innerHTML: "hello" } },
css: { "> title": { ... } }
// ^^^^^^^^^ error: '"> title"' does not exist in type
// '{ readonly "> heading"?: ... }'The stale style is not a visual bug you discover next month. It's a red squiggle right now - and if the types were bypassed, a runtime error instead of silence.
If you trust your pipeline and want to skip the per-component runtime
validation in production, pass skipValidation: true as a second argument to engine():
const { createComponent } = engine(config, { skipValidation: true });createComponent becomes a pass-through — no tag, attribute, or CSS checks.
Useful when tsc/CI has already caught everything and you're CPU-bound.
Components are plain functions, so parameters, conditional classes, and
composition are ordinary TypeScript - and the class wall holds through all
of it. A &.class selector is typed against the classes actually declared
in the element's class attribute, including classes computed at runtime
through template literals:
const card = (num: number) => {
const active = num > 1 ? "active" : "";
return createComponent({
tag: "li",
attributes: { class: `${active} card` },
innerHTML: {
someImage: {
tag: "img",
attributes: { alt: "", src: "" },
},
content: {
tag: "a",
attributes: { href: "" },
innerHTML: {
content: { tag: "div", innerHTML: {} },
},
css: {
":link": {}, // pseudo-class, validated for <a>
},
},
},
css: {
width: "100px",
"&.active": {}, // valid: 'active' is a possible class above
"> content": {}, // valid: 'content' is a named child
},
});
};Remove active from the class expression - or typo the selector as
&.actve - and the rule is a compile error, same as a renamed child. The
guarantee doesn't loosen because the class is conditional: the type system
sees every class the expression can produce, so styles for a state the
element can never be in are caught, not shipped.
Class checking in shast operates on types, not source text. This is the
opposite of Tailwind's model: Tailwind discovers class names by scanning
your files as strings, so a dynamically constructed class name
(`text-${color}-500`) silently escapes the scanner and breaks. shast
doesn't care how the class string is built - concatenated, computed,
returned from a helper - as long as TypeScript can compute its literal
type. You can create the type or compute it; what matters is that it's
known:
// ✅ known - checked
class: "card"
class: num > 1 ? "active card" : "card" // "active card" | "card"
class: `${active} card` // if active: "active" | ""
class: variantClass(props.kind) // if it returns "primary" | "ghost"
// ❌ not known - disregarded by the wall
class: userInput // typed as string
class: classNames.join(" ") // string
class: legacyHelper() // untyped / returns stringFor better or for worse. The better: total freedom in how you build
class strings, with no scanner heuristics to appease - the check follows
the type system wherever it can see. The worse: the moment a class value
widens to plain string, it carries no information, and validation over it
is disregarded - the class wall simply does not cover that expression. The
boundary is exactly TypeScript's boundary, nothing smarter and nothing
dumber. If you want a dynamic-but-checked class, give it a type: a const
map, an as const array, a helper with a literal-union return type - the
usual TypeScript moves all work.
Everything is defined in closed-world config registries - tags, allowed
children, attributes (as DSL strings), CSS properties, syntax tokens,
@property custom properties, pseudo-classes/elements - and every component
is checked against them twice:
| Rule | Compile time | Runtime |
|---|---|---|
| Tag exists in the registry | ✓ | ✓ |
Child tag allowed by parent (ul → only li) |
✓ | ✓ |
Ancestral inheritance (a > h1 > b rejected because a ∩ h1 forbids b) |
✓ | ✓ |
| Attribute exists and value matches its DSL type | ✓ | ✓ |
| CSS property value matches the syntax config | ✓ | ✓ |
> child selector targets a real named child (at any nesting depth) |
✓ | ✓ |
&.class references a class declared on the context element - including classes computed via template literals (class: `${active} card`) |
✓ | ✓ |
Custom property (--x) registered and value matches its syntax |
✓ | ✓ |
| Pseudo-class/element declared for that tag | ✓ | see Limitations |
Composition does not weaken any of this: components built in separate files and embedded into parents are re-validated under the parent's context, at both levels. Widened types fail closed - they can't be embedded at all. See docs/structural-coupling.md for the verified guarantees and their test methodology.
A reusable child does not need to know its parent when it is defined. Its literal structure is preserved, then checked again when a parent supplies the missing context:
const item = {
tag: "li",
innerHTML: "item",
} as const;
createComponent({
tag: "ul",
innerHTML: { item }, // valid: <ul> permits <li>
});The same child embedded under a parent that does not permit <li> is rejected
at the parent call. This is not limited to direct children: the existing
ancestral-inheritance rules already thread parent context through an entire
prebuilt subtree. Unknown context is not guessed at; once composition makes it
known, validity is narrowed there.
This existing HTML behavior is also the foundation for planned structural CSS
rules. For example, a standalone child may declare flex: 1 without knowing
its eventual parent. When composed under a known parent, shast can accept it
under display: flex and reject it under a display mode where it cannot act as
a flex item. That CSS relationship is not implemented yet; parent-driven
revalidation is.
The wall is only as good as its error messages, so diagnostic quality is treated as an interface, not an accident - error strings carry the path and the expectation, and regressions in message clarity are considered bugs.
shast checks what is mechanically checkable from declared, unconditional facts - the same principle behind the ancestral-inheritance check, which already threads parent context through the type system at arbitrary depth.
What that means concretely:
- Checked: structure, selector targets, child validity, attribute and value vocabularies, named relationships between a component's CSS and its own tree.
- Plausible future work: unconditional cross-node style relations
(e.g. a child declaring
flex: 1under a parent whose owncssblock declaresdisplay: flex). Both facts are visible in the same definition the types already walk. A broader catalog of these checks — the common CSS "why isn't this working" mistakes (z-indexwithout a stacking context, inline elements ignoringwidth, flex/grid item props without a flex/grid parent,grid-areanames,positionsticky/absolute/fixed ancestor requirements) — is enumerated inTASK.mdunder CSS Semantic Rules, ordered by type-system cost. The cheap tiers (same-node and one-level parent→child rules) cover a large fraction of the most-Googled CSS bugs; heavier tiers are gated behind the performance budget. Tracked, not promised. - Out of scope, permanently: conditional layout semantics. The moment
displaysits behind a media query, a pseudo-class, or resolves via inheritance from a parent unknown at definition time, "isflex: 1meaningful here?" stops having a yes/no answer. Erroring conservatively would reject correct code and breed escape hatches; permitting optimistically would quietly weaken the guarantee. shast chooses to not check what it cannot check honestly.
If you adopt shast, you will still ship visual regressions that depend on facts outside the component tree. The claim is narrower and therefore keepable: shast catches structural and contextual mistakes that can be derived efficiently from the explicit component facts it owns, at the definition site, before your code runs.
The registries are meant to live inside your codebase and be tailored to it - the same philosophy as shadcn: you don't install a black box, you own the config and grow it as your project grows.
- Start from
common(orminimal), notfull. Add a tag, an attribute, a syntax token when you need it, next to the code that needs it. fullis a reference, not a starting point. It covers essentially the entire HTML/CSS surface, and it's extremely unlikely your project wants the entire web platform as its vocabulary. A registry that permits everything protects against nothing.- Smaller registries are strictly better on every axis this library cares about: tighter anti-hallucination walls for AI (a model can't reach for a tag your design system doesn't use), sharper autocomplete for humans, and a smaller type-checking constant (registry breadth - not component count - is what drives editor latency).
Your registry is your design system's vocabulary. If <table> isn't in
it, nobody - human or model - ships a table. For humans that is a curation
chore; for AI-generated code it is the entire point. Defining what a model
is permitted to emit is the new code review, and the registry is where that
definition lives.
The one-string, two-walls technique - a TypeScript-syntax definition string parsed identically at the type level and at runtime - is the approach proven by ArkType. shast applies it to a deliberately smaller domain: HTML attribute values and CSS values are scalars (keyword unions, numbers, template literals), so shast's DSL intentionally supports no arrays and no objects. That restraint is what keeps the type-level parser small enough for one maintainer to own and fast enough to stay out of your editor's way.
You already know the DSL if you know TypeScript: "'ltr' | 'rtl' | undefined" means exactly what it looks like.
tsccost is linear: ~4.8K instantiations / ~5ms per component (400 components: 2.4s full check).- Editor: warm completions inside a
cssblock 8–18ms; ~43ms full-file recheck for a typical component file.
Keep files to a handful of components each and the type machinery is imperceptible. Details in docs/structural-coupling.md.
Some limitations are deliberate trade-offs to keep the type system
snappy; others are known gaps with the fix tracked in TASK.md. They
are listed here rather than hidden in either category's fine print.
Two DSL parsing edge cases are intentionally unsupported because handling them would slow every DSL string down for a vanishingly rare case:
-
Pipe inside quoted strings. The
|character is a union separator. A literal pipe inside a single/double-quoted string ("'|'") is not supported: the type-level parser splits on|before checking quote boundaries, and quote-aware splitting at the type level adds significant complexity. Template literals are the exception:`${"a" | "b"}`handles|inside${...}correctly, because backtick strings are parsed byDSLTemplateDelimiterbefore the pipe split. -
Nested template literals.
`\`${number | string}\`` (a backtick-literal backtick containing an interpolation) is not supported - tracking escape depth across quote contexts at the type level costs far more than the edge case is worth. -
CSS class name syntax is not validated at the type level. The type system checks existence (
&.foomust reference a class on the element) but not well-formedness — a class like&.123or&.foo!barpasses the type wall even though it is an invalid CSS identifier. The runtime wall catches these; adding character-level validation at the type level would require recursion across every class name for a mistake the runtime already catches.
- Pseudo-class/element usage in css blocks and pseudo-element declarations in the tag config are type-checked but not runtime-checked.
CSS property values and custom properties (--*) are now validated at
runtime inside createComponent. The remaining gap covers only
pseudo-class/element usage — worth knowing if you rely on the runtime wall
alone (e.g. validating untyped AI output without running tsc).
Early, honest version: one maintainer, 200+ commits, no releases yet. The type-level and runtime guarantees in the table above are tested (see docs/structural-coupling.md); the API surface may still move. If a closed-world, server-rendered approach to AI-generated UI resonates with you, issues and skepticism are equally welcome.
MIT