Skip to content

Authoring kit

@astrolabe/editor adds the Astrolabe parts of an editor to the WYSIWYG editor you already have: an insert menu, an inspector for answers and settings, a live issues list and a preview that grades. Your editor connects through one interface, EditorAdapter. How to use it: Any WYSIWYG editor. The kit assembled around a surface, ready to use, is <astl-editor>; a running example on a textarea: Authoring kit.

createAuthoring(options) returns the editor-independent logic (no DOM, so it also runs in workers, servers and agent tools).

Option Type Meaning
engine Engine validates and compiles; give it a throw-away secret such as 'editor-preview-only-key'
descriptors EditorDescriptor[] one per component authors can insert and inspect; each tag must be registered in the engine
elements ElementEntry[] player elements for <astl-preview>, eager or lazy
locale string the author's UI locale: labels, issues, template texts
catalogs MessageCatalog[] more or overriding messages (any namespace), for example another locale
styleTemplates StyleTemplate[] looks the inspector offers per component and for the document, rendered by <astl-preview> (Looks); the inspector also offers each type's variants
urlPolicy UrlPolicy the engine's URL policy: editors show only pictures it allows, and <astl-preview> sets it as the page's player policy (the default never replaces a policy the page set). Default isSafeUrl (URLs)
uploadAsset UploadAsset the host's upload, (file, { signal }) to a Promise of { src, width?, height? }: stores a picture file (or a component's media file, through a url field with accept) and gives its address (and size in pixels). The picture dialog, pasted and dropped pictures, picture fields and url fields with accept use it; the address must pass urlPolicy, or nothing is written. signal aborts when the author cancels or picks another file. Without it, pictures are added by address only, and the UI says so; files too
Member Returns Meaning
instantiate(tag, source) string the descriptor's template with a fresh id: <idPrefix>-<n>, the smallest n source does not use; {{t:key}} becomes an escaped message; other components written without an id get fresh ones too; an element of a type with a companion and that attribute empty names the first such element of the template, else of source (or the one withCompanion adds). A third argument, the id of a named starter, instantiates that starter instead. Throws for an unknown tag or starter
withCompanion(tag, source) string | null for a descriptor with a companion ({ tag, attribute }, a gap and its word bank): source with that tag's template at the end of the document when it has no such element yet; else null. <astl-insert-menu> applies it after inserting a default template and keeps the new element selected
templates(tag) EditorTemplate[] the named starters of a type (its descriptor's templates), none without
inspect(elementHtml, source?) InspectorModel | null the inspector model of one component element: { tag, id, label, icon, fields }; with the whole document source, fields offer what other elements hold (a drop lists the words of its bank)
inspectDocument(source) InspectorModel | null the look of the whole exercise: a style-template field for <astl-doc>; null when no template allows the document scope and none is written (Looks)
update(elementHtml, change) string the element with one InspectorChange applied; everything else stays byte-identical
validate(source) ValidationReport { ok, issues } from the engine
describe(issue) string an issue as a sentence in the authoring locale
assignIds(source) string source with fresh ids on pages and components that lack one
outline(source) Outline pages, their components and error and warning counts
preview(source) Promise<PreviewResult> compiles with a random per-session salt and grades locally ({ ok, view, checker, issues })
descriptor(tag) EditorDescriptor | null the descriptor of a tag
t(namespace, key, params?) string a message in the authoring locale
engine, locale, descriptors, elements the options
styleTemplates, urlPolicy the options, with their defaults
uploadAsset UploadAsset | null the host's upload, or null

pictureHtml({ src, alt, width?, height?, caption? }) writes the source of a picture as the picture dialog gives it: an <img> (it sits in text), or with a caption a <figure> with its <figcaption>. Every value is escaped and only the attributes of the content format are written; alt: '' is a decorative picture.

A type can bring whole exercises with sample content next to its default template, which stays what its own entry inserts: templates: [{ id, label, description?, icon?, template }] on its EditorDescriptor. Ids are kebab-case and unique in the type (createAuthoring throws otherwise); each template has the placeholders of template (exactly one {{id}}, texts as {{t:key}} from the type's catalogs, so a starter comes in every UI language).

A starter can hold more components than its own, such as the word bank's "Drag words into gaps": {{id}} goes on the type's own element (the bank) and the others (its gaps) are written without an id. Each insert gives them fresh ones, and every element of a type with a companion (a gap) that leaves the attribute out names the starter's own bank.

type InspectorChange =
| { kind: 'attribute'; name: string; value: string | boolean | null } // true = bare attribute, false/null = remove
| { kind: 'text-list'; tag: string; values: readonly string[] } // e.g. the <astl-alt> alternatives
| { kind: 'child-flag'; tag: string; attribute: string; index: number; value: boolean } // e.g. `correct` on option 2
| { kind: 'child-choice'; tag: string; attribute: string; index: number; value: string | null } // e.g. `answer` on statement 1
| { kind: 'child-attributes'; tag: string; index: number; attributes: Record<string, string | null> } // a zone's shape and coordinates
| { kind: 'child-add'; tag: string; attributes: Record<string, string> } // a new empty child after the last of its tag
| { kind: 'child-remove'; tag: string; index: number }; // one child, its content included

Inspector fields come from the EditorDescriptor (or from the manifest when it has no fields); controls follow the attribute types: text, number with min, max and step, checkbox, select with translated options from editor.value.<attribute>.<value>, text-list, child-flag, child-text (a text input per child for a free-text attribute, e.g. the word each clue of a word search hides; an empty input removes it, through a child-attributes change), child-choice (radios of the enum values, or with options: '<tag>' a select of the ids of those children) and child-table ({ kind: 'child-table', tag, columns: [{ attribute, label }], label, help? }: a row per child, named by its text, with a text, number or select control per column, Remove per row and Add, through child-attributes, child-remove and child-add changes; a crossword's words). An attribute field with options: { from, tag } is a select of the ids of the tag children of the element whose id is in the attribute from, by their text (a drop's answer among the words of its bank bank); without the document or that element it keeps its own control. Each component's reference page lists its fields.

Two field options are for media:

  • { kind: 'attribute', name, label, help?, accept } on a url attribute: next to its address, an upload of the files accept names (a file input's accept, e.g. video/mp4,video/webm) through uploadAsset. The address it gives goes to the element the upload was for, when urlPolicy allows it; the field says what happened.
  • { kind: 'text-list', tag, label, help?, multiline: true }: each child in a text area that keeps its line breaks (a transcript), not on one line.

Two field kinds are for components with a picture (<astl-picture-field> draws them):

  • { kind: 'picture-regions', tag, src, label, help? }: zones on the picture of the element's url attribute src, one child of tag each. A zone has id, shape (rect, circle or polygon), x and y (a rectangle's top-left corner, a circle's centre), width and height (rectangle), r (circle, a share of the width), points (polygon: x,y x,y ...) and an optional label. Every coordinate is a percentage of the picture: x of its width from the left edge, y of its height from the top.
  • { kind: 'picture-points', tag, src, label, help? }: pins on the picture, one child of tag each, with id, x, y (percent) and label.

Both show the picture, take its address or an upload, and draw each zone or pin in the palette colour of its number (--astl-palette-1 to -8) with the number on it, so no zone is told apart by colour alone. Authors drag on the picture to draw a zone or click it to place a pin, and edit every value in number fields (no pointer needed): add, remove, change the shape around the same centre, add or remove polygon points, label.

Call defineAuthoringElements() once (nothing is defined on import), then give each element its authoring and adapter properties. Use as many as you like.

Element Members What it does
<astl-insert-menu> insert(tag, template?): string | null, insertPicture(): Promise<string | null> buttons in named groups: "Components" (each type with its default template), "Exercises" (the named starters, when any) and "Content" ("Picture"); each button's name is its label and description. A press inserts at the cursor
<astl-inspector> model: InspectorModel | null the form for the component at the cursor: answers, alternatives, correct options, scoring, points
<astl-issues> issues: Issue[] | null, validate() live validation in the UI locale, 300 ms after the last change, errors first; choosing an issue calls adapter.select(id)
<astl-preview> state, player, refresh(): Promise<void> compiles on request and plays the result in <astl-player> with local grading; an invalid source shows its errors instead. state: idle, loading, ready, invalid or failed
<astl-picture-dialog> ask(request?): Promise<PictureFields | null> a modal dialog for one picture: a file (uploaded with uploadAsset) or an address, the alt text (required, or "decorative" for alt="") and, for a new picture, an optional caption. The address must pass urlPolicy. request: { current?, file?, caption?, decorative? }; null when the author cancels. The insert menu has its own
<astl-picture-field> field, authoring the inspector's picture fields (above); it fires astl-field-change with an InspectorChange

All of them use native controls, the --astl-* tokens (override them on the elements), data-theme for the theme, and the parts root and title (plus button, icon and player where they have them). See Looks.

EditorAdapter (@astrolabe/core) is the only thing an editor integration implements:

Member Meaning
getSource() / setSource(source) the whole <astl-doc> as source HTML
insert(html) insert an instantiated template at the cursor
getElement(id) / replaceElement(id, html) one component element as source HTML, read and replaced in place
getSelection() { id, tag } of the component at the cursor, or null
select(id) move the cursor to a component or a page and focus the editor
onChange(listener) / onSelectionChange(listener) subscriptions; each returns an unsubscribe function

createTextareaAdapter(textarea) is the reference implementation: it edits the source as text, keeps your formatting and finds elements by id. destroy() removes its listeners. insert(html) replaces the selection unless that would break the source: inside a tag it inserts after the tag, inside or on a component after that component, outside <astl-doc> at the end of the last page. Change events fire on input and after the adapter's own edits; selection events on select, keyup, click and focus, only when the component at the caret changes.

scanElements(source) lists pages and components with their { tag, id, start, end } offsets in the text, for text-based adapters. It tolerates comments, quoting, case and unclosed tags.

The kit's own strings are in the namespace astl.editor (editorMessages, English and Polish). Labels, help texts and templates of each component come from its own catalogs (editor.*, template.* keys).