Skip to content
oleks crane
Open-source libraryLive

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+
Try it
npm install langtell
Stack
TypeScriptVitesttsupESLintPrettierpnpmChangesetsGitHub Actions

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

Text, HTML and header signals are produced independently; optional franc and Chrome AI engines add more when you register them. fuse() blends every signal with a script guard, and compile() binds it all into one detect() call whose sync-or-Promise return type mirrors what you 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.

  • 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.

    View (opens in a new tab)

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