The Hypra language
Hypra is a strict markup language that compiles to HTML, plus Markdown, JSON and WebMCP tools. This document is the complete reference, written so that a person or a language model can write correct Hypra from it alone.
A complete page
// product.hypra: comments start with //
use "layouts/shop.hypra" // imports the Shop layout from another file (see `layout` below)
page(layout: Shop, title: "{product.name} – Running Shoes", description: "{product.name}: a light running shoe for long distances.") {
props(product: Product)
img(product.photo, alt: "{product.name}, side view", width: 600, height: 400)
h1 "{product.name}"
p { "Only " b "{product.weight} g" " per shoe." }
ul {
for feature in product.features { li "{feature}" }
}
action("cart.add", id: product.id) "Add to cart"
form("/reviews", name: "review", description: "Leave a review") {
field("stars", type: Int(1..5), label: "Rating")
field("body", type: Text(max: 2000), label: "Review", optional: true)
button "Send review"
}
}Rules
- Every element has one shape:
name(attributes) "text" { children }. Each part is optional, and they combine freely:button(hx-get: "/more", hx-target: "#list") "Load more". The text and the opening{must be on the same line as the name; the block itself may span as many lines as you like. - Attributes are
name: value, separated by commas. Some elements take their main attribute first, without a name (writing the name works too, e.g.action(name: "cart.add")):a("/shoes"),img("/photo.jpg", alt: "..."),form("/reviews", ...),field("email", ...),action("cart.add", ...),script("/app.js"),video("/clip.mp4", ...). It can be data too:a(product.url) "View",img(photo.src, alt: "{photo.caption}"). - A value says what it is:
"text"is text.{name}inside it reads data:"Hello {user.name}".42,3.5,-1are numbers;true,falseare booleans.product.name(lowercase, no quotes) is data.Shop,Email,Product(capitalized) are layouts, components and types.
- Element names are HTML tag names:
div,p,h1…h6,ul,li,a,img,section,table, … Hypra adds onlypage,props,action,field,rawandallow. - Settings belong to the element they configure:
page(title: ..., layout: Shop, robots: "noindex").
Text
Text after an element is always a quoted string: h1 "Welcome", p "{post.body}". Never write data without
quotes after an element (h1 post.title is wrong; write h1 "{post.title}").
{name}reads data only when written without spaces and with a valid name:{user.name},{item}. Braces around anything else are text, so JavaScript like"{ open: false }"works as is.{name}works in any quoted value, text or attribute:alt: "Photo of {product.name}".{{and}}write literal braces.Write
&,<,©,üand any other character as is; Hypra escapes what HTML needs.A backslash starts an escape:
\"(quote),\\(backslash),\n(new line),\t(tab). Any other is an error, so a Windows path is written"C:\\files".Text over several lines goes between
"""lines. The indentation of the closing"""is removed from every line, and the text is taken as written: quotes and backslashes need no escaping, so code keeps its own escapes like"\n".{name}still reads data, except in scripts and styles:pre """ fn main() { println!("hi"); } """Numbers are written exactly as in the source:
lng: 13.40stays13.40.To mix text with inline elements, use a block of strings and elements:
p { "Read " a("/terms") "the terms" " first." }.ifandforwork inside such a line too:p { "Hello" if user.vip { b " (VIP)" } "!" }.Several elements may share a line:
nav { a("/") "Home" a("/shop") "Shop" }.
The page
Every file you compile has exactly one page at the top level. Components and layouts can be defined in the
same file (before or after the page) or imported from other files with use.
page(title: "About us", description: "Who we are: a small bakery that bakes bread by hand every night.") {
h1 "About us"
}| Attribute | Meaning |
|---|---|
title |
required. The <title>; keep it under 60 characters wide |
description |
required, unless the layout sets one or robots has noindex. For search results and link previews; 50 to 160 characters wide |
layout |
a layout name, e.g. layout: Shop |
canonical |
the page's full canonical URL, starting with https:// |
image |
the image for link previews |
robots |
any robots value, e.g. robots: "noindex" or robots: "noindex, nofollow" |
lang |
the page language as a code, e.g. lang: "de" or "pt-BR" (default "en"), never a name like "German" |
Any other HTML attribute of the page goes on <html>: page(title: "...", dir: "rtl", class: "dark"). Hypra
writes html, body and title itself; to give <body> attributes, write body(class: "...") without content
directly inside the page or layout. The page's body(...) wins over the layout's for the same attribute.
Data: props
A page declares the data it uses. Using undeclared data is an error.
page(title: "{post.title}", description: "{post.summary}") {
props(post: Post, user: User)
h1 "{post.title}"
}Type names are capitalized and document the data; any capitalized word works (Post, User, List for a list).
They aren't checked against the data.
head
head goes directly inside a page, layout or component and takes meta, link, script, style, base,
noscript and template, and can stand anywhere in the page (usually first). Hypra already writes the title,
description, canonical, Open Graph, Twitter card and viewport tags; a meta with the same name or property
(or a canonical link) in head replaces Hypra's. A page loads the libraries its attributes rely on, such
as htmx or Alpine.js, itself; local paths and full URLs both work. The same goes for CSS: class names like
Tailwind's don't load anything, so link the stylesheet (or the framework's script) in head.
head {
link(rel: "stylesheet", href: "/app.css")
link(rel: "icon", href: "/favicon.svg")
script("/app.js", type: "module")
script("https://unpkg.com/htmx.org@2.0.3", defer: true)
meta(name: "theme-color", content: "#3b5bdb")
}HTML elements and attributes
Any HTML element works with its normal attributes: div(class: "card", id: "main"), input(type: "search", name: "q"), td(colspan: 2) "Total".
truewrites an attribute bare andfalseleaves it out:hidden: true,download: true,popover: true. Attributes whose value is the text"true"or"false"keep it:aria-*,data-*,hx-*, Alpine and Vue attributes, event handlers,contenteditable,draggableandspellcheck, soaria-expanded: falsebecomesaria-expanded="false". Data works too:checked: user.adminwritescheckedwhen the value is true, for HTML's boolean attributes (hidden,disabled,checked, …); other attributes get the value as text.An object or list from data is written as JSON:
data-props: island.props.Attributes with a dash pass through:
data-id: "7",aria-label: "Close",hx-post: "/like",x-data: "{ open: false }".Any other name can be quoted:
"@click": "open = !open","x-on:keyup.escape": "open = false". A quoted name can't contain spaces, quotes,=,>or/.An attribute Hypra doesn't know is written as-is with a warning, since HTML keeps gaining attributes; quote its name to say it's meant. A near miss of a Hypra setting, like
decortive: trueonimg, is an error.URLs from data that would run code (
javascript:,vbscript:,data:text/html) are replaced withabout:blank#blocked. Written in the page, they're an error.scriptandstyletake their code as text and write it exactly as it is: braces in JavaScript and CSS are never data, soimport {createRoot} from "react"works. Pass data to a script in adata-*attribute, or in a JSON script, where{name}is data written as a JSON string (so don't put quotes around it):script(type: "application/ld+json") """ {"@context": "https://schema.org", "@type": "Product", "name": {product.name}} """Custom elements (with a dash in the name) take any attribute:
model-viewer(src: "/shoe.glb", ar: true).svgandmathwork inline. Inside them, any element and attribute is written as-is, following SVG and MathML:svg(viewBox: "0 0 24 24") { path(d: "M12 2L2 22h20z") }.Inside an HTML
template,slotis the web components element:template(shadowrootmode: "open") { slot }.Void elements (
img,input,br,hr,meta,link, …) have no text and no children.
Elements with Hypra rules:
| Element | Rule |
|---|---|
img(src, alt: "...") |
needs alt, or decorative: true for images that carry no meaning. priority: true loads it first |
a(href) "text" |
needs text |
button "text" |
needs text, or aria-label for an icon button |
video(src, captions: "file.vtt") |
needs captions, unless it's muted: true; source and track elements work inside it, and controls: false hides the controls |
h1 |
exactly one per page, counted as the page renders: a component with an h1 used twice, or an h1 in a loop, is too many |
Forms
form("/signup", name: "signup", description: "Create an account") {
field("name", type: String(1..80), label: "Full name")
field("email", type: Email, label: "Email")
field("password", type: Password(min: 12), label: "Password")
field("plan", type: Choice("free", "pro"), label: "Plan", default: "free")
field("bio", type: Text(max: 500), label: "About you", optional: true)
field("terms", type: Accept, label: "I accept the terms")
button "Sign up"
}form(url, name: "..."): thenamemakes the form a tool agents can call, so a form with fields should have one. Also:description, andmethod:"post"by default;"get","put","patch"and"delete"also work, and"dialog"closes the surroundingdialogwithout sending anything, so it's never a tool.- A form needs a
buttonto submit it. field("name", type: ..., label: "...")goes anywhere inside the form, also insidefieldsetor adiv, but not insidefororif: fields are the form's fixed inputs.typeandlabelare required. Fields are required to fill in unless they haveoptional: true. Also:default: "...", and any input attribute such asplaceholder: "...". Plaininput,selectandtextareawork too, but agents only see fields.- Forms can't be nested, and an
actionis a form of its own, so it can't be inside a form or a line of text.
| Field type | Input |
|---|---|
String, String(1..80) |
one line of text, with optional length range |
Text, Text(max: 2000) |
several lines |
Password, Password(min: 12) |
a password, with optional length range; it has no default |
Email, Url, Tel |
an email address, a URL, a phone number; also with a length range |
Int, Int(1..5) |
a whole number, with optional range |
Number, Number(min: 0, max: 9.99) |
any number, with optional range |
Date, Time |
a date or time, with optional min: "2025-01-01" and max: ... |
Choice("s", "m", "l") |
one of the listed options |
Choice(de: "Germany", at: "Austria") |
one of the options, each with its own text; the form sends de or at |
Accept |
a checkbox that must be ticked |
File |
a file upload; the form is sent as multipart/form-data |
A range is 1..80, 1.. or ..80, or written out as min: 1, max: 80. A field's default can come from data,
e.g. default: user.country selects the user's country.
Actions
An action is a button that does one thing on the server, like adding to a cart:
action("cart.add", id: product.id) "Add to cart"
action("cart.remove", id: item.id, confirm: "Remove this item?") "Remove"
action("cart.checkout") "Check out"The first argument is the action's name, written in quotes with letters, digits, dots, dashes and underscores.
Attributes that style the button or hook up a script go on the button: class, style, title, disabled,
form, and aria-*, data-*, hx-*, x-*, @* and :*. Every other attribute except confirm is sent with
the action. The text is the button label and is required. Actions post to /_a/<name>. Hidden fields such as a
CSRF token are only added to forms that post to this site.
An action shown in a loop is one tool for agents: values that differ between the copies become its choices.
for item in cart.items { action("cart.remove", id: item.id) "Remove" } is the tool cart.remove(id: 1|2|3).
Statements
use
Imports components and layouts from another file, relative to this one. use lines go at the top. A file that
holds only components and layouts has no page; hypra check checks it on its own, and pages build it.
use "components/card.hypra"component
component Card(title: Text, featured: Bool = false) {
article(class: "card") {
h3 "{title}"
if featured { p "Staff pick" }
slot
}
}Names are capitalized. Parameters are
name: Type, optionally with a default= value.A component sees only its parameters, never the page's data. Pass what it needs.
Components are expanded into the page, so a component can't use itself, directly or through another one. Render nested data, like a menu tree, with
forloops instead.A component that renders one HTML element passes HTML attributes from the call on to it, and joins classes:
Card(title: "Shoes", class: "mt-4", id: "shoes")renders<article class="card mt-4" id="shoes">.A component's
headgoes into the head of every page that uses it, once however often it's used, so a component can bring its own script or stylesheet:component Map(...) { head { script("/map.js") } ... }.slotrenders the content the component was called with. Text right after a call is that content too:Badge(kind: "new") "New"isBadge(kind: "new") { "New" }.A component can have named slots besides the default one. The call fills them with
slot("name") { ... }; a named slot the call leaves out shows its own content instead:component Panel(title: Text) { section { header { h2 "{title}" slot("actions") { a("/help") "Help" } } slot } } Panel(title: "Orders") { slot("actions") { a("/orders/new") "New order" } p "Your recent orders" }Call it like an element with named arguments:
Card(title: post.title, featured: true) { p "{post.summary}" }.
layout
The frame around a page. Its settings are defaults every page can override.
layout Shop(lang: "en", title_suffix: " | Shop", site_name: "Shop") {
head { link(rel: "stylesheet", href: "/app.css") }
header { nav(aria-label: "Main") { a("/") "Home" a("/shoes") "Shoes" } }
main { slot }
footer { p "© 2026 Shop" }
}Settings: lang, title_suffix, site_name, description, image, robots, and layout. A layout must
contain slot, where the page's content goes. A page uses it with page(layout: Shop, ...).
A layout can sit inside another one with its own layout setting. The page goes into the inner layout's slot,
which goes into the outer one's. Settings come from the page first, then from the innermost layout that has them;
heads and body(...) attributes of all of them are kept:
layout Docs(layout: Shop, title_suffix: " · Docs") {
div(class: "docs") {
aside { a("/docs/install") "Install" }
article { slot }
}
}for
for post in posts { article { h2 "{post.title}" } }
for size in ["S", "M", "L"] { option "{size}" }for repeats its body for every item of a list, from data or written in brackets. Inside the body, the name
before in is the current item.
A list in brackets holds fixed values: text, numbers, true and false. Besides for, it works as a component
argument or an attribute, where it's written as JSON: div(data-sizes: ["S", "M"]).
if
if user.admin { a("/admin") "Admin" } else { p "Welcome, {user.name}" }
if not cart.items { p "Your cart is empty." }
if order.status == "shipped" { p "On its way" } else if order.status != "paid" { p "Waiting for payment" }A value is false when it's false, missing, empty text, 0 or an empty list. == and != compare a value
with fixed text, a number, true or false, as text: if count == 3 matches 3. To compare two pieces of
data, prepare the answer in the controller (if user.is_owner { ... }).
Writing good pages
These rules give warnings, except two that are errors: more than one h1, and javascript: URLs. A warning doesn't
stop the build, but fix it unless there's a reason (then use allow).
- Every page has exactly one
h1with its main topic. Headings go down one level at a time:h2afterh1,h3afterh2, neverh2straight toh4. This is checked on the page as it renders, so a component used right after anh1starts with anh2. altdescribes what the image shows ("Blue running shoe, side view"), never"image","photo"or a file name. Images without meaning getdecorative: true.- Link text says where it goes ("Read the setup guide"), never just "click here", "here" or "read more". Links
with the same text go to the same place: in a loop, put what's different into the text ("Read more about
{post.title}") or into
aria-label. - Titles and descriptions are measured by how wide they are, like in search results: Chinese, Japanese and Korean characters count twice.
- Vague link and alt texts ("click here", "image") are checked in the page's language (
lang): English and German so far. A page in another language isn't checked for them, and neither is one whoselangcomes from data. - An
iframehas atitle. A video that autoplays ismuted: true.tabindexis0or-1, never positive. - An
idis used once per page, so a component with a fixedidis used once. rawwith data writes it unescaped; only use it for trusted HTML.javascript:URLs are errors.
Escape hatches
raw "<marquee>Sale</marquee>"writes HTML as-is.allow("SEO-003") "reason"turns one rule off for the element right after it, so put it directly before the element that breaks the rule. The reason is required:
allow("SEO-003") "the hero splits its headline into two h1"
h1 "Run faster."An allow at the very start of a page covers the whole page, including rules about the page itself:
page(title: "Dashboard", robots: "noindex") {
allow("SEO-004") "an app screen has no main heading"
h2 "Today"
}Formatting
hypra fmt lays out files the same way: two spaces per level of nesting, name: value and a, b with one
space, p { "x" } with spaces inside one-line blocks, and at most one blank line. Comments, line breaks and the
two spaces between items on one line stay as written. Run hypra fmt --check in CI to keep it that way.
Errors
Every message has a code, a position and a fix (help). Read the help line; it usually says exactly what to
write. Errors stop the build; warnings don't. allow("CODE") "reason" turns one off for the next element.
| Code | Meaning |
|---|---|
A11Y-001 |
image has no alt text |
A11Y-002 |
link has no text |
A11Y-003 |
video has no captions |
A11Y-004 |
action has no label |
A11Y-005 |
button has no text |
A11Y-006 |
heading level skipped (warning) |
A11Y-007 |
link text doesn't say where it goes (warning) |
A11Y-008 |
alt text doesn't describe the image (warning) |
A11Y-009 |
iframe has no title (warning) |
A11Y-010 |
positive tabindex (warning) |
A11Y-011 |
autoplaying video isn't muted (warning) |
A11Y-012 |
ARIA name or state on a div or span without a role (warning) |
A11Y-013 |
links with the same text go to different places (warning) |
ALLOW-001 |
allow has no reason |
ALLOW-002 |
allow has no rule code |
ALLOW-003 |
allow names an unknown rule (warning) |
ATTR-001 |
unknown attribute (a warning on HTML elements, unless it's a near miss of a Hypra setting) |
ATTR-002 |
raw has no HTML |
ATTR-003 |
wrong kind of value |
ATTR-004 |
unnamed argument where a name is needed |
ATTR-005 |
attribute set twice |
ATTR-006 |
text on an element that takes none |
ATTR-007 |
default outside a component parameter |
ATTR-008 |
invalid attribute name |
COMP-001 |
unknown component |
COMP-002 |
missing component argument |
COMP-003 |
unknown component argument |
COMP-004 |
component defined twice |
COMP-005 |
component name isn't capitalized |
COMP-006 |
unnamed component argument |
COMP-007 |
component calls itself |
COMP-008 |
malformed component parameter |
COMP-009 |
page expands to too many elements |
COMP-010 |
content given to a component without a slot |
FORM-001 |
field has no name or type |
FORM-002 |
field has no label |
FORM-003 |
form has no button (warning) |
FORM-004 |
form with fields has no name (warning) |
FORM-005 |
field outside a form, or inside for or if in it |
FORM-006 |
unknown form method |
IMPORT-001 |
imported file can't be read |
LAYOUT-001 |
unknown layout |
LAYOUT-002 |
layout has no slot |
LAYOUT-003 |
layout defined twice |
LAYOUT-004 |
layout name isn't capitalized |
LAYOUT-005 |
layout inside itself |
LEX-001 |
unexpected character |
LEX-002 |
unterminated string |
LEX-003 |
unknown escape in a string |
PARSE-001 |
syntax error |
PARSE-002 |
nested too deeply |
SEC-001 |
inline event handler (warning) |
SEC-002 |
raw HTML from data (warning) |
SEC-003 |
URL that runs code |
SEC-004 |
data inside an attribute that runs or loads code (warning) |
SEC-005 |
data name in a script or style, which is written as-is (warning) |
SEC-006 |
formaction or base that could send forms to another site |
SEC-007 |
data in the values of an SVG animation that sets a link |
SEM-001 |
unknown element |
SEM-002 |
file has no page |
SEM-003 |
element in the wrong place |
SEM-004 |
image or video has no source |
SEM-005 |
action has no name |
SEM-006 |
slot outside a component or layout |
SEM-007 |
imported file contains a page |
SEM-008 |
void element has children |
SEM-009 |
html, body or title written by hand |
SEM-010 |
head in the wrong place |
SEM-011 |
element doesn't belong in head |
SEM-012 |
id used more than once (warning) |
SEM-013 |
lang isn't a language code (warning) |
SEM-014 |
invalid action name |
SEO-001 |
page has no title or description |
SEO-002 |
title too long (warning) |
SEO-003 |
more than one h1 |
SEO-004 |
page has no h1 (warning) |
SEO-005 |
canonical URL isn't absolute (warning) |
SEO-006 |
description too short or too long (warning) |
TYPE-001 |
prop missing from the data |
TYPE-002 |
unknown name or field |
TYPE-003 |
value isn't a list |
TYPE-004 |
unknown field type |
TYPE-005 |
wrong argument or default for a field type |
Common mistakes
| Wrong | Right |
|---|---|
text "Hello", heading(1) "Hi" |
p "Hello", h1 "Hi" |
h1 post.title |
h1 "{post.title}" |
a(href: "/x", "Label") |
a("/x") "Label" |
img("/a.jpg") |
img("/a.jpg", alt: "What the image shows") |
lang: en, robots: noindex |
lang: "en", robots: "noindex" |
layout: shop, type: email |
layout: Shop, type: Email |
field("email", type: Email) |
field("email", type: Email, label: "Email") |
title "About" inside the page |
page(title: "About", ...) |
p "Hi" on one line, { on the next |
p "Hi" { on one line |
using data without props |
props(user: User) first |
@click: "open = true" |
"@click": "open = true" (names without a dash are quoted) |
onclick: "..." |
a script in head, or "@click" with Alpine (onclick works but warns) |