npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@finbheara/names

v0.20.0

Published

A curated lexicon of personal, school and place names, the surname-particle vocabulary, a phrase classifier and a person-name normalizer

Readme

@finbheara/names

Person names as they are actually printed: parsed, normalized, compared, and told apart from school and place names.

  • A curated lexicon (names.txt): ~15,000 given names, surnames, places, schools and multi-word phrases, each flagged for what it is.
  • The surname-particle vocabulary: Mac, Ní, O, van, de and the rest, with one display spelling each.
  • A person-name normalizer: one printed cell in, a PersonName out, with a canonical display form, a filing key and a matching key.
  • A phrase classifier: is this string a person, a school or a place, and where does the person end and the school begin?
  • Measurement tools: run a stream of names through all of the above and see what it does not recognise.

It was built on results sheets from Irish dance competitions, where one dancer's name arrives as O'RERVTON, MOIRA, Moira ORervton and Moira O Rervton, and a school's name is fused onto the dancer's. The rules are general to Anglo, Irish and European names.

npm install @finbheara/names

ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT.

Entry points

Import only what you use; /lexicon, /particles, /normalize and /phonetic never load the classifier's bloom tables.

| import | what | |---|---| | @finbheara/names/normalize | PersonName, its factories, compare, compose, lists | | @finbheara/names/lexicon | names.txt parsed: flags, multi-word phrases, spellings, roles | | @finbheara/names/particles | the particle vocabulary | | @finbheara/names/classifier | person / school / location classification and segmentation | | @finbheara/names/phonetic | Double Metaphone: how a word sounds, as a key | | @finbheara/names/measure | the census and the classifier evaluation | | @finbheara/names | all of the above | | @finbheara/names/names.txt | the raw lexicon |

Normalize a name

import { isNoNamePrint, personName } from "@finbheara/names/normalize";

const n = personName("WYRKOW NÍ MHAOLVARNA");
n.nn2;           // "NiMhaolvarna<<Wyrkow"      canonical display form
n.nn3;           // "NIMHAOLVARNA<<WYRKOW"      filing key
n.nn4;           // "NIMHAOLVARNA<WYRKOW"       order-free matching key
n.surnameKey();  // "NIMHAOLVARNA"
n.givenName();   // "WYRKOW"
n.surname;       // { particles: ["NÍ"], base: "MHAOLVARNA", tradition: "i", ... }
n.isPerson();    // true

personName("ORERVTON, MOIRA", "surname-first").nn2;  // "O'Rervton<<Moira"
personName("Foireann A").isTeam();                  // true: a team, not a person
personName("Kelsey Pearraillina - BN2018").refusal;       // { rule: "refuse-digit", ... }
personName("N/A").printsNoName();                   // true: a placeholder, no name printed
personName("").isEmpty();                           // true: nothing printed at all
isNoNamePrint("Not known");                         // true, from the print alone (no parse)

Every input gets a PersonName; a cell that names no person is refused, not thrown: isPerson() is false, refusal says which rule, and every level from NN2 down reads PersonName.REFUSED ("!!!INVALID"). An empty cell is neither a person nor a refusal: it answers isEmpty(). printsNoName() is true for an empty cell, a cell with no letter (???) and a placeholder (N/A, TBA, Unknown, Not known), whichever rule refused it.

The levels

| level | is | case | accents | apostrophes | field order | |---|---|---|---|---|---| | nn0 | exactly what the page said | kept | kept | kept | kept | | nn1 | nn0 with whitespace, quotes and control bytes repaired | kept | kept | normalized | kept | | nn2 | the canonical display form (Surname<<Given<Middle) | derived | ASCII | kept | kept | | nn3 | the filing key | upper | ASCII | dropped | kept | | nn4 | the matching key: are two prints the same name? | upper | ASCII | dropped | dropped |

Persist a level string, never the object: nn2 to display, nn3 as a key. Read a stored nn2 back with personNameFromIcao(nn2) (or factory.fromIcao). The full contract is in docs/NAME-NORMALIZATION.md.

Factories

personName is the default factory's shorthand: a caching factory over the committed names.txt. Make your own when you need other options; a factory's options are fixed when it is made, and no factory can change another's answers.

import { createCachingPersonNameFactory, createPersonNameFactory } from "@finbheara/names/normalize";

const names = createCachingPersonNameFactory({
  lexicon: myLexicon,             // a Lexicon or a (key) => spelling lookup; null for rules only
  traditions: null,               // no tradition lookup
  parenthetical: (aside) =>       // what "( … )" or "\" … \"" in a name cell means
    aside === "Ontario" ? { refuse: "refuse-location-alias" } : "strip",
  capacity: 100_000,              // memo size; 0 disables the memo
});
names.parse("Anna (Ontario) Mailtina").isPerson();  // false
names.parse("Lena (Xiaoyu) Zumock").nn2;           // "Zumock<<Lena"
names.stats();                                   // { hits, misses, evictions, size, capacity }

