Compare commits

...

7 commits
v3.0.1 ... main

Author SHA1 Message Date
Aric Camarata
f20984e431
add opt-in anonymous telemetry (#2)
Some checks failed
CI / Test (Node 20) (push) Failing after 36s
CI / Test (Node 22) (push) Failing after 29s
CI / Test (Node 24) (push) Failing after 30s
CI / Lint (push) Failing after 30s
CI / Typecheck (push) Failing after 38s
CI / Pack check (push) Failing after 36s
CI / Coverage (push) Failing after 3s
* add opt-in telemetry via @acamarata/telemetry (off by default)

* chore: update lockfile for @acamarata/telemetry devDep

* chore: fix prettier formatting on telemetry import
2026-06-30 15:56:48 -04:00
Aric Camarata
57dd684f4a chore: bump to v3.0.3 2026-06-13 11:52:27 -04:00
Aric Camarata
56fdd8d14d build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:11:20 -04:00
Aric Camarata
ab7c5d814e chore: bump to v3.0.2 2026-06-10 16:50:11 -04:00
Aric Camarata
8990001e17 chore: update hijri-core to 1.0.3 2026-06-10 16:49:20 -04:00
Aric Camarata
eea0bc808d test: align day-boundary tests and docs with hijri-core's UTC-day contract
Convert all LOCAL-noon Date anchors (new Date(y, m, d, 12)) to UTC-explicit
anchors (new Date(Date.UTC(y, m-1, d))) in test.mjs and test-cjs.cjs.
Add UAQ default-engine round-trip regression suite (5 cases).
Extend FCNA round-trips; update vitest header comment.

README: add "Day boundaries and time zones" section explaining the UTC-day
contract, the correct pattern for zone-aware Luxon DateTimes, and ISO-string
parsing behaviour. Quick Start examples updated to use Date.UTC.

CHANGELOG: document inherited UTC-day fix under [Unreleased].

Lock-step dependency: requires hijri-core fix (commit 3419378,
branch fix/utc-day-boundary). Both packages release together per ADR-013.

Verified: TZ={UTC,America/New_York,Pacific/Auckland} × {test.mjs,
test-cjs.cjs, test-crossval.mjs, vitest} — all pass (88+26+120+15 tests).
2026-06-10 16:38:29 -04:00
Aric Camarata
1e6fdfa407 ci: fix eslint parser devDeps, typed-linting config, coverage ignores, prettier format 2026-05-31 08:48:01 -04:00
20 changed files with 901 additions and 107 deletions

View file

@ -5,7 +5,21 @@ 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] ## [3.0.3] - 2026-06-13
### Fixed
- Published package now includes dist/index.d.mts so ESM type resolution under node16/nodenext resolves the import condition.
## [3.0.2] - 2026-06-10
### Fixed
- Inherits hijri-core's UTC-day fix: `toHijri` with UTC-midnight Dates is now exact on all hosts
(previously, LOCAL date components were read, causing off-by-one errors west of UTC and on UTC+13+).
- Round-trips (`toGregorian` then `toHijri`) are now exact for both the UAQ (default) and FCNA engines.
- Tests updated to use `new Date(Date.UTC(...))` anchors throughout; UAQ engine round-trip regression
suite added. Lock-step release with hijri-core 1.0.3 fix (commit 3419378).
## [3.0.1] - 2026-05-30 ## [3.0.1] - 2026-05-30

View file

