:::meta
description: How to give Luccet's element names meaning, look and behaviour: the schema and every schema field, window.LUCCET.schema, add-ons, luccet.define, custom transforms, styling, and labels in other languages.
:::
::site-nav
# Vocabulary and behaviour {#top}
::toc
Luccet's core knows how a name is *written*, as in `:::theorem`, `::toc` or `:kbd[Ctrl]`, but not what it *means*. A **schema** gives names their meaning: the element, a label such as "Theorem 2", a counter, how a reference reads, a title slot, an end mark, a table of contents. The schema's `css` and `dom` fields, `luccet.style` and `luccet.define` give them a look and a behaviour.
This page covers all of it, from a note that declares three names in its head to an add-on shared by a hundred notes.
The live examples run with **no schema** unless they say `schema=demo`. The demo schema is the small vocabulary listed at the [end of this page](#demo-schema).
## Why names need a schema {#why}
The core gives a name no meaning. `:::theorem` is `div.t-block.theorem` until a schema says otherwise: no label, no number, and a reference to it reads its title.
```luccet {.live}
:::theorem[Rank–nullity]{#rn}
$\dim V = \dim \ker T + \dim \operatorname{im} T$.
:::
See @rn.
```
The same note with the demo schema, which declares `theorem`:
```luccet {.live schema=demo}
:::theorem[Rank–nullity]{#rn}
$\dim V = \dim \ker T + \dim \operatorname{im} T$.
:::
See @rn.
```
The difference is one entry:
```js
theorem: { tag: 'section', counter: 'theorem', label: 'Theorem', ref: '{label} {n}' }
```
**Why the core declares nothing.** Every subject needs its own names: a lecture has theorems, a lab notebook has protocols, a cookbook has steps. Labels differ from one language to the next. And a note must render from one script line, without a standard library to keep in step with it. So a note, or an add-on it loads, declares exactly the names it uses.
A name without an entry still renders. It keeps its class and its `data-t`, so CSS and behaviours can find it, and it becomes an element on its own when the name is one:
- an allowlisted element: `:::aside` is an ``;
- a custom element: `:::tide-panel` is a ``.
[Elements and output](elements.html#names) has the whole resolution.
:::table[What a schema entry adds]{#tbl-adds}
| | Without an entry | With an entry |
|---|---|---|
| Element | `div` or `span`, or the name itself when it is an element | `tag` |
| Classes and attributes | `t-block`, the name, the author's | `class`, `attrs` |
| Label | none | `label` |
| Number | none | `counter` |
| Reference text | the title, else the id | the label and number, through `ref` |
| Title element | written titles only | `title`, `titleDefault`, and a slot for the label |
| End mark | none | `end` |
| Body | blocks, or verbatim with `{raw}` | `body` |
| Generated content | none | `transform: 'toc'` |
| Look | the base stylesheet | `css` |
| Behaviour | `luccet.define` only | `dom` |
:::
**Where vocabulary comes from.**
1. **The note's own head:** `window.LUCCET.schema`, in a script before the luccet.js line ([below](#window-luccet)). Best for a note that stands alone.
2. **An add-on** listed in `data-use`, which calls `luccet.block`, `luccet.leaf` and `luccet.inline` ([Add-ons](#addons)). Best for vocabulary shared by several notes.
3. **Node**, for scripts that call the engine: the same three functions, or a `schema` option on each call ([JavaScript API](api.html#registry)).
When a note and an add-on declare the same name, the note's entry wins.
## The schema {#schema}
### The registry {#the-registry}
A schema is plain data: an object with three levels, each mapping a name to an entry.
```js
{
block: { theorem: { tag: 'section', counter: 'theorem', label: 'Theorem' } },
leaf: { toc: { tag: 'nav', transform: 'toc' } },
inline: { key: { tag: 'kbd' } },
}
```
In the browser this object is `luccet.schema`, the registry. Three functions write it:
:::syntax
```js
luccet.block(name, spec) // :::name … :::
luccet.leaf(name, spec) // ::name[…]{…}
luccet.inline(name, spec) // :name[…]{…}
```
:::
- **The entry.** Each function stores a shallow copy of `spec` and returns it. Changing your `spec` object afterwards changes nothing; changing the stored entry does: `luccet.schema.block.note.label = 'Hinweis'`.
- **Again.** Registering a name a second time replaces its entry whole.
- **Refused.** They throw a `TypeError`, and register nothing, when:
- the name is not a NAME: a letter, then letters, marks, digits, `_` or `-`;
- the name is reserved: `base body head html link main meta noscript script style template title`, `luccet`, any `t-…` name, and `end`;
- a block name is one of the core containers `meta`, `macros`, `script`, `style` or `html`;
- `spec` is not an object.
- **When.** The registry is read each time a note is rendered. Declare entries in an add-on or in `window.LUCCET.schema`: both are applied before the note is mounted. A `
::toc
## The theorem
:::theorem[Rank–nullity]{#rn}
$\dim V = \dim \ker T + \dim \operatorname{im} T$.
:::
:::proof
Extend a basis of $\ker T$ to a basis of $V$.
:::
## Using it
By @rn, … Press :key[Ctrl].
```
The toc is a `nav` with two entries, the theorem a `section` labelled "Theorem 1", the proof ends with ∎, `@rn` reads "Theorem 1", and `:key[Ctrl]` is a ``.
:::note
The examples in this section are source, not live. A live example renders with the engine alone, and `luccet.toHTML(source, {head})` reads the head only for what `:::meta` and the labels need: its ``, the `lang` and `dir` of ``, and its ` ` tags. It runs no script. Running the head's scripts is the job of the browser runtime and the command line. Under node, pass the vocabulary as the `schema` option instead.
:::
**The rules.**
- **Shape.** `window.LUCCET.schema` holds `block`, `leaf` and `inline`, each optional, with entries as for `luccet.block`. Function fields (`label`, `dom`) and `css` work as in an add-on.
- **Where.** In a classic `
```
`The answer is :hide[42].` renders as `42 `. Because it is a ``, it is focusable and works from the keyboard with no extra code.
#### Reading `data-*` attributes and raw bodies {#pattern-data}
A behaviour reads its options from the element. Attributes written in braces arrive as written, so `el.dataset` holds them:
```luccet
::countdown{data-to=2026-12-31 data-label="New year"}
```
```js
luccet.define('countdown', (el) => {
const days = Math.ceil((new Date(el.dataset.to) - Date.now()) / 864e5);
el.textContent = `${el.dataset.label}: ${days} days`;
});
```
For more than a few values, give the container a raw body (`{raw}`, or `body: 'raw'` in its entry). The behaviour reads it as `el.textContent`:
```luccet
:::chart{raw}
month,rain
Jan,62
Feb,48
:::
```
```js
luccet.define('chart', (el) => {
const [head, ...rows] = el.textContent.trim().split('\n').map((line) => line.split(','));
// … draw the chart from head and rows, then replace el's content
});
```
No schema entry is needed: `::countdown` and `:::chart` already carry their `data-t`.
#### Behaviours that need rendered math {#pattern-math}
The formulas are MathML when the note mounts, so a behaviour already sees each `span.t-math` with its `` element and its TeX in `data-tex`. Code that measures them, or that must also see what an add-on renders itself (the [KaTeX add-on](math.html#katex) does it in a `render` listener), listens for `math`:
```js
luccet.on('math', (root) => {
for (const eq of root.querySelectorAll('.t-eq')) {
eq.classList.toggle('wide', eq.scrollWidth > eq.clientWidth);
}
});
```
- `math` fires on `main.luccet` once the `render` listeners have settled, and again with its root at each `luccet.render`. So work inside `root`.
- Each formula's TeX is in its `data-tex` attribute. A formula that does not translate has the class `t-error` and shows its TeX as text.
To render new Luccet content into a part of the page, as a live preview does, use `luccet.render(root, source)`: its formulas are MathML too, and it runs the behaviours on the new elements ([`luccet.render`](api.html#render)).
## Custom transforms {#transforms}
A transform computes something from the whole note before it is written: a glossary, an index, a count, a check. It works on the AST, so its result is in the HTML from the start, in the browser and in `luccet.toHTML`.
:::syntax
```js
const remove = luccet.transform(name, fn, {before: 'refs'}) // or {after: 'numbering'}
// fn(ast, ctx), with ctx = {schema, opts, lang, report}
```
:::
- **`name`**: a non-empty string that is not a built-in transform's name. Registering a name again moves it and replaces its function.
- **Where it runs:** `{before: 'x'}` or `{after: 'x'}`, where `x` is any transform already registered. Without either, it runs last. An unknown `x` throws.
- **`remove()`** takes it out. A `remove` from an earlier registration of the same name does nothing.
- **`fn(ast, ctx)`** changes `ast` in place. Its return value is ignored.
- **A transform that throws** is skipped, and the note renders without it. It is reported as `addon.failed`, an error at line 0: "the transform "NAME" threw: MESSAGE".
- **Once per tree.** A tree is transformed once; transforming it again returns it unchanged.
:::table[The built-in transforms, in order]{#tbl-transforms}
| Transform | Does | So a transform placed after it sees |
|---|---|---|
| `figures` | turns an image with an id into a `figure` container | implicit figures as containers |
| `tables` | merges a `table` container with its pipe table | merged tables |
| `math` | translates every formula's TeX to MathML, with the note's macros ([Math](math.html)) | `data.luccet.mathml`, or `texError` for a formula that does not translate |
| `attributes` | resolves elements, title slots, schema `class` and `attrs`, reserved keys | `data.luccet.tag`, `data.luccet.slot` |
| `footnotes` | numbers footnotes, `fn-`/`fnref-` ids | footnote numbers |
| `ids` | the id registry, heading slugs, invalid and duplicate ids | every heading's id |
| `numbering` | counters, labels, equation numbers | `data.luccet.number`, `data.luccet.text` |
| `refs` | fills references, checks fragment links | reference text |
| `toc` | fills `toc` leaves | tables of contents |
| `meta` | the title fallback | `data.luccet.meta.title` |
:::
So a transform that adds ids or headings runs **before `ids`**: then references to its ids resolve, and its headings get slugs and enter the table of contents. One that reads numbers runs **after `numbering`**. [The transformed AST](api.html#transformed-ast) lists every field the built-in transforms write.
**`ctx`:**
- `ctx.schema`: the registry in use;
- `ctx.opts`: the options of the call (`head`, `lang` …);
- `ctx.lang`: the label language ([Labels in other languages](#languages));
- `ctx.report(code, at, message, hint, severity)` adds a diagnostic.
- `at` is a node (its position is used) or a position `{start, end}`; without one, line 1, column 1.
- `severity` is `'error'`, `'warning'` (the default) or `'info'`. A code of Luccet's own keeps its own severity, so name yours after your add-on: `glossary.empty`.
- Your diagnostics reach `luccet.check`, `luccet.diagnostics`, the console and the panel like Luccet's.
### A worked transform {#glossary}
A glossary: `:term[…]` marks a term, and a `::glossary` leaf lists every term once, sorted, each linking to its first use.
```js [glossary.js, an add-on with a transform]{#lst-glossary}
(function () {
'use strict';
const luccet = window.luccet;
luccet.inline('term', { tag: 'dfn' });
luccet.leaf('glossary', { tag: 'nav' });
const text = (nodes) => nodes.map((n) => n.value || (n.children ? text(n.children) : '')).join('');
luccet.transform('glossary', (ast, ctx) => {
const terms = new Map(); // term -> the id of its first use
const lists = [];
luccet.visit(ast, (node) => {
if (node.type === 'leafDirective' && node.name === 'glossary') lists.push(node);
if (node.type !== 'textDirective' || node.name !== 'term') return;
const word = text(node.children).trim();
if (!word) return ctx.report('glossary.empty', node, 'this term is empty', 'write the term in the brackets: :term[kernel]');
if (terms.has(word)) return;
const a = node.attributes || (node.attributes = {});
if (a.id === undefined) a.id = `term-${terms.size + 1}`;
terms.set(word, a.id);
});
if (terms.size && !lists.length) ctx.report('glossary.missing', null, 'the note has terms but no ::glossary', 'add ::glossary where the list goes', 'info');
const items = [...terms.keys()].sort((a, b) => a.localeCompare(b)).map((word) => ({
type: 'listItem', spread: false, checked: null,
children: [{ type: 'paragraph', children: [{ type: 'link', url: `#${terms.get(word)}`, title: null, children: [{ type: 'text', value: word }] }] }],
}));
for (const leaf of lists) leaf.children = [{ type: 'list', ordered: false, start: null, spread: false, children: items }];
}, { before: 'ids' });
})();
```
With it, `A :term[kernel] is a set; so is the :term[image].` followed by `::glossary` renders as:
```html
A kernel is a set; so is the image .
```
- It runs **before `ids`**, so the ids it gives the terms are registered: the links are checked like any fragment link, and `@term-1` would resolve.
- It walks the tree with [`luccet.visit`](api.html#visit), which an add-on has under node too ([Under node](addon-api.html#node)). So it runs in the browser, in `luccet check` and in `luccet build` alike.
- The nodes it builds are mdast nodes ([Node types](api.html#node-types)); the emitter writes them like parsed ones. They have no positions, so they get no `data-pos`.
- An empty `:term[]` gets `glossary.empty` (warning) at the term.
### When to use a transform {#when-transform}
Use a transform when:
- the result depends on the whole note (counts, indexes, cross-references);
- it must be in the HTML before any script runs, with no flash of unfinished content;
- it must be in `luccet.toHTML`'s output.
Use a behaviour when the work needs the page: events, measurement, a widget, data fetched at run time.
Where transforms run:
- **in the browser**, once per tree: at mount, and at each `luccet.render(root, source)`;
- **under the command line and the language server**, from an add-on: `luccet check` reports their diagnostics, and `luccet build` writes their changes into the page ([Under node](addon-api.html#node));
- **under node, in your own script**: a transform registered with `require('./luccet.js').transform(…)` runs in `toHTML`, `check` and `transform`.
## Styling {#styling}
### Classes and attributes {#classes}
Every element Luccet writes carries classes and attributes to key CSS on. Key on these, never on the tag: an author can change the tag with `{tag=…}`.
:::table[Classes and attributes]{#tbl-classes}
| Hook | On |
|---|---|
| `.luccet` | `main.luccet`, the note's root |
| `.t-block` | every container, and a titled fence's `figure` |
| `.NAME` | every container, leaf and inline element, by name (reserved names excepted) |
| `[data-t="NAME"]` | every named element, reserved names included, and every fence (`code`) |
| `.t-title` | the title element: `div`, `span`, `summary`, `figcaption`, `caption` or `legend` |
| `.t-label` | the label: first in a container's title, or first in a leaf or inline element |
| `.t-end` | the end mark |
| `.t-cols` | a grid: a container with cells or `cols`; its columns are `--t-cols` |
| `.t-cell` | one cell of a container |
| `.t-eq` | a display equation's box |
| `.t-eqno` | an equation's number |
| `.t-math` | every formula, holding its `` element, with its TeX in `data-tex`; display formulas also have `data-display` ([Math](math.html)) |
| `.t-error` | a problem shown where it is: opener junk, an unclosed comment or `$$`, a `:::script`, or a formula whose TeX does not translate |
| `.t-footnotes` | the footnote section; `.t-fnref` the reference, `.t-fnback` the back link |
| `.t-task` | a task list item, with its checkbox |
| `.t-ref` | a resolved reference `@id` |
| `.t-line`, `.t-hl` | a fence's lines, with `lines`, `hl` or `start`; `pre[data-lines]` shows the numbers |
| `.t-macros` | a hidden `:::macros` block |
| `.t-diagnostics` | the diagnostics panel, after `main` |
| `[data-pos]` | the source line and column, on named elements, cells, fences and formulas |
:::
Most of them at once:
```luccet {.live schema=demo}
:::theorem[Mean value]{#mv}
If $f$ is differentiable on $[a, b]$, then
$$
f(b) - f(a) = f'(\xi)(b - a)
$$ {#eq-mv}
:::
:::proof
Apply Rolle's theorem; see @eq-mv.[^r]
:::
:::row
- [x] stated
+++
- [ ] proved
:::
[^r]: Any calculus text.
```
And `t-error`, on opener junk:
```luccet {.live schema=demo expect="block.opener-junk"}
:::tip Careful
The title belongs in brackets.
:::
```
### Custom properties {#custom-properties}
The base stylesheet, `luccet.css`, is functional: a note looks like plain HTML, in the browser's own type and colours, light or dark, and `luccet.css` adds only what Luccet's constructs need (line numbers, grids, an equation's number, the math font and the few rules the engine's MathML needs, scrolling where content would overflow, errors and the diagnostics panel). The look is a [theme's](#themes). The custom properties on `:root` are the contract between them: `luccet.css` draws its own parts with them, add-ons read them, and a theme sets them. Their defaults are the system's colours, or `luccet.css`'s own where the system has none.
:::table[Custom properties]{#tbl-properties}
| Property | Light | Dark | Used for |
|---|---|---|---|
| `--t-bg` | `Canvas` | | the page as the browser draws it: the diagnostics panel; a theme's page |
| `--t-fg` | `CanvasText` | | the text |
| `--t-muted` | `CanvasText` | | line numbers; quotes, captions and equation numbers in a theme (the system's `GrayText` is too faint for text in some browsers) |
| `--t-line` | `GrayText` | | rules and borders in a theme |
| `--t-line-strong` | `GrayText` | | a control's border, 3:1 against the page: the diagnostics panel; `kbd` and a search box in a theme |
| `--t-soft` | `ButtonFace` | | code backgrounds and table headers in a theme |
| `--t-link` | `LinkText` | | links in a theme |
| `--t-mark` | `#fff2a8` | `#5c4d00` | highlighted code lines; `==highlight==` in a theme, where text in it is `--t-fg` (a plain note's highlight is the browser's) |
| `--t-error` | `#b3261e` | `#ffb4ab` | `.t-error` text and border, the panel |
| `--t-error-bg` | `#fdecea` | `#3b1714` | `.t-error` background |
| `--t-warn` | `#8a5300` | `#e3b341` | warnings in the panel |
| `--t-sans` | `system-ui, sans-serif` | | the panel; headings and table headers in a theme |
| `--t-mono` | `monospace` | | `.t-error`; code in a theme |
| `--t-math` | `"Latin Modern Math", "STIX Two Math", "Cambria Math", "Libertinus Math", "TeX Gyre Termes Math", "DejaVu Math TeX Gyre", "Noto Sans Math", math` | | the math font of every formula's ``: a font with OpenType's math table, so brackets stretch and big operators grow ([fonts](math.html#browsers)). A theme may set it, with a math font of its own first |
| `--t-font`, `--t-width`, `--t-size`, `--t-leading` | | | a theme's body font, text column, base size and line height; `luccet.css` sets none of them, the [classic theme](#themes) sets all four |
| `--t-cols` | | | a grid's columns, set by Luccet on each grid |
:::
**Code colours** are not `luccet.css`'s: `--t-code-comment`, `--t-code-keyword` and the rest belong to the [code add-on](https://github.com/Luccet/code), which defines their defaults, and a [theme](#themes) sets them with its palette.
### Overriding them {#overriding}
A note is an HTML page, so CSS styles it as any page: write rules for its elements, `body`, `h1`, `a`, `pre`. With a theme, set its properties instead. A style in the head, before the luccet.js line, is enough:
```html
```
```html
```
**The cascade.** Luccet's stylesheets form one group in the head, placed before the head's first stylesheet of your own, in this order:
1. the base, `luccet.css`;
2. each add-on in `data-use` order: a CSS add-on's link, or the `luccet.style` sheets a script add-on adds;
3. other `luccet.style` sheets: from `window.LUCCET.schema` entries, from note scripts.
Your own head styles come after the group, and a `