const uncached = createPersonNameFactory();      // parses every call

The default parenthetical handler strips every aside: Pundeallina "Katie" Foxe is Foxe<<Pundeallina.

A document's name order

deriveNameFormat reads the name cells of ONE document (a results sheet, a roster, a column) and says which order it prints them in, as probabilities over First Last, Last, First and Last First, with "unknown" where none clears the threshold or the document prints both orders. It also reports hasSchool and hasLocation (do most cells carry a school or a place after the name?) and each print's reading, so a caller can strip those parts before parsing.

import { createSegmenter } from "@finbheara/names/classifier";
import { Lexicon } from "@finbheara/names/lexicon";
import { deriveNameFormat, nameFormatOrder, readNameCell } from "@finbheara/names/normalize";

// invented names, in a lexicon of their own
const lexicon = Lexicon.fromText("Zelvine:F\nOramie:F\nTalwyn:F\nQuorrish:L\nVantrell:L\nOspindle:L\nZandrel:G");
const segmenter = createSegmenter(lexicon);   // optional: reads the school part; same lexicon
const doc = deriveNameFormat(
  ["Quorrish, Zelvine", "Vantrell, Oramie - Zandrel", "Ospindle, Talwyn", "Quorrish, Oramie", "Vantrell, Talwyn"],
  { lexicon, segmenter },
);
doc.verdict;                    // "Last, First"
nameFormatOrder(doc.verdict);   // "surname-first": pass it as the document's declared order
doc.readings[1].name;           // "Vantrell, Oramie"
doc.readings[1].location;       // "Zandrel"

deriveNameFormat(["QUORRISH ZELVINE", "VANTRELL ORAMIE"], { lexicon }).verdict;  // "unknown": too few
readNameCell("QUORRISH ZELVINE", { lexicon }).order;                           // "surname-first:two-token"

The verdict is a declaration the caller makes about a document; grouping rows into documents is the caller's. bun run names order <file> prints the same for a file of name cells, one per line. The specification is docs/NAME-NORMALIZATION.md.

How two names are related

import {
  createGivenFamilyList, createNameIndex, createNameKinship, givenFamilyTable, nameKinship,
  personName,
} from "@finbheara/names/normalize";

nameKinship(personName("Kate Smith"), personName("Katherine Smith"));
// { relation: "candidate", via: ["given-family"], reason: "middles-equal", gateSafe: true }
nameKinship(personName("Mary Larson"), personName("Mary Conley-Larson")).via;  // ["surname-part"]
nameKinship(personName("Mary Larson"), personName("Mary Carlson")).reason;     // "surname-differs"
nameKinship(personName("Kate M Smith"), personName("Katherine Conley-Smith")).via;
// ["surname-part", "given-family", "middle-absent"]
nameKinship(personName("Connor Smith"), personName("Conor Smith")).via;       // ["given-variant"]
nameKinship(personName("Evie Smith"), personName("Genevieve Smith"));
// { relation: "candidate", via: ["given-family-not-gate"], reason: "middles-equal", gateSafe: false }
nameKinship(personName("Beth Smith"), personName("Bethany Smith"));   // the extended family list
// { relation: "candidate", via: ["given-maybe"], reason: "middles-equal", gateSafe: false }

// the given-name tolerance is a list of providers, asked in order
const tableOnly = createNameKinship({ given: [givenFamilyTable] });
tableOnly(personName("Beth Smith"), personName("Bethany Smith")).reason;  // "given-differs"
const common = createNameKinship({
  given: [givenFamilyTable, createGivenFamilyList({ maxTier: 1 })],  // common forms only
});
common(personName("Maggie Smith"), personName("Marjorie Smith")).reason;  // "given-differs"

const index = createNameIndex<number>("nn3");   // a NameMap with a tolerant lookup
index.set(personName("Mary Conley-Larson"), 1).set(personName("Mary Carlson"), 2);
index.get(personName("Mary Larson"));            // undefined: get stays exact
index.related(personName("Mary Larson"));        // [{ name: Conley-Larson<<Mary, value: 1, kinship: … }]

To block a set of RECORDS (two records may print one name), hold them in a blocker and take the pairs; each related pair comes once, without comparing every pair:

import { createNameBlocker, personName } from "@finbheara/names/normalize";

const blocker = createNameBlocker<string>();
blocker
  .add("row-1", personName("Mary Larson"))
  .add("row-2", personName("Mary Conley-Larson"))
  .add("row-3", personName("Mary Larson"))
  .add("row-4", personName("Mary Carlson"));
for (const { a, b, kinship } of blocker.pairs()) console.log(a.id, b.id, kinship.relation);
// row-1 row-3 same
// row-1 row-2 candidate
// row-2 row-3 candidate