@ -20,8 +20,8 @@ npm install luxon-hijri luxon hijri-core
```javascript ```javascript
import { toHijri, toGregorian, formatHijriDate } from 'luxon-hijri'; import { toHijri, toGregorian, formatHijriDate } from 'luxon-hijri';
// Gregorian to Hijri (Umm al-Qura, default) // Gregorian to Hijri (Umm al-Qura, default) — use Date.UTC for cross-host consistency
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
// { hy: 1444, hm: 9, hd: 1 } // { hy: 1444, hm: 9, hd: 1 }
// Hijri to Gregorian // Hijri to Gregorian
@ -33,9 +33,33 @@ formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iEEEE, iD iMMMM iYYYY ioooo');
// "Yawm al-Khamis, 1 Ramadan 1444 AH" // "Yawm al-Khamis, 1 Ramadan 1444 AH"
// FCNA/ISNA calendar // FCNA/ISNA calendar
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' }); toHijri(new Date(Date.UTC(2025, 2, 1)), { calendar: 'fcna' });
``` ```
## Day boundaries and time zones
`toHijri(date)` reads the **UTC calendar day** of the Date you pass. `toGregorian(hy, hm, hd)` returns a Date at **UTC midnight** on the corresponding Gregorian day. Round-trips are therefore exact and produce identical results on any machine regardless of local timezone.
**Converting a zone-aware Luxon DateTime.** Pass the DateTime's calendar fields, not `.toJSDate()`, unless the DateTime is already pinned to UTC:
```javascript
import { DateTime } from 'luxon';
import { toHijri } from 'luxon-hijri';
const dt = DateTime.now().setZone('America/New_York');
// Correct — reads the calendar date in the DateTime's own zone
const h = toHijri(new Date(Date.UTC(dt.year, dt.month - 1, dt.day)));
// Wrong if dt is not UTC-anchored — toJSDate() produces local-zone midnight,
// which may land on the previous UTC day for western timezones
// const h = toHijri(dt.toJSDate());
```
**ISO string parsing.** `new Date("2025-03-01")` parses as UTC midnight — that is exactly the right input for a calendar-day conversion and will produce the correct Hijri date.
Note: determining when the Hijri day begins at local sunset is out of scope for this library.
## TypeScript ## TypeScript
```typescript ```typescript

8
TELEMETRY.md Normal file
View 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)

View file

