Sort free-form historical date strings across six calendar systems by reducing them to a single IEEE 754 float (astronomical year). Zero runtime dependencies, dual ESM/CJS output, full TypeScript types.
npm install historical-date-sortimport { parseHistoricalDate, compareHistoricalDates } from "historical-date-sort";
const records = [
{ id: "A", date: "ca. 450 BCE" },
{ id: "B", date: "AH 150" }, // Islamic Hijri → ~767 CE
{ id: "C", date: "AM 5500" }, // Hebrew Anno Mundi → ~1740 CE
{ id: "D", date: "Meiji 34" }, // Japanese era → 1901 CE
{ id: "E", date: "AUC 754" }, // Roman ab urbe condita → 1 CE
{ id: "F", date: "1921" },
{ id: "G", date: "Unknown date" }, // unparseable → sorts last
];
records.sort((a, b) => compareHistoricalDates(a.date, b.date));
// Result: A (-450), E (1 CE), B (767 CE), C (~1740 CE), D (1901), F (1921), G (last)Parses a historical date string and returns an astronomical-year sort key.
- Returns a finite number for successfully parsed dates (negative = BCE/Before Common Era, 0 = year 0, positive = CE).
- Returns
Infinityfor recognized-but-unresolvable inputs (e.g. regnal dates without a resolver, garbled era markers) — these sort last. - Returns
Infinityfor unrecognized inputs.
parseHistoricalDate("Meiji 34") // 1901
parseHistoricalDate("AH 150") // ≈767.12
parseHistoricalDate("AM 5786") // 2025
parseHistoricalDate("AUC 754") // 1
parseHistoricalDate("ca. 450 BCE") // -450
parseHistoricalDate("49-44 BC") // -49
parseHistoricalDate("after 217 BC") // -216.5 (sorts between 217 BC and 216 BC)
parseHistoricalDate("Unknown date") // InfinityOptions:
interface ParseOptions {
calendars?: CalendarSystem[]; // restrict which plugins run; default: all six
regnalResolver?: RegnalResolver; // required for regnal dates
regnalRuler?: string; // fallback ruler for bare "regnal year N" inputs
}Throws if calendars contains an unknown calendar name (catches typos).
Drop-in comparator for Array.prototype.sort. Handles null inputs and Infinity results safely — two unparseable values compare as equal (0), not NaN.
records.sort((a, b) => compareHistoricalDates(a.date, b.date));null and unparseable strings always sort after all valid dates.
Formats an astronomical-year sort key back to a human-readable string.
formatHistoricalDate(-450) // "450 BCE"
formatHistoricalDate(767.12, { calendar: "islamic" }) // "AH 150"
formatHistoricalDate(2025, { calendar: "hebrew" }) // "AM 5786"Regnal year parsing requires a caller-supplied resolver. Without one, regnal patterns return Infinity (sorts last) rather than falling through to Gregorian — this prevents "Year 12 of Henry III" from silently sorting to 12 CE instead of ~1227 CE.
import type { RegnalResolver } from "historical-date-sort";
const resolver: RegnalResolver = {
resolve(ruler: string, regnalYear: number): number | null {
const accession: Record<string, number> = {
"Henry III": 1216,
"Edward I": 1272,
};
const start = accession[ruler];
return start !== undefined ? start + regnalYear - 1 : null;
},
};
parseHistoricalDate("Year 12 of Henry III", { regnalResolver: resolver }); // 1227
parseHistoricalDate("3rd year of Edward I", { regnalResolver: resolver }); // 1274
parseHistoricalDate("Year 12 of Henry III"); // InfinityReturn null from resolve() for unknown rulers — the plugin then returns Infinity.
| Input | Sort key | Notes |
|---|---|---|
"1921" |
1921 | Plain integer |
"ca. 450 BCE" |
-450 | ca., circa, c., ~, ≈ all accepted |
"450 BC" |
-450 | BC and BCE treated identically |
"450 AD" |
450 | AD and CE treated identically |
"49-44 BC" |
-49 | Range → start year |
"after 217 BC" |
-216.5 | Sorts between 217 BC and 216 BC |
"before 217 BC" |
-217.5 | Sorts between 218 BC and 217 BC |
"1 BC" |
-1 | Standard convention |
"0 BC" / "0 AD" |
0 | Year 0 (astronomical) |
Structural guard: Gregorian is the catch-all fallback and requires the input to contain an era marker (BC, BCE, AD, CE), a circa indicator, a plain number, a plain range, or a 4-digit year. Inputs like "page 5", "Hour 5", or "10 AM Tuesday" return Infinity rather than silently sorting to 5 CE or 10 CE.
| Input | Sort key |
|---|---|
"AH 150" |
≈767.12 |
"A.H. 150" |
≈767.12 |
"150 AH" |
≈767.12 |
"Hijri 150" |
≈767.12 |
"Hegira 150" |
≈767.12 |
"150 H" |
≈767.12 |
Conversion: CE = AH × 0.9702 + 621.57
| Input | Sort key | Notes |
|---|---|---|
"AM 5786" |
2025 | |
"5786 AM" |
2025 | |
"A.M. 5786" |
2025 | |
"Anno Mundi 5786" |
2025 | Case-insensitive |
"[5786]" |
2025 | Bracketed form |
"AM5786" |
2025 | No space |
Conversion: astro = AM − 3761 (AM 1 → −3760; AM 3761 → 0; AM 5786 → 2025)
Bracketed form caveat: [N] is only recognized as a Hebrew date when the value is ≥ 4000. Values below 4000 (e.g. [1923]) are rejected to avoid false positives with bracketed Gregorian years or editorial notations.
| Input | Sort key | Notes |
|---|---|---|
"AUC 754" |
1 | Arabic year |
"AUC DCCLIV" |
1 | Roman numeral year |
"A.U.C. 753" |
0 | AUC 753 = 1 BCE (year 0) |
Conversion: astro = AUC − 753
Roman numeral parsing uses round-trip validation (re-encodes result back to canonical form; rejects non-standard notations like IC, XM, IL). Bare Roman numerals without the AUC marker are not recognized.
| Input | Sort key | Notes |
|---|---|---|
"Meiji 34" |
1901 | |
"Showa 15" |
1940 | |
"Shōwa 15" |
1940 | Macron variants normalized |
"Reiwa 6" |
2024 | |
"Tempo 5" |
1834 | Romanization variants accepted |
Conversion: CE = era_start + regnal_year − 1
The era table covers Meio (1492) through Reiwa (2019) — approximately 50 entries covering the Edo period onward. Pre-1492 eras are not included in v1.
| Input | Sort key (with resolver) |
|---|---|
"Year 12 of Henry III" |
1227 |
"3rd year of Edward I" |
1274 |
"Henry VIII yr. 12" |
1520 |
"Henry VIII year 12" |
1520 |
"Regnal Year 12 of Henry III" |
1227 |
"regnal year 12" (with regnalRuler) |
depends on resolver |
Without a resolver: returns Infinity. See Regnal dates above.
| Modifier | Effect | Example |
|---|---|---|
after X |
+0.5 to sort key | "after AH 150" → between AH 150 and AH 151 |
before X |
−0.5 to sort key | "before AH 150" → between AH 149 and AH 150 |
ca. X, circa X, c. X, ~X, ≈X |
no offset | treated as the date itself |
Each calendar system has its own subpath export. Bundlers that support exports will tree-shake unused plugins automatically:
import { parseGregorian } from "historical-date-sort/gregorian";
import { parseIslamic, ahToCE } from "historical-date-sort/islamic";
import { parseHebrew, amToCE } from "historical-date-sort/hebrew";
import { parseRoman, aucToCE, parseRomanNumeral } from "historical-date-sort/roman";
import { parseJapanese, gengoToCE, GENGO } from "historical-date-sort/japanese";
import { parseRegnal } from "historical-date-sort/regnal";
import type { RegnalResolver } from "historical-date-sort/regnal";When using the main parseHistoricalDate entry point, plugins run in this order — first match wins:
- Islamic —
AH,A.H.,Hijri,Hegira, digit+H - Hebrew —
AM,A.M.,Anno Mundi, bracketed[N≥4000] - Japanese — era name token (
Meiji,Showa,Reiwa, …) - Roman — requires explicit
AUC/A.U.C.marker - Regnal —
Year N of X,X yr. N,Nth year of X, etc. - Gregorian — catch-all fallback with structural guard
Gregorian runs last deliberately: its digit-extraction regex would otherwise consume strings like "Meiji 34" (extracting 34) or "AH 150" (extracting 150) instead of delegating to the correct plugin.
- Node.js ≥ 18
- Zero runtime dependencies
MIT