candidate is never a merge: it says two prints MAY be one person. via lists every tolerance the pair used, and gateSafe is false when any of them is one a gate must not admit on its own.

A ? (or U+FFFD) in a print is a letter the capture lost. The levels keep what the print kept, and nameKinship reads each ? as exactly one letter where the prints disagree without it:

import { lostLetterForm, nameKinship, personName } from "@finbheara/names/normalize";

nameKinship(personName("Zoë Bérard"), personName("Zo? B?rard"));
// { relation: "candidate", via: ["lost-letter"], reason: "middles-equal", gateSafe: false }
personName("Orlaith N? Varraigh").nn2;              // "NiVarraigh<<Orlaith": `N?` can only be Ní
lostLetterForm(personName("Zo? B?rard")).surname;   // "B?RARD": NN3's field, the `?` kept

A given name alone, against a known set

import { createNameRoster, givenOnlyName, personName } from "@finbheara/names/normalize";

givenOnlyName("Lena").nn2;     // "<<Lena": the caller declares the print given-only
personName("Lena").isPerson(); // false: undeclared, one word is still refused
givenOnlyName("Lena Z.").nn2;  // "Z<<Lena": a trailing single letter is the surname initial

const roster = createNameRoster(
  ["Lena Zumock", "Mairéad Rervton", "Lena Brambleton", "Éilis Zumock"].map((p) => personName(p)),
);
roster.match(givenOnlyName("Mairead")); // { kind: "unique", name: Rervton<<Mairead }
roster.match(givenOnlyName("eilis"));   // { kind: "unique", name: Zumock<<Eilis }
roster.match(givenOnlyName("Lena"));    // { kind: "ambiguous", names: [Brambleton<<Lena, Zumock<<Lena] }
roster.match(givenOnlyName("Lena Z"));  // { kind: "unique", name: Zumock<<Lena }
roster.match(givenOnlyName("Orla"));    // { kind: "none", reason: "no-candidate" }

Only unique is an answer: the roster never picks between candidates. A print is matched by nameKinship, where a missing surname (surname-absent) or an initial (surname-initial) cannot rule a pair out and is never gate-safe. The default relation uses the curated given-name table, so Kate finds Katherine; pass { kinship: createNameKinship({ given: [] }) } to match given names exactly. Answers do not depend on the order the set was given in.

The given-name tables, for another language

The tables behind givenFamilyTable and givenFamilyList print as JSON, for a consumer that is not JavaScript:

npx @finbheara/names tables given-name > given-name-tables.json   # or bunx

--output json and --schema 1 are the defaults. The PRINTED form is the contract: a schema keeps its shape for as long as the package offers it, and a new shape is a new schema, so a command written today keeps working. The files the tables are stored in are not part of it. Schema 1 is

{
  "$comment": [ /* how to read it */ ],
  "table": "given-name", "schema": 1,
  "variants":  { "CONNOR": ["CONOR"], … },        // canonical -> its other spellings
  "families":  { "CATHERINE": ["KATE", …], … },   // the curated gate tier: root -> forms
  "notAtGate": ["MEGAN", …],                      // forms a gate must not admit as forms
  "extended":  { "BETHANY": { "BETH": 1 }, … }    // root -> form -> tier (1 common .. 3 rare)
}

Read it as the library does, and as $comment says: read each name as its canonical spelling first; two names relate only when they share a root, a form meets EVERY root it sits under, and nothing is transitive (EVIE meets EVE and EVA, which do not meet each other). A shared root is given-family unless one name reaches it only as a notAtGate form; a name that is the root itself always passes. An extended pair's tier is its rarer member's, the root counting as 1. To avoid comparing every pair, index each name under every root it has and compare within a root, never on one root per name.

Phonetic and blocking keys

import { doubleMetaphone } from "@finbheara/names/phonetic";
import { initialsBlock, metaphoneBlocks, personName, surnameBlock } from "@finbheara/names/normalize";

doubleMetaphone("SMITH");      // ["SM0", "XMT"]  primary, secondary
doubleMetaphone("KAVANAGH");   // ["KFNK", "KFNK"]

const n = personName("Moira O'Rervton");
surnameBlock(n.surnameKey());                     // "ORERVTON"  A-Z letters only
metaphoneBlocks(n.surnameKey());                  // ["ARRF"]
initialsBlock(n.givenFields()[0] ?? "", n.surnameKey());  // "MO"

doubleMetaphone is a general phonetic key: a printed name and a transcribed spoken word are asked the same question. The three block keys bucket names that are worth comparing and decide nothing; each answers null (or []) where there is nothing to bucket on, so a self-join never pools the blanks.

A name as database columns

import { NAME_COLUMNS, nameColumns, personName } from "@finbheara/names/normalize";

