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.

FileFor
stemin-x86_64-linuxLinux on Intel or AMD
stemin-aarch64-linuxLinux on ARM
stemin-x86_64-macosmacOS on Intel
stemin-aarch64-macosmacOS on Apple silicon
stemin-x86_64-windows.exeWindows
SHA256SUMSthe 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:

  1. 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.
  2. 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.

FaultMessageRead
no root index.mdindex.md: no `index.md` at the repository root: every content repository needs oneThe root and the domain
no format:, or a bad oneindex.md: `format:` must be a semantic version like `3.0.0`, and this repository declares noneThe root and the domain
a format this build cannot readindex.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.0The 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.mdmath/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 8The tree
bad YAML in a deck or a checkpointmath/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 8The tree
a field the file does not takemath/alg/linears/solving.md: `titel` is not a field of a deck: a deck takes title, draftThe tree
a label that does not existmath/index.md: `formula` is not a label: the labels are domain, section, topic, deck, card, flashcard, question, solution, reference, references, derivation, checkpoint, checkpointsThe root and the domain
a tier that the section does not listmath/alg/linears/index.md: tier `graduate` is not in the section's `tiers`, [entrance, university]: use one of them, or add it thereThe root and the domain
a child that order does not listmath/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 childmath/alg/linears/index.md: `order` names `missing`, and no file or directory here carries that idThe tree
a name twice in ordermath/alg/linears/index.md: `order` names `solving` more than onceThe tree
order names a draftmath/alg/linears/index.md: `order` names `extra`, which is marked `draft: true`: take it out of `order`, or take the draft mark offThe tree
an empty containermath/alg/linears/index.md: this topic holds nothing: give it children, or take it outThe tree
a directory in a topicmath/alg/linears/more: `more` is a directory, and only files belong at this depth, so nothing can ever reach it: move it up a levelThe tree
a missing index.mdmath/checkpoints/index.md: cannot read: no such fileThe tree
a file that is not UTF-8math/alg/linears/solving.md: cannot read: this file is not UTF-8 text
two nodes of one kind with one idmath/geo/lines/solving.md: `solving` is already the id of a deck, at math/alg/linears/solving.md: ids are unique per kindThe tree
a reserved idmath/alg/test/index.md: `test` is reserved, because the app routes on it: rename this oneThe tree
a .md file over 512 KBmath/alg/linears/big.md: this file is 600000 bytes, over the 524288-byte cap on a `.md` file: split it into more decksThe tree
a path more than 16 levels deepmath/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 upThe tree
a symbolic linkmath/alg/linears/link.md: `link.md` is a symbolic link, and the check does not follow one: put the file itself in its placeThe tree
a device, a pipe or a socketmath/alg/linears/fifo: `fifo` is not a regular file or a directory: take it out of the repositoryThe tree
a word that is not a blockmath/alg/linears/solving.md:23: `lesson` is not a block: the grammar is card, figure, flashcard, question, answer, solution, reference, equation, legend, derivationThe block grammar
a block that never closesmath/alg/linears/solving.md:23: block `card` is never closed: add a `:::` lineThe block grammar
a ::: that closes nothingmath/alg/linears/solving.md:23: `:::` closes nothing hereThe block grammar
a block two levels deepmath/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 firstThe block grammar
a child block at the top levelmath/alg/linears/solving.md:23: `answer` cannot sit on its own: it belongs inside the block it explainsThe block grammar
a block in the wrong placemath/alg/linears/solving.md:24: `answer` cannot sit inside `card` The block grammar
text outside a block in a deckmath/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 outDecks and cards
text after the front matter of an index.mdmath/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 outThe block grammar
a flashcard without an answermath/alg/linears/solving.md:9: this block has no `::: answer`: a flashcard needs its back, and a question its answerFlashcards and solutions
the block of format 2math/alg/linears/solving.md:9: `exercise` is not a block since format 3: write `flashcard` in a deck and `question` in a checkpointThe block grammar
two flashcards with one id in a filemath/alg/linears/solving.md:13: `q1` is already the id of another flashcard in this file, at line 5: rename oneFlashcards and solutions
two figures with one id in a deckmath/alg/linears/solving.md:9: `f` is already the id of another figure in this file, at line 5: rename oneDecks and cards
a reference without an idmath/alg/linears/solving.md:23: `::: reference` needs an id, like `::: reference ohms-law`: a `reference:` link names a reference by itReferences and derivations
a reference without a titlemath/alg/linears/solving.md:23: reference `r1` has no `# Title` line: the title is what the learner looks the reference up byReferences 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 parsemath/alg/linears/solving.md:24: this `plot` block will not parse, so it would draw nothing: x: missing field `from` at line 1 column 4Live plots
a plot past a limitmath/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 figuremath/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 cardLive plots
two plots in one cardmath/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 ownLive plots
a requires target that is not theremath/alg/linears/index.md: `fundamentals` names no topic in this sectionReferences and derivations
a requires target in another repositorymath/alg/linears/index.md: `/phy/eam/fields` points outside this repository, and a reference cannot cross one: move that content here, or drop the linkReferences and derivations
a cycle in requiresmath/alg/linears/index.md: `requires` is cyclic: /math/alg/linears -> /math/alg/quadratics -> /math/alg/linearsReferences and derivations
a picture outside assets/math/alg/linears/pic.png: `pic.png` sits outside an `assets/` directory: every asset belongs in oneMath, 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 .svgMath, figures and assets
an asset over 256 KBmath/alg/linears/assets/big.png: `big.png` is 300000 bytes, over the 262144-byte cap: shrink it before committing itMath, figures and assets

What the compile step finds

FaultMessageRead
LaTeX that does not parsemath/alg/linears/solving.md:6: the LaTeX `\frac{a}` will not render: parsing error: expected a tokenMath, figures and assets
a $ without a partner in a legend meaningmath/alg/linears/solving.md:18: a `$` has no closing `$` in "costs $5"References and derivations
a link to no referencemath/alg/linears/solving.md:6: `reference:nope` names no reference in this domainReferences and derivations
a link to no reference in another domainmath/alg/linears/solving.md:6: `reference:/math/gone` names no reference in domain `math` References and derivations
a link out of the repositorymath/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 linkReferences and derivations
a link to no figuremath/alg/linears/solving.md:6: `figure:nope` names no figure in this deckDecks and cards
a cycle in usesmath/alg/linears/solving.md:9: `uses` is cyclic: /math/a -> /math/b -> /math/aReferences and derivations
a missing picturemath/alg/linears/solving.md:6: `assets/none.png` is not in this repositoryMath, figures and assets
a picture on another sitemath/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 SVGmath/alg/linears/solving.md:10: `math/alg/linears/assets/a.svg`: this file holds no <svg> elementMath, figures and assets
an SVG that is too largemath/alg/linears/solving.md:10: `math/alg/linears/assets/a.svg`: this SVG holds more than 20000 elements: simplify itMath, figures and assets
a domain with no deckmath/index.md: this domain holds no deck, so it compiles to nothing: give it a section, a topic and a deckThe root and the domain
a domain over 32 MB of HTMLmath/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 domainMath, 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.