An interactive Linux learning platform that teaches the command line through short lessons, hands-on challenges, and a safe in-browser terminal.
No login. No accounts. No database. Open the page, type commands, learn.
- Bite-sized Markdown lessons covering the core of the Linux command line.
- In-browser terminal sandbox (xterm.js) with a small POSIX-style shell and an in-memory virtual filesystem — nothing touches the user's real machine.
- Auto-graded challenges and end-of-lesson quizzes with score-based completion.
- Anonymous progress tracking via a signed per-browser cookie. No signup, no DB.
- Polished, terminal-inspired dark UI with a shared design-token system (
lx-card,lx-btn,lx-pill,lx-input,lx-progress). - Keyboard shortcuts (
g ljumps to lessons,g hjumps home), tab completion in the sandbox, and clipboard paste viaCtrl+Shift+V. - Ships with Docker support for production-style deployments.
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 LTS |
| Framework | Next.js 14 (App Router) + TypeScript |
| Styling | Tailwind CSS |
| Storage | None — lessons are code, progress is a cookie |
| Terminal | xterm.js + xterm-addon-fit |
| Markdown | react-markdown + remark-gfm |
- Node.js 18.18 or newer (20.x recommended)
- npm
# 1. Install dependencies
npm install
# 2. Start the dev server
npm run devThen open http://localhost:3000. No database to create, no seed script to run — the lesson catalogue is in code and progress is a cookie on the browser.
A multi-stage Dockerfile is included. Production builds use Next.js's output: 'standalone' mode, so the runtime image carries only the traced node_modules plus a single server.js.
# Build and run with Docker directly
docker build -t learninx:latest .
docker run --rm -p 3000:3000 learninx:latestOr with the supplied compose file:
docker compose up --buildThe image is self-contained: no volumes, no database, no migrations.
| Stage | Purpose |
|---|---|
deps |
Install all npm dependencies (build + runtime). |
builder |
Run next build with output: 'standalone'. |
runner |
Minimal node:20-alpine image that runs the standalone server.js. |
On every container start, the runner copies the static assets into the standalone output (idempotent), then launches the Next.js server on port 3000.
learninx/
├── scripts/
│ └── copy-standalone-assets.mjs # Idempotent post-build step
└── src/
├── app/
│ ├── layout.tsx # Root layout: skip link, nav, footer, keyboard shortcuts
│ ├── page.tsx # Landing page (reads progress cookie)
│ ├── loading.tsx # Global loading state
│ ├── not-found.tsx # 404 page
│ └── lessons/
│ ├── page.tsx # Lesson index (grouped by difficulty)
│ ├── [slug]/page.tsx # Lesson detail (markdown + terminal + challenge + quiz)
│ └── [slug]/actions.ts # Server actions: mark complete, submit challenge, submit quiz, reset
├── components/
│ ├── Terminal.tsx # xterm.js sandbox (client-only, with tab complete + paste)
│ ├── TerminalClient.tsx # next/dynamic wrapper with skeleton loader
│ ├── ChallengeRunner.tsx # Auto-graded practice form with hint reveal
│ ├── LessonQuiz.tsx # Multi-question grader with retry
│ ├── CompleteButton.tsx # Manual "Mark complete" button
│ ├── Markdown.tsx # react-markdown wrapper (uses CodeBlock)
│ ├── CodeBlock.tsx # Fenced code block with copy + language label
│ ├── ResetProgressButton.tsx # Wipes the progress cookie
│ ├── KeyboardShortcuts.tsx # `g l`, `g h` global shortcuts
│ └── ui/ # Shared primitives (Icon, Pill, Card, ProgressBar, ScrollProgress)
└── lib/
├── lessons.ts # Lesson + quiz catalogue (plain TypeScript)
├── progress.ts # Signed-cookie progress store (HMAC-SHA256)
├── types.ts # Shared types
└── shell/
├── fs.ts # In-memory virtual filesystem
└── evaluator.ts # POSIX-style shell interpreter
The app does not have user accounts and does not use a database. Instead:
- When the learner marks a lesson complete or submits a quiz, the server action reads/writes a signed cookie named
learninx_progress. The cookie isbase64url(json).base64url(hmac-sha256)and contains the visitor's completed-lesson ids plus their last quiz score per lesson. - The cookie is
httpOnly,sameSite=lax, expires in 1 year, and is verified on every read. - Clearing cookies (or browsing in a private window) starts a fresh profile.
The signing key comes from LEARNINX_SECRET if set, otherwise a per-process random key (fine for dev, resets on every container restart). See Configuration.
This gives each learner a persistent "account" without ever asking them to register and without a database.
The sandbox in src/lib/shell/evaluator.ts implements the most common teaching commands:
- Navigation and inspection:
pwd,cd,ls(incl.-l,-a,-la),cat,head,wc. - File operations:
mkdir(incl.-p),touch,rm(incl.-r,-f),mv,cp. - Permissions and process info:
chmod,ps,top(read-only informational output). - System info:
uname,uptime,free,df,whoami,hostname,date,echo,clear,help. - Simulated editors:
nano,vi,vim,pico,emacs— print a TUI-style view of the file and point the learner to the editing commands that actually mutate the file (echo >,>>, heredocs,sed,printf). - Shell built-ins: command history (Up/Down arrows),
Ctrl+Cto abort a line,Ctrl+Lto clear.
Type help inside the terminal for the full list.
The shell evaluates each command against the in-memory virtual filesystem in src/lib/shell/fs.ts. Running rm -rf / is harmless — the root node is just a JavaScript object.
All lessons live in code. Edit src/lib/lessons.ts and append a new entry to LESSONS, plus a matching block in QUIZ_QUESTIONS:
// src/lib/lessons.ts
export const LESSONS: Lesson[] = [
// …existing entries…
{
id: 'my-new-lesson', // stable id; also used as the quiz join key
slug: 'my-new-lesson', // URL: /lessons/my-new-lesson
title: 'Title',
description: 'Short blurb shown on the lessons index.',
difficulty: 'beginner', // beginner | intermediate | advanced
order: 6, // next available order number
trackCommand: 'grep', // command to surface in the sandbox hint
challenge: 'Find lines containing "hello" in notes.txt',
solution: 'grep hello notes.txt', // pipe-separated alternatives: 'a || b'
content: `# My new lesson
Markdown content goes here.`,
},
];
export const QUIZ_QUESTIONS: QuizQuestion[] = [
// …existing entries…
{
id: 'q-mnl-1',
lessonId: 'my-new-lesson',
order: 0,
prompt: 'Which command prints text to the screen?',
answer: 'echo',
},
];Save the file — Next.js dev server hot-reloads. The new lesson will be live at /lessons/my-new-lesson.
| Script | Description |
|---|---|
npm run dev |
Start the dev server on http://localhost:3000. |
npm run build |
Produce a production build in .next/. |
npm run start |
Copy static assets into the standalone output and serve the build. |
npm run start:dev |
Serve a previously-built bundle via next start (no asset copy). |
npm run lint |
Lint the codebase with next lint. |
The app reads one optional environment variable:
| Variable | Example | Purpose |
|---|---|---|
LEARNINX_SECRET |
a long random string |
HMAC key for the progress cookie. Set this in production so progress survives deploys and restarts. |
No other secrets are required — the app is anonymous and has no third-party integrations.
A few choices are deliberate and worth knowing if you plan to extend the project:
- No database is a feature. The whole point of a learning app is that a visitor can try it instantly. Spinning up Postgres or running migrations would be friction with no payoff for this app's scale. A signed cookie is the simplest store that still gives every browser a persistent profile.
- Lessons in code, not data.
src/lib/lessons.tsis the single source of truth. Edits are typed, reviewed via git, hot-reloaded bynext dev, and ship in the JS bundle — no seed step, no migrations. - No xterm.js on the server. The terminal is loaded client-side via dynamic imports inside a
useEffectso xterm's browser-only globals never reach the server bundle. This is why the lesson page is small on the client initial payload. output: 'standalone'. The Next.js config emits a runnableserver.jsplus a tracednode_modules/directory. The Dockerfile's runtime stage copies just that.- Server actions, not API routes. All writes (lesson completion, challenge submission, quiz grading, progress reset) are Next.js Server Actions colocated with the lesson route under
src/app/lessons/[slug]/actions.ts. This keeps the data flow explicit and visible. - Design tokens, not magic colors.
src/app/globals.cssdefines--lx-bg,--lx-card,--lx-accent, etc. and a small component library (.lx-card,.lx-btn,.lx-pill,.lx-input,.lx-progress) is registered in@layer components. New pages should compose these instead of inventing one-off styles.
| Shortcut | Action |
|---|---|
g then l |
Jump to the lessons index |
g then h |
Jump to the home page |
Tab (in sandbox) |
Autocomplete a command name |
Ctrl+L (sandbox) |
Clear the terminal screen |
Ctrl+C (sandbox) |
Abandon the current line |
Ctrl+Shift+V |
Paste from the clipboard |
↑ / ↓ |
Walk through command history |
Shortcuts are inactive when a text field has focus.
MIT — see LICENSE if present, otherwise standard MIT terms apply.