NAME_COLUMNS;  // ["nn0", …, "nn4", "surname_key", "given_key", "blk_surname", "blk_initials",
               //  "blk_metaphone", "blk_metaphone_alt"]
nameColumns(personName("Moira O'Rervton"));
// { nn0: "Moira O'Rervton", …, nn3: "ORERVTON<<MOIRA", surname_key: "ORERVTON",
//   given_key: "MOIRA", blk_surname: "ORERVTON", blk_initials: "MO", blk_metaphone: "ARRF", … }

One encoder for every key a store indexes, with no database dependency. A level column is the slot createNameMap files a name under, null for a print that names no person, so a map lookup at a level is one equality on one column.

The lexicon

import { Lexicon } from "@finbheara/names/lexicon";

const lex = Lexicon.default();              // the committed names.txt, parsed on first use
lex.getNameFlags("Murphy");                 // "LT": a surname, and a tradename
lex.getNameFlags("New York");               // "BG": a multi-word place
lex.matchBigramContinuations("New");        // [["south","wales"], ["york","city"], ..., ["york"], ...]
lex.displaySpelling("MACDONALD");           // "MacDonald"

Lexicon.load("./my-names.txt");             // your own file, same format
Lexicon.fromText("Aoife:F\nKowalski:L6\n");

names.txt

One entry per line, Name:FLAGS. # starts a comment. A line with no flags is a female given name. Flags are case-sensitive and combine (Kelly:AF, New York:BG).

| flag | meaning | |---|---| | F M N | female / male / gender-neutral given name | | L | surname | | A | ambiguous: seen as both a given name and a surname | | G | place | | T | tradename: a school or a teacher | | C | concept word (River) | | B | multi-word entry (bigram or trigram) | | S | a known typo, kept so it is recognised | | X | excluded from the display renderer's round-trip check | | D | a given name a diminutive table holds (Kate:FD); a mark, like S and X |

A digit after L or A is a tradition footnote: 1 Armenian, 2 Germanic, 3 Iberian, 4 Irish/Gaelic, 5 Nordic, 6 Slavic (Kowalski:L6). An index is permanent once issued; a new tradition takes the next free index.

Classify a phrase

import { phraseClassifier } from "@finbheara/names/classifier";

const pc = phraseClassifier();               // over the committed lexicon
pc.classifyNameNormalized("Quorrish Academy of Irish Dance");
// { scores: { person: 0.00, school: 1.00, location: 0.00 }, confidence: 1.00, category: "school" }
pc.segment("Mary Kate O Trurane Quorrish Academy");
// { type: "PS", person: ["Mary","Kate","O","Trurane"], school: ["Quorrish","Academy"], boundary: 4, ... }

A logistic-regression classifier and a segmenter over trained weights and bloom filters of public name and place lists (data/bloom/MANIFEST.json). It is built for multi-word phrases; for a single token, ask the lexicon.

A sheet that declares its name order (given-first or surname-first) tells the classifier so, and the classifier then reads its cells with weights fitted only on documents declaring that order. On a surname-first sheet, a comma in the name column separates surname from given name:

pc.getNameCategory(cell, "surname-first"); // the order the document declares
pc.getNameCategory(cell);                  // no order: the same as "unknown"

Pass the order the document declares, never a guess: an order read off the cell itself (inferNameOrder) is not a declaration, so it is not an input to the classifier. With no order, or unknown, the answers are exactly what they are without the argument. classifyName, classifyNameNormalized, getNameCategory, classifyBoundary and the is… tests (after threshold) take it. The garbage refusal and the place rules answer first under every order.

The model always picks one of person, school or place. Text that is none of them (page footers, headings) can be declared up front; the whole phrase is matched ignoring case, spacing and punctuation, scores zero everywhere and segments to nothing:

const pc = phraseClassifier(undefined, { garbageVocab: ["Results are provisional (c)"] });
pc.classifyNameNormalized("RESULTS ARE PROVISIONAL (C)");
// { scores: { person: 0, school: 0, location: 0 }, confidence: 0, category: "none" }

Measure a stream of names

npx names-census < names.txt-or.jsonl           # one name per line, or JSON lines
npx names-census --field=name --order-field=order --classify --limit=50 < rows.jsonl
import { createNameCensus } from "@finbheara/names/measure";

const census = createNameCensus();
for (const row of rows) census.add(row.name, row.order);
const report = census.report();
report.unknownTokens;  // words no lexicon entry knows, most printed first, with examples
report.unknownPairs;   // adjacent unknown words: candidate multi-word entries
report.refused;        // refusals by rule

The unknown words are the candidate names.txt additions. That is the fix for almost every misread.

Contributing

See CONTRIBUTING.md: how to add a name, the gates, the classifier methodology and versioning.

Licence

MIT © Finbheara Systems. The bloom tables are built from third-party lists; their sources are in data/bloom/MANIFEST.json.