Skip to content

Content format 1

An Astrolabe exercise is HTML: one <astl-doc> root, optional pages, rich text and component elements. Authors (and agents) write the source, which contains the answers. The engine compiles it into a public view without answers, a private answer key and public metadata.

<astl-doc lang="en">
<astl-page id="warm-up" label="Warm-up">
<p>An elephant is <astl-gap id="g1" answer="grey"><astl-alt>gray</astl-alt></astl-gap>.</p>
<astl-choice id="q1" multiple>
<astl-prompt>Which animals are mammals?</astl-prompt>
<astl-option id="a" correct>Otter</astl-option>
<astl-option id="b">Trout</astl-option>
<astl-option id="c" correct>Mole</astl-option>
</astl-choice>
</astl-page>
<astl-page id="numbers" label="Numbers">
<p>Half of one is <astl-gap id="g2" type="number" answer="0.5"></astl-gap>.</p>
</astl-page>
</astl-doc>

The same file works in both usage modes: paste it into a page with astrolabe.js and it grades in the browser (answers visible in the page source), or compile it on a server and ship only the view (see the guides).

Element Attributes Content
<astl-doc> lang (required, BCP 47), format (optional, 1), dir (optional, as in HTML but without auto; default from lang) <astl-page> elements only, or flow content (= one implicit page)
<astl-page> id (required), label (optional, shown as the page heading) flow content; pages do not nest
  • Exactly one <astl-doc>; whitespace and comments around it are ignored.
  • Do not mix pages and loose content at the top level (DOC_MIXED_PAGES).
  • lang is required (DOC_LANG_MISSING). Text in another language goes in <span lang="en">.
  • Limits: at most 1,000,000 characters (UTF-16 code units, SOURCE_TOO_LARGE), at most 256 attributes on one element (SOURCE_TOO_COMPLEX) and at most 100 levels of nested elements, counting <astl-doc> as level 1 (SOURCE_TOO_DEEP). They stop validation before anything else is checked, and they are enforced before or while parsing, so no input can make the HTML parser slow. The attribute check reads tag-shaped text anywhere, also inside comments and scripts, so it never misses a real tag.
  • Pattern [A-Za-z][A-Za-z0-9_-]{0,63}, unique in the document (ID_INVALID, ID_DUPLICATE).
  • Pages and components need an id. Child ids (such as option ids) are local to their component and unique inside it.
  • Editors assign ids; the engine never invents them, so answer keys stay stable across edits.
  • Views never contain source ids: every id becomes an opaque id (k + 16 base32 characters) derived with HMAC-SHA-256 from the host secret, the compile salt and the source id.

The allow-list of @astrolabe/core (generated from content.ts):

Kind Elements Attributes
Blocks p, h2, h3, h4, ul, ol, li, blockquote start on ol
Picture with a caption (a block) figure: one img and at most one figcaption none
Inline strong, em, b, i, u, s, sub, sup, code, mark, span, a, br, img href on a; src, alt, width, height on img

Every element above also takes lang, dir (as in HTML).

  • href on a goes through the URL policy (http:, https:, mailto: by default); the picture attributes on img are explained below.
  • Removed together with their content (warning ELEMENT_REMOVED): script, style, template, iframe, object, embed, noscript, svg, math, video, audio, form, input, select, textarea, button. Video and audio come as components.
  • Other HTML elements are unwrapped: the element goes, its content stays (warning ELEMENT_REMOVED).
  • Unknown custom elements (a tag with a hyphen that no registered component declares) are errors (ELEMENT_UNKNOWN): a typo or a missing component must not be silently dropped.
  • Other attributes (class, style, on*, data-*, ...) are removed (warning ATTRIBUTE_REMOVED).
  • Comments are removed.
<figure>
<img src="https://assets.example.org/tree.svg" alt="An old apple tree in a meadow" width="320" height="200">
<figcaption>Many animals live in and around <strong>trees</strong>.</figcaption>
</figure>
<p><img src="https://assets.example.org/robin.svg" alt="A robin" width="72" height="48"> A robin lays its eggs in a
<astl-gap id="robin" answer="nest"></astl-gap>.</p>
  • <img> is inline: it sits in text, headings, list items, links and captions, and in the rich children of components (prompts, options, cards), so they can show pictures. Text-only children (accepted alternatives) drop it (ELEMENT_REMOVED).
  • src is required and goes through the URL policy. A missing address (ATTRIBUTE_REQUIRED) or one the policy blocks (URL_BLOCKED, an error here, a warning for links) stops the compile: a picture without its address cannot be shown. The default policy accepts absolute http: and https: addresses; relative paths need a wider policy.
  • alt is required (IMG_ALT_MISSING): describe what the picture shows, without giving an answer away. alt="" marks a decorative picture, which screen readers skip.
  • width and height are optional positive integers (ATTRIBUTE_INVALID otherwise): the space in pixels the picture takes while it loads. It scales down to the width of the column and keeps its aspect ratio.
  • Nothing else stays: srcset, loading, title, class, style and event handlers are removed (ATTRIBUTE_REMOVED). The player adds loading="lazy" and decoding="async".
  • <figure> is a block for a picture with a caption: directly on a page (or in the document), like a block component (PLACEMENT_INVALID elsewhere). It holds exactly one <img> and at most one <figcaption>, before or after it, and nothing else (FIGURE_INVALID). In a prompt, an option or a card a figure is unwrapped (ELEMENT_REMOVED): use an <img> there, the option's text is its caption.
  • <figcaption> holds rich inline text, inline components included (a gap in a caption). Outside a figure it is unwrapped.
  • The player shows a picture in text as display: var(--astl-picture-display, inline), never wider than its column; a picture in a figure is always a block. A component that puts the pictures of a child on lines of their own (the picture of a card above its label, as the cards variant of <astl-choice> does) sets --astl-picture-display: block on that child from its shadow root: its own CSS cannot reach the pictures inside.
  • In HTML with answers the browser may fetch the pictures of the source as the page loads, before the engine checks them: the source is part of the page.

Every component is a custom element declared by a ComponentManifest (@astrolabe/core). The manifest lists its attributes, children, scoring modes and issue codes; each built-in has a page generated from it, linked below.

Every built-in component and the children it declares (generated from the manifests):

Component Display Holds
<astl-audio> block, on a page <astl-transcript>
<astl-callout> block, on a page rich content
<astl-categorise> block, on a page <astl-prompt>, <astl-category>, <astl-card>
<astl-choice> block, on a page <astl-prompt>, <astl-option>
<astl-crossword> block, on a page <astl-prompt>, <astl-crossword-word>
<astl-drop> inline, in text no children
<astl-flash-cards> block, on a page <astl-front>, <astl-back>
<astl-gap> inline, in text <astl-alt>
<astl-hotspot> block, on a page <astl-prompt>, <astl-hotspot-region>
<astl-image-drop> block, on a page <astl-prompt>, <astl-image-drop-zone>, <astl-image-drop-label>
<astl-image-label> block, on a page <astl-prompt>, <astl-image-label-pin>
<astl-long-answer> block, on a page <astl-prompt>
<astl-mark-words> block, on a page rich content, with <astl-mark-word> inline in its text
<astl-match> block, on a page <astl-prompt>, <astl-row>, <astl-column>
<astl-memory> block, on a page <astl-prompt>, <astl-card-a>, <astl-card-b>
<astl-order> block, on a page <astl-prompt>, <astl-item>
<astl-select> inline, in text <astl-option>
<astl-true-false> block, on a page <astl-prompt>, <astl-statement>
<astl-video> block, on a page no children
<astl-word-bank> block, on a page <astl-word>
<astl-word-search> block, on a page <astl-prompt>, <astl-search-word>

Their children, which never stand on their own:

