Check your repository
stemin check reads a repository and runs the compile that runs in the browser of a learner when they add it. It writes nothing. So a repository that passes the check imports, and a repository that fails the check does not. The checker reports every fault it finds, each with its file and its line. Run it before each push, and in CI.
Get the checker
Download it
Download the binary for your platform from the releases of the checker. Each file needs nothing else. The links below always give the latest release.
| File | For |
|---|---|
stemin-x86_64-linux | Linux on Intel or AMD |
stemin-aarch64-linux | Linux on ARM |
stemin-x86_64-macos | macOS on Intel |
stemin-aarch64-macos | macOS on Apple silicon |
stemin-x86_64-windows.exe | Windows |
SHA256SUMS | the SHA-256 hash of each file |
On Linux and macOS, download the binary and the hashes with curl, compare the hash, and put the binary on your path. On macOS, use curl: a browser marks a downloaded file, and macOS then refuses to run it. curl sets no mark.
curl -LO https://github.com/stemin-app/stemin-checker/releases/latest/download/stemin-aarch64-macos
curl -LO https://github.com/stemin-app/stemin-checker/releases/latest/download/SHA256SUMS
grep stemin-aarch64-macos SHA256SUMS | shasum -a 256 -c
chmod +x stemin-aarch64-macos
mv stemin-aarch64-macos /usr/local/bin/stemin
On Linux, use sha256sum in place of shasum -a 256, and the name of the Linux file. If you downloaded the file on macOS with a browser, remove the mark first:
xattr -d com.apple.quarantine stemin-aarch64-macos
On Windows, download the .exe file and run it from a terminal.
Build it from source
The checker is open source, in stemin-checker. You need Rust 1.93 or later. Get it through rustup.
cargo install --locked --git https://github.com/stemin-app/stemin-checker stemin
This puts the stemin command in ~/.cargo/bin.
Run it
stemin check
stemin check /path/to/your/repository
stemin check /path/to/your/repository --url https://github.com/alice/algebra
Without a path, it checks the current directory. The path is the root of the repository: the directory that holds the root index.md. --url is optional. The app makes the id of each domain from the address of the repository, and the check passes or fails the same way with any address.
A valid repository gives one line, and the exit code 0:
INFO . is a valid content repository
A repository with faults gives one line per fault, a count, and the exit code 1:
ERROR ./math/alg/linears/solving.md:6: the LaTeX `\frac{a}` will not render: parsing error: expected a token
ERROR ./math/alg/linears/solving.md:6: `reference:nope` names no reference in this domain
ERROR 2 faults
Each line gives the file, a line number where the checker knows one, and the fault. The file path starts with the path you gave. In a terminal the output has colour; in a CI log or a pipe it is plain text.
The checker works in two steps, as the import does:
- The structure. It reads every file, and checks the tree, the front matter, the blocks, the plots and the graph of topics. It reports every fault of this step in one run.
- The compile. It renders every card, flashcard, reference and exam, and resolves every link and every picture. It reports every fault of this step in one run.
The compile runs only when the structure has no fault, because a file that does not load would give false faults about the links to it. So fix the faults of the first step, and run the checker again. A fault that stops the parse of a file can also cause a second fault in its parent: fix the first one, and the second one goes too.
What the structure step finds
The messages below leave out the word ERROR and the path you gave.
| Fault | Message | Read |
|---|---|---|
no root index.md | index.md: no `index.md` at the repository root: every content repository needs one | The root and the domain |
no format:, or a bad one | index.md: `format:` must be a semantic version like `3.0.0`, and this repository declares none | The root and the domain |
| a format this build cannot read | index.md: this repository targets format 1.0.0, and this build reads 2.x: update the app, or pin the repository to a release that speaks 1.0.0 | The root and the domain |
a domain without lang: | math/index.md: `lang:` is missing or malformed: a domain declares one BCP 47 tag, like `en` | The root and the domain |
bad YAML in an index.md | math/alg/index.md: front matter will not parse: did not find expected ',' or ']' at line 2 column 6, while parsing a flow sequence at line 1 column 8 | The tree |
| bad YAML in a deck or a checkpoint | math/alg/linears/solving.md: front matter will not parse: did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 8 | The tree |
| a field the file does not take | math/alg/linears/solving.md: `titel` is not a field of a deck: a deck takes title, draft | The tree |
| a label that does not exist | math/index.md: `formula` is not a label: the labels are domain, section, topic, deck, card, flashcard, question, solution, reference, references, derivation, checkpoint, checkpoints | The root and the domain |
| a tier that the section does not list | math/alg/linears/index.md: tier `graduate` is not in the section's `tiers`, [entrance, university]: use one of them, or add it there | The root and the domain |
a child that order does not list | math/alg/linears/index.md: `extra` is here and `order` does not list it: add it to `order`, or mark the file `draft: true` | The tree |
a name in order with no child | math/alg/linears/index.md: `order` names `missing`, and no file or directory here carries that id | The tree |
a name twice in order | math/alg/linears/index.md: `order` names `solving` more than once | The tree |
order names a draft | math/alg/linears/index.md: `order` names `extra`, which is marked `draft: true`: take it out of `order`, or take the draft mark off | The tree |
| an empty container | math/alg/linears/index.md: this topic holds nothing: give it children, or take it out | The tree |
| a directory in a topic | math/alg/linears/more: `more` is a directory, and only files belong at this depth, so nothing can ever reach it: move it up a level | The tree |
a missing index.md | math/checkpoints/index.md: cannot read: no such file | The tree |
| a file that is not UTF-8 | math/alg/linears/solving.md: cannot read: this file is not UTF-8 text | |
| two nodes of one kind with one id | math/geo/lines/solving.md: `solving` is already the id of a deck, at math/alg/linears/solving.md: ids are unique per kind | The tree |
| a reserved id | math/alg/test/index.md: `test` is reserved, because the app routes on it: rename this one | The tree |
a .md file over 512 KB | math/alg/linears/big.md: this file is 600000 bytes, over the 524288-byte cap on a `.md` file: split it into more decks | The tree |
| a path more than 16 levels deep | math/checkpoints/a/b/c/d/e/f/g/h/i/j/k/l/m/n/q.md: this sits more than 16 levels below the repository root, and nothing is read that deep: move it up | The tree |
| a symbolic link | math/alg/linears/link.md: `link.md` is a symbolic link, and the check does not follow one: put the file itself in its place | The tree |
| a device, a pipe or a socket | math/alg/linears/fifo: `fifo` is not a regular file or a directory: take it out of the repository | The tree |
| a word that is not a block | math/alg/linears/solving.md:23: `lesson` is not a block: the grammar is card, figure, flashcard, question, answer, solution, reference, equation, legend, derivation | The block grammar |
| a block that never closes | math/alg/linears/solving.md:23: block `card` is never closed: add a `:::` line | The block grammar |
a ::: that closes nothing | math/alg/linears/solving.md:23: `:::` closes nothing here | The block grammar |
| a block two levels deep | math/alg/linears/solving.md:27: `::: card` opens two blocks deep, and a block nests one level at most (an `answer` in a `flashcard`, a `legend` in a `reference`): close the block above it first | The block grammar |
| a child block at the top level | math/alg/linears/solving.md:23: `answer` cannot sit on its own: it belongs inside the block it explains | The block grammar |
| a block in the wrong place | math/alg/linears/solving.md:24: `answer` cannot sit inside `card` | The block grammar |
| text outside a block in a deck | math/alg/linears/solving.md:5: this text sits outside any block, so the app never shows it: put it in a `::: card`, or take it out | Decks and cards |
text after the front matter of an index.md | math/alg/linears/index.md:7: this text sits outside any block, so the app never shows it: an `index.md` holds front matter and nothing else: take it out | The block grammar |
| a flashcard without an answer | math/alg/linears/solving.md:9: this block has no `::: answer`: a flashcard needs its back, and a question its answer | Flashcards and solutions |
| the block of format 2 | math/alg/linears/solving.md:9: `exercise` is not a block since format 3: write `flashcard` in a deck and `question` in a checkpoint | The block grammar |
| two flashcards with one id in a file | math/alg/linears/solving.md:13: `q1` is already the id of another flashcard in this file, at line 5: rename one | Flashcards and solutions |
| two figures with one id in a deck | math/alg/linears/solving.md:9: `f` is already the id of another figure in this file, at line 5: rename one | Decks and cards |
| a reference without an id | math/alg/linears/solving.md:23: `::: reference` needs an id, like `::: reference ohms-law`: a `reference:` link names a reference by it | References and derivations |
| a reference without a title | math/alg/linears/solving.md:23: reference `r1` has no `# Title` line: the title is what the learner looks the reference up by | References and derivations |
a legend row without : | math/alg/linears/solving.md:26: `$x$ the unknown` is not a legend row: each line reads `$symbol$: what it means`, and this one has no `:` | References and derivations |
| a plot that does not parse | math/alg/linears/solving.md:24: this `plot` block will not parse, so it would draw nothing: x: missing field `from` at line 1 column 4 | Live plots |
| a plot past a limit | math/alg/linears/solving.md:24: this `plot` block will not parse, so it would draw nothing: `x.ticks` is `0`, and a spacing must be above zero: write one like `0.1` | Live plots |
| a plot outside a card or a figure | math/alg/linears/solving.md:8: a `plot` block draws only in a `card` or a `figure`, and this one sits in `flashcard`, so the app never draws it: move it to a card | Live plots |
| two plots in one card | math/alg/linears/solving.md:11: this `card` already holds a `plot` block, and a card draws only one: put the second plot in a card of its own | Live plots |
a requires target that is not there | math/alg/linears/index.md: `fundamentals` names no topic in this section | References and derivations |
a requires target in another repository | math/alg/linears/index.md: `/phy/eam/fields` points outside this repository, and a reference cannot cross one: move that content here, or drop the link | References and derivations |
a cycle in requires | math/alg/linears/index.md: `requires` is cyclic: /math/alg/linears -> /math/alg/quadratics -> /math/alg/linears | References and derivations |
a picture outside assets/ | math/alg/linears/pic.png: `pic.png` sits outside an `assets/` directory: every asset belongs in one | Math, figures and assets |
a file of another type in assets/ | math/alg/linears/assets/notes.txt: `notes.txt` is not an asset this format carries: use .png, .jpg, .jpeg, .webp or .svg | Math, figures and assets |
| an asset over 256 KB | math/alg/linears/assets/big.png: `big.png` is 300000 bytes, over the 262144-byte cap: shrink it before committing it | Math, figures and assets |
What the compile step finds
| Fault | Message | Read |
|---|---|---|
| LaTeX that does not parse | math/alg/linears/solving.md:6: the LaTeX `\frac{a}` will not render: parsing error: expected a token | Math, figures and assets |
a $ without a partner in a legend meaning | math/alg/linears/solving.md:18: a `$` has no closing `$` in "costs $5" | References and derivations |
| a link to no reference | math/alg/linears/solving.md:6: `reference:nope` names no reference in this domain | References and derivations |
| a link to no reference in another domain | math/alg/linears/solving.md:6: `reference:/math/gone` names no reference in domain `math` | References and derivations |
| a link out of the repository | math/alg/linears/solving.md:6: `reference:/phy/ohms-law` points outside this repository, and a reference cannot cross one: move that content here, or drop the link | References and derivations |
| a link to no figure | math/alg/linears/solving.md:6: `figure:nope` names no figure in this deck | Decks and cards |
a cycle in uses | math/alg/linears/solving.md:9: `uses` is cyclic: /math/a -> /math/b -> /math/a | References and derivations |
| a missing picture | math/alg/linears/solving.md:6: `assets/none.png` is not in this repository | Math, figures and assets |
| a picture on another site | math/alg/linears/solving.md:8: `https://example.com/a.png` is a link to another site, and a card fetches nothing: commit the picture under `assets/` | Math, figures and assets |
| an SVG that is not an SVG | math/alg/linears/solving.md:10: `math/alg/linears/assets/a.svg`: this file holds no <svg> element | Math, figures and assets |
| an SVG that is too large | math/alg/linears/solving.md:10: `math/alg/linears/assets/a.svg`: this SVG holds more than 20000 elements: simplify it | Math, figures and assets |
| a domain with no deck | math/index.md: this domain holds no deck, so it compiles to nothing: give it a section, a topic and a deck | The root and the domain |
| a domain over 32 MB of HTML | math/alg/linears/solving.md: the domain's HTML passes its cap of 32 MB in this file: show each picture in fewer places, or split the domain | Math, figures and assets |
One fault reaches no tool: an expression in a plot that gives no value. The mark then does not show. Open the card in the app and move each slider across its range (Live plots).
See the result
The checker writes nothing. To see what a learner gets, push the repository and add it in the app (Publish and add to Stemin). The app compiles it with the same code, so a course that passes the checker opens.
CI
This GitHub Actions job downloads the checker, compares its hash, and checks the repository on each push:
name: check
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get the checker
run: |
curl -LO https://github.com/stemin-app/stemin-checker/releases/latest/download/stemin-x86_64-linux
curl -LO https://github.com/stemin-app/stemin-checker/releases/latest/download/SHA256SUMS
grep stemin-x86_64-linux SHA256SUMS | sha256sum -c
chmod +x stemin-x86_64-linux
- name: Check the repository
run: ./stemin-x86_64-linux check .
The job fails when the checker finds a fault, and its log lists each fault with its file and its line.