npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

leitner

v0.1.3

Published

Terminal UI for spaced-repetition review of Flashcard Markdown decks.

Readme

leitner

Terminal UI for reviewing markdown flashcards from ~/notes/flashcards.

Named after the Leitner system (Sebastian Leitner, 1972): boxes of cards that move forward when you get them right and back when you don't — the canonical spaced-repetition method this scheduler is a variant of.

Each ## heading in a markdown file is one card: the heading is the question, everything until the next ## is the answer, and a *** splits a longer front from the back. Review state is kept outside the notes folder; markdown files are never written to.

The deck format is Flashcard Markdown 1.0, which leitner implements as a consumer — it parses anything the format calls valid and never refuses a file over one bad card. Its conformance corpus runs in this project's test suite. docs/format.md covers what the format leaves to an implementation, and the card-identity scheme that decides when an edit costs you review history.

Install

Needs Node 24 or newer.

pnpm add -g leitner

From a clone instead — which is what the development launcher below assumes:

pnpm install
pnpm build

Usage

leitner init   [dir...]  # record where your flashcards live
leitner list   [dir...]  # decks and card counts
leitner cards  [dir...]  # every card's reference and title
leitner note   <ref>     # add a note to a card, or list the notes it has
leitner notes  [dir...]  # every note, orphaned ones included
leitner stats  [dir...]  # totals, due/new/suspended, parse warnings
leitner review [dir...]  # interactive review session
leitner export [dir...]  # review state as a portable JSON bundle
leitner import <file>    # merge a bundle into local state

dir defaults to sourceDirs from ~/.config/leitner/config.json.

Naming a card

leitner cards prints one line per card: the card's reference, then its title. A reference is deckSlug#headingSlug — spanish-verbs#ser-vs-estar — and it is how a card is named outside the file it lives in: readable enough to say out loud, and stable enough to paste somewhere and come back to.

leitner cards --deck spanish-verbs
leitner cards | grep estar

The listing honours --deck, --type and --untyped, and reads no review state — which cards are due is stats's question, not this one.

Two headings in a file that slugify alike take an ordinal (~1, ~2), and the file is reported as a warning, because deleting one of them shifts the other's number. docs/format.md has the derivation and what edits a reference survives.

Notes on a card

A thought about a card that is not about your memory of it — this back conflates two rules, this front gives the answer away, these two cards are the same card — is a note. Notes are keyed by reference and stored in .leitner-notes.json at the root of the source directory, so they travel with the deck rather than with your schedule.

leitner cards | grep estar            # find the card
leitner note spanish-verbs#ser-vs-estar 'the back conflates two rules'
leitner note spanish-verbs#ser-vs-estar   # list its notes, numbered
leitner note spanish-verbs#ser-vs-estar --rm 1
leitner notes                         # every note in the collection
leitner notes --orphans               # only the ones whose card is gone

Text is optional: leitner note <ref> with nothing after it lists that card's notes with the numbers --rm takes. The reference can be shortened as long as it stays unambiguous — spanish-verbs#ser is enough unless two cards answer to it, and then the candidates are printed for you to pick from. A note is added to a card that exists; nothing is filed against a reference that matches none.

Notes accumulate per card rather than replacing one another, and stats counts them alongside the card totals.

Orphans. Rename a heading or move a deck file and the reference breaks, so its notes point at nothing. They are shown, marked as orphaned and carrying the card's title and path as they were when the note was written — never deleted for you. Removing one is leitner note <ref> --rm <n> on the old reference, which still works precisely so an orphan can be cleared. A card filter (--deck, --type, --untyped) hides orphans, since there is no card left for it to match them on.

docs/notes.md has the file format and the reasoning.

More than one directory

Give as many directories as you like and they are read as one collection, in the order written. The arguments replace the configured directories rather than adding to them, so a one-off leitner list ~/work/cards studies that directory alone without touching the config.

They may not contain one another: a file under two of them would be counted as two cards with two separate review histories, so that is refused rather than merged. Two directories holding the same relative path (spanish.md in both) are allowed, but those two cards then share one review record and grading one schedules the other; list and stats print a warning naming both files.

--deck matches a source path as well as a deck slug, so it doubles as a way to study one of the directories: leitner review --deck ~/work/cards.

First run

The first command that needs your flashcards and cannot find that setting asks for the directory and writes the config file, reporting how many cards it found there so a typo does not pass for an empty collection. It then carries on with the command you asked for.