Child Inside Visibility Attributes
<astl-alt> <astl-gap>: 0 to 20, plain text private none
<astl-back> <astl-flash-cards>: 1 to 50, plain text public none
<astl-card> <astl-categorise>: 2 to 24, inline content public id (public, required), category (private, required)
<astl-card-a> <astl-memory>: 2 to 15, inline content public id (public, required)
<astl-card-b> <astl-memory>: 2 to 15, inline content public id (public, required), pair (private, required)
<astl-category> <astl-categorise>: 2 to 6, inline content public id (public, required), palette (public)
<astl-column> <astl-match>: 2 to 8, inline content public id (public, required)
<astl-crossword-word> <astl-crossword>: 1 or more, inline content public id (public, required), answer (private, required), row (public, required), column (public, required), direction (public, required), number (derived), length (derived)
<astl-front> <astl-flash-cards>: 1 to 50, inline content public none
<astl-hotspot-region> <astl-hotspot>: 2 to 30, plain text public id (public, required), shape (public), x (public), y (public), width (public), height (public), r (public), points (public), label (public), correct (private)
<astl-image-drop-label> <astl-image-drop>: 1 to 30, inline content public id (public, required)
<astl-image-drop-zone> <astl-image-drop>: 1 to 20, plain text public id (public, required), x (public), y (public), width (public), height (public), label (public), answer (private, required)
<astl-image-label-pin> <astl-image-label>: 1 to 20, plain text public id (public, required), x (public, required), y (public, required), label (private, required), alternatives (private)
<astl-item> <astl-order>: 3 to 20, inline content public id (public, required)
<astl-mark-word> <astl-mark-words>: 1 to 100, inline content, anywhere in its text public id (public, required), correct (private)
<astl-option> <astl-choice>: 2 to 12, inline content
<astl-select>: 2 to 50, plain text
public id (public, required), correct (private)
<astl-prompt> <astl-categorise>: 0 to 1, inline content
<astl-choice>: 0 to 1, inline content
<astl-crossword>: 0 to 1, inline content
<astl-hotspot>: 0 to 1, inline content
<astl-image-drop>: 0 to 1, inline content
<astl-image-label>: 0 to 1, inline content
<astl-long-answer>: 0 to 1, inline content
<astl-match>: 0 to 1, inline content
<astl-memory>: 0 to 1, inline content
<astl-order>: 0 to 1, inline content
<astl-true-false>: 0 to 1, inline content
<astl-word-search>: 0 to 1, inline content
public none
<astl-row> <astl-match>: 1 to 12, inline content public id (public, required), column (private, required)
<astl-search-word> <astl-word-search>: 1 to 20, inline content public id (public, required), word (private), clue (derived)
<astl-statement> <astl-true-false>: 1 to 12, inline content public id (public, required), answer (private, required)
<astl-transcript> <astl-audio>: 0 to 1, plain text public none
<astl-word> <astl-word-bank>: 1 to 30, inline content public id (public, required), distractor (private)

Placement:

  • Inline components (display: inline, e.g. astl-gap) sit inside text: p, h2-h4, li, blockquote, a caption or an inline element. Not inside a. Not directly inside a page (PLACEMENT_INVALID).
  • Block components (display: block, e.g. astl-choice) are direct children of a page (or of the document when it has no pages), like a <figure>.
  • Components never nest (COMPONENT_NESTED).
  • A rich block (a manifest with content: 'flow': a callout, a passage to select words in) wraps rich content as a page holds it: paragraphs, headings, lists, quotes, figures, formatting and pictures, but no pages (PAGE_NESTED) and no components. Its declared children are inline children of that text (the words to select): they sit where an inline component may (PLACEMENT_INVALID elsewhere, also directly in the block), never inside one another, and their min and max count them wherever they are.
  • A child element outside every component that declares it is an error (CHILD_OUTSIDE); in another component's children it is not accepted (CHILD_UNEXPECTED).

Common attributes (generated from @astrolabe/core):

Attribute Visibility Meaning
id public, required Source id, unique in the document. Views carry an opaque id instead. On every component.
v public Element format version. Optional in sources (absent = 1); the compiler always writes the current one. On every component.
style-template public A look by name, from the style templates the host registers (the component scope); it wins over the document's. More: Looks. On every component.
scoring private One of the manifest's scoring modes; absent = the manifest's default mode. On graded components.
points private Points of the element's scoring group. On graded components.

Types with variants also take variant (presentation only, never grading: Variants and versions); <astl-doc> takes style-template too, the look of the whole exercise (Looks).

Visibility, as declared in the manifest:

  • public: kept in the view;
  • private: only in the answer key; never in the view;
  • derived: computed by the compiler for the view (authors cannot set it).

Attribute types: string, boolean (present = true; "false" is invalid), integer, number (a dot as the decimal separator, with optional min / max), enum, id (a local id, opaque in the view) and url. A url attribute goes through the URL policy like a[href]: a blocked URL is removed with a URL_BLOCKED warning, an allowed one stays in the view when the attribute is public. Manifests cannot declare lang, dir or on* attributes.

A component's answers are slots, and each slot has a kind: how its answer is compared with the key. Each component page lists the kinds its type produces; hosts may register more (slotKinds of createEngine).

