refactor: code quality improvements across the board

This commit is contained in:
Aric Camarata 2026-03-08 11:42:29 -04:00
parent 20dc36541b
commit cc620328a0
16 changed files with 2118 additions and 834 deletions

View file

@ -22,8 +22,22 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm run build - run: pnpm run build
- run: node test.mjs - run: node --test test.mjs
- run: node test-cjs.cjs - run: node --test test-cjs.cjs
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck: typecheck:
name: Typecheck name: Typecheck

6
.gitignore vendored
View file

@ -54,3 +54,9 @@ coverage/
.windsurf/ .windsurf/
.cody/ .cody/
.sourcegraph/ .sourcegraph/
.vscode/*
.codex/
.aider/
.aider.chat.history.md
.continue/
.gemini/

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -7,13 +7,13 @@
Converts a Gregorian `Date` to a Hijri date object. Converts a Gregorian `Date` to a Hijri date object.
```typescript ```typescript
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null function toHijri(date: Date, options?: ConversionOptions): HijriDate | null;
``` ```
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --------- | ------------------- | -------------------------------------------------------------------- |
| `date` | `Date` | Any valid JavaScript `Date`. | | `date` | `Date` | Any valid JavaScript `Date`. |
| `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. | | `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. |
@ -29,10 +29,10 @@ For FCNA: returns `null` only for dates before 1 Muharram 1 AH (pre-Islamic epoc
**Example** **Example**
```javascript ```javascript
toHijri(new Date(2023, 2, 23, 12)) // { hy: 1444, hm: 9, hd: 1 } (UAQ) toHijri(new Date(2023, 2, 23, 12)); // { hy: 1444, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12)) // { hy: 1446, hm: 9, hd: 1 } (UAQ) toHijri(new Date(2025, 2, 1, 12)); // { hy: 1446, hm: 9, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }) // { hy: 1446, hm: 9, hd: 1 } (FCNA) toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }) // null (before table range) toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }); // null (before table range)
``` ```
--- ---
@ -42,13 +42,13 @@ toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }) // null (before table range)
Converts a Hijri date to a Gregorian `Date`. Converts a Hijri date to a Gregorian `Date`.
```typescript ```typescript
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date;
``` ```
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --------- | ------------------- | -------------------------------------------------------------------- |
| `hy` | `number` | Hijri year (13181500 for UAQ; any year ≥ 1 for FCNA) | | `hy` | `number` | Hijri year (13181500 for UAQ; any year ≥ 1 for FCNA) |
| `hm` | `number` | Hijri month (112) | | `hm` | `number` | Hijri month (112) |
| `hd` | `number` | Hijri day (129 or 130 depending on the month) | | `hd` | `number` | Hijri day (129 or 130 depending on the month) |
@ -63,10 +63,10 @@ Returns a UTC Date at midnight.
**Example** **Example**
```javascript ```javascript
toGregorian(1444, 9, 1) // 2023-03-23T00:00:00.000Z toGregorian(1444, 9, 1); // 2023-03-23T00:00:00.000Z
toGregorian(1446, 9, 1, { calendar: 'fcna' }) // 2025-03-01T00:00:00.000Z toGregorian(1446, 9, 1, { calendar: 'fcna' }); // 2025-03-01T00:00:00.000Z
toGregorian(1446, 10, 1, { calendar: 'fcna' }) // 2025-03-30T00:00:00.000Z toGregorian(1446, 10, 1, { calendar: 'fcna' }); // 2025-03-30T00:00:00.000Z
toGregorian(1444, 0, 1) // throws: month 0 is invalid toGregorian(1444, 0, 1); // throws: month 0 is invalid
``` ```
--- ---
@ -76,13 +76,13 @@ toGregorian(1444, 0, 1) // throws: month 0 is invalid
Formats a Hijri date using a format string with Hijri-specific tokens. Formats a Hijri date using a format string with Hijri-specific tokens.
```typescript ```typescript
function formatHijriDate(date: HijriDate, format: string): string function formatHijriDate(date: HijriDate, format: string): string;
``` ```
**Parameters** **Parameters**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | -------- | ----------- | ---------------------------------------------------- |
| `date` | `HijriDate` | A Hijri date object with `hy`, `hm`, `hd` properties | | `date` | `HijriDate` | A Hijri date object with `hy`, `hm`, `hd` properties |
| `format` | `string` | Format string with tokens listed below | | `format` | `string` | Format string with tokens listed below |
@ -93,7 +93,7 @@ Tokens in the format string are replaced with the corresponding Hijri values. Un
**Format tokens** **Format tokens**
| Token | Description | Example | | Token | Description | Example |
| --- | --- | --- | | ---------------- | ---------------------------- | ---------------- |
| `iYYYY` | Year, 4 digits | `1444` | | `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` | | `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` | | `iMMMM` | Month, full name | `Ramadan` |
@ -126,7 +126,7 @@ Time, timezone, and weekday tokens are computed from a Gregorian DateTime derive
The weekday arrays follow the Islamic convention where Sunday is the first day: The weekday arrays follow the Islamic convention where Sunday is the first day:
| Index | Day | `iE` value | | Index | Day | `iE` value |
| --- | --- | --- | | ----- | --------- | ---------- |
| 0 | Sunday | 1 | | 0 | Sunday | 1 |
| 1 | Monday | 2 | | 1 | Monday | 2 |
| 2 | Tuesday | 3 | | 2 | Tuesday | 3 |
@ -140,9 +140,9 @@ The weekday arrays follow the Islamic convention where Sunday is the first day:
```javascript ```javascript
const d = { hy: 1444, hm: 9, hd: 1 }; const d = { hy: 1444, hm: 9, hd: 1 };
formatHijriDate(d, 'iYYYY-iMM-iDD') // "1444-09-01" formatHijriDate(d, 'iYYYY-iMM-iDD'); // "1444-09-01"
formatHijriDate(d, 'iMMMM iD, iYYYY') // "Ramadan 1, 1444" formatHijriDate(d, 'iMMMM iD, iYYYY'); // "Ramadan 1, 1444"
formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo') // "Yawm al-Khamis, 1 Ramadan 1444 AH" formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo'); // "Yawm al-Khamis, 1 Ramadan 1444 AH"
``` ```
--- ---
@ -152,7 +152,7 @@ formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo') // "Yawm al-Khamis, 1 Ram
Checks whether a Hijri date is valid for the given calendar system. Checks whether a Hijri date is valid for the given calendar system.
```typescript ```typescript
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean;
``` ```
**Returns** `boolean` **Returns** `boolean`
@ -164,12 +164,12 @@ For FCNA: `hy` must be ≥ 1, `hm` must be 112, and `hd` must not exceed the
**Example** **Example**
```javascript ```javascript
isValidHijriDate(1444, 9, 1) // true isValidHijriDate(1444, 9, 1); // true
isValidHijriDate(1444, 9, 30) // false - Ramadan 1444 has 29 days (UAQ) isValidHijriDate(1444, 9, 30); // false - Ramadan 1444 has 29 days (UAQ)
isValidHijriDate(1317, 1, 1) // false - before table range isValidHijriDate(1317, 1, 1); // false - before table range
isValidHijriDate(1501, 1, 1) // false - sentinel boundary isValidHijriDate(1501, 1, 1); // false - sentinel boundary
isValidHijriDate(1, 1, 1, { calendar: 'fcna' }) // true - FCNA supports all years isValidHijriDate(1, 1, 1, { calendar: 'fcna' }); // true - FCNA supports all years
isValidHijriDate(1600, 1, 1, { calendar: 'fcna' }) // true - beyond UAQ table, FCNA computed isValidHijriDate(1600, 1, 1, { calendar: 'fcna' }); // true - beyond UAQ table, FCNA computed
``` ```
--- ---
@ -232,7 +232,7 @@ import {
**Month name arrays** (index 0 = Muharram, index 11 = Dhul Hijjah) **Month name arrays** (index 0 = Muharram, index 11 = Dhul Hijjah)
| Index | `hmLong` | `hmMedium` | `hmShort` | | Index | `hmLong` | `hmMedium` | `hmShort` |
| --- | --- | --- | --- | | ----- | ------------- | ----------- | --------- |
| 0 | Muharram | Muharram | Muh | | 0 | Muharram | Muharram | Muh |
| 1 | Safar | Safar | Saf | | 1 | Safar | Safar | Saf |
| 2 | Rabi'l Awwal | Rabi1 | Ra1 | | 2 | Rabi'l Awwal | Rabi1 | Ra1 |
@ -249,7 +249,7 @@ import {
**Weekday arrays** (index 0 = Sunday, index 6 = Saturday) **Weekday arrays** (index 0 = Sunday, index 6 = Saturday)
| Index | `hwLong` | `hwShort` | `hwNumeric` | | Index | `hwLong` | `hwShort` | `hwNumeric` |
| --- | --- | --- | --- | | ----- | ------------------ | --------- | ----------- |
| 0 | Yawm al-Ahad | Ahad | 1 | | 0 | Yawm al-Ahad | Ahad | 1 |
| 1 | Yawm al-Ithnayn | Ithn | 2 | | 1 | Yawm al-Ithnayn | Ithn | 2 |
| 2 | Yawm ath-Thulatha' | Thul | 3 | | 2 | Yawm ath-Thulatha' | Thul | 3 |

View file

@ -13,7 +13,7 @@ The table lives in `hijri-core` and is re-exported from this package as `hDatesT
Each row stores: Each row stores:
| Field | Type | Description | | Field | Type | Description |
| --- | --- | --- | | ----- | ------ | ------------------------------------------------------------------------------------- |
| `hy` | number | Hijri year | | `hy` | number | Hijri year |
| `dpm` | number | 12-bit bitmask: bit 0 = month 1, bit 11 = month 12. 1 means 30 days, 0 means 29 days. | | `dpm` | number | 12-bit bitmask: bit 0 = month 1, bit 11 = month 12. 1 means 30 days, 0 means 29 days. |
| `gy` | number | Gregorian year of 1 Muharram | | `gy` | number | Gregorian year of 1 Muharram |
@ -99,7 +99,7 @@ The Fiqh Council of North America uses a global visibility rule: if the astronom
### New Moon Computation ### New Moon Computation
New moon times come from Jean Meeus, *Astronomical Algorithms* (2nd ed.), Chapter 49. The algorithm takes an integer k (count of new moons since a reference epoch near J2000) and returns the Julian Ephemeris Day (JDE) of the corrected new moon. The correction terms include the solar anomaly, lunar anomaly, argument of latitude, ascending node, and 14 additional planetary terms. Accuracy: within a few minutes for 10003000 CE. New moon times come from Jean Meeus, _Astronomical Algorithms_ (2nd ed.), Chapter 49. The algorithm takes an integer k (count of new moons since a reference epoch near J2000) and returns the Julian Ephemeris Day (JDE) of the corrected new moon. The correction terms include the solar anomaly, lunar anomaly, argument of latitude, ascending node, and 14 additional planetary terms. Accuracy: within a few minutes for 10003000 CE.
### Anchor Strategy ### Anchor Strategy

View file

@ -16,7 +16,7 @@ Different countries and communities follow different approaches.
## Hijri Months ## Hijri Months
| No. | Arabic Name | Common Transliteration | | No. | Arabic Name | Common Transliteration |
| --- | --- | --- | | --- | ------------ | ---------------------- |
| 1 | محرم | Muharram | | 1 | محرم | Muharram |
| 2 | صفر | Safar | | 2 | صفر | Safar |
| 3 | ربيع الأول | Rabi' al-Awwal | | 3 | ربيع الأول | Rabi' al-Awwal |
@ -37,7 +37,7 @@ Months alternate between 29 and 30 days. Dhul Hijjah has 29 days in a normal yea
The Islamic week begins on Sunday. Friday (Yawm al-Jum'a) is the day of congregational prayer. The Islamic week begins on Sunday. Friday (Yawm al-Jum'a) is the day of congregational prayer.
| No. | Arabic Name | Transliteration | | No. | Arabic Name | Transliteration |
| --- | --- | --- | | --- | ----------- | ---------------------------- |
| 1 | الأحد | Yawm al-Ahad (Sunday) | | 1 | الأحد | Yawm al-Ahad (Sunday) |
| 2 | الاثنين | Yawm al-Ithnayn (Monday) | | 2 | الاثنين | Yawm al-Ithnayn (Monday) |
| 3 | الثلاثاء | Yawm ath-Thulatha' (Tuesday) | | 3 | الثلاثاء | Yawm ath-Thulatha' (Tuesday) |
@ -85,7 +85,7 @@ A Hijri year has either 354 days (12 months × 29.5 days average) or 355 days. T
## Epoch and Date Range ## Epoch and Date Range
| | Hijri | Gregorian | | | Hijri | Gregorian |
| --- | --- | --- | | ----------------- | ------------------------------ | ----------------- |
| Table start | 1 Muharram 1318 H | April 30, 1900 | | Table start | 1 Muharram 1318 H | April 30, 1900 |
| Table end | Last day of Dhul Hijjah 1500 H | ~November 2076 | | Table end | Last day of Dhul Hijjah 1500 H | ~November 2076 |
| Sentinel boundary | 1 Muharram 1501 H | November 17, 2077 | | Sentinel boundary | 1 Muharram 1501 H | November 17, 2077 |

View file

@ -41,7 +41,7 @@ toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T0
Converts a Gregorian `Date` to a Hijri date object. Converts a Gregorian `Date` to a Hijri date object.
```typescript ```typescript
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null function toHijri(date: Date, options?: ConversionOptions): HijriDate | null;
``` ```
For `'uaq'` (default): returns `null` if the date falls outside the table range (before 1 Muharram 1318 H / 1900-04-30, or at/after 1 Muharram 1501 H / 2077-11-17). Uses local date components. For `'uaq'` (default): returns `null` if the date falls outside the table range (before 1 Muharram 1318 H / 1900-04-30, or at/after 1 Muharram 1501 H / 2077-11-17). Uses local date components.
@ -51,9 +51,9 @@ For `'fcna'`: returns `null` only for dates before 1 AH. Uses UTC date component
Throws `Error("Invalid Gregorian date")` if `date` is not a valid `Date`. Throws `Error("Invalid Gregorian date")` if `date` is not a valid `Date`.
```javascript ```javascript
toHijri(new Date(2024, 6, 7, 12)) // { hy: 1446, hm: 1, hd: 1 } (UAQ) toHijri(new Date(2024, 6, 7, 12)); // { hy: 1446, hm: 1, hd: 1 } (UAQ)
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }) // { hy: 1446, hm: 9, hd: 1 } (FCNA) toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 } (FCNA)
toHijri(new Date(1800, 0, 1)) // null - before UAQ table range toHijri(new Date(1800, 0, 1)); // null - before UAQ table range
``` ```
### `toGregorian(hy, hm, hd, options?)` ### `toGregorian(hy, hm, hd, options?)`
@ -61,15 +61,15 @@ toHijri(new Date(1800, 0, 1)) // null - before UAQ
Converts a Hijri date to a Gregorian `Date` at UTC midnight. Converts a Hijri date to a Gregorian `Date` at UTC midnight.
```typescript ```typescript
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date;
``` ```
Throws `Error("Invalid Hijri date")` if the date is invalid for the selected calendar. Throws `Error("Invalid Hijri date")` if the date is invalid for the selected calendar.
```javascript ```javascript
toGregorian(1446, 1, 1) // Date: 2024-07-07T00:00:00.000Z (UAQ) toGregorian(1446, 1, 1); // Date: 2024-07-07T00:00:00.000Z (UAQ)
toGregorian(1446, 9, 1, { calendar: 'fcna' }) // Date: 2025-03-01T00:00:00.000Z (FCNA) toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T00:00:00.000Z (FCNA)
toGregorian(1, 1, 1, { calendar: 'fcna' }) // Date: 0622-07-18T00:00:00.000Z (Islamic epoch) toGregorian(1, 1, 1, { calendar: 'fcna' }); // Date: 0622-07-18T00:00:00.000Z (Islamic epoch)
``` ```
### `formatHijriDate(date, format)` ### `formatHijriDate(date, format)`
@ -77,11 +77,11 @@ toGregorian(1, 1, 1, { calendar: 'fcna' }) // Date: 0622-07-18T00:00:00.0
Formats a Hijri date using the token patterns below. Tokens not listed pass through unchanged. Formats a Hijri date using the token patterns below. Tokens not listed pass through unchanged.
```typescript ```typescript
function formatHijriDate(date: HijriDate, format: string): string function formatHijriDate(date: HijriDate, format: string): string;
``` ```
| Token | Output | Example | | Token | Output | Example |
| --- | --- | --- | | -------------------- | ------------------------ | --------------------- |
| `iYYYY` | Year, 4 digits | `1444` | | `iYYYY` | Year, 4 digits | `1444` |
| `iYY` | Year, last 2 digits | `44` | | `iYY` | Year, last 2 digits | `44` |
| `iMMMM` | Month, full name | `Ramadan` | | `iMMMM` | Month, full name | `Ramadan` |
@ -107,7 +107,7 @@ function formatHijriDate(date: HijriDate, format: string): string
Returns `true` if the Hijri date is valid for the selected calendar. Returns `true` if the Hijri date is valid for the selected calendar.
```typescript ```typescript
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean;
``` ```
For `'uaq'` (default): year must be 13181500, month 112, day must not exceed the actual month length from the UAQ table. For `'uaq'` (default): year must be 13181500, month 112, day must not exceed the actual month length from the UAQ table.

10
eslint.config.mjs Normal file
View file

@ -0,0 +1,10 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist/', 'node_modules/', '*.cjs', '*.mjs'] },
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
);

View file

@ -37,8 +37,11 @@
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"pretest": "tsup", "pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs", "test": "node --test test.mjs && node --test test-cjs.cjs",
"prepublishOnly": "tsup" "prepublishOnly": "tsup",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"keywords": [ "keywords": [
"hijri", "hijri",
@ -57,10 +60,15 @@
"luxon": "^3.5.0" "luxon": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.0.0", "@types/node": "^22.15.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.0.0", "tsup": "^8.0.0",
"typescript": "^5.5.0" "typescript": "^5.5.0",
"typescript-eslint": "^8.56.1"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
// formatHijriDate.ts // formatHijriDate.ts
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { hmLong, hmMedium, hmShort } from './hMonths'; import { hmLong, hmMedium } from './hMonths';
import { hwLong, hwShort, hwNumeric } from './hWeekdays'; import { hwLong, hwShort, hwNumeric } from './hWeekdays';
import { toGregorian } from './toGregorian'; import { toGregorian } from './toGregorian';
import type { HijriDate } from './types'; import type { HijriDate } from './types';
@ -9,7 +9,23 @@ import type { HijriDate } from './types';
const TOKEN_RE = const TOKEN_RE =
/iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo|HH|H|hh|h|mm|m|ss|s|a|z{1,3}|ZZ|Z/g; /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo|HH|H|hh|h|mm|m|ss|s|a|z{1,3}|ZZ|Z/g;
/**
* Format a Hijri date using a token-based format string.
*
* Hijri-specific tokens use the `i` prefix (iYYYY, iMM, iDD, iEEEE, etc.).
* Time and timezone tokens (HH, mm, ss, a, Z, z) are delegated to Luxon via the
* corresponding Gregorian date.
*
* @param hijriDate - the Hijri date to format
* @param format - a format string containing Hijri and/or Luxon tokens
* @returns the formatted date string
* @throws {RangeError} if the Hijri month is outside the 1-12 range
*/
export function formatHijriDate(hijriDate: HijriDate, format: string): string { export function formatHijriDate(hijriDate: HijriDate, format: string): string {
if (hijriDate.hm < 1 || hijriDate.hm > 12) {
throw new RangeError(`Hijri month must be 1-12, got ${hijriDate.hm}`);
}
// Lazy Gregorian DateTime, computed at most once per format call, // Lazy Gregorian DateTime, computed at most once per format call,
// only when a token that needs it is encountered. // only when a token that needs it is encountered.
let _gregDt: DateTime | undefined; let _gregDt: DateTime | undefined;

View file

@ -45,6 +45,3 @@ export const formatPatterns = {
Z: 'Timezone offset from UTC (+HH:MM)', Z: 'Timezone offset from UTC (+HH:MM)',
ZZ: 'Timezone offset from UTC (condensed)', ZZ: 'Timezone offset from UTC (condensed)',
}; };
// Export the patterns for use in the rest of the package
export default formatPatterns;

View file

@ -2,6 +2,19 @@
import { toGregorian as coreToGregorian } from 'hijri-core'; import { toGregorian as coreToGregorian } from 'hijri-core';
import type { ConversionOptions } from './types'; import type { ConversionOptions } from './types';
/**
* Convert a Hijri date to a Gregorian Date object.
*
* Unlike the hijri-core function (which returns null for invalid input), this
* wrapper throws an Error so callers always receive a valid Date.
*
* @param hy - Hijri year
* @param hm - Hijri month (1-12)
* @param hd - Hijri day (1-30)
* @param options - conversion options (calendar engine selection)
* @returns a UTC Date corresponding to the given Hijri date
* @throws {Error} if the Hijri date is invalid or out of range
*/
export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date { export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date {
const result = coreToGregorian(hy, hm, hd, options); const result = coreToGregorian(hy, hm, hd, options);
if (result === null) throw new Error('Invalid Hijri date'); if (result === null) throw new Error('Invalid Hijri date');

View file

@ -1,5 +1,6 @@
// test-cjs.cjs — CJS test suite for luxon-hijri // test-cjs.cjs — CJS test suite for luxon-hijri
'use strict'; 'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
@ -13,122 +14,110 @@ const {
hwNumeric, hwNumeric,
} = require('./dist/index.cjs'); } = require('./dist/index.cjs');
let passed = 0; const FCNA = { calendar: 'fcna' };
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (err) {
console.error(` ${name}... FAIL`);
console.error(` ${err.message}`);
failed++;
}
}
// ─── Exports ──────────────────────────────────────────────────────────────── // ─── Exports ────────────────────────────────────────────────────────────────
console.log('\nCJS exports'); describe('CJS exports', () => {
it('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
it('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
it('isValidHijriDate is a function', () =>
assert.strictEqual(typeof isValidHijriDate, 'function'));
it('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
it('hDatesTable has 184 entries', () => assert.strictEqual(hDatesTable.length, 184));
it('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
it('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
it('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
});
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function')); // ─── Core conversions ──────────────────────────────────────────────────────
test('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
test('isValidHijriDate is a function', () => assert.strictEqual(typeof isValidHijriDate, 'function'));
test('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
test('hDatesTable has 184 entries (183 real + 1 sentinel)', () => assert.strictEqual(hDatesTable.length, 184));
test('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
test('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
test('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
// ─── Core conversions ──────────────────────────────────────────────────────── describe('CJS core conversions', () => {
it('toGregorian(1444, 1, 1) = 2022-07-30', () => {
console.log('\nCJS core conversions');
test('toGregorian(1444, 1, 1) = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1); const d = toGregorian(1444, 1, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30'); assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
}); });
it('toGregorian(1444, 9, 1) = 2023-03-23', () => {
test('toGregorian(1444, 9, 1) = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1); const d = toGregorian(1444, 9, 1);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23'); assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
}); });
it('toHijri(2022-07-30) = 1 Muharram 1444', () => {
test('toHijri(2022-07-30) = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12)); const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
}); });
it('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
test('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
}); });
// ─── Validation ───────────────────────────────────────────────────────────── // ─── Validation ─────────────────────────────────────────────────────────────
console.log('\nCJS validation'); describe('CJS validation', () => {
it('isValidHijriDate(1444, 9, 1) = true', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
it('isValidHijriDate(1444, 0, 1) = false', () =>
assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
it('isValidHijriDate(1317, 1, 1) = false (out of range)', () =>
assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
});
test('isValidHijriDate(1444, 9, 1) = true', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true)); // ─── Formatting ─────────────────────────────────────────────────────────────
test('isValidHijriDate(1444, 0, 1) = false', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
test('isValidHijriDate(1317, 1, 1) = false (out of range)', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
// ─── Formatting ──────────────────────────────────────────────────────────────
console.log('\nCJS formatting');
const ramadan1 = { hy: 1444, hm: 9, hd: 1 }; const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
test('iYYYY-iMM-iDD = 1444-09-01', () => { describe('CJS formatting', () => {
it('iYYYY-iMM-iDD = 1444-09-01', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01'); assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
}); });
it('iMMMM = Ramadan', () => {
test('iMMMM = Ramadan', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan'); assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
}); });
it('iEEEE = Yawm al-Khamis (Thursday)', () => {
test('iEEEE = Yawm al-Khamis (Thursday)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis'); assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
}); });
it('iooo = AH', () => {
test('iooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH'); assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
}); });
// ─── FCNA calendar ─────────────────────────────────────────────────────────── describe('CJS formatHijriDate - invalid month', () => {
it('throws for month 0', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 0, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
it('throws for month 13', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 13, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
});
console.log('\nCJS FCNA calendar'); // ─── FCNA calendar ──────────────────────────────────────────────────────────
const FCNA = { calendar: 'fcna' }; describe('CJS FCNA calendar', () => {
it('1 Ramadan 1446 = 2025-03-01', () => {
test('FCNA: 1 Ramadan 1446 = 2025-03-01', () => {
const d = toGregorian(1446, 9, 1, FCNA); const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
}); });
it('2025-03-01 = 1 Ramadan 1446', () => {
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA); const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
}); });
it('isValidHijriDate(1446, 9, 1) = true', () => {
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true); assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
}); });
it('isValidHijriDate(1, 1, 1) = true (year 1 AH)', () => {
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH)', () => {
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true); assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
}); });
it('round-trip 1446/9/1', () => {
test('FCNA: round-trip 1446/9/1', () => {
const greg = toGregorian(1446, 9, 1, FCNA); const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA); const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
}); });
// ─── Summary ────────────────────────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${total} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

414
test.mjs
View file

@ -1,4 +1,5 @@
// test.mjs — ESM test suite for luxon-hijri // test.mjs — ESM test suite for luxon-hijri
import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
@ -18,382 +19,321 @@ import {
const FCNA = { calendar: 'fcna' }; const FCNA = { calendar: 'fcna' };
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (err) {
console.error(` ${name}... FAIL`);
console.error(` ${err.message}`);
failed++;
}
}
// ─── Exports ──────────────────────────────────────────────────────────────── // ─── Exports ────────────────────────────────────────────────────────────────
console.log('\nExports'); describe('exports', () => {
it('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function')); it('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
test('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function')); it('isValidHijriDate is a function', () =>
test('isValidHijriDate is a function', () => assert.strictEqual(typeof isValidHijriDate, 'function')); assert.strictEqual(typeof isValidHijriDate, 'function'));
test('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function')); it('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
test('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object')); it('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
test('hDatesTable is an array', () => assert(Array.isArray(hDatesTable))); it('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
// 183 real year entries (13181500) + 1 sentinel entry (1501) marking the table boundary. it('hDatesTable has 184 entries (1318-1500 + sentinel 1501)', () =>
test('hDatesTable has 184 entries (13181500 + sentinel 1501)', () => assert.strictEqual(hDatesTable.length, 184)); assert.strictEqual(hDatesTable.length, 184));
test('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12)); it('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
test('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12)); it('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
test('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12)); it('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
test('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7)); it('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
test('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7)); it('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
test('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7)); it('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
});
// ─── toGregorian ──────────────────────────────────────────────────────────── // ─── toGregorian ────────────────────────────────────────────────────────────
console.log('\ntoGregorian — known dates'); describe('toGregorian - known dates', () => {
it('1 Muharram 1444 = 2022-07-30', () => {
test('1 Muharram 1444 = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1); const d = toGregorian(1444, 1, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30'); assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
}); });
test('1 Ramadan 1444 = 2023-03-23', () => { it('1 Ramadan 1444 = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1); const d = toGregorian(1444, 9, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23'); assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
}); });
test('1 Shawwal 1444 = 2023-04-21', () => { it('1 Shawwal 1444 = 2023-04-21', () => {
const d = toGregorian(1444, 10, 1); const d = toGregorian(1444, 10, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21'); assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21');
}); });
test('1 Muharram 1446 = 2024-07-07', () => { it('1 Muharram 1446 = 2024-07-07', () => {
const d = toGregorian(1446, 1, 1); const d = toGregorian(1446, 1, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07'); assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07');
}); });
test('first table entry: 1 Muharram 1318 = 1900-04-30', () => { it('first table entry: 1 Muharram 1318 = 1900-04-30', () => {
const d = toGregorian(1318, 1, 1); const d = toGregorian(1318, 1, 1);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30'); assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30');
});
}); });
console.log('\ntoGregorian — error cases'); describe('toGregorian - error cases', () => {
it('throws on invalid Hijri year (out of table range)', () => {
test('throws on invalid Hijri year (out of table range)', () => {
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/); assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
}); });
it('throws on month 0', () => {
test('throws on month 0', () => {
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/); assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
}); });
it('throws on month 13', () => {
test('throws on month 13', () => {
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/); assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
}); });
it('throws on day 0', () => {
test('throws on day 0', () => {
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/); assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
}); });
it('throws on day 30 in 29-day month (Ramadan 1444)', () => {
test('throws on day 30 in 29-day month (Ramadan 1444)', () => {
// Ramadan 1444 has 29 days (1 Ramadan = Mar 23, 1 Shawwal = Apr 21 → 29 days)
assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/); assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/);
});
}); });
// ─── toHijri ──────────────────────────────────────────────────────────────── // ─── toHijri ────────────────────────────────────────────────────────────────
console.log('\ntoHijri — known dates'); describe('toHijri - known dates', () => {
it('2022-07-30 = 1 Muharram 1444', () => {
// Use noon (hour=12) to avoid date-boundary issues across timezones.
test('2022-07-30 = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12)); const h = toHijri(new Date(2022, 6, 30, 12));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
}); });
it('2023-03-23 = 1 Ramadan 1444', () => {
test('2023-03-23 = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
}); });
it('2023-04-21 = 1 Shawwal 1444', () => {
test('2023-04-21 = 1 Shawwal 1444', () => {
const h = toHijri(new Date(2023, 3, 21, 12)); const h = toHijri(new Date(2023, 3, 21, 12));
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
}); });
it('2024-07-07 = 1 Muharram 1446', () => {
test('2024-07-07 = 1 Muharram 1446', () => {
const h = toHijri(new Date(2024, 6, 7, 12)); const h = toHijri(new Date(2024, 6, 7, 12));
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
}); });
it('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
test('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
const h = toHijri(new Date(1900, 3, 30, 12)); const h = toHijri(new Date(1900, 3, 30, 12));
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
});
}); });
console.log('\ntoHijri — error cases'); describe('toHijri - error cases', () => {
it('throws on invalid Date', () => {
test('throws on invalid Date', () => {
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/); assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
}); });
it('returns null for date before first table entry', () => {
test('returns null for date before first table entry', () => {
const h = toHijri(new Date(1800, 0, 1, 12)); const h = toHijri(new Date(1800, 0, 1, 12));
assert.strictEqual(h, null); assert.strictEqual(h, null);
});
}); });
// ─── isValidHijriDate ─────────────────────────────────────────────────────── // ─── isValidHijriDate ───────────────────────────────────────────────────────
console.log('\nisValidHijriDate'); describe('isValidHijriDate', () => {
it('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
test('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true)); it('1444-09-29 is valid (last day of Ramadan 1444)', () =>
test('1444-09-29 is valid (last day of Ramadan 1444)', () => assert.strictEqual(isValidHijriDate(1444, 9, 29), true)); assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
test('1318-01-01 is valid (first table entry)', () => assert.strictEqual(isValidHijriDate(1318, 1, 1), true)); it('1318-01-01 is valid (first table entry)', () =>
test('1500-12-29 is valid (last table entry, last day)', () => assert.strictEqual(isValidHijriDate(1500, 12, 29), true)); assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
test('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false)); it('1500-12-29 is valid (last table entry)', () =>
test('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false)); assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
test('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false)); it('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
test('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false)); it('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
test('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false)); it('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
test('day 30 in Ramadan 1444 (29-day month) is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 30), false)); it('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
it('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
it('day 30 in Ramadan 1444 (29-day month) is invalid', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
});
// ─── formatHijriDate ──────────────────────────────────────────────────────── // ─── formatHijriDate ────────────────────────────────────────────────────────
console.log('\nformatHijriDate — date tokens');
const ramadan1 = { hy: 1444, hm: 9, hd: 1 }; const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
test('iYYYY-iMM-iDD', () => { describe('formatHijriDate - date tokens', () => {
it('iYYYY-iMM-iDD', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01'); assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
}); });
it('iYY (last 2 digits of year)', () => {
test('iYY (last 2 digits of year)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44'); assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
}); });
it('iM (month without padding)', () => {
test('iM (month without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9'); assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
}); });
it('iMM (month zero-padded)', () => {
test('iMM (month zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09'); assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
}); });
it('iMMM (medium month name: Ramadan)', () => {
test('iMMM (medium month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan'); assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
}); });
it('iMMMM (full month name: Ramadan)', () => {
test('iMMMM (full month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan'); assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
}); });
it('iD (day without padding)', () => {
test('iD (day without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1'); assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
}); });
it('iDD (day zero-padded)', () => {
test('iDD (day zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01'); assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
});
}); });
console.log('\nformatHijriDate — weekday tokens (1 Ramadan 1444 = Thursday)'); describe('formatHijriDate - weekday tokens (1 Ramadan 1444 = Thursday)', () => {
it('iE = 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
test('iE → 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5'); assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
}); });
it('iEEE = Kham (Thursday abbreviated)', () => {
test('iEEE → Kham (Thursday abbreviated)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham'); assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
}); });
it('iEEEE = Yawm al-Khamis (Thursday full)', () => {
test('iEEEE → Yawm al-Khamis (Thursday full)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis'); assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
});
}); });
console.log('\nformatHijriDate — era tokens'); describe('formatHijriDate - era tokens', () => {
it('iooo = AH', () => {
test('iooo → AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH'); assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
}); });
it('ioooo = AH', () => {
test('ioooo → AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH'); assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
});
}); });
console.log('\nformatHijriDate — composite format'); describe('formatHijriDate - composite format', () => {
it('iMMMM iD, iYYYY', () => {
test('iMMMM iD, iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444'); assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
}); });
it('iDD/iMM/iYYYY', () => {
test('iDD/iMM/iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444'); assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
}); });
it('iEEEE, iD iMMMM iYYYY ioooo', () => {
test('iEEEE, iD iMMMM iYYYY ioooo', () => {
assert.strictEqual( assert.strictEqual(
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'), formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
'Yawm al-Khamis, 1 Ramadan 1444 AH', 'Yawm al-Khamis, 1 Ramadan 1444 AH',
); );
});
});
describe('formatHijriDate - invalid month', () => {
it('throws for month 0', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 0, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
it('throws for month 13', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 13, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
}); });
// ─── hDatesTable structure ────────────────────────────────────────────────── // ─── hDatesTable structure ──────────────────────────────────────────────────
console.log('\nhDatesTable structure'); describe('hDatesTable structure', () => {
it('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
test('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318)); it('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
test('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500)); it('index 183 is sentinel year 1501 with dpm=0', () => {
test('index 183 is sentinel year 1501 with dpm=0', () => {
assert.strictEqual(hDatesTable[183].hy, 1501); assert.strictEqual(hDatesTable[183].hy, 1501);
assert.strictEqual(hDatesTable[183].dpm, 0); assert.strictEqual(hDatesTable[183].dpm, 0);
}); });
test('table is sorted ascending by hy', () => { it('table is sorted ascending by hy', () => {
for (let i = 1; i < hDatesTable.length; i++) { for (let i = 1; i < hDatesTable.length; i++) {
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy); assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
} }
});
}); });
// ─── FCNA calendar — toGregorian ──────────────────────────────────────────── // ─── FCNA calendar ──────────────────────────────────────────────────────────
//
// FCNA/ISNA criterion: conjunction before 12:00 UTC → month starts D+1; else D+2.
// New moon for 1 Ramadan 1446: Feb 28, 2025 ~00:45 UTC → before noon → March 1.
// New moon for 1 Shawwal 1446: March 29, 2025 ~10:57 UTC → before noon → March 30.
// Both match ISNA's publicly published 2025 Ramadan/Eid calendar.
console.log('\nFCNA — toGregorian known dates'); describe('FCNA toGregorian', () => {
it('1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
test('FCNA: 1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
const d = toGregorian(1446, 9, 1, FCNA); const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
}); });
it('1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
test('FCNA: 1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
const d = toGregorian(1446, 10, 1, FCNA); const d = toGregorian(1446, 10, 1, FCNA);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30');
}); });
it('1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
test('FCNA: 1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
// New moon: March 10, 2024 ~09:00 UTC → before noon → D+1 = March 11.
const d = toGregorian(1445, 9, 1, FCNA); const d = toGregorian(1445, 9, 1, FCNA);
assert(d instanceof Date); assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11'); assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
}); });
it('1 Muharram 1444 = ~2022-07-30', () => {
test('FCNA: 1 Muharram 1444 = 2022-07-30', () => {
// New moon near July 28-29, 2022 → FCNA starts July 30 (same as UAQ for this month).
const d = toGregorian(1444, 1, 1, FCNA); const d = toGregorian(1444, 1, 1, FCNA);
assert(d instanceof Date); assert(d instanceof Date);
// Allow ±1 day: FCNA and UAQ can differ by 1 day on month boundaries.
const iso = d.toISOString().slice(0, 10); const iso = d.toISOString().slice(0, 10);
assert(iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31', assert(
`Expected ~2022-07-30, got ${iso}`); iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31',
`Expected ~2022-07-30, got ${iso}`,
);
});
}); });
console.log('\nFCNA — toHijri known dates'); describe('FCNA toHijri', () => {
it('2025-03-01 = 1 Ramadan 1446', () => {
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA); const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
}); });
it('2025-03-30 = 1 Shawwal 1446', () => {
test('FCNA: 2025-03-30 = 1 Shawwal 1446', () => {
const h = toHijri(new Date(2025, 2, 30, 12), FCNA); const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
}); });
it('2024-03-11 = 1 Ramadan 1445', () => {
test('FCNA: 2024-03-11 = 1 Ramadan 1445', () => {
const h = toHijri(new Date(2024, 2, 11, 12), FCNA); const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
});
}); });
console.log('\nFCNA — round-trip consistency'); describe('FCNA round-trips', () => {
it('1446/9/1 toGregorian then toHijri', () => {
test('FCNA round-trip: toGregorian → toHijri for 1446/9/1', () => {
const greg = toGregorian(1446, 9, 1, FCNA); const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA); const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
}); });
it('1446/10/15 toGregorian then toHijri', () => {
test('FCNA round-trip: toGregorian → toHijri for 1446/10/15', () => {
const greg = toGregorian(1446, 10, 15, FCNA); const greg = toGregorian(1446, 10, 15, FCNA);
const hijri = toHijri(greg, FCNA); const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 }); assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
}); });
it('1318/1/1 toGregorian then toHijri', () => {
test('FCNA round-trip: toGregorian → toHijri for 1318/1/1', () => {
const greg = toGregorian(1318, 1, 1, FCNA); const greg = toGregorian(1318, 1, 1, FCNA);
assert(greg instanceof Date); assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA); const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 }); assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
}); });
it('out-of-range year 1200/6/1 round-trip', () => {
test('FCNA round-trip: toGregorian → toHijri for out-of-range year 1200/6/1', () => {
// Out of UAQ table range — uses mean k estimate + Meeus correction.
const greg = toGregorian(1200, 6, 1, FCNA); const greg = toGregorian(1200, 6, 1, FCNA);
assert(greg instanceof Date); assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA); const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 }); assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 });
});
}); });
console.log('\nFCNA — isValidHijriDate'); describe('FCNA isValidHijriDate', () => {
it('1446/9/1 = true', () => assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true));
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => { it('month 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false));
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true); it('month 13 = false', () => assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false));
it('day 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false));
it('day 31 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false));
it('year 1 AH = true', () => assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true));
it('year 1600 = true (beyond UAQ table)', () =>
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true));
}); });
test('FCNA: isValidHijriDate(1446, 0, 1) = false (month 0)', () => { describe('UAQ default regression', () => {
assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false); it('1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
}); const d = toGregorian(1446, 9, 1);
test('FCNA: isValidHijriDate(1446, 13, 1) = false (month 13)', () => {
assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false);
});
test('FCNA: isValidHijriDate(1446, 9, 0) = false (day 0)', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false);
});
test('FCNA: isValidHijriDate(1446, 9, 31) = false (day 31 always invalid)', () => {
assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false);
});
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH supported)', () => {
// FCNA works for any year ≥ 1 AH, not limited to 13181500.
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
});
test('FCNA: isValidHijriDate(1600, 1, 1) = true (beyond UAQ table)', () => {
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true);
});
console.log('\nFCNA — UAQ default unchanged (regression)');
test('UAQ default: 1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
const d = toGregorian(1446, 9, 1); // no options → UAQ
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
}); });
it('toHijri still works without options', () => {
test('UAQ default: toHijri still works without options', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(2023, 2, 23, 12));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
}); });
it('isValidHijriDate still works without options', () => {
test('UAQ default: isValidHijriDate still works without options', () => {
assert.strictEqual(isValidHijriDate(1444, 9, 1), true); assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
assert.strictEqual(isValidHijriDate(1501, 1, 1), false); assert.strictEqual(isValidHijriDate(1501, 1, 1), false);
});
}); });
// ─── Summary ────────────────────────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${total} tests total: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);

View file

@ -8,10 +8,13 @@
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"] "include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
} }