Skip to content

Pages, layouts, components and fields in vzk

Use this guide for the structure of a vzk site: what a page, a layout and a component are, the fields an owner edits apart from the markup, links, and pictures. Read vzk/authoring first for the editing loop.

A page is a file under pages/, and its path is its address: pages/prices.vzk is /prices, and pages/index.vzk is /. Each part of the path is lower-case letters, digits, - and _. One address is one page, so a site holds pages/prices.vzk or pages/prices/index.vzk, never both.

What a page says about itself is written in its server script and its <head>:

Written Means
<head> with a <title> and a description the page’s own head (vzk/authoring, “The head”)
export const name = "Prices" the page’s name, which site.pages lists it under
export const fields = { ... } the page’s named fields (below)
export const layout = "plain" wraps the page in layouts/plain.vzk; null wraps it in none

A page whose file is pages/<path>/[slug].vzk is drawn once for each row of a table (vzk/tables).

Every top-level element of a page or of a layout is a block, and has an id:

<section id="hero" note="The first thing a visitor sees.">
<h1>Bread, every morning</h1>
</section>

outline lists a file’s blocks, and read_block, write_block and edit_block find one by its address, pages/index.vzk#hero. A top-level element with no id is a warning, and has no address. note says in the author’s words what a block is for: it is shown in the outline and never sent to a visitor. An element deeper in a file that carries note is a block of its own. An id on an element under :for holds a part of the item, as in id="faq-{item.slug}".

layouts/site.vzk wraps every page. The page is drawn where the layout says <slot />:

<script server>
const links = page.fields?.links ?? []
</script>
<head>
<meta name="description" content={page.description ?? "Dan Okafor, plumber in South Manchester: leaks, boilers, bathrooms and blocked drains."}>
</head>
<div id="navbar" class="sticky top-0 z-30 bg-surface border-b border-ink/8">
<p class="font-heading font-strong text-brand text-ink">{site.fields.brand}</p>
<a :for={link in links} href={link.href} class="link-quiet">{link.label}</a>
<a href="tel:{site.fields.dial}" class="btn-compact">{site.fields.phone}</a>
</div>
<slot />
<div id="footer" class="border-t border-ink/8">
<p class="text-[13px] text-muted">© {today().year} Dan Okafor Plumbing, Manchester</p>
</div>
  • A layout reads the page it wraps: page.title and page.description from the page’s head, page.path, and the page’s fields as page.fields.<name>. A field the page does not declare is a missing value there, so read one with ?. and ?? as above.
  • A top-level element of a page that says slot="aside" fills the layout’s <slot name="aside" />. What is written inside a <slot> is drawn when nothing was given for it.
  • A change to a layout changes every page it wraps once the branch is merged, so look at a few of them in the preview before asking the owner.

A component is a file directly in components/, used by its name, which is a capital letter and then letters and digits. Its inputs are the names it takes out of props:

<script server>
const { alt, picture = null } = props
</script>
<div class="frame">
<img :if={picture} src={picture} alt={alt} class="size-full object-cover">
<div :else class="frame-empty"><span class="text-xs text-muted px-4 text-center">{alt}</span></div>
</div>
<Frame class="aspect-[4/3] rounded-surface" alt="Dan at work under a kitchen sink" />
  • An attribute on a use is an input when the component declares it: alt="A text" is that text, picture={value} is the value, and a bare name is true. An input with a default (picture = null) may be left out.
  • class, id, style, hidden, data-* and aria-* that the component does not declare land on its root element, so a component given one has exactly one root element. Any other attribute is an error that names the nearest input.
  • <slot /> in a component draws what was written between its tags, and <slot name="title" /> draws the children that say slot="title".
  • :if and :for work on a component’s tag as on an element.
  • A component reads its own names and what it was given, never its caller’s, and a component that uses itself is an error.

A named field is a value an owner edits apart from the markup: the words on a button, a price, a list of links. A file names its own in its server script, and what a field is comes from how its value is written:

