Canonical instruction file for all AI coding agents working on this repository.
eXeLearning is an open-source (AGPL-3.0) educational content authoring tool. Educators create interactive learning materials and export them as SCORM 1.2/2004, HTML5, EPUB3, or IMS Content Packages. The new backend runs on Bun + Elysia + Kysely with Yjs for real-time collaboration. The frontend is vanilla JavaScript. Desktop builds use Electron.
Current state: Early development. Prioritize clean, well-organized code.
Before any change is submitted (PR, commit push, or handoff), all of the following must hold:
make fixpasses — lint and formatting are clean.- Unit tests pass —
make test-unitis green (backendbun test+ frontendvitest). - Integration tests pass —
make test-integrationis green. - E2E tests pass —
make test-e2eis green (Playwright). Runmake test-e2e-statictoo if the change affects the static build, embedding, or export flow. - Patch coverage ≥ 90% — every new or modified line must be covered by a test. Check with
make test-coverageand inspect the diff, not just the global percentage. If a line is genuinely untestable, justify it in the PR description. - New code ships with tests in the same PR. A new
.tsfile undersrc/needs a colocated*.spec.ts; a new.jsfile underpublic/app/needs a colocated*.test.js. User-visible flows need an E2E spec undertest/e2e/playwright/specs/. - No skipped or disabled tests without an issue link and explanation.
These apply to every skill below. If you cannot meet them, stop and ask the user — do not submit partial work.
- No workarounds. Always build full, long-term-sustainable implementations for >1000 users. Never create compatibility shims or half-baked solutions.
- Do not remove existing features or UI options unless explicitly asked. Stub/annotate unfinished work instead of deleting it.
- Single source of truth. When two code paths need the same data, extract a shared function. Duplicated logic is a bug waiting to happen. Example: PR #1564.
- Extract testable pure functions. When fixing a bug in inline arithmetic or logic, extract it into a named function with edge-case handling. Example: PR #1546.
- Respect platform differences. File operations must work on Windows (EBUSY file locks), macOS, and Linux. Kill processes before deleting their files. Use
path.join()always.
| Files you are touching | Work type | Test runner | Lint command |
|---|---|---|---|
src/**/*.ts |
Backend | bun test |
make fix |
public/app/**/*.js |
Frontend | vitest |
make fix |
public/files/perm/idevices/** |
iDevice | vitest |
make fix |
src/shared/export/** |
Exporter | bun test |
make fix |
src/db/migrations/** |
Database | bun test |
make fix |
src/routes/api/v1/** |
API v1 | bun test |
make fix |
src/websocket/**, src/yjs/** |
WebSocket/Yjs | bun test |
make fix |
public/app/yjs/** |
Yjs client | vitest |
make fix |
test/e2e/playwright/** |
E2E test | playwright |
make fix |
translations/** |
i18n | N/A | make fix |
assets/styles/** |
Styles | N/A | make css |
Critical: Frontend tests (public/) use Vitest. Backend tests (src/) use Bun test. Never swap them. Frontend tests fail with "window is not defined" under Bun.
make deps # Install dependencies (preferred over bun install)
cp .env.dist .env # Create env file (auto-created by make check-env)
make up-local # Local dev server (web only)
make up-local APP_ENV=prod # Local dev server (prod mode)
make run-app # Electron desktop app
make bundle # Build all assets (TS + CSS + JS)
make up # Docker dev environment| Category | Command | Description |
|---|---|---|
| Build | make bundle |
Build all assets (TS + CSS + JS bundle) |
| Build | make build-static |
Build static PWA distribution |
| Run | make up-local |
Local dev server |
| Run | make run-app |
Electron + backend |
| Run | make up |
Docker dev environment |
| Test | make test-unit |
All unit tests (backend + frontend) — use this |
| Test | make test-frontend |
Frontend tests only (Vitest) |
| Test | make test-integration |
Integration tests |
| Test | make test-e2e |
E2E tests (Playwright) |
| Test | make test-e2e-static |
E2E tests against static build |
| Test | make test-coverage |
Tests with coverage report |
| Lint | make fix |
Autofix lint + check — always run after changes |
| Lint | make lint |
Lint without fixing |
| i18n | make translations |
Extract new translation keys |
| CLI | make create-user |
Create a user account |
| CLI | make export-* |
Export via CLI (html5, scorm12, scorm2004, epub3, ims) |
| Location | Pattern | Runner |
|---|---|---|
src/**/*.spec.ts |
Backend unit tests | bun test |
public/app/**/*.test.js |
Frontend unit tests | vitest |
public/libs/**/*.test.js |
Frontend lib tests | vitest |
public/files/perm/idevices/**/*.test.js |
iDevice tests | vitest |
test/e2e/playwright/specs/*.spec.ts |
E2E tests | playwright |
# Backend (single file)
bun test src/services/my-service.spec.ts
# Frontend (single file)
npx vitest run public/app/workarea/project/projectManager.test.js
# Frontend (specific test name)
npx vitest run public/app/path/file.test.js -t "test name"
# E2E (single file)
bun x playwright test --project=chromium test/e2e/playwright/specs/my-test.spec.tsPatch coverage ≥ 90% is required on every PR, measured against the lines you added or modified — not the global project average. A PR that only raises global coverage but leaves new lines uncovered does not pass.
- Backend (
src/): global target 90%, patch coverage ≥ 90% (hard gate). - Frontend (
public/app/): global target 80%, patch coverage ≥ 90% on changed lines. - Every new
.tsfile undersrc/must ship with a colocated*.spec.tsin the same PR. - Every new
.jsfile underpublic/app/must ship with a colocated*.test.jsin the same PR. - User-visible behavior changes (workarea, preview, export, embedding, collaboration) must include or update a Playwright spec under
test/e2e/playwright/specs/. - Before submitting: run
make fix && make test-unit && make test-integration && make test-e2eand inspectmake test-coveragefor the diff. All four must be green. E2E may be slow — budget time for it rather than skipping. - Do not mark tests as
.skip/.todoto land a PR. If a test cannot run in CI, open an issue and link it in the PR description.
Avoid mock.module() in Bun tests — it can cause test pollution. The project uses a DI pattern instead:
// Source file: export configure/resetDependencies
export function configure(newDeps: Partial<Deps>): void { deps = { ...defaultDeps, ...newDeps }; }
export function resetDependencies(): void { deps = defaultDeps; }
// Spec file: inject mocks via DI
beforeEach(() => configure({ queries: { findById: mockFindById } }));
afterEach(() => resetDependencies());See backend-service and backend-route skills for full details.
Always run make fix after any code changes. This runs Biome for both TypeScript and JavaScript.
Config: biome.json — 120 char line width, 4-space indent, single quotes, trailing commas, semicolons always.
Note: Biome formatter is disabled for public/app/** (legacy code). Linting still applies.
The Yjs Y.Doc in the browser is the canonical document state.
CLIENT (Browser) SERVER (Bun/Elysia)
───────────────── ────────────────────
YjsDocumentManager (Y.Doc) SessionManager: lightweight metadata
├── navigation (Y.Array) WebSocket: stateless relay
├── metadata (Y.Map) Database: projects + yjs snapshots
├── assets (Y.Map) Filesystem: FILES_DIR/assets/{uuid}/
└── themeFiles (Y.Map)
- ELP extraction and export generation happen client-side by default
- Server stores assets permanently, persists Yjs snapshots, relays WebSocket
- Server-side exports are used by CLI commands and the external API
- REST API v1 (
/api/v1/*) is designed for external integrations — internal frontend uses Yjs + WebSocket instead
Every opened project gets a UUID session ID. Server maintains lightweight in-memory Map<sessionId, ProjectSession>.
Client-side storage:
| Storage | Pattern | Purpose |
|---|---|---|
| IndexedDB | exelearning-project-{uuid} |
Yjs Y.Doc persistence |
| IndexedDB | exelearning |
User preferences |
| IndexedDB | exelearning-resources-v1 |
Theme/library cache |
| Cache API | exe-assets-{uuid} |
Blob storage for images/files |
FILES_DIR resolution: ELYSIA_FILES_DIR (tests) → FILES_DIR (.env) → ./data/ (fallback).
FILES_DIR/
├── assets/{projectUuid}/ # Permanent project assets
├── tmp/{year}/{month}/{day}/{id}/ # Temporary files
├── dist/{year}/{month}/{day}/{id}/ # Ready-to-download exports
├── chunks/ # Upload chunks
├── themes/site/ # Custom themes
└── exelearning.db # SQLite DB (if pdo_sqlite)
Key rules:
- Directories created lazily (on-demand), never eagerly
- Assets use project UUID, not numeric ID
- Always use
isPathSafe()for user-supplied paths - Use
path.join()for cross-platform paths
- i18n:
_()for GUI strings,c_()for content strings,| transin Nunjucks templates. Avoid hardcoded English. See i18n skill. - Styles: Prefer SCSS classes in
assets/styles/over inline styles. See doc/development/styles.md. - No framework: Vanilla JavaScript in
public/app/.
- Routes: Elysia plugins with
{ prefix: '/api/...' }, registered insrc/index.tsvia.use() - Queries: Kysely functions always take
dbparameter (dependency injection) - Migrations: Sequential numbering in
src/db/migrations/, must work across SQLite/PostgreSQL/MariaDB - ZIP: JSZip for extraction, Archiver for creation. Check
zipEntry.dirbefore reading.
Service Worker (public/preview-sw.js) intercepts /viewer/* requests. Html5Exporter.generateForPreview() generates files in memory, sends to SW via postMessage, iframe loads from SW cache.
ZIP archives containing content.xml (modern) or contentv3.xml (legacy). Legacy .elp files from pre-v3.0 must be supported — handled client-side by ElpxImporter.importLegacyFormat().
Two paths exist. The primary path is entirely browser-side; the server never parses ELP files in the normal flow.
- Primary (browser, direct import): User selects
.elp/.elpx→ browser imports in memory via Yjs (importElpDirectly→importFromElpxViaYjs) → UI refreshes from Y.Doc → saved to server on explicit save/autosave. - Fallback (chunked upload): Browser uploads in 15 MB chunks to
POST /api/project/upload-chunk→ server concatenates into temp file (no parsing) → browser reloads workarea with?import=...and imports client-side → browser callsDELETE /api/project/cleanup-importto remove temp file.
Key rule: the server is a temp store in the fallback path. The browser is always responsible for importing into Yjs.
The UI tries browser-side first, falling back to server-side.
- Primary (browser): UI triggers export →
SharedExportersgenerates ZIP in memory → browser downloads (web) or Electron saves to disk. - Fallback (server): Browser POSTs to
/api/export/:sessionId/:exportType/download→ server builds Y.Doc from DB/structure, runs exporters, writes ZIP todist/→ streams back for download. Used by CLI commands and the external API.
Static editor embeds in LMS plugins (WordPress, Moodle, Drupal, Omeka-S) via iframe + postMessage. Key files: RuntimeConfig.js, Capabilities.js, EmbeddingBridge.js, app.js, previewPanel.js, main.scss. See doc/development/embedding.md.
Configure via .env file (use .env.dist as template).
| Variable | Description | Default |
|---|---|---|
APP_PORT |
Server port | 8080 |
DB_PATH |
SQLite database path | /mnt/data/exelearning.db |
DB_DRIVER |
Database driver | pdo_sqlite |
FILES_DIR |
Root for assets/tmp/dist | /mnt/data/ (prod), ./data/ (dev) |
APP_SECRET |
JWT secret | (required) |
BASE_PATH |
URL prefix for subdirectory install | (empty) |
APP_AUTH_METHODS |
Auth methods | password |
Use built-in debug flags before adding ad-hoc logs.
ELPX export timing:
window.eXeLearning.config.debugElpxExport = true;
// After export: window.__lastElpxExportSummary, window.__lastElpxExportTimelineSave memory profiling:
window.eXeLearning.config.debugSaveMemory = true;
// After save: window.__lastSaveMemorySummary, window.__lastSaveMemoryTimelineFull details: doc/development/profiling.md
Credentials: user@exelearning.net / 1234
Fixtures: test/fixtures/ and test/fixtures/xml/
Helpers (test/e2e/playwright/helpers/workarea-helpers.ts):
waitForAppReady, openElpFile, saveProject, openPreviewPanel, getPreviewFrame, addIdevice, editIdevice, saveIdevice, deleteIdevice, expandIdeviceCategory
Key selectors:
// Login
'input[type="email"]', 'input[type="password"]', 'button[type="submit"]'
// Workarea
'#head-bottom-preview', '#head-top-save-button'
// Preview
'#preview-iframe' (frameLocator), 'article', 'nav a[href*="html/"]'Guidelines:
- Prefer deterministic waits (
waitForFunction,waitFor) overwaitForTimeout()for async operations - Each test should create its own project for isolation
- Use helpers from
workarea-helpers.tsto avoid duplication - Review existing specs in
test/e2e/playwright/specs/for patterns - See e2e-test skill for full details
Domain-specific guidance lives in .agents/skills/*/SKILL.md.
| Skill | When to use |
|---|---|
| backend-route | Adding/modifying Elysia API routes |
| backend-service | Adding/modifying business logic services |
| frontend-module | Working in public/app/ vanilla JS |
| idevice | Creating/modifying interactive devices |
| exporter | Adding/modifying export formats |
| database-migration | Changing database schema |
| e2e-test | Writing Playwright E2E tests |
| websocket-yjs | Real-time collaboration code |
| i18n | Adding/modifying translations |
| api-v1 | External REST API v1 endpoints |
| Topic | File |
|---|---|
| Contributing | doc/development/contributing.md |
| Testing | doc/development/testing.md |
| Version control | doc/development/version-control.md |
| Internationalization | doc/development/internationalization.md |
| Real-time/Yjs | doc/development/real-time.md |
| REST API v1 | doc/development/rest-api.md |
| Embedding in LMS | doc/development/embedding.md |
| Profiling | doc/development/profiling.md |
| Styles/Themes | doc/development/styles.md |
| Conventions | doc/conventions.md |
| Architecture | doc/architecture.md |