Compare commits

..

28 commits
v1.0.0 ... 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
Aric Camarata
d50f03adcc chore: bump to v1.0.1
- Flatten exports map to ADR-015 standard
- Add coverage script (c8)
- Migrate CI to corepack enable
2026-05-28 13:55:01 -04:00
Aric Camarata
c45bb5ee22 chore(config): add AGENTS.md for dual-harness parity 2026-05-25 15:51:05 -04:00
Aric Camarata
8b51b0fa48 chore: align repository structure with portfolio documentation standards 2026-05-15 15:26:52 -04:00
Aric Camarata
d598ec1a39 Add GitHub Sponsors funding config 2026-03-28 18:18:44 -04:00
Aric Camarata
db42550d3f style: fix prettier table formatting in wiki 2026-03-08 17:30:55 -04:00
Aric Camarata
862ee41be5 style: replace em dashes with colons in docs 2026-03-08 17:28:04 -04:00
Aric Camarata
1d3b0e2fcb docs: add Architecture section to README 2026-03-08 17:10:39 -04:00
Aric Camarata
64925cdbfd ci: let pnpm/action-setup read version from packageManager field 2026-03-08 16:52:33 -04:00
Aric Camarata
044bbbd623 docs: add Compatibility and Acknowledgments sections to README 2026-03-08 16:46:30 -04:00
Aric Camarata
fcef3a3754 ci: pin pnpm to version 10 in all CI jobs
Also enable sourcemap: true in tsup config
2026-03-08 16:37:41 -04:00
Aric Camarata
c360c83536 refactor: code quality improvements across the board 2026-03-08 11:37:44 -04:00
Aric Camarata
73f0d874e0 chore: add forceConsistentCasingInFileNames to tsconfig 2026-02-25 15:25:39 -05:00
40 changed files with 2839 additions and 453 deletions

View file

@ -3,12 +3,16 @@ root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
trim_trailing_whitespace = true
[*.{ts,js,mjs,cjs,json,yaml,yml,md}]
[*.{ts,mts,cts,js,mjs,cjs,json,yaml,yml,md}]
indent_style = space
indent_size = 2
[*.{c,h}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [acamarata]

16
.github/docs/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,16 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.0] - 2026-02-25
### Added
- Day.js plugin with `.toHijri()`, `.fromHijri()`, `.hijriYear()`, `.hijriMonth()`, `.hijriDay()`, `.isValidHijri()`, and `.formatHijri()` methods
- Umm al-Qura (UAQ) calendar support via hijri-core
- FCNA/ISNA calendar support via hijri-core
- Full TypeScript definitions including module augmentation for Day.js types
- Dual CJS/ESM build with separate type declaration files
- Re-exports of `registerCalendar`, `getCalendar`, and `listCalendars` from hijri-core for custom calendar registration

View file