<script server>
export const fields = {
/** The words on the button. */
cta: "Order a loaf",
price: 320,
open: true,
more: "/bread",
tone: "calm" as "calm" | "bold",
loaves: [
{ name: "Rye", price: 4 },
{ name: "Wheat", price: 3 },
],
}
</script>
<h1 class={{ calm: "soft", bold: "loud" }[fields.tone]}>{fields.cta}</h1>
<p :if={fields.open}>From {fields.price / 100} today</p>
<ul><li :for={loaf in fields.loaves}>{loaf.name}: {loaf.price}</li></ul>
<a href={fields.more}>More bread</a>
  • A text, a number and true or false are fields of those kinds. A text that is one address ("/bread", "https://example.com/") is a link. assets["oven"] is a picture. "calm" as "calm" | "bold" is a choice among its texts. A list of records that all hold the same names is a list.
  • An empty list says what its items hold after as: loaves: [] as { name: text, price: number }[], or kinds: [] as text[]. A list emptied by an edit is written that way, so it keeps its columns and the owner’s panel still adds to it. A bare [] says nothing, and takes records of texts, numbers and booleans.
  • The doc comment above a field (/** ... */) is its label, what a person calls it.
  • A file reads its own fields as fields.cta. A layout and a component read the fields of the page they are drawn for as page.fields.cta.
  • site.json holds the fields every file reads, as site.fields.<name>:
{
"edition": 1,
"language": "visitor",
"fields": {
"brand": "Dan Okafor Plumbing",
"phone": "0161 496 0732",
"dial": "+44 161 496 0732"
}
}

Name a field for what it holds (cta, hours, links), never with a counter. Keep markup in the file and plain words in fields: a field’s value is escaped where it is written.

Name What
props in a component, the inputs it was given
fields.<name>, page.fields.<name> the file’s own fields, and those of the page being drawn
site.fields.<name> the fields of site.json
site.name the site’s name
site.pages the site’s named pages, each as { name, path, url, current }
page.path, page.title, page.description the address asked for, and what the page’s head declares
page.locale, page.languages the language of the page, and each language it is served in (vzk/languages)
params.slug on a [slug] page, that part of the address
query.<name> what the address asks for after ?, a visitor’s text
tables.<handle> a table the public may read (vzk/tables)
assets["<key>"], assets.has("<key>") a picture among the site’s files

A visitor’s text (query, params, a row of a table visitors write to) is shown as text and is refused in an address, a class, a style or an id.

There are two ways. A page may hand the layout its own links as a field, which suits a site whose links differ from page to page:

<script server>
export const fields = {
links: [
{ label: "Prices", href: "/prices" },
{ label: "Areas", href: "#areas" },
{ label: "Call me back", href: "#contact" },
],
}
</script>

Or the layout draws site.pages, which holds every page that has a fixed address and says export const name, in the order of its address:

<nav aria-label="Main">
<a :for={p in site.pages} href={p.url} class={[p.current && "on"]}>{p.name}</a>
</nav>

Write p.url into href and compare p.path, never p.name. A [slug] page and a page with no name are not in the list.

list_files and upload_file answer each file’s key. A file reads the picture as assets["<key>"], and asks first where the site may not have it yet:

<img :if={assets.has("hero")} src={assets["hero"]} alt="">
  • A key is written out, or is the author’s own value. A visitor’s text never chooses a file.
  • assets["<key>"] for a key the site has no picture under is a missing value, which is why a place for a picture the site may not have yet asks with assets.has first.
  • Every <img> says an alt: what the picture shows, or alt="" for decoration.

The reference that list_files answers for a file is that expression, ready to write in a page.

today() is the day the page is asked for, so a year that must move is {today().year}. formatDate(date, "d MMMM yyyy") writes a date, with the names of months in the language of the page.

{markdown(text)} draws a Markdown text as markup. It is written in one place only, as the content of an element, and it is the one value that is not escaped. excerpt(text, 30) gives the first words of such a text as plain text.