# Hypra
> Hypra is a strict markup language that compiles to HTML for people and to Markdown, JSON and WebMCP tools for AI agents.
# 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
```hypra
// 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
1. **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.
2. **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}")`.
3. **A value says what it is:**
- `"text"` is text. `{name}` inside it reads data: `"Hello {user.name}"`.
- `42`, `3.5`, `-1` are numbers; `true`, `false` are booleans.
- `product.name` (lowercase, no quotes) is data.
- `Shop`, `Email`, `Product` (capitalized) are layouts, components and types.
4. **Element names are HTML tag names:** `div`, `p`, `h1`…`h6`, `ul`, `li`, `a`, `img`, `section`, `table`, …
Hypra adds only `page`, `props`, `action`, `field`, `raw` and `allow`.
5. **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:
```hypra
pre """
fn main() {
println!("hi");
}
"""
```
- Numbers are written exactly as in the source: `lng: 13.40` stays `13.40`.
- To mix text with inline elements, use a block of strings and elements:
`p { "Read " a("/terms") "the terms" " first." }`. `if` and `for` work 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`.
```hypra
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 `
`; 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 ``: `page(title: "...", dir: "rtl", class: "dark")`. Hypra
writes `html`, `body` and `title` itself; to give `` 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.
```hypra
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`.
```hypra
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"`.
- `true` writes an attribute bare and `false` leaves 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`, `draggable` and `spellcheck`, so `aria-expanded: false` becomes
`aria-expanded="false"`. Data works too: `checked: user.admin` writes `checked` when 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: true` on `img`, is an error.
- URLs from data that would run code (`javascript:`, `vbscript:`, `data:text/html`) are replaced with
`about:blank#blocked`. Written in the page, they're an error.
- `script` and `style` take their code as text and write it exactly as it is: braces in JavaScript and CSS are
never data, so `import {createRoot} from "react"` works. Pass data to a script in a `data-*` attribute, or in a
JSON script, where `{name}` is data written as a JSON string (so don't put quotes around it):
```hypra
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)`.
- `svg` and `math` work 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`, `slot` is 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
```hypra
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: "...")`: the `name` makes the form a tool agents can call, so a form with fields should have
one. Also: `description`, and `method`: `"post"` by default; `"get"`, `"put"`, `"patch"` and `"delete"` also
work, and `"dialog"` closes the surrounding `dialog` without sending anything, so it's never a tool.
- A form needs a `button` to submit it.
- `field("name", type: ..., label: "...")` goes anywhere inside the form, also inside `fieldset` or a `div`, but
not inside `for` or `if`: fields are the form's fixed inputs. `type` and `label` are required. Fields are
required to fill in unless they have `optional: true`. Also: `default: "..."`, and any input attribute such as
`placeholder: "..."`. Plain `input`, `select` and `textarea` work too, but agents only see fields.
- Forms can't be nested, and an `action` is 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:
```hypra
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/`. 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.
```hypra
use "components/card.hypra"
```
### `component`
```hypra
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 `for` loops 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 ``.
- A component's `head` goes 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") } ... }`.
- `slot` renders the content the component was called with. Text right after a call is that content too:
`Badge(kind: "new") "New"` is `Badge(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:
```hypra
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.
```hypra
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:
```hypra
layout Docs(layout: Shop, title_suffix: " · Docs") {
div(class: "docs") {
aside { a("/docs/install") "Install" }
article { slot }
}
}
```
### `for`
```hypra
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`
```hypra
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 `h1` with its main topic. Headings go down one level at a time: `h2` after `h1`,
`h3` after `h2`, never `h2` straight to `h4`. This is checked on the page as it renders, so a component used
right after an `h1` starts with an `h2`.
- `alt` describes what the image shows ("Blue running shoe, side view"), never `"image"`, `"photo"` or a file name.
Images without meaning get `decorative: 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, unless what's around them tells them apart: a list item, a table row
or cell, or an `article` or `section` with its own heading. Otherwise 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, German,
French, Spanish, Portuguese, Italian and Dutch so far. A page in another language isn't checked for them, and
neither is one whose `lang` comes from data.
- An `iframe` has a `title`. A video that autoplays is `muted: true`. `tabindex` is `0` or `-1`, never positive.
- Something people click is a `button` or a link, not a `div` or `span` with `onclick` or `"@click"`. Form
controls written by hand (`input`, `select`, `textarea`) have a label; `field` writes one for you.
- An `id` is used once per page, so a component with a fixed `id` is used once.
- `raw` with data writes it unescaped; only use it for trusted HTML. `javascript:` URLs are errors.
## Escape hatches
- `raw ""` 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:
```hypra
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:
```hypra
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, a fix (`help`) and a link to its rule's page (`docs`), which explains the
rule with a page that breaks it and the fixed page. 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`](https://hypralang.dev/rules/a11y-001/) | image has no alt text |
| [`A11Y-002`](https://hypralang.dev/rules/a11y-002/) | link has no text |
| [`A11Y-003`](https://hypralang.dev/rules/a11y-003/) | video has no captions |
| [`A11Y-004`](https://hypralang.dev/rules/a11y-004/) | action has no label |
| [`A11Y-005`](https://hypralang.dev/rules/a11y-005/) | button has no text |
| [`A11Y-006`](https://hypralang.dev/rules/a11y-006/) | heading level skipped (warning) |
| [`A11Y-007`](https://hypralang.dev/rules/a11y-007/) | link text doesn't say where it goes (warning) |
| [`A11Y-008`](https://hypralang.dev/rules/a11y-008/) | alt text doesn't describe the image (warning) |
| [`A11Y-009`](https://hypralang.dev/rules/a11y-009/) | iframe has no title (warning) |
| [`A11Y-010`](https://hypralang.dev/rules/a11y-010/) | positive tabindex (warning) |
| [`A11Y-011`](https://hypralang.dev/rules/a11y-011/) | autoplaying video isn't muted (warning) |
| [`A11Y-012`](https://hypralang.dev/rules/a11y-012/) | ARIA name or state on a div or span without a role (warning) |
| [`A11Y-013`](https://hypralang.dev/rules/a11y-013/) | links with the same text go to different places (warning) |
| [`A11Y-014`](https://hypralang.dev/rules/a11y-014/) | click handler on an element that isn't a control |
| [`A11Y-015`](https://hypralang.dev/rules/a11y-015/) | form control has no label |
| [`ALLOW-001`](https://hypralang.dev/rules/allow-001/) | allow has no reason |
| [`ALLOW-002`](https://hypralang.dev/rules/allow-002/) | allow has no rule code |
| [`ALLOW-003`](https://hypralang.dev/rules/allow-003/) | allow names an unknown rule (warning) |
| [`ATTR-001`](https://hypralang.dev/rules/attr-001/) | unknown attribute (a warning on HTML elements, unless it's a near miss of a Hypra setting) |
| [`ATTR-002`](https://hypralang.dev/rules/attr-002/) | raw has no HTML |
| [`ATTR-003`](https://hypralang.dev/rules/attr-003/) | wrong kind of value |
| [`ATTR-004`](https://hypralang.dev/rules/attr-004/) | unnamed argument where a name is needed |
| [`ATTR-005`](https://hypralang.dev/rules/attr-005/) | attribute set twice |
| [`ATTR-006`](https://hypralang.dev/rules/attr-006/) | text on an element that takes none |
| [`ATTR-007`](https://hypralang.dev/rules/attr-007/) | default outside a component parameter |
| [`ATTR-008`](https://hypralang.dev/rules/attr-008/) | invalid attribute name |
| [`COMP-001`](https://hypralang.dev/rules/comp-001/) | unknown component |
| [`COMP-002`](https://hypralang.dev/rules/comp-002/) | missing component argument |
| [`COMP-003`](https://hypralang.dev/rules/comp-003/) | unknown component argument |
| [`COMP-004`](https://hypralang.dev/rules/comp-004/) | component defined twice |
| [`COMP-005`](https://hypralang.dev/rules/comp-005/) | component name isn't capitalized |
| [`COMP-006`](https://hypralang.dev/rules/comp-006/) | unnamed component argument |
| [`COMP-007`](https://hypralang.dev/rules/comp-007/) | component calls itself |
| [`COMP-008`](https://hypralang.dev/rules/comp-008/) | malformed component parameter |
| [`COMP-009`](https://hypralang.dev/rules/comp-009/) | page expands to too many elements |
| [`COMP-010`](https://hypralang.dev/rules/comp-010/) | content given to a component without a `slot` |
| [`FORM-001`](https://hypralang.dev/rules/form-001/) | field has no name or type |
| [`FORM-002`](https://hypralang.dev/rules/form-002/) | field has no label |
| [`FORM-003`](https://hypralang.dev/rules/form-003/) | form has no button (warning) |
| [`FORM-004`](https://hypralang.dev/rules/form-004/) | form with fields has no name (warning) |
| [`FORM-005`](https://hypralang.dev/rules/form-005/) | field outside a form, or inside for or if in it |
| [`FORM-006`](https://hypralang.dev/rules/form-006/) | unknown form method |
| [`IMPORT-001`](https://hypralang.dev/rules/import-001/) | imported file can't be read |
| [`LAYOUT-001`](https://hypralang.dev/rules/layout-001/) | unknown layout |
| [`LAYOUT-002`](https://hypralang.dev/rules/layout-002/) | layout has no slot |
| [`LAYOUT-003`](https://hypralang.dev/rules/layout-003/) | layout defined twice |
| [`LAYOUT-004`](https://hypralang.dev/rules/layout-004/) | layout name isn't capitalized |
| [`LAYOUT-005`](https://hypralang.dev/rules/layout-005/) | layout inside itself |
| [`LEX-001`](https://hypralang.dev/rules/lex-001/) | unexpected character |
| [`LEX-002`](https://hypralang.dev/rules/lex-002/) | unterminated string |
| [`LEX-003`](https://hypralang.dev/rules/lex-003/) | unknown escape in a string |
| [`PARSE-001`](https://hypralang.dev/rules/parse-001/) | syntax error |
| [`PARSE-002`](https://hypralang.dev/rules/parse-002/) | nested too deeply |
| [`SEC-001`](https://hypralang.dev/rules/sec-001/) | inline event handler (warning) |
| [`SEC-002`](https://hypralang.dev/rules/sec-002/) | raw HTML from data (warning) |
| [`SEC-003`](https://hypralang.dev/rules/sec-003/) | URL that runs code |
| [`SEC-004`](https://hypralang.dev/rules/sec-004/) | data inside an attribute that runs or loads code (warning) |
| [`SEC-005`](https://hypralang.dev/rules/sec-005/) | data name in a script or style, which is written as-is (warning) |
| [`SEC-006`](https://hypralang.dev/rules/sec-006/) | `formaction` or `base` that could send forms to another site |
| [`SEC-007`](https://hypralang.dev/rules/sec-007/) | data in the `values` of an SVG animation that sets a link |
| [`SEM-001`](https://hypralang.dev/rules/sem-001/) | unknown element |
| [`SEM-002`](https://hypralang.dev/rules/sem-002/) | file has no page |
| [`SEM-003`](https://hypralang.dev/rules/sem-003/) | element in the wrong place |
| [`SEM-004`](https://hypralang.dev/rules/sem-004/) | image or video has no source |
| [`SEM-005`](https://hypralang.dev/rules/sem-005/) | action has no name |
| [`SEM-006`](https://hypralang.dev/rules/sem-006/) | slot outside a component or layout |
| [`SEM-007`](https://hypralang.dev/rules/sem-007/) | imported file contains a page |
| [`SEM-008`](https://hypralang.dev/rules/sem-008/) | void element has children |
| [`SEM-009`](https://hypralang.dev/rules/sem-009/) | html, body or title written by hand |
| [`SEM-010`](https://hypralang.dev/rules/sem-010/) | head in the wrong place |
| [`SEM-011`](https://hypralang.dev/rules/sem-011/) | element doesn't belong in head |
| [`SEM-012`](https://hypralang.dev/rules/sem-012/) | id used more than once (warning) |
| [`SEM-013`](https://hypralang.dev/rules/sem-013/) | lang isn't a language code (warning) |
| [`SEM-014`](https://hypralang.dev/rules/sem-014/) | invalid action name |
| [`SEO-001`](https://hypralang.dev/rules/seo-001/) | page has no title or description |
| [`SEO-002`](https://hypralang.dev/rules/seo-002/) | title too long (warning) |
| [`SEO-003`](https://hypralang.dev/rules/seo-003/) | more than one h1 |
| [`SEO-004`](https://hypralang.dev/rules/seo-004/) | page has no h1 (warning) |
| [`SEO-005`](https://hypralang.dev/rules/seo-005/) | canonical URL isn't absolute (warning) |
| [`SEO-006`](https://hypralang.dev/rules/seo-006/) | description too short or too long (warning) |
| [`TYPE-001`](https://hypralang.dev/rules/type-001/) | prop missing from the data |
| [`TYPE-002`](https://hypralang.dev/rules/type-002/) | unknown name or field |
| [`TYPE-003`](https://hypralang.dev/rules/type-003/) | value isn't a list |
| [`TYPE-004`](https://hypralang.dev/rules/type-004/) | unknown field type |
| [`TYPE-005`](https://hypralang.dev/rules/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) |
# hypra/laravel
[Hypra](https://github.com/hypralang/hypra) views for Laravel 11, 12 and 13.
```console
$ composer require hypra/laravel
$ php artisan hypra:install
```
`hypra:install` downloads the `hypra` compiler for this machine from the GitHub release that matches the
package version, checks it against the release's SHA-256 checksums, and puts it in `storage/hypra`, where the
package finds it. No Rust needed. To keep it after `composer update`, add it to your `composer.json`:
```json
"scripts": {
"post-update-cmd": ["@php artisan hypra:install"]
}
```
The compiler is only needed where pages are compiled. To use another one, set `HYPRA_BINARY`.
## Deploying
`php artisan hypra:compile` compiles every page ahead of time and fails if one doesn't compile, so a broken page
stops the deploy instead of reaching a visitor. From Laravel 11.27, `php artisan optimize` runs it too, and
`optimize:clear` removes the compiled pages.
## Views
`.hypra` files live next to Blade views and are rendered with `view()` as usual:
```php
// resources/views/products/show.hypra
return view('products.show', ['product' => $product]);
```
Pages are compiled on first use and cached next to Laravel's compiled views, so `php artisan view:clear`
(or `php artisan hypra:clear`) removes them. A page is recompiled when it or any file it imports changes, and
when the `hypra` binary is replaced, e.g. by an upgrade. A page that doesn't compile throws a `CompileError` with
the compiler's message:
```text
Hypra could not compile resources/views/products/show.hypra:
error[A11Y-001]: image has no alt text
--> resources/views/components/review-card.hypra:4:9
```
## One URL, three formats
Routes in the `web` group answer with the format the client asks for in its `Accept` header:
| Accept | Response |
| --- | --- |
| `text/html` (browsers) | the page |
| `text/markdown` | the page as Markdown, with a list of its actions |
| `application/json` | page metadata and actions |
Responses carry `Vary: Accept`, so caches keep the formats apart, and the HTML links its Markdown version at
the same URL. POST forms to this site get the CSRF token; forms posting to other sites don't.
## Actions
`action("cart.add", id: product.id) "Add to cart"` posts to `/_a/cart.add`. Connect it to a handler:
```php
Route::hypra('cart.add', [CartController::class, 'add']);
```
## Configuration
```console
$ php artisan vendor:publish --tag=hypra-config
```
| Key | Default | Meaning |
| --- | --- | --- |
| `binary` | `HYPRA_BINARY`, else the one `hypra:install` downloaded, else `hypra` on the `PATH` | the compiler |
| `releases` | `HYPRA_RELEASES_URL`, or this project's GitHub releases | where `hypra:install` downloads from |
| `compiled` | `HYPRA_COMPILED_PATH`, or `hypra` inside the compiled views folder | compiled page cache |
| `strict` | `HYPRA_STRICT` or `APP_DEBUG` | throw on data that doesn't match a page |
| `negotiate` | `true` | serve Markdown and JSON via the `Accept` header |
## License
MIT OR Apache-2.0
# hypra for JavaScript
The [Hypra](https://hypralang.dev) compiler, and the runtime for the pages it compiles to JavaScript. A page
renders at request time in Node, Bun or Deno, from Express, Hono, Fastify, Next.js route handlers or any other
server: as HTML for browsers, Markdown for LLMs, JSON for apps and WebMCP tools for AI agents.
```console
$ npm install hypra
$ npx hypra build views/product.hypra --target js --out views/dist
✔ views/dist/product.mjs
✔ views/dist/product.d.mts
✔ views/dist/product.sources.json
0 errors, 0 warnings
```
The package brings the compiler for Linux, macOS and Windows on x64 and ARM; nothing is downloaded when it's
installed. `npx hypra check` and `npx hypra fmt` work the same way.
## Rendering a page
A compiled page is an ES module. Its `render` takes the page's data, the props it declares:
```js
import product from "./views/dist/product.mjs";
const output = product.render({ product: await db.products.find(42) });
output.html; // the page
output.markdown; // for LLMs
output.json; // page metadata and actions
output.webmcp; // tool definitions for AI agents
```
One URL can serve all of them. `negotiate` picks what the request's `Accept` header asks for: Markdown for
`text/markdown`, JSON for `application/json`, HTML for everything else.
```js
import express from "express";
import product from "./views/dist/product.mjs";
const app = express();
app.get("/shoes/:id", async (req, res) => {
const output = product.render({ product: await db.products.find(req.params.id) });
const { contentType, body } = output.negotiate(req.get("accept"));
res.vary("Accept").type(contentType).send(body);
});
```
With Hono, or anything else built on the web's `Request` and `Response`:
```js
app.get("/shoes/:id", async (c) => {
const output = product.render({ product: await db.products.find(c.req.param("id")) });
const { contentType, body } = output.negotiate(c.req.header("accept"));
return c.body(body, 200, { "Content-Type": contentType, Vary: "Accept" });
});
```
## Options
`render(data, options)` takes:
| Option | Meaning |
| --- | --- |
| `strict` | throw a `DataError` naming the `.hypra` line when data is missing, instead of rendering it empty |
| `formFields` | a function returning hidden fields for POST forms to this site, e.g. `() => ({ _csrf: req.csrfToken() })` |
| `alternateUrl` | where the page's Markdown is served; adds `` |
```js
import { DataError } from "hypra/runtime";
try {
product.render({}, { strict: true });
} catch (error) {
if (error instanceof DataError) console.error(error.message);
// views/product.hypra:13:9: prop `product` is missing from the data
}
```
Hidden form fields only go to forms that post to this site, never to a form whose address leads somewhere else.
## Data
Data is what JSON can hold, plus what JavaScript apps pass around:
- objects, arrays, strings, numbers, booleans and `null`
- `Map` and `Set`, read like objects and arrays
- class instances, whose getters are fields: `{user.initial}` reads `get initial()`
- values with a `toJSON` method, like `Date`, which show as what they turn into: `2026-01-02T00:00:00.000Z`
A page renders exactly as `hypra build` writes it with the same data as JSON; the tests compare the two on
thousands of generated pages. One difference comes from JavaScript itself: it has one kind of number, so `1.0`
from JSON shows as `1`, where the compiler's static build writes `1.0`.
## TypeScript
The package has types, and the compiler writes a `.d.mts` next to every page, so `render`, `negotiate` and
the output are typed:
```ts
import product from "./views/dist/product.mjs";
import type { Output } from "hypra/runtime";
const output: Output = product.render({ product });
```
## Rebuilding
`product.sources.json` lists the `.hypra` files a page was built from, so a build script or a file watcher knows
which pages to rebuild when one of them changes.
## License
MIT OR Apache-2.0
# hypra/runtime
The PHP runtime for pages compiled by [Hypra](https://github.com/hypralang/hypra) with `--target php`.
```console
$ hypra build product.hypra --target php
✔ dist/product.php
✔ dist/product.sources.json
0 errors, 0 warnings
```
```php
$page = require 'dist/product.php';
$output = $page->render(['product' => $product]);
$output->html; // the page
$output->markdown; // for LLMs
$output->json; // page metadata and actions
$output->webmcp; // tool definitions for AI agents
```
`render()` takes:
| Argument | Meaning |
| --- | --- |
| `$data` | an array or object with the page's props; models, collections and `ArrayAccess` objects work |
| `strict` | throw a `DataError` naming the `.hypra` line when data is missing, instead of rendering it empty |
| `formFields` | a closure returning hidden fields for POST forms to this site, e.g. `fn () => ['_token' => $token]` |
| `alternateUrl` | where the page's Markdown is served; adds `` |
For Laravel, use [hypra/laravel](https://hypralang.dev/docs/laravel/).
## License
MIT OR Apache-2.0
# Hypra for VS Code
Support for [Hypra](https://github.com/hypralang/hypra), the markup language that compiles to HTML for people
and to Markdown and JSON for AI agents. Works in VS Code, Cursor and other editors built on VS Code.
- **Highlighting** for `.hypra` files, including the JavaScript, CSS and JSON inside `script` and `style`.
- **Errors and warnings as you type**, with the rule code, how to fix it, and a link to where the docs explain it.
Files you import are checked too, as you edit them.
- **Go to Definition** (Ctrl+click or F12) on components, layouts, `use` paths, props and loop variables, also in
imported files. Ctrl+click on a definition lists where it's used.
- **Find All References** (Shift+F12) and **Rename** (F2), across every `.hypra` file in the folder: renaming a
component also renames its calls, and renaming a parameter renames the arguments that set it.
- **Hover** over a name to see where it's defined, or over an element or field type to see what it does, with an
example.
- **Format Document**, the same as `hypra fmt`.
## Install
In Cursor, VSCodium and other editors that install from [Open VSX](https://open-vsx.org/extension/hypralang/hypra),
search for "Hypra" in the Extensions view. In VS Code, download `hypra-.vsix` from the
[latest release](https://github.com/hypralang/hypra/releases/latest), then in the Extensions view choose
**…** → **Install from VSIX…**.
## Settings
Everything except highlighting comes from `hypra lsp`, the language server in the `hypra` compiler. Put the
compiler on your `PATH`, or set `hypra.path` to where it is.
| Setting | Default | |
| --- | --- | --- |
| `hypra.path` | `hypra` | the compiler: a command on your `PATH` or a full path |
# Hypra with AI coding agents
Coding agents like Claude Code, Codex, Cursor and GitHub Copilot write Hypra well when they have two things: the
language reference, and the compiler's errors after every change. In the [benchmark](https://github.com/hypralang/hypra/tree/main/bench), a small model wrote
19 of 20 pages correctly on the first try from the reference alone, and all 20 after reading the compiler's errors.
## Tell the agent about Hypra
Agents read a file of instructions at the root of a project: `AGENTS.md` for Codex, Cursor, Copilot and most
others, `CLAUDE.md` for Claude Code. Add this to it:
```markdown
## Views
The views in this project are Hypra (.hypra files), not HTML or Blade.
- The language: https://hypralang.dev/llms-full.txt. Read it before writing a .hypra file.
- After changing a .hypra file, run `hypra check ` and fix every error and warning. Each one has a code, a line and a help line that says what to change.
- Don't silence a check with `allow` unless the page really needs it; give the reason.
```
For Claude Code, a `CLAUDE.md` with the line `@AGENTS.md` reads the same instructions, so they're kept in one place.
## What the agent reads
| URL | What it is |
| --- | --- |
| [`/llms-full.txt`](https://hypralang.dev/llms-full.txt) | the whole documentation in one Markdown file: the language, Laravel, PHP, the editors, security and the guides |
| [`/llms.txt`](https://hypralang.dev/llms.txt) | a list of every page, with a line on each, for agents that read only what they need |
| [`/docs/index.md`](https://hypralang.dev/docs/index.md) | the [language reference](https://hypralang.dev/docs/) alone; every page of the docs has its Markdown at `index.md` |
## Checking its work
`hypra check` exits with 1 when a page has an error, so an agent sees that something is wrong even if it doesn't
read the output. Every error links to a page about its rule, with an example and the fix:
```console
$ hypra check shoes.hypra
error[A11Y-001]: image has no alt text
--> shoes.hypra:3:7
|
3 | img("/shoe.jpg")
| ^^^^^^^^^^^
= help: add alt: "..." or decorative: true
= docs: https://hypralang.dev/rules/a11y-001/
1 error, 0 warnings
```
Scripts and tools that read the errors get them as JSON with `--format json`:
```json
[
{
"severity": "error",
"code": "A11Y-001",
"message": "image has no alt text",
"file": "shoes.hypra",
"line": 3,
"col": 7,
"len": 11,
"help": "add alt: \"...\" or decorative: true",
"docs": "https://hypralang.dev/rules/a11y-001/"
}
]
```
In Cursor and VS Code, the [extension](https://hypralang.dev/docs/vscode/) shows the same errors in the editor as the agent writes,
and Cursor's agent reads them from there too.
## Pages for agents, too
This page is about agents that *write* Hypra. Pages written in Hypra also answer agents that *read* the web: in
Markdown and JSON, and with WebMCP tools for their forms and buttons. See
[Serve Markdown to LLMs from Laravel](https://hypralang.dev/guides/markdown-for-llms-in-laravel/) and
[Add WebMCP tools to your website](https://hypralang.dev/guides/webmcp-tools-for-your-website/).
# Security
## Reporting a vulnerability
Please report security problems privately through GitHub's
[private vulnerability reporting](https://github.com/hypralang/hypra/security/advisories/new) rather than in a
public issue. Include a page and data that show the problem.
## Trust model
| Input | Trusted? | Why |
| --- | --- | --- |
| `.hypra` files | **yes** | written by the site's developers, like any other source code |
| data passed when rendering | **no** | comes from databases, users and requests |
| the Accept header and the request URL | **no** | sent by the visitor |
Hypra's job is to make sure untrusted data can't change what a page does. A page's author can still write
unsafe code on purpose, for example with `raw`; the compiler reports those places (see below).
## How data is written
Every place data can end up has its own rule. They're the same in the static build (`interp.rs`) and the PHP
runtime (`php/runtime`), and `tests/differential.rs` renders hundreds of random pages with tricky data in both to
make sure (set `HYPRA_FUZZ_CASES` for more).
| Where the data goes | What happens |
| --- | --- |
| text, and attribute values | HTML-escaped: `&`, `<`, `>`, `"` |
| URL attributes (`href`, `src`, `action`, `formaction`, `poster`, `cite`, `data`, SVG's `xlink:href`, and the `to`, `from` and `by` of an SVG `set` or `animate` that changes a link) | a `javascript:`, `vbscript:` or `data:text/html` URL (ignoring case, whitespace and control characters) becomes `about:blank#blocked`; characters URLs can't contain are percent-encoded; then HTML-escaped |
| `script` and `style` | never: their text is written as it is, braces included; a `{name}` that names data is a warning (`SEC-005`) |
| JSON scripts (`application/json`, `*+json`, `importmap`) | a JSON string with `<`, `>` and `&` written as `\u003C`, `\u003E` and `\u0026`, so the data can't close the element |
| attributes that run code (`on*`, `x-*`, `@*`, `:*`, `v-on:*`, `ng-*`, `hx-on*`, `srcdoc`) or load it (`src` of `script`, `iframe` and `embed`, `data` of `object`, `href` of `link`, `content` of `meta` with `http-equiv`) | HTML-escaped, and a warning (`SEC-004`): escaping keeps the attribute closed but can't make data safe as code |
| `raw` | written as-is, with a warning (`SEC-002`) |
| the `confirm` question of an action | a JSON string inside the HTML-escaped `onsubmit` attribute |
| Markdown and JSON outputs | the same values as the HTML, unescaped for Markdown and JSON-encoded for JSON; they're served as `text/markdown` and `application/json`, never as HTML |
What's checked when a page is compiled:
- URLs written in the page that run code are errors (`SEC-003`), including `"javascript:{x}"` and URLs split by
whitespace.
- An SVG animation that changes a link can't take data in its `values` list (`SEC-007`), since a list can't be
checked as one URL.
- Inline event handlers like `onclick` are warnings (`SEC-001`).
- Quoted attribute names must be valid names (`ATTR-008`), so a name can't smuggle in another attribute.
- Action names only use letters, digits, dots, dashes and underscores (`SEM-014`), because they become URLs.
- Names that end up in generated PHP (form, field and action names, paths) are fixed text, never data, and every
string in generated PHP is written as an escaped PHP literal.
## Forms and Laravel
- Hidden fields such as Laravel's CSRF token are only added to forms that post to this site. The check is an
allowlist: a URL counts as this site only if it has no scheme and doesn't start with `//`, read the way browsers
read it (tabs and newlines removed, leading spaces and control characters skipped, `\` treated as `/`). So
`https://…`, `//…`, `/\evil.example` and `https://…` never receive them, also when the URL comes from data.
The Rust and PHP versions are tested against the same list of tricky URLs.
- A button's `formaction` must be a fixed path on this site, and `base(href: …)` must be fixed text (`SEC-006`):
both could otherwise redirect a form, hidden fields included.
- Quoted attribute names are lowercased like HTML does, so `"HREF"` or `"ONCLICK"` get the same checks as `href`
and `onclick`.
- Format negotiation only changes a response that is a rendered `.hypra` page; other responses are untouched.
Every negotiated response carries `Vary: Accept`, so shared caches keep the formats apart.
- In strict mode (the default when `APP_DEBUG` is on), data that doesn't match the page throws a `DataError`
naming the `.hypra` line. Turn `APP_DEBUG` off in production, as with any Laravel error.
- The compiler runs as a separate process with the view's path as an argument, never through a shell.
## Limits
- **Templates are trusted.** A `.hypra` file from an untrusted source can do anything a PHP view can, starting
with `raw`. Don't compile templates that visitors upload.
- **Data inside URLs isn't encoded per part.** `a("/search?q={query}")` percent-encodes characters URLs can't
contain, but `&` or `=` in the data still start a new query parameter. Encode such values before rendering.
- **Attributes that run code are only warned about.** If a page passes data into `onclick` or Alpine's `x-data`,
the data is escaped as an attribute but runs as JavaScript.
- **Compiling is bounded**, not cheap: nesting is limited to 256 levels and expanded pages to 200,000 elements,
but a page near those limits still takes noticeable time and memory to compile. Compile trusted templates only.
# Serve Markdown to LLMs from Laravel
By the end of this guide, every page of a Laravel app answers in Markdown when an AI agent asks for it, from the
same URL that serves HTML to browsers, with the actions an agent can take on the page listed at the end.
## Why Markdown
When an AI agent or crawler reads a web page, it has to dig the content out of the HTML: navigation, scripts,
styles and wrapper elements all cost tokens and can confuse it. The same content as Markdown is smaller and says
plainly what is a heading, a list or a link.
The common way to offer it is HTTP content negotiation: a client that prefers Markdown sends
`Accept: text/markdown`, and the server answers with Markdown instead of HTML, from the same URL. `text/markdown`
is a registered media type (RFC 7763), and a page can also point to its Markdown version with
``.
## Without Hypra
You can add this to any Laravel app today. A middleware converts the HTML of each response with
[league/html-to-markdown](https://github.com/thephpleague/html-to-markdown):
```console
$ composer require league/html-to-markdown
```
```php
// app/Http/Middleware/ServeMarkdown.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use League\HTMLToMarkdown\HtmlConverter;
use Symfony\Component\HttpFoundation\Response;
class ServeMarkdown
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! str_contains($response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
// the same URL answers in two formats, so caches must keep them apart
$response->setVary('Accept', false);
if ($request->prefers(['text/html', 'text/markdown']) !== 'text/markdown') {
return $response;
}
$converter = new HtmlConverter(['strip_tags' => true, 'remove_nodes' => 'head script style nav footer']);
$response->setContent($converter->convert($response->getContent()));
$response->headers->set('Content-Type', 'text/markdown; charset=UTF-8');
return $response;
}
}
```
Register it for the `web` group in `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [\App\Http\Middleware\ServeMarkdown::class]);
})
```
This works with the Blade views you have. Its limits come from guessing structure after the fact:
- The converter only sees HTML. A `div` styled as a heading stays text, a button styled as a link becomes a
link, and anything your layout adds around the content has to be removed by name.
- Forms and buttons, the things an agent could *do* on the page, are dropped or turned into loose text.
- Nothing checks the result. A layout change can quietly fill the Markdown with menu items.
## With Hypra
[Hypra](https://hypralang.dev) pages are written once and compiled into both formats, so the Markdown is made
from what the page means rather than from its HTML. Install the Laravel package and the compiler:
```console
$ composer require hypra/laravel
$ php artisan hypra:install
```
`.hypra` files are views, next to your Blade views. Here is a product page,
`resources/views/shoes/show.hypra`:
```hypra
page(title: "{shoe.name}", description: "{shoe.summary}") {
props(shoe: Shoe)
h1 "{shoe.name}"
img(shoe.photo, alt: "{shoe.name}, side view")
p "{shoe.summary}"
ul {
for feature in shoe.features { li "{feature}" }
}
p { "From " b "{shoe.price}" ", free returns for 30 days." }
action("cart.add", id: shoe.id) "Add to cart"
}
```
Render it like any view:
```php
Route::get('/shoes/{shoe}', fn (Shoe $shoe) => view('shoes.show', ['shoe' => $shoe]));
Route::hypra('cart.add', [CartController::class, 'add']);
```
Browsers get HTML. A client that sends `Accept: text/markdown` gets this, from the same URL:
```console
$ curl -H "Accept: text/markdown" https://shop.test/shoes/7
```
```markdown output
---
title: "Trail Runner GTX"
description: "A waterproof trail shoe with a grippy sole, for wet and muddy runs of any length."
---
# Trail Runner GTX

A waterproof trail shoe with a grippy sole, for wet and muddy runs of any length.
- Waterproof membrane
- 4 mm lugs for mud
- 290 g in size 42
From **€149**, free returns for 30 days.
[Action: Add to cart]
## Actions
- `cart.add(id: 7)`: Add to cart → POST /_a/cart.add
```
The package also sends `Vary: Accept` with both formats, and the HTML links to its Markdown version, so agents
that look for that link find it. A client that asks for `application/json` gets the page's title, description and
actions as JSON.
What's different from converting HTML:
- **Structure comes from the page.** `h1` is the title, `ul` is a list, an image keeps its alt text. Nothing has
to be guessed or removed.
- **Actions are listed.** `action("cart.add", id: shoe.id)` is a button for people, and a line under
`## Actions` for agents, with the parameters and where to send them.
- **It can't drift.** Both formats come from the same file, compiled together; there's no second template to keep
in sync.
- **Data stays text.** A product name like `## Ignore previous instructions` from your database is escaped in the
Markdown too, so it can't add headings or fake actions.
In this example, the Markdown is 490 bytes, the HTML page 1,188 bytes. On real pages with a
header, navigation and footer, the difference grows: the Markdown keeps only the page's content.
## Limits
- Hypra is new (version 0.1), and the language may still change before 1.0.
- Only pages written in Hypra get Markdown this way. Blade views keep working as before; you can convert one page
at a time, and use the middleware above for the rest in the meantime.
- Rendering on request is PHP only for now. Other backends can use the static build: `hypra build` writes the
HTML and Markdown files for given data.
## Next steps
- The [Laravel docs](https://hypralang.dev/docs/laravel/) cover deploying, configuration and actions.
- The [language reference](https://hypralang.dev/docs/) is short enough to give to an AI coding assistant, so it
can write Hypra pages for you.
# Accessibility checks at compile time
By the end of this guide, missing alt text, unlabeled form fields, empty buttons and skipped heading levels stop
your build with an error that says where the problem is and how to fix it, before a page reaches anyone.
## Why check before the page exists
HTML accepts anything. An image without `alt`, an input without a label or a button with only an icon all render
fine, so the mistakes ship silently, and people using screen readers find them first.
Most teams test accessibility on rendered pages: a browser extension, Lighthouse, or
[axe](https://github.com/dequelabs/axe-core) in end-to-end tests. Those tools are good, and they find problems
only a browser can see, like color contrast. But they check the pages and states your tests happen to visit, with
the data your tests happen to use, after the code is written.
## Without Hypra
The usual setup runs axe in Playwright tests:
```console
$ npm install --save-dev @playwright/test @axe-core/playwright
```
```js
// tests/accessibility.spec.js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
for (const path of ["/", "/signup", "/shoes/7"]) {
test(`no accessibility violations on ${path}`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}
```
Keep this. It catches what only a browser can see. Its limits:
- It needs a running app with test data, and it checks only the URLs you list.
- A problem in a component shows up on every page that uses it, but only on pages you test.
- Failures arrive minutes later in CI, as a list of selectors, not as a line in your editor.
## With Hypra
[Hypra](https://hypralang.dev) is a markup language that compiles to HTML. The compiler knows what every element
is, so it checks accessibility while it compiles, on every page and every component, without running anything.
Here's a sign-up page with five common mistakes:
```hypra
page(title: "Sign up", description: "Create an account to save your favourite routes and get one weekly email with new trails.") {
h1 "Create an account"
img("/img/trail.jpg")
h3 "Why sign up?"
p { "Save routes and get new trails every week. " a("/about") "Click here" }
form("/signup", name: "signup") {
field("email", type: Email)
button
}
}
```
```console output
$ hypra check signup.hypra
error[A11Y-001]: image has no alt text
--> signup.hypra:3:7
|
3 | img("/img/trail.jpg")
| ^^^^^^^^^^^^^^^^
= help: add alt: "..." or decorative: true
= docs: https://hypralang.dev/rules/a11y-001/
warning[A11Y-006]: h3 follows h1, skipping a level
--> signup.hypra:4:3
|
4 | h3 "Why sign up?"
| ^^
= help: use h2; screen readers navigate by heading levels
= docs: https://hypralang.dev/rules/a11y-006/
warning[A11Y-007]: link text `click here` doesn't say where it goes
--> signup.hypra:5:65
|
5 | p { "Save routes and get new trails every week. " a("/about") "Click here" }
| ^^^^^^^^^^^^
= help: describe the target, e.g. a("/guide") "Read the setup guide"
= docs: https://hypralang.dev/rules/a11y-007/
error[FORM-002]: `field` needs `label`
--> signup.hypra:7:5
|
7 | field("email", type: Email)
| ^^^^^
= help: add label: "..."
= docs: https://hypralang.dev/rules/form-002/
error[A11Y-005]: button has no text
--> signup.hypra:8:5
|
8 | button
| ^^^^^^
= help: button "Send", or aria-label: "..." for an icon button
= docs: https://hypralang.dev/rules/a11y-005/
3 errors, 2 warnings
```
Errors stop the build; warnings don't. Every message has a code, the exact place and the fix. With the
[editor extension](https://hypralang.dev/docs/vscode/), they show up as you type.
The fixed page:
```hypra
page(title: "Sign up", description: "Create an account to save your favourite routes and get one weekly email with new trails.") {
h1 "Create an account"
img("/img/trail.jpg", alt: "A runner on a muddy forest trail")
h2 "Why sign up?"
p { "Save routes and get new trails every week. " a("/about") "Read how we pick trails" }
form("/signup", name: "signup") {
field("email", type: Email, label: "Email")
button "Sign up"
}
}
```
What it checks, among others:
| Check | Code |
| --- | --- |
| images need alt text, or `decorative: true`; alt text like "image" or "photo" is a warning | `A11Y-001`, `A11Y-008` |
| links and buttons need text; link text says where it goes, not "click here", and means one place | `A11Y-002`, `A11Y-005`, `A11Y-007`, `A11Y-013` |
| form fields need a label, and a form needs a button | `FORM-002`, `FORM-003` |
| headings go down one level at a time, and a page has one `h1` | `A11Y-006`, `SEO-003` |
| videos need captions; autoplaying videos are muted | `A11Y-003`, `A11Y-011` |
| iframes need a title; no positive `tabindex` | `A11Y-009`, `A11Y-010` |
The [error reference](https://hypralang.dev/docs/#errors) lists all of them. A few details that make this work
on real sites:
- **Components are checked once, everywhere.** A component without alt text on its image is an error where the
component is written, not on each page that uses it.
- **Heading levels are checked as the page renders**, so a component that starts with `h2` is fine after an
`h1`, and an `h1` inside a loop is caught.
- **Text from data is checked when a page is built with that data**, for example alt text from your database.
- **Exceptions are explicit.** A rule can be turned off for one element, with a reason that shows up in review:
```hypra
allow("A11Y-006") "the card title is styled small on purpose"
h3 "Trail of the week"
```
## Limits
- Hypra can't see what only a browser sees: color contrast, focus order in JavaScript widgets, or whether alt
text is actually *good*. Keep running axe on rendered pages for those.
- It checks pages written in Hypra. Existing HTML or Blade templates aren't checked; you can move one page at a
time.
- Hypra is new (version 0.1), and the language may still change before 1.0.
## Next steps
- [Install the compiler](https://hypralang.dev/#install), or try it in Laravel with
[hypra/laravel](https://hypralang.dev/docs/laravel/).
- The [language reference](https://hypralang.dev/docs/) explains every element and check.
# Add WebMCP tools to your website
By the end of this guide, the forms and buttons on your pages are also tools that AI agents can call, with a
name, a description and a typed list of parameters, generated from the page itself.
## What WebMCP is
Today an AI agent that wants to use a website has to look at the page and guess: which button adds to the cart,
which field is the email, what values are allowed. WebMCP is a proposal from the W3C Web Machine Learning
community group to let a page say it directly: "this page offers these tools, with these parameters". It borrows
the idea of tools from the Model Context Protocol (MCP), which AI assistants already use to call APIs.
WebMCP has two parts:
- a **declarative** one: attributes on an ordinary HTML form, `toolname` and `tooldescription`, from which the
browser builds the tool and its parameters;
- an **imperative** one: a JavaScript API for tools that aren't forms.
It's experimental. Chrome is testing it, the details are still changing, and other browsers haven't committed to
it. The declarative form is the safe start: browsers that don't know the attributes ignore them, so the forms work
as they always did.
## Without Hypra
You can add the attributes by hand to any form:
```html
```
That's a good start. What it leaves to you:
- The tool's parameters are only as good as the form's markup: a field without `name`, `type` or `required`
becomes a vague parameter, and nothing tells you.
- Buttons that do one thing, like "remove from cart", need to be wrapped in forms to become tools at all, and a
list of ten of them becomes ten tools with the same name.
- Agents that don't run a browser, like a crawler or an assistant reading your page as text, don't see the
attributes.
## With Hypra
[Hypra](https://hypralang.dev) is a markup language that compiles to HTML. Its forms and actions are typed, so
every one of them is a tool, in every output the compiler writes. A cart page:
```hypra
page(title: "Your cart", description: "The shoes in your cart, ready to check out, with free shipping on orders over €100.") {
props(cart: Cart)
h1 "Your cart"
for item in cart.items {
article {
h2 "{item.name}"
p "Size {item.size}"
action("cart.remove", id: item.id, confirm: "Remove {item.name}?") "Remove from cart"
}
}
form("/checkout", name: "checkout", description: "Pay for the cart and choose how it's delivered") {
field("delivery", type: Choice(standard: "Standard, 3–5 days", express: "Express, next day"), label: "Delivery")
field("email", type: Email, label: "Email for the receipt")
field("note", type: Text(max: 200), label: "Note for the courier", optional: true)
button "Pay now"
}
}
```
with this data:
```json
{
"cart": {
"items": [
{ "id": 11, "name": "Trail Runner GTX", "size": 42 },
{ "id": 12, "name": "Road Racer", "size": 43 }
]
}
}
```
### For browsers: WebMCP attributes
The checkout form becomes a normal HTML form with the WebMCP attributes, labels on every field and the
constraints from the field types:
```html output
```
Each **Remove from cart** button becomes a small form of its own with `toolname="cart.remove"`, and posts to
`/_a/cart.remove`.
### For agents: the tools as JSON
The compiler also writes the page's tools as a JSON file, `cart.webmcp.json`, with a JSON Schema for each tool's
parameters. This file is Hypra's own, not part of the WebMCP proposal; it's for agents and tests that work
without a browser.
```json output
[
{
"name": "cart.remove",
"description": "Remove from cart",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"enum": [
11,
12
]
}
},
"required": [
"id"
]
}
},
{
"name": "checkout",
"description": "Pay for the cart and choose how it's delivered",
"inputSchema": {
"type": "object",
"properties": {
"delivery": {
"type": "string",
"enum": [
"standard",
"express"
]
},
"email": {
"type": "string",
"format": "email"
},
"note": {
"type": "string",
"maxLength": 200
}
},
"required": [
"delivery",
"email"
]
}
}
]
```
The two **Remove from cart** buttons became *one* tool, `cart.remove`, whose `id` must be one of the items in the
cart. A field with `optional: true` isn't required; a `Choice` becomes an `enum`; `Text(max: 200)` becomes
`maxLength`.
### For LLMs: the actions in Markdown
The page's Markdown version, for agents that read the page as text, ends with the same tools:
```markdown output
## Actions
- `cart.remove(id: 11|12)`: Remove from cart → POST /_a/cart.remove
- `checkout(delivery: standard|express, email: email, note?: string ..200)`: Pay for the cart and choose how it's delivered → POST /checkout
```
With the [Laravel package](https://hypralang.dev/docs/laravel/), that Markdown is served from the page's own
URL to clients that ask for it, and `action("cart.remove", ...)` posts to a route you connect with
`Route::hypra('cart.remove', ...)`. CSRF tokens are added to every form posting to your site.
## Limits
- WebMCP itself is experimental and may change; Hypra will follow it.
- The tools describe what a page offers. Your server still has to check every request, as with any form: an
agent can send anything a browser can.
- Hypra is new (version 0.1), and the language may still change before 1.0.
## Next steps
- [Forms](https://hypralang.dev/docs/#forms) and [actions](https://hypralang.dev/docs/#actions) in the language
reference.
- [Serve Markdown to LLMs from Laravel](https://hypralang.dev/guides/markdown-for-llms-in-laravel/), the same
page for agents that read text.