; // position β previous FENs
+}
+```
-
+## Data Structure
-There are two entries for the Brooklyn Variation of the Alekhine Defense on lines 132 and 133. However, there is no named variation for the move sequence __1. e4 Nf6 2. e5 Ng8 3. d4__. This makes the variation on line 133 "orphaned", in that it has no preceding named variation that leads to it.
+The eco.json database contains **12,000+ chess opening variations** organized across multiple JSON files:
-## the eco_interpolated.json file
-Every orphan variation (like the Alekhine Defense: Brooklyn Variation, Everglades Variation) has a move sequence. By moving backwards from the end of the move sequence, we eventually wind up at a named variation, which is called the __root__ variation. Along the way, a record of each FEN position and remaining moves in the sequence is made. Then, from the root to the orphan, are created __interpolated__ opening objects that bridge the gap between root and orphan.
+- **ecoA.json - ecoE.json**: Main opening databases, one per ECO category (A-E)
+- **eco_interpolated.json**: Generated variations that bridge gaps between named openings
+- **fromTo.json**: Raw transition graph array of `[from_fen, to_fen, from_source, to_source]`
+- **fromToPositionIndexed.json**: Pre-indexed transition data (optimized for O(1) lookup)
-A more detailed explanation can be found in Part 2 of [this blog post](https://medium.com/@jefflowery/navigating-chess-openings-part-1-8d779aee0965).
+All data is downloaded on-demand from GitHub and cached in memory.
-### naming the interpolated variations
-Interpolated opening variations _may_ have a name, but just weren't found in our sources. This can be corrected over time, and freshly named interpolated openings can be inserted into the __eco.json__ file as they are discovered. In the meantime, the names assigned to interpolated openings are the root name plus `" (i)"`. There may be several of these, but openings names are not required to be unique (only FENs are).
+### Opening Object Schema
-For the example above, the interpolated opening object would be:
+Each opening is keyed by **FEN** (Forsyth-Edwards Notation), which uniquely identifies positions:
+```json
+{
+ "rnbqkb1r/pppppppp/8/3nP3/3P4/8/PPP2PPP/RNBQKBNR b KQkq": {
+ "src": "eco_tsv",
+ "eco": "B03",
+ "moves": "1. e4 Nf6 2. e5",
+ "name": "Alekhine Defense",
+ "aliases": {
+ "scid": "Alekhine: 3.d4"
+ },
+ "scid": "B03a",
+ "isEcoRoot": true
+ }
+}
```
+
+#### Field Descriptions
+
+- **fen** (key): Unique position identifier using Forsyth-Edwards Notation. Can be full FEN (with turn, castling, en passant) or position-only (first field).
+- **src**: Data source - `eco_tsv` (authoritative), `eco_js`, `scid`, `eco_wikip`, `wiki_b`, `ct`, `chessGraph`, `chronos`, `icsbot`, `pgn`, or `interpolated`
+- **eco**: ECO category code (e.g., "B03") - not unique; many openings share the same ECO code
+- **moves**: Standard algebraic notation move sequence (e.g., "1. e4 Nf6 2. e5")
+- **name**: Opening name (e.g., "Alekhine Defense")
+- **aliases**: Alternative names from other sources (object with source keys)
+- **scid**: Extended SCID code when applicable (e.g., "B03a")
+- **isEcoRoot**: `true` if this is the canonical root variation for this ECO code
+
+### Source Hierarchy
+
+**eco_tsv** (from [lichess](https://github.com/lichess-org/chess-openings)) is the authoritative source and supersedes all conflicts. Other sources are merged with eco_tsv taking precedence.
+
+### Interpolated Openings
+
+"Orphan" variations lack a `from` field (no preceding named variation). Interpolations fill these gaps:
+
+```json
{
- ...
- {
- "rnbqkbnr/pppppppp/8/4P3/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 0 3": {
- "src": "interpolated",
- "eco": "B02",
- "moves": "1. e4 Nf6 2. e5 Ng8 3. d4",
- "name": "Alekhine Defense: Brooklyn Variation (i)",
- "scid": "B02l",
- "aliases": {
- "scid": "Alekhine: Brooklyn Defence (Retreat Variation)"
- }
- },
- ...
+ "rnbqkbnr/pppppppp/8/4P3/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 0 3": {
+ "src": "interpolated",
+ "eco": "B02",
+ "moves": "1. e4 Nf6 2. e5 Ng8 3. d4",
+ "name": "Alekhine Defense: Brooklyn Variation (i)",
+ "scid": "B02l",
+ "aliases": {
+ "scid": "Alekhine: Brooklyn Defence (Retreat Variation)"
+ }
+ }
}
```
-Note that __src__ is labeled "interpolated", meaning it wasn't derived directly from either of the originating three sources: __eco_tsv__, __eco_js__, or __scid__.
-### additional interpolations
-It is often desirable to have every move subsequence that appears within an opening to have an entry in the database as well. For example, the move sequence for the "Queen's Gambit Declined: Exchange Variation" is "1. d4 Nf6 2. c4 e6 3. Nc3 d5 4. cxd5"; however there is no opening book entry for the subsequence "1. d4 Nf6 2. c4 e6 3. Nc3 d5". This leaves a gap between "1. d4 Nf6 2. c4 e6 3. Nc3" ("Queen's Pawn: Neo-Indian") and the current variation. To fix this, an interpolation is created for the missing subsequence, "Queen's Pawn: Neo-Indian (i)".
+**Key characteristics:**
+
+- **src**: "interpolated"
+- **name**: Root name + " (i)" suffix
+- **Purpose**: Every move subsequence that appears within an opening has an entry
+For example, if "Queen's Gambit Declined: Exchange Variation" is "1. d4 Nf6 2. c4 e6 3. Nc3 d5 4. cxd5" but no entry exists for "1. d4 Nf6 2. c4 e6 3. Nc3 d5", an interpolated opening is created to bridge the gap.
-### using interpolated openings
-It's a simple matter to merge eco_interpolated.json with eco.json, once the two files are read into a program as JSON objects. See `/tooling/ecoConjoined.mjs` for how to do this.
+### FEN as Primary Key
-# fromTo.json
-One or more move sequences can lead to every opening position in eco*.json. Obversely, zero or more opening continuations are available from any opening in eco*.json. The data file `fromTo.json` is an array of arrays holding this information. Each element of the fromTo array is as follows:
-1. __from__ position (FEN notation)
-2. __to__ position
-3. __from__ source (see list at top of page; may include "interpolated")
-4. __to__ source
+- FEN strings uniquely identify positions
+- Move sequences and names are NOT unique identifiers
+- Multiple move orders (transpositions) can reach the same FEN
+- Always use FEN for lookups, not moves or names
+### Data Sources
-# The /tooling folder
-*NOTE*: It is recommended to use the [eco.json.tooling](https://github.com/JeffML/eco.json.tooling) project to generate pull request opening data. The files in this project's current /tooling folder will be incorporated there soon, making this a data-only project once again.
+This data is a collation of several chess opening databases:
-The /tooling folder has scripts for manipulating the data in the JSON files. They have a node.js-compatible ".mjs" extension so that they can be run standalone from the command line. For example:
- > node tooling/ecoConjoin.mjs
+- **eco_tsv**: [lichess chess-openings](https://github.com/lichess-org/chess-openings) (Jun. 2025)
+- **eco_js**: Original eco.json data from several years ago
+- **scid**: [SCID project](https://scid.sourceforge.net/) database
+- **eco_wikip**: [Wikipedia List of Chess Openings](https://en.wikipedia.org/wiki/List_of_chess_openings) (Aug. 2024)
+- **wiki_b**: [Wikibooks Chess Opening Theory](https://en.wikibooks.org/wiki/Chess_Opening_Theory) (Nov. 2024)
+- **ct**: [ChessTempo](https://chesstempo.com/) (Jun 2025)
+- **chessGraph**: [chess-graph](https://github.com/Destaq/chess-graph) (Jun 2025)
+- **chronos**: chronos eco.pgn (Jun 2025)
+- **icsbot**: [icsbot](https://github.com/seberg/icsbot/blob/master/misc/eco.txt) (Jun 2025)
+- **pgn**: Various PGN file databases (Jun 2025)
-These can be useful on their own and an aid to understanding how eco.json encodes opening information.
+## Performance Tips
-* ecoConjoin.mjs: joins together all eco*.json files into on monolithic eco.json files (just like the old days)
-* readJsonFile: reads a JSON formatted file and returns a JSON object (or array)
-* from.mjs/to.mjs: given a FEN argument, these scripts will return variations from the current FEN position, preceeding or following the current position. It uses `fromTo.json` data.
-* findOpeningByFen: takes a FEN command-line argument(s) and outputs the opening + from + to variations.
+1. **Reuse loaded data**: Call `openingBook()` once and reuse the result
+2. **Pre-build position book**: Create once with `getPositionBook()` if doing multiple lookups
+3. **Cache transitions**: `getFromTos()` caches the index automatically
+4. **Pass openings**: Provide pre-loaded openings to `getFromTos()` to skip re-fetching
-# Acknowledgements
+```typescript
+// Efficient pattern
+const openings = await openingBook();
+const posBook = getPositionBook(openings);
+
+for (const fen of fens) {
+ const opening = findOpening(openings, fen, posBook);
+ const transitions = await getFromTos(fen, openings);
+}
+```
+
+## Acknowledgements
Thanks to [@niklasf](https://github.com/niklasf) for [eco](https://github.com/niklasf/eco).
-Credit goes to Shane Hudson for the original SCID opening data
+Credit goes to Shane Hudson for the original SCID opening data.
Original eco.json data was compiled by ΓmΓΌr YanΔ±koΔlu.
-# Implementations
-[Fenster](https://fensterchess.com) derives its opening book from __eco.json__.
+## Contributing
+
+This package provides read-only utilities for consuming opening data. For data maintenance and updates:
+
+- **Data source**: [eco.json repository](https://github.com/JeffML/eco.json)
+- **Data tooling**: [eco.json.tooling repository](https://github.com/JeffML/eco.json.tooling)
+
+## License
+MIT
-# References
+## Related Projects
-This data is a collation of several chess opening databases, as follows:
-* __eco_tsv__: Source: [lichess](https://github.com/lichess-org/chess-openings). (Jun. 2025)
-* __eco_js__: The original eco.json data from several years ago, which contains some openings not in __eco_tsv__
-* __scid__: A database that's part of a [sourceforge project](https://scid.sourceforge.net/), pulled via Waterford Chess Club's [website](https://watfordchessclub.org/images/downloads/scid.eco). SCID codes extend ECO, and opening names vary.
-* __eco_wikip__: Opening data from the Wikipedia page at https://en.wikipedia.org/wiki/List_of_chess_openings (Aug. 2024)
-* __wiki_b__: Opening data from the Wikibooks pages at https://en.wikibooks.org/wiki/Chess_Opening_Theory (Nov. 2024)
-* __eco_wikip.g__: Opening data from the Wikipedia List of Chess Gambits at https://en.wikipedia.org/wiki/List_of_chess_gambits (May 2025)
-* __ct__: Opening data from [ChessTempo](https://chesstempo.com/) (Jun 2025)
-* __chessGraph__: Opening data from [chess-graph](https://github.com/Destaq/chess-graph) (Jun 2025)
-* __chronos__: Opening data (all aliases) from [chronos eco.pgn](file:///home/chronos/u-a5d83366612aa8feeee6083530d5bb7f7b8939a9/MyFiles/Downloads/eco.pgn) (Jun 2025)
-* __icsbot__: Opening data from [icsbot](https://github.com/seberg/icsbot/blob/master/misc/eco.txt) (Jun 2025)
-* __pgn__: Opening data gleaned from various PGN file databases, using the `pgnOpeningAnalyzer` of [eco.json.tooling](https://github.com/JeffML/eco.json.tooling)
\ No newline at end of file
+- [eco.json](https://github.com/JeffML/eco.json) - Chess opening database
+- [fensterchess](https://fensterchess.com) - Chess opening research tool
diff --git a/chess-openings-eco.json-0.1.0.tgz b/chess-openings-eco.json-0.1.0.tgz
new file mode 100644
index 0000000..7071f4c
Binary files /dev/null and b/chess-openings-eco.json-0.1.0.tgz differ
diff --git a/methods/findOpening.ts b/methods/findOpening.ts
new file mode 100644
index 0000000..2156c6a
--- /dev/null
+++ b/methods/findOpening.ts
@@ -0,0 +1,89 @@
+import type { Opening, OpeningCollection } from "../src/types.js";
+
+/**
+ * Position book maps position-only FEN (without turn/castling/en passant) to full FENs.
+ * Used for fallback when exact FEN match fails.
+ */
+export interface PositionBook {
+ [position: string]: string[];
+}
+
+/**
+ * Creates a position book from an opening collection.
+ * This allows looking up positions without turn, castling, or en passant information.
+ *
+ * @param openingBook - The complete opening collection
+ * @returns Position book mapping position-only FEN to array of full FENs
+ *
+ * @example
+ * ```typescript
+ * const openings = await openingBook();
+ * const posBook = getPositionBook(openings);
+ *
+ * // Look up position without game state
+ * const fens = posBook["rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR"];
+ * // Returns: ["rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"]
+ * ```
+ */
+export function getPositionBook(openingBook: OpeningCollection): PositionBook {
+ const positionToFen: PositionBook = {};
+
+ for (const fen in openingBook) {
+ const position = fen.split(" ")[0];
+
+ positionToFen[position] ??= [];
+ positionToFen[position].push(fen);
+ }
+
+ return positionToFen;
+}
+
+/**
+ * Finds an opening by FEN with automatic fallback to position-only matching.
+ * First tries exact FEN match, then falls back to position-only (ignoring turn, castling, en passant).
+ *
+ * @param openingBook - The complete opening collection
+ * @param fen - The FEN string to look up (full or position-only)
+ * @param positionBook - Optional position book for fallback lookup (created via getPositionBook)
+ * @returns The opening if found, undefined otherwise
+ *
+ * @example
+ * ```typescript
+ * const openings = await openingBook();
+ * const posBook = getPositionBook(openings);
+ *
+ * // Exact match
+ * const opening1 = findOpening(
+ * openings,
+ * "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
+ * posBook
+ * );
+ *
+ * // Position-only match (different turn/castling but same position)
+ * const opening2 = findOpening(
+ * openings,
+ * "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 5 3",
+ * posBook
+ * );
+ * // Both return the same opening: King's Pawn Opening
+ * ```
+ */
+export function findOpening(
+ openingBook: OpeningCollection,
+ fen: string,
+ positionBook?: PositionBook
+): Opening | undefined {
+ // Try exact FEN match first
+ let opening = openingBook[fen];
+
+ // Fallback to position-only match if available
+ if (!opening && positionBook) {
+ const position = fen.split(" ")[0];
+ const posEntry = positionBook[position];
+ if (posEntry && posEntry.length > 0) {
+ opening = openingBook[posEntry[0]];
+ }
+ }
+
+ return opening;
+}
diff --git a/methods/fromTo.ts b/methods/fromTo.ts
new file mode 100644
index 0000000..fb95849
--- /dev/null
+++ b/methods/fromTo.ts
@@ -0,0 +1,108 @@
+import type { Opening, OpeningCollection } from "../src/types.js";
+import { openingBook } from "./getLatestEcoJson.js";
+
+/**
+ * From/To transition index structure.
+ * Maps position-only FEN to arrays of full FENs representing transitions.
+ */
+export interface FromToIndex {
+ /** Maps position β array of next position FENs */
+ to: Record;
+ /** Maps position β array of previous position FENs */
+ from: Record;
+}
+
+// Cache for fromToIndex to avoid re-downloading
+let fromToIndexCache: FromToIndex | null = null;
+
+/**
+ * Downloads and caches the pre-indexed from/to transition data from GitHub.
+ * This file is generated by eco.json.tooling and maps position-only FENs to transition arrays.
+ *
+ * @returns Indexed transition data with to/from mappings
+ *
+ * @example
+ * ```typescript
+ * const index = await getFromToIndex();
+ * const position = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR";
+ * const nextFens = index.to[position]; // FENs for e2-e4 position
+ * ```
+ */
+export async function getFromToIndex(): Promise {
+ if (fromToIndexCache) {
+ return fromToIndexCache;
+ }
+
+ const url =
+ "https://raw.githubusercontent.com/JeffML/eco.json/master/fromToPositionIndexed.json";
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch fromToPositionIndexed.json: ${response.status} ${response.statusText}`
+ );
+ }
+
+ fromToIndexCache = await response.json();
+ return fromToIndexCache!;
+}
+
+/**
+ * Gets the next and previous opening positions for a given FEN.
+ * Returns full Opening objects (not just FEN strings) by looking up transitions
+ * in the pre-indexed data and enriching with opening book information.
+ *
+ * Uses position-only FEN for lookup to handle transpositions.
+ *
+ * @param fen - The FEN string to look up transitions for
+ * @param openings - Optional pre-loaded opening collection (will fetch if not provided)
+ * @returns Object with next and from Opening arrays
+ *
+ * @example
+ * ```typescript
+ * // Get transitions for e2-e4 position
+ * const { next, from } = await getFromTos(
+ * "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
+ * );
+ *
+ * console.log(next[0].name); // "King's Pawn Opening: Sicilian Defense"
+ * console.log(from[0].name); // "Starting Position"
+ * ```
+ */
+export async function getFromTos(
+ fen: string,
+ openings?: OpeningCollection
+): Promise<{
+ next: Opening[];
+ from: Opening[];
+}> {
+ // Load data if not provided
+ const openingCollection = openings || (await openingBook());
+ const fromToIndex = await getFromToIndex();
+
+ // Extract position-only FEN (ignore turn, castling, en passant)
+ const position = fen.split(" ")[0];
+
+ // Get FEN arrays from index
+ const nextFens = fromToIndex.to[position] || [];
+ const fromFens = fromToIndex.from[position] || [];
+
+ // Enrich with full Opening data
+ const next = nextFens
+ .map((f) => openingCollection[f])
+ .filter((o): o is Opening => o != null);
+
+ const from = fromFens
+ .map((f) => openingCollection[f])
+ .filter((o): o is Opening => o != null);
+
+ return { next, from };
+}
+
+/**
+ * Clears the cached fromToIndex, forcing a fresh download on next access.
+ * Useful for testing or when data updates are expected.
+ */
+export function clearFromToCache(): void {
+ fromToIndexCache = null;
+}
diff --git a/methods/getLatestEcoJson.ts b/methods/getLatestEcoJson.ts
new file mode 100644
index 0000000..c824451
--- /dev/null
+++ b/methods/getLatestEcoJson.ts
@@ -0,0 +1,110 @@
+import { OpeningCollection } from "../src/types.js";
+
+/**
+ * GitHub raw content URL for eco.json repository
+ */
+const ECO_JSON_RAW =
+ "https://raw.githubusercontent.com/JeffML/eco.json/master/";
+
+interface EcoCategoryData {
+ url: string;
+ json?: OpeningCollection;
+}
+
+interface OpeningsByCat {
+ initialized: boolean;
+ A?: EcoCategoryData;
+ B?: EcoCategoryData;
+ C?: EcoCategoryData;
+ D?: EcoCategoryData;
+ E?: EcoCategoryData;
+ IN?: EcoCategoryData;
+}
+
+let openingsByCat: OpeningsByCat = { initialized: false };
+
+/**
+ * Downloads and caches opening data from the eco.json GitHub repository.
+ * Data is fetched once and cached for subsequent calls.
+ *
+ * @returns Promise resolving to opening data organized by ECO category
+ *
+ * @example
+ * ```typescript
+ * const { A, B, C, D, E, IN } = await getLatestEcoJson();
+ * console.log(A?.json); // ecoA.json data
+ * console.log(IN?.json); // eco_interpolated.json data
+ * ```
+ */
+export async function getLatestEcoJson(): Promise {
+ if (!openingsByCat.initialized) {
+ const ROOT = ECO_JSON_RAW;
+
+ openingsByCat = {
+ initialized: false,
+ A: { url: ROOT + "ecoA.json" },
+ B: { url: ROOT + "ecoB.json" },
+ C: { url: ROOT + "ecoC.json" },
+ D: { url: ROOT + "ecoD.json" },
+ E: { url: ROOT + "ecoE.json" },
+ IN: { url: ROOT + "eco_interpolated.json" },
+ };
+
+ const promises: Promise[] = [];
+ for (const cat in openingsByCat) {
+ if (cat !== "initialized") {
+ const category =
+ openingsByCat[cat as keyof Omit];
+ if (category) {
+ promises.push(fetch(category.url));
+ }
+ }
+ }
+
+ const res = await Promise.all(promises);
+ let i = 0;
+
+ for (const cat in openingsByCat) {
+ if (cat !== "initialized") {
+ const category =
+ openingsByCat[cat as keyof Omit];
+ if (category) {
+ const json = await res[i++].json();
+ category.json = json;
+ }
+ }
+ }
+
+ openingsByCat.initialized = true;
+ }
+
+ return openingsByCat;
+}
+
+/**
+ * Downloads and merges all opening data (ecoA-E + interpolated) into a single collection.
+ * Data is cached after the first call.
+ *
+ * @returns Promise resolving to a complete opening collection keyed by FEN
+ *
+ * @example
+ * ```typescript
+ * const openings = await openingBook();
+ * const fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3";
+ * console.log(openings[fen]); // Opening data for this position
+ * ```
+ */
+export async function openingBook(): Promise {
+ const { A, B, C, D, E, IN } = await getLatestEcoJson();
+
+ const openingBook: OpeningCollection = {
+ ...A?.json,
+ ...B?.json,
+ ...C?.json,
+ ...D?.json,
+ ...E?.json,
+ ...IN?.json,
+ };
+
+ return openingBook;
+}
diff --git a/methods/queryOpenings.ts b/methods/queryOpenings.ts
new file mode 100644
index 0000000..78d8e78
--- /dev/null
+++ b/methods/queryOpenings.ts
@@ -0,0 +1,97 @@
+import type { Opening, OpeningCollection } from "../src/types.js";
+import { openingBook } from "./getLatestEcoJson.js";
+
+/**
+ * Returns all openings for a specific ECO code.
+ * Note: Multiple openings can share the same ECO code (it's a category, not a unique identifier).
+ *
+ * @param ecoCode - The ECO code to filter by (e.g., "B12", "C42")
+ * @param openings - Optional pre-loaded opening collection (fetched via openingBook() if not provided)
+ * @returns Array of openings matching the ECO code
+ *
+ * @example
+ * ```typescript
+ * // Get all Caro-Kann Defense variations
+ * const caroKann = await getOpeningsByEco("B12");
+ * console.log(`Found ${caroKann.length} variations`);
+ * caroKann.forEach(o => console.log(o.name, o.moves));
+ * ```
+ */
+export async function getOpeningsByEco(
+ ecoCode: string,
+ openings?: OpeningCollection
+): Promise {
+ const book = openings || (await openingBook());
+
+ return Object.values(book).filter((opening) => opening.eco === ecoCode);
+}
+
+/**
+ * Returns all openings for a specific ECO category (A, B, C, D, or E).
+ *
+ * @param category - The ECO category letter ("A", "B", "C", "D", or "E")
+ * @param openings - Optional pre-loaded opening collection (fetched via openingBook() if not provided)
+ * @returns Array of openings in the category
+ *
+ * @example
+ * ```typescript
+ * // Get all semi-open games (category B)
+ * const semiOpen = await getOpeningsByEcoCategory("B");
+ * console.log(`Found ${semiOpen.length} openings in category B`);
+ *
+ * // With pre-loaded book (more efficient for multiple queries)
+ * const book = await openingBook();
+ * const catA = await getOpeningsByEcoCategory("A", book);
+ * const catB = await getOpeningsByEcoCategory("B", book);
+ * ```
+ */
+export async function getOpeningsByEcoCategory(
+ category: string,
+ openings?: OpeningCollection
+): Promise {
+ const book = openings || (await openingBook());
+ const upperCategory = category.toUpperCase();
+
+ if (!["A", "B", "C", "D", "E"].includes(upperCategory)) {
+ throw new Error(
+ `Invalid ECO category: ${category}. Must be A, B, C, D, or E.`
+ );
+ }
+
+ return Object.values(book).filter((opening) =>
+ opening.eco?.startsWith(upperCategory)
+ );
+}
+
+/**
+ * Returns only the canonical ECO root variations (where isEcoRoot === true).
+ * These are the "official" variations defined in the Encyclopedia of Chess Openings.
+ *
+ * @param openings - Optional pre-loaded opening collection (fetched via openingBook() if not provided)
+ * @returns Object mapping FEN to root opening (preserves FEN keys for lookup)
+ *
+ * @example
+ * ```typescript
+ * // Get all canonical ECO variations
+ * const roots = await getEcoRoots();
+ * console.log(`Found ${Object.keys(roots).length} ECO root variations`);
+ *
+ * // Check if a position is an ECO root
+ * const fen = "rnbqkb1r/pppppppp/5n2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2";
+ * if (roots[fen]) {
+ * console.log(`${roots[fen].name} (${roots[fen].eco}) is an ECO root`);
+ * }
+ * ```
+ */
+export async function getEcoRoots(
+ openings?: OpeningCollection
+): Promise {
+ const book = openings || (await openingBook());
+
+ return Object.entries(book)
+ .filter(([, opening]) => (opening as any).isEcoRoot === true)
+ .reduce((acc, [fen, opening]) => {
+ acc[fen] = opening;
+ return acc;
+ }, {} as OpeningCollection);
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..10e5646
--- /dev/null
+++ b/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@chess-openings/eco.json",
+ "version": "1.0.0",
+ "description": "Read-only utilities for consuming chess opening data from eco.json",
+ "type": "module",
+ "main": "./dist/src/index.js",
+ "types": "./dist/src/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/src/index.js",
+ "types": "./dist/src/index.d.ts"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "watch": "tsc --watch",
+ "clean": "rm -rf dist",
+ "type-check": "tsc --noEmit",
+ "prepublishOnly": "npm run clean && npm run build",
+ "publish:public": "npm publish --access public"
+ },
+ "keywords": [
+ "chess",
+ "openings",
+ "eco",
+ "chess-openings",
+ "fen",
+ "pgn"
+ ],
+ "author": "Jeff Lowery",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/JeffML/eco.json.git"
+ },
+ "bugs": {
+ "url": "https://github.com/JeffML/eco.json/issues"
+ },
+ "homepage": "https://github.com/JeffML/eco.json#readme",
+ "devDependencies": {
+ "typescript": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..dc20510
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,25 @@
+/**
+ * @chess-openings/eco.json
+ *
+ * Read-only utilities for consuming chess opening data from the eco.json repository.
+ * Opening data is downloaded on-demand from GitHub to keep the package size small.
+ */
+
+export * from "./types.js";
+export { getLatestEcoJson, openingBook } from "../methods/getLatestEcoJson.js";
+export {
+ findOpening,
+ getPositionBook,
+ type PositionBook,
+} from "../methods/findOpening.js";
+export {
+ getFromToIndex,
+ getFromTos,
+ clearFromToCache,
+ type FromToIndex,
+} from "../methods/fromTo.js";
+export {
+ getOpeningsByEco,
+ getOpeningsByEcoCategory,
+ getEcoRoots,
+} from "../methods/queryOpenings.js";
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..d52ded6
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,64 @@
+/**
+ * Source of the opening data.
+ * eco_tsv is the authoritative source from lichess.
+ */
+export type OpeningSource =
+ | "eco_tsv"
+ | "eco_js"
+ | "scid"
+ | "eco_wikip"
+ | "wiki_b"
+ | "ct"
+ | "chessGraph"
+ | "chronos"
+ | "icsbot"
+ | "pgn"
+ | "interpolated";
+
+/**
+ * ECO category (A-E)
+ */
+export type EcoCategory = "A" | "B" | "C" | "D" | "E";
+
+/**
+ * Opening variation record keyed by FEN position
+ */
+export interface Opening {
+ /** Source of this opening data */
+ src: OpeningSource;
+
+ /** ECO code (e.g., "B03"). Not unique - many openings share the same ECO code */
+ eco: string;
+
+ /** Standard algebraic notation move sequence (e.g., "1. e4 Nf6 2. e5") */
+ moves: string;
+
+ /** Common English name of the opening */
+ name: string;
+
+ /** Alternative names from other sources */
+ aliases?: Record;
+
+ /** Extended SCID code when applicable */
+ scid?: string;
+
+ /** True if this is the official ECO root variation for this code */
+ isEcoRoot?: boolean;
+
+ /** For interpolated openings: the original source of the root variation */
+ rootSrc?: OpeningSource;
+}
+
+/**
+ * Collection of openings keyed by FEN notation
+ * FEN is the unique identifier for each position
+ */
+export interface OpeningCollection {
+ [fen: string]: Opening;
+}
+
+/**
+ * Opening transition in the fromTo graph
+ * [from_fen, to_fen, from_source, to_source]
+ */
+export type OpeningTransition = [string, string, OpeningSource, OpeningSource];
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..49df09c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022", "DOM"],
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*", "methods/**/*"],
+ "exclude": ["node_modules", "dist", "tooling"]
+}