Kind Answer Graded
text typed text, compared with the normalisation profile (below): a gap's word, a word search's span yes
number a number in the content language's notation, within a tolerance: a number gap, an item's position yes
choice one picked value: an option's opaque id, or a fixed public value such as true or false yes
flag "true" when an option is selected (one slot per option) yes
open text for the teacher, at most 2000 characters no
  • Text compares with the normalisation profile: NFC, trimmed and collapsed spaces, edge punctuation ignored, typographic quotes and dashes folded, capital letters and diacritics as the component says (a gap's case and marks). With the defaults żółw does not accept zolw, and Grey. accepts grey. The profile is stored in the key, so a key grades the same way forever.
  • Numbers accept . and, for languages that write a decimal comma (pl, de, fr, uk, ...), , as the decimal separator; spaces may group thousands. 0,5 is correct for answer="0.5" in a Polish document. Where the language groups thousands with a dot (de, es, it, pt, nl, tr, ...), 1.234 means 1234; elsewhere a lone dot is a decimal separator.
  • A gap's answer and every <astl-alt> are at most 500 characters (GAP_ANSWER_TOO_LONG): students' answers longer than that count as unanswered (see Scoring), so a longer accepted answer could never match.

Each graded component is one scoring group, or one group per part when its definition grades it in parts (a crossword's words: a square in two words counts in both), each with the component's rule and points. With n slots, c of them correct and P points:

Rule Max Score Feedback
all-correct P P when c = n, else 0 group verdict only, never which slot is wrong
per-slot P x n P x c per slot
proportional P P x c / n (exact) per slot

Selection slots (flags): an unselected option gets no verdict, and a group with nothing selected scores 0 with verdict empty. A slot is unanswered when its answer is missing, empty or longer than its kind allows: 500 characters, 2000 for an open slot, counted in Unicode code points (so a Polish or an English letter counts one).

Ungraded types (graded: false, scoring: null) add nothing to the maximum. A type with slotKinds: ['open'] (a long answer) still has answers: its open slots are stored with the attempt for the teacher and never graded.

  • Graded types produce graded slot kinds only, ungraded types ungraded ones only (createEngine checks it).
  • The key holds an open slot like any other ({ "kind": "open", "group": "<the component's id>", "key": {} }), but no scoring group does: it gets no verdict and no solution, and adds nothing to maxScore.
  • The metadata counts open slots in slots (as astl-ready does) and lists their ids in open, so a server or a teacher's view can pick these answers out of a submission.
  • The player never counts an empty open answer in the hand-in confirmation. After a hand-in, a result with nothing graded says the answers went to the teacher (no score); with graded parts too, the score covers those and a line says how many answers go to the teacher. A check says answers for the teacher are not checked. With nothing graded and no open answers either (flash cards alone), a check or a hand-in says it is practice only, instead of a score.

The compiled view is what students download. For the example above (ids shortened):

<astl-doc format="1" lang="en" dir="ltr">
<astl-page id="k7q2..." label="Warm-up">
<p>An elephant is <astl-gap id="kd3f..." v="1" size="4"></astl-gap>.</p>
<astl-choice id="kx91..." v="1" multiple><astl-prompt>Which animals are mammals?</astl-prompt><astl-option id="ka0b...">Otter</astl-option><astl-option id="k55c...">Trout</astl-option><astl-option id="kr2m...">Mole</astl-option></astl-choice>
</astl-page>
...
</astl-doc>
  • Pages are always explicit, with opaque ids; format, lang and dir are always written.
  • Private attributes and children are gone, derived attributes are added, v is written on every component.
  • Every component's view is checked against its manifest before it is written: id and v first, only public and derived attributes, only declared public children, rich text inside inline children limited to the allow-list above, no event handler attributes, every URL through the URL policy, and none of the element's private strings or private child texts (unless the author also wrote them in public content). A violation fails the compile: it is a bug in the component's definition.
  • The view is what a browser builds from it: when unwrapping leaves nesting the HTML parser would rebuild (a list item directly in a list item, once a table around it is gone), the compiler writes the rebuilt structure.
  • The view is deterministic for (source, secret, salt): the same input gives byte-identical output and the same viewHash (SHA-256), so views can be cached forever under /v/{viewHash}.html.
{
"format": 1,
"lang": "en",
"viewHash": "3b1f...",
"pages": ["k7q2...", "k0p1..."],
"slots": {
"kd3f...": { "kind": "text", "group": "kd3f...", "page": "k7q2...",
"key": { "accept": ["grey", "gray"], "profile": { "form": "NFC", "space": "collapse", "case": "insensitive", "marks": "strict", "punctuation": "ignore-edge", "quotes": "fold" }, "lang": "en" } },
"ka0b...": { "kind": "flag", "group": "kx91...", "page": "k7q2...", "key": { "correct": true } }
},
"groups": [
{ "id": "kd3f...", "rule": "all-correct", "points": 1, "slots": ["kd3f..."], "page": "k7q2..." },
{ "id": "kx91...", "rule": "all-correct", "points": 1, "slots": ["ka0b...", "k55c...", "kr2m..."], "page": "k7q2..." }
]
}

Keys are immutable once published: the normalisation profile is stored inside each text key, so a key grades the same way forever.