@ -20,14 +20,15 @@ Call `dayjs.extend` once, globally. After that, every Day.js instance has the pl
Convert the Day.js date to a Hijri date.
**Signature:**
```ts
toHijri(opts?: ConversionOptions): HijriDate | null
```
**Parameters:**
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| Name | Type | Default | Description |
| --------------- | -------- | ------- | ------------------------------------------------ |
| `opts.calendar` | `string` | `'uaq'` | Calendar engine id. Built-ins: `'uaq'`, `'fcna'` |
**Returns:** `{ hy: number, hm: number, hd: number }` or `null` if the date is outside the table range.
@ -47,6 +48,7 @@ dayjs('2023-03-23').toHijri({ calendar: 'fcna' });
Check whether the date has a valid Hijri representation in the supported range.
**Signature:**
```ts
isValidHijri(opts?: ConversionOptions): boolean
```
@ -58,6 +60,7 @@ Returns `false` for dates outside the coverage range, `true` otherwise.
### `.hijriYear(opts?)`
**Signature:**
```ts
hijriYear(opts?: ConversionOptions): number | null
```
@ -69,6 +72,7 @@ Returns the Hijri year, or `null` if out of range.
### `.hijriMonth(opts?)`
**Signature:**
```ts
hijriMonth(opts?: ConversionOptions): number | null
```
@ -80,6 +84,7 @@ Returns the Hijri month (1-12), or `null` if out of range.
### `.hijriDay(opts?)`
**Signature:**
```ts
hijriDay(opts?: ConversionOptions): number | null
```
@ -93,36 +98,37 @@ Returns the Hijri day (1-30), or `null` if out of range.
Format the date using a mix of Hijri-specific tokens and standard Day.js tokens.
**Signature:**
```ts
formatHijri(formatStr: string, opts?: ConversionOptions): string
```
**Parameters:**
| Name | Type | Description |
| --- | --- | --- |
| `formatStr` | `string` | Format string containing Hijri tokens, Day.js tokens, or both |
| `opts` | `ConversionOptions` | Optional calendar selection |
| Name | Type | Description |
| ----------- | ------------------- | ------------------------------------------------------------- |
| `formatStr` | `string` | Format string containing Hijri tokens, Day.js tokens, or both |
| `opts` | `ConversionOptions` | Optional calendar selection |
Returns an empty string if the date is outside the supported range.
**Hijri tokens:**
| Token | Example | Description |
| --- | --- | --- |
| `iYYYY` | `1444` | 4-digit Hijri year |
| `iYY` | `44` | 2-digit Hijri year |
| `iMMMM` | `Ramadan` | Full month name |
| `iMMM` | `Ramadan` | Medium month name |
| `iMM` | `09` | Zero-padded month number |
| `iM` | `9` | Month number |
| `iDD` | `01` | Zero-padded day |
| `iD` | `1` | Day number |
| `iEEEE` | `Yawm al-Khamis` | Full weekday name |
| `iEEE` | `Kham` | Short weekday name |
| `iE` | `5` | Weekday number (1=Sun ... 7=Sat) |
| `ioooo` | `AH` | Era |
| `iooo` | `AH` | Era (same as ioooo) |
| Token | Example | Description |
| ------- | ---------------- | -------------------------------- |
| `iYYYY` | `1444` | 4-digit Hijri year |
| `iYY` | `44` | 2-digit Hijri year |
| `iMMMM` | `Ramadan` | Full month name |
| `iMMM` | `Ramadan` | Medium month name |
| `iMM` | `09` | Zero-padded month number |
| `iM` | `9` | Month number |
| `iDD` | `01` | Zero-padded day |
| `iD` | `1` | Day number |
| `iEEEE` | `Yawm al-Khamis` | Full weekday name |
| `iEEE` | `Kham` | Short weekday name |
| `iE` | `5` | Weekday number (1=Sun ... 7=Sat) |
| `ioooo` | `AH` | Era |
| `iooo` | `AH` | Era (same as ioooo) |
Standard Day.js tokens pass through to `.format()` after Hijri token substitution.
@ -146,6 +152,7 @@ dayjs('2023-03-23').formatHijri('iYYYY YYYY');
Construct a Day.js instance from a Hijri date.
**Signature:**
```ts
dayjs.fromHijri(
hy: number,
@ -157,11 +164,11 @@ dayjs.fromHijri(
**Parameters:**
| Name | Type | Description |
| --- | --- | --- |
| `hy` | `number` | Hijri year |
| `hm` | `number` | Hijri month (1-12) |
| `hd` | `number` | Hijri day (1-30) |
| Name | Type | Description |
| --------------- | -------- | ------------------------------------- |
| `hy` | `number` | Hijri year |
| `hm` | `number` | Hijri month (1-12) |
| `hd` | `number` | Hijri day (1-30) |
| `opts.calendar` | `string` | Calendar engine id (default: `'uaq'`) |
**Throws:** `Error` if the Hijri date is invalid or outside the table range.
@ -180,9 +187,9 @@ dayjs.fromHijri(1444, 9, 1, { calendar: 'fcna' }).format('YYYY-MM-DD');
```ts
import type {
HijriDate, // { hy: number, hm: number, hd: number }
HijriDate, // { hy: number, hm: number, hd: number }
ConversionOptions, // { calendar?: string }
CalendarSystem, // string alias for calendar ids
CalendarSystem, // string alias for calendar ids
} from 'dayjs-hijri-plus';
```

View file

@ -87,11 +87,11 @@ The package ships a dual CJS/ESM build via tsup. Both `dayjs` and `hijri-core` a
Output:
| File | Format |
| --- | --- |
| `dist/index.cjs` | CommonJS (Node `require`) |
| `dist/index.mjs` | ESM (`import`) |
| `dist/index.d.ts` | TypeScript declarations for CJS |
| File | Format |
| ------------------ | ------------------------------- |
| `dist/index.cjs` | CommonJS (Node `require`) |
| `dist/index.mjs` | ESM (`import`) |
| `dist/index.d.ts` | TypeScript declarations for CJS |
| `dist/index.d.mts` | TypeScript declarations for ESM |
---

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,22 +15,39 @@ jobs:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: node test.mjs
- run: node test-cjs.cjs
- run: node --test test.mjs
- run: node --test test-cjs.cjs
lint:
name: Lint
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 lint
- run: pnpm run format:check
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
@ -43,7 +60,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
@ -60,3 +78,24 @@ jobs:
grep "README.md" pack-output.txt
grep "CHANGELOG.md" 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

@ -4,33 +4,22 @@ on:
push:
branches: [main]
paths:
- '.wiki/**'
- '.github/wiki/**'
permissions:
contents: write
jobs:
sync:
name: Sync .wiki/ to GitHub Wiki
name: Sync wiki to GitHub Wiki
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Push wiki pages
uses: actions/checkout@v4
- name: Sync .github/wiki/ to GitHub Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
repository: ${{ github.repository }}.wiki
path: wiki-repo
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy wiki files
run: cp .wiki/*.md wiki-repo/
- name: Commit and push
working-directory: wiki-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "Sync wiki from .wiki/ [skip ci]"
git push
path: .github/wiki/
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}

15
.gitignore vendored
View file

@ -6,3 +6,18 @@ dist/
.claude/
.env
.env.*
coverage/
# AI agent directories
.cursor/
.copilot/
.aider*
.aider.chat.history.md
.continue/
.codex/
.gemini/
.vscode/*
.idea/
.aider/
.windsurf/
.codeium/

View file

@ -2,15 +2,33 @@
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.0.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).
## [1.0.0] - 2026-02-25
## [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
### Changed
- Flatten exports map to ADR-015 standard (import/require/types at top level)
- Add "./package.json" export condition
- Add coverage script (c8 --reporter=lcov)
- Migrate CI from pnpm/action-setup to corepack enable
## [1.0.0] - 2026-05-28
### Added
- Day.js plugin with `.toHijri()`, `.fromHijri()`, `.hijriYear()`, `.hijriMonth()`, `.hijriDay()`, `.isValidHijri()`, and `.formatHijri()` methods
- Umm al-Qura (UAQ) calendar support via hijri-core
- FCNA/ISNA calendar support via hijri-core
- Full TypeScript definitions including module augmentation for Day.js types
- Dual CJS/ESM build with separate type declaration files
- Re-exports of `registerCalendar`, `getCalendar`, and `listCalendars` from hijri-core for custom calendar registration
- Initial release

171
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)
[![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
@ -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
```
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
@ -24,166 +24,29 @@ import hijriPlugin from 'dayjs-hijri-plus';
dayjs.extend(hijriPlugin);
// Convert a Gregorian date to Hijri
const d = dayjs(new Date(2023, 2, 23));
const hijri = d.toHijri();
// => { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444 AH)
const d = dayjs('2023-03-23');
d.toHijri(); // { hy: 1444, hm: 9, hd: 1 }
d.formatHijri('iD iMMMM iYYYY'); // '1 Ramadan 1444'
d.formatHijri('iYYYY-iMM-iDD'); // '1444-09-01'
// Format using Hijri tokens mixed with standard Day.js tokens
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)
dayjs.fromHijri(1444, 10, 1).format('YYYY-MM-DD'); // '2023-04-21'
```
## 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.
## 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
- [hijri-core](https://github.com/acamarata/hijri-core): zero-dependency Hijri calendar engine this plugin wraps
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): the same Hijri conversion for Luxon users
- [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 conversion for Luxon users
- [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
## 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)

20
eslint.config.mjs Normal file
View file

@ -0,0 +1,20 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslintConfigPrettier from 'eslint-config-prettier';
import { typescript } from '@acamarata/eslint-config';
export default [
{
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,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
},
];

View file

@ -1,6 +1,6 @@
{
"name": "dayjs-hijri-plus",
"version": "1.0.0",
"version": "1.0.4",
"description": "Day.js plugin for Hijri calendar conversion and formatting. Supports Umm al-Qura and FCNA calendars via hijri-core.",
"author": "Aric Camarata",
"license": "MIT",
@ -9,9 +9,11 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }
}
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
@ -23,14 +25,22 @@
"CHANGELOG.md",
"LICENSE"
],
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@10.30.1",
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"prepublishOnly": "tsup"
"test": "node --test test.mjs && node --test test-cjs.cjs",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"prepack": "pnpm run build",
"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": [
"dayjs",
@ -49,14 +59,38 @@
"hijri-core": "^1.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@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",
"@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",
"hijri-core": "^1.0.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"hijri-core": "^1.0.3",
"prettier": "^3.8.1",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.56.1"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acamarata/dayjs-hijri-plus.git"
},
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
"repository": { "type": "git", "url": "git+https://github.com/acamarata/dayjs-hijri-plus.git" },
"homepage": "https://github.com/acamarata/dayjs-hijri-plus#readme",
"bugs": { "url": "https://github.com/acamarata/dayjs-hijri-plus/issues" }
"bugs": {
"url": "https://github.com/acamarata/dayjs-hijri-plus/issues"
},
"type": "module",
"prettier": "@acamarata/prettier-config"
}

File diff suppressed because it is too large Load diff

View file

@ -1,36 +1,87 @@
import type { PluginFunc } from 'dayjs';
import {
toHijri,
toGregorian,
hmLong,
hmMedium,
hwLong,
hwShort,
hwNumeric,
} from 'hijri-core';
import type { ConversionOptions, HijriDate } from './types';
import type { PluginFunc } from "dayjs";
import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from "hijri-core";
import type { ConversionOptions, HijriDate } from "./types";
// Augment Day.js to expose plugin methods on the instance type.
declare module 'dayjs' {
declare module "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;
/** 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;
/** 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;
/** 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;
/** 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;
/**
* Format the date using Hijri tokens (i-prefixed) and standard Day.js tokens.
* Returns an empty string if the date is outside the supported range.
* Format the date using a mix of Hijri tokens (`i`-prefixed) and standard
* 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;
}
@ -40,8 +91,33 @@ declare module 'dayjs' {
// Using the function declaration form (same pattern as dayjs timezone plugin)
// because dayjs does not export an IStatic interface for module augmentation.
// import('dayjs').Dayjs is used explicitly to satisfy the tsup DTS emitter.
declare module 'dayjs' {
function fromHijri(hy: number, hm: number, hd: number, opts?: ConversionOptions): import('dayjs').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(
hy: number,
hm: number,
hd: number,
opts?: ConversionOptions,
): import("dayjs").Dayjs;
}
// Hijri-specific format tokens, ordered longest-first to prevent partial matches.
@ -56,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
* close it prematurely, so we split on `]` and re-join with `][` (which
* 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 {
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) => {
// ------------------------------------------------------------------ //
// Instance methods //
// ------------------------------------------------------------------ //
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 {
@ -92,7 +195,7 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
opts?: ConversionOptions,
): string {
const hijri = this.toHijri(opts);
if (!hijri) return '';
if (!hijri) return "";
// Day.js .day() returns 0 (Sunday) ... 6 (Saturday), matching the index
// layout of hwLong, hwShort, and hwNumeric from hijri-core.
@ -100,20 +203,40 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
const replaced = formatStr.replace(HIJRI_TOKEN_RE, (token) => {
switch (token) {
case 'iYYYY': return lit(String(hijri.hy).padStart(4, '0'));
case 'iYY': return lit(String(hijri.hy % 100).padStart(2, '0'));
case 'iMMMM': return lit(hmLong[hijri.hm - 1]);
case 'iMMM': return lit(hmMedium[hijri.hm - 1]);
case 'iMM': return lit(String(hijri.hm).padStart(2, '0'));
case 'iM': return lit(String(hijri.hm));
case 'iDD': return lit(String(hijri.hd).padStart(2, '0'));
case 'iD': return lit(String(hijri.hd));
case 'iEEEE': return lit(hwLong[dow]);
case 'iEEE': return lit(hwShort[dow]);
case 'iE': return lit(String(hwNumeric[dow]));
case 'ioooo':
case 'iooo': return lit('AH');
default: return token;
case "iYYYY":
return lit(String(hijri.hy).padStart(4, "0"));
case "iYY":
return lit(String(hijri.hy % 100).padStart(2, "0"));
case "iMMMM":
// Non-null: hijri.hm is a valid Hijri month 1-12; hm-1 is always within hmLong bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hmLong[hijri.hm - 1]!);
case "iMMM":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hmMedium[hijri.hm - 1]!);
case "iMM":
return lit(String(hijri.hm).padStart(2, "0"));
case "iM":
return lit(String(hijri.hm));
case "iDD":
return lit(String(hijri.hd).padStart(2, "0"));
case "iD":
return lit(String(hijri.hd));
case "iEEEE":
// Non-null: dow is always 0-6 (day of week), within hwLong bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hwLong[dow]!);
case "iEEE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(hwShort[dow]!);
case "iE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return lit(String(hwNumeric[dow]!));
case "ioooo":
case "iooo":
return lit("AH");
default:
return token;
}
});
@ -143,22 +266,49 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
if (!greg) {
throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`);
}
// Construct from ISO date string to avoid timezone offset issues.
// dayjsFactory(Date) interprets the Date in local time; a UTC-midnight Date
// in western timezones would resolve to the previous local day.
// Construct from an ISO date string (YYYY-MM-DD) so the result is the
// Gregorian calendar day that corresponds to the Hijri date, at local
// 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 mo = String(greg.getUTCMonth() + 1).padStart(2, '0');
const dy = String(greg.getUTCDate()).padStart(2, '0');
const mo = String(greg.getUTCMonth() + 1).padStart(2, "0");
const dy = String(greg.getUTCDate()).padStart(2, "0");
return dayjsFactory(`${y}-${mo}-${dy}`);
};
};
export default plugin;
// Re-export hijri-core types for consumers who import from dayjs-hijri-plus.
export type { HijriDate, ConversionOptions, CalendarSystem } from './types';
export type { CalendarEngine } from 'hijri-core';
/**
* 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.
*/
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.
export { registerCalendar, getCalendar, listCalendars } from 'hijri-core';
/**
* Re-exported CalendarEngine interface 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,13 +1,2 @@
import type { HijriDate, ConversionOptions } from 'hijri-core';
import type { HijriDate, ConversionOptions } from "hijri-core";
export type { HijriDate, ConversionOptions };
/** A registered calendar identifier. The built-in values are 'uaq' and 'fcna'. */
export type CalendarSystem = string;
/**
* Options passed to plugin methods. Inherits `calendar` from ConversionOptions
* so callers can switch between 'uaq' (default) and 'fcna'.
*/
export interface HijriPluginOptions extends ConversionOptions {
// calendar?: string (inherited: 'uaq' | 'fcna' | any registered calendar id)
}

