GizStack
View .mdOpen in ChatGPTOpen in Claude

GHTML

A .ghtml file is one page, one component or one library of functions, written as HTML with plain Gizlang in braces. tools/cli/ghtml.giz turns it into a Gizlang module with the same line numbers, so a compile error names the line of the .ghtml file. A program imports a .ghtml file as it imports a .giz file.

The page says what it shows, what it keeps and what it does. The compiler finds the rest: what is state, what is derived, what each control changes, and which condition is a database query. There are no $ functions, no stores and no bind:.

1. A page

html
<script>
  import { Todo } from "./model.giz";

  let filter = "all";                       // let is state
  const todos = table<Todo>("id desc");     // a table of the page: its type and its order

  function clear(done: bool) { for (const t of todos.where(t => t.done === done)) { todos.remove(t); } }
</script>

<h1>Todos ({todos.count(t => !t.done)} open)</h1>

<form create={todos}>
  <input name="text" placeholder="What next?"/>
  <button>Add</button>
</form>

<select value={filter}>
  <option value="all">All</option>
  <option value="open">Open</option>
  <option value="done">Done</option>
</select>

<ul>
  <li each={t of todos.where(t => filter === "all" || t.done === (filter === "done"))} key={t.id} class:done={t.done}>
    <input type="checkbox" checked={t.done}/> {t.text}
    <button onclick={todos.remove(t)}>Remove</button>
  </li>
</ul>
<p if={todos.length === 0}>Nothing to do.</p>

<style> .done { text-decoration: line-through; } </style>

create={todos} adds a row whose fields are the named inputs. value={filter} is two-way, and checked={t.done} writes the row. The model is a normal @Model.Table type (tests/web/todo/model.giz). Its @Model.MaxLength and @Model.Min are the rules of the form: a refusal is answered with status 422 and the reason in <output role="alert">.

2. Three kinds of file

