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).
Document
Section titled “Document”| 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). langis 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.
Rich text
Section titled “Rich text”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).
hrefonagoes through the URL policy (http:,https:,mailto:by default); the picture attributes onimgare 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 (warningATTRIBUTE_REMOVED). - Comments are removed.
Pictures
Section titled “Pictures”<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).srcis 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 absolutehttp:andhttps:addresses; relative paths need a wider policy.altis required (IMG_ALT_MISSING): describe what the picture shows, without giving an answer away.alt=""marks a decorative picture, which screen readers skip.widthandheightare optional positive integers (ATTRIBUTE_INVALIDotherwise): 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,styleand event handlers are removed (ATTRIBUTE_REMOVED). The player addsloading="lazy"anddecoding="async". <figure>is a block for a picture with a caption: directly on a page (or in the document), like a block component (PLACEMENT_INVALIDelsewhere). 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 thecardsvariant of<astl-choice>does) sets--astl-picture-display: blockon 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.
Components
Section titled “Components”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.
Elements
Section titled “Elements”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 insidea. 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_INVALIDelsewhere, also directly in the block), never inside one another, and theirminandmaxcount 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).
Attributes
Section titled “Attributes”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.
Slot kinds
Section titled “Slot kinds”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
caseandmarks). With the defaultsżółwdoes not acceptzolw, andGrey.acceptsgrey. 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,5is correct foranswer="0.5"in a Polish document. Where the language groups thousands with a dot (de,es,it,pt,nl,tr, ...),1.234means 1234; elsewhere a lone dot is a decimal separator. - A gap's
answerand 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.
Scoring
Section titled “Scoring”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).
Open answers
Section titled “Open answers”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 (
createEnginechecks 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 tomaxScore. - The metadata counts open slots in
slots(asastl-readydoes) and lists their ids inopen, 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,langanddirare always written. - Private attributes and children are gone, derived attributes are added,
vis written on every component. - Every component's view is checked against its manifest before it is written:
idandvfirst, only public and derived attributes, only declared public children, rich text insideinlinechildren 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.
Answer key
Section titled “Answer key”{ "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.