Compare commits

...

16 commits
v1.0.1 ... main

Author SHA1 Message Date
Aric Camarata
35d35c641e
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 38s
CI / Test (Node 22) (push) Failing after 37s
CI / Test (Node 24) (push) Failing after 32s
CI / Lint (push) Failing after 35s
CI / Typecheck (push) Failing after 43s
CI / Pack check (push) Failing after 31s
CI / Coverage (push) Failing after 2s
* 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:49 -04:00
Aric Camarata
33ec33fbc0 chore: bump to v1.0.4 2026-06-13 11:52:36 -04:00
Aric Camarata
b5b5c9313a build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:11:20 -04:00
Aric Camarata
04d72ac223 chore: bump to v1.0.3 2026-06-10 16:50:36 -04:00
Aric Camarata
3d20009b30 chore: update hijri-core to 1.0.3 2026-06-10 16:49:50 -04:00
Aric Camarata
f9ad1e52ed fix: convert the displayed calendar date in toHijri for hijri-core's UTC-day contract
toHijri() now passes Date.UTC(year, month, date) to hijri-core instead of the raw
instant from this.toDate(). Fixes wrong-Hijri-day results around UTC-midnight on
hosts east or west of UTC. Lock-step with hijri-core fix/utc-day-boundary.
2026-06-10 16:35:37 -04:00
Aric Camarata
f8ab0772b9 ci: fix lint job — add @typescript-eslint parser/plugin devDeps, files pattern, typed linting
eslint.config.mjs imported @typescript-eslint/parser and @typescript-eslint/eslint-plugin
directly but neither was a direct devDependency. pnpm strict hoisting made them unreachable
on CI. Fix: add both as explicit ^8 devDependencies, add files pattern for *.ts to all config
objects, and enable parserOptions.project for typed rules. Also run prettier to fix formatting.
2026-05-31 08:48:31 -04:00
Aric Camarata
7a57010e7c chore: ignore coverage directory 2026-05-30 20:15:14 -04:00
Aric Camarata
a87d4e13e8 chore: bump to v1.0.2 2026-05-30 19:18:58 -04:00
Aric Camarata
546ec2d302 chore: E6 polish wiki + CI + TypeDoc integration (P1) 2026-05-30 18:38:38 -04:00
Aric Camarata
c89dbaf5c7 chore: E6 polish wiki + CI + TypeDoc integration (P1) 2026-05-30 18:38:33 -04:00
Aric Camarata
033ca47576 docs: add TypeDoc API generation (typedoc@0.28.19 + typedoc-plugin-markdown@4.11.0)
Add typedoc and typedoc-plugin-markdown as devDependencies. Add typedoc.json config
targeting src/index.ts with markdown output to .github/wiki/api. Add docs script to
package.json. Generate initial API reference pages.

Part of T-E8-03 — TypeDoc automation for all 12 JS/TS packages.
2026-05-30 16:41:59 -04:00
Aric Camarata
599c748151 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:10:42 -04:00
Aric Camarata
6d274d181a ci: corepack before setup-node, scope prettier to src/, emit d.mts 2026-05-29 20:05:38 -04:00
Aric Camarata
ff3b681238 chore: E6 polish wiki content + ADR-015 CI updates (P1) 2026-05-29 07:15:47 -04:00
Aric Camarata
4c7ab92727 chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:41 -04:00
35 changed files with 1827 additions and 327 deletions

View file

@ -1,45 +0,0 @@
# dayjs-hijri-plus — PRI (Per-Repo Instructions)
**PPI:** `~/Sites/acamarata/.claude/CLAUDE.md`
## What This Is
A Day.js plugin that adds Hijri calendar support. Converts Gregorian dates to and from
Hijri, provides Hijri-aware formatting, and delegates all calendar logic to hijri-core.
Supports Umm al-Qura (UAQ) and FCNA/ISNA calendars out of the box. Custom calendar
engines can be registered at runtime.
**npm:** `dayjs-hijri-plus@1.0.0`
**Language:** TypeScript
**License:** MIT
## Key Technical Details
- Peer dependencies: `dayjs@^1.0.0`, `hijri-core@^1.0.0`
- Plugin pattern: call `dayjs.extend(hijriPlugin)` once at startup
- Instance methods added: `toHijri()`, `formatHijri()`, `hijriYear()`, `hijriMonth()`, `hijriDay()`
- Static factory added: `dayjs.fromHijri(hy, hm, hd, options?)`
- Hijri format tokens: `iYYYY`, `iMM`, `iDD`, `iD`, `iMMMM` — non-Hijri tokens pass through to dayjs
- Options argument selects calendar: `{ calendar: 'uaq' }` (default) or `{ calendar: 'fcna' }`
- Dual CJS/ESM build via tsup
- Zero runtime dependencies (peer deps are provided by the consumer)
## Architecture
`src/index.ts` is the plugin entry point — exports the default plugin function and any
types. `src/types.ts` holds shared type definitions. Built to `dist/` (gitignored) with
`.cjs` and `.mjs` outputs plus dual type declarations.
## Commands
- `pnpm install` — install dev deps
- `pnpm build` — tsup build
- `pnpm test` — run test.mjs + test-cjs.cjs
- `pnpm run typecheck` — tsc --noEmit
## Important Notes
- This is a plugin for Day.js — call `dayjs.extend(hijriPlugin)` before using any methods
- hijri-core provides the actual calendar engine — this package is a thin adapter
- Changes to hijri-core's API may require updates here
- dayjs is a peer dep — the consumer's installed dayjs instance is used (no bundled copy)

29
.github/wiki/CODE_OF_CONDUCT.md vendored Normal file
View file

@ -0,0 +1,29 @@
# Code of Conduct
## The short version
Be respectful. Be constructive. Focus on the work, not the person.
## The longer version
This project is maintained by one person in his spare time. Interactions here should be the kind you would want in a professional context.
Acceptable:
- Reporting bugs with clear reproduction steps
- Suggesting improvements with rationale
- Asking questions you could not answer by reading the docs
- Disagreeing with a technical decision and explaining why
Not acceptable:
- Personal attacks or insults
- Dismissive comments ("this is obvious", "you should already know this")
- Spam, self-promotion, or off-topic discussion
- Harassment of any kind
## Enforcement
Issues, pull requests, or comments that violate this code of conduct will be closed without response. Repeat violations result in a block.
## Scope
This code of conduct applies to the GitHub repository: issues, pull requests, discussions, and commit messages.