KindNameModule
ComponentCapital first: Card.ghtmlexport type Props from its prop lines, and export function Card(_p: Props, children): Html
LibraryOnly <template fn> at the top level, any nameOne function for each template; export makes it public
RouteLower case, [ or _ first: index.ghtml, [slug].ghtml, _layout.ghtmlexport function page(_p, children): Html and export const style; see §7

A file has at most one <script>, before the markup. Its statements are:

StatementMeaning
import …, type …, export type …Module code.
prop name: Type;A prop of a component. prop size: string = "m"; has a default (a literal or an expression of the file) that a caller may leave out, in any package: the prop is an optional field of Props, and the component fills the default.
let name = value;State (a string, i64, f64 or bool). Route files only. @url after the value keeps it in the address (?name=value), @local in the browser, @cookie in a cookie; see §4.
const t = table<T>("order");A table of the page: T is a @Model.Table type, the string is its ORDER BY. Route files only.
const d = expr;Derived when expr uses a name of the page (it is computed again in each render), else a module constant.
function f(…) { … }A function. One that assigns state or writes a table is an action: see §4.

Any other statement is an error that says it belongs in a function. A component and a layout keep no state, table or event: the page keeps them and passes props. me, params, request and error are names that a route file has.

3. Markup

  • {expr} is escaped text; html={expr} on <template> is raw Html.
  • Directives are attributes: if={c}, else-if={c}, else, each={x of xs} or each={x, i of xs} with key={k}, class:name={c}. On one element each runs first and if runs for each item.
  • <template> groups without an element: <template if={c}>, <template match={v}><template case={Pattern(x)}>…, <template const={x = expr}/>, <template html={expr}/>, and <template fn="name(a: T)" export>…</template> (a function of the file).
  • A capital name is a component: <Card title="x">…</Card>. <slot/> is the children. A child with slot="title" (<h2 slot="title">…</h2>) is the content of <slot name="title"/> in the component, and <slot/> then holds the rest. A component needs no import: Card.ghtml is found in the folder of the file or its components/ folder, then in each folder above, up to the root of the package. An import in the script wins.
  • <meta> and <link> tags in a page or a layout go to the head of the document, once each (name, property or http-equiv is the key; the innermost page wins), and the kernel swaps them when it opens another page.
  • <title> in a page is the title of the document, once, with the page's values (<title>Todos ({n} open)</title>); without it the first <h1> is the title.
  • Not ghtml, each with a message that names the line: {#if} blocks, bind:, use:, $props(), $state(), $effect.

4. Events, state and binding

A page is a function of its state. An event is a button of a form that carries the state; the server runs it and answers the new page, and the kernel morphs it in. Without JavaScript the same page is a plain form post.

  • onclick goes on a <button>; onsubmit and create={table} go on a <form>. The value is a call (todos.remove(t)), a function name or statements separated by ;. A button inside an each knows its row.
  • A function that assigns state or writes a table (or calls one) gets the names it uses as extra parameters and returns nothing. A table write returns a reason when the Gate or the model refuses; the function stops there and the page answers 422.
  • value or checked on an input, select or textarea is two-way when it is a state name (value={filter}) or a field of the row of an each over a table (checked={t.done}, needs key). Such a control is named by the page; it has no name. A state is sent on change; a field is written to its row on change, through the same Gate as an action.
  • State travels in the hidden form #_g (_s.name), so a server keeps no session.
  • let q = "" @url; is state that the address holds: /?q=x shows the page with q set, and a change of q is a step of the browser history. A state at its initial value is not in the address. let tab = "a" @local; is state the browser keeps for the next visit (localStorage, by address): the first render has the initial value, then the kernel restores the stored one. let theme = "light" @cookie; is state in the cookie g.theme (one year, Path=/, for every page that names it): the server reads it on a visit that the address does not set, so the first render already shows the person's choice. Without JavaScript none of the three is kept.
  • A bound control of a row sends what it showed (_o.…) with its new value. The change is one UPDATE of that field; if another person changed the field meanwhile the change is refused (a 409 conflict): the page says "That Todo changed meanwhile. Read it again." and shows the new value. A control that shows what it posts did not change and is not written.

5. Tables and queries

table<T>(order) reads a @Model.Table type through the actor of the request (the Gate): the person sees and writes only what the grants allow. tbl.rows() is every row (each={t of todos}), tbl.length and tbl.count() the total.

tbl.where(row => cond) and tbl.count(row => cond) are queries. The condition is read at build time into SQL with typed parameters; the page values enter as parameters, never as text. A condition has row.field compared with a value of the page (===, !==, <, <=, >, >=), &&, ||, !, a boolean field, and row.field.includes(v), .startsWith(v), .endsWith(v). Anything else is a build error that names the line; a silent scan of rows in memory is a slow path nobody sees.

A table is live: the page carries data-sub, the page's live channel (one WebSocket at /_s) follows the table, and a change by anyone reaches the page as the least that changed. The build puts a range (<!--g:ID-->…<!--/g:ID-->) around each part whose own code reads a table: a hole, an element with a reading attribute, an if, a match, an each. Each poll of the channel (with the _s.* state of the page and its address) runs the page again for the changed table and renders nothing but those ranges, so a count, a title and an empty note change with the row and nothing else is rendered or sent. An each over a table whose body is one element that reads no other table is a list: only the rows of the changed keys are read (under the Gate, the condition of the list and the state of the page), and the answer is those rows with their place and the keys that left. A list with a limit, or whose rows read another table, is one range. The browser keeps the nodes that match (focus, selection, scroll) and changes the rest. A page that cannot be told (the notice was lost, or a read failed) is fetched again. GizDB is the default store; Pages.hosted(sys, db, space, shapes, actor) takes any Data database. Without one (Pages.standalone) a table is empty and a write is refused.

tbl.grid() is the table as HTML: a <giz-grid> around a <table> with a head row of the field labels (data-field the name) and a row for each row the person may read (data-k its key), with the text of each cell escaped. tbl.editableGrid() is the same table whose cells of the fields that are not the key are bound controls, as value={t.field} in an each: a change updates that field through the Gate, and a field that another person changed meanwhile is a conflict. Without script the editable grid is a form; with the giz-grid enhancement (components) RevoGrid Pro shows the table (sort, filter, range selection, copy, paste, fill, export), and an edit changes the cell's control. A page with a design writes its own markup.

A page follows its tables only for a client that runs the kernel (a browser: Sec-Fetch-Mode or x-giz on the request). A crawler, a curl or a link preview gets the page without a watch, so it holds nothing in the database. An engine watch is a cursor of one reader, a connection holds 128 of them and a view that is not polled keeps its own for the 60 s lease; a page that finds no free watch is served static until it is loaded again. A watch shared by pages with one query needs a feed in Data and is not built.

tbl.where(row => cond, { order: "text desc", limit: 5 }) reads the first rows of that order (order is a literal of field names, limit a value of the page). tbl.removeWhere(row => cond) deletes every row that the condition selects, as the person may, and stops at the first refusal.

<form create={tbl}> posts the fields named by its inputs (name="text" is the field text of T); the key is made by the page and sorts by time.

A refused write keeps its own status: 422 for what the model refuses, 409 for a change that lost to another, 403 and 404 for the Gate. A read that the database failed is not an empty list: the request answers 500 with the table and the reason. server-timing on each page answer gives the time to render and the reads and writes it made, for the network panel of the browser. The live channel of a page tells a person of the changes of rows that the person may read and of the removal of rows, and of nothing else (a row that the person may not read is never sent); it needs the tables of the host (Pages.hosted(…, shapes, …)).

Tools for an agent. The tables of the host are also tools: GET /_tools lists list_T, get_T, add_T, update_T and remove_T for each table T, each with a JSON Schema of its input from the same Shape (so a limit of a field is the same limit), and POST /_tools/NAME with a JSON body runs one. A tool runs as the person of the request through the Gate, reads at most the rows a page reads, and refuses with the same statuses and a JSON reason. A call must be application/json, so a form on another site cannot make it.

6. Style

<style> is scoped: the elements of the file get a class g-HASH, and the module exports the scoped CSS as style.

Tailwind v4. web/tailwind.giz runs the official standalone compiler (no Node) over the files of the site and writes the style sheet of only the classes they use. The version is pinned in the module with the SHA-256 of each platform's executable: it is fetched once into $GIZLANG_CACHE/tailwind/v4.3.3/, checked, and run from there (GIZLANG_TAILWIND names an executable of your own). The sheet is kept by the content of the files, the tokens and the version, so an unchanged site never starts the compiler.

gizlang
import {System} from "gizlang:system";
import * as Web from "gizweb";
import * as Pages from "gizweb:pages";
import * as Tailwind from "gizweb:tailwind";
import * as Tokens from "gizweb:tokens";

/** The pages of the site. Each server thread makes its own. */
function site(): Pages.Site { return Pages.site([Pages.markdown("/", "# Home")], [], []); }

/** Serves the site on port 8080, with the style sheet of the Tailwind classes that its files use. */
export function serve(sys: System, brand: string): i64 {
    const tokens = Tokens.css(Web.uiTheme().concat([Tokens.Theme("brand", brand)]), true)!;   // DTCG JSON -> @theme
    const css = Tailwind.stylesheet(sys, ["pages"], tokens, "")!;
    return Web.listen(sys, 8080, Pages.shell(css), Web.serving(), (own: System) => Pages.handler(site(), Pages.shell(css), Pages.standalone(own)));
}
  • class="p-4 text-brand-500" and class:font-bold={bold} both work: the compiler cannot see a class: directive, so the module lists the names after class: in the .ghtml files as @source inline(…).
  • Pages.shell(css) puts the built-in design system in the components layer, so a utility on an element beats a component's default, and the scoped <style> of a component beats a utility. Pages.shell("") is the design system alone.
  • dark: follows the colour scheme of the browser, and data-theme="dark" or "light" on the root forces it.

Design tokens. web/tokens.giz reads tokens in the DTCG format and writes CSS custom properties: color.brand.500 is --color-brand-500, so under Tailwind it gives bg-brand-500; an alias {color.brand.500} is var(--color-brand-500). With Tailwind the base theme is its @theme, else :root. A second file replaces tokens: dark applies by the colour scheme and by data-theme="dark", any other name (a brand, a tenant) by data-theme="NAME". A token that an overlay names and the base does not, a value of the wrong shape and JSON that does not parse are errors that name the file and the token.

The default theme. web/theme/base.json and its dark.json overlay (Web.uiTheme()) are the tokens of the built-in components: colors (bg, surface, hover, fg, muted, line, line-strong, accent, on-accent, link and the four states), font, text (12 to 28 px), radius, shadow, spacing (4 px) and control.h (32 px). ui.css uses only these, so a site restyles every component with an overlay of a few tokens and never copies a rule. Web.uiStyle() is the theme as :root and the components; with Tailwind, the theme goes into @theme and Pages.shell(css) adds the components alone (Web.uiComponents()).

7. Pages: a folder of files is a site

A program imports site from the module .routes of a folder; the CLI writes that module from the files below the folder:

pages/
  _layout.ghtml        wraps every page below it
  index.ghtml          /
  about.md             /about        (Markdown, in the layout)
  raw.html             /raw          (sent as it is)
  404.ghtml            the page of an unknown address, status 404
  blog/
    _layout.ghtml      wraps the pages of /blog, inside the root layout
    index.ghtml        /blog
    [slug].ghtml       /blog/{slug}  (params.get("slug"))
  Card.ghtml           a component: no page
import {System} from "gizlang:system";
import * as Web from "gizweb";
import * as Pages from "gizweb:pages";
import {site} from "./pages/.routes";

function main(sys: System): i64 {
    return Web.listen(sys, 8080, Pages.shell(""), Web.serving(), (own: System) => Pages.handler(site(), Pages.shell(""), Pages.standalone(own)));
}

A route file has params (the [name] values), request (the Http.Request) and me (the actor) as names; <slot/> in a layout is the page inside it. A page is the body of the page shell (Web.listen gives the design tokens, the kernel, ETags and HTTPS); the title is its first <h1>, and the scoped styles of the page and its layouts come first. A page that starts with <!doctype is sent as it is. Two files with one address, or a folder without a page, stop the build with a message that names the files. A name that starts with . or _ (except _layout.ghtml), a capital letter first, and other file types are no page.

8. Compiled by the CLI

Every command that builds a program (run, live, check, build, test) reads .ghtml imports. A library (web/) may hold .ghtml files and import them from each other.

9. Languages

A site in several languages is written in English, with a catalog locales/TAG.json beside its pages for each other language (MULTILINGUAL-ARCHITECTURE). The text a person reads (a run of text, holes and inline elements, and attributes such as title, alt, placeholder and aria-label) is a message keyed by itself in MessageFormat 2:

<p>Read the <a href="/terms">terms</a>, {user.name}.</p>   →   "Read the {#a}terms{/a}, {$name}."
  • A page has the ambient locale (the request's language, a Locale of gizlang:locale) and languages (the site's). A component translates its text when it declares prop locale: Locale;, and a caller passes it: <Card {locale}/>.
  • <template plural={n}> with <template case="one">, case="other" and exact counts (case="=0") is one message whose translation names the plural categories of its own language.
  • translate="no" and pre, code, kbd, samp, var and textarea keep their text.
  • giz i18n pages --add ko starts a catalog, giz i18n pages adds the new messages to each catalog, and --check fails CI on a catalog that is behind. A translation that drops an element or names a missing placeholder stops the build at its line.
  • /ko/… is the Korean address of a page, ?hl=ko the person's choice, and a browser opens its own language; <LanguagePicker {locale} {languages} path={request.path}/> links the languages. A file with no catalog compiles as it did.