Tell me the language.
Langtell is an open-source library that infers the language of short strings — titles, snippets, headlines — for products that have to sort or route text by language and show their reasoning. It fuses script and letter evidence with the page and transport metadata the text arrived in, and, optionally, heavier engines, into one verdict with an auditable trail.
0
runtime dependencies in the core
3.35 kB
core bundle budget, brotli
8
ready-made language profiles
import { compile } from "langtell";import { uk, ru, en } from "langtell/profiles"; // ready-made roster data // compile() does the per-roster setup once; call the returned fn many times.const detect = compile({ candidates: [uk, ru, en] }); const result = detect({ text: "Їжак Сонік", html, // optional: <html lang>, og:locale, meta content-language responseHeaders, // optional: HTTP Content-Language});// → { language: "uk", confidence: 0.9x, evidence: [{ kind: "title-script", ... }, ...] }At a glance
- Type
- My own open-source library
- Scope
- Everything: the evidence-producer/fuser core, the optional franc and Chrome AI engines, the classify and cyrillic subpaths, the test suite, and the Changesets-based npm release pipeline.
- Status
- Live — 0.6.1 on npm · as of 24 Sep 2026
- Timeline
About 3 weeks to the first working release · Jun 2026
Grew out of Movar’s language detection, extracted in June 2026
Latest 0.6.1 on npm, 18 Aug 2026
- Platforms
- Node.js 20+
- Source code
- Source on GitHub (opens in a new tab)
- Try it
npm install langtell- Stack
The challenge
Most language detectors need a paragraph to work with; langtell has to call a two- or three-word title, where Ukrainian, Russian, Belarusian and Bulgarian share almost their entire Cyrillic alphabet. It also has to know when to say nothing, because a closed set of candidates can only ever argue for someone — never against all of them.
- A two- or three-word title starves a statistical detector like franc of the trigrams it needs.
- Ukrainian, Russian, Belarusian and Bulgarian share almost their whole Cyrillic alphabet — only a handful of letters are truly distinctive.
- A closed candidate roster has no way to say ‘none of you’ on its own: left unchecked, it always elects a winner, even for text in a language that was never in the roster.
- A quoted sentence in another language should not be allowed to reclassify the article quoting it.
- An optional heavier engine — franc’s trigram tables, Chrome’s on-device model — must not force every caller to await, even though most callers never register one.
The constraints
The core has to stay usable entirely on its own, with nothing heavier pulled in until a caller asks for it.
- Zero runtime dependencies in the core — franc is an optional peer dependency, never a hard one.
- Heavy engines and word-list data (franc's trigram tables, the Chrome AI model, the language profiles) live behind their own subpath imports, never the default one.
- No default or namespace-object export: every symbol is a named export, so unused code can be tree-shaken away.
- A caller who registers no async engine must get a synchronous detect() back — the type has to guarantee that, not a runtime check.
The solution
I split the problem into small evidence producers — script and letters, page tags, HTTP headers, plus two optional engines — that each only report what they see, and a separate fuser that weighs their evidence into one verdict. compile() does the per-roster setup once and hands back a plain detect function whose sync-or-Promise return type is settled at the type level, not by a runtime flag.
Evidence producers, kept separate from the fuser
Each signal source only reports what it saw; a separate fuser does the deciding, so every verdict carries an auditable trail of exactly which signals produced it.
compile(config) → detect(), not a stateful class
There is exactly one operation, so the factory hands back the configured function directly instead of an object — precompute happens once, per roster, up front.
Sync vs. async encoded in the type, not checked at runtime
Registering an async engine flips detect's return type to a Promise at compile time, so an unnecessary await, a .sync() sibling, or a runtime throw are never needed.
Every heavy piece lives behind its own subpath
franc's trigram tables, the Chrome AI engine and the language-profile word lists only enter a bundle that imports them, so the default import stays dependency-free.
A contradiction veto resolves an unrepresented language to ‘unknown’
A closed candidate set can only ever argue for someone; checking the winner against the text’s own alphabet stops it from confidently naming a language that was never in the roster.
Architecture
Text signals
Script + distinctive letters, scored relative to your candidate roster
HTML signals
<html lang>, og:locale, meta content-language
Header signals
HTTP Content-Language
franc engine
Trigram statistics over 400+ languages — sync, opt-in subpath
Chrome AI engine
On-device browser model — async, opt-in subpath
fuse()
Weighted blend across every signal that was registered
Script guard
Context never overrides clear script evidence
compile(config)
One-time setup for a candidate roster
detect(input)
Sync, or a Promise once an async engine is registered
I build — you own
Built to hand over
Langtell’s repository is public, so I can point at a handover instead of describing one — every item below is a real link into it, not a summary of it.
- View (opens in a new tab)
Source and licence
MIT-licensed — 17 source files and 14 test files in src/, all one author.
- View (opens in a new tab)
Design writeup
DESIGN.md — the evidence-producer/fuser architecture, the sync/async typing and the packaging rules, written out in full.
- View (opens in a new tab)
Test suite
14 Vitest files covering the fuser, both classifiers and each opt-in engine, run in CI on Node 20 and 22.
- View (opens in a new tab)
Release pipeline
Changesets-driven workflow — publishes to npm over OIDC trusted publishing with build provenance, gated on the CI verify matrix.
Results
I've published langtell to npm — MIT-licensed and dependency-free at its core — and CI runs the full verify gate (typecheck, lint, tests, build, package checks, a bundle-size budget) on every push, across Node 20 and 22.
MIT
open-source licence
What’s next
The next piece I have planned is a langtell/diagnostics subpath that compares the classifier’s verdict against an independent oracle such as franc and reports whether they agree — a QA tool for measuring classifier quality without changing any verdict.
Have a similar project in mind?
I’ll build it and hand it over: the code, the docs and the accounts are yours.
Discuss a project