init is the same question on demand — run it to move a collection, or give it the directories (leitner init ~/notes/cards ~/work/cards) to answer without being asked. Asked interactively it keeps offering Another directory? until the answer is blank. leitner init ~/work/cards --add adds to the configured directories instead of replacing them. Every other key keeps its value.

Nothing is written into the notes tree, then or ever. Passing dir skips the question, and so does any run whose stdin or stdout is not a terminal: with no directory configured, those fail with a message rather than block on a prompt nobody can see.

Local command

bin/leitner is the development launcher: it rebuilds only when sources changed and writes build output to stderr so list/stats/export stdout stays parseable. It execs node from wherever it was called, so a relative dir argument means what it looks like it means. Expose it as a command with a thin wrapper:

cat > ~/.local/bin/leitner <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

exec /path/to/leitner/bin/leitner "$@"
EOF
chmod +x ~/.local/bin/leitner

Flags

--add                   init: add the directories instead of replacing them
--deck <slug-or-path>   only decks matching slug or source path (skips the picker)
--type <type>           frontmatter type, matched verbatim (the format defines no set)
--untyped               only cards whose file declares no type
--due                   only due cards
--new                   only new cards
--limit <n>             cap queue size (review defaults to dailyLimit, 50)
--state <path>          state file (default ~/.local/share/leitner/review-state.json)
--images                enable inline image previews (kitty graphics protocol)
--out <path>            export: write here instead of stdout
--prune                 export: drop records whose cards no longer exist
--rm <n>                note: remove the card's note number n (counting from 1)
--orphans               notes: only notes whose card no longer exists
--merge <strategy>      import: newer (default) | theirs | ours
--dry-run               import: report what would change without writing
-h, --help              show this help

Review keys

review opens a deck picker first, unless the session is already narrowed to one deck — by --deck, or by defaultDeckFilter in the config, which sets the same option. Pick All decks to study everything.

When a deck runs out, the completion screen offers the picker again, so one review can cover several decks; q ends the whole session and prints the total. A session narrowed by --deck has no picker to return to, so it offers q and a practice pass over the deck it was given.

A collection with nothing due still opens the picker, showing 0 due against every deck. Only a collection with no cards in it at all exits first.

enter/space  select deck (picker) · reveal answer, then next card (review)
             back to decks (done)
p            practise the whole deck (picker, done screen)
1 2 3 4      grade: again / hard / good / easy
j/k, arrows  move selection / scroll body
/            filter decks (picker) · search cards (review)
.            show the hidden decks, greyed (picker)
H            hide the deck under the cursor, or show it again (picker)
s            suspend card
u            undo last grade
n            note the card: write one, or look at the ones it has
e            edit card in $EDITOR
i            image preview (needs --images)
b            back to the deck picker (done screen)
esc          clear an active search
q            quit

The session runs on the terminal's alternate screen, like vim or less, so it leaves the scrollback untouched. The counts of cards reviewed and practised are printed on the normal screen once it exits.

Noting a card mid-session

n opens a small composer under the card: the notes it already has, and a line to write another. Enter saves and closes, enter on an empty line just closes — so n is also how you read them — and esc cancels. Grading keys do nothing while it is open.

The note goes to .leitner-notes.json in the source directory the card came from, the same file leitner note writes, and a card that has notes shows a 📝 count beside its title. Notes can be written during a practice pass: a practice pass schedules nothing, and a note is not scheduling.

If the file cannot be written the session says so and keeps the note for the rest of the session rather than dropping it on the floor.

Renaming a heading through e carries the card's notes to its new reference, the way it already carries the review record. A rename made in your own editor, outside a session, orphans both.

Practising a deck

p opens a deck outside the schedule: every card in it, in deck order, suspended ones aside, however far out the next review is. It is there for the exam on Tuesday and the deck that says "not for three weeks". Press it on a row in the picker, or on the completion screen to reopen the deck just finished, which is the way in when --deck skipped the picker.

A practice pass schedules nothing. There is no grading, no suspending, and nothing is written to the state file, so an evening of last-minute revision leaves the schedule exactly as it found it. space or enter reveals a card and the same key moves to the next one. Practised cards are counted apart from reviewed ones, on screen and in the totals printed at the end.

e still edits, because a renamed heading has to keep its review record. docs/scheduling.md has the reasoning.

Hiding a deck

