learn-content-engine
Framework-agnostic TypeScript engine that parses and validates lesson
content from pluggable sources into a canonical lesson object.
It takes raw content (a single-JSON lesson plus a manifest.yaml) and a set
context, and produces canonical lesson / set-entry objects. It contains no
network, storage, or UI code - you supply the bytes and keep fetch +
persistence. The bundled, strict JSON-Schema makes it a self-contained format
reference: you can author and validate lessons without the application the
format originated in (Adaptive Learner).
Beyond validity it checks a lesson against the shared quality minimums
(validateLessonQuality), turns a parametric exercise into a concrete
instance (resolveExerciseVariables) and maps a lesson run's percent to the
verdict of its set's evaluation block (gradeFor), so every consumer gets
the same answer.
Ships the lesson schema it validates against (schema/lesson.schema.json; its version is x-schema-version there).
Install
npm install learn-content-engine
ESM, ships TypeScript declarations, Node >= 18.
As a git dependency
For development loops against an unreleased revision, or when consuming a fork,
install straight from GitHub, pinned to a commit or tag for reproducibility:
// package.json of the host app
{
"dependencies": {
"learn-content-engine": "github:astrapi69/learn-content-engine#<commit-or-tag>"
}
}
dist/ is not committed, so on install npm runs the package's prepare script
(npm run build) to compile dist/ (JS + .d.ts) from source in the checkout.
No extra step is needed in the host - a plain npm install builds the engine.
Quick example
import { parseLesson, validateLesson, type LessonSetContext } from "learn-content-engine";
const context: LessonSetContext = {
language: "fr", target_language: "fr", source_language: "en", domain: "language",
};
const raw = `{ "id": "01", "title": "Greetings", "steps": [
{ "id": "s1", "type": "exercise",
"exercise": { "id": "e1", "type": "free_text", "prompt": "Say hello.", "accept": ["bonjour"] } }
] }`;
const lesson = parseLesson(raw, context); // canonical ContentLesson (set context injected)
const result = validateLesson(JSON.parse(raw)); // explicit, opt-in validation
if (!result.valid) console.error(result.errors); // [{ path, message, id, severity, docAnchor, params? }, …]
Documentation
- Getting started - install, the 5-minute pipeline example.
- Concepts - the pipeline, context inheritance, the legacy alias, schema policy.
- Lesson format reference - every field and exercise type, with tested examples.
- Schema diagrams - four pictures of the format; the two schema-derived ones are generated and drift-gated.
- Authoring patterns - expressing common exercise ideas (true/false, conjugation, synonyms, collocations, word order, sung chunks) with the existing types.
- Validation - the strict schema, the semantic rules, the quality minimums, the error model, and the
/rules entry for browser consumers.
- Extensions - opt-in
ext: exercise types, the portability contract, the registry.
- QTI interop - the optional QTI import/export adapter (2.x and 3.0 dialects) and its
qti import / qti export command, mapping table, fidelity limits.
- Architecture - the engine boundary, consumer parity, roadmap.
- - exercise types benchmarked against Moodle, H5P, QTI 3.0, Canvas and Duolingo; the core / extension / adoption tiers, why not QTI or H5P as the native format, what is still open.
Reference extensions
The core enum has six exercise types. Twelve more are worked out as
reference extensions under src/examples/ext-ref-*, each with an engine half
(payload validation), a consumer half (minimal render and grade), tests, and a
doc-gated example lesson in Extensions. They are
excluded from the published package; a consumer adopts one under its own
vendor namespace (adaptive-learner runs ext:ref-categorization as
ext:al-categorization). Before concluding a type is missing, look here:
Parametric exercises (sampled variables, computed answers) are not an
extension but a core field since schema 1.14: variables on any exercise
(engine#151, see Variables).
The gap analysis behind this list is
docs/comparative-analysis.md.
Public API
| Export | Kind | Purpose |
|---|
parseLesson | fn | raw source + context → canonical ContentLesson (via an adapter) |
singleJsonLessonAdapter | fn | the built-in single-JSON source adapter |
parseManifest | fn | raw manifest.yaml text → ParsedManifest |
asContentSetEntry | fn | raw parsed set → canonical ContentSetEntry |
inheritFromRootEntry | fn | root-manifest entry + set-file entry → one ParsedSet; a field the set file leaves silent inherits the root value (engine#246) |
resolveSetEntry | fn | root-manifest entry + set-file entry → the one ParsedSet to project; the root owns ROOT_OWNED_SET_FIELDS, a silent field falls back to the other file (engine#246) |
ROOT_OWNED_SET_FIELDS | const | the set fields the root entry owns: title, description, visibility, review_status |
resolveLanguagePair | fn | language-pair resolution (legacy alias + en default) |
setBasePath | fn | repo-relative base dir for a set |
asContentSetBook | fn | project a manifest book block → ContentSetBook | null |
validateLesson | fn | validate a lesson against the bundled schema + semantic rules → ValidationResult |
validateManifest | fn | validate a manifest against the bundled schema (legacy alias normalized) |
validateManifestPair | fn | compare a set manifest with its root-manifest entry; E-MANIFEST-ENTRY-MISMATCH per differing field (engine#246), W-MANIFEST-DESCRIPTION-COPY when the set manifest carries the root-owned description (engine#268) |
validateLessonQuality |
The bundled JSON-Schema ships too, so a content repo can mirror against it
directly: import schema from "learn-content-engine/schema/lesson.schema.json".
The same holds for quality-rules.json (the numbers validateLessonQuality
applies) and grading-presets.json (the
grading presets catalog).
Two subpath entries sit next to the package root:
| Entry | Exports | Purpose |
|---|
learn-content-engine/rules | validateLessonRules, validateManifestRules, validateLessonQuality, QUALITY_MINIMUMS, RENAMED_RULE_IDS, currentRuleId, gradeFor (with its EvaluationOutcome), isSlugId, SLUG_ID_PATTERN, SLUG_ID_MAX_LENGTH, plus two helpers the structural layer composes: unusedCardIds (the detection core of W-CARD-UNUSED) and normalizeManifestAliases (maps the legacy language alias to target_language before validation) | the semantic rules and author lints without the structural layer: no ajv, no node:*, for a browser consumer that has already shape-checked its input (Validation) |
learn-content-engine/qti | importQti, exportQti, qtiLessonAdapter, ... | the optional QTI adapter and its XML parser (QTI interop) |
Scope
By design, this package contains only parse / transform /
validate / types + the single-JSON source adapter - no fetch, storage, or
UI; those stay in the consumer. See architecture.md. The
adaptive-learner app consumes
this library (pinned in its frontend/package.json) as the reference
consumer, so parse/validate/types live here once. As of v0.6.0 this engine is
the canonical source of the lesson schema (schema authority moved here,
roadmap stage 4); consumers - adaptive-learner
and the content repos - mirror it. See Schema authority.
What this is NOT
This is a language-learning-shaped lesson engine: the format is built
around cards, drill-style exercise types, and a target/source language pair
(see concepts.md). The shape carries more than languages,
though - a domain field (language, programming, psychology, ...;
known values plus other, see
content domains) lets the same
format hold knowledge-domain sets (tech courses, driving-test prep, dog
training in the dedicated domain repos below); there, target_language is
simply the language the content is written in. It is
deliberately not:
- a general assessment standard - the CORE schema covers the exercise types
its consumers render and grows additively when a consumer needs a new core
type. A consumer needing a bespoke type can add one via the opt-in
extension tier (
ext: types) without touching the core
enum; the core stays the portable authority.
- a runtime - no rendering, answer checking, scoring of a run,
scheduling/SRS, persistence, or networking; consumers own all of that. What
it computes, it computes as pure functions of what the format defines:
resolveExerciseVariables (since 0.34.0) turns a parametric exercise into
one instance from the random source the consumer passes in, and gradeFor
(since 0.41.0) maps the percent a consumer computed for a run to the
verdict of the set's evaluation block. The
grading presets are a catalog of
such blocks, not grading code.
- a content repository - it ships the format, the validator, and the
author tooling, not lessons.
Example repositories
The engine is used in the wild - these repos show the full consumer setup
(pinned engine version, byte-mirrored schema artifacts, make lint running
the same validator locally that CI enforces):
- adaptive-learner-content-template -
fork-and-go scaffold with one example set, the validator wiring, CI gates
and docs; the fastest way to see the engine used end to end.
- adaptive-learner-content-test -
the test/starter repo and a conformance target of the engine's
make conformance-real, plus the full author-tooling setup.
- adaptive-learner-content -
the production content repo: the language sets plus the app tutorial
(28 sets and counting).
- the
alc-* domain repos - one repo per knowledge domain, all created
from the template and registered in the app's repo registry:
psychology,
programming,
technology,
ai,
traffic-knowledge,
dog-training,
die-waehrung-des-geistes
and books (one set per book).
Schema authority
The canonical source of the lesson schema is this engine's
schema/lesson.schema.json (+
content-manifest.schema.json, quality-rules.json, grading-presets.json). It is an authored
artifact here; consumers mirror the schema shipped in each pinned engine release:
- adaptive-learner (the
reference consumer) keeps its Pydantic models as an editorial tool for its
own runtime types, but its generated schema must be byte-identical to the
engine's - its parity gate treats the engine as the reference (the consumer
conforms to the engine, not the reverse).
- Content repos mirror the schema from the pinned engine release via their
drift tool (
schema/engine-version.txt).
The lesson schema's $id is engine-owned:
https://astrapi69.github.io/learn-content-engine/schema/lesson.schema.json.
To evolve the schema, edit the artifact here (the frozen byte baseline in
src/schema-baseline.test.ts guards against accidental content drift), run
make sync-types to regenerate src/types/lesson-schema.generated.ts from it,
mirror any new cross-field rule in src/rules.ts, extend the fixtures + rule
catalog, and bump the version; consumers then re-pin. The TypeScript types are
generated here (in-engine, scripts/generate-lesson-types.mjs), so they cannot
drift from the schema; the drift gate runs in release-check + CI.
Changelog
See CHANGELOG.md - one dated section per release, from the
current release back to 0.1.0.
License
MIT © Asterios Raptis