Skip to content

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.

yes-no/manifest.ts
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.

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.

yes-no/messages/ui/en.json
{
"ui.yes": "Yes",
"ui.no": "No",
"ui.solution": "Correct answer: {answer}"
}
yes-no/ui-messages.ts
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 },
];
yes-no/messages/en.json
{
"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"
}
yes-no/messages.ts
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 },
},
];
yes-no/definition.ts
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.

yes-no/element.ts
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.

yes-no/editor.ts
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.

An option replaces its default, so spread the built-ins first. With the astrolabe package, each where it runs:

the server
import { builtInDefinitions, createEngine } from 'astrolabe/server';
const engine = createEngine({ components: [...builtInDefinitions, yesNoDefinition], secret });
the student's page
import { builtInElements, definePlayer } from 'astrolabe/player';
definePlayer({ elements: [...builtInElements, yesNoElement] });
the author's page
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',
});

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 });

The component above, registered next to the built-ins and graded in the browser:

exercise.html
<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>
The Sun is a star. Bats are birds.