H in the deck picker hides the deck under the cursor: an archive, a deck someone else maintains, one collection of several you rarely study. The header says how many are out — 3 decks · +2 hidden — rather than letting them vanish, . shows them again, greyed, and H on a revealed deck puts it back in the list. The reveal lasts as long as the picker is on screen; leaving it for a deck and coming back starts from the config again.

A hidden deck is out of All decks too, because that row means the decks on screen — press . first to study everything. Hiding is a picker setting and nothing else: list, stats and export still see every deck, --deck opens a hidden one directly, and no card's schedule changes. Suspending (s) is what stops a card coming round; this only stops a deck being offered.

H writes hiddenDecks in the config, adding the deck's source path and removing it again. The rest of the file is left exactly as it was, so a key with a config open in another window costs you nothing but that one line. The list can also be written by hand:

{ "hiddenDecks": ["german-verbs", "~/notes/flashcards/archive"] }

Entries are matched the way --deck is: a deck slug, or any part of a source path, so one entry can stand for a whole directory. H only ever takes back an entry that is the deck's own path — pressed on a deck hidden by a directory entry it says which entry that is and changes nothing, because dropping it would show every other deck under it too.

Editing a card

e hands the terminal to editor from the config, or failing that $VISUAL, $EDITOR, or vi, opened on the card's ## heading in its source file. The config key is a command line, the way the variables are, so a setting of "editor": "code --wait" works. Editors whose line syntax is known get taken there directly (+N for the vi and emacs families, path:line for helix, --goto for VS Code); anything else opens at the top of the file. Editors that detach by default are launched with --wait so the session waits for the file rather than reparsing it unedited.

On return the file is reread and the session picks up where it left off, with the card's new text. Because a card's id hashes its heading text and position (so ids survive across machines without an index file), renaming a ## heading or inserting a card above one would otherwise orphan the review history. The reread pairs the cards before and after the edit and carries the records across; when two or more headings were renamed in one pass the pairing is ambiguous, so those cards are left to come back as new rather than risk attaching history to the wrong card.

Scheduling

A minimal SM-2-style algorithm: again comes back in 10 minutes and counts a lapse, hard grows the interval slowly and lowers ease, good multiplies the interval by ease, easy grows faster and raises ease. Anki compatibility is not a goal, and there is no .apkg on either side. docs/scheduling.md has the exact transitions, the queue order, the state file, and the config keys.

Image previews

--images renders attachments as real pixels using the kitty graphics protocol, so it needs kitty or ghostty. Only PNG is transmitted as pixels — the protocol's direct file transmission is defined for PNG, and files are checked by magic bytes rather than extension. Everything else stays a text attachment line.

Inside tmux the escapes must be forwarded:

tmux set -g allow-passthrough on

review --images reports when passthrough is off rather than drawing nothing.

Moving state between machines

leitner export --out review-state.json     # on the first machine
leitner import review-state.json           # on the second

import accepts an export bundle or a raw state file. The default newer strategy keeps whichever copy of a card was reviewed most recently; theirs always takes the incoming record, ours only fills in unseen cards. Pair with --dry-run to see the effect first.

Development

pnpm test          # vitest
pnpm typecheck     # tsc --noEmit over src and tests
pnpm lint          # oxlint
pnpm format:check  # oxfmt, read-only (pnpm format writes)
pnpm build         # tsc -p tsconfig.build.json, into a cleared dist/

tsconfig.json is the checking config and covers tests/ too; pnpm build uses tsconfig.build.json, which narrows the input to src so only library code is emitted to dist.

pnpm install runs prepare, which installs the git hooks in lefthook.yml. The only one is a shared commit-msg rule fetched from lefthook-rules; the checks above are not wired to a hook, so run them yourself before a commit.

Layout: src/deck.ts (Flashcard Markdown → parsed deck; the conformance surface, pure and free of I/O), src/tags.ts and src/diagnostics.ts (the format's tag grammar and its closed code list), src/parser.ts (files → cards: ids, resolved image paths), src/render.ts (markdown → styled terminal lines), src/scheduler.ts (grading), src/state.ts (JSON store), src/transfer.ts (import/export merge), src/queue.ts (due/new ordering and deck summaries), src/images.ts (kitty graphics), src/editor.ts ($EDITOR invocation), src/edit.ts (carrying records across re-ids), src/cli.ts (commands), src/tui/ (Ink deck picker and review screen).