@ -6,11 +6,18 @@ import { typescript } from '@acamarata/eslint-config';
export default [ export default [
{ {
plugins: { '@typescript-eslint': tsPlugin }, plugins: { '@typescript-eslint': tsPlugin },
languageOptions: { parser: tsParser }, languageOptions: {
parser: tsParser,
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
files: ['src/**/*.ts'],
}, },
...typescript, ...typescript.map((config) => ({ ...config, files: ['src/**/*.ts'] })),
eslintConfigPrettier, eslintConfigPrettier,
{ {
ignores: ['dist/', 'node_modules/', '*.cjs', '*.mjs'], ignores: ['dist/', 'node_modules/', 'coverage/', 'test.mjs', 'test-cjs.cjs', 'test-crossval.mjs', 'tsup.config.ts', 'typedoc.json'],
}, },
]; ];

103
luxon-hijri.test.ts Normal file
View file

@ -0,0 +1,103 @@
/**
* Purpose: Vitest suite for luxon-hijri conversion, formatting, and validation.
* Inputs: Pure functions from src/index.ts. Requires luxon + hijri-core as peer deps.
* Outputs: Vitest pass/fail assertions.
* Constraints: UAQ range 13181500 AH. toGregorian throws (not null) on invalid input.
* toHijri reads the Date's UTC calendar day; pass UTC midnight or use
* Date.UTC(year, month-1, day) for exact results on all hosts.
* Usage: pnpm vitest run
* SOT: packages.md luxon-hijri row
*/
import { describe, it, expect } from "vitest";
import {
toHijri,
toGregorian,
isValidHijriDate,
formatHijriDate,
hmLong,
hmMedium,
hmShort,
} from "./src/index";
// Anchor: toGregorian(1446, 9, 1) = 2025-03-01 midnight UTC
// toHijri on noon 2025-03-01 reliably returns { hm: 9, hd: 1 }
const RAMADAN_1446_NOON = new Date("2025-03-01T12:00:00Z");
describe("toHijri", () => {
it("converts noon 2025-03-01 UTC to 1 Ramadan 1446", () => {
const result = toHijri(RAMADAN_1446_NOON);
expect(result).not.toBeNull();
expect(result!.hy).toBe(1446);
expect(result!.hm).toBe(9);
expect(result!.hd).toBe(1);
});
it("returns null for dates outside UAQ range", () => {
expect(toHijri(new Date("2100-01-01"))).toBeNull();
});
it("throws on an invalid Date", () => {
expect(() => toHijri(new Date("not-a-date"))).toThrow();
});
});
describe("toGregorian", () => {
it("converts 1 Ramadan 1446 to 2025-03-01 UTC midnight", () => {
const result = toGregorian(1446, 9, 1);
expect(result.toISOString()).toBe("2025-03-01T00:00:00.000Z");
});
it("throws on invalid Hijri date (out of range)", () => {
expect(() => toGregorian(1501, 1, 1)).toThrow("Invalid Hijri date");
});
});
describe("isValidHijriDate", () => {
it("returns true for 1 Ramadan 1446", () => {
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
});
it("returns false for month 0", () => {
expect(isValidHijriDate(1446, 0, 1)).toBe(false);
});
it("returns false for day 31", () => {
expect(isValidHijriDate(1446, 1, 31)).toBe(false);
});
});
describe("formatHijriDate", () => {
const hijriDate = { hy: 1446, hm: 9, hd: 1 };
it("formats iYYYY-iMM-iDD correctly", () => {
expect(formatHijriDate(hijriDate, "iYYYY-iMM-iDD")).toBe("1446-09-01");
});
it("formats iMMMM as full month name Ramadan", () => {
expect(formatHijriDate(hijriDate, "iMMMM")).toBe("Ramadan");
});
it("formats iMMM as a non-empty medium month name", () => {
const result = formatHijriDate(hijriDate, "iMMM");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("throws RangeError on invalid month 0", () => {
expect(() => formatHijriDate({ hy: 1446, hm: 0, hd: 1 }, "iMMMM")).toThrow(RangeError);
});
});
describe("month name tables", () => {
it("hmLong index 8 is Ramadan", () => {
expect(hmLong[8]).toBe("Ramadan");
});
it("hmMedium has 12 entries", () => {
expect(hmMedium).toHaveLength(12);
});
it("hmShort has 12 entries", () => {
expect(hmShort).toHaveLength(12);
});
});

View file

@ -1,6 +1,6 @@
{ {
"name": "luxon-hijri", "name": "luxon-hijri",
"version": "3.0.1", "version": "3.0.3",
"description": "Hijri/Gregorian date conversion and formatting using the Umm al-Qura calendar. Built on Luxon. Supports toHijri, toGregorian, formatHijriDate, and isValidHijriDate.", "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", "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",
@ -69,13 +70,17 @@
"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/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@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.2",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"hijri-core": "^1.0.0", "hijri-core": "^1.0.3",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tsup": "^8.0.0", "tsup": "^8.0.0",
@ -83,7 +88,7 @@
"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",
"c8": "^10.1.2" "vitest": "^2.1.9"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
// formatHijriDate.ts // formatHijriDate.ts
import { DateTime } from 'luxon'; import { DateTime } from "luxon";
import { hmLong, hmMedium } from './hMonths'; import { hmLong, hmMedium } from "./hMonths";
import { hwLong, hwShort, hwNumeric } from './hWeekdays'; import { hwLong, hwShort, hwNumeric } from "./hWeekdays";
import { toGregorian } from './toGregorian'; import { toGregorian } from "./toGregorian";
import type { HijriDate } from './types'; import type { HijriDate } from "./types";
// Token regex: longest tokens first to prevent partial matches. // Token regex: longest tokens first to prevent partial matches.
const TOKEN_RE = const TOKEN_RE =
@ -33,49 +33,49 @@ export function formatHijriDate(hijriDate: HijriDate, format: string): string {
function getGregDt(): DateTime { function getGregDt(): DateTime {
if (!_gregDt) { if (!_gregDt) {
const greg = toGregorian(hijriDate.hy, hijriDate.hm, hijriDate.hd); const greg = toGregorian(hijriDate.hy, hijriDate.hm, hijriDate.hd);
_gregDt = DateTime.fromJSDate(greg, { zone: 'UTC' }); _gregDt = DateTime.fromJSDate(greg, { zone: "UTC" });
} }
return _gregDt; return _gregDt;
} }
return format.replace(TOKEN_RE, (match): string => { return format.replace(TOKEN_RE, (match): string => {
switch (match) { switch (match) {
case 'iYYYY': case "iYYYY":
return String(hijriDate.hy).padStart(4, '0'); return String(hijriDate.hy).padStart(4, "0");
case 'iYY': case "iYY":
return String(hijriDate.hy % 100).padStart(2, '0'); return String(hijriDate.hy % 100).padStart(2, "0");
case 'iMM': case "iMM":
return String(hijriDate.hm).padStart(2, '0'); return String(hijriDate.hm).padStart(2, "0");
case 'iM': case "iM":
return String(hijriDate.hm); return String(hijriDate.hm);
case 'iMMM': case "iMMM":
// Non-null: hm is validated 1-12 above; index hm-1 is always 0-11, within array bounds. // Non-null: hm is validated 1-12 above; index hm-1 is always 0-11, within array bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmMedium[hijriDate.hm - 1]!; return hmMedium[hijriDate.hm - 1]!;
case 'iMMMM': case "iMMMM":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hmLong[hijriDate.hm - 1]!; return hmLong[hijriDate.hm - 1]!;
case 'iDD': case "iDD":
return String(hijriDate.hd).padStart(2, '0'); return String(hijriDate.hd).padStart(2, "0");
case 'iD': case "iD":
return String(hijriDate.hd); return String(hijriDate.hd);
case 'iE': case "iE":
case 'iEEE': case "iEEE":
case 'iEEEE': { case "iEEEE": {
// Luxon weekday: 1=Mon … 7=Sun. Modulo 7: Mon=1 … Sat=6, Sun=0. // 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. // hwLong/hwShort/hwNumeric arrays: index 0=Sunday, 1=Monday, … 6=Saturday.
const idx = getGregDt().weekday % 7; const idx = getGregDt().weekday % 7;
// Non-null: idx is always 0-6 (weekday%7), within all hw* array bounds. // Non-null: idx is always 0-6 (weekday%7), within all hw* array bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (match === 'iE') return String(hwNumeric[idx]!); if (match === "iE") return String(hwNumeric[idx]!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (match === 'iEEE') return hwShort[idx]!; if (match === "iEEE") return hwShort[idx]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return hwLong[idx]!; return hwLong[idx]!;
} }
case 'iooo': case "iooo":
case 'ioooo': case "ioooo":
return 'AH'; return "AH";
default: default:
// Delegate time and timezone tokens to Luxon using the Gregorian DateTime. // Delegate time and timezone tokens to Luxon using the Gregorian DateTime.
return getGregDt().toFormat(match); return getGregDt().toFormat(match);

View file

@ -9,46 +9,46 @@
// Define a mapping of Hijri format tokens to their meanings // Define a mapping of Hijri format tokens to their meanings
export const formatPatterns = { export const formatPatterns = {
// Hijri Year // Hijri Year
iYYYY: 'Hijri year (4 digits)', iYYYY: "Hijri year (4 digits)",
iYY: 'Hijri year (2 digits)', iYY: "Hijri year (2 digits)",
// Hijri Month // Hijri Month
iMM: 'Hijri month (2 digits, zero-padded)', iMM: "Hijri month (2 digits, zero-padded)",
iM: 'Hijri month (1 or 2 digits without zero-padding)', iM: "Hijri month (1 or 2 digits without zero-padding)",
iMMM: 'Hijri month (abbreviated name)', iMMM: "Hijri month (abbreviated name)",
iMMMM: 'Hijri month (full name)', iMMMM: "Hijri month (full name)",
// Hijri Day // Hijri Day
iDD: 'Hijri day of the month (2 digits, zero-padded)', iDD: "Hijri day of the month (2 digits, zero-padded)",
iD: 'Hijri day of the month (1 or 2 digits without zero-padding)', iD: "Hijri day of the month (1 or 2 digits without zero-padding)",
// Hijri Weekday // Hijri Weekday
iE: 'Hijri weekday (1 digit)', iE: "Hijri weekday (1 digit)",
iEEE: 'Hijri weekday (abbreviated name)', iEEE: "Hijri weekday (abbreviated name)",
iEEEE: 'Hijri weekday (full name)', iEEEE: "Hijri weekday (full name)",
// Hour, Minute, Second // Hour, Minute, Second
// These can remain the same as in Gregorian as they dont change in Hijri // These can remain the same as in Gregorian as they dont change in Hijri
HH: 'Hour (2 digits, zero-padded, 24-hour clock)', HH: "Hour (2 digits, zero-padded, 24-hour clock)",
H: 'Hour (1 or 2 digits without zero-padding, 24-hour clock)', H: "Hour (1 or 2 digits without zero-padding, 24-hour clock)",
hh: 'Hour (2 digits, zero-padded, 12-hour clock)', hh: "Hour (2 digits, zero-padded, 12-hour clock)",
h: 'Hour (1 or 2 digits without zero-padding, 12-hour clock)', h: "Hour (1 or 2 digits without zero-padding, 12-hour clock)",
mm: 'Minute (2 digits, zero-padded)', mm: "Minute (2 digits, zero-padded)",
m: 'Minute (1 or 2 digits without zero-padding)', m: "Minute (1 or 2 digits without zero-padding)",
ss: 'Second (2 digits, zero-padded)', ss: "Second (2 digits, zero-padded)",
s: 'Second (1 or 2 digits without zero-padding)', s: "Second (1 or 2 digits without zero-padding)",
// AM/PM // AM/PM
a: 'AM/PM marker', a: "AM/PM marker",
// Other // Other
iooo: 'Hijri era (abbreviated)', iooo: "Hijri era (abbreviated)",
ioooo: 'Hijri era (full)', ioooo: "Hijri era (full)",
// Timezone // Timezone
z: 'Timezone (abbreviated)', z: "Timezone (abbreviated)",
zz: 'Timezone (medium)', zz: "Timezone (medium)",
zzz: 'Timezone (full)', zzz: "Timezone (full)",
Z: 'Timezone offset from UTC (+HH:MM)', Z: "Timezone offset from UTC (+HH:MM)",
ZZ: 'Timezone offset from UTC (condensed)', ZZ: "Timezone offset from UTC (condensed)",
}; };

View file

@ -6,5 +6,5 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// hDates.ts: re-exports from hijri-core; table is maintained in the core package // hDates.ts: re-exports from hijri-core; table is maintained in the core package
export { hDatesTable } from 'hijri-core'; export { hDatesTable } from "hijri-core";
export type { HijriYearRecord } from 'hijri-core'; export type { HijriYearRecord } from "hijri-core";

View file

@ -6,4 +6,4 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// hMonths.ts: re-exports from hijri-core // hMonths.ts: re-exports from hijri-core
export { hmLong, hmMedium, hmShort } from 'hijri-core'; export { hmLong, hmMedium, hmShort } from "hijri-core";

View file

@ -6,4 +6,4 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// hWeekdays.ts: re-exports from hijri-core // hWeekdays.ts: re-exports from hijri-core
export { hwLong, hwShort, hwNumeric } from 'hijri-core'; export { hwLong, hwShort, hwNumeric } from "hijri-core";

View file

@ -1,11 +1,20 @@
// index.ts // index.ts
export { formatPatterns } from './formatPatterns'; export { formatPatterns } from "./formatPatterns";
export { hDatesTable } from './hDates'; export { hDatesTable } from "./hDates";
export type { HijriYearRecord } from './hDates'; export type { HijriYearRecord } from "./hDates";
export { hmLong, hmMedium, hmShort } from './hMonths'; export { hmLong, hmMedium, hmShort } from "./hMonths";
export { hwLong, hwShort, hwNumeric } from './hWeekdays'; export { hwLong, hwShort, hwNumeric } from "./hWeekdays";
export { toGregorian } from './toGregorian'; export { toGregorian } from "./toGregorian";
export { toHijri } from './toHijri'; export { toHijri } from "./toHijri";
export { formatHijriDate } from './formatHijriDate'; export { formatHijriDate } from "./formatHijriDate";
export { isValidHijriDate } from './utils'; export { isValidHijriDate } from "./utils";
export type { HijriDate, CalendarSystem, ConversionOptions } from './types'; export type { HijriDate, CalendarSystem, ConversionOptions } from "./types";
// ── 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: "luxon-hijri", version: "3.0.3" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

View file

@ -6,8 +6,8 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// toGregorian.ts: thin wrapper over hijri-core; preserves throw-on-invalid behavior // toGregorian.ts: thin wrapper over hijri-core; preserves throw-on-invalid behavior
import { toGregorian as coreToGregorian } from 'hijri-core'; import { toGregorian as coreToGregorian } from "hijri-core";
import type { ConversionOptions } from './types'; import type { ConversionOptions } from "./types";
/** /**
* Convert a Hijri date to a Gregorian Date object. * Convert a Hijri date to a Gregorian Date object.
@ -24,6 +24,6 @@ import type { ConversionOptions } from './types';
*/ */
export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date { export function toGregorian(hy: number, hm: number, hd: number, options?: ConversionOptions): Date {
const result = coreToGregorian(hy, hm, hd, options); const result = coreToGregorian(hy, hm, hd, options);
if (result === null) throw new Error('Invalid Hijri date'); if (result === null) throw new Error("Invalid Hijri date");
return result; return result;
} }

View file

@ -6,4 +6,4 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// toHijri.ts: delegates to hijri-core // toHijri.ts: delegates to hijri-core
export { toHijri } from 'hijri-core'; export { toHijri } from "hijri-core";

View file

@ -6,7 +6,7 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// types.ts: re-exports from hijri-core for backward compatibility // types.ts: re-exports from hijri-core for backward compatibility
export type { HijriDate, HijriYearRecord, ConversionOptions } from 'hijri-core'; export type { HijriDate, HijriYearRecord, ConversionOptions } from "hijri-core";
/** /**
* Built-in calendar system identifiers. * Built-in calendar system identifiers.
@ -17,4 +17,4 @@ export type { HijriDate, HijriYearRecord, ConversionOptions } from 'hijri-core';
* hijri-core accepts any string identifier via `registerCalendar()`. This type covers * hijri-core accepts any string identifier via `registerCalendar()`. This type covers
* the built-in defaults only. * the built-in defaults only.
*/ */
export type CalendarSystem = 'uaq' | 'fcna'; export type CalendarSystem = "uaq" | "fcna";

View file

@ -6,4 +6,4 @@
* SPORT: packages.md luxon-hijri row * SPORT: packages.md luxon-hijri row
*/ */
// utils.ts: delegates to hijri-core // utils.ts: delegates to hijri-core
export { isValidHijriDate } from 'hijri-core'; export { isValidHijriDate } from "hijri-core";

View file

@ -43,11 +43,11 @@ describe('CJS core conversions', () => {
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23'); assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
}); });
it('toHijri(2022-07-30) = 1 Muharram 1444', () => { it('toHijri(2022-07-30) = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12)); const h = toHijri(new Date(Date.UTC(2022, 6, 30)));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
}); });
it('toHijri(2023-03-23) = 1 Ramadan 1444', () => { it('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
}); });
}); });
@ -106,7 +106,7 @@ describe('CJS FCNA calendar', () => {
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
}); });
it('2025-03-01 = 1 Ramadan 1446', () => { it('2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA); const h = toHijri(new Date(Date.UTC(2025, 2, 1)), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
}); });
it('isValidHijriDate(1446, 9, 1) = true', () => { it('isValidHijriDate(1446, 9, 1) = true', () => {

View file

@ -95,23 +95,23 @@ describe('toGregorian - error cases', () => {
describe('toHijri - known dates', () => { describe('toHijri - known dates', () => {
it('2022-07-30 = 1 Muharram 1444', () => { it('2022-07-30 = 1 Muharram 1444', () => {
const h = toHijri(new Date(2022, 6, 30, 12)); const h = toHijri(new Date(Date.UTC(2022, 6, 30)));
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
}); });
it('2023-03-23 = 1 Ramadan 1444', () => { it('2023-03-23 = 1 Ramadan 1444', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
}); });
it('2023-04-21 = 1 Shawwal 1444', () => { it('2023-04-21 = 1 Shawwal 1444', () => {
const h = toHijri(new Date(2023, 3, 21, 12)); const h = toHijri(new Date(Date.UTC(2023, 3, 21)));
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
}); });
it('2024-07-07 = 1 Muharram 1446', () => { it('2024-07-07 = 1 Muharram 1446', () => {
const h = toHijri(new Date(2024, 6, 7, 12)); const h = toHijri(new Date(Date.UTC(2024, 6, 7)));
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
}); });
it('1900-04-30 = 1 Muharram 1318 (first table entry)', () => { it('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
const h = toHijri(new Date(1900, 3, 30, 12)); const h = toHijri(new Date(Date.UTC(1900, 3, 30)));
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 }); assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
}); });
}); });
@ -274,15 +274,15 @@ describe('FCNA toGregorian', () => {
describe('FCNA toHijri', () => { describe('FCNA toHijri', () => {
it('2025-03-01 = 1 Ramadan 1446', () => { it('2025-03-01 = 1 Ramadan 1446', () => {
const h = toHijri(new Date(2025, 2, 1, 12), FCNA); const h = toHijri(new Date(Date.UTC(2025, 2, 1)), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
}); });
it('2025-03-30 = 1 Shawwal 1446', () => { it('2025-03-30 = 1 Shawwal 1446', () => {
const h = toHijri(new Date(2025, 2, 30, 12), FCNA); const h = toHijri(new Date(Date.UTC(2025, 2, 30)), FCNA);
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 }); assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
}); });
it('2024-03-11 = 1 Ramadan 1445', () => { it('2024-03-11 = 1 Ramadan 1445', () => {
const h = toHijri(new Date(2024, 2, 11, 12), FCNA); const h = toHijri(new Date(Date.UTC(2024, 2, 11)), FCNA);
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
}); });
}); });
@ -312,6 +312,36 @@ describe('FCNA round-trips', () => {
}); });
}); });
describe('UAQ round-trips (default engine)', () => {
it('1444/1/1 toGregorian then toHijri', () => {
const greg = toGregorian(1444, 1, 1);
const hijri = toHijri(greg);
assert.deepEqual(hijri, { hy: 1444, hm: 1, hd: 1 });
});
it('1444/9/1 toGregorian then toHijri', () => {
const greg = toGregorian(1444, 9, 1);
const hijri = toHijri(greg);
assert.deepEqual(hijri, { hy: 1444, hm: 9, hd: 1 });
});
it('1446/9/1 toGregorian then toHijri', () => {
const greg = toGregorian(1446, 9, 1);
const hijri = toHijri(greg);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
it('1318/1/1 toGregorian then toHijri (first table entry)', () => {
const greg = toGregorian(1318, 1, 1);
assert(greg instanceof Date);
const hijri = toHijri(greg);
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
});
it('1500/12/29 toGregorian then toHijri (last table entry)', () => {
const greg = toGregorian(1500, 12, 29);
assert(greg instanceof Date);
const hijri = toHijri(greg);
assert.deepEqual(hijri, { hy: 1500, hm: 12, hd: 29 });
});
});
describe('FCNA isValidHijriDate', () => { describe('FCNA isValidHijriDate', () => {
it('1446/9/1 = true', () => assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true)); it('1446/9/1 = true', () => assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true));
it('month 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false)); it('month 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false));
@ -329,7 +359,7 @@ describe('UAQ default regression', () => {
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01'); assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
}); });
it('toHijri still works without options', () => { it('toHijri still works without options', () => {
const h = toHijri(new Date(2023, 2, 23, 12)); const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 }); assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
}); });
it('isValidHijriDate still works without options', () => { it('isValidHijriDate still works without options', () => {

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["luxon-hijri.test.ts"],
},
});