Skip to content

medelman17/eyecite-ts

Repository files navigation

eyecite-ts

CI codecov npm version npm bundle size License: MIT Node.js TypeScript Zero Dependencies

TypeScript legal citation extraction — a port of Python eyecite with extended capabilities.

Extract structured data from legal citations in court opinions, briefs, and legal documents. A citation like 500 F.2d 123 (9th Cir. 2020) encodes a volume (500), reporter (Federal Reporter, 2nd Series), page (123), court (Ninth Circuit), and year. This library parses all of that into typed objects, resolves short-form references like "Id." back to their antecedents, and can annotate the original text with HTML markup. Zero runtime dependencies, browser-compatible, ~37 KB brotli.

Installation

npm install eyecite-ts

Quick Start

A complete extract → resolve → annotate workflow:

import { extractCitations } from "eyecite-ts"
import { annotate } from "eyecite-ts/annotate"

const text = `In Smith v. Jones, 500 F.2d 123 (9th Cir. 2020), the court
applied 42 U.S.C. § 1983. Id. at 130. See also 123 Harv. L. Rev. 456 (2019).`

// Step 1: Extract and resolve in one call
const citations = extractCitations(text, { resolve: true })

// Step 2: Inspect results
for (const cite of citations) {
  switch (cite.type) {
    case "case":
      console.log(cite.caseName, cite.reporter, cite.year)
      // "Smith v. Jones" "F.2d" 2020
      break
    case "statute":
      console.log(cite.title, cite.code, cite.section)
      // 42 "U.S.C." "1983"
      break
    case "id":
      console.log("Id. resolves to:", cite.resolution?.resolvedTo)
      // Id. resolves to: 0
      break
    case "journal":
      console.log(cite.journal, cite.volume, cite.page)
      // "Harv. L. Rev." 123 456
      break
  }
}

// Step 3: Annotate the original text
const result = annotate(text, citations, {
  template: { before: '<cite>', after: '</cite>' },
})
console.log(result.text)

What It Extracts

12 citation types, each with its own TypeScript interface:

Type Example Key Fields
case 500 F.2d 123 (9th Cir. 2020) volume, reporter, page, court, year, caseName
docket No. 12-3456 (S.D.N.Y. 2024) docketNumber, court, year, caseName
statute 42 U.S.C. § 1983(a)(1) title, code, section, subsection, jurisdiction
constitutional U.S. Const. amend. XIV, § 1 jurisdiction, amendment, section, clause
journal 123 Harv. L. Rev. 456 volume, journal, page, year
neutral 2020 WL 123456 year, database, documentNumber
publicLaw Pub. L. No. 117-263 congress, lawNumber
federalRegister 87 Fed. Reg. 1234 volume, page, year
statutesAtLarge 136 Stat. 4459 volume, page, year
id Id. at 125 pincite, caseName (inherited)
supra Smith, supra, at 130 partyName, pincite
shortFormCase 500 F.2d at 140 volume, reporter, pincite, partyName

Statute & Administrative Code Coverage

Statutes are extracted across 52 jurisdictions (50 states + DC + federal) using four pattern families:

Family Jurisdictions Example
Federal USC, CFR, USCA, prose ("section X of title Y") 42 U.S.C. § 1983(a)(1) et seq.
Named-code NY (21 laws), CA (29 codes), TX (29 codes), MD (36 articles), VA, AL, MA N.Y. Penal Law § 125.25(1)(a)
Abbreviated-code FL, OH, MI, UT, CO, WA, NC, GA, PA, IN, NJ, DE + 20 more states Fla. Stat. § 775.082
Chapter-act IL (ILCS), IL (Ill. Rev. Stat.) 735 ILCS 5/2-1001

State-specific forms include: Alabama Code of 1940, California bare-code (Penal Code § 187), Georgia pre-1983 Code Ann., Hawaii Revised Laws (pre-1955), Idaho postfix (I.C. § N), Kansas year-edition (K.S.A. 2019 Supp.), Nebraska R.R.S. 1943, Oregon chapter-only (ORS chapter 174), Rhode Island General Laws 1956, Washington RCW chapter-postfix, West Virginia Code 1931, Wisconsin Stats. postfix, and more.

Administrative codes: NMAC (New Mexico), OAR (Oregon), COMAR (Maryland), IDAPA (Idaho), ARM (Montana).

Key Features

Case Names & Full Spans

The library backward-searches for party names and tracks full citation boundaries:

const text = "In Smith v. Jones, 500 F.2d 123 (9th Cir. 2020) (en banc), the court held..."
const [cite] = extractCitations(text)