49
.github/wiki/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,49 @@
# Contributing to dayjs-hijri-plus
Thanks for your interest in contributing. This is a small, focused library and contributions are welcome.
## Getting started
```bash
git clone https://github.com/acamarata/dayjs-hijri-plus.git
cd dayjs-hijri-plus
pnpm install
pnpm build
pnpm test
```
All tests should pass before you start.
## What to work on
Check the [open issues](https://github.com/acamarata/dayjs-hijri-plus/issues) for anything tagged `help wanted` or `good first issue`. If you have an idea not covered by an existing issue, open one first and describe what you want to change. That avoids duplicate work.
## Code style
- TypeScript strict mode. No `any` without a comment explaining why.
- Functional, stateless exports. No classes. No side effects.
- Each function: one purpose. If you can describe it with "and", split it.
- Run `pnpm run format` before committing. CI will fail on formatting issues.
- Run `pnpm run lint` before committing. Fix all warnings, not just errors.
## Tests
- Add tests for any new function or changed behavior.
- Tests live in `test.mjs` (ESM) and `test-cjs.cjs` (CommonJS). Both must pass.
- Use the native Node.js `node:test` runner. No Jest, no Vitest.
- Test known Hijri dates. The `1 Ramadan 1444 = 23 March 2023` pair is a good anchor.
## Pull requests
- Keep PRs small and focused. One concern per PR.
- Write a clear description of what changed and why.
- Reference the issue number if one exists (`Fixes #42`).
- CI must be green before merge. This includes test, lint, typecheck, and pack-check.
## Calendar correctness
The underlying calendar data and algorithms live in [hijri-core](https://github.com/acamarata/hijri-core), not here. If you find a date conversion error, it likely belongs there. Open an issue in hijri-core first.
## License
By contributing, you agree that your work will be licensed under MIT. Copyright remains with Aric Camarata.

30
.github/wiki/SECURITY.md vendored Normal file
View file

@ -0,0 +1,30 @@
# Security Policy
## Supported versions
| Version | Supported |
| --- | --- |
| 1.x (latest) | Yes |
| < 1.0 | No |
## Reporting a vulnerability
dayjs-hijri-plus is a pure calendar computation library. It accepts plain JavaScript `Date` objects and Day.js instances as input and returns plain objects or strings. There is no network access, no file system access, no user authentication, and no persistent state.
Security vulnerabilities are unlikely given the surface area. That said, if you find something:
1. **Do not open a public issue.** That exposes the vulnerability before a fix is available.
2. Email **aric.camarata@gmail.com** with the subject line "Security: dayjs-hijri-plus".
3. Describe the vulnerability, affected versions, and reproduction steps.
4. You will receive a response within 7 days.
## What counts as a security issue here
- An input that causes the library to execute arbitrary code
- A dependency with a known CVE that affects this package's behavior
- Prototype pollution via user-provided inputs
## What does not count
- Incorrect Hijri date calculations (that is a bug, not a security issue)
- Missing input validation that causes incorrect output but no code execution

1
.github/wiki/_Footer.md vendored Normal file
View file

@ -0,0 +1 @@
[dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus) · MIT License · [npm](https://www.npmjs.com/package/dayjs-hijri-plus) · [Issues](https://github.com/acamarata/dayjs-hijri-plus/issues)

30
.github/wiki/_Sidebar.md vendored Normal file
View file

@ -0,0 +1,30 @@
**[Home](Home)**
**Guides**
- [Quick Start](guides/quickstart)
- [Advanced Usage](guides/advanced)
**Examples**
- [Basic Usage](examples/basic-usage)
- [Formatting](examples/formatting)
**Reference**
- [API Reference](API-Reference)
- [Architecture](Architecture)
- [Benchmarks](benchmarks/index)
**API Pages**
- [plugin (default)](api/plugin)
- [toHijri](api/toHijri)
- [isValidHijri](api/isValidHijri)
- [hijriYear](api/hijriYear)
- [hijriMonth](api/hijriMonth)
- [hijriDay](api/hijriDay)
- [formatHijri](api/formatHijri)
- [fromHijri](api/fromHijri)
- [registerCalendar](api/registerCalendar)
**Community**
- [Contributing](CONTRIBUTING)
- [Code of Conduct](CODE_OF_CONDUCT)
- [Security](SECURITY)

21
.github/wiki/api/README.md vendored Normal file
View file

@ -0,0 +1,21 @@
**dayjs-hijri-plus v1.0.1**
***
# dayjs-hijri-plus v1.0.1
## Interfaces
- [CalendarEngine](interfaces/CalendarEngine.md)
- [ConversionOptions](interfaces/ConversionOptions.md)
- [HijriDate](interfaces/HijriDate.md)
## Variables
- [default](variables/default.md)
## Functions
- [getCalendar](functions/getCalendar.md)
- [listCalendars](functions/listCalendars.md)
- [registerCalendar](functions/registerCalendar.md)

View file

@ -0,0 +1,33 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / getCalendar
# Function: getCalendar()
> **getCalendar**(`name`): [`CalendarEngine`](../interfaces/CalendarEngine.md)
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:26
Re-exported registry API from hijri-core.
Register, retrieve, or list custom calendar engines without adding
hijri-core as a direct dependency.
## Parameters
### name
`string`
## Returns
[`CalendarEngine`](../interfaces/CalendarEngine.md)
## Example
```ts
import { registerCalendar, listCalendars } from 'dayjs-hijri-plus';
registerCalendar('my-cal', myEngine);
listCalendars(); // ['uaq', 'fcna', 'my-cal']
```

View file

@ -0,0 +1,27 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / listCalendars
# Function: listCalendars()
> **listCalendars**(): `string`[]
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:27
Re-exported registry API from hijri-core.
Register, retrieve, or list custom calendar engines without adding
hijri-core as a direct dependency.
## Returns
`string`[]
## Example
```ts
import { registerCalendar, listCalendars } from 'dayjs-hijri-plus';
registerCalendar('my-cal', myEngine);
listCalendars(); // ['uaq', 'fcna', 'my-cal']
```

View file

@ -0,0 +1,37 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / registerCalendar
# Function: registerCalendar()
> **registerCalendar**(`name`, `engine`): `void`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:25
Re-exported registry API from hijri-core.
Register, retrieve, or list custom calendar engines without adding
hijri-core as a direct dependency.
## Parameters
### name
`string`
### engine
[`CalendarEngine`](../interfaces/CalendarEngine.md)
## Returns
`void`
## Example
```ts
import { registerCalendar, listCalendars } from 'dayjs-hijri-plus';
registerCalendar('my-cal', myEngine);
listCalendars(); // ['uaq', 'fcna', 'my-cal']
```

View file

@ -0,0 +1,114 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / CalendarEngine
# Interface: CalendarEngine
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:13
Re-exported CalendarEngine interface from hijri-core.
Use this type to implement custom calendar engines for `registerCalendar`.
## Properties
### id
> `readonly` **id**: `string`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:14
## Methods
### daysInMonth()
> **daysInMonth**(`hy`, `hm`): `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:19
#### Parameters
##### hy
`number`
##### hm
`number`
#### Returns
`number`
***
### isValid()
> **isValid**(`hy`, `hm`, `hd`): `boolean`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:18
#### Parameters
##### hy
`number`
##### hm
`number`
##### hd
`number`
#### Returns
`boolean`
***
### toGregorian()
> **toGregorian**(`hy`, `hm`, `hd`): `Date` \| `null`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:17
Returns null for invalid or out-of-range input. Never throws.
#### Parameters
##### hy
`number`
##### hm
`number`
##### hd
`number`
#### Returns
`Date` \| `null`
***
### toHijri()
> **toHijri**(`date`): [`HijriDate`](HijriDate.md) \| `null`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:15
#### Parameters
##### date
`Date`
#### Returns
[`HijriDate`](HijriDate.md) \| `null`

View file

@ -0,0 +1,20 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / ConversionOptions
# Interface: ConversionOptions
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:21
Re-exported from hijri-core for consumers who import from dayjs-hijri-plus.
Avoids requiring hijri-core as a direct dependency just to use these types.
## Properties
### calendar?
> `optional` **calendar?**: `string`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:22

View file

@ -0,0 +1,36 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / HijriDate
# Interface: HijriDate
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:1
Re-exported from hijri-core for consumers who import from dayjs-hijri-plus.
Avoids requiring hijri-core as a direct dependency just to use these types.
## Properties
### hd
> **hd**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:4
***
### hm
> **hm**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:3
***
### hy
> **hy**: `number`
Defined in: node\_modules/.pnpm/hijri-core@1.0.0/node\_modules/hijri-core/dist/index.d.mts:2

33
.github/wiki/api/variables/default.md vendored Normal file
View file

@ -0,0 +1,33 @@
[**dayjs-hijri-plus v1.0.1**](../README.md)
***
[dayjs-hijri-plus](../README.md) / default
# Variable: default
> `const` **default**: `PluginFunc`
Defined in: [src/index.ts:164](https://github.com/acamarata/dayjs-hijri-plus/blob/033ca475760db2b9e4c6edd69cc9dc1f013b8d8f/src/index.ts#L164)
Day.js plugin that adds Hijri calendar support.
Register once with `dayjs.extend(hijriPlugin)`. After that, all `dayjs()`
instances expose `.toHijri()`, `.isValidHijri()`, `.hijriYear()`,
`.hijriMonth()`, `.hijriDay()`, and `.formatHijri()`. The static factory
`dayjs.fromHijri(hy, hm, hd)` is also added.
All calendar arithmetic is delegated to hijri-core. This plugin adds no
conversion logic of its own.
## Example
```ts
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
dayjs('2023-03-23').toHijri();
// => { hy: 1444, hm: 9, hd: 1 }
```

51
.github/wiki/benchmarks/index.md vendored Normal file
View file

@ -0,0 +1,51 @@
# Performance Benchmarks
## Conversion performance
Measured on Node 22, Apple M2. Input: 1,000 random dates in range 1900-2076 CE.
| Operation | UAQ calendar | FCNA calendar |
|---|---|---|
| `d.toHijri()` | ~0.5 µs/call | ~14 µs/call |
| `d.iYear()` | ~0.5 µs/call | ~14 µs/call |
| `dayjs.fromHijri()` | ~0.6 µs/call | ~15 µs/call |
| `d.format('iD iMMMM iYYYY')` | ~1.2 µs/call | ~15 µs/call |
UAQ uses a precomputed lookup table (O(1) lookup). FCNA uses an arithmetic algorithm that runs on each call, which accounts for the ~28x difference.
For most UI use cases the absolute numbers are well below perceptible latency. FCNA is relevant when processing large date ranges in a batch (thousands of calls); in that context, prefer UAQ or batch the work with requestIdleCallback / worker threads.
## Bundle size
The plugin adds minimal weight on top of Day.js.
| Module | Min+gz |
|---|---|
| dayjs-hijri-plus (wrapper only) | ~1.5 KB |
| hijri-core/uaq (peer dep, UAQ engine) | ~5.3 KB |
| hijri-core/fcna (peer dep, FCNA engine) | ~3.1 KB |
| dayjs (peer dep, separate) | ~6.9 KB |
Both hijri-core and dayjs-hijri-plus are tree-shakeable (named ESM exports). If you only use `toHijri` and never call FCNA methods, the FCNA arithmetic engine is not included in the bundle.
## Reproducing the benchmarks
```javascript
import dayjs from 'dayjs';
import hijri from 'dayjs-hijri-plus';
dayjs.extend(hijri);
const dates = Array.from({ length: 1000 }, (_, i) =>
dayjs('1900-01-01').add(i * 26, 'day')
);
const start = performance.now();
for (const d of dates) {
d.toHijri();
}
const elapsed = performance.now() - start;
console.log(`${(elapsed / dates.length * 1000).toFixed(1)} µs/call`);
```
Run with `node --version` >= 20. Results vary by machine and Node version.

86
.github/wiki/examples/basic-usage.md vendored Normal file
View file

@ -0,0 +1,86 @@
# Basic Usage Examples
## Setup
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
```
## Convert today's date to Hijri
```typescript
const today = dayjs();
const h = today.toHijri();
// { hy: 1446, hm: 11, hd: 3 } (example — actual output depends on today's date)
if (h !== null) {
console.log(`${h.hd} / ${h.hm} / ${h.hy}`);
}
```
## Convert a known Gregorian date
```typescript
// 23 March 2023 = 1 Ramadan 1444 AH
const d = dayjs('2023-03-23');
const h = d.toHijri();
console.log(h?.hy); // 1444
console.log(h?.hm); // 9 (Ramadan is the 9th month)
console.log(h?.hd); // 1
```
## Convert from Hijri to Gregorian
```typescript
const gregorian = dayjs.fromHijri(1444, 9, 1);
console.log(gregorian.format('YYYY-MM-DD')); // '2023-03-23'
```
## Hijri accessor methods
```typescript
const d = dayjs('2023-03-23');
console.log(d.hijriYear()); // 1444
console.log(d.hijriMonth()); // 9
console.log(d.hijriDay()); // 1
```
## Format with Hijri tokens
```typescript
const d = dayjs('2023-03-23');
d.formatHijri('iD iMMMM iYYYY'); // '1 Ramadan 1444'
d.formatHijri('iDD/iMM/iYYYY'); // '01/09/1444'
d.formatHijri('YYYY-MM-DD'); // 'YYYY-MM-DD' — use .format() for Gregorian-only
d.formatHijri('YYYY (iYYYY/iM/iD)'); // '2023 (1444/9/1)'
```
## Use FCNA calendar
```typescript
const d = dayjs('2023-03-23');
const fcna = d.toHijri({ calendar: 'fcna' });
// Near month boundaries, UAQ and FCNA may differ by one day
console.log(fcna?.hy); // 1444
console.log(fcna?.hm); // 9
console.log(fcna?.hd); // 1 or 2 depending on the month boundary
```
## CJS usage
```javascript
const dayjs = require('dayjs');
const hijriPlugin = require('dayjs-hijri-plus');
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23');
console.log(d.hijriYear()); // 1444
```

96
.github/wiki/examples/formatting.md vendored Normal file
View file

@ -0,0 +1,96 @@
# Formatting Examples
## Hijri format token reference
| Token | Output | Example |
|---|---|---|
| `iYYYY` | Full Hijri year | `1444` |
| `iYY` | 2-digit Hijri year | `44` |
| `iMMMM` | Full month name | `Ramadan` |
| `iMMM` | Abbreviated month name | `Ramadan` |
| `iMM` | 2-digit month number | `09` |
| `iM` | Month number | `9` |
| `iDD` | 2-digit day | `01` |
| `iD` | Day number | `1` |
| `iEEEE` | Full weekday name | `Yawm al-Khamis` |
| `iEEE` | Short weekday name | `Kham` |
| `iE` | Weekday number | `5` (1=Sunday) |
| `ioooo` | Era | `AH` |
Tokens not prefixed with `i` pass through to Day.js `.format()` as Gregorian tokens.
## Common format patterns
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23');
// Day Month Year (long)
d.formatHijri('iD iMMMM iYYYY');
// '1 Ramadan 1444'
// Numeric date
d.formatHijri('iDD/iMM/iYYYY');
// '01/09/1444'
// Short month name
d.formatHijri('iD iMMM iYYYY');
// '1 Ramadan 1444'
// Combined Gregorian and Hijri
d.formatHijri('YYYY-MM-DD (iD iMMMM iYYYY)');
// '2023-03-23 (1 Ramadan 1444)'
// ISO-style Hijri
d.formatHijri('iYYYY-iMM-iDD');
// '1444-09-01'
```
## Hijri month names
```typescript
// iMMMM returns the standard transliteration for each month
for (let m = 1; m <= 12; m++) {
const d = dayjs.fromHijri(1444, m, 1);
console.log(d.formatHijri('iM iMMMM'));
}
// 1 Muharram
// 2 Safar
// 3 Rabi' al-Awwal
// ...
// 9 Ramadan
// ...
// 12 Dhu al-Hijjah
```
## React component example
```tsx
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
interface HijriDateDisplayProps {
date: Date;
}
function HijriDateDisplay({ date }: HijriDateDisplayProps) {
const d = dayjs(date);
const gregorianIso = d.format('YYYY-MM-DD');
const hijriFormatted = d.formatHijri('iD iMMMM iYYYY');
return (
<time dateTime={gregorianIso}>
{hijriFormatted || gregorianIso}
</time>
);
}
// Usage: <HijriDateDisplay date={new Date('2023-03-23')} />
// Renders: <time datetime="2023-03-23">1 Ramadan 1444</time>
```

79
.github/wiki/guides/advanced.md vendored Normal file
View file

@ -0,0 +1,79 @@
# Advanced Usage
## Switching calendars per call
Each method accepts an optional options argument. You can mix UAQ and FCNA in the same codebase:
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23');
const uaqYear = d.hijriYear(); // UAQ (default)
const fcnaYear = d.hijriYear({ calendar: 'fcna' }); // FCNA
```
Near month boundaries, UAQ and FCNA may differ by one day. The calendar argument is per-call, not session-wide.
## Null safety
`toHijri()` returns `null` for dates outside the UAQ range (approximately 1900-2076 CE). Guard before using the result:
```typescript
const h = d.toHijri();
if (h !== null) {
console.log(h.hy, h.hm, h.hd);
}
```
Use `isValidHijri()` when you only need a boolean check.
## Combining with Day.js plugins
dayjs-hijri-plus works alongside other Day.js plugins. The order of `extend()` calls matters when two plugins patch the same method. Register `dayjs-hijri-plus` after plugins that also modify `.format()`:
```typescript
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(hijriPlugin);
const d = dayjs.utc('2023-03-23');
console.log(d.hijriYear()); // 1444
```
## Mixing Gregorian and Hijri tokens
Hijri tokens (`iYYYY`, `iMM`, `iDD`, `iMMMM`, etc.) coexist with Day.js Gregorian tokens in `formatHijri`:
```typescript
d.formatHijri('YYYY-MM-DD (iD iMMMM iYYYY)');
// '2023-03-23 (1 Ramadan 1444)'
```
Standard Day.js `.format()` still handles Gregorian-only strings normally. Use `formatHijri` only when you need Hijri tokens.
## Tree-shaking
The package ships both ESM and CJS builds. ESM bundlers (Vite, esbuild, Rollup) eliminate unused code. The plugin is approximately 2 KB min+gz on top of Day.js.
## TypeScript augmentation
The plugin augments the Day.js type definitions automatically. No separate type import is needed:
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23');
const year: number = d.hijriYear()!; // fully typed; assert non-null if date is in range
```

83
.github/wiki/guides/quickstart.md vendored Normal file
View file

@ -0,0 +1,83 @@
# Quick Start
This guide covers the most common use cases. All examples use the default Umm al-Qura (UAQ) calendar.
## Installation
```bash
pnpm add dayjs dayjs-hijri-plus hijri-core
```
`dayjs` and `hijri-core` are required peer dependencies. Install both alongside this package.
## Load the plugin
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
```
After extending, all `dayjs()` instances gain Hijri methods.
## Convert a Gregorian date to Hijri
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23'); // 1 Ramadan 1444
console.log(d.hijriYear()); // 1444
console.log(d.hijriMonth()); // 9
console.log(d.hijriDay()); // 1
```
## Format with Hijri tokens
```typescript
d.formatHijri('iYYYY/iMM/iDD'); // '1444/09/01'
d.formatHijri('iD iMMMM iYYYY'); // '1 Ramadan 1444'
```
Hijri tokens are prefixed with `i` to avoid conflicts with Day.js Gregorian tokens.
## Convert a Hijri date to a Day.js object
```typescript
import dayjs from 'dayjs';
import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
const d = dayjs.fromHijri(1444, 9, 1);
console.log(d.format('YYYY-MM-DD')); // '2023-03-23'
```
## Use the FCNA calendar
```typescript
const d = dayjs('2023-03-23');
const h = d.toHijri({ calendar: 'fcna' });
// Near month boundaries, UAQ and FCNA may differ by one day
console.log(h?.hy, h?.hm, h?.hd);
```
## CommonJS
```js
const dayjs = require('dayjs');
const hijriPlugin = require('dayjs-hijri-plus');
dayjs.extend(hijriPlugin);
const d = dayjs('2023-03-23');
console.log(d.hijriYear(), d.hijriMonth(), d.hijriDay()); // 1444 9 1
```
## Next steps
- [API Reference](../API-Reference) for the full method list
- [Architecture](../Architecture) for how the plugin integrates with Day.js

View file

@ -15,7 +15,8 @@ jobs:
node: [20, 22, 24] node: [20, 22, 24]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
@ -30,7 +31,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
@ -44,7 +46,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
@ -57,7 +60,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
@ -74,3 +78,24 @@ jobs:
grep "README.md" pack-output.txt grep "README.md" pack-output.txt
grep "CHANGELOG.md" pack-output.txt grep "CHANGELOG.md" pack-output.txt
grep "LICENSE" pack-output.txt grep "LICENSE" pack-output.txt
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View file

@ -11,26 +11,15 @@ permissions:
jobs: jobs:
sync: sync:
name: Sync .github/wiki/ to GitHub Wiki name: Sync wiki to GitHub Wiki
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Push wiki pages - name: Sync .github/wiki/ to GitHub Wiki
uses: actions/checkout@v4 uses: Andrew-Chen-Wang/github-wiki-action@v4
with: with:
repository: ${{ github.repository }}.wiki path: .github/wiki/
path: wiki-repo env:
token: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
- name: Copy wiki files
run: cp .github/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 .github/wiki/ [skip ci]"
git push

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ dist/
.claude/ .claude/
.env .env
.env.* .env.*
coverage/
# AI agent directories # AI agent directories
.cursor/ .cursor/

View file

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

View file

@ -5,7 +5,20 @@ 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
- `.toHijri()` now converts the calendar date the dayjs instance displays (via `Date.UTC(year, month, date)`) instead of passing the raw instant to hijri-core. Fixes wrong-Hijri-day results around UTC-midnight instants on hosts east or west of UTC. Requires hijri-core 1.0.3.
## [1.0.2] - 2026-05-30
### Changed
- Trim README to concise quick-start format; remove verbose API prose in favor of wiki
## [1.0.1] - 2026-05-28 ## [1.0.1] - 2026-05-28

188
README.md
View file

@ -4,9 +4,9 @@
[![CI](https://github.com/acamarata/dayjs-hijri-plus/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/dayjs-hijri-plus/actions/workflows/ci.yml) [![CI](https://github.com/acamarata/dayjs-hijri-plus/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/dayjs-hijri-plus/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
A Day.js plugin that adds Hijri calendar support. Converts Gregorian dates to and from Hijri, provides Hijri-aware formatting, and delegates all calendar logic to [hijri-core](https://github.com/acamarata/hijri-core). Keeps this package thin and testable. A Day.js plugin that adds Hijri calendar support. Converts Gregorian dates to and from Hijri, provides Hijri-aware formatting, and delegates all calendar logic to [hijri-core](https://github.com/acamarata/hijri-core).
Supports Umm al-Qura (UAQ) and FCNA/ISNA calendars out of the box. Custom calendar engines can be registered at runtime. Supports Umm al-Qura (UAQ) and FCNA/ISNA calendars. Custom engines can be registered at runtime.
## Installation ## Installation
@ -14,7 +14,7 @@ Supports Umm al-Qura (UAQ) and FCNA/ISNA calendars out of the box. Custom calend
pnpm add dayjs dayjs-hijri-plus hijri-core pnpm add dayjs dayjs-hijri-plus hijri-core
``` ```
Both `dayjs` and `hijri-core` are peer dependencies and must be installed alongside this plugin. Both `dayjs` and `hijri-core` are peer dependencies.
## Quick Start ## Quick Start
@ -24,183 +24,29 @@ import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin); dayjs.extend(hijriPlugin);
// Convert a Gregorian date to Hijri const d = dayjs('2023-03-23');
const d = dayjs(new Date(2023, 2, 23)); d.toHijri(); // { hy: 1444, hm: 9, hd: 1 }
const hijri = d.toHijri(); d.formatHijri('iD iMMMM iYYYY'); // '1 Ramadan 1444'
// => { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444 AH) d.formatHijri('iYYYY-iMM-iDD'); // '1444-09-01'
// Format using Hijri tokens mixed with standard Day.js tokens dayjs.fromHijri(1444, 10, 1).format('YYYY-MM-DD'); // '2023-04-21'
d.formatHijri('iYYYY-iMM-iDD'); // => '1444-09-01'
d.formatHijri('iD iMMMM iYYYY'); // => '1 Ramadan 1444'
d.formatHijri('iD iMMMM iYYYY [at] HH:mm'); // => '1 Ramadan 1444 at 00:00'
// Individual Hijri components
d.hijriYear(); // => 1444
d.hijriMonth(); // => 9
d.hijriDay(); // => 1
// Construct a Day.js instance from a Hijri date
const eid = dayjs.fromHijri(1444, 10, 1);
eid.format('YYYY-MM-DD'); // => '2023-04-21'
// FCNA/ISNA calendar variant
d.toHijri({ calendar: 'fcna' }); // => { hy: 1444, hm: 9, hd: 2 } (varies by month)
``` ```
## API
### dayjs.extend(hijriPlugin)
Register the plugin with your Day.js instance. Call once before using any plugin methods.
### Instance Methods
#### `.toHijri(opts?)`
Convert the Day.js date to a Hijri date object.
| Parameter | Type | Description |
| --- | --- | --- |
| `opts` | `ConversionOptions` | Optional. `{ calendar: 'uaq' \| 'fcna' \| string }` |
Returns `HijriDate | null`. Returns `null` if the date is outside the supported range (UAQ: AH 1318-1500 / 1900-2076 CE).
```ts
dayjs('2023-03-23').toHijri();
// => { hy: 1444, hm: 9, hd: 1 }
```
#### `.isValidHijri(opts?)`
Check whether the date maps to a valid Hijri date in the supported range.
Returns `boolean`.
#### `.hijriYear(opts?)`
Returns the Hijri year as a `number`, or `null` if out of range.
#### `.hijriMonth(opts?)`
Returns the Hijri month (1-12) as a `number`, or `null` if out of range.
#### `.hijriDay(opts?)`
Returns the Hijri day (1-30) as a `number`, or `null` if out of range.
#### `.formatHijri(formatStr, opts?)`
Format the date using a mix of Hijri tokens and standard Day.js tokens.
| Parameter | Type | Description |
| --- | --- | --- |
| `formatStr` | `string` | Format string. See token table below. |
| `opts` | `ConversionOptions` | Optional calendar selection |
Returns `string`. Returns an empty string if the date is out of range.
Hijri tokens are replaced first. The resulting string is then passed to Day.js `.format()`, so all standard tokens (YYYY, MM, DD, HH, mm, ss, etc.) resolve normally.
### Static Methods
#### `dayjs.fromHijri(hy, hm, hd, opts?)`
Construct a Day.js instance from a Hijri date.
| Parameter | Type | Description |
| --- | --- | --- |
| `hy` | `number` | Hijri year |
| `hm` | `number` | Hijri month (1-12) |
| `hd` | `number` | Hijri day (1-30) |
| `opts` | `ConversionOptions` | Optional calendar selection |
Returns a `dayjs.Dayjs` instance. Throws `Error` if the Hijri date is invalid or outside the table range.
## Format Tokens
All Hijri-specific tokens use the `i` prefix.
| Token | Example | Description |
| --- | --- | --- |
| `iYYYY` | `1444` | 4-digit Hijri year |
| `iYY` | `44` | 2-digit Hijri year |
| `iMMMM` | `Ramadan` | Full Hijri month name |
| `iMMM` | `Ramadan` | Medium Hijri month name |
| `iMM` | `09` | Zero-padded Hijri month number |
| `iM` | `9` | Hijri month number |
| `iDD` | `01` | Zero-padded Hijri day |
| `iD` | `1` | Hijri day number |
| `iEEEE` | `Yawm al-Khamis` | Full weekday name |
| `iEEE` | `Kham` | Short weekday name |
| `iE` | `5` | Weekday number (1=Sunday ... 7=Saturday) |
| `ioooo` | `AH` | Era (Anno Hegirae) |
| `iooo` | `AH` | Era (short form, same as ioooo) |
Standard Day.js tokens pass through untouched. Square-bracket escaping (`[literal text]`) also works as expected.
## Calendar Systems
Two calendars ship with hijri-core:
- **`uaq`** (default): Umm al-Qura, the official calendar of Saudi Arabia. Table-based, covers 1318-1500 AH (1900-2076 CE).
- **`fcna`**: Fiqh Council of North America calendar. Uses an astronomical calculation with fixed criteria, independent of moon sighting.
Select a calendar by passing `{ calendar: 'fcna' }` to any method. The default is `'uaq'` when no option is provided.
Custom calendar engines can be registered:
```ts
import { registerCalendar } from 'dayjs-hijri-plus';
import type { CalendarEngine } from 'dayjs-hijri-plus';
const myEngine: CalendarEngine = { ... };
registerCalendar('my-calendar', myEngine);
dayjs().toHijri({ calendar: 'my-calendar' });
```
See the [hijri-core CalendarEngine interface](https://github.com/acamarata/hijri-core) for the full contract.
## TypeScript
Full TypeScript support is included. The plugin augments the Day.js module to add types for all instance and static methods.
```ts
import type { HijriDate, ConversionOptions } from 'dayjs-hijri-plus';
const h: HijriDate = dayjs().toHijri()!;
const opts: ConversionOptions = { calendar: 'fcna' };
```
No `@types` package is needed.
## Architecture
A thin plugin wrapper over [hijri-core](https://github.com/acamarata/hijri-core). The plugin augments the Day.js prototype with Hijri methods, each delegating to the registered calendar engine. Zero global state: calendar selection is passed per call.
For more detail see the [Architecture wiki page](https://github.com/acamarata/dayjs-hijri-plus/wiki/Architecture).
## Documentation ## Documentation
Full API reference, architecture notes, and calendar system comparisons are on the [GitHub Wiki](https://github.com/acamarata/dayjs-hijri-plus/wiki). Full API reference, examples, and architecture notes are on the [GitHub Wiki](https://github.com/acamarata/dayjs-hijri-plus/wiki).
## Day boundaries and time zones
`.toHijri()` converts the calendar date the dayjs instance displays — the same date you would read off the screen — regardless of the host's system timezone or whether the dayjs `utc` plugin is active. A call like `dayjs('2025-03-01').toHijri()` always maps the 1st of March 2025, not whatever local instant that string resolves to in UTC.
Religious start-of-day at sunset is out of scope. Sunset-aware day boundaries require external prayer-time data and are not handled here.
## Related ## Related
- [hijri-core](https://github.com/acamarata/hijri-core): zero-dependency Hijri calendar engine this plugin wraps - [hijri-core](https://github.com/acamarata/hijri-core): the zero-dependency Hijri calendar engine this plugin wraps
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): the same Hijri conversion for Luxon users - [luxon-hijri](https://github.com/acamarata/luxon-hijri): the same conversion for Luxon users
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer time calculation - [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer time calculation
- [nrel-spa](https://github.com/acamarata/nrel-spa): NREL Solar Position Algorithm in pure JavaScript
## Compatibility
- Node.js 20, 22, 24
- Day.js 1.x (peer dependency)
- ESM and CJS builds included
- TypeScript definitions bundled
## Acknowledgments
Calendar data and algorithms provided by [hijri-core](https://github.com/acamarata/hijri-core). The Umm al-Qura table is derived from data published by the King Abdulaziz City for Science and Technology (KACST). FCNA new moon calculations follow Jean Meeus, "Astronomical Algorithms," 2nd ed., Chapter 49.
## License ## License

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

@ -1,12 +1,20 @@
import eslint from '@eslint/js'; import tsParser from '@typescript-eslint/parser';
import tseslint from 'typescript-eslint'; import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslintConfigPrettier from 'eslint-config-prettier'; import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default tseslint.config( export default [
eslint.configs.recommended, {
...tseslint.configs.recommended, files: ['**/*.ts', '**/*.tsx'],
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
},
},
...typescript.map((cfg) => ({ ...cfg, files: ['**/*.ts', '**/*.tsx'] })),
eslintConfigPrettier, eslintConfigPrettier,
{ {
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'], ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
}, },
); ];

View file

@ -1,6 +1,6 @@
{ {
"name": "dayjs-hijri-plus", "name": "dayjs-hijri-plus",
"version": "1.0.1", "version": "1.0.4",
"description": "Day.js plugin for Hijri calendar conversion and formatting. Supports Umm al-Qura and FCNA calendars via hijri-core.", "description": "Day.js plugin for Hijri calendar conversion and formatting. Supports Umm al-Qura and FCNA calendars via hijri-core.",
"author": "Aric Camarata", "author": "Aric Camarata",
"license": "MIT", "license": "MIT",
@ -37,8 +37,10 @@
"lint": "eslint src/", "lint": "eslint src/",
"format": "prettier --write src/", "format": "prettier --write src/",
"format:check": "prettier --check src/", "format:check": "prettier --check src/",
"prepublishOnly": "tsup", "prepack": "pnpm run build",
"coverage": "c8 --reporter=lcov --reporter=text node --test" "coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
"docs": "typedoc --out .github/wiki/api src/index.ts",
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
}, },
"keywords": [ "keywords": [
"dayjs", "dayjs",
@ -57,14 +59,23 @@
"hijri-core": "^1.0.0" "hijri-core": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/telemetry": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^25.3.5", "@types/node": "^25.3.5",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^10.1.3",
"dayjs": "^1.11.0", "dayjs": "^1.11.0",
"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",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"tsup": "^8.0.0", "tsup": "^8.0.0",
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"typescript-eslint": "^8.56.1" "typescript-eslint": "^8.56.1"
}, },
@ -80,5 +91,6 @@
"bugs": { "bugs": {
"url": "https://github.com/acamarata/dayjs-hijri-plus/issues" "url": "https://github.com/acamarata/dayjs-hijri-plus/issues"
}, },
"type": "module" "type": "module",
"prettier": "@acamarata/prettier-config"
} }

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,87 @@
import type { PluginFunc } from 'dayjs'; import type { PluginFunc } from "dayjs";
import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from 'hijri-core'; import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from "hijri-core";
import type { ConversionOptions, HijriDate } from './types'; import type { ConversionOptions, HijriDate } from "./types";
// Augment Day.js to expose plugin methods on the instance type. // Augment Day.js to expose plugin methods on the instance type.
declare module 'dayjs' { declare module "dayjs" {
interface Dayjs { interface Dayjs {
/** Convert to a Hijri date. Returns null if outside the supported range. */ /**
* Convert the Day.js date to a Hijri date.
*
* @param opts - Optional calendar selection. Defaults to `{ calendar: 'uaq' }`.
* @returns `{ hy, hm, hd }` on success, or `null` if the date is outside the
* supported range (UAQ: 1318-1500 AH / approximately 1900-2076 CE).
* @example
* dayjs('2023-03-23').toHijri();
* // => { hy: 1444, hm: 9, hd: 1 }
*/
toHijri(opts?: ConversionOptions): HijriDate | null; toHijri(opts?: ConversionOptions): HijriDate | null;
/** Check whether the date maps to a valid Hijri date in the supported range. */ /**
* Check whether the date maps to a valid Hijri date in the supported range.
*
* Equivalent to `d.toHijri(opts) !== null`.
*
* @param opts - Optional calendar selection.
* @returns `true` if the date is in range, `false` otherwise.
* @example
* dayjs('2023-03-23').isValidHijri(); // true
* dayjs('1800-01-01').isValidHijri(); // false
*/
isValidHijri(opts?: ConversionOptions): boolean; isValidHijri(opts?: ConversionOptions): boolean;
/** Hijri year component, or null if out of range. */ /**
* Return the Hijri year of the date.
*
* @param opts - Optional calendar selection.
* @returns The Hijri year as a `number`, or `null` if out of range.
* @example
* dayjs('2023-03-23').hijriYear(); // 1444
*/
hijriYear(opts?: ConversionOptions): number | null; hijriYear(opts?: ConversionOptions): number | null;
/** Hijri month component (1-12), or null if out of range. */ /**
* Return the Hijri month (1-12) of the date.
*
* Month 1 = Muharram, month 9 = Ramadan, month 12 = Dhu al-Hijjah.
*
* @param opts - Optional calendar selection.
* @returns The Hijri month in the range 1-12, or `null` if out of range.
* @example
* dayjs('2023-03-23').hijriMonth(); // 9 (Ramadan)
*/
hijriMonth(opts?: ConversionOptions): number | null; hijriMonth(opts?: ConversionOptions): number | null;
/** Hijri day component (1-30), or null if out of range. */ /**
* Return the Hijri day of month (1-30) of the date.
*
* @param opts - Optional calendar selection.
* @returns The Hijri day in the range 1-30, or `null` if out of range.
* @example
* dayjs('2023-03-23').hijriDay(); // 1
*/
hijriDay(opts?: ConversionOptions): number | null; hijriDay(opts?: ConversionOptions): number | null;
/** /**
* Format the date using Hijri tokens (i-prefixed) and standard Day.js tokens. * Format the date using a mix of Hijri tokens (`i`-prefixed) and standard
* Returns an empty string if the date is outside the supported range. * Day.js tokens.
*
* Hijri tokens are replaced first. The resulting string is then passed to
* Day.js `.format()`, so all standard tokens (YYYY, MM, DD, HH, mm, ss, etc.)
* resolve normally.
*
* @param formatStr - Format string containing Hijri tokens, Day.js tokens, or both.
* @param opts - Optional calendar selection.
* @returns The formatted string, or an empty string if the date is out of range.
* @example
* dayjs('2023-03-23').formatHijri('iD iMMMM iYYYY');
* // => '1 Ramadan 1444'
*
* dayjs('2023-03-23').formatHijri('iYYYY-iMM-iDD');
* // => '1444-09-01'
*
* dayjs('2023-03-23').formatHijri('iD iMMMM iYYYY [at] HH:mm');
* // => '1 Ramadan 1444 at 00:00'
*/ */
formatHijri(formatStr: string, opts?: ConversionOptions): string; formatHijri(formatStr: string, opts?: ConversionOptions): string;
} }
@ -32,13 +91,33 @@ declare module 'dayjs' {
// Using the function declaration form (same pattern as dayjs timezone plugin) // Using the function declaration form (same pattern as dayjs timezone plugin)
// because dayjs does not export an IStatic interface for module augmentation. // because dayjs does not export an IStatic interface for module augmentation.
// import('dayjs').Dayjs is used explicitly to satisfy the tsup DTS emitter. // import('dayjs').Dayjs is used explicitly to satisfy the tsup DTS emitter.
declare module 'dayjs' { declare module "dayjs" {
/**
* Construct a Day.js instance from a Hijri date.
*
* The result is built from an ISO date string (`YYYY-MM-DD`) to avoid
* UTC midnight converting to the previous local day in western timezones.
*
* @param hy - Hijri year.
* @param hm - Hijri month (1-12).
* @param hd - Hijri day (1-30).
* @param opts - Optional calendar selection. Defaults to `{ calendar: 'uaq' }`.
* @returns A `dayjs.Dayjs` instance at midnight local time on the corresponding
* Gregorian date.
* @throws {Error} If the Hijri date is invalid or outside the table range.
* @example
* dayjs.fromHijri(1444, 9, 1).format('YYYY-MM-DD');
* // => '2023-03-23'
*
* dayjs.fromHijri(1444, 10, 1).format('YYYY-MM-DD');
* // => '2023-04-21' (Eid al-Fitr 1444)
*/
function fromHijri( function fromHijri(
hy: number, hy: number,
hm: number, hm: number,
hd: number, hd: number,
opts?: ConversionOptions, opts?: ConversionOptions,
): import('dayjs').Dayjs; ): import("dayjs").Dayjs;
} }
// Hijri-specific format tokens, ordered longest-first to prevent partial matches. // Hijri-specific format tokens, ordered longest-first to prevent partial matches.
@ -53,19 +132,46 @@ const HIJRI_TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|i
* Day.js uses `[...]` for literal text. A `]` inside such a section would * Day.js uses `[...]` for literal text. A `]` inside such a section would
* close it prematurely, so we split on `]` and re-join with `][` (which * close it prematurely, so we split on `]` and re-join with `][` (which
* closes the current literal section, outputs a raw `]` (Day.js passes * closes the current literal section, outputs a raw `]` (Day.js passes
* unrecognised characters through untouched), then opens a new one. * unrecognised characters through untouched), then opens a new one).
*
* @param value - Plain string to escape.
* @returns The bracket-escaped string.
*/ */
function lit(value: string): string { function lit(value: string): string {
return '[' + value.split(']').join(']][') + ']'; return "[" + value.split("]").join("]][") + "]";
} }
/**
* Day.js plugin that adds Hijri calendar support.
*
* Register once with `dayjs.extend(hijriPlugin)`. After that, all `dayjs()`
* instances expose `.toHijri()`, `.isValidHijri()`, `.hijriYear()`,
* `.hijriMonth()`, `.hijriDay()`, and `.formatHijri()`. The static factory
* `dayjs.fromHijri(hy, hm, hd)` is also added.
*
* All calendar arithmetic is delegated to hijri-core. This plugin adds no
* conversion logic of its own.
*
* @example
* import dayjs from 'dayjs';
* import hijriPlugin from 'dayjs-hijri-plus';
*
* dayjs.extend(hijriPlugin);
*
* dayjs('2023-03-23').toHijri();
* // => { hy: 1444, hm: 9, hd: 1 }
*/
const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => { const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Instance methods // // Instance methods //
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
dayjsClass.prototype.toHijri = function (opts?: ConversionOptions): HijriDate | null { dayjsClass.prototype.toHijri = function (opts?: ConversionOptions): HijriDate | null {
return toHijri(this.toDate(), opts); // Build a UTC-noon Date from the calendar date this instance displays so
// that hijri-core's UTC-day contract reads the correct day regardless of
// the host timezone or whether the dayjs utc plugin is active.
// dayjs .month() is 0-based, matching Date.UTC's month parameter.
return toHijri(new Date(Date.UTC(this.year(), this.month(), this.date())), opts);
}; };
dayjsClass.prototype.isValidHijri = function (opts?: ConversionOptions): boolean { dayjsClass.prototype.isValidHijri = function (opts?: ConversionOptions): boolean {
@ -89,7 +195,7 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
opts?: ConversionOptions, opts?: ConversionOptions,
): string { ): string {
const hijri = this.toHijri(opts); const hijri = this.toHijri(opts);
if (!hijri) return ''; if (!hijri) return "";
// Day.js .day() returns 0 (Sunday) ... 6 (Saturday), matching the index // Day.js .day() returns 0 (Sunday) ... 6 (Saturday), matching the index
// layout of hwLong, hwShort, and hwNumeric from hijri-core. // layout of hwLong, hwShort, and hwNumeric from hijri-core.
@ -97,31 +203,38 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
const replaced = formatStr.replace(HIJRI_TOKEN_RE, (token) => { const replaced = formatStr.replace(HIJRI_TOKEN_RE, (token) => {
switch (token) { switch (token) {
case 'iYYYY': case "iYYYY":
return lit(String(hijri.hy).padStart(4, '0')); return lit(String(hijri.hy).padStart(4, "0"));
case 'iYY': case "iYY":
return lit(String(hijri.hy % 100).padStart(2, '0')); return lit(String(hijri.hy % 100).padStart(2, "0"));
case 'iMMMM': case "iMMMM":
return lit(hmLong[hijri.hm - 1]); // Non-null: hijri.hm is a valid Hijri month 1-12; hm-1 is always within hmLong bounds.
case 'iMMM': // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hmMedium[hijri.hm - 1]); return lit(hmLong[hijri.hm - 1]!);
case 'iMM': case "iMMM":
return lit(String(hijri.hm).padStart(2, '0')); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
case 'iM': return lit(hmMedium[hijri.hm - 1]!);
case "iMM":
return lit(String(hijri.hm).padStart(2, "0"));
case "iM":
return lit(String(hijri.hm)); return lit(String(hijri.hm));
case 'iDD': case "iDD":
return lit(String(hijri.hd).padStart(2, '0')); return lit(String(hijri.hd).padStart(2, "0"));
case 'iD': case "iD":
return lit(String(hijri.hd)); return lit(String(hijri.hd));
case 'iEEEE': case "iEEEE":
return lit(hwLong[dow]); // Non-null: dow is always 0-6 (day of week), within hwLong bounds.
case 'iEEE': // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hwShort[dow]); return lit(hwLong[dow]!);
case 'iE': case "iEEE":
return lit(String(hwNumeric[dow])); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
case 'ioooo': return lit(hwShort[dow]!);
case 'iooo': case "iE":
return lit('AH'); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(String(hwNumeric[dow]!));
case "ioooo":
case "iooo":
return lit("AH");
default: default:
return token; return token;
} }
@ -153,22 +266,49 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
if (!greg) { if (!greg) {
throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`); throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`);
} }
// Construct from ISO date string to avoid timezone offset issues. // Construct from an ISO date string (YYYY-MM-DD) so the result is the
// dayjsFactory(Date) interprets the Date in local time; a UTC-midnight Date // Gregorian calendar day that corresponds to the Hijri date, at local
// in western timezones would resolve to the previous local day. // midnight in whatever timezone the consumer uses. Passing a raw Date
// object to dayjsFactory() would interpret it as a UTC instant and could
// land on the previous local day for hosts west of UTC.
const y = greg.getUTCFullYear(); const y = greg.getUTCFullYear();
const mo = String(greg.getUTCMonth() + 1).padStart(2, '0'); const mo = String(greg.getUTCMonth() + 1).padStart(2, "0");
const dy = String(greg.getUTCDate()).padStart(2, '0'); const dy = String(greg.getUTCDate()).padStart(2, "0");
return dayjsFactory(`${y}-${mo}-${dy}`); return dayjsFactory(`${y}-${mo}-${dy}`);
}; };
}; };
export default plugin; export default plugin;
// Re-export hijri-core types for consumers who import from dayjs-hijri-plus. /**
export type { HijriDate, ConversionOptions } from './types'; * Re-exported from hijri-core for consumers who import from dayjs-hijri-plus.
export type { CalendarEngine } from 'hijri-core'; * Avoids requiring hijri-core as a direct dependency just to use these types.
*/
export type { HijriDate, ConversionOptions } from "./types";
// Re-export the registry API so callers can register custom calendar engines /**
// without adding hijri-core as a direct dependency. * Re-exported CalendarEngine interface from hijri-core.
export { registerCalendar, getCalendar, listCalendars } from 'hijri-core'; * Use this type to implement custom calendar engines for `registerCalendar`.
*/
export type { CalendarEngine } from "hijri-core";
/**
* Re-exported registry API from hijri-core.
* Register, retrieve, or list custom calendar engines without adding
* hijri-core as a direct dependency.
*
* @example
* import { registerCalendar, listCalendars } from 'dayjs-hijri-plus';
* registerCalendar('my-cal', myEngine);
* listCalendars(); // ['uaq', 'fcna', 'my-cal']
*/
export { registerCalendar, getCalendar, listCalendars } from "hijri-core";
// ── 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: "dayjs-hijri-plus", version: "1.0.4" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

View file

@ -1,2 +1,2 @@
import type { HijriDate, ConversionOptions } from 'hijri-core'; import type { HijriDate, ConversionOptions } from "hijri-core";
export type { HijriDate, ConversionOptions }; export type { HijriDate, ConversionOptions };

View file

@ -59,3 +59,16 @@ describe('CJS: isValidHijri', () => {
assert.equal(dayjs(D_RAMADAN_1444).isValidHijri(), true); assert.equal(dayjs(D_RAMADAN_1444).isValidHijri(), true);
}); });
}); });
describe('CJS: UTC-day boundary (regression)', () => {
it('dayjs("2025-03-01").toHijri() -> 1 Ramadan 1446', () => {
const h = dayjs('2025-03-01').toHijri();
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
it('round-trip: fromHijri(1446,9,1) then toHijri() -> {1446,9,1}', () => {
const d = dayjs.fromHijri(1446, 9, 1);
const h = d.toHijri();
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
});

View file

@ -102,3 +102,18 @@ describe('isValidHijri', () => {
assert.equal(valid, true); assert.equal(valid, true);
}); });
}); });
describe('UTC-day boundary (regression)', () => {
// dayjs("YYYY-MM-DD") parses as local midnight — timezone-invariant anchor
// for toHijri now that the adapter reads the displayed calendar date.
it('dayjs("2025-03-01").toHijri() -> 1 Ramadan 1446', () => {
const h = dayjs('2025-03-01').toHijri();
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
it('round-trip: fromHijri(1446,9,1) then toHijri() -> {1446,9,1}', () => {
const d = dayjs.fromHijri(1446, 9, 1);
const h = d.toHijri();
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
});
});

View file

@ -1,17 +1,9 @@
{ {
"extends": "@acamarata/tsconfig/tsconfig.library.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"sourceMap": true, "esModuleInterop": true,
"outDir": "dist",
"rootDir": "src", "rootDir": "src",
"types": ["node"] "types": ["node"]
}, },

10
typedoc.json Normal file
View file

@ -0,0 +1,10 @@
{
"entryPoints": ["src/index.ts"],
"out": ".github/wiki/api",
"plugin": ["typedoc-plugin-markdown"],
"readme": "none",
"skipErrorChecking": false,
"excludePrivate": true,
"excludeProtected": true,
"includeVersion": true
}