View file

@ -1,67 +1,74 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const dayjs = require('dayjs');
const { default: plugin } = require('./dist/index.cjs');
dayjs.extend(plugin);
let passed = 0;
let total = 0;
function test(name, fn) {
total++;
try {
fn();
console.log(`[${name}]... PASS`);
passed++;
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
process.exit(1);
}
}
const D_RAMADAN_1444 = new Date(2023, 2, 23, 12);
const D_MUHARRAM_1446 = new Date(2024, 6, 7, 12);
test('plugin registers (CJS)', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(typeof d.toHijri, 'function');
assert.equal(typeof d.formatHijri, 'function');
assert.equal(typeof dayjs.fromHijri, 'function');
describe('CJS: plugin registration', () => {
it('registers on dayjs', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(typeof d.toHijri, 'function');
assert.equal(typeof d.formatHijri, 'function');
assert.equal(typeof dayjs.fromHijri, 'function');
});
});
test('toHijri (CJS): 2023-03-23 -> 1 Ramadan 1444', () => {
const h = dayjs(D_RAMADAN_1444).toHijri();
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
describe('CJS: toHijri', () => {
it('2023-03-23 -> 1 Ramadan 1444', () => {
const h = dayjs(D_RAMADAN_1444).toHijri();
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
it('2024-07-07 -> 1 Muharram 1446', () => {
const h = dayjs(D_MUHARRAM_1446).toHijri();
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
});
});
test('toHijri (CJS): 2024-07-07 -> 1 Muharram 1446', () => {
const h = dayjs(D_MUHARRAM_1446).toHijri();
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
describe('CJS: fromHijri', () => {
it('1444/9/1 -> 2023-03-23', () => {
const d = dayjs.fromHijri(1444, 9, 1);
assert.equal(d.format('YYYY-MM-DD'), '2023-03-23');
});
it('throws for out-of-range date', () => {
assert.throws(() => dayjs.fromHijri(1301, 1, 1), /Invalid or out-of-range/);
});
});
test('fromHijri (CJS): 1444/9/1 -> 2023-03-23', () => {
const d = dayjs.fromHijri(1444, 9, 1);
assert.equal(d.format('YYYY-MM-DD'), '2023-03-23');
describe('CJS: formatHijri', () => {
it('iYYYY-iMM-iDD', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
});
it('iMMMM -> Ramadan', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
});
test('formatHijri (CJS): iYYYY-iMM-iDD', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
describe('CJS: isValidHijri', () => {
it('true for in-range date', () => {
assert.equal(dayjs(D_RAMADAN_1444).isValidHijri(), true);
});
});
test('formatHijri (CJS): iMMMM -> Ramadan', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
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 });
});
test('isValidHijri (CJS): true for in-range date', () => {
assert.equal(dayjs(D_RAMADAN_1444).isValidHijri(), true);
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 });
});
});
test('fromHijri (CJS): throws for out-of-range date', () => {
assert.throws(() => dayjs.fromHijri(1301, 1, 1), /Invalid or out-of-range/);
});
console.log(`\n${passed}/${total} tests passed`);

179
test.mjs
View file

@ -1,110 +1,119 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import dayjs from 'dayjs';
import plugin from './dist/index.mjs';
dayjs.extend(plugin);
let passed = 0;
let total = 0;
function test(name, fn) {
total++;
try {
fn();
console.log(`[${name}]... PASS`);
passed++;
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
process.exit(1);
}
}
// Use noon to avoid UTC midnight boundary issues across timezones.
const D_RAMADAN_1444 = new Date(2023, 2, 23, 12); // 1 Ramadan 1444
const D_MUHARRAM_1446 = new Date(2024, 6, 7, 12); // 1 Muharram 1446
test('plugin registers on dayjs', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(typeof d.toHijri, 'function');
assert.equal(typeof d.formatHijri, 'function');
assert.equal(typeof d.isValidHijri, 'function');
assert.equal(typeof d.hijriYear, 'function');
assert.equal(typeof d.hijriMonth, 'function');
assert.equal(typeof d.hijriDay, 'function');
assert.equal(typeof dayjs.fromHijri, 'function');
describe('plugin registration', () => {
it('registers on dayjs', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(typeof d.toHijri, 'function');
assert.equal(typeof d.formatHijri, 'function');
assert.equal(typeof d.isValidHijri, 'function');
assert.equal(typeof d.hijriYear, 'function');
assert.equal(typeof d.hijriMonth, 'function');
assert.equal(typeof d.hijriDay, 'function');
assert.equal(typeof dayjs.fromHijri, 'function');
});
});
test('toHijri: 2023-03-23 -> 1 Ramadan 1444', () => {
const h = dayjs(D_RAMADAN_1444).toHijri();
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
describe('toHijri', () => {
it('2023-03-23 -> 1 Ramadan 1444', () => {
const h = dayjs(D_RAMADAN_1444).toHijri();
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
});
it('2024-07-07 -> 1 Muharram 1446', () => {
const h = dayjs(D_MUHARRAM_1446).toHijri();
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
});
});
test('toHijri: 2024-07-07 -> 1 Muharram 1446', () => {
const h = dayjs(D_MUHARRAM_1446).toHijri();
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
describe('fromHijri', () => {
it('1444/9/1 -> 2023-03-23', () => {
const d = dayjs.fromHijri(1444, 9, 1);
assert.equal(d.format('YYYY-MM-DD'), '2023-03-23');
});
it('1446/1/1 -> 2024-07-07', () => {
const d = dayjs.fromHijri(1446, 1, 1);
assert.equal(d.format('YYYY-MM-DD'), '2024-07-07');
});
it('throws for out-of-range UAQ date', () => {
assert.throws(() => dayjs.fromHijri(1301, 1, 1), /Invalid or out-of-range/);
});
});
test('fromHijri: 1444/9/1 -> 2023-03-23', () => {
const d = dayjs.fromHijri(1444, 9, 1);
assert.equal(d.format('YYYY-MM-DD'), '2023-03-23');
describe('accessors', () => {
it('hijriYear/hijriMonth/hijriDay on 1 Ramadan 1444', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(d.hijriYear(), 1444);
assert.equal(d.hijriMonth(), 9);
assert.equal(d.hijriDay(), 1);
});
});
test('fromHijri: 1446/1/1 -> 2024-07-07', () => {
const d = dayjs.fromHijri(1446, 1, 1);
assert.equal(d.format('YYYY-MM-DD'), '2024-07-07');
describe('formatHijri', () => {
it('iYYYY-iMM-iDD on 1 Ramadan 1444', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
});
it('iMMMM -> Ramadan', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
it('iEEEE on 2023-03-23 (Thursday)', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iEEEE');
assert.equal(result, 'Yawm al-Khamis');
});
it('ioooo -> AH', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('ioooo');
assert.equal(result, 'AH');
});
it('passthrough: iYYYY YYYY contains both Hijri and Gregorian year', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY YYYY');
assert.ok(result.includes('1444'), `Expected Hijri year 1444 in: ${result}`);
assert.ok(result.includes('2023'), `Expected Gregorian year 2023 in: ${result}`);
});
});
test('hijriYear/hijriMonth/hijriDay accessors on 1 Ramadan 1444', () => {
const d = dayjs(D_RAMADAN_1444);
assert.equal(d.hijriYear(), 1444);
assert.equal(d.hijriMonth(), 9);
assert.equal(d.hijriDay(), 1);
describe('FCNA calendar', () => {
it('toHijri returns a valid HijriDate', () => {
const h = dayjs(D_RAMADAN_1444).toHijri({ calendar: 'fcna' });
assert.notEqual(h, null);
assert.equal(typeof h.hy, 'number');
assert.equal(typeof h.hm, 'number');
assert.equal(typeof h.hd, 'number');
});
});
test('formatHijri: iYYYY-iMM-iDD on 1 Ramadan 1444', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
describe('isValidHijri', () => {
it('returns true for in-range date', () => {
const valid = dayjs(D_RAMADAN_1444).isValidHijri();
assert.equal(valid, true);
});
});
test('formatHijri: iMMMM -> Ramadan', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
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 });
});
test('formatHijri: iEEEE on 2023-03-23 (Thursday)', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iEEEE');
// 2023-03-23 is a Thursday; hwLong[4] = 'Yawm al-Khamis'
assert.equal(result, 'Yawm al-Khamis');
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 });
});
});
test('formatHijri: ioooo -> AH', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('ioooo');
assert.equal(result, 'AH');
});
test('FCNA calendar: toHijri returns a valid HijriDate', () => {
const h = dayjs(D_RAMADAN_1444).toHijri({ calendar: 'fcna' });
assert.notEqual(h, null);
assert.equal(typeof h.hy, 'number');
assert.equal(typeof h.hm, 'number');
assert.equal(typeof h.hd, 'number');
});
test('isValidHijri returns true for in-range date', () => {
const valid = dayjs(D_RAMADAN_1444).isValidHijri();
assert.equal(valid, true);
});
test('fromHijri throws for out-of-range UAQ date', () => {
// 1301 is before the UAQ table begins (coverage starts at 1318)
assert.throws(() => dayjs.fromHijri(1301, 1, 1), /Invalid or out-of-range/);
});
test('formatHijri passthrough: iYYYY YYYY contains both Hijri and Gregorian year', () => {
const result = dayjs(D_RAMADAN_1444).formatHijri('iYYYY YYYY');
// Should contain '1444' (Hijri) and '2023' (Gregorian)
assert.ok(result.includes('1444'), `Expected Hijri year 1444 in: ${result}`);
assert.ok(result.includes('2023'), `Expected Gregorian year 2023 in: ${result}`);
});
console.log(`\n${passed}/${total} tests passed`);

View file

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

View file

@ -9,7 +9,7 @@ export default defineConfig({
splitting: false,
sourcemap: true,
target: 'es2020',
platform: 'node',
platform: 'neutral',
external: ['dayjs', 'hijri-core'],
outExtension({ format }) {
return { js: format === 'esm' ? '.mjs' : '.cjs' };

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
}