Write your own component
A component is one folder with five small parts. Nothing in Astrolabe's core changes: you register your parts with the engine, the player and the authoring kit, exactly like the built-ins.
| Part | Contract (@astrolabe/core) |
Used by |
|---|---|---|
manifest.ts |
ComponentManifest: tag, attributes with visibility, children, scoring modes, slot kinds, issue codes |
everything |
definition.ts |
ComponentDefinition: validate, slots, toView |
createEngine({ components }) |
element.ts |
PlayerElementApi (Lit base classes in @astrolabe/player) |
definePlayer({ elements }) |
editor.ts |
EditorDescriptor: label, icon, template, inspector fields |
defineEditor({ editors }) or createAuthoring({ descriptors }) |
messages/ |
ICU MessageFormat 1 catalogs in your namespace: the student strings and the authoring strings | all three; the element only the student ones |
The code on this page is a complete component, <acme-yes-no>: the docs site type-checks it and plays it at the
end of the page.
1. Manifest: the only schema
Section titled “1. Manifest: the only schema”import type { ComponentManifest } from '@astrolabe/core';
/** A statement the student answers with yes or no. */export const yesNoManifest: ComponentManifest = { tag: 'acme-yes-no', // your own prefix: `astl-` is reserved for built-ins version: 1, display: 'block', graded: true, attributes: [ { name: 'answer', type: 'enum', values: ['yes', 'no'], visibility: 'private', required: true }, ], children: [ { tag: 'acme-statement', visibility: 'public', content: 'inline', min: 1, max: 1, attributes: [] }, ], scoring: { default: 'all', modes: { all: 'all-correct' } }, variants: null, slotKinds: ['choice'], issues: [], messages: 'acme.yes-no', a11y: { pattern: 'radiogroup', keys: ['Tab', 'ArrowUp', 'ArrowDown'] }, cssParts: ['statement', 'option'],};Visibility decides what the browser may see: private attributes and children exist only in the answer key. The
built-ins have a generated reference page each, made from their manifests: components.
A child tag means one thing in every type: types may share one (astl-prompt) only with the same attributes and
visibility, and createEngine throws otherwise, so give your own children your own prefix.
2. Messages
Section titled “2. Messages”Every string the element, the issues and the authoring kit show is a message in your namespace (here
acme.yes-no), in ICU MessageFormat 1, with files for each UI locale; see Internationalisation.
Keep the student strings (ui.*) apart from the authoring ones (issues, editor.*, template.*), and give the
element only the student ones, from a module of their own. Bundlers split code by module, so an element that imports
every string ships your issue and editor texts to every student. One catalog per locale still works, at that cost.
{ "ui.yes": "Yes", "ui.no": "No", "ui.solution": "Correct answer: {answer}"}import type { MessageCatalog } from '@astrolabe/core';import en from './messages/ui/en.json' with { type: 'json' };
/** The student strings, the element's only catalogs. */export const yesNoUiMessages: readonly MessageCatalog[] = [ { namespace: 'acme.yes-no', locale: 'en', messages: en },];{ "editor.label": "Yes or no", "editor.description": "The student answers a statement with yes or no.", "editor.answer": "Correct answer", "editor.value.answer.yes": "Yes", "editor.value.answer.no": "No", "template.statement": "Type the statement"}import type { MessageCatalog } from '@astrolabe/core';import en from './messages/en.json' with { type: 'json' };import uiEn from './messages/ui/en.json' with { type: 'json' };
/** Every string, for the definition and the editor descriptor. */export const yesNoMessages: readonly MessageCatalog[] = [ { namespace: 'acme.yes-no', locale: 'en', messages: { ...uiEn, ...en }, },];3. Definition: grading side
Section titled “3. Definition: grading side”import type { ComponentDefinition } from '@astrolabe/core';import { kit } from '@astrolabe/engine';import { yesNoManifest } from './manifest.js';import { yesNoMessages } from './messages.js';
export const yesNoDefinition: ComponentDefinition = { manifest: yesNoManifest, messages: yesNoMessages, // The engine has already checked everything the manifest says: `answer` is there and is `yes` or `no`. validate() {}, slots(el, ctx) { const correct = kit.enumAttr(el, 'answer', ['yes', 'no'], 'yes'); return [{ id: ctx.opaque(), kind: 'choice', key: { correct } }]; }, toView(el, ctx) { return kit.publicView(el, yesNoManifest, ctx); // drops `answer`, writes the opaque id and `v` },};Reuse a built-in slot kind (text, number, choice, flag, letter) or register your own SlotKind with
createEngine({ slotKinds }). Here a choice slot holds the correct answer, yes or no, and the element answers
with the same strings. A type whose answers the teacher reads instead (a long answer) is graded: false with
scoring: null and slotKinds: ['open'], and its slots() returns { id, kind: 'open', key: {} }: the answer is
stored, never graded, and adds nothing to the maximum.
The engine checks every toView result before it reaches a view: only the manifest's public and derived attributes
and declared public children, rich text inside inline children limited to the content allow-list, no event handler
attributes, URLs through the host's URL policy, and none of the element's private strings or private child texts. A
violation fails the compile with an error naming your definition. Read attributes with the kit helpers
(stringAttr, boolAttr, numberAttr, intAttr, enumAttr, urlAttr); declare URL attributes with
type: 'url' so the sanitiser applies the URL policy to them. ctx.permute(items, { avoidOriginal: true }) is
uniform over the orders that differ from the source order; for two items that is always the swap.
Report type-specific problems with context.report('CODE', params, { severity }) in validate, and list every code
in the manifest's issues with a message per locale.
Slot ids come from ctx.opaque(localId), and local ids from the children's id attributes. A slot that no child
stands for (a crossword's square) needs localIds(element): the definition lists them (start them with a digit, so
they never meet a child's id) and each gets an opaque id like a child's. A component graded in parts returns its
groups from groups(element, ctx), each { id: ctx.opaque(localId), slots } with the component's rule and points;
its element lists those ids in groupIds, and the player passes their verdicts in feedback.groups.
Components that work together (a drop and its word bank) meet through ids. In validate, context.find(id) gives
another component of the same page, sanitised as its own definition sees it, also when it comes later in the source.
In slots and toView, ctx.opaqueOf(id, localId) gives the opaque ids that component's view carries.
4. Element: player side
Section titled “4. Element: player side”import type { ComponentManifest, MessageCatalog } from '@astrolabe/core';import { type ElementRegistration, renderVerdict, SingleSlotElement, verdictStyles } from '@astrolabe/player';import { type CSSResultGroup, css, html, nothing, type TemplateResult } from 'lit';import { yesNoManifest } from './manifest.js';import { yesNoUiMessages } from './ui-messages.js';
export class AcmeYesNo extends SingleSlotElement { static override manifest: ComponentManifest = yesNoManifest; static override messages: readonly MessageCatalog[] = yesNoUiMessages; static override styles: CSSResultGroup = [ verdictStyles, css` fieldset { border: var(--astl-outline-width) solid var(--astl-line); border-radius: var(--astl-radius-m); background: var(--astl-surface); } label { display: flex; align-items: center; gap: var(--astl-space-2); min-block-size: var(--astl-target-min); } `, ];
override render(): TemplateResult { const verdict = this.verdict; const solution = this.solutionValue; return html`<fieldset part="statement"> <legend><slot></slot></legend> ${['yes', 'no'].map( (option) => html`<label part="option"> <input type="radio" name="answer" .checked=${this.value === option} ?disabled=${this.disabled} @change=${() => this.updateValue(option)} /> ${this.t(`ui.${option}`)} </label>`, )} ${verdict === null ? nothing : renderVerdict(verdict, (key, params) => this.t(key, params))} ${solution === null ? nothing : html`<p>${this.t('ui.solution', { answer: this.t(`ui.${solution}`) })}</p>`} </fieldset>`; }}
export const yesNoElement: ElementRegistration = { manifest: yesNoManifest, element: AcmeYesNo, messages: yesNoUiMessages,};SingleSlotElement implements the slot, answer and unit bookkeeping for one-slot components: render from
this.value, report changes with this.updateValue(value), show this.verdict and this.solutionValue, respect
this.disabled. Elements must never import the engine or the definition: the student bundle carries no answer
logic. Keep them accessible: Accessibility.
5. Editor descriptor
Section titled “5. Editor descriptor”import type { EditorDescriptor } from '@astrolabe/core';import { yesNoManifest } from './manifest.js';import { yesNoMessages } from './messages.js';
export const yesNoEditor: EditorDescriptor = { manifest: yesNoManifest, messages: yesNoMessages, label: 'editor.label', description: 'editor.description', icon: 'check-circle', idPrefix: 'yesno', template: '<acme-yes-no id="{{id}}" answer="yes"><acme-statement>{{t:template.statement}}</acme-statement></acme-yes-no>', fields: [{ kind: 'attribute', name: 'answer', label: 'editor.answer' }],};template is what the type's entry in the insert menu inserts. Whole exercises with sample content can come too, as
named starters in templates (the menu lists them under "Exercises"), and a type with a picture has inspector fields
for zones and pins on it: Authoring kit.
6. Register
Section titled “6. Register”An option replaces its default, so spread the built-ins first. With the astrolabe package, each where it runs:
import { builtInDefinitions, createEngine } from 'astrolabe/server';
const engine = createEngine({ components: [...builtInDefinitions, yesNoDefinition], secret });import { builtInElements, definePlayer } from 'astrolabe/player';
definePlayer({ elements: [...builtInElements, yesNoElement] });import { builtInDefinitions, builtInEditors, builtInElements, defineEditor } from 'astrolabe/editor';
defineEditor({ components: [...builtInDefinitions, yesNoDefinition], editors: [...builtInEditors, yesNoEditor], elements: [...builtInElements, yesNoElement],});With the @astrolabe/* packages, the kit pieces take the same lists:
createAuthoring({ engine, descriptors: [...builtInEditors, yesNoEditor], elements: [...builtInElements, yesNoElement], locale: 'en',});7. Prove it
Section titled “7. Prove it”Add fixtures/<case>/source.html and a hand-written grading.json, then run the conformance runner to generate and
check the view, key and metadata goldens and the grading cases; the leak probe fails the run if a private value
reaches the view. astl conformance [dir] registers the built-in components only, so run your own through
@astrolabe/devkit:
import { CONFORMANCE_SECRET, runConformance } from '@astrolabe/devkit';
const engine = createEngine({ components: [...builtInDefinitions, yesNoDefinition], secret: CONFORMANCE_SECRET });const outcomes = await runConformance('src/yes-no/fixtures', engine, { update: false });Try it
Section titled “Try it”The component above, registered next to the built-ins and graded in the browser:
<astl-doc lang="en"> <acme-yes-no id="sun" answer="yes"><acme-statement>The Sun is a star.</acme-statement></acme-yes-no> <acme-yes-no id="bats" answer="no"><acme-statement>Bats are birds.</acme-statement></acme-yes-no></astl-doc>