if (cite.type === "case") {
  cite.caseName    // "Smith v. Jones"
  cite.plaintiff   // "Smith"
  cite.defendant   // "Jones"
  cite.disposition // "en banc"
  cite.span        // covers "500 F.2d 123" (citation core)
  cite.fullSpan    // covers "Smith v. Jones, 500 F.2d 123 (9th Cir. 2020) (en banc)"
}

Procedural prefixes recognized: In re, Ex parte, Matter of, Estate of, In the Matter of, and bankruptcy adversary captions (Spence v. Hintze (In re Hintze)). Case name search also runs on neutral/vendor citations (2020 WL 123456).

Docket Citations

Slip opinions and unreported decisions identified by docket number:

const text = "IKB Int'l, S.A. v. Wells Fargo Bank, N.A., No. 51 (N.Y. 2023)"
const [cite] = extractCitations(text)

if (cite.type === "docket") {
  cite.docketNumber // "51"
  cite.court        // "N.Y."
  cite.caseName     // "IKB Int'l, S.A. v. Wells Fargo Bank, N.A."
}

Accepts PACER colon prefixes (2:17-cv-00413), space-separated parts (18 C 7039), and prefix variants (C.A., Civ., Civil Action, Adv.).

Parallel Citations

When multiple reporters cite the same case, the library groups them automatically:

const text = "See 410 U.S. 113, 93 S. Ct. 705, 35 L. Ed. 2d 147 (1973)."
const citations = extractCitations(text)

citations[0].groupId // "410-U.S.-113"
citations[1].groupId // "410-U.S.-113" (same group)
citations[2].groupId // "410-U.S.-113" (same group)

if (citations[0].type === "case") {
  citations[0].parallelCitations
  // [{ volume: 93, reporter: 'S. Ct.', page: 705 },
  //  { volume: 35, reporter: 'L. Ed. 2d', page: 147 }]
}

Short-Form Resolution

Pass { resolve: true } to link Id., supra, and short-form case citations to their full antecedents:

const text = `Smith v. Jones, 500 F.2d 123 (2020). Id. at 125. Smith, supra, at 130.`
const citations = extractCitations(text, { resolve: true })

// Id. resolves to most recent antecedent
citations[1].resolution  // { resolvedTo: 0 }

// Id. inherits case name from antecedent
if (citations[1].type === "id") {
  citations[1].caseName   // "Smith v. Jones" (inherited)
  citations[1].plaintiff  // "Smith" (inherited)
}

The resolver supports paragraph/section/footnote scope boundaries, fuzzy party name matching via Levenshtein distance, bare-party shortform (Smith, at 12), and bracketed [supra] (Connecticut style). See the Resolution Guide for the power-user API.

Subsequent History & Dispositions

Case citations automatically extract subsequent history chains and disposition parentheticals:

const text = "Smith v. Jones, 500 F.2d 123 (9th Cir. 2020), aff'd, 600 U.S. 456 (2021)"
const [cite] = extractCitations(text)

if (cite.type === "case") {
  cite.subsequentHistoryEntries
  // [{ signal: 'affirmed', rawSignal: "aff'd", signalSpan: { ... }, order: 0 }]
}

Recognized history signals include federal (aff'd, rev'd, vacated, remanded, cert. denied, rehearing denied), Texas writ/petition history (writ refused, pet. denied), and California review history (review denied, review granted, not published, superseded by grant of review).

Dispositions extracted: en banc, per curiam, dissent, concurrence, plurality opinion, mem., with justice attribution ((Brennan, J., dissenting)justices: ["Brennan"]).

Explanatory Parentheticals

Explanatory parentheticals following case citations are parsed and classified:

const text = '500 F.2d 123 (9th Cir. 2020) (holding that X requires Y)'
const [cite] = extractCitations(text)

if (cite.type === "case") {
  cite.parentheticals
  // [{ text: "holding that X requires Y", type: "holding" }]
}

Classification types: holding, finding, stating, noting, explaining, quoting, citing, discussing, describing, recognizing, applying, rejecting, adopting, requiring, other.

Citation Annotation

Mark up citations with HTML using template or callback modes:

import { annotate } from "eyecite-ts/annotate"

// Template mode
const result = annotate(text, citations, {
  template: { before: '<cite>', after: '</cite>' },
})

// Callback mode for custom markup
const linked = annotate(text, citations, {
  callback: (citation, surrounding) => {
    if (citation.type === "case") {
      return `<a href="/cases/${citation.volume}-${citation.page}">${citation.matchedText}</a>`
    }
    return `<span>${citation.matchedText}</span>`
  },
})

XSS auto-escape is enabled by default. Use useFullSpan: true to annotate from case name through closing parenthetical.

Confidence Scoring

Each citation carries a confidence score (0–1) based on pattern match quality, reporter validation, and metadata completeness:

const [cite] = extractCitations(text)
cite.confidence // 0.85

Scores are adjusted by reporter validation (+0.2 for known reporters, -0.3 for unknown), year plausibility, case name presence, and court identification. False positives from international reporters or implausible years get reduced to 0.1.

Citation Signals

Citations preceded by Bluebook signals are tagged:

const text = "See also Smith v. Jones, 500 F.2d 123 (2020)."
const [cite] = extractCitations(text)
cite.signal // "see also"

Recognized signals: see, see also, see generally, cf, but see, but cf, compare, accord, contra, e.g., and combined forms (see, e.g., see also, e.g., but see, e.g., cf., e.g., but cf., e.g.).

Court Inference

Case citations carry a inferredCourt field derived from the reporter series:

const [cite] = extractCitations(text)
if (cite.type === "case") {
  cite.inferredCourt
  // { level: "appellate", jurisdiction: "federal", confidence: 1.0 }
}

Component Spans

Every citation carries per-field position data for precise source mapping:

const [cite] = extractCitations(text)
if (cite.type === "case") {
  cite.spans?.volume    // { cleanStart, cleanEnd, originalStart, originalEnd }
  cite.spans?.reporter  // ...
  cite.spans?.page      // ...
  cite.spans?.court     // ...
  cite.spans?.year      // ...
  cite.spans?.caseName  // ...
}

Footnote Detection

Opt-in feature that tags citations with their footnote context and enables zone-scoped resolution:

const citations = extractCitations(text, { detectFootnotes: true })

for (const cite of citations) {
  if (cite.inFootnote) {
    console.log(`Footnote ${cite.footnoteNumber}: ${cite.matchedText}`)
  }
}

Two strategies: HTML tag scanner (<footnote>, <fn>, footnote class/id attributes) and plaintext separator detection (5+ dashes/underscores followed by numbered markers). The "footnote" scope strategy enforces zone-based isolation: Id. is strict (same zone only), supra and short-form case can cross from footnotes to body.

Structured Dates

Parentheticals with full dates return structured date objects:

const text = "500 F.3d 100 (2d Cir. Jan. 15, 2020)"
const [cite] = extractCitations(text)
if (cite.type === "case") {
  cite.date // { iso: '2020-01-15', parsed: { year: 2020, month: 1, day: 15 } }
}

Post-Extraction Utilities

The eyecite-ts/utils entry point provides composable post-processing:

import { extractCitations, isCaseCitation } from "eyecite-ts"
import { groupByCase, toBluebook, toReporterKey, getSurroundingContext } from "eyecite-ts/utils"

const citations = extractCitations(text, { resolve: true })

// Group citations by case (parallel + short-form → full)
// Requires resolved citations — pass `{ resolve: true }` to extractCitations.
const groups = groupByCase(citations)

// Format as Bluebook citation string (any Citation)
const formatted = toBluebook(citations[0])

// Get canonical reporter key for deduplication (full case citations only)
const first = citations[0]
if (isCaseCitation(first)) {
  const key = toReporterKey(first) // "500 F.2d 123"
}

// Extract surrounding sentence context (pass a {start, end} span, not the citation)
const cite = citations[0]
const ctx = getSurroundingContext(
  text,
  { start: cite.span.originalStart, end: cite.span.originalEnd },
  { maxLength: 100 },
)

Type System

All citation types use a discriminated union on the type field:

import type { Citation, FullCaseCitation, StatuteCitation } from "eyecite-ts"
import { isFullCitation, isCaseCitation, assertUnreachable } from "eyecite-ts"

// Type guards
if (isCaseCitation(citation)) {
  citation.reporter // typed as string
}

// Exhaustive switch
switch (citation.type) {
  case "case": /* ... */ break
  case "docket": /* ... */ break
  case "statute": /* ... */ break
  case "constitutional": /* ... */ break
  case "journal": /* ... */ break
  case "neutral": /* ... */ break
  case "publicLaw": /* ... */ break
  case "federalRegister": /* ... */ break
  case "statutesAtLarge": /* ... */ break
  case "id": /* ... */ break
  case "supra": /* ... */ break
  case "shortFormCase": /* ... */ break
  default: assertUnreachable(citation.type)
}

CitationOfType<'case'> extracts the subtype: CitationOfType<'case'> = FullCaseCitation. See the Type Reference for the full catalog.

Bundle Size

Four entry points for tree-shaking:

Entry Point Import Size (brotli)
Core extraction eyecite-ts ~37 KB
Annotation eyecite-ts/annotate ~1 KB
Post-extraction utils eyecite-ts/utils ~1.8 KB
Reporter data eyecite-ts/data lazy-loaded

Import only what you need — the reporter database is loaded on first use, not at import time.

Comparison with Python eyecite

Every claim verified against Python eyecite source code (May 2026).

Capability Python eyecite eyecite-ts Notes
Case citations Yes Yes Both extract volume/reporter/page/court/year
Docket citations No Yes Slip opinions, PACER docket numbers
Statute citations Yes (50 states + DC + territories) Yes (50 states + DC + federal) Python uses reporters-db; TS uses built-in patterns
Constitutional citations No Yes (U.S. + 50 states) Dedicated type with article/amendment/section/clause
State admin codes No Yes (NM, OR, MD, ID, MT) NMAC, OAR, COMAR, IDAPA, ARM
Journal / law review Yes Yes
Neutral (WL/LEXIS) Yes (as case) Yes (dedicated type) Separate NeutralCitation with database/court split
Short-form resolution Yes Yes
Case name extraction Yes Yes Both use backward scanning; TS runs on neutral cites too
Parallel citation linking Partial Yes groupId + parallelCitations array
Subsequent history No Yes Federal, Texas writ/petition, California review signals
Explanatory parentheticals No Yes Classified by gerund (holding, finding, stating, ...)
Justice attribution No Yes (Brennan, J., dissenting) → justices + scope
Court inference No Yes Level/jurisdiction from reporter series
Full span tracking Yes Yes TS carries dual clean/original positions
Component spans Minimal Yes (all fields) Per-component position data
Footnote detection No Yes HTML + plaintext strategies
Citation signals No (stop words) Yes (metadata) Bluebook signals including combined forms
Confidence scoring No Yes Pattern quality + reporter validation
Annotation Yes (HTML modes) Yes (template/callback) XSS auto-escape on by default
Position mapping Yes (diff-based) Yes (incremental) TransformationMap during cleaning
Type system Class inheritance Discriminated union Exhaustive switch, conditional types
Post-extraction utils No Yes groupByCase, toBluebook, toReporterKey

eyecite-ts started as a port and has diverged. Both are capable citation extractors — eyecite-ts adds docket citations, constitutional citations, subsequent history, explanatory parentheticals, footnote detection, citation signals, structured confidence scoring, court inference, rich component spans, and a TypeScript-native type system, while Python eyecite has broader statute coverage via reporters-db and a mature ecosystem.

Coming from Python eyecite? See the Migration Guide.

Architecture

Citations flow through a 4-stage pipeline: clean → tokenize → extract → resolve. Text cleaning builds a TransformationMap that tracks position shifts, so every citation carries dual coordinates (cleaned and original text). Resolution is optional and runs as a final pass.

See ARCHITECTURE.md for details.

Development

pnpm install           # Install dependencies (corepack, pnpm 10)
pnpm test              # Run tests (vitest, watch mode)
pnpm exec vitest run   # Run tests once (2,966 tests, 96 files)
pnpm typecheck         # Type-check with tsc
pnpm build             # Build (ESM + CJS + DTS)
pnpm lint              # Lint with Biome
pnpm format            # Format with Biome
pnpm size              # Check bundle size limits

Requires Node.js >= 18.0.0. See ARCHITECTURE.md for contributor orientation.

Internal Bughunt CLI

pnpm bughunt is a repo-local development tool for reproducible citation-parser bug hunting. It is intentionally private to this repository: it is not exported as a package entry point and is not installed as a public binary.

pnpm bughunt run --lane all --seed 1234 --sample 5
pnpm bughunt inspect .bughunt/latest.json --id <finding-id>
pnpm bughunt promote .bughunt/latest.json --id <finding-id>

The run command writes local artifacts under .bughunt/runs/<run-id>/ plus a .bughunt/latest.json pointer. Runs include manifest.json, findings.jsonl, cases.jsonl, events.jsonl, report.json, and summary.md; .bughunt/ is gitignored and should not be committed.

Available v1 lanes:

  • corpus: runs extraction and resolution over inline smoke cases and reports crashes or performance outliers.
  • invariants: checks citation/span invariants and records violations.
  • mutate: uses fast-check with deterministic seeds and replay paths for generated-input failures.

promote is preview-only in v1. It prints a Vitest repro skeleton with the finding ID, original command, source context, and minimized/input text when available; it does not write files.

License

MIT

Credits

Inspired by and ported from eyecite (Python) by Free Law Project. This TypeScript implementation extends the original with docket citations, constitutional citations, subsequent history, explanatory parentheticals, footnote detection, citation signals, structured confidence scoring, court inference, parallel citation grouping, component spans, post-extraction utilities, and a discriminated-union type system.

About

TypeScript legal citation extraction library with zero dependencies. Extract, resolve, and annotate citations from court opinions. Supports case citations, statutes, journals, parallel linking, short-form resolution, and more. 7KB core bundle.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors