mirror of
https://github.com/acamarata/hijri-core.git
synced 2026-07-02 19:50:40 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c762bd694 | ||
|
|
9367a45ac8 | ||
|
|
d9ce016e4b | ||
|
|
fb803e4068 | ||
|
|
34193780f3 | ||
|
|
6caa9eed2c |
17 changed files with 907 additions and 95 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,7 +5,15 @@ 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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.0.4] - 2026-06-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Published package now includes dist/index.d.mts so ESM type resolution under node16/nodenext resolves the import condition.
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-06-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- UAQ engine `toHijri` was reading local date components (`getFullYear/getMonth/getDate`) instead of UTC components, causing incorrect results on hosts west of UTC (e.g. `America/New_York`, `Pacific/Auckland`) when the input Date was a UTC-midnight value such as those returned by `toGregorian` or ISO date-only strings. `toHijri` now reads UTC calendar day components (`getUTCFullYear/getUTCMonth/getUTCDate`), matching the FCNA engine. **Behavior change:** on non-UTC hosts the converted Hijri day may shift to the UTC calendar day; round-trips via `toGregorian` are now exact on every machine.
|
||||||
|
|
||||||
## [1.0.2] - 2026-05-30
|
## [1.0.2] - 2026-05-30
|
||||||
|
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -35,6 +35,27 @@ isValidHijriDate(1444, 9, 1); // true
|
||||||
daysInHijriMonth(1444, 9); // 29
|
daysInHijriMonth(1444, 9); // 29
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Day boundaries and time zones
|
||||||
|
|
||||||
|
hijri-core maps civil calendar days one-to-one (tabular UAQ, computed FCNA). The religious Hijri day beginning at sunset is intentionally out of scope.
|
||||||
|
|
||||||
|
`toHijri` reads the input Date's UTC calendar day (`getUTCFullYear`, `getUTCMonth`, `getUTCDate`). `toGregorian` returns a UTC-midnight Date. This means round-trips are exact and results are identical on every machine regardless of local time zone:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Safe on any host — UTC-explicit construction
|
||||||
|
const greg = toGregorian(1446, 9, 1); // 2025-03-01T00:00:00.000Z
|
||||||
|
const back = toHijri(greg!); // { hy: 1446, hm: 9, hd: 1 } — always exact
|
||||||
|
|
||||||
|
// ISO date-only strings parse as UTC midnight — correct
|
||||||
|
toHijri(new Date('2025-03-01')); // { hy: 1446, hm: 9, hd: 1 }
|
||||||
|
|
||||||
|
// For a local wall-clock date, construct explicitly in UTC
|
||||||
|
toHijri(new Date(Date.UTC(2025, 2, 1))); // { hy: 1446, hm: 9, hd: 1 }
|
||||||
|
|
||||||
|
// Avoid local Date constructor for date-only conversions — breaks on UTC+13
|
||||||
|
// toHijri(new Date(2025, 2, 1)) ← do NOT do this
|
||||||
|
```
|
||||||
|
|
||||||
## Custom Calendars
|
## Custom Calendars
|
||||||
|
|
||||||
Implement `CalendarEngine` and call `registerCalendar('my-id', engine)`. Pass `{ calendar: 'my-id' }` to any conversion function.
|
Implement `CalendarEngine` and call `registerCalendar('my-id', engine)`. Pass `{ calendar: 'my-id' }` to any conversion function.
|
||||||
|
|
|
||||||
8
TELEMETRY.md
Normal file
8
TELEMETRY.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Telemetry Disclosure
|
||||||
|
|
||||||
|
This package supports opt-in anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry).
|
||||||
|
|
||||||
|
Telemetry is **off by default**. No data is sent unless you set `ACAMARATA_TELEMETRY=1`.
|
||||||
|
|
||||||
|
Full disclosure (what is sent, where it goes, how to disable):
|
||||||
|
[github.com/acamarata/telemetry/blob/main/TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md)
|
||||||
|
|
@ -5,12 +5,19 @@ import { typescript } from '@acamarata/eslint-config';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
plugins: { '@typescript-eslint': tsPlugin },
|
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'docs/**'],
|
||||||
languageOptions: { parser: tsParser },
|
|
||||||
},
|
},
|
||||||
...typescript,
|
|
||||||
eslintConfigPrettier,
|
|
||||||
{
|
{
|
||||||
ignores: ['dist/', 'node_modules/', '*.cjs', '*.mjs'],
|
files: ['src/**/*.ts'],
|
||||||
|
plugins: { '@typescript-eslint': tsPlugin },
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
...typescript.map((cfg) => ({ ...cfg, files: ['src/**/*.ts'] })),
|
||||||
|
eslintConfigPrettier,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
140
hijri-core.test.ts
Normal file
140
hijri-core.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Purpose: Vitest suite for hijri-core — conversion, validation, registry, and name exports.
|
||||||
|
* Inputs: Pure functions from src/index.ts (no network; module-load registers uaq+fcna engines).
|
||||||
|
* Outputs: Vitest pass/fail assertions.
|
||||||
|
* Constraints: UAQ calendar covers 1318–1500 AH (≈1900–2076 CE). toHijri interprets the input
|
||||||
|
* Date by its UTC calendar day (getUTC* components); pass UTC-explicit Dates for
|
||||||
|
* deterministic results. toGregorian returns UTC midnight, so round-trips are exact.
|
||||||
|
* Usage: pnpm vitest run
|
||||||
|
* SOT: packages.md — hijri-core row
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
toHijri,
|
||||||
|
toGregorian,
|
||||||
|
isValidHijriDate,
|
||||||
|
daysInHijriMonth,
|
||||||
|
listCalendars,
|
||||||
|
hmLong,
|
||||||
|
hwLong,
|
||||||
|
} from "./src/index";
|
||||||
|
|
||||||
|
describe("toGregorian (UAQ default)", () => {
|
||||||
|
it("converts 1 Ramadan 1446 to 2025-03-01 UTC midnight", () => {
|
||||||
|
const result = toGregorian(1446, 9, 1);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toISOString()).toBe("2025-03-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts 1 Muharram 1446 to 2024-07-07 UTC midnight", () => {
|
||||||
|
const result = toGregorian(1446, 1, 1);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toISOString()).toBe("2024-07-07T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an out-of-range Hijri year (1501)", () => {
|
||||||
|
expect(toGregorian(1501, 1, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toHijri (UAQ default)", () => {
|
||||||
|
it("converts 2025-03-01 noon UTC to 1 Ramadan 1446", () => {
|
||||||
|
// toGregorian(1446,9,1) = 2025-03-01 midnight; add 12h to stay on that Gregorian day
|
||||||
|
const noonOnRamadanStart = new Date("2025-03-01T12:00:00Z");
|
||||||
|
const result = toHijri(noonOnRamadanStart);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.hy).toBe(1446);
|
||||||
|
expect(result!.hm).toBe(9);
|
||||||
|
expect(result!.hd).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a date outside UAQ range (year 2100)", () => {
|
||||||
|
expect(toHijri(new Date("2100-01-01T12:00:00Z"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on an invalid Date object", () => {
|
||||||
|
expect(() => toHijri(new Date("invalid"))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidHijriDate", () => {
|
||||||
|
it("accepts a known valid date", () => {
|
||||||
|
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects month 13", () => {
|
||||||
|
expect(isValidHijriDate(1446, 13, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects day 0", () => {
|
||||||
|
expect(isValidHijriDate(1446, 1, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("daysInHijriMonth", () => {
|
||||||
|
it("returns 29 or 30 for a valid month", () => {
|
||||||
|
const days = daysInHijriMonth(1446, 9);
|
||||||
|
expect([29, 30]).toContain(days);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registry", () => {
|
||||||
|
it("lists at least uaq and fcna after module load", () => {
|
||||||
|
const calendars = listCalendars();
|
||||||
|
expect(calendars).toContain("uaq");
|
||||||
|
expect(calendars).toContain("fcna");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("name tables", () => {
|
||||||
|
it("hmLong has 12 entries and index 8 is Ramadan", () => {
|
||||||
|
expect(hmLong).toHaveLength(12);
|
||||||
|
expect(hmLong[8]).toBe("Ramadan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hwLong has 7 weekday entries", () => {
|
||||||
|
expect(hwLong).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("day boundaries (UTC contract)", () => {
|
||||||
|
it("UAQ round-trip: toHijri(toGregorian(1446, 9, 1)) returns {hy:1446, hm:9, hd:1}", () => {
|
||||||
|
const greg = toGregorian(1446, 9, 1);
|
||||||
|
expect(greg).not.toBeNull();
|
||||||
|
const hijri = toHijri(greg!);
|
||||||
|
expect(hijri).not.toBeNull();
|
||||||
|
expect(hijri!.hy).toBe(1446);
|
||||||
|
expect(hijri!.hm).toBe(9);
|
||||||
|
expect(hijri!.hd).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toHijri(new Date('2025-03-01T00:00:00Z')) = 1 Ramadan 1446", () => {
|
||||||
|
const result = toHijri(new Date("2025-03-01T00:00:00Z"));
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.hy).toBe(1446);
|
||||||
|
expect(result!.hm).toBe(9);
|
||||||
|
expect(result!.hd).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("last ms of 2025-02-28 UTC maps to the same Hijri day as noon on 2025-02-28 UTC", () => {
|
||||||
|
const lastMs = toHijri(new Date("2025-02-28T23:59:59.999Z"));
|
||||||
|
const noon = toHijri(new Date("2025-02-28T12:00:00Z"));
|
||||||
|
expect(lastMs).not.toBeNull();
|
||||||
|
expect(noon).not.toBeNull();
|
||||||
|
expect(lastMs!.hy).toBe(noon!.hy);
|
||||||
|
expect(lastMs!.hm).toBe(noon!.hm);
|
||||||
|
expect(lastMs!.hd).toBe(noon!.hd);
|
||||||
|
// and it is the day before 1 Ramadan 1446
|
||||||
|
expect(lastMs!.hm).not.toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FCNA round-trip: toHijri(toGregorian(1446, 9, 1, {calendar:'fcna'}), {calendar:'fcna'}) is exact", () => {
|
||||||
|
const greg = toGregorian(1446, 9, 1, { calendar: "fcna" });
|
||||||
|
expect(greg).not.toBeNull();
|
||||||
|
const hijri = toHijri(greg!, { calendar: "fcna" });
|
||||||
|
expect(hijri).not.toBeNull();
|
||||||
|
expect(hijri!.hy).toBe(1446);
|
||||||
|
expect(hijri!.hm).toBe(9);
|
||||||
|
expect(hijri!.hd).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
package.json
13
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "hijri-core",
|
"name": "hijri-core",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"description": "Zero-dependency Hijri calendar engine with pluggable calendar support. Includes Umm al-Qura (UAQ) and FCNA/ISNA calendars. Extensible registry for custom calendars.",
|
"description": "Zero-dependency Hijri calendar engine with pluggable calendar support. Includes Umm al-Qura (UAQ) and FCNA/ISNA calendars. Extensible registry for custom calendars.",
|
||||||
"author": "Aric Camarata",
|
"author": "Aric Camarata",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -34,13 +34,14 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"pretest": "tsup",
|
"pretest": "tsup",
|
||||||
"test": "node --test test.mjs && node --test test-cjs.cjs",
|
"test": "node --test test.mjs && node --test test-cjs.cjs",
|
||||||
"prepublishOnly": "tsup",
|
"prepack": "pnpm run build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"format:check": "prettier --check src/",
|
"format:check": "prettier --check src/",
|
||||||
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
|
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
|
||||||
"docs": "typedoc --out .github/wiki/api src/index.ts",
|
"docs": "typedoc --out .github/wiki/api src/index.ts",
|
||||||
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
|
"postbuild": "cp dist/index.d.ts dist/index.d.mts",
|
||||||
|
"test:vitest": "vitest run"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"hijri",
|
"hijri",
|
||||||
|
|
@ -57,9 +58,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@acamarata/eslint-config": "^0.1.0",
|
"@acamarata/eslint-config": "^0.1.0",
|
||||||
"@acamarata/prettier-config": "^0.1.0",
|
"@acamarata/prettier-config": "^0.1.0",
|
||||||
|
"@acamarata/telemetry": "^0.1.0",
|
||||||
"@acamarata/tsconfig": "^0.1.0",
|
"@acamarata/tsconfig": "^0.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
"c8": "^10.1.0",
|
"c8": "^10.1.0",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
|
@ -68,7 +72,8 @@
|
||||||
"typedoc": "^0.28.19",
|
"typedoc": "^0.28.19",
|
||||||
"typedoc-plugin-markdown": "^4.11.0",
|
"typedoc-plugin-markdown": "^4.11.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"typescript-eslint": "^8.56.1"
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|
|
||||||
593
pnpm-lock.yaml
593
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
import type { HijriYearRecord } from '../types';
|
import type { HijriYearRecord } from "../types";
|
||||||
|
|
||||||
// Umm al-Qura reference table: Hijri years 1318-1501.
|
// Umm al-Qura reference table: Hijri years 1318-1501.
|
||||||
// Each entry records the 1 Muharram Gregorian date and a 12-bit days-per-month
|
// Each entry records the 1 Muharram Gregorian date and a 12-bit days-per-month
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
// New moon times come from Jean Meeus, Astronomical Algorithms (2nd ed.),
|
// New moon times come from Jean Meeus, Astronomical Algorithms (2nd ed.),
|
||||||
// Chapter 49, accurate to within a few minutes for 1000-3000 CE.
|
// Chapter 49, accurate to within a few minutes for 1000-3000 CE.
|
||||||
|
|
||||||
import { hDatesTable } from '../data/hDates';
|
import { hDatesTable } from "../data/hDates";
|
||||||
import { MS_PER_DAY, MONTHS_PER_YEAR } from '../constants';
|
import { MS_PER_DAY, MONTHS_PER_YEAR } from "../constants";
|
||||||
import type { CalendarEngine, HijriDate } from '../types';
|
import type { CalendarEngine, HijriDate } from "../types";
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -211,7 +211,7 @@ function fcnaDaysInMonth(hy: number, hm: number): number {
|
||||||
|
|
||||||
function fcnaToHijri(gregorianDate: Date): HijriDate | null {
|
function fcnaToHijri(gregorianDate: Date): HijriDate | null {
|
||||||
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
|
||||||
throw new Error('Invalid Gregorian date');
|
throw new Error("Invalid Gregorian date");
|
||||||
}
|
}
|
||||||
|
|
||||||
// FCNA criterion is UTC-based, so UTC date components ensure correct round-trips.
|
// FCNA criterion is UTC-based, so UTC date components ensure correct round-trips.
|
||||||
|
|
@ -271,7 +271,7 @@ function fcnaIsValid(hy: number, hm: number, hd: number): boolean {
|
||||||
// ─── Engine export ────────────────────────────────────────────────────────────
|
// ─── Engine export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const fcnaEngine: CalendarEngine = {
|
export const fcnaEngine: CalendarEngine = {
|
||||||
id: 'fcna',
|
id: "fcna",
|
||||||
toHijri: fcnaToHijri,
|
toHijri: fcnaToHijri,
|
||||||
toGregorian: fcnaToGregorian,
|
toGregorian: fcnaToGregorian,
|
||||||
isValid: fcnaIsValid,
|
isValid: fcnaIsValid,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
// (Gregorian 1900-2076). Each entry records the Gregorian date of 1 Muharram and
|
// (Gregorian 1900-2076). Each entry records the Gregorian date of 1 Muharram and
|
||||||
// a 12-bit days-per-month bitmask. Dates outside that window return null.
|
// a 12-bit days-per-month bitmask. Dates outside that window return null.
|
||||||
|
|
||||||
import { hDatesTable } from '../data/hDates';
|
import { hDatesTable } from "../data/hDates";
|
||||||
import { MS_PER_DAY, MONTHS_PER_YEAR } from '../constants';
|
import { MS_PER_DAY, MONTHS_PER_YEAR } from "../constants";
|
||||||
import type { CalendarEngine, HijriDate, HijriYearRecord } from '../types';
|
import type { CalendarEngine, HijriDate, HijriYearRecord } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary search for a Hijri year entry in the UAQ table.
|
* Binary search for a Hijri year entry in the UAQ table.
|
||||||
|
|
@ -34,14 +34,17 @@ function findYearEntry(hy: number): HijriYearRecord | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// toHijri uses local date components (getFullYear, getMonth, getDate) so that
|
// toHijri interprets the input Date by its UTC calendar day (getUTC* components),
|
||||||
// the calendar-date lookup is timezone-safe regardless of the host environment.
|
// matching the FCNA engine and the UTC-midnight Dates returned by toGregorian.
|
||||||
|
// This makes conversions host-timezone-independent and round-trips exact:
|
||||||
|
// toHijri(toGregorian(hy, hm, hd)) === { hy, hm, hd } on any machine.
|
||||||
|
// To convert a local wall-clock date, pass new Date(Date.UTC(y, m - 1, d)).
|
||||||
function uaqToHijri(date: Date): HijriDate | null {
|
function uaqToHijri(date: Date): HijriDate | null {
|
||||||
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
throw new Error('Invalid Gregorian date');
|
throw new Error("Invalid Gregorian date");
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputUtc = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
|
const inputUtc = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
|
||||||
// Binary search: find the last table entry whose Gregorian start date <= input.
|
// Binary search: find the last table entry whose Gregorian start date <= input.
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
|
|
@ -126,7 +129,7 @@ function uaqDaysInMonth(hy: number, hm: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uaqEngine: CalendarEngine = {
|
export const uaqEngine: CalendarEngine = {
|
||||||
id: 'uaq',
|
id: "uaq",
|
||||||
toHijri: uaqToHijri,
|
toHijri: uaqToHijri,
|
||||||
toGregorian: uaqToGregorian,
|
toGregorian: uaqToGregorian,
|
||||||
isValid: uaqIsValid,
|
isValid: uaqIsValid,
|
||||||
|
|
|
||||||
53
src/index.ts
53
src/index.ts
|
|
@ -1,32 +1,32 @@
|
||||||
// Built-in engines are registered at module load so that 'uaq' and 'fcna' are
|
// Built-in engines are registered at module load so that 'uaq' and 'fcna' are
|
||||||
// available immediately on import. This module-level side effect is intentional
|
// available immediately on import. This module-level side effect is intentional
|
||||||
// and documented in the sideEffects field of package.json.
|
// and documented in the sideEffects field of package.json.
|
||||||
import { uaqEngine } from './engines/uaq';
|
import { uaqEngine } from "./engines/uaq";
|
||||||
import { fcnaEngine } from './engines/fcna';
|
import { fcnaEngine } from "./engines/fcna";
|
||||||
import { registerCalendar } from './registry';
|
import { registerCalendar } from "./registry";
|
||||||
|
|
||||||
registerCalendar('uaq', uaqEngine);
|
registerCalendar("uaq", uaqEngine);
|
||||||
registerCalendar('fcna', fcnaEngine);
|
registerCalendar("fcna", fcnaEngine);
|
||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
export { registerCalendar, getCalendar, listCalendars } from './registry';
|
export { registerCalendar, getCalendar, listCalendars } from "./registry";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export { MS_PER_DAY, MONTHS_PER_YEAR } from './constants';
|
export { MS_PER_DAY, MONTHS_PER_YEAR } from "./constants";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { HijriDate, HijriYearRecord, CalendarEngine, ConversionOptions } from './types';
|
export type { HijriDate, HijriYearRecord, CalendarEngine, ConversionOptions } from "./types";
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
export { hDatesTable } from './data/hDates';
|
export { hDatesTable } from "./data/hDates";
|
||||||
|
|
||||||
// Names
|
// Names
|
||||||
export { hmLong, hmMedium, hmShort } from './names/months';
|
export { hmLong, hmMedium, hmShort } from "./names/months";
|
||||||
export { hwLong, hwShort, hwNumeric } from './names/weekdays';
|
export { hwLong, hwShort, hwNumeric } from "./names/weekdays";
|
||||||
|
|
||||||
// Convenience wrappers
|
// Convenience wrappers
|
||||||
import { getCalendar } from './registry';
|
import { getCalendar } from "./registry";
|
||||||
import type { HijriDate, ConversionOptions } from './types';
|
import type { HijriDate, ConversionOptions } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a Gregorian date to a Hijri date.
|
* Convert a Gregorian date to a Hijri date.
|
||||||
|
|
@ -34,6 +34,14 @@ import type { HijriDate, ConversionOptions } from './types';
|
||||||
* Uses the UAQ (Umm al-Qura) calendar by default. Pass `{ calendar: 'fcna' }`
|
* Uses the UAQ (Umm al-Qura) calendar by default. Pass `{ calendar: 'fcna' }`
|
||||||
* or any registered calendar name via options to use a different engine.
|
* or any registered calendar name via options to use a different engine.
|
||||||
*
|
*
|
||||||
|
* **Time-zone contract:** the Date is interpreted by its UTC calendar day
|
||||||
|
* (`getUTCFullYear`, `getUTCMonth`, `getUTCDate`). `toGregorian` returns a
|
||||||
|
* UTC-midnight Date, so round-trips are exact and results are identical on
|
||||||
|
* every host regardless of its local time zone.
|
||||||
|
*
|
||||||
|
* To convert a local wall-clock date, pass `new Date(Date.UTC(y, m - 1, d))`.
|
||||||
|
* Note that `new Date("2025-03-01")` parses as UTC midnight, which is correct.
|
||||||
|
*
|
||||||
* @param date - a valid JavaScript Date object
|
* @param date - a valid JavaScript Date object
|
||||||
* @param options - conversion options (calendar engine selection)
|
* @param options - conversion options (calendar engine selection)
|
||||||
* @returns the corresponding Hijri date, or null if the date is out of range
|
* @returns the corresponding Hijri date, or null if the date is out of range
|
||||||
|
|
@ -41,9 +49,9 @@ import type { HijriDate, ConversionOptions } from './types';
|
||||||
*/
|
*/
|
||||||
export function toHijri(date: Date, options?: ConversionOptions): HijriDate | null {
|
export function toHijri(date: Date, options?: ConversionOptions): HijriDate | null {
|
||||||
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
throw new Error('Invalid Gregorian date');
|
throw new Error("Invalid Gregorian date");
|
||||||
}
|
}
|
||||||
return getCalendar(options?.calendar ?? 'uaq').toHijri(date);
|
return getCalendar(options?.calendar ?? "uaq").toHijri(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -63,7 +71,7 @@ export function toGregorian(
|
||||||
hd: number,
|
hd: number,
|
||||||
options?: ConversionOptions,
|
options?: ConversionOptions,
|
||||||
): Date | null {
|
): Date | null {
|
||||||
return getCalendar(options?.calendar ?? 'uaq').toGregorian(hy, hm, hd);
|
return getCalendar(options?.calendar ?? "uaq").toGregorian(hy, hm, hd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,7 +89,7 @@ export function isValidHijriDate(
|
||||||
hd: number,
|
hd: number,
|
||||||
options?: ConversionOptions,
|
options?: ConversionOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
return getCalendar(options?.calendar ?? 'uaq').isValid(hy, hm, hd);
|
return getCalendar(options?.calendar ?? "uaq").isValid(hy, hm, hd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -94,5 +102,14 @@ export function isValidHijriDate(
|
||||||
* @throws {RangeError} if the month or year is out of range
|
* @throws {RangeError} if the month or year is out of range
|
||||||
*/
|
*/
|
||||||
export function daysInHijriMonth(hy: number, hm: number, options?: ConversionOptions): number {
|
export function daysInHijriMonth(hy: number, hm: number, options?: ConversionOptions): number {
|
||||||
return getCalendar(options?.calendar ?? 'uaq').daysInMonth(hy, hm);
|
return getCalendar(options?.calendar ?? "uaq").daysInMonth(hy, hm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Opt-in anonymous telemetry ────────────────────────────────────────────────
|
||||||
|
// Off by default. Enable: ACAMARATA_TELEMETRY=1
|
||||||
|
// What is sent + how to disable: https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md
|
||||||
|
import("@acamarata/telemetry")
|
||||||
|
.then(({ track }) => track("load", { package: "hijri-core", version: "1.0.4" }))
|
||||||
|
.catch(() => {
|
||||||
|
// telemetry not installed or disabled — that's fine
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,18 @@
|
||||||
* const month = hmLong[hijriDate.hm - 1]; // "Ramadan"
|
* const month = hmLong[hijriDate.hm - 1]; // "Ramadan"
|
||||||
*/
|
*/
|
||||||
export const hmLong = [
|
export const hmLong = [
|
||||||
'Muharram', // 1
|
"Muharram", // 1
|
||||||
'Safar', // 2
|
"Safar", // 2
|
||||||
"Rabi'l Awwal", // 3
|
"Rabi'l Awwal", // 3
|
||||||
"Rabi'l Thani", // 4
|
"Rabi'l Thani", // 4
|
||||||
'Jumadal Awwal', // 5
|
"Jumadal Awwal", // 5
|
||||||
'Jumadal Thani', // 6
|
"Jumadal Thani", // 6
|
||||||
'Rajab', // 7
|
"Rajab", // 7
|
||||||
"Sha'ban", // 8
|
"Sha'ban", // 8
|
||||||
'Ramadan', // 9
|
"Ramadan", // 9
|
||||||
'Shawwal', // 10
|
"Shawwal", // 10
|
||||||
"Dhul Qi'dah", // 11
|
"Dhul Qi'dah", // 11
|
||||||
'Dhul Hijjah', // 12
|
"Dhul Hijjah", // 12
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,18 +35,18 @@ export const hmLong = [
|
||||||
* const label = hmMedium[hijriDate.hm - 1]; // "Ramadan"
|
* const label = hmMedium[hijriDate.hm - 1]; // "Ramadan"
|
||||||
*/
|
*/
|
||||||
export const hmMedium = [
|
export const hmMedium = [
|
||||||
'Muharram',
|
"Muharram",
|
||||||
'Safar',
|
"Safar",
|
||||||
'Rabi1',
|
"Rabi1",
|
||||||
'Rabi2',
|
"Rabi2",
|
||||||
'Jumada1',
|
"Jumada1",
|
||||||
'Jumada2',
|
"Jumada2",
|
||||||
'Rajab',
|
"Rajab",
|
||||||
'Shaban',
|
"Shaban",
|
||||||
'Ramadan',
|
"Ramadan",
|
||||||
'Shawwal',
|
"Shawwal",
|
||||||
'Dhul-Qidah',
|
"Dhul-Qidah",
|
||||||
'Dhul-Hijjah',
|
"Dhul-Hijjah",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,16 +59,16 @@ export const hmMedium = [
|
||||||
* const abbr = hmShort[hijriDate.hm - 1]; // "Ram"
|
* const abbr = hmShort[hijriDate.hm - 1]; // "Ram"
|
||||||
*/
|
*/
|
||||||
export const hmShort = [
|
export const hmShort = [
|
||||||
'Muh',
|
"Muh",
|
||||||
'Saf',
|
"Saf",
|
||||||
'Ra1',
|
"Ra1",
|
||||||
'Ra2',
|
"Ra2",
|
||||||
'Ju1',
|
"Ju1",
|
||||||
'Ju2',
|
"Ju2",
|
||||||
'Raj',
|
"Raj",
|
||||||
'Shb',
|
"Shb",
|
||||||
'Ram',
|
"Ram",
|
||||||
'Shw',
|
"Shw",
|
||||||
'DhQ',
|
"DhQ",
|
||||||
'DhH',
|
"DhH",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@
|
||||||
* const dayName = hwLong[gregorianDate.getDay()]; // "Yawm al-Jum`a"
|
* const dayName = hwLong[gregorianDate.getDay()]; // "Yawm al-Jum`a"
|
||||||
*/
|
*/
|
||||||
export const hwLong = [
|
export const hwLong = [
|
||||||
'Yawm al-Ahad', // Sunday
|
"Yawm al-Ahad", // Sunday
|
||||||
'Yawm al-Ithnayn', // Monday
|
"Yawm al-Ithnayn", // Monday
|
||||||
"Yawm ath-Thulatha'", // Tuesday
|
"Yawm ath-Thulatha'", // Tuesday
|
||||||
"Yawm al-Arba`a'", // Wednesday
|
"Yawm al-Arba`a'", // Wednesday
|
||||||
'Yawm al-Khamis', // Thursday
|
"Yawm al-Khamis", // Thursday
|
||||||
'Yawm al-Jum`a', // Friday
|
"Yawm al-Jum`a", // Friday
|
||||||
'Yawm as-Sabt', // Saturday
|
"Yawm as-Sabt", // Saturday
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,13 +30,13 @@ export const hwLong = [
|
||||||
* const abbr = hwShort[gregorianDate.getDay()]; // "Jum`a"
|
* const abbr = hwShort[gregorianDate.getDay()]; // "Jum`a"
|
||||||
*/
|
*/
|
||||||
export const hwShort = [
|
export const hwShort = [
|
||||||
'Ahad', // Sunday
|
"Ahad", // Sunday
|
||||||
'Ithn', // Monday
|
"Ithn", // Monday
|
||||||
'Thul', // Tuesday
|
"Thul", // Tuesday
|
||||||
'Arba', // Wednesday
|
"Arba", // Wednesday
|
||||||
'Kham', // Thursday
|
"Kham", // Thursday
|
||||||
'Jum`a', // Friday
|
"Jum`a", // Friday
|
||||||
'Sabt', // Saturday
|
"Sabt", // Saturday
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { CalendarEngine } from './types';
|
import type { CalendarEngine } from "./types";
|
||||||
|
|
||||||
const _engines = new Map<string, CalendarEngine>();
|
const _engines = new Map<string, CalendarEngine>();
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ export function registerCalendar(name: string, engine: CalendarEngine): void {
|
||||||
export function getCalendar(name: string): CalendarEngine {
|
export function getCalendar(name: string): CalendarEngine {
|
||||||
const engine = _engines.get(name);
|
const engine = _engines.get(name);
|
||||||
if (!engine) {
|
if (!engine) {
|
||||||
const available = listCalendars().join(', ');
|
const available = listCalendars().join(", ");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown Hijri calendar: "${name}". Available: ${available}. Register custom calendars with registerCalendar().`,
|
`Unknown Hijri calendar: "${name}". Available: ${available}. Register custom calendars with registerCalendar().`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ describe('CJS UAQ conversions', () => {
|
||||||
assert.equal(d.toISOString().slice(0, 10), '2025-03-01');
|
assert.equal(d.toISOString().slice(0, 10), '2025-03-01');
|
||||||
});
|
});
|
||||||
it('toHijri: 2023-03-23 = 1444/9/1', () => {
|
it('toHijri: 2023-03-23 = 1444/9/1', () => {
|
||||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
const h = toHijri(new Date(Date.UTC(2023, 2, 23, 12)));
|
||||||
assert.ok(h !== null);
|
assert.ok(h !== null);
|
||||||
assert.equal(h.hy, 1444);
|
assert.equal(h.hy, 1444);
|
||||||
assert.equal(h.hm, 9);
|
assert.equal(h.hm, 9);
|
||||||
|
|
|
||||||
13
test.mjs
13
test.mjs
|
|
@ -97,19 +97,28 @@ describe('UAQ toGregorian', () => {
|
||||||
|
|
||||||
describe('UAQ toHijri', () => {
|
describe('UAQ toHijri', () => {
|
||||||
it('2023-03-23 = 1444/9/1', () => {
|
it('2023-03-23 = 1444/9/1', () => {
|
||||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
const h = toHijri(new Date(Date.UTC(2023, 2, 23, 12)));
|
||||||
assert.ok(h !== null);
|
assert.ok(h !== null);
|
||||||
assert.equal(h.hy, 1444);
|
assert.equal(h.hy, 1444);
|
||||||
assert.equal(h.hm, 9);
|
assert.equal(h.hm, 9);
|
||||||
assert.equal(h.hd, 1);
|
assert.equal(h.hd, 1);
|
||||||
});
|
});
|
||||||
it('2025-03-01 = 1446/9/1', () => {
|
it('2025-03-01 = 1446/9/1', () => {
|
||||||
const h = toHijri(new Date(2025, 2, 1, 12));
|
const h = toHijri(new Date(Date.UTC(2025, 2, 1, 12)));
|
||||||
assert.ok(h !== null);
|
assert.ok(h !== null);
|
||||||
assert.equal(h.hy, 1446);
|
assert.equal(h.hy, 1446);
|
||||||
assert.equal(h.hm, 9);
|
assert.equal(h.hm, 9);
|
||||||
assert.equal(h.hd, 1);
|
assert.equal(h.hd, 1);
|
||||||
});
|
});
|
||||||
|
it('UAQ round-trip: toHijri(toGregorian(1446, 9, 1)) = 1446/9/1', () => {
|
||||||
|
const greg = toGregorian(1446, 9, 1);
|
||||||
|
assert.ok(greg instanceof Date);
|
||||||
|
const hijri = toHijri(greg);
|
||||||
|
assert.ok(hijri !== null);
|
||||||
|
assert.equal(hijri.hy, 1446);
|
||||||
|
assert.equal(hijri.hm, 9);
|
||||||
|
assert.equal(hijri.hd, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── UAQ isValidHijriDate ───────────────────────────────────────────────────
|
// ─── UAQ isValidHijriDate ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
include: ["hijri-core.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue