@echecs/fen 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.1] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Updated lockfile after removing `@echecs/position` dependency.
|
|
8
|
+
|
|
9
|
+
## [1.0.0] - 2026-03-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- FEN syntax validation for halfmove clock (>= 0) and fullmove number (>= 1).
|
|
14
|
+
- Position warnings via `onWarning`: missing king, pawns on rank 1 or 8, more
|
|
15
|
+
than 8 pawns per side, more than 16 pieces per side.
|
|
16
|
+
- Accurate `offset`, `line`, and `column` fields on `ParseError` and
|
|
17
|
+
`ParseWarning`.
|
|
18
|
+
- Exported all core types: `CastlingRights`, `Color`, `File`, `Piece`,
|
|
19
|
+
`PieceType`, `Position`, `Rank`, `Square`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Invalid halfmove clock and fullmove number are now hard errors (previously
|
|
24
|
+
warnings with fallback defaults).
|
|
25
|
+
- Rank mismatch in placement fires `onError` exactly once (previously fired both
|
|
26
|
+
`onWarning` and `onError`).
|
|
27
|
+
- Removed `@echecs/position` runtime dependency; all types are defined locally.
|
|
28
|
+
- Package is ESM-only; removed `"main"` field from `package.json`.
|
|
29
|
+
|
|
30
|
+
## [0.1.0] - 2026-03-19
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `parse` default export: FEN string to `Position` object (returns `null` on
|
|
35
|
+
invalid input).
|
|
36
|
+
- `stringify` named export: `Position` object to FEN string.
|
|
37
|
+
- `STARTING_FEN` constant for the standard starting position.
|
|
38
|
+
- FEN syntax validation for piece types, castling availability, and en passant
|
|
39
|
+
target square.
|
|
40
|
+
- `onError` and `onWarning` callbacks via `ParseOptions`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Adrian de la Rosa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @echecs/fen
|
|
2
|
+
|
|
3
|
+
Parse and stringify
|
|
4
|
+
[FEN](https://www.chessprogramming.org/Forsyth-Edwards_Notation)
|
|
5
|
+
(Forsyth-Edwards Notation) chess positions. Strict TypeScript, no-throw API.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @echecs/fen
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Parsing
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import parse from '@echecs/fen';
|
|
19
|
+
|
|
20
|
+
const position = parse(
|
|
21
|
+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
22
|
+
);
|
|
23
|
+
// => { board, turn, castlingRights, enPassantSquare, halfmoveClock, fullmoveNumber }
|
|
24
|
+
|
|
25
|
+
parse('invalid');
|
|
26
|
+
// => null
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`parse` never throws. It returns `null` when the input is not a valid FEN
|
|
30
|
+
string.
|
|
31
|
+
|
|
32
|
+
#### Error and warning callbacks
|
|
33
|
+
|
|
34
|
+
Errors indicate invalid FEN syntax — the string cannot be parsed. Warnings
|
|
35
|
+
indicate a successfully parsed position that is suspicious (e.g. missing king).
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const position = parse(fen, {
|
|
39
|
+
onError(error) {
|
|
40
|
+
// FEN is malformed — parse returns null.
|
|
41
|
+
console.error(`[${error.offset}] ${error.message}`);
|
|
42
|
+
},
|
|
43
|
+
onWarning(warning) {
|
|
44
|
+
// FEN is valid but the position is suspicious.
|
|
45
|
+
console.warn(warning.message);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Errors are reported for:
|
|
51
|
+
|
|
52
|
+
- Wrong number of fields
|
|
53
|
+
- Invalid piece placement (bad piece type, wrong rank length)
|
|
54
|
+
- Invalid active color
|
|
55
|
+
- Invalid castling availability
|
|
56
|
+
- Invalid en passant target square
|
|
57
|
+
- Invalid halfmove clock (non-numeric or negative)
|
|
58
|
+
- Invalid fullmove number (non-numeric or less than 1)
|
|
59
|
+
|
|
60
|
+
Warnings are reported for:
|
|
61
|
+
|
|
62
|
+
- Missing king for either side
|
|
63
|
+
- Pawn on rank 1 or 8
|
|
64
|
+
- More than 8 pawns per side
|
|
65
|
+
- More than 16 pieces per side
|
|
66
|
+
|
|
67
|
+
### Stringifying
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { stringify } from '@echecs/fen';
|
|
71
|
+
|
|
72
|
+
stringify(position);
|
|
73
|
+
// => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`stringify` always succeeds.
|
|
77
|
+
|
|
78
|
+
### Constants
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { STARTING_FEN } from '@echecs/fen';
|
|
82
|
+
|
|
83
|
+
STARTING_FEN;
|
|
84
|
+
// => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
### `parse(input: string, options?: ParseOptions): Position | null`
|
|
90
|
+
|
|
91
|
+
Parses a FEN string into a `Position` object. Returns `null` if the input is not
|
|
92
|
+
a valid FEN string.
|
|
93
|
+
|
|
94
|
+
### `stringify(position: Position): string`
|
|
95
|
+
|
|
96
|
+
Serializes a `Position` object into a FEN string.
|
|
97
|
+
|
|
98
|
+
### `STARTING_FEN`
|
|
99
|
+
|
|
100
|
+
The FEN string for the standard starting position.
|
|
101
|
+
|
|
102
|
+
### Types
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
interface Position {
|
|
106
|
+
board: Map<Square, Piece>;
|
|
107
|
+
castlingRights: CastlingRights;
|
|
108
|
+
enPassantSquare: Square | undefined;
|
|
109
|
+
fullmoveNumber: number;
|
|
110
|
+
halfmoveClock: number;
|
|
111
|
+
turn: Color;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ParseError {
|
|
115
|
+
column: number; // 1-indexed column in the FEN string
|
|
116
|
+
line: number; // Always 1 (FEN is single-line)
|
|
117
|
+
message: string;
|
|
118
|
+
offset: number; // 0-indexed offset into the FEN string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ParseWarning {
|
|
122
|
+
column: number;
|
|
123
|
+
line: number;
|
|
124
|
+
message: string;
|
|
125
|
+
offset: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
type Color = 'w' | 'b';
|
|
129
|
+
type Square = `${File}${Rank}`;
|
|
130
|
+
type File = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h';
|
|
131
|
+
type Rank = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
|
|
132
|
+
type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
|
|
133
|
+
|
|
134
|
+
interface Piece {
|
|
135
|
+
color: Color;
|
|
136
|
+
type: PieceType;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface CastlingRights {
|
|
140
|
+
wK: boolean;
|
|
141
|
+
wQ: boolean;
|
|
142
|
+
bK: boolean;
|
|
143
|
+
bQ: boolean;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type Color = 'b' | 'w';
|
|
3
|
+
type File = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h';
|
|
4
|
+
type PieceType = 'b' | 'k' | 'n' | 'p' | 'q' | 'r';
|
|
5
|
+
type Rank = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8';
|
|
6
|
+
type Square = `${File}${Rank}`;
|
|
7
|
+
interface CastlingRights {
|
|
8
|
+
bK: boolean;
|
|
9
|
+
bQ: boolean;
|
|
10
|
+
wK: boolean;
|
|
11
|
+
wQ: boolean;
|
|
12
|
+
}
|
|
13
|
+
interface Piece {
|
|
14
|
+
color: Color;
|
|
15
|
+
type: PieceType;
|
|
16
|
+
}
|
|
17
|
+
interface Position {
|
|
18
|
+
board: Map<Square, Piece>;
|
|
19
|
+
castlingRights: CastlingRights;
|
|
20
|
+
enPassantSquare: Square | undefined;
|
|
21
|
+
fullmoveNumber: number;
|
|
22
|
+
halfmoveClock: number;
|
|
23
|
+
turn: Color;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/index.d.ts
|
|
27
|
+
interface ParseError {
|
|
28
|
+
column: number;
|
|
29
|
+
line: number;
|
|
30
|
+
message: string;
|
|
31
|
+
offset: number;
|
|
32
|
+
}
|
|
33
|
+
interface ParseWarning {
|
|
34
|
+
column: number;
|
|
35
|
+
line: number;
|
|
36
|
+
message: string;
|
|
37
|
+
offset: number;
|
|
38
|
+
}
|
|
39
|
+
interface ParseOptions {
|
|
40
|
+
onError?: (error: ParseError) => void;
|
|
41
|
+
onWarning?: (warning: ParseWarning) => void;
|
|
42
|
+
}
|
|
43
|
+
declare const STARTING_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
|
|
44
|
+
declare function parse(input: string, options?: ParseOptions): Position | null;
|
|
45
|
+
declare function stringify(position: Position): string;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { type CastlingRights, type Color, type File, type ParseError, type ParseOptions, type ParseWarning, type Piece, type PieceType, type Position, type Rank, STARTING_FEN, type Square, parse as default, stringify };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=`rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1`,t=[`a`,`b`,`c`,`d`,`e`,`f`,`g`,`h`],n=new Set([`p`,`n`,`b`,`r`,`q`,`k`]),r=[`8`,`7`,`6`,`5`,`4`,`3`,`2`,`1`];function i(e,t=0){return{column:t+1,line:1,message:e,offset:t}}function a(e,t=0){return{column:t+1,line:1,message:e,offset:t}}function o(e,a){let o=new Map,s=e.split(`/`);if(s.length!==8)return null;for(let[e,c]of s.entries()){let s=r[e];if(s===void 0||c===void 0)return null;let l=0;for(let e of c){let r=Number.parseInt(e,10);if(Number.isNaN(r)){let r=e.toLowerCase();if(!n.has(r))return a?.(i(`Invalid piece type: "${e}"`)),null;let c=e===e.toUpperCase()?`w`:`b`,u=r,d=t[l];if(d===void 0)return null;o.set(`${d}${s}`,{color:c,type:u}),l+=1}else l+=r}if(l!==8)return a?.(i(`Invalid FEN rank "${c}": expected 8 files, got ${l}`)),null}return o}const s=/^(?:-|K?Q?k?q?)$/,c=/^[a-h][36]$/;function l(e){return!s.test(e)||e.length===0?null:{bK:e.includes(`k`),bQ:e.includes(`q`),wK:e.includes(`K`),wQ:e.includes(`Q`)}}function u(e){let n=[];for(let i of r){let r=``,a=0;for(let n of t){let t=e.get(`${n}${i}`);t===void 0?a+=1:(a>0&&(r+=String(a),a=0),r+=t.color===`w`?t.type.toUpperCase():t.type)}a>0&&(r+=String(a)),n.push(r)}return n.join(`/`)}function d(e){let t=``;return e.wK&&(t+=`K`),e.wQ&&(t+=`Q`),e.bK&&(t+=`k`),e.bQ&&(t+=`q`),t.length>0?t:`-`}function f(e,t){let n=e.replace(/^\uFEFF/,``).trim();if(n.length===0)return t?.onError?.(i(`Input is empty`)),null;let r=n.split(` `);if(r.length!==6)return t?.onError?.(i(`Expected 6 FEN fields, got ${r.length}`)),null;let[s,u,d,f,p,m]=r,h=[0];for(let e=0;e<r.length-1;e++){let t=h[e]??0,n=r[e]??``;h.push(t+n.length+1)}let g=o(s,t?.onError);if(g===null)return null;if(u!==`w`&&u!==`b`)return t?.onError?.(i(`Invalid active color: "${u}"`,h[1])),null;let _=l(d);if(_===null)return t?.onError?.(i(`Invalid castling availability: "${d}"`,h[2])),null;if(f!==`-`&&!c.test(f))return t?.onError?.(i(`Invalid en passant square: "${f}"`,h[3])),null;let v=f===`-`?void 0:f,y=Number.parseInt(p,10);if(Number.isNaN(y)||y<0)return t?.onError?.(i(`Invalid halfmove clock: "${p}"`,h[4])),null;let b=Number.parseInt(m,10);if(Number.isNaN(b)||b<1)return t?.onError?.(i(`Invalid fullmove number: "${m}"`,h[5])),null;if(t?.onWarning){let e=0,n=0,r=0,i=0,o=0,s=0,c=!1;for(let[t,a]of g)a.color===`w`?(o+=1,a.type===`k`&&(e+=1),a.type===`p`&&(r+=1,(t.endsWith(`1`)||t.endsWith(`8`))&&(c=!0))):(s+=1,a.type===`k`&&(n+=1),a.type===`p`&&(i+=1,(t.endsWith(`1`)||t.endsWith(`8`))&&(c=!0)));e===0&&t.onWarning(a(`White king is missing`)),n===0&&t.onWarning(a(`Black king is missing`)),c&&t.onWarning(a(`Pawn on rank 1 or 8`)),r>8&&t.onWarning(a(`White has ${r} pawns (maximum is 8)`)),i>8&&t.onWarning(a(`Black has ${i} pawns (maximum is 8)`)),o>16&&t.onWarning(a(`White has ${o} pieces (maximum is 16)`)),s>16&&t.onWarning(a(`Black has ${s} pieces (maximum is 16)`))}return{board:g,castlingRights:_,enPassantSquare:v,fullmoveNumber:b,halfmoveClock:y,turn:u}}function p(e){let t=u(e.board),n=d(e.castlingRights),r=e.enPassantSquare??`-`;return[t,e.turn,n,r,String(e.halfmoveClock),String(e.fullmoveNumber)].join(` `)}export{e as STARTING_FEN,f as default,p as stringify};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import type {\n CastlingRights,\n Color,\n File,\n Piece,\n PieceType,\n Position,\n Rank,\n Square,\n} from './types.js';\n\ninterface ParseError {\n column: number;\n line: number;\n message: string;\n offset: number;\n}\n\ninterface ParseWarning {\n column: number;\n line: number;\n message: string;\n offset: number;\n}\n\ninterface ParseOptions {\n onError?: (error: ParseError) => void;\n onWarning?: (warning: ParseWarning) => void;\n}\n\nconst STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';\n\nconst FILES: File[] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];\nconst PIECE_TYPES = new Set<string>(['p', 'n', 'b', 'r', 'q', 'k']);\nconst RANKS: Rank[] = ['8', '7', '6', '5', '4', '3', '2', '1'];\n\nfunction makeError(message: string, offset = 0): ParseError {\n return { column: offset + 1, line: 1, message, offset };\n}\n\nfunction makeWarning(message: string, offset = 0): ParseWarning {\n return { column: offset + 1, line: 1, message, offset };\n}\n\nfunction parsePlacement(\n placement: string,\n onError?: (error: ParseError) => void,\n): Map<Square, Piece> | null {\n const board = new Map<Square, Piece>();\n const ranks = placement.split('/');\n\n if (ranks.length !== 8) {\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n for (const [rankIndex, rankString] of ranks.entries()) {\n const rank = RANKS[rankIndex];\n if (rank === undefined || rankString === undefined) {\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n let fileIndex = 0;\n for (const char of rankString) {\n const emptyCount = Number.parseInt(char, 10);\n if (Number.isNaN(emptyCount)) {\n const lower = char.toLowerCase();\n if (!PIECE_TYPES.has(lower)) {\n onError?.(makeError(`Invalid piece type: \"${char}\"`));\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n const color: Color = char === char.toUpperCase() ? 'w' : 'b';\n const type = lower as PieceType;\n const file = FILES[fileIndex];\n if (file === undefined) {\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n board.set(`${file}${rank}` as Square, { color, type });\n fileIndex += 1;\n } else {\n fileIndex += emptyCount;\n }\n }\n\n if (fileIndex !== 8) {\n onError?.(\n makeError(\n `Invalid FEN rank \"${rankString}\": expected 8 files, got ${fileIndex}`,\n ),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n }\n\n return board;\n}\n\nconst CASTLING_PATTERN = /^(?:-|K?Q?k?q?)$/;\nconst EN_PASSANT_PATTERN = /^[a-h][36]$/;\n\nfunction parseCastling(castling: string): CastlingRights | null {\n if (!CASTLING_PATTERN.test(castling) || castling.length === 0) {\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n return {\n bK: castling.includes('k'),\n bQ: castling.includes('q'),\n wK: castling.includes('K'),\n wQ: castling.includes('Q'),\n };\n}\n\nfunction stringifyPlacement(board: Map<Square, Piece>): string {\n const rankStrings: string[] = [];\n\n for (const rank of RANKS) {\n let rankString = '';\n let emptyCount = 0;\n\n for (const file of FILES) {\n const p = board.get(`${file}${rank}` as Square);\n if (p === undefined) {\n emptyCount += 1;\n } else {\n if (emptyCount > 0) {\n rankString += String(emptyCount);\n emptyCount = 0;\n }\n rankString += p.color === 'w' ? p.type.toUpperCase() : p.type;\n }\n }\n\n if (emptyCount > 0) {\n rankString += String(emptyCount);\n }\n rankStrings.push(rankString);\n }\n\n return rankStrings.join('/');\n}\n\nfunction stringifyCastling(rights: CastlingRights): string {\n let result = '';\n if (rights.wK) {\n result += 'K';\n }\n if (rights.wQ) {\n result += 'Q';\n }\n if (rights.bK) {\n result += 'k';\n }\n if (rights.bQ) {\n result += 'q';\n }\n return result.length > 0 ? result : '-';\n}\n\nfunction parse(input: string, options?: ParseOptions): Position | null {\n const content = input.replace(/^\\uFEFF/, '').trim();\n\n if (content.length === 0) {\n options?.onError?.(makeError('Input is empty'));\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n const parts = content.split(' ');\n if (parts.length !== 6) {\n options?.onError?.(makeError(`Expected 6 FEN fields, got ${parts.length}`));\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n const [\n placement,\n turnString,\n castlingString,\n epString,\n halfString,\n fullString,\n ] = parts as [string, string, string, string, string, string];\n\n // Compute the start offset of each field within the content string.\n // Fields are separated by single spaces.\n const fieldOffsets: number[] = [0];\n for (let index = 0; index < parts.length - 1; index++) {\n const previous = fieldOffsets[index] ?? 0;\n const field = parts[index] ?? '';\n fieldOffsets.push(previous + field.length + 1);\n }\n\n const board = parsePlacement(placement, options?.onError);\n if (board === null) {\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n if (turnString !== 'w' && turnString !== 'b') {\n options?.onError?.(\n makeError(`Invalid active color: \"${turnString}\"`, fieldOffsets[1]),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n const castlingRights = parseCastling(castlingString);\n if (castlingRights === null) {\n options?.onError?.(\n makeError(\n `Invalid castling availability: \"${castlingString}\"`,\n fieldOffsets[2],\n ),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n if (epString !== '-' && !EN_PASSANT_PATTERN.test(epString)) {\n options?.onError?.(\n makeError(`Invalid en passant square: \"${epString}\"`, fieldOffsets[3]),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n const enPassantSquare: Square | undefined =\n epString === '-' ? undefined : (epString as Square);\n\n const halfmoveClock = Number.parseInt(halfString, 10);\n if (Number.isNaN(halfmoveClock) || halfmoveClock < 0) {\n options?.onError?.(\n makeError(`Invalid halfmove clock: \"${halfString}\"`, fieldOffsets[4]),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n const fullmoveNumber = Number.parseInt(fullString, 10);\n if (Number.isNaN(fullmoveNumber) || fullmoveNumber < 1) {\n options?.onError?.(\n makeError(`Invalid fullmove number: \"${fullString}\"`, fieldOffsets[5]),\n );\n // eslint-disable-next-line unicorn/no-null\n return null;\n }\n\n // Position warnings — syntactically valid FEN but suspicious position.\n if (options?.onWarning) {\n let whiteKings = 0;\n let blackKings = 0;\n let whitePawns = 0;\n let blackPawns = 0;\n let whitePieces = 0;\n let blackPieces = 0;\n let pawnOnBackRank = false;\n\n for (const [square, piece] of board) {\n if (piece.color === 'w') {\n whitePieces += 1;\n if (piece.type === 'k') {\n whiteKings += 1;\n }\n if (piece.type === 'p') {\n whitePawns += 1;\n if (square.endsWith('1') || square.endsWith('8')) {\n pawnOnBackRank = true;\n }\n }\n } else {\n blackPieces += 1;\n if (piece.type === 'k') {\n blackKings += 1;\n }\n if (piece.type === 'p') {\n blackPawns += 1;\n if (square.endsWith('1') || square.endsWith('8')) {\n pawnOnBackRank = true;\n }\n }\n }\n }\n\n if (whiteKings === 0) {\n options.onWarning(makeWarning('White king is missing'));\n }\n if (blackKings === 0) {\n options.onWarning(makeWarning('Black king is missing'));\n }\n if (pawnOnBackRank) {\n options.onWarning(makeWarning('Pawn on rank 1 or 8'));\n }\n if (whitePawns > 8) {\n options.onWarning(\n makeWarning(`White has ${whitePawns} pawns (maximum is 8)`),\n );\n }\n if (blackPawns > 8) {\n options.onWarning(\n makeWarning(`Black has ${blackPawns} pawns (maximum is 8)`),\n );\n }\n if (whitePieces > 16) {\n options.onWarning(\n makeWarning(`White has ${whitePieces} pieces (maximum is 16)`),\n );\n }\n if (blackPieces > 16) {\n options.onWarning(\n makeWarning(`Black has ${blackPieces} pieces (maximum is 16)`),\n );\n }\n }\n\n return {\n board,\n castlingRights,\n enPassantSquare,\n fullmoveNumber,\n halfmoveClock,\n turn: turnString,\n };\n}\n\nfunction stringify(position: Position): string {\n const placement = stringifyPlacement(position.board);\n const castling = stringifyCastling(position.castlingRights);\n const enPassant = position.enPassantSquare ?? '-';\n\n return [\n placement,\n position.turn,\n castling,\n enPassant,\n String(position.halfmoveClock),\n String(position.fullmoveNumber),\n ].join(' ');\n}\n\nexport type { ParseError, ParseOptions, ParseWarning };\nexport type {\n CastlingRights,\n Color,\n File,\n Piece,\n PieceType,\n Position,\n Rank,\n Square,\n} from './types.js';\nexport { STARTING_FEN, stringify };\nexport default parse;\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Adrian de la Rosa <adrian@delarosab.me>",
|
|
3
|
+
"bugs": {
|
|
4
|
+
"url": "https://github.com/mormubis/fen/issues"
|
|
5
|
+
},
|
|
6
|
+
"description": "Parse and stringify FEN (Forsyth–Edwards Notation) chess positions. Strict TypeScript, no-throw API.",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@eslint/js": "^10.0.1",
|
|
9
|
+
"@typescript-eslint/parser": "^8.57.0",
|
|
10
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
11
|
+
"@vitest/eslint-plugin": "^1.6.12",
|
|
12
|
+
"eslint": "^10.0.3",
|
|
13
|
+
"eslint-config-prettier": "^10.1.8",
|
|
14
|
+
"eslint-import-resolver-typescript": "^4.4.4",
|
|
15
|
+
"eslint-plugin-import-x": "^4.16.2",
|
|
16
|
+
"eslint-plugin-unicorn": "^63.0.0",
|
|
17
|
+
"husky": "^9.1.7",
|
|
18
|
+
"lint-staged": "^16.4.0",
|
|
19
|
+
"prettier": "^3.8.1",
|
|
20
|
+
"tsdown": "^0.21.4",
|
|
21
|
+
"typedoc": "^0.28.17",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"typescript-eslint": "^8.57.0",
|
|
24
|
+
"vitest": "^4.1.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"import": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"/dist/",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"homepage": "https://github.com/mormubis/fen#readme",
|
|
42
|
+
"keywords": [
|
|
43
|
+
"chess",
|
|
44
|
+
"fen",
|
|
45
|
+
"forsyth-edwards",
|
|
46
|
+
"parser",
|
|
47
|
+
"position",
|
|
48
|
+
"typescript"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"name": "@echecs/fen",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/mormubis/fen.git"
|
|
55
|
+
},
|
|
56
|
+
"sideEffects": false,
|
|
57
|
+
"type": "module",
|
|
58
|
+
"version": "1.0.1",
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsdown",
|
|
61
|
+
"docs": "typedoc",
|
|
62
|
+
"format": "pnpm run format:ci --write",
|
|
63
|
+
"format:ci": "prettier -l \"**/*.+(css|js|json|jsx|md|mjs|mts|ts|tsx|yml|yaml)\"",
|
|
64
|
+
"lint": "pnpm run lint:style && pnpm run lint:types",
|
|
65
|
+
"lint:ci": "pnpm run lint:style --max-warnings 0 && pnpm run lint:types",
|
|
66
|
+
"lint:style": "eslint \"src/**/*.{ts,tsx}\" \"*.mjs\" --fix",
|
|
67
|
+
"lint:types": "tsc --noEmit --project tsconfig.json",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:coverage": "pnpm run test --coverage",
|
|
70
|
+
"test:watch": "pnpm run test --watch"
|
|
71
|
+
}
|
|
72
|
+
}
|