mirror of
https://github.com/acamarata/luxon-hijri.git
synced 2026-06-30 18:54:28 +00:00
feat: v2.0.0 — FCNA calendar, dual ESM/CJS build, weekday bug fix, full test suite
Core fixes:
- Fix critical weekday bug: iE/iEEE/iEEEE tokens used Hijri year as Gregorian,
returning weekdays ~580 years wrong. Now converts via toGregorian() first.
- Fix era tokens iooo/ioooo: were returning Gregorian era, now always return "AH".
- Fix toGregorian timezone sensitivity: was using DateTime.local(), now DateTime.utc().
- Fix format token regex: word-boundary approach caused partial matches.
New: FCNA/ISNA calendar support:
- toHijri, toGregorian, isValidHijriDate now accept { calendar: 'fcna' } option.
- FCNA criterion: conjunction before 12:00 UTC → month starts D+1, else D+2.
- New moon times from Meeus Ch.49 full formula (accurate to within minutes, 1000–3000 CE).
- Works for all Hijri years, not just the 1318–1500 UAQ table range.
- Anchor: UAQ table for in-range years, Islamic epoch estimate for out-of-range.
- Exports: CalendarSystem, ConversionOptions types.
Build and infrastructure:
- pnpm replaces npm; tsup replaces tsc for dual CJS/ESM output.
- Exports map with types-first conditional exports for import/require.
- Binary search O(log 183) replaces linear O(n) scan in all three functions.
- Luxon upgraded from ^2.5.2 to ^3.5.0; TypeScript from ^4 to ^5.5.
- CI: Node 20/22/24 matrix, typecheck, and pack-check jobs.
- GitHub Wiki: four pages synced via Actions on push.
- Test suite: 81 ESM tests + 24 CJS tests, verified against ISNA 2023–2025 calendars.
- Exports hwLong, hwShort, hwNumeric weekday arrays.
Breaking changes:
- Dual ESM/CJS exports map (CJS consumers: no change via main field).
- HijriYearRecord replaces hDates interface name.
- Luxon peer dep bumped to ^3.5.0.
- Node >=20 required.
This commit is contained in:
parent
ba66326b98
commit
1ab6463184
30 changed files with 3003 additions and 4246 deletions
14
.editorconfig
Normal file
14
.editorconfig
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{ts,js,mjs,cjs,json,yaml,yml,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
68
.github/workflows/ci.yml
vendored
Normal file
68
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [20, 22, 24]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm run build
|
||||
- run: node test.mjs
|
||||
- run: node test-cjs.cjs
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm run typecheck
|
||||
|
||||
pack-check:
|
||||
name: Pack check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm run build
|
||||
- name: Verify pack contents
|
||||
run: |
|
||||
npm pack --dry-run 2>&1 | tee pack-output.txt
|
||||
grep "dist/index.cjs" pack-output.txt
|
||||
grep "dist/index.mjs" pack-output.txt
|
||||
grep "dist/index.d.ts" pack-output.txt
|
||||
grep "dist/index.d.mts" pack-output.txt
|
||||
grep "README.md" pack-output.txt
|
||||
grep "CHANGELOG.md" pack-output.txt
|
||||
grep "LICENSE" pack-output.txt
|
||||
37
.github/workflows/wiki-sync.yml
vendored
Normal file
37
.github/workflows/wiki-sync.yml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: Wiki Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.wiki/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync .wiki/ to GitHub Wiki
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Push wiki pages
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}.wiki
|
||||
path: wiki-repo
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Copy wiki files
|
||||
run: |
|
||||
cp .wiki/*.md wiki-repo/
|
||||
|
||||
- name: Commit and push
|
||||
working-directory: wiki-repo
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "Sync wiki from .wiki/ [skip ci]"
|
||||
git push
|
||||
60
.gitignore
vendored
60
.gitignore
vendored
|
|
@ -1,32 +1,56 @@
|
|||
# Dependency directories
|
||||
# ─── Dependencies ───
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Build output of TypeScript
|
||||
# ─── Build ───
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
# Environment files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# ─── Environment ───
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE specific files
|
||||
# ─── OS ───
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
._*
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# ─── IDE / Editor ───
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
# ─── Logs ───
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# ─── Testing / Coverage ───
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# ─── AI Agents ───
|
||||
.claude/
|
||||
.cursor/
|
||||
.copilot/
|
||||
.github/copilot/
|
||||
.aider*
|
||||
.codeium/
|
||||
.tabnine/
|
||||
.windsurf/
|
||||
.cody/
|
||||
.sourcegraph/
|
||||
|
|
|
|||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
package-import-method=hardlink
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24
|
||||
263
.wiki/API-Reference.md
Normal file
263
.wiki/API-Reference.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# API Reference
|
||||
|
||||
## Functions
|
||||
|
||||
### `toHijri(date, options?)`
|
||||
|
||||
Converts a Gregorian `Date` to a Hijri date object.
|
||||
|
||||
```typescript
|
||||
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `date` | `Date` | Any valid JavaScript `Date`. |
|
||||
| `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. |
|
||||
|
||||
For the default `'uaq'` calendar, the local year/month/day components of the Date are used. For `'fcna'`, UTC components are used (since FCNA month boundaries are defined in UTC).
|
||||
|
||||
**Returns** `HijriDate | null`
|
||||
|
||||
For UAQ: 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).
|
||||
For FCNA: returns `null` only for dates before 1 Muharram 1 AH (pre-Islamic epoch).
|
||||
|
||||
**Throws** `Error("Invalid Gregorian date")` if the argument is not a valid `Date` instance.
|
||||
|
||||
**Example**
|
||||
|
||||
```javascript
|
||||
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), { calendar: 'fcna' }) // { hy: 1446, hm: 9, hd: 1 } (FCNA)
|
||||
toHijri(new Date(1800, 0, 1), { calendar: 'uaq' }) // null (before table range)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `toGregorian(hy, hm, hd, options?)`
|
||||
|
||||
Converts a Hijri date to a Gregorian `Date`.
|
||||
|
||||
```typescript
|
||||
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date | null
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `hy` | `number` | Hijri year (1318–1500 for UAQ; any year ≥ 1 for FCNA) |
|
||||
| `hm` | `number` | Hijri month (1–12) |
|
||||
| `hd` | `number` | Hijri day (1–29 or 1–30 depending on the month) |
|
||||
| `options` | `ConversionOptions` | Optional. `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`. |
|
||||
|
||||
**Returns** `Date | null`
|
||||
|
||||
Returns a UTC Date at midnight. Returns `null` if no table entry matches (UAQ only; for valid input that passes `isValidHijriDate` this should not occur).
|
||||
|
||||
**Throws** `Error("Invalid Hijri date")` if the date fails validation.
|
||||
|
||||
**Example**
|
||||
|
||||
```javascript
|
||||
toGregorian(1444, 9, 1) // 2023-03-23T00: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(1444, 0, 1) // throws — month 0 is invalid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `formatHijriDate(date, format)`
|
||||
|
||||
Formats a Hijri date using a format string with Hijri-specific tokens.
|
||||
|
||||
```typescript
|
||||
function formatHijriDate(date: HijriDate, format: string): string
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `date` | `HijriDate` | A Hijri date object with `hy`, `hm`, `hd` properties |
|
||||
| `format` | `string` | Format string with tokens listed below |
|
||||
|
||||
**Returns** `string`
|
||||
|
||||
Tokens in the format string are replaced with the corresponding Hijri values. Unrecognized substrings pass through unchanged.
|
||||
|
||||
**Format tokens**
|
||||
|
||||
| Token | Description | Example |
|
||||
| --- | --- | --- |
|
||||
| `iYYYY` | Year, 4 digits | `1444` |
|
||||
| `iYY` | Year, last 2 digits | `44` |
|
||||
| `iMMMM` | Month, full name | `Ramadan` |
|
||||
| `iMMM` | Month, medium name | `Ramadan` |
|
||||
| `iMM` | Month, 2 digits, zero-padded | `09` |
|
||||
| `iM` | Month, no padding | `9` |
|
||||
| `iDD` | Day, 2 digits, zero-padded | `01` |
|
||||
| `iD` | Day, no padding | `1` |
|
||||
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
|
||||
| `iEEE` | Weekday, abbreviated | `Kham` |
|
||||
| `iE` | Weekday, numeric (Sunday=1) | `5` |
|
||||
| `ioooo` | Era, full | `AH` |
|
||||
| `iooo` | Era, abbreviated | `AH` |
|
||||
| `HH` | Hour, 24h, zero-padded | `14` |
|
||||
| `H` | Hour, 24h | `14` |
|
||||
| `hh` | Hour, 12h, zero-padded | `02` |
|
||||
| `h` | Hour, 12h | `2` |
|
||||
| `mm` | Minute, zero-padded | `05` |
|
||||
| `m` | Minute | `5` |
|
||||
| `ss` | Second, zero-padded | `30` |
|
||||
| `s` | Second | `30` |
|
||||
| `a` | AM/PM | `AM` |
|
||||
| `z`, `zz`, `zzz` | Timezone name | `UTC` |
|
||||
| `Z`, `ZZ` | Timezone offset | `+00:00` |
|
||||
|
||||
Time, timezone, and weekday tokens are computed from a Gregorian DateTime derived from the Hijri date using the UAQ calendar. For FCNA-derived dates in months where UAQ and FCNA start on different days, weekday and time tokens will reflect the UAQ Gregorian equivalent, not the FCNA one. Pure Hijri tokens (`iYYYY`, `iMM`, `iDD`, `iMMMM`, etc.) are always accurate regardless of which calendar system produced the date.
|
||||
|
||||
**Weekday numbering**
|
||||
|
||||
The weekday arrays follow the Islamic convention where Sunday is the first day:
|
||||
|
||||
| Index | Day | `iE` value |
|
||||
| --- | --- | --- |
|
||||
| 0 | Sunday | 1 |
|
||||
| 1 | Monday | 2 |
|
||||
| 2 | Tuesday | 3 |
|
||||
| 3 | Wednesday | 4 |
|
||||
| 4 | Thursday | 5 |
|
||||
| 5 | Friday | 6 |
|
||||
| 6 | Saturday | 7 |
|
||||
|
||||
**Example**
|
||||
|
||||
```javascript
|
||||
const d = { hy: 1444, hm: 9, hd: 1 };
|
||||
|
||||
formatHijriDate(d, 'iYYYY-iMM-iDD') // "1444-09-01"
|
||||
formatHijriDate(d, 'iMMMM iD, iYYYY') // "Ramadan 1, 1444"
|
||||
formatHijriDate(d, 'iEEEE, iD iMMMM iYYYY ioooo') // "Yawm al-Khamis, 1 Ramadan 1444 AH"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `isValidHijriDate(hy, hm, hd, options?)`
|
||||
|
||||
Checks whether a Hijri date is valid for the given calendar system.
|
||||
|
||||
```typescript
|
||||
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean
|
||||
```
|
||||
|
||||
**Returns** `boolean`
|
||||
|
||||
For UAQ (default): returns `false` if `hy` is outside 1318–1500, `hm` is outside 1–12, or `hd` exceeds the actual days in that month.
|
||||
|
||||
For FCNA: `hy` must be ≥ 1, `hm` must be 1–12, and `hd` must not exceed the computed FCNA month length.
|
||||
|
||||
**Example**
|
||||
|
||||
```javascript
|
||||
isValidHijriDate(1444, 9, 1) // true
|
||||
isValidHijriDate(1444, 9, 30) // false — Ramadan 1444 has 29 days (UAQ)
|
||||
isValidHijriDate(1317, 1, 1) // false — before table range
|
||||
isValidHijriDate(1501, 1, 1) // false — sentinel boundary
|
||||
isValidHijriDate(1, 1, 1, { calendar: 'fcna' }) // true — FCNA supports all years
|
||||
isValidHijriDate(1600, 1, 1, { calendar: 'fcna' }) // true — beyond UAQ table, FCNA computed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface HijriDate {
|
||||
hy: number; // Hijri year
|
||||
hm: number; // Hijri month (1–12)
|
||||
hd: number; // Hijri day (1–30)
|
||||
}
|
||||
|
||||
// Calendar system selector.
|
||||
// 'uaq' — Umm al-Qura (default): table-based, covers 1318–1500 H.
|
||||
// 'fcna' — FCNA/ISNA: astronomical calculation, works for all Hijri years ≥ 1 AH.
|
||||
type CalendarSystem = 'uaq' | 'fcna';
|
||||
|
||||
interface ConversionOptions {
|
||||
calendar?: CalendarSystem;
|
||||
}
|
||||
|
||||
interface HijriYearRecord {
|
||||
hy: number; // Hijri year
|
||||
dpm: number; // days-per-month bitmask
|
||||
gy: number; // Gregorian year of 1 Muharram
|
||||
gm: number; // Gregorian month of 1 Muharram
|
||||
gd: number; // Gregorian day of 1 Muharram
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type exports
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
HijriDate, // { hy, hm, hd }
|
||||
HijriYearRecord, // UAQ table row
|
||||
CalendarSystem, // 'uaq' | 'fcna'
|
||||
ConversionOptions, // { calendar?: CalendarSystem }
|
||||
} from 'luxon-hijri';
|
||||
```
|
||||
|
||||
## Data exports
|
||||
|
||||
```javascript
|
||||
import {
|
||||
hDatesTable, // HijriYearRecord[] — 184 entries (183 real years + 1 sentinel)
|
||||
hmLong, // string[12] — full month names
|
||||
hmMedium, // string[12] — medium month names
|
||||
hmShort, // string[12] — abbreviated month names
|
||||
hwLong, // string[7] — full weekday names (Sunday-first order)
|
||||
hwShort, // string[7] — abbreviated weekday names
|
||||
hwNumeric, // number[7] — weekday numbers (1–7, Sunday=1)
|
||||
formatPatterns, // Record<string, string> — token reference map
|
||||
} from 'luxon-hijri';
|
||||
```
|
||||
|
||||
**Month name arrays** (index 0 = Muharram, index 11 = Dhul Hijjah)
|
||||
|
||||
| Index | `hmLong` | `hmMedium` | `hmShort` |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | Muharram | Muharram | Muh |
|
||||
| 1 | Safar | Safar | Saf |
|
||||
| 2 | Rabi'l Awwal | Rabi1 | Ra1 |
|
||||
| 3 | Rabi'l Thani | Rabi2 | Ra2 |
|
||||
| 4 | Jumadal Awwal | Jumada1 | Ju1 |
|
||||
| 5 | Jumadal Thani | Jumada2 | Ju2 |
|
||||
| 6 | Rajab | Rajab | Raj |
|
||||
| 7 | Sha'ban | Shaban | Shb |
|
||||
| 8 | Ramadan | Ramadan | Ram |
|
||||
| 9 | Shawwal | Shawwal | Shw |
|
||||
| 10 | Dhul Qi'dah | Dhul-Qidah | DhQ |
|
||||
| 11 | Dhul Hijjah | Dhul-Hijah | DhH |
|
||||
|
||||
**Weekday arrays** (index 0 = Sunday, index 6 = Saturday)
|
||||
|
||||
| Index | `hwLong` | `hwShort` | `hwNumeric` |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | Yawm al-Ahad | Ahad | 1 |
|
||||
| 1 | Yawm al-Ithnayn | Ithn | 2 |
|
||||
| 2 | Yawm ath-Thulatha' | Thul | 3 |
|
||||
| 3 | Yawm al-Arba`a' | Arba | 4 |
|
||||
| 4 | Yawm al-Khamis | Kham | 5 |
|
||||
| 5 | Yawm al-Jum`a | Jum`a | 6 |
|
||||
| 6 | Yawm as-Sabt | Sabt | 7 |
|
||||
|
||||
---
|
||||
|
||||
[Home](Home) . [Architecture](Architecture) . [Hijri Calendar](Hijri-Calendar)
|
||||
138
.wiki/Architecture.md
Normal file
138
.wiki/Architecture.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
luxon-hijri is a pure table lookup implementation. It does not perform any astronomical calculation at runtime. Instead it ships the official Umm al-Qura calendar table — 183 Hijri years of precomputed data — and does binary search to navigate it.
|
||||
|
||||
Luxon is used only for two things: computing the equivalent Gregorian DateTime when format tokens like `iEEEE` (weekday) or time tokens need it, and for date arithmetic in `toGregorian` when adding days to the Muharram start date.
|
||||
|
||||
## The Umm al-Qura Table
|
||||
|
||||
The table in [src/hDates.ts](../src/hDates.ts) has 184 rows. The first 183 are real Hijri years (1318–1500). The 184th is a sentinel entry (year 1501, `dpm: 0`) that records the Gregorian start date of 1 Muharram 1501 — used as an upper boundary when converting Gregorian dates near the end of the table.
|
||||
|
||||
Each row stores:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `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. |
|
||||
| `gy` | number | Gregorian year of 1 Muharram |
|
||||
| `gm` | number | Gregorian month of 1 Muharram (1-based) |
|
||||
| `gd` | number | Gregorian day of 1 Muharram |
|
||||
|
||||
Example entry: `{ hy: 1444, dpm: 0x0555, gy: 2022, gm: 7, gd: 30 }` — year 1444 started on July 30, 2022 (Gregorian). The `dpm` bitmask tells us which months have 30 days vs 29.
|
||||
|
||||
Reading `dpm`: for month `m` (1-based), the day count is `((dpm >> (m - 1)) & 1) ? 30 : 29`.
|
||||
|
||||
## Conversion Algorithm
|
||||
|
||||
### Gregorian to Hijri (`toHijri`)
|
||||
|
||||
1. Normalize the input `Date` to UTC midnight using `Date.UTC(year, month, day)`. This uses local date components (`getFullYear`, `getMonth`, `getDate`) to determine the calendar date, making the result independent of the machine's timezone.
|
||||
|
||||
2. Binary search the table to find the last entry whose Gregorian start date is on or before the input. This is the Hijri year that contains the input date.
|
||||
|
||||
3. Compute `remainingDays = (inputUtc - entryStartUtc) / 86_400_000`.
|
||||
|
||||
4. Walk through the 12 months of that Hijri year, subtracting each month's day count until `remainingDays` falls within a month. The month index where it fits is the Hijri month; `remainingDays + 1` is the day.
|
||||
|
||||
5. Return `{ hy, hm, hd }`.
|
||||
|
||||
Returns `null` for input before the first entry or if the sentinel is hit (input is in Gregorian year 2077 or later).
|
||||
|
||||
### Hijri to Gregorian (`toGregorian`)
|
||||
|
||||
1. Validate the input with `isValidHijriDate`. Throws on failure.
|
||||
|
||||
2. Binary search the table on the `hy` field to find the row for the given Hijri year.
|
||||
|
||||
3. Sum the day counts for months 1 through `hm - 1` using the `dpm` bitmask. Add `hd - 1` for the day within the current month. This gives `totalDays` elapsed since 1 Muharram of that year.
|
||||
|
||||
4. Use `DateTime.utc(gy, gm, gd).plus({ days: totalDays }).toJSDate()` to produce the Gregorian date as a UTC Date.
|
||||
|
||||
### Validation (`isValidHijriDate`)
|
||||
|
||||
Binary search on `hy` to locate the row. If the row has `dpm === 0` (sentinel), returns `false`. Otherwise validates month range (1–12), then computes the actual day count for that month from `dpm` and checks the day.
|
||||
|
||||
## Format Token Resolution (`formatHijriDate`)
|
||||
|
||||
The regex `TOKEN_RE` matches all supported tokens in a single pass, ordered longest-first to prevent partial matches (e.g. `iMMMM` before `iMMM` before `iMM` before `iM`).
|
||||
|
||||
For pure Hijri tokens (`iYYYY`, `iMM`, etc.) the value is read directly from `hijriDate.hy`, `.hm`, or `.hd`.
|
||||
|
||||
For weekday tokens (`iE`, `iEEE`, `iEEEE`), era tokens, and time/timezone tokens, a Gregorian `DateTime` is needed. It is computed lazily via a closure:
|
||||
|
||||
```typescript
|
||||
let _gregDt: DateTime | undefined;
|
||||
|
||||
function getGregDt(): DateTime {
|
||||
if (!_gregDt) {
|
||||
const greg = toGregorian(hijriDate.hy, hijriDate.hm, hijriDate.hd);
|
||||
_gregDt = DateTime.fromJSDate(greg as Date, { zone: 'UTC' });
|
||||
}
|
||||
return _gregDt;
|
||||
}
|
||||
```
|
||||
|
||||
This avoids the Gregorian lookup entirely when only pure Hijri tokens are used.
|
||||
|
||||
**Weekday index mapping**: Luxon's `weekday` runs 1 (Monday) through 7 (Sunday). The weekday arrays (`hwLong`, `hwShort`, `hwNumeric`) are indexed 0–6 with Sunday at index 0. The mapping is:
|
||||
|
||||
```
|
||||
arrayIndex = luxonWeekday % 7
|
||||
// Monday=1 → 1, Tuesday=2 → 2, ..., Saturday=6 → 6, Sunday=7 → 0
|
||||
```
|
||||
|
||||
## Binary Search Complexity
|
||||
|
||||
All three functions (`toHijri`, `toGregorian`, `isValidHijriDate`) use binary search on a 184-entry table. This gives O(log 184) ≈ 8 comparisons worst case, compared to the O(184) linear scan used in v1.
|
||||
|
||||
For typical usage — converting a handful of dates per request — the difference is negligible. For batch workloads converting thousands of dates, the reduction is meaningful.
|
||||
|
||||
## FCNA Calendar Engine (`src/fcna.ts`)
|
||||
|
||||
The FCNA/ISNA calendar is computed astronomically rather than looked up from a table. It works for all Hijri years, not just the 1318–1500 range covered by the UAQ table.
|
||||
|
||||
### FCNA Criterion
|
||||
|
||||
The Fiqh Council of North America uses a global visibility rule: if the astronomical new moon conjunction occurs before **12:00 noon UTC** on day D, the new Hijri month begins at midnight starting day D+1. If the conjunction is at or after 12:00 UTC, the month begins at midnight starting day D+2.
|
||||
|
||||
### 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 1000–3000 CE.
|
||||
|
||||
### Anchor Strategy
|
||||
|
||||
For years within the UAQ table (1318–1500 H), the UAQ month start date is used as the anchor for the nearest-new-moon search. This ensures the FCNA computation is consistent with the validated UAQ dataset for the date range where both systems overlap.
|
||||
|
||||
For years outside the table, the anchor comes from the Islamic epoch (1 Muharram 1 AH ≈ JDE 1948438.5) plus the mean number of synodic months elapsed. Meeus corrections then adjust the mean estimate to the actual conjunction time.
|
||||
|
||||
### Nearest New Moon Search
|
||||
|
||||
Given an anchor UTC timestamp, the engine estimates k, then checks k−2 through k+2 (five candidates) for the corrected new moon closest to the anchor. This handles any estimation error from the anchor strategy.
|
||||
|
||||
### Calendar Conversion
|
||||
|
||||
`fcnaToGregorian(hy, hm, hd)`: sum the FCNA month-start offsets and add hd−1 days.
|
||||
|
||||
`fcnaToHijri(date)`: shift back ~15 days to ensure kApprox points to the current month's conjunction rather than the next. Try three adjacent k values; for each, compute the FCNA month start and next month start, then check whether the input falls within that window. Map the matching k to (hy, hm) via the K_EPOCH offset, and compute hd from the day offset.
|
||||
|
||||
FCNA uses UTC date components (`getUTCFullYear`, `getUTCMonth`, `getUTCDate`) because the FCNA criterion itself is defined in UTC. UAQ uses local date components.
|
||||
|
||||
### Performance
|
||||
|
||||
FCNA conversion calls `newMoonJDE` (the Meeus formula) 3–5 times per call. Each call is a fixed set of floating-point trig operations — sub-millisecond in any modern JS engine. Month length computation (`fcnaDaysInMonth`) calls it twice more. No caching is done since usage patterns are typically small-batch.
|
||||
|
||||
## Why Luxon
|
||||
|
||||
Luxon is used for two narrow purposes:
|
||||
|
||||
1. `DateTime.utc(gy, gm, gd).plus({ days: n }).toJSDate()` in `toGregorian` — cleaner than manual day arithmetic across month/year boundaries.
|
||||
|
||||
2. `DateTime.fromJSDate(greg, { zone: 'UTC' })` in `formatHijriDate` — provides `.weekday` and `.toFormat()` for time/timezone tokens.
|
||||
|
||||
Neither use requires Luxon's timezone database for standard Hijri date formatting. If you only use Hijri date tokens (no time/timezone tokens), the Gregorian DateTime is never constructed.
|
||||
|
||||
---
|
||||
|
||||
[Home](Home) . [API Reference](API-Reference) . [Hijri Calendar](Hijri-Calendar)
|
||||
97
.wiki/Hijri-Calendar.md
Normal file
97
.wiki/Hijri-Calendar.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Hijri Calendar
|
||||
|
||||
## The Islamic Calendar
|
||||
|
||||
The Islamic calendar (also called the Hijri calendar) is a lunar calendar consisting of 12 months in a year of 354 or 355 days. Because the lunar year is roughly 11 days shorter than the solar year, the Islamic calendar cycles through all seasons over a period of approximately 33 years.
|
||||
|
||||
The calendar begins from the year of the Hijra — the migration of the Prophet Muhammad from Mecca to Medina in 622 CE. That year is 1 AH (Anno Hegirae). The Hijri year is written with the suffix "H" or "AH."
|
||||
|
||||
Each month begins with the sighting of the crescent moon (hilal). Because actual lunar visibility depends on atmospheric conditions and geographic location, two methods exist for determining month start:
|
||||
|
||||
- **Moon sighting** (rukyah): the month begins when the crescent moon is physically observed, potentially varying by region.
|
||||
- **Astronomical calculation** (hisab): the month is computed mathematically from the astronomical new moon.
|
||||
|
||||
Different countries and communities follow different approaches.
|
||||
|
||||
## Hijri Months
|
||||
|
||||
| No. | Arabic Name | Common Transliteration |
|
||||
| --- | --- | --- |
|
||||
| 1 | محرم | Muharram |
|
||||
| 2 | صفر | Safar |
|
||||
| 3 | ربيع الأول | Rabi' al-Awwal |
|
||||
| 4 | ربيع الثاني | Rabi' al-Thani |
|
||||
| 5 | جمادى الأولى | Jumada al-Awwal |
|
||||
| 6 | جمادى الآخرة | Jumada al-Thani |
|
||||
| 7 | رجب | Rajab |
|
||||
| 8 | شعبان | Sha'ban |
|
||||
| 9 | رمضان | Ramadan |
|
||||
| 10 | شوال | Shawwal |
|
||||
| 11 | ذو القعدة | Dhul Qi'dah |
|
||||
| 12 | ذو الحجة | Dhul Hijjah |
|
||||
|
||||
Months alternate between 29 and 30 days. Dhul Hijjah has 29 days in a normal year and 30 in a leap year.
|
||||
|
||||
## Hijri Weekdays
|
||||
|
||||
The Islamic week begins on Sunday. Friday (Yawm al-Jum'a) is the day of congregational prayer.
|
||||
|
||||
| No. | Arabic Name | Transliteration |
|
||||
| --- | --- | --- |
|
||||
| 1 | الأحد | Yawm al-Ahad (Sunday) |
|
||||
| 2 | الاثنين | Yawm al-Ithnayn (Monday) |
|
||||
| 3 | الثلاثاء | Yawm ath-Thulatha' (Tuesday) |
|
||||
| 4 | الأربعاء | Yawm al-Arba'a' (Wednesday) |
|
||||
| 5 | الخميس | Yawm al-Khamis (Thursday) |
|
||||
| 6 | الجمعة | Yawm al-Jum'a (Friday) |
|
||||
| 7 | السبت | Yawm as-Sabt (Saturday) |
|
||||
|
||||
## The Umm al-Qura Calendar
|
||||
|
||||
The Umm al-Qura calendar is the official civil calendar of Saudi Arabia, published by the Umm al-Qura University in Mecca. It determines month start dates through astronomical calculation of the lunar crescent visibility at the coordinates of Mecca, rather than physical observation.
|
||||
|
||||
**Key properties:**
|
||||
|
||||
- Published in advance for civil and administrative use
|
||||
- Based on a fixed astronomical rule, not physical sighting
|
||||
- Accepted as the reference Hijri calendar in most Islamic finance and legal contexts globally
|
||||
- Covers years 1318 AH onward in published tables
|
||||
|
||||
The calendar is deterministic: a given Gregorian date corresponds to exactly one Hijri date, and vice versa. This makes it suitable for database storage, financial records, and software applications.
|
||||
|
||||
luxon-hijri ships the Umm al-Qura table covering 1318–1500 H (April 30, 1900 – November 2076 CE). For dates outside this range, the FCNA calendar option (`{ calendar: 'fcna' }`) provides astronomical computation for all Hijri years.
|
||||
|
||||
## Encoding the Days-per-Month Bitmask
|
||||
|
||||
Each year in the table stores a 12-bit integer (`dpm`) where bit 0 represents month 1 (Muharram) and bit 11 represents month 12 (Dhul Hijjah). A set bit means the month has 30 days; a clear bit means 29 days.
|
||||
|
||||
```
|
||||
bit 11 bit 10 ... bit 1 bit 0
|
||||
Dhul H Dhul Q Safar Muharram
|
||||
```
|
||||
|
||||
To get the day count for month `m`:
|
||||
|
||||
```javascript
|
||||
const days = (dpm >> (m - 1)) & 1 ? 30 : 29;
|
||||
```
|
||||
|
||||
This encoding packs an entire year's month structure into a single 16-bit integer, keeping the table compact.
|
||||
|
||||
## Year Length
|
||||
|
||||
A Hijri year has either 354 days (12 months × 29.5 days average) or 355 days. The `dpm` bitmask determines whether a given year is 354 or 355 days by counting how many bits are set. A year with 6 set bits has 6 months of 30 days and 6 months of 29 days: (6 × 30) + (6 × 29) = 354 days. A year with 7 set bits has 355 days.
|
||||
|
||||
## Epoch and Date Range
|
||||
|
||||
| | Hijri | Gregorian |
|
||||
| --- | --- | --- |
|
||||
| Table start | 1 Muharram 1318 H | April 30, 1900 |
|
||||
| Table end | Last day of Dhul Hijjah 1500 H | ~November 2076 |
|
||||
| Sentinel boundary | 1 Muharram 1501 H | November 17, 2077 |
|
||||
|
||||
For the Umm al-Qura calendar (default), dates outside this range return `null` from `toHijri` and throw from `toGregorian`. The FCNA calendar supports all Hijri years and has no range limit.
|
||||
|
||||
---
|
||||
|
||||
[Home](Home) . [API Reference](API-Reference) . [Architecture](Architecture)
|
||||
44
.wiki/Home.md
Normal file
44
.wiki/Home.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# luxon-hijri
|
||||
|
||||
Hijri/Gregorian date conversion and formatting based on the Umm al-Qura calendar. Built on Luxon. Zero runtime dependencies beyond Luxon itself.
|
||||
|
||||
**Package:** [luxon-hijri on npm](https://www.npmjs.com/package/luxon-hijri)
|
||||
**Repository:** [acamarata/luxon-hijri on GitHub](https://github.com/acamarata/luxon-hijri)
|
||||
**License:** MIT
|
||||
|
||||
## Pages
|
||||
|
||||
- [API Reference](API-Reference) - Function signatures, parameters, return types, format tokens
|
||||
- [Architecture](Architecture) - Umm al-Qura table structure, binary search, conversion algorithm
|
||||
- [Hijri Calendar](Hijri-Calendar) - Islamic calendar background, Umm al-Qura system, epoch
|
||||
|
||||
## Quick Example
|
||||
|
||||
```javascript
|
||||
import { toHijri, toGregorian, formatHijriDate } from 'luxon-hijri';
|
||||
|
||||
// Gregorian to Hijri
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
// { hy: 1444, hm: 9, hd: 1 } → 1 Ramadan 1444
|
||||
|
||||
// Hijri to Gregorian
|
||||
const g = toGregorian(1444, 9, 1);
|
||||
// Date: 2023-03-23T00:00:00.000Z
|
||||
|
||||
// Format
|
||||
formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iEEEE, iD iMMMM iYYYY ioooo');
|
||||
// "Yawm al-Khamis, 1 Ramadan 1444 AH"
|
||||
```
|
||||
|
||||
## Key Facts
|
||||
|
||||
- Two calendars: Umm al-Qura (default, table-based, 1318–1500 H) and FCNA/ISNA (astronomical, all years)
|
||||
- FCNA criterion: conjunction before 12:00 UTC → month starts D+1, else D+2 (Meeus Ch.49 algorithm)
|
||||
- Zero runtime dependencies beyond Luxon
|
||||
- Synchronous — no async, no loading delay
|
||||
- Dual CJS and ESM, full TypeScript definitions
|
||||
- Weekday format bug from v1 is fixed in v2 (weekday tokens now use correct Gregorian conversion)
|
||||
|
||||
---
|
||||
|
||||
[API Reference](API-Reference) . [Architecture](Architecture) . [Hijri Calendar](Hijri-Calendar)
|
||||
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.0.0] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- **FCNA/ISNA calendar support**: `toHijri`, `toGregorian`, and `isValidHijriDate` now accept an optional `{ calendar: 'fcna' }` option. The FCNA criterion: if the astronomical conjunction occurs before 12:00 UTC the month begins D+1, otherwise D+2. New moon times use the full Meeus Chapter 49 formula (accurate to within a few minutes for 1000–3000 CE). The FCNA calendar works for all Hijri years, not just the 1318–1500 H range covered by the UAQ table.
|
||||
- New exported types: `CalendarSystem` (`'uaq' | 'fcna'`) and `ConversionOptions` (`{ calendar?: CalendarSystem }`).
|
||||
- `src/fcna.ts`: standalone FCNA engine — Meeus Ch.49 new moon algorithm, UAQ anchor lookup, FCNA criterion, and full Hijri↔Gregorian conversion without external dependencies beyond the existing hDatesTable.
|
||||
- Dual CJS and ESM build via tsup (`dist/index.cjs`, `dist/index.mjs`)
|
||||
- Full TypeScript declarations for both module formats (`dist/index.d.ts`, `dist/index.d.mts`)
|
||||
- `src/types.ts` with named exported `HijriDate` and `HijriYearRecord` interfaces
|
||||
- Exports: `hwLong`, `hwShort`, `hwNumeric` weekday arrays now public
|
||||
- Exports: `HijriDate`, `HijriYearRecord`, `CalendarSystem`, `ConversionOptions` types exported from the package root
|
||||
- `isValidHijriDate` now correctly handles the table sentinel entry (Hijri year 1501 boundary marker)
|
||||
- Comprehensive test suite: `test.mjs` (ESM) and `test-cjs.cjs` (CJS), including FCNA test cases verified against ISNA 2024–2025 calendar announcements
|
||||
- CI workflow: Node 20/22/24 matrix, typecheck, and pack-check jobs
|
||||
- GitHub Wiki with four pages: Home, API Reference, Architecture, Hijri Calendar Background
|
||||
- `.editorconfig`, `.nvmrc` (24), `.npmrc`, `pnpm-workspace.yaml`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Critical weekday bug**: `iE`, `iEEE`, `iEEEE` format tokens previously called `DateTime.fromObject({ year: hijriDate.hy, ... })` which Luxon interprets as Gregorian, returning dates in the year ~1444 CE (January 1444 CE is ~580 years ago). Every weekday result was wrong. Fix: convert Hijri to Gregorian via `toGregorian()` first, then call `DateTime.fromJSDate()` for weekday lookup.
|
||||
- **Era tokens**: `iooo` and `ioooo` were delegated to Luxon's `toFormat()` which returned Gregorian era strings. Both now return `"AH"` directly.
|
||||
- **Time/timezone tokens**: These also used the broken `DateTime.fromObject()` path. Now use a lazily computed Gregorian DateTime via `toGregorian()` + `DateTime.fromJSDate()`.
|
||||
- **Format token regex**: Previous word-boundary (`\b`) regex caused partial token matches and missed some tokens. Replaced with an ordered alternation that matches longest tokens first.
|
||||
- **`toGregorian` timezone sensitivity**: Was using `DateTime.local()` to build the start date, which shifted the result by the host machine's UTC offset. Now uses `DateTime.utc()` for consistent UTC output across all environments.
|
||||
- `README.md` license reference corrected from ISC to MIT.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking**: Package now ships dual ESM/CJS with a conditional `exports` map. The old `main: dist/index.js` entry is replaced by `main: dist/index.cjs` + `module: dist/index.mjs`. Bundlers and Node 20+ resolve automatically.
|
||||
- **Breaking**: `hDates` interface renamed to `HijriYearRecord` (cleaner public API name).
|
||||
- **Breaking**: Luxon upgraded from `^2.5.2` to `^3.5.0`.
|
||||
- **Breaking**: `engines.node` set to `>=20` (Node 18 EOL April 2025).
|
||||
- Build system changed from `tsc` to `tsup`. Manual `src/index.d.ts` deleted (tsup auto-generates).
|
||||
- Package manager changed from npm to pnpm. `package-lock.json` replaced by `pnpm-lock.yaml`.
|
||||
- `toHijri` lookup replaced with binary search (O(log 183) vs O(n) reduce+find).
|
||||
- `toGregorian` lookup replaced with binary search on `hy` (O(log 183) vs O(n) find).
|
||||
- `isValidHijriDate` lookup replaced with binary search (O(log 183) vs O(n) find).
|
||||
- `author` field corrected to `"Aric Camarata"`.
|
||||
- `repository.url` updated to use `git+https://` prefix (prevents npm publish warnings).
|
||||
|
||||
### Removed
|
||||
|
||||
- `@umalqura/core` runtime dependency (was unused in the implementation).
|
||||
- `jest` and related test infrastructure.
|
||||
- `typescript ^4.0.0` (replaced with `^5.5.0`).
|
||||
- `src/index.d.ts` (manual, incomplete, generated automatically by tsup).
|
||||
|
||||
## [1.0.4] - 2024-01-01
|
||||
|
||||
Initial public release on npm. CommonJS only. Umm al-Qura table-based conversion with Luxon formatting.
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Aric Camarata
|
||||
Copyright (c) 2024-2026 Aric Camarata
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
199
README.md
199
README.md
|
|
@ -1,13 +1,10 @@
|
|||
# Luxon-Hijri
|
||||
# luxon-hijri
|
||||
|
||||
Luxon-Hijri is a comprehensive Hijri date conversion and formatting library based on the Umm al-Qura calendar system, leveraging the power of Luxon for robust date manipulations. Please note this is a first draft but should work well. It was created quickly for a need I had within my own app but should be ready for contributions and more. I will add localization and more if requested or if someone contributes.
|
||||
[](https://www.npmjs.com/package/luxon-hijri)
|
||||
[](https://github.com/acamarata/luxon-hijri/actions/workflows/ci.yml)
|
||||
[](./LICENSE)
|
||||
|
||||
## Features
|
||||
- Convert between Hijri and Gregorian dates.
|
||||
- Format Hijri dates with customizable patterns.
|
||||
- Locale support for different languages.
|
||||
- Efficient and optimized for performance.
|
||||
- Comprehensive unit testing for reliability.
|
||||
Hijri/Gregorian date conversion and formatting. Supports two calendar systems: Umm al-Qura (default, table-based) and FCNA/ISNA (astronomical, all Hijri years). Built on Luxon.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -17,58 +14,186 @@ npm install luxon-hijri
|
|||
|
||||
## Quick Start
|
||||
|
||||
Here's how you can quickly convert a Gregorian date to a Hijri date:
|
||||
|
||||
```javascript
|
||||
import { toHijri } from 'luxon-hijri';
|
||||
import { toHijri, toGregorian, formatHijriDate } from 'luxon-hijri';
|
||||
|
||||
const hijriDate = toHijri(new Date());
|
||||
console.log(hijriDate);
|
||||
// Gregorian to Hijri (Umm al-Qura, default)
|
||||
const h = toHijri(new Date(2023, 2, 23, 12)); // March 23, 2023
|
||||
// { hy: 1444, hm: 9, hd: 1 }
|
||||
|
||||
// Hijri to Gregorian
|
||||
const g = toGregorian(1444, 9, 1); // 1 Ramadan 1444
|
||||
// Date: 2023-03-23T00:00:00.000Z
|
||||
|
||||
// Format a Hijri date
|
||||
formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iEEEE, iD iMMMM iYYYY ioooo');
|
||||
// "Yawm al-Khamis, 1 Ramadan 1444 AH"
|
||||
|
||||
// FCNA/ISNA calendar (astronomical, works for all Hijri years)
|
||||
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); // { hy: 1446, hm: 9, hd: 1 }
|
||||
toGregorian(1446, 9, 1, { calendar: 'fcna' }); // Date: 2025-03-01T00:00:00.000Z
|
||||
```
|
||||
|
||||
## Usage
|
||||
## API
|
||||
|
||||
### Converting Dates
|
||||
Convert Gregorian dates to Hijri:
|
||||
### `toHijri(date, options?)`
|
||||
|
||||
```javascript
|
||||
import { toHijri } from 'luxon-hijri';
|
||||
Converts a Gregorian `Date` to a Hijri date object.
|
||||
|
||||
const hijriDate = toHijri(new Date(2023, 3, 14)); // April 14, 2023
|
||||
console.log(hijriDate);
|
||||
```typescript
|
||||
function toHijri(date: Date, options?: ConversionOptions): HijriDate | null
|
||||
```
|
||||
|
||||
Convert Hijri dates to Gregorian:
|
||||
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 `'fcna'`: returns `null` only for dates before 1 AH. Uses UTC date components (FCNA boundaries are defined in UTC).
|
||||
|
||||
Throws `Error("Invalid Gregorian date")` if `date` is not a valid `Date`.
|
||||
|
||||
```javascript
|
||||
import { toGregorian } from 'luxon-hijri';
|
||||
|
||||
const gregorianDate = toGregorian(1444, 9, 1); // 1st of Ramadan, 1444 H
|
||||
console.log(gregorianDate);
|
||||
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(1800, 0, 1)) // null — before UAQ table range
|
||||
```
|
||||
|
||||
### Formatting Dates
|
||||
Format Hijri dates using predefined patterns:
|
||||
### `toGregorian(hy, hm, hd, options?)`
|
||||
|
||||
Converts a Hijri date to a Gregorian `Date` at UTC midnight.
|
||||
|
||||
```typescript
|
||||
function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date | null
|
||||
```
|
||||
|
||||
Throws `Error("Invalid Hijri date")` if the date is invalid for the selected calendar.
|
||||
|
||||
```javascript
|
||||
import { formatHijriDate } from 'luxon-hijri';
|
||||
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(1, 1, 1, { calendar: 'fcna' }) // Date: 0622-07-18T00:00:00.000Z (Islamic epoch)
|
||||
```
|
||||
|
||||
const formattedDate = formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iYYYY-iMM-iDD');
|
||||
console.log(formattedDate); // 1444-09-01
|
||||
### `formatHijriDate(date, format)`
|
||||
|
||||
Formats a Hijri date using the token patterns below. Tokens not listed pass through unchanged.
|
||||
|
||||
```typescript
|
||||
function formatHijriDate(date: HijriDate, format: string): string
|
||||
```
|
||||
|
||||
| Token | Output | Example |
|
||||
| --- | --- | --- |
|
||||
| `iYYYY` | Year, 4 digits | `1444` |
|
||||
| `iYY` | Year, last 2 digits | `44` |
|
||||
| `iMMMM` | Month, full name | `Ramadan` |
|
||||
| `iMMM` | Month, medium name | `Ramadan` |
|
||||
| `iMM` | Month, zero-padded | `09` |
|
||||
| `iM` | Month, no padding | `9` |
|
||||
| `iDD` | Day, zero-padded | `01` |
|
||||
| `iD` | Day, no padding | `1` |
|
||||
| `iEEEE` | Weekday, full name | `Yawm al-Khamis` |
|
||||
| `iEEE` | Weekday, abbreviated | `Kham` |
|
||||
| `iE` | Weekday, numeric (Sun=1) | `5` |
|
||||
| `ioooo` | Era, full | `AH` |
|
||||
| `iooo` | Era, abbreviated | `AH` |
|
||||
| `HH`, `H`, `hh`, `h` | Hour (via Luxon) | `14`, `14`, `02`, `2` |
|
||||
| `mm`, `m` | Minute (via Luxon) | `05`, `5` |
|
||||
| `ss`, `s` | Second (via Luxon) | `30`, `30` |
|
||||
| `a` | AM/PM | `AM` |
|
||||
| `z`, `zz`, `zzz` | Timezone | `UTC` |
|
||||
| `Z`, `ZZ` | Timezone offset | `+00:00` |
|
||||
|
||||
### `isValidHijriDate(hy, hm, hd, options?)`
|
||||
|
||||
Returns `true` if the Hijri date is valid for the selected calendar.
|
||||
|
||||
```typescript
|
||||
function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean
|
||||
```
|
||||
|
||||
For `'uaq'` (default): year must be 1318–1500, month 1–12, day must not exceed the actual month length from the UAQ table.
|
||||
|
||||
For `'fcna'`: year must be ≥ 1, month 1–12, day must not exceed the computed FCNA month length.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface HijriDate {
|
||||
hy: number; // Hijri year
|
||||
hm: number; // Hijri month (1–12)
|
||||
hd: number; // Hijri day (1–30)
|
||||
}
|
||||
|
||||
type CalendarSystem = 'uaq' | 'fcna';
|
||||
|
||||
interface ConversionOptions {
|
||||
calendar?: CalendarSystem; // default: 'uaq'
|
||||
}
|
||||
|
||||
interface HijriYearRecord {
|
||||
hy: number; // Hijri year
|
||||
dpm: number; // days-per-month bitmask (bit 0 = month 1, 1 = 30 days, 0 = 29 days)
|
||||
gy: number; // Gregorian year of 1 Muharram
|
||||
gm: number; // Gregorian month of 1 Muharram
|
||||
gd: number; // Gregorian day of 1 Muharram
|
||||
}
|
||||
```
|
||||
|
||||
### Additional exports
|
||||
|
||||
```javascript
|
||||
import {
|
||||
hDatesTable, // HijriYearRecord[] — the full Umm al-Qura table (184 entries)
|
||||
hmLong, // string[12] — full month names
|
||||
hmMedium, // string[12] — medium month names
|
||||
hmShort, // string[12] — abbreviated month names
|
||||
hwLong, // string[7] — full weekday names (Sunday first)
|
||||
hwShort, // string[7] — abbreviated weekday names
|
||||
hwNumeric, // number[7] — weekday numbers (1–7, Sunday=1)
|
||||
formatPatterns, // Record<string, string> — token reference
|
||||
} from 'luxon-hijri';
|
||||
```
|
||||
|
||||
## Calendar Systems
|
||||
|
||||
**Umm al-Qura (`'uaq'`, default):** Official Saudi calendar, table-based, covers Hijri years 1318–1500 (April 1900 to November 2076). Authoritative for Saudi Arabia and widely used across the Arab world.
|
||||
|
||||
**FCNA/ISNA (`'fcna'`):** Used by the Fiqh Council of North America and ISNA. Astronomical criterion: if the new moon conjunction occurs before 12:00 UTC on day D, the month begins at midnight of D+1; otherwise D+2. Works for all Hijri years (no range limit). New moon times use the full Meeus Chapter 49 algorithm, accurate to within a few minutes for 1000–3000 CE.
|
||||
|
||||
## Architecture
|
||||
|
||||
The UAQ engine is a pure table lookup with binary search (O(log 183)). The FCNA engine computes new moon times astronomically using the Meeus Ch.49 formula — 3 to 5 trigonometric evaluations per call, sub-millisecond on any modern JS engine.
|
||||
|
||||
For more detail see the [Architecture wiki page](https://github.com/acamarata/luxon-hijri/wiki/Architecture).
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Node.js 20+ (ESM and CJS)
|
||||
- Bundlers: webpack, Rollup, Vite, esbuild (tree-shakeable, `sideEffects: false`)
|
||||
- TypeScript: full type definitions included
|
||||
|
||||
## TypeScript
|
||||
|
||||
```typescript
|
||||
import { toHijri, toGregorian, formatHijriDate, isValidHijriDate } from 'luxon-hijri';
|
||||
import type { HijriDate, HijriYearRecord, CalendarSystem, ConversionOptions } from 'luxon-hijri';
|
||||
|
||||
const h: HijriDate | null = toHijri(new Date());
|
||||
const g: Date | null = toGregorian(1444, 9, 1, { calendar: 'fcna' });
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, examples, and API reference, visit [Documentation Link].
|
||||
Full API reference, architecture notes, calendar background, and format token guide:
|
||||
[https://github.com/acamarata/luxon-hijri/wiki](https://github.com/acamarata/luxon-hijri/wiki)
|
||||
|
||||
## Contributing
|
||||
## Related
|
||||
|
||||
We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) for details on how to submit pull requests, report issues, and suggest enhancements.
|
||||
- [nrel-spa](https://www.npmjs.com/package/nrel-spa) — NREL Solar Position Algorithm (pure JS)
|
||||
- [pray-calc](https://www.npmjs.com/package/pray-calc) — Islamic prayer times, depends on nrel-spa
|
||||
- [solar-spa](https://www.npmjs.com/package/solar-spa) — NREL SPA compiled to WebAssembly
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the ISC License - see the [LICENSE.md](LICENSE.md) file for details.
|
||||
MIT. Copyright (c) 2024-2026 Aric Camarata.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Participation in this project is governed by a [Code of Conduct](CODE_OF_CONDUCT.md). We expect everyone to adhere to it to maintain a respectful and welcoming environment.
|
||||
See [LICENSE](./LICENSE) for the full text.
|
||||
|
|
|
|||
4023
package-lock.json
generated
4023
package-lock.json
generated
File diff suppressed because it is too large
Load diff
76
package.json
76
package.json
|
|
@ -1,41 +1,75 @@
|
|||
{
|
||||
"name": "luxon-hijri",
|
||||
"version": "1.0.4",
|
||||
"description": "A Hijri date converter based on the Umm al-Qura calendar system, using Luxon for date manipulations.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build",
|
||||
"test": "jest"
|
||||
"version": "2.0.0",
|
||||
"description": "Hijri/Gregorian date conversion and formatting using the Umm al-Qura calendar. Built on Luxon. Supports toHijri, toGregorian, formatHijriDate, and isValidHijriDate.",
|
||||
"author": "Aric Camarata",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist"
|
||||
"dist/index.cjs",
|
||||
"dist/index.mjs",
|
||||
"dist/index.d.ts",
|
||||
"dist/index.d.mts",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pretest": "tsup",
|
||||
"test": "node test.mjs && node test-cjs.cjs",
|
||||
"prepublishOnly": "tsup"
|
||||
},
|
||||
"keywords": [
|
||||
"hijri",
|
||||
"gregorian",
|
||||
"calendar",
|
||||
"converter",
|
||||
"luxon",
|
||||
"umm-al-qura"
|
||||
"umm-al-qura",
|
||||
"islamic",
|
||||
"date",
|
||||
"format",
|
||||
"typescript"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.4",
|
||||
"jest": "^27.0.0",
|
||||
"typescript": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@umalqura/core": "^0.0.7",
|
||||
"luxon": "^2.5.2"
|
||||
"luxon": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"author": "Ali Camarata",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/acamarata/luxon-hijri.git"
|
||||
"url": "git+https://github.com/acamarata/luxon-hijri.git"
|
||||
},
|
||||
"homepage": "https://github.com/acamarata/luxon-hijri#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/acamarata/luxon-hijri/issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
943
pnpm-lock.yaml
Normal file
943
pnpm-lock.yaml
Normal file
|
|
@ -0,0 +1,943 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.7.2
|
||||
devDependencies:
|
||||
'@types/luxon':
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.11
|
||||
tsup:
|
||||
specifier: ^8.0.0
|
||||
version: 8.5.1(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/luxon@3.7.1':
|
||||
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
||||
|
||||
'@types/node@22.19.11':
|
||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
bundle-require@5.1.0:
|
||||
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
peerDependencies:
|
||||
esbuild: '>=0.18'
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
commander@4.1.1:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
consola@3.4.2:
|
||||
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
esbuild@0.27.3:
|
||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fix-dts-default-cjs-exports@1.0.1:
|
||||
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
load-tsconfig@0.2.5:
|
||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
postcss-load-config@6.0.1:
|
||||
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
jiti: '>=1.21.0'
|
||||
postcss: '>=8.0.9'
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
jiti:
|
||||
optional: true
|
||||
postcss:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
resolve-from@5.0.0:
|
||||
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
rollup@4.59.0:
|
||||
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
sucrase@3.35.1:
|
||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tsup@8.5.1:
|
||||
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@microsoft/api-extractor': ^7.36.0
|
||||
'@swc/core': ^1
|
||||
postcss: ^8.4.12
|
||||
typescript: '>=4.5.0'
|
||||
peerDependenciesMeta:
|
||||
'@microsoft/api-extractor':
|
||||
optional: true
|
||||
'@swc/core':
|
||||
optional: true
|
||||
postcss:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ufo@1.6.3:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/luxon@3.7.1': {}
|
||||
|
||||
'@types/node@22.19.11':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
bundle-require@5.1.0(esbuild@0.27.3):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
load-tsconfig: 0.2.5
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
consola@3.4.2: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
esbuild@0.27.3:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.3
|
||||
'@esbuild/android-arm': 0.27.3
|
||||
'@esbuild/android-arm64': 0.27.3
|
||||
'@esbuild/android-x64': 0.27.3
|
||||
'@esbuild/darwin-arm64': 0.27.3
|
||||
'@esbuild/darwin-x64': 0.27.3
|
||||
'@esbuild/freebsd-arm64': 0.27.3
|
||||
'@esbuild/freebsd-x64': 0.27.3
|
||||
'@esbuild/linux-arm': 0.27.3
|
||||
'@esbuild/linux-arm64': 0.27.3
|
||||
'@esbuild/linux-ia32': 0.27.3
|
||||
'@esbuild/linux-loong64': 0.27.3
|
||||
'@esbuild/linux-mips64el': 0.27.3
|
||||
'@esbuild/linux-ppc64': 0.27.3
|
||||
'@esbuild/linux-riscv64': 0.27.3
|
||||
'@esbuild/linux-s390x': 0.27.3
|
||||
'@esbuild/linux-x64': 0.27.3
|
||||
'@esbuild/netbsd-arm64': 0.27.3
|
||||
'@esbuild/netbsd-x64': 0.27.3
|
||||
'@esbuild/openbsd-arm64': 0.27.3
|
||||
'@esbuild/openbsd-x64': 0.27.3
|
||||
'@esbuild/openharmony-arm64': 0.27.3
|
||||
'@esbuild/sunos-x64': 0.27.3
|
||||
'@esbuild/win32-arm64': 0.27.3
|
||||
'@esbuild/win32-ia32': 0.27.3
|
||||
'@esbuild/win32-x64': 0.27.3
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fix-dts-default-cjs-exports@1.0.1:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
mlly: 1.8.0
|
||||
rollup: 4.59.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mlly@1.8.0:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
pathe: 2.0.3
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
mlly: 1.8.0
|
||||
pathe: 2.0.3
|
||||
|
||||
postcss-load-config@6.0.1:
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
|
||||
rollup@4.59.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.59.0
|
||||
'@rollup/rollup-android-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-arm64': 4.59.0
|
||||
'@rollup/rollup-darwin-x64': 4.59.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.59.0
|
||||
'@rollup/rollup-freebsd-x64': 4.59.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-loong64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.59.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.59.0
|
||||
'@rollup/rollup-openbsd-x64': 4.59.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.59.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.59.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.59.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
commander: 4.1.1
|
||||
lines-and-columns: 1.2.4
|
||||
mz: 2.7.0
|
||||
pirates: 4.0.7
|
||||
tinyglobby: 0.2.15
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
|
||||
thenify@3.3.1:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsup@8.5.1(typescript@5.9.3):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.3)
|
||||
cac: 6.7.14
|
||||
chokidar: 4.0.3
|
||||
consola: 3.4.2
|
||||
debug: 4.4.3
|
||||
esbuild: 0.27.3
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
picocolors: 1.1.1
|
||||
postcss-load-config: 6.0.1
|
||||
resolve-from: 5.0.0
|
||||
rollup: 4.59.0
|
||||
source-map: 0.7.6
|
||||
sucrase: 3.35.1
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tree-kill: 1.2.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- supports-color
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ufo@1.6.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
301
src/fcna.ts
Normal file
301
src/fcna.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// fcna.ts — FCNA/ISNA Hijri calendar engine
|
||||
//
|
||||
// The Fiqh Council of North America (FCNA) uses a global astronomical criterion:
|
||||
// if the new moon conjunction occurs before 12:00 noon UTC on day D, the new
|
||||
// Hijri month begins at midnight starting day D+1 (next calendar day); if at or
|
||||
// after 12:00 UTC, the month begins at midnight starting day D+2.
|
||||
//
|
||||
// For years in the Umm al-Qura table (1318–1500 H), the UAQ month start date
|
||||
// serves as the anchor for locating the nearest new moon. For years outside that
|
||||
// range the anchor comes from the Islamic epoch plus mean synodic months.
|
||||
//
|
||||
// New moon times come from Jean Meeus, Astronomical Algorithms (2nd ed.),
|
||||
// Chapter 49 — accurate to within a few minutes for 1000 CE to 3000 CE.
|
||||
|
||||
import { hDatesTable } from './hDates';
|
||||
import type { HijriDate } from './types';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const SYNODIC = 29.530588861; // Mean synodic month (days)
|
||||
const JDE0 = 2451550.09766; // Meeus k=0: mean new moon ~2000-01-06
|
||||
const JDE_UNIX = 2440587.5; // JDE of Unix epoch 1970-01-01 00:00 UTC
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const TO_RAD = Math.PI / 180;
|
||||
|
||||
// Approximate k index of 1 Muharram 1 AH in Meeus numbering.
|
||||
// Derived: Islamic epoch JDE ≈ 1948438.5 → k ≈ (1948438.5 − JDE0) / SYNODIC ≈ −17037.
|
||||
const K_EPOCH = -17037;
|
||||
|
||||
// ─── Meeus Chapter 49: corrected new moon JDE ────────────────────────────────
|
||||
|
||||
function newMoonJDE(k: number): number {
|
||||
const T = k / 1236.85;
|
||||
const T2 = T * T;
|
||||
const T3 = T2 * T;
|
||||
const T4 = T3 * T;
|
||||
|
||||
// Mean JDE (Meeus Eq. 49.1)
|
||||
let jde = JDE0
|
||||
+ SYNODIC * k
|
||||
+ 0.00015437 * T2
|
||||
- 0.000000150 * T3
|
||||
+ 0.00000000073 * T4;
|
||||
|
||||
// Sun's mean anomaly M (degrees)
|
||||
const M = (2.5534
|
||||
+ 29.10535670 * k
|
||||
- 0.0000014 * T2
|
||||
- 0.00000011 * T3) % 360;
|
||||
|
||||
// Moon's mean anomaly M' (degrees)
|
||||
const Mprime = (201.5643
|
||||
+ 385.81693528 * k
|
||||
+ 0.0107582 * T2
|
||||
+ 0.00001238 * T3
|
||||
- 0.000000058 * T4) % 360;
|
||||
|
||||
// Moon's argument of latitude F (degrees)
|
||||
const F = (160.7108
|
||||
+ 390.67050284 * k
|
||||
- 0.0016118 * T2
|
||||
- 0.00000227 * T3
|
||||
+ 0.000000011 * T4) % 360;
|
||||
|
||||
// Longitude of ascending node Omega (degrees)
|
||||
const Omega = (124.7746
|
||||
- 1.56375588 * k
|
||||
+ 0.0020672 * T2
|
||||
+ 0.00000215 * T3) % 360;
|
||||
|
||||
// Eccentricity correction factor E
|
||||
const E = 1 - 0.002516 * T - 0.0000074 * T2;
|
||||
const E2 = E * E;
|
||||
|
||||
// Angles to radians
|
||||
const Mrad = M * TO_RAD;
|
||||
const Mprad = Mprime * TO_RAD;
|
||||
const Frad = F * TO_RAD;
|
||||
const Orad = Omega * TO_RAD;
|
||||
|
||||
// Planetary correction (Meeus Table 49.a — new moon phase)
|
||||
jde +=
|
||||
- 0.40720 * Math.sin(Mprad)
|
||||
+ 0.17241 * E * Math.sin(Mrad)
|
||||
+ 0.01608 * Math.sin(2 * Mprad)
|
||||
+ 0.01039 * Math.sin(2 * Frad)
|
||||
+ 0.00739 * E * Math.sin(Mprad - Mrad)
|
||||
- 0.00514 * E * Math.sin(Mprad + Mrad)
|
||||
+ 0.00208 * E2 * Math.sin(2 * Mrad)
|
||||
- 0.00111 * Math.sin(Mprad - 2 * Frad)
|
||||
- 0.00057 * Math.sin(Mprad + 2 * Frad)
|
||||
+ 0.00056 * E * Math.sin(2 * Mprad + Mrad)
|
||||
- 0.00042 * Math.sin(3 * Mprad)
|
||||
+ 0.00042 * E * Math.sin(Mrad + 2 * Frad)
|
||||
+ 0.00038 * E * Math.sin(Mrad - 2 * Frad)
|
||||
- 0.00024 * E * Math.sin(2 * Mprad - Mrad)
|
||||
- 0.00017 * Math.sin(Orad)
|
||||
- 0.00007 * Math.sin(Mprad + 2 * Mrad)
|
||||
+ 0.00004 * Math.sin(2 * Mprad - 2 * Frad)
|
||||
+ 0.00004 * Math.sin(3 * Mrad)
|
||||
+ 0.00003 * Math.sin(Mprad + Mrad - 2 * Frad)
|
||||
+ 0.00003 * Math.sin(2 * Mprad + 2 * Frad)
|
||||
- 0.00003 * Math.sin(Mprad + Mrad + 2 * Frad)
|
||||
+ 0.00003 * Math.sin(Mprad - Mrad + 2 * Frad)
|
||||
- 0.00002 * Math.sin(Mprad - Mrad - 2 * Frad)
|
||||
- 0.00002 * Math.sin(3 * Mprad + Mrad)
|
||||
+ 0.00002 * Math.sin(4 * Mprad);
|
||||
|
||||
// Additional planetary corrections (Meeus Table 49.b)
|
||||
const A1 = (299.77 + 0.107408 * k - 0.009173 * T2) * TO_RAD;
|
||||
const A2 = (251.88 + 0.016321 * k) * TO_RAD;
|
||||
const A3 = (251.83 + 26.651886 * k) * TO_RAD;
|
||||
const A4 = (349.42 + 36.412478 * k) * TO_RAD;
|
||||
const A5 = ( 84.66 + 18.206239 * k) * TO_RAD;
|
||||
const A6 = (141.74 + 53.303771 * k) * TO_RAD;
|
||||
const A7 = (207.14 + 2.453732 * k) * TO_RAD;
|
||||
const A8 = (154.84 + 7.306860 * k) * TO_RAD;
|
||||
const A9 = ( 34.52 + 27.261239 * k) * TO_RAD;
|
||||
const A10 = (207.19 + 0.121824 * k) * TO_RAD;
|
||||
const A11 = (291.34 + 1.844379 * k) * TO_RAD;
|
||||
const A12 = (161.72 + 24.198154 * k) * TO_RAD;
|
||||
const A13 = (239.56 + 25.513099 * k) * TO_RAD;
|
||||
const A14 = (331.55 + 3.592518 * k) * TO_RAD;
|
||||
|
||||
jde +=
|
||||
+ 0.000325 * Math.sin(A1)
|
||||
+ 0.000165 * Math.sin(A2)
|
||||
+ 0.000164 * Math.sin(A3)
|
||||
+ 0.000126 * Math.sin(A4)
|
||||
+ 0.000110 * Math.sin(A5)
|
||||
+ 0.000062 * Math.sin(A6)
|
||||
+ 0.000060 * Math.sin(A7)
|
||||
+ 0.000056 * Math.sin(A8)
|
||||
+ 0.000047 * Math.sin(A9)
|
||||
+ 0.000042 * Math.sin(A10)
|
||||
+ 0.000040 * Math.sin(A11)
|
||||
+ 0.000037 * Math.sin(A12)
|
||||
+ 0.000035 * Math.sin(A13)
|
||||
+ 0.000023 * Math.sin(A14);
|
||||
|
||||
return jde;
|
||||
}
|
||||
|
||||
// ─── JDE / UTC conversion ─────────────────────────────────────────────────────
|
||||
|
||||
function jdeToUtcMs(jde: number): number {
|
||||
return (jde - JDE_UNIX) * MS_PER_DAY;
|
||||
}
|
||||
|
||||
function utcMsToKApprox(ms: number): number {
|
||||
const jde = ms / MS_PER_DAY + JDE_UNIX;
|
||||
return (jde - JDE0) / SYNODIC;
|
||||
}
|
||||
|
||||
// ─── Find nearest corrected new moon ─────────────────────────────────────────
|
||||
|
||||
// Returns the UTC ms of the corrected new moon closest to anchorMs.
|
||||
// Searches k0-2 through k0+2 (5 candidates) to handle any estimation error.
|
||||
function nearestNewMoonMs(anchorMs: number): number {
|
||||
const k0 = Math.round(utcMsToKApprox(anchorMs));
|
||||
let bestMs = 0;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (let k = k0 - 2; k <= k0 + 2; k++) {
|
||||
const ms = jdeToUtcMs(newMoonJDE(k));
|
||||
const dist = Math.abs(ms - anchorMs);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestMs = ms;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMs;
|
||||
}
|
||||
|
||||
// ─── FCNA criterion ──────────────────────────────────────────────────────────
|
||||
|
||||
// Given a conjunction UTC ms, return the midnight UTC ms that starts the
|
||||
// new FCNA Hijri month: D+1 if conjunction before 12:00 UTC, D+2 otherwise.
|
||||
function fcnaCriterionMs(conjMs: number): number {
|
||||
const midnight = Math.floor(conjMs / MS_PER_DAY) * MS_PER_DAY;
|
||||
const noon = midnight + 12 * 3_600_000;
|
||||
return conjMs < noon ? midnight + MS_PER_DAY : midnight + 2 * MS_PER_DAY;
|
||||
}
|
||||
|
||||
// ─── UAQ anchor ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Return the UTC ms of the UAQ month start for (hy, hm).
|
||||
// For years 1318–1500 H: binary-search hDatesTable, sum dpm day counts.
|
||||
// For years outside that range: estimate from Islamic epoch + mean month count.
|
||||
export function uaqAnchorMs(hy: number, hm: number): number {
|
||||
// Binary search for hy in table.
|
||||
let lo = 0, hi = hDatesTable.length - 1, found = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const midHy = hDatesTable[mid].hy;
|
||||
if (midHy === hy) { found = mid; break; }
|
||||
else if (midHy < hy) lo = mid + 1;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
|
||||
if (found !== -1 && hDatesTable[found].dpm !== 0) {
|
||||
// In-range: sum prior-month day counts from table start date.
|
||||
const r = hDatesTable[found];
|
||||
let days = 0;
|
||||
for (let i = 0; i < hm - 1; i++) {
|
||||
days += (r.dpm >> i) & 1 ? 30 : 29;
|
||||
}
|
||||
return Date.UTC(r.gy, r.gm - 1, r.gd) + days * MS_PER_DAY;
|
||||
}
|
||||
|
||||
// Out of range: estimate from Islamic epoch + mean months elapsed.
|
||||
const monthsFromEpoch = (hy - 1) * 12 + (hm - 1);
|
||||
const kApprox = K_EPOCH + monthsFromEpoch;
|
||||
return jdeToUtcMs(newMoonJDE(kApprox));
|
||||
}
|
||||
|
||||
// ─── FCNA month start ─────────────────────────────────────────────────────────
|
||||
|
||||
// Return UTC ms of midnight beginning the given FCNA Hijri month.
|
||||
export function fcnaMonthStartMs(hy: number, hm: number): number {
|
||||
const anchor = uaqAnchorMs(hy, hm);
|
||||
const conjMs = nearestNewMoonMs(anchor);
|
||||
return fcnaCriterionMs(conjMs);
|
||||
}
|
||||
|
||||
// ─── FCNA month length ───────────────────────────────────────────────────────
|
||||
|
||||
export function fcnaDaysInMonth(hy: number, hm: number): number {
|
||||
const thisStart = fcnaMonthStartMs(hy, hm);
|
||||
const nextHy = hm < 12 ? hy : hy + 1;
|
||||
const nextHm = hm < 12 ? hm + 1 : 1;
|
||||
const nextStart = fcnaMonthStartMs(nextHy, nextHm);
|
||||
return Math.round((nextStart - thisStart) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
// ─── FCNA Gregorian → Hijri ──────────────────────────────────────────────────
|
||||
|
||||
export function fcnaToHijri(gregorianDate: Date): HijriDate | null {
|
||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||
throw new Error('Invalid Gregorian date');
|
||||
}
|
||||
|
||||
// Use UTC date components. The FCNA criterion is UTC-based (conjunction before
|
||||
// 12:00 UTC), so month boundaries are defined in UTC. Using UTC methods ensures
|
||||
// fcnaToGregorian ↔ fcnaToHijri round-trips correctly in any host timezone.
|
||||
const inputMs = Date.UTC(
|
||||
gregorianDate.getUTCFullYear(),
|
||||
gregorianDate.getUTCMonth(),
|
||||
gregorianDate.getUTCDate(),
|
||||
);
|
||||
|
||||
// Shift back ~15 days before estimating k so that kApprox resolves to the
|
||||
// current month's conjunction rather than possibly the next month's.
|
||||
const kApprox = utcMsToKApprox(inputMs - 15 * MS_PER_DAY);
|
||||
const k0 = Math.floor(kApprox);
|
||||
|
||||
// Search k0-1, k0, k0+1 for the FCNA month containing inputMs.
|
||||
for (let ki = k0 - 1; ki <= k0 + 1; ki++) {
|
||||
const conjMs = jdeToUtcMs(newMoonJDE(ki));
|
||||
const monthStart = fcnaCriterionMs(conjMs);
|
||||
|
||||
if (monthStart > inputMs) continue;
|
||||
|
||||
const nextConjMs = jdeToUtcMs(newMoonJDE(ki + 1));
|
||||
const nextMonthStart = fcnaCriterionMs(nextConjMs);
|
||||
|
||||
if (inputMs < nextMonthStart) {
|
||||
// inputMs falls in the month that began at monthStart (k = ki).
|
||||
// Map ki → Hijri (hy, hm) via K_EPOCH offset.
|
||||
const monthsFromEpoch = ki - K_EPOCH;
|
||||
let hy = Math.floor(monthsFromEpoch / 12) + 1;
|
||||
let hm = (monthsFromEpoch % 12) + 1;
|
||||
// JavaScript % can return negative; normalize to 1–12.
|
||||
if (hm <= 0) { hm += 12; hy--; }
|
||||
if (hy < 1) return null; // before 1 AH
|
||||
|
||||
const hd = Math.round((inputMs - monthStart) / MS_PER_DAY) + 1;
|
||||
return { hy, hm, hd };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── FCNA Hijri → Gregorian ──────────────────────────────────────────────────
|
||||
|
||||
export function fcnaToGregorian(hy: number, hm: number, hd: number): Date | null {
|
||||
if (hy < 1 || hm < 1 || hm > 12 || hd < 1) return null;
|
||||
const days = fcnaDaysInMonth(hy, hm);
|
||||
if (hd > days) return null;
|
||||
const startMs = fcnaMonthStartMs(hy, hm);
|
||||
return new Date(startMs + (hd - 1) * MS_PER_DAY);
|
||||
}
|
||||
|
||||
// ─── FCNA validation ─────────────────────────────────────────────────────────
|
||||
|
||||
export function fcnaIsValid(hy: number, hm: number, hd: number): boolean {
|
||||
if (hy < 1 || hm < 1 || hm > 12 || hd < 1) return false;
|
||||
return hd <= fcnaDaysInMonth(hy, hm);
|
||||
}
|
||||
|
|
@ -2,60 +2,61 @@
|
|||
import { DateTime } from 'luxon';
|
||||
import { hmLong, hmMedium, hmShort } from './hMonths';
|
||||
import { hwLong, hwShort, hwNumeric } from './hWeekdays';
|
||||
import { toGregorian } from './toGregorian';
|
||||
import type { HijriDate } from './types';
|
||||
|
||||
/**
|
||||
* Formats a Hijri date according to the given format string
|
||||
* @param {Date} hijriDate - The Hijri date to format
|
||||
* @param {string} format - The format string
|
||||
* @returns {string} - The formatted date string
|
||||
*/
|
||||
export function formatHijriDate(hijriDate: { hy: number; hm: number; hd: number }, format: string): string {
|
||||
// Replace each pattern in the format string with the corresponding value
|
||||
return format.replace(/\biYYYY\b|\biYY\b|\biMM\b|\biM\b|\biMMM\b|\biMMMM\b|\biDD\b|\biD\b|\biE\b|\biEEE\b|\biEEEE\b|\b[HHhmsaiozZ]+\b/g, (match) => {
|
||||
switch (match) {
|
||||
case 'iYYYY':
|
||||
return String(hijriDate.hy).padStart(4, '0');
|
||||
case 'iYY':
|
||||
return String(hijriDate.hy % 100).padStart(2, '0');
|
||||
case 'iMM':
|
||||
return String(hijriDate.hm).padStart(2, '0');
|
||||
case 'iM':
|
||||
return String(hijriDate.hm);
|
||||
case 'iMMM':
|
||||
return hmMedium[hijriDate.hm - 1];
|
||||
case 'iMMMM':
|
||||
return hmLong[hijriDate.hm - 1];
|
||||
case 'iDD':
|
||||
return String(hijriDate.hd).padStart(2, '0');
|
||||
case 'iD':
|
||||
return String(hijriDate.hd);
|
||||
case 'iE':
|
||||
return String(hwNumeric[DateTime.fromObject({ year: hijriDate.hy, month: hijriDate.hm, day: hijriDate.hd }).weekday - 1]);
|
||||
case 'iEEE':
|
||||
return hwShort[DateTime.fromObject({ year: hijriDate.hy, month: hijriDate.hm, day: hijriDate.hd }).weekday - 1];
|
||||
case 'iEEEE':
|
||||
return hwLong[DateTime.fromObject({ year: hijriDate.hy, month: hijriDate.hm, day: hijriDate.hd }).weekday - 1];
|
||||
|
||||
// The following patterns are the same for both Gregorian and Hijri
|
||||
case 'HH':
|
||||
case 'H':
|
||||
case 'hh':
|
||||
case 'h':
|
||||
case 'mm':
|
||||
case 'm':
|
||||
case 'ss':
|
||||
case 's':
|
||||
case 'a':
|
||||
case 'iooo':
|
||||
case 'ioooo':
|
||||
case 'z':
|
||||
case 'zz':
|
||||
case 'Z':
|
||||
// Use Luxon's DateTime formatting for these patterns
|
||||
const gregorianDate = DateTime.fromObject({ year: hijriDate.hy, month: hijriDate.hm, day: hijriDate.hd });
|
||||
return gregorianDate.toFormat(match);
|
||||
default:
|
||||
return match;
|
||||
}
|
||||
});
|
||||
// Token regex: longest tokens first to prevent partial matches.
|
||||
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;
|
||||
|
||||
export function formatHijriDate(hijriDate: HijriDate, format: string): string {
|
||||
// Lazy Gregorian DateTime — computed at most once per format call,
|
||||
// only when a token that needs it is encountered.
|
||||
let _gregDt: DateTime | undefined;
|
||||
|
||||
function getGregDt(): DateTime {
|
||||
if (!_gregDt) {
|
||||
const greg = toGregorian(hijriDate.hy, hijriDate.hm, hijriDate.hd);
|
||||
// toGregorian throws for invalid input, so greg is non-null here.
|
||||
_gregDt = DateTime.fromJSDate(greg as Date, { zone: 'UTC' });
|
||||
}
|
||||
return _gregDt;
|
||||
}
|
||||
|
||||
return format.replace(TOKEN_RE, (match) => {
|
||||
switch (match) {
|
||||
case 'iYYYY':
|
||||
return String(hijriDate.hy).padStart(4, '0');
|
||||
case 'iYY':
|
||||
return String(hijriDate.hy % 100).padStart(2, '0');
|
||||
case 'iMM':
|
||||
return String(hijriDate.hm).padStart(2, '0');
|
||||
case 'iM':
|
||||
return String(hijriDate.hm);
|
||||
case 'iMMM':
|
||||
return hmMedium[hijriDate.hm - 1];
|
||||
case 'iMMMM':
|
||||
return hmLong[hijriDate.hm - 1];
|
||||
case 'iDD':
|
||||
return String(hijriDate.hd).padStart(2, '0');
|
||||
case 'iD':
|
||||
return String(hijriDate.hd);
|
||||
case 'iE':
|
||||
case 'iEEE':
|
||||
case 'iEEEE': {
|
||||
// Luxon weekday: 1=Mon … 7=Sun. Modulo 7: Mon=1 … Sat=6, Sun=0.
|
||||
// hwLong/hwShort/hwNumeric arrays: index 0=Sunday, 1=Monday, … 6=Saturday.
|
||||
const idx = getGregDt().weekday % 7;
|
||||
if (match === 'iE') return String(hwNumeric[idx]);
|
||||
if (match === 'iEEE') return hwShort[idx];
|
||||
return hwLong[idx];
|
||||
}
|
||||
case 'iooo':
|
||||
case 'ioooo':
|
||||
return 'AH';
|
||||
default:
|
||||
// Delegate time and timezone tokens to Luxon using the Gregorian DateTime.
|
||||
return getGregDt().toFormat(match);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
// hDates.ts
|
||||
// Hijri Dates Reference Table
|
||||
export interface hDates {
|
||||
hy: number; // Hijri Year
|
||||
dpm: number; // Days Per Month
|
||||
gy: number; // Gregorian Year
|
||||
gm: number; // Gregorian Month
|
||||
gd: number; // Gregorian Day
|
||||
}
|
||||
import { HijriYearRecord } from './types';
|
||||
|
||||
export const hDatesTable: hDates[] = [
|
||||
export type { HijriYearRecord };
|
||||
|
||||
export const hDatesTable: HijriYearRecord[] = [
|
||||
{ hy: 1318, dpm: 0x02EA, gy: 1900, gm: 4, gd: 30 },
|
||||
{ hy: 1319, dpm: 0x06E9, gy: 1901, gm: 4, gd: 19 },
|
||||
{ hy: 1320, dpm: 0x0ED2, gy: 1902, gm: 4, gd: 9 },
|
||||
|
|
|
|||
11
src/index.d.ts
vendored
11
src/index.d.ts
vendored
|
|
@ -1,11 +0,0 @@
|
|||
// Define interfaces and types if necessary
|
||||
interface HijriDate {
|
||||
hy: number; // Hijri Year
|
||||
hm: number; // Hijri Month
|
||||
hd: number; // Hijri Day
|
||||
}
|
||||
|
||||
// Export functions with their signatures
|
||||
export function toGregorian(hy: number, hm: number, hd: number): Date | null;
|
||||
export function toHijri(gregorianDate: Date): HijriDate | null;
|
||||
export function formatHijriDate(hijriDate: HijriDate, format: string): string;
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
// index.ts
|
||||
export { formatPatterns } from './formatPatterns';
|
||||
export { hDatesTable, hDates } from './hDates';
|
||||
export { hDatesTable } from './hDates';
|
||||
export type { HijriYearRecord } from './hDates';
|
||||
export { hmLong, hmMedium, hmShort } from './hMonths';
|
||||
export { hwLong, hwShort, hwNumeric } from './hWeekdays';
|
||||
export { toGregorian } from './toGregorian';
|
||||
export { toHijri } from './toHijri';
|
||||
export { formatHijriDate } from './formatHijriDate';
|
||||
export { isValidHijriDate } from './utils';
|
||||
export type { HijriDate, CalendarSystem, ConversionOptions } from './types';
|
||||
|
|
|
|||
|
|
@ -1,28 +1,50 @@
|
|||
// toGregorian.ts
|
||||
import { DateTime } from 'luxon';
|
||||
import { hDatesTable, hDates } from './hDates';
|
||||
import { hDatesTable } from './hDates';
|
||||
import { fcnaToGregorian } from './fcna';
|
||||
import { isValidHijriDate } from './utils';
|
||||
import type { ConversionOptions } from './types';
|
||||
|
||||
export function toGregorian(hy: number, hm: number, hd: number): Date | null {
|
||||
// Validate the input Hijri date
|
||||
if (!isValidHijriDate(hy, hm, hd)) {
|
||||
throw new Error('Invalid Hijri date');
|
||||
export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date | null {
|
||||
if (options?.calendar === 'fcna') {
|
||||
const result = fcnaToGregorian(hy, hm, hd);
|
||||
if (result === null) throw new Error('Invalid Hijri date');
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!isValidHijriDate(hy, hm, hd)) {
|
||||
throw new Error('Invalid Hijri date');
|
||||
}
|
||||
|
||||
// Binary search on hy (table is sorted ascending by Hijri year).
|
||||
let lo = 0;
|
||||
let hi = hDatesTable.length - 1;
|
||||
let found = -1;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const midHy = hDatesTable[mid].hy;
|
||||
|
||||
if (midHy === hy) {
|
||||
found = mid;
|
||||
break;
|
||||
} else if (midHy < hy) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const hijriYearRecord = hDatesTable.find(record => record.hy === hy);
|
||||
if (found === -1) return null;
|
||||
|
||||
if (hijriYearRecord) {
|
||||
let totalDaysTillMonthStart = 0;
|
||||
for (let i = 0; i < hm - 1; i++) {
|
||||
totalDaysTillMonthStart += (hijriYearRecord.dpm >> i) & 1 ? 30 : 29;
|
||||
}
|
||||
const record = hDatesTable[found];
|
||||
let totalDays = 0;
|
||||
|
||||
const totalDays = totalDaysTillMonthStart + hd - 1;
|
||||
const startDate = DateTime.local(hijriYearRecord.gy, hijriYearRecord.gm, hijriYearRecord.gd);
|
||||
const gregorianDate = startDate.plus({ days: totalDays });
|
||||
for (let i = 0; i < hm - 1; i++) {
|
||||
totalDays += (record.dpm >> i) & 1 ? 30 : 29;
|
||||
}
|
||||
totalDays += hd - 1;
|
||||
|
||||
return gregorianDate.toJSDate();
|
||||
}
|
||||
|
||||
return null;
|
||||
const startDate = DateTime.utc(record.gy, record.gm, record.gd);
|
||||
return startDate.plus({ days: totalDays }).toJSDate();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,62 @@
|
|||
// toHijri.ts
|
||||
import { DateTime } from 'luxon';
|
||||
import { hDatesTable, hDates } from './hDates';
|
||||
import { hDatesTable } from './hDates';
|
||||
import { fcnaToHijri } from './fcna';
|
||||
import type { HijriDate, HijriYearRecord, ConversionOptions } from './types';
|
||||
|
||||
export function toHijri(gregorianDate: Date): { hy: number, hm: number, hd: number } | null {
|
||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||
throw new Error('Invalid Gregorian date');
|
||||
export function toHijri(gregorianDate: Date, options?: ConversionOptions): HijriDate | null {
|
||||
if (options?.calendar === 'fcna') {
|
||||
return fcnaToHijri(gregorianDate);
|
||||
}
|
||||
|
||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||
throw new Error('Invalid Gregorian date');
|
||||
}
|
||||
|
||||
// Normalize input to UTC midnight so comparisons are timezone-independent.
|
||||
const inputUtc = Date.UTC(
|
||||
gregorianDate.getFullYear(),
|
||||
gregorianDate.getMonth(),
|
||||
gregorianDate.getDate(),
|
||||
);
|
||||
|
||||
// Binary search: find the last table entry whose Gregorian date <= input.
|
||||
// Table is sorted ascending by (gy, gm, gd).
|
||||
let lo = 0;
|
||||
let hi = hDatesTable.length - 1;
|
||||
let found = -1;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const entry = hDatesTable[mid];
|
||||
const entryUtc = Date.UTC(entry.gy, entry.gm - 1, entry.gd);
|
||||
|
||||
if (entryUtc <= inputUtc) {
|
||||
found = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const inputDate = DateTime.fromJSDate(gregorianDate).startOf('day');
|
||||
// dpm === 0 means sentinel entry (marks end-of-table boundary, not a real year).
|
||||
if (found === -1 || hDatesTable[found].dpm === 0) return null;
|
||||
|
||||
const closestDate = hDatesTable.reduce((prev: Date, curr: hDates) => {
|
||||
const currDate = DateTime.local(curr.gy, curr.gm, curr.gd).startOf('day');
|
||||
if (currDate <= inputDate && currDate > DateTime.fromJSDate(prev)) {
|
||||
return currDate.toJSDate();
|
||||
}
|
||||
return prev;
|
||||
}, new Date(0));
|
||||
const record: HijriYearRecord = hDatesTable[found];
|
||||
const startUtc = Date.UTC(record.gy, record.gm - 1, record.gd);
|
||||
let remainingDays = Math.round((inputUtc - startUtc) / 86_400_000);
|
||||
let hijriMonth = 0;
|
||||
|
||||
const correspondingHijriYear = hDatesTable.find((date: hDates) => {
|
||||
const dt = DateTime.local(date.gy, date.gm, date.gd).startOf('day');
|
||||
return dt.toJSDate().getTime() === closestDate.getTime();
|
||||
});
|
||||
|
||||
if (correspondingHijriYear) {
|
||||
const differenceInDays = inputDate.diff(DateTime.fromJSDate(closestDate).startOf('day'), 'days').days;
|
||||
let hijriYear = correspondingHijriYear.hy;
|
||||
let hijriMonth = 0;
|
||||
let remainingDays = Math.round(differenceInDays);
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const daysInThisMonth = (correspondingHijriYear.dpm >> i) & 1 ? 30 : 29;
|
||||
if (remainingDays < daysInThisMonth) {
|
||||
hijriMonth = i + 1;
|
||||
break;
|
||||
}
|
||||
remainingDays -= daysInThisMonth;
|
||||
}
|
||||
|
||||
return { hy: hijriYear, hm: hijriMonth, hd: remainingDays + 1 };
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const daysInThisMonth = (record.dpm >> i) & 1 ? 30 : 29;
|
||||
if (remainingDays < daysInThisMonth) {
|
||||
hijriMonth = i + 1;
|
||||
break;
|
||||
}
|
||||
remainingDays -= daysInThisMonth;
|
||||
}
|
||||
|
||||
return null;
|
||||
// hijriMonth remains 0 if the date falls beyond the last table entry's year.
|
||||
if (hijriMonth === 0) return null;
|
||||
|
||||
return { hy: record.hy, hm: hijriMonth, hd: remainingDays + 1 };
|
||||
}
|
||||
|
|
|
|||
23
src/types.ts
Normal file
23
src/types.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// types.ts
|
||||
export interface HijriDate {
|
||||
hy: number; // Hijri year
|
||||
hm: number; // Hijri month (1–12)
|
||||
hd: number; // Hijri day (1–30)
|
||||
}
|
||||
|
||||
export interface HijriYearRecord {
|
||||
hy: number; // Hijri year
|
||||
dpm: number; // days-per-month bitmask (bit 0 = month 1: 1→30 days, 0→29 days)
|
||||
gy: number; // Gregorian year of 1 Muharram
|
||||
gm: number; // Gregorian month of 1 Muharram
|
||||
gd: number; // Gregorian day of 1 Muharram
|
||||
}
|
||||
|
||||
// Calendar system selector.
|
||||
// 'uaq' — Umm al-Qura (default): table-based, covers 1318–1500 H.
|
||||
// 'fcna' — FCNA/ISNA: astronomical calculation, works for all Hijri years.
|
||||
export type CalendarSystem = 'uaq' | 'fcna';
|
||||
|
||||
export interface ConversionOptions {
|
||||
calendar?: CalendarSystem;
|
||||
}
|
||||
37
src/utils.ts
37
src/utils.ts
|
|
@ -1,12 +1,37 @@
|
|||
// utils.ts
|
||||
import { hDatesTable } from './hDates';
|
||||
import { fcnaIsValid } from './fcna';
|
||||
import type { ConversionOptions } from './types';
|
||||
|
||||
export function isValidHijriDate(hy: number, hm: number, hd: number): boolean {
|
||||
const yearRecord = hDatesTable.find(record => record.hy === hy);
|
||||
if (!yearRecord) {
|
||||
return false;
|
||||
export function isValidHijriDate(hy: number, hm: number, hd: number, options?: ConversionOptions): boolean {
|
||||
if (options?.calendar === 'fcna') {
|
||||
return fcnaIsValid(hy, hm, hd);
|
||||
}
|
||||
|
||||
// Binary search on hy.
|
||||
let lo = 0;
|
||||
let hi = hDatesTable.length - 1;
|
||||
let found = -1;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const midHy = hDatesTable[mid].hy;
|
||||
|
||||
if (midHy === hy) {
|
||||
found = mid;
|
||||
break;
|
||||
} else if (midHy < hy) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const daysInMonth = (yearRecord.dpm >> (hm - 1)) & 1 ? 30 : 29;
|
||||
return hm >= 1 && hm <= 12 && hd >= 1 && hd <= daysInMonth;
|
||||
// dpm === 0 means sentinel entry (marks end-of-table boundary, not a real year).
|
||||
if (found === -1 || hDatesTable[found].dpm === 0) return false;
|
||||
|
||||
const record = hDatesTable[found];
|
||||
if (hm < 1 || hm > 12 || hd < 1) return false;
|
||||
const daysInMonth = (record.dpm >> (hm - 1)) & 1 ? 30 : 29;
|
||||
return hd <= daysInMonth;
|
||||
}
|
||||
|
|
|
|||
134
test-cjs.cjs
Normal file
134
test-cjs.cjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// test-cjs.cjs — CJS test suite for luxon-hijri
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
toHijri,
|
||||
toGregorian,
|
||||
isValidHijriDate,
|
||||
formatHijriDate,
|
||||
hDatesTable,
|
||||
hwLong,
|
||||
hwShort,
|
||||
hwNumeric,
|
||||
} = require('./dist/index.cjs');
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nCJS exports');
|
||||
|
||||
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nCJS core conversions');
|
||||
|
||||
test('toGregorian(1444, 1, 1) = 2022-07-30', () => {
|
||||
const d = toGregorian(1444, 1, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
|
||||
});
|
||||
|
||||
test('toGregorian(1444, 9, 1) = 2023-03-23', () => {
|
||||
const d = toGregorian(1444, 9, 1);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
|
||||
});
|
||||
|
||||
test('toHijri(2022-07-30) = 1 Muharram 1444', () => {
|
||||
const h = toHijri(new Date(2022, 6, 30, 12));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
|
||||
});
|
||||
|
||||
test('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nCJS validation');
|
||||
|
||||
test('isValidHijriDate(1444, 9, 1) = true', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
|
||||
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 };
|
||||
|
||||
test('iYYYY-iMM-iDD = 1444-09-01', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
|
||||
});
|
||||
|
||||
test('iMMMM = Ramadan', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
|
||||
});
|
||||
|
||||
test('iEEEE = Yawm al-Khamis (Thursday)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
|
||||
});
|
||||
|
||||
test('iooo = AH', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
|
||||
});
|
||||
|
||||
// ─── FCNA calendar ───────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nCJS FCNA calendar');
|
||||
|
||||
const FCNA = { calendar: 'fcna' };
|
||||
|
||||
test('FCNA: 1 Ramadan 1446 = 2025-03-01', () => {
|
||||
const d = toGregorian(1446, 9, 1, FCNA);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
|
||||
});
|
||||
|
||||
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
|
||||
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
|
||||
});
|
||||
|
||||
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH)', () => {
|
||||
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
|
||||
});
|
||||
|
||||
test('FCNA: round-trip 1446/9/1', () => {
|
||||
const greg = toGregorian(1446, 9, 1, FCNA);
|
||||
const hijri = toHijri(greg, FCNA);
|
||||
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);
|
||||
399
test.mjs
Normal file
399
test.mjs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
// test.mjs — ESM test suite for luxon-hijri
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
toHijri,
|
||||
toGregorian,
|
||||
isValidHijriDate,
|
||||
formatHijriDate,
|
||||
formatPatterns,
|
||||
hDatesTable,
|
||||
hmLong,
|
||||
hmMedium,
|
||||
hmShort,
|
||||
hwLong,
|
||||
hwShort,
|
||||
hwNumeric,
|
||||
} from './dist/index.mjs';
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nExports');
|
||||
|
||||
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
|
||||
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('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
|
||||
test('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
|
||||
// 183 real year entries (1318–1500) + 1 sentinel entry (1501) marking the table boundary.
|
||||
test('hDatesTable has 184 entries (1318–1500 + sentinel 1501)', () => assert.strictEqual(hDatesTable.length, 184));
|
||||
test('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
|
||||
test('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
|
||||
test('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
|
||||
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));
|
||||
|
||||
// ─── toGregorian ────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\ntoGregorian — known dates');
|
||||
|
||||
test('1 Muharram 1444 = 2022-07-30', () => {
|
||||
const d = toGregorian(1444, 1, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
|
||||
});
|
||||
|
||||
test('1 Ramadan 1444 = 2023-03-23', () => {
|
||||
const d = toGregorian(1444, 9, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
|
||||
});
|
||||
|
||||
test('1 Shawwal 1444 = 2023-04-21', () => {
|
||||
const d = toGregorian(1444, 10, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21');
|
||||
});
|
||||
|
||||
test('1 Muharram 1446 = 2024-07-07', () => {
|
||||
const d = toGregorian(1446, 1, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07');
|
||||
});
|
||||
|
||||
test('first table entry: 1 Muharram 1318 = 1900-04-30', () => {
|
||||
const d = toGregorian(1318, 1, 1);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30');
|
||||
});
|
||||
|
||||
console.log('\ntoGregorian — error cases');
|
||||
|
||||
test('throws on invalid Hijri year (out of table range)', () => {
|
||||
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
|
||||
});
|
||||
|
||||
test('throws on month 0', () => {
|
||||
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
|
||||
});
|
||||
|
||||
test('throws on month 13', () => {
|
||||
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
|
||||
});
|
||||
|
||||
test('throws on day 0', () => {
|
||||
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
// ─── toHijri ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\ntoHijri — known dates');
|
||||
|
||||
// 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));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
|
||||
});
|
||||
|
||||
test('2023-03-23 = 1 Ramadan 1444', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
test('2023-04-21 = 1 Shawwal 1444', () => {
|
||||
const h = toHijri(new Date(2023, 3, 21, 12));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
|
||||
});
|
||||
|
||||
test('2024-07-07 = 1 Muharram 1446', () => {
|
||||
const h = toHijri(new Date(2024, 6, 7, 12));
|
||||
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
|
||||
});
|
||||
|
||||
test('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
|
||||
const h = toHijri(new Date(1900, 3, 30, 12));
|
||||
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
|
||||
});
|
||||
|
||||
console.log('\ntoHijri — error cases');
|
||||
|
||||
test('throws on invalid Date', () => {
|
||||
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
|
||||
});
|
||||
|
||||
test('returns null for date before first table entry', () => {
|
||||
const h = toHijri(new Date(1800, 0, 1, 12));
|
||||
assert.strictEqual(h, null);
|
||||
});
|
||||
|
||||
// ─── isValidHijriDate ───────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nisValidHijriDate');
|
||||
|
||||
test('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
|
||||
test('1444-09-29 is valid (last day of Ramadan 1444)', () => assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
|
||||
test('1318-01-01 is valid (first table entry)', () => assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
|
||||
test('1500-12-29 is valid (last table entry, last day)', () => assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
|
||||
test('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
|
||||
test('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
|
||||
test('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
|
||||
test('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
|
||||
test('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
|
||||
test('day 30 in Ramadan 1444 (29-day month) is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
|
||||
|
||||
// ─── formatHijriDate ────────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nformatHijriDate — date tokens');
|
||||
|
||||
const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
|
||||
|
||||
test('iYYYY-iMM-iDD', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
|
||||
});
|
||||
|
||||
test('iYY (last 2 digits of year)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
|
||||
});
|
||||
|
||||
test('iM (month without padding)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
|
||||
});
|
||||
|
||||
test('iMM (month zero-padded)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
|
||||
});
|
||||
|
||||
test('iMMM (medium month name: Ramadan)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
|
||||
});
|
||||
|
||||
test('iMMMM (full month name: Ramadan)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
|
||||
});
|
||||
|
||||
test('iD (day without padding)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
|
||||
});
|
||||
|
||||
test('iDD (day zero-padded)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
|
||||
});
|
||||
|
||||
console.log('\nformatHijriDate — weekday tokens (1 Ramadan 1444 = Thursday)');
|
||||
|
||||
test('iE → 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
|
||||
});
|
||||
|
||||
test('iEEE → Kham (Thursday abbreviated)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
|
||||
});
|
||||
|
||||
test('iEEEE → Yawm al-Khamis (Thursday full)', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
|
||||
});
|
||||
|
||||
console.log('\nformatHijriDate — era tokens');
|
||||
|
||||
test('iooo → AH', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
|
||||
});
|
||||
|
||||
test('ioooo → AH', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
|
||||
});
|
||||
|
||||
console.log('\nformatHijriDate — composite format');
|
||||
|
||||
test('iMMMM iD, iYYYY', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
|
||||
});
|
||||
|
||||
test('iDD/iMM/iYYYY', () => {
|
||||
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
|
||||
});
|
||||
|
||||
test('iEEEE, iD iMMMM iYYYY ioooo', () => {
|
||||
assert.strictEqual(
|
||||
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
|
||||
'Yawm al-Khamis, 1 Ramadan 1444 AH',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── hDatesTable structure ──────────────────────────────────────────────────
|
||||
|
||||
console.log('\nhDatesTable structure');
|
||||
|
||||
test('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
|
||||
test('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
|
||||
test('index 183 is sentinel year 1501 with dpm=0', () => {
|
||||
assert.strictEqual(hDatesTable[183].hy, 1501);
|
||||
assert.strictEqual(hDatesTable[183].dpm, 0);
|
||||
});
|
||||
test('table is sorted ascending by hy', () => {
|
||||
for (let i = 1; i < hDatesTable.length; i++) {
|
||||
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── FCNA calendar — toGregorian ────────────────────────────────────────────
|
||||
//
|
||||
// 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');
|
||||
|
||||
test('FCNA: 1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
|
||||
const d = toGregorian(1446, 9, 1, FCNA);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
|
||||
});
|
||||
|
||||
test('FCNA: 1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
|
||||
const d = toGregorian(1446, 10, 1, FCNA);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30');
|
||||
});
|
||||
|
||||
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);
|
||||
assert(d instanceof Date);
|
||||
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
assert(iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31',
|
||||
`Expected ~2022-07-30, got ${iso}`);
|
||||
});
|
||||
|
||||
console.log('\nFCNA — toHijri known dates');
|
||||
|
||||
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
test('FCNA: 2025-03-30 = 1 Shawwal 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
|
||||
});
|
||||
|
||||
test('FCNA: 2024-03-11 = 1 Ramadan 1445', () => {
|
||||
const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
|
||||
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
console.log('\nFCNA — round-trip consistency');
|
||||
|
||||
test('FCNA round-trip: toGregorian → toHijri for 1446/9/1', () => {
|
||||
const greg = toGregorian(1446, 9, 1, FCNA);
|
||||
const hijri = toHijri(greg, FCNA);
|
||||
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
test('FCNA round-trip: toGregorian → toHijri for 1446/10/15', () => {
|
||||
const greg = toGregorian(1446, 10, 15, FCNA);
|
||||
const hijri = toHijri(greg, FCNA);
|
||||
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
|
||||
});
|
||||
|
||||
test('FCNA round-trip: toGregorian → toHijri for 1318/1/1', () => {
|
||||
const greg = toGregorian(1318, 1, 1, FCNA);
|
||||
assert(greg instanceof Date);
|
||||
const hijri = toHijri(greg, FCNA);
|
||||
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
|
||||
});
|
||||
|
||||
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);
|
||||
assert(greg instanceof Date);
|
||||
const hijri = toHijri(greg, FCNA);
|
||||
assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 });
|
||||
});
|
||||
|
||||
console.log('\nFCNA — isValidHijriDate');
|
||||
|
||||
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
|
||||
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
|
||||
});
|
||||
|
||||
test('FCNA: isValidHijriDate(1446, 0, 1) = false (month 0)', () => {
|
||||
assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false);
|
||||
});
|
||||
|
||||
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 1318–1500.
|
||||
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');
|
||||
});
|
||||
|
||||
test('UAQ default: toHijri still works without options', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
test('UAQ default: isValidHijriDate still works without options', () => {
|
||||
assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
|
||||
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);
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"noImplicitAny": true,
|
||||
"module": "commonjs",
|
||||
"target": "es6",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
|
|||
17
tsup.config.ts
Normal file
17
tsup.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
outDir: 'dist',
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
platform: 'node',
|
||||
external: ['luxon'],
|
||||
outExtension({ format }) {
|
||||
return { js: format === 'cjs' ? '.cjs' : '.mjs' };
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue