Compare commits

...

25 commits
v1.0.0 ... main

Author SHA1 Message Date
Aric Camarata
1308ee107f
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 43s
CI / Test (Node 22) (push) Failing after 35s
CI / Test (Node 24) (push) Failing after 37s
CI / Lint (push) Failing after 33s
CI / Typecheck (push) Failing after 32s
CI / Pack check (push) Failing after 29s
CI / Coverage (push) Failing after 3s
* add Forgejo CI mirror and telemetry disclosure

Mirrors .github/workflows/ci.yml to .forgejo/workflows/ for self-hosted
runner on git.ariccamarata.com. Adds failure-reporting hook stub (server
registration via nself sentry ci enable is a server-side step). Adds
telemetry disclosure section to README.

* add opt-in telemetry via @acamarata/telemetry (off by default)

* chore: update lockfile for @acamarata/telemetry devDep

* chore: fix prettier formatting on telemetry import
2026-06-30 15:56:48 -04:00
Aric Camarata
a0643e0b86 chore: bump to v1.0.4 2026-06-13 11:52:42 -04:00
Aric Camarata
ab908e6eba build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:11:20 -04:00
Aric Camarata
903fa34896 chore: bump to v1.0.3 2026-06-10 16:50:42 -04:00
Aric Camarata
3b90105caf chore: update hijri-core to 1.0.3 2026-06-10 16:50:02 -04:00
Aric Camarata
c6e9a49d19 fix: convert the displayed calendar date in toHijri for hijri-core's UTC-day contract
moment.fn.toHijri now passes new Date(Date.UTC(this.year(), this.month(), this.date()))
to hijri-core instead of the raw instant (this.toDate()). This converts the calendar
date the moment instance displays, respecting utc() mode, rather than the underlying
millisecond value — eliminating wrong-Hijri-day results around UTC-midnight for hosts
east or west of UTC.

Lock-step with hijri-core fix/utc-day-boundary (commit 3419378). fromHijri path
was already correct; its comment updated for clarity.
2026-06-10 16:35:48 -04:00
Aric Camarata
1e3044c859 ci: fix coverage corepack order, eslint parser devDeps, pack-check d.mts
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin to devDependencies
- Fix eslint.config.mjs: add files pattern and parserOptions.project for typed linting
- Fix prettier formatting in src/index.ts and src/types.ts
- Add coverage/ to .gitignore and untrack committed coverage artifacts
- postbuild cp already handles dist/index.d.mts (verified)
2026-05-31 08:48:36 -04:00
Aric Camarata
b1fba5f68d chore: bump to v1.0.2 2026-05-30 19:19:21 -04:00
Aric Camarata
94d8b8f8c2 chore: P1 polish and documentation updates 2026-05-30 18:40:33 -04:00
Aric Camarata
b96b21a861 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
50b6665035 chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:11:48 -04:00
Aric Camarata
60f2ac366a ci: corepack before setup-node, scope prettier to src/, emit d.mts 2026-05-29 20:05:42 -04:00
Aric Camarata
62c247b942 chore: E6 polish wiki content + ADR-015 CI updates (P1) 2026-05-29 07:15:49 -04:00
Aric Camarata
1ffea38e1c chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:41 -04:00
Aric Camarata
308e5e2c48 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:02 -04:00
Aric Camarata
baf7fc616b chore(config): add AGENTS.md for dual-harness parity 2026-05-25 15:51:10 -04:00
Aric Camarata
04f7e43f3d chore: align repository structure with portfolio documentation standards 2026-05-15 15:27:08 -04:00
Aric Camarata
6b89778c6a Add GitHub Sponsors funding config 2026-03-28 18:18:50 -04:00
Aric Camarata
d1373ece1c style: fix prettier table formatting in wiki 2026-03-08 17:30:56 -04:00
Aric Camarata
efc9e60dd9 docs: add Architecture section to README 2026-03-08 17:10:47 -04:00
Aric Camarata
c6f5bc7211 ci: let pnpm/action-setup read version from packageManager field 2026-03-08 16:52:34 -04:00
Aric Camarata
e64150b337 docs: add Compatibility and Acknowledgments sections to README 2026-03-08 16:46:31 -04:00
Aric Camarata
4fba2ec221 ci: pin pnpm to version 10 in all CI jobs
Also enable sourcemap: true in tsup config
2026-03-08 16:37:44 -04:00
Aric Camarata
7d7e723555 refactor: code quality improvements across the board 2026-03-08 11:37:55 -04:00
Aric Camarata
a846d93fe9 chore: add forceConsistentCasingInFileNames to tsconfig 2026-02-25 15:25:36 -05:00
41 changed files with 2921 additions and 415 deletions

View file

@ -3,12 +3,15 @@ root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
trim_trailing_whitespace = true
[*.{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]

140
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,140 @@
# Forgejo CI mirror — git.ariccamarata.com
# Mirrors .github/workflows/ci.yml for the self-hosted Forgejo Actions runner.
# Keep in sync with the GitHub workflow; only addition is the nSentry failure step.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- 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 test.mjs
- run: node --test test-cjs.cjs
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
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
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
typecheck:
name: Typecheck
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 typecheck
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
pack-check:
name: Pack check
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
- name: Verify pack contents
run: |
npm pack --dry-run 2>&1 | tee pack-output.txt
grep "dist/index.cjs" pack-output.txt
grep "dist/index.mjs" pack-output.txt
grep "dist/index.d.ts" pack-output.txt
grep "dist/index.d.mts" pack-output.txt
grep "README.md" pack-output.txt
grep "CHANGELOG.md" pack-output.txt
grep "LICENSE" pack-output.txt
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
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
- name: Coverage
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
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2

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

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

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

@ -0,0 +1,20 @@
# 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
- Initial release
- `toHijri()` instance method: convert a moment to a Hijri date object
- `hijriYear()`, `hijriMonth()`, `hijriDay()` convenience accessors
- `isValidHijri()` range check
- `formatHijri()` with 13 Hijri-specific format tokens
- `moment.fromHijri()` static factory for constructing moments from Hijri dates
- Umm al-Qura (UAQ) calendar support via hijri-core (default)
- FCNA/ISNA calendar support via hijri-core
- Full TypeScript definitions with module augmentation for `moment.Moment` and `moment.MomentStatic`
- Dual CJS/ESM build with separate type declaration files

View file

@ -41,11 +41,11 @@ const h = moment(new Date(2023, 2, 23)).toHijri({ calendar: 'fcna' });
**HijriDate fields:**
| Field | Type | Description |
| --- | --- | --- |
| `hy` | `number` | Hijri year |
| `hm` | `number` | Hijri month (1 = Muharram, 12 = Dhul Hijjah) |
| `hd` | `number` | Hijri day (1-30) |
| Field | Type | Description |
| ----- | -------- | -------------------------------------------- |
| `hy` | `number` | Hijri year |
| `hm` | `number` | Hijri month (1 = Muharram, 12 = Dhul Hijjah) |
| `hd` | `number` | Hijri day (1-30) |
---
@ -89,7 +89,7 @@ Returns `true` if the date falls within the supported range of the chosen calend
```javascript
moment(new Date(2023, 2, 23)).isValidHijri(); // => true
moment(new Date(1900, 0, 1)).isValidHijri(); // => false (before UAQ range)
moment(new Date(1900, 0, 1)).isValidHijri(); // => false (before UAQ range)
```
---
@ -116,21 +116,21 @@ moment(new Date(2023, 2, 23)).formatHijri('iD iMMMM iYYYY [CE:] MMMM D, YYYY');
**Format tokens:**
| Token | Example output | Description |
| --- | --- | --- |
| `iYYYY` | `1444` | Hijri year, 4+ digits, zero-padded to 4 |
| `iYY` | `44` | Hijri year, last 2 digits, zero-padded |
| `iMMMM` | `Ramadan` | Month long name |
| `iMMM` | `Ramadan` | Month medium name |
| `iMM` | `09` | Month number, zero-padded |
| `iM` | `9` | Month number |
| `iDD` | `01` | Day, zero-padded |
| `iD` | `1` | Day |
| `iEEEE` | `Yawm al-Khamis` | Weekday long name |
| `iEEE` | `Kham` | Weekday short name |
| `iE` | `5` | Weekday numeric (1=Sunday, 7=Saturday) |
| `ioooo` | `AH` | Era, long |
| `iooo` | `AH` | Era, short |
| Token | Example output | Description |
| ------- | ---------------- | --------------------------------------- |
| `iYYYY` | `1444` | Hijri year, 4+ digits, zero-padded to 4 |
| `iYY` | `44` | Hijri year, last 2 digits, zero-padded |
| `iMMMM` | `Ramadan` | Month long name |
| `iMMM` | `Ramadan` | Month medium name |
| `iMM` | `09` | Month number, zero-padded |
| `iM` | `9` | Month number |
| `iDD` | `01` | Day, zero-padded |
| `iD` | `1` | Day |
| `iEEEE` | `Yawm al-Khamis` | Weekday long name |
| `iEEE` | `Kham` | Weekday short name |
| `iE` | `5` | Weekday numeric (1=Sunday, 7=Saturday) |
| `ioooo` | `AH` | Era, long |
| `iooo` | `AH` | Era, short |
---
@ -160,10 +160,10 @@ interface ConversionOptions {
}
```
| Calendar ID | Description |
| --- | --- |
| `uaq` | Umm al-Qura: official Saudi calendar, tabular, covers AH 1318-1500 |
| `fcna` | FCNA/ISNA: Fiqh Council of North America calculated calendar |
| Calendar ID | Description |
| ----------- | ------------------------------------------------------------------ |
| `uaq` | Umm al-Qura: official Saudi calendar, tabular, covers AH 1318-1500 |
| `fcna` | FCNA/ISNA: Fiqh Council of North America calculated calendar |
Custom calendars can be registered with hijri-core's `registerCalendar()`.

View file

@ -42,7 +42,7 @@ The augmentation is emitted in the declaration files produced by tsup, so consum
The regex is ordered longest-match-first to prevent prefix collisions:
```javascript
/iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g
/iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g;
```
`iYYYY` must appear before `iYY` for obvious reasons; `iMMMM` before `iMMM` and `iMM`; `iDD` before `iD`; `iEEEE` before `iEEE`. The global flag allows the regex to find all non-overlapping tokens in one pass.
@ -66,21 +66,21 @@ Because moment-hijri-plus uses hijri-core as a peer dependency, the registry is
tsup produces four files:
| File | Format | Purpose |
| --- | --- | --- |
| `dist/index.cjs` | CommonJS | `require()` in Node.js and bundlers in CJS mode |
| `dist/index.mjs` | ESM | `import` in Node.js, Vite, Rollup, esbuild |
| `dist/index.d.ts` | CJS declaration | Types for CJS consumers (`require`) |
| `dist/index.d.mts` | ESM declaration | Types for ESM consumers (`import`) |
| File | Format | Purpose |
| ------------------ | --------------- | ----------------------------------------------- |
| `dist/index.cjs` | CommonJS | `require()` in Node.js and bundlers in CJS mode |
| `dist/index.mjs` | ESM | `import` in Node.js, Vite, Rollup, esbuild |
| `dist/index.d.ts` | CJS declaration | Types for CJS consumers (`require`) |
| `dist/index.d.mts` | ESM declaration | Types for ESM consumers (`import`) |
Both `moment` and `hijri-core` are marked external, so they are not bundled. They resolve from the consumer's `node_modules` at runtime.
## Calendar coverage
| Calendar | ID | Range | Authority |
| --- | --- | --- | --- |
| Umm al-Qura | `uaq` | AH 1318-1500 (approx CE 1900-2076) | Official Saudi calendar |
| FCNA/ISNA | `fcna` | Calculated, no hard range | Fiqh Council of North America |
| Calendar | ID | Range | Authority |
| ----------- | ------ | ---------------------------------- | ----------------------------- |
| Umm al-Qura | `uaq` | AH 1318-1500 (approx CE 1900-2076) | Official Saudi calendar |
| FCNA/ISNA | `fcna` | Calculated, no hard range | Fiqh Council of North America |
The UAQ calendar is tabular: dates are looked up in a precomputed table published by the Umm al-Qura University. Dates outside the table return `null`. The FCNA calendar uses an astronomical calculation rule and has no strict boundary.

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.

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

@ -0,0 +1,53 @@
# Contributing to moment-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/moment-hijri-plus.git
cd moment-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/moment-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.
## Note on Moment.js
Moment.js is in maintenance mode. The authors recommend Luxon, Day.js, or date-fns for new projects. This package targets existing codebases already using Moment.js. Bug fixes are welcome; new features that require significant new Moment.js integration are unlikely to be accepted.
## 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
moment-hijri-plus is a pure calendar computation library. It accepts Moment.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: moment-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 @@
[moment-hijri-plus](https://github.com/acamarata/moment-hijri-plus) · MIT License · [npm](https://www.npmjs.com/package/moment-hijri-plus) · [Issues](https://github.com/acamarata/moment-hijri-plus/issues)

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

@ -0,0 +1,25 @@
**[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)
- [installHijri](api/installHijri)
- [toHijri](api/toHijri)
- [fromHijri](api/fromHijri)
- [formatHijri](api/formatHijri)
- [hijriYear / hijriMonth / hijriDay](api/hijriYear-hijriMonth-hijriDay)
- [isValidHijri](api/isValidHijri)
- [Architecture](Architecture)
- [Benchmarks](benchmarks/index)
**Community**
- [Contributing](CONTRIBUTING)
- [Code of Conduct](CODE_OF_CONDUCT)
- [Security](SECURITY)

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

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

45
.github/wiki/api/functions/default.md vendored Normal file
View file

@ -0,0 +1,45 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-hijri-plus](../README.md) / default
# Function: default()
> **default**(`momentInstance`): `void`
Defined in: [src/index.ts:150](https://github.com/acamarata/moment-hijri-plus/blob/b96b21a86195492a30c50860eaa4dcadad9946ab/src/index.ts#L150)
Install the Hijri plugin into the provided Moment.js instance.
Mutates `momentInstance.fn` to add instance methods (`toHijri`, `hijriYear`,
`hijriMonth`, `hijriDay`, `isValidHijri`, `formatHijri`) and attaches
`momentInstance.fromHijri` as a static factory. Call once at application startup.
The call is idempotent: calling it a second time overwrites the methods with
identical implementations.
## Parameters
### momentInstance
`__module`
The Moment.js constructor to augment. Pass your imported
`moment` directly. Works with any moment instance, including locale-scoped ones.
## Returns
`void`
## Example
```ts
import moment from 'moment';
import installHijri from 'moment-hijri-plus';
installHijri(moment);
moment(new Date(2023, 2, 23)).toHijri();
// => { hy: 1444, hm: 9, hd: 1 }
```

View file

@ -0,0 +1,21 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## Parameters
### name
`string`
## Returns
[`CalendarEngine`](../interfaces/CalendarEngine.md)

View file

@ -0,0 +1,15 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## Returns
`string`[]

View file

@ -0,0 +1,25 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## Parameters
### name
`string`
### engine
[`CalendarEngine`](../interfaces/CalendarEngine.md)
## Returns
`void`

View file

@ -0,0 +1,111 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## 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,17 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## 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,33 @@
[**moment-hijri-plus v1.0.1**](../README.md)
***
[moment-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
## 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

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

@ -0,0 +1,49 @@
# 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 |
|---|---|---|
| `m.iYear()` | ~0.6 µs/call | ~14 µs/call |
| `m.toHijri()` | ~0.6 µs/call | ~14 µs/call |
| `moment.fromHijri()` | ~0.7 µs/call | ~15 µs/call |
| `m.format('iD iMMMM iYYYY')` | ~1.4 µs/call | ~15 µs/call |
UAQ uses a precomputed lookup table (O(1) lookup). FCNA uses an arithmetic algorithm per call, which accounts for the ~24x difference.
For UI rendering the numbers are well below perceptible latency. In batch-processing scenarios (thousands of calls), prefer UAQ or run the work off the main thread.
## Bundle size
| Module | Min+gz |
|---|---|
| moment-hijri-plus (wrapper only) | ~1.6 KB |
| hijri-core/uaq (peer dep, UAQ engine) | ~5.3 KB |
| hijri-core/fcna (peer dep, FCNA engine) | ~3.1 KB |
| moment (peer dep, separate) | ~72 KB |
Moment.js itself is the dominant bundle cost. The plugin adds a negligible ~1.6 KB. If bundle size is a concern for a new project, Day.js + [dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus) delivers the same Hijri API with a much smaller footprint.
## Reproducing the benchmarks
```javascript
import moment from 'moment';
import { installHijri } from 'moment-hijri-plus';
installHijri(moment);
const dates = Array.from({ length: 1000 }, (_, i) =>
moment('1900-01-01').add(i * 26, 'days')
);
const start = performance.now();
for (const m of dates) {
m.iYear();
}
const elapsed = performance.now() - start;
console.log(`${(elapsed / dates.length * 1000).toFixed(1)} µs/call`);
```
Run with `node --version` >= 20.

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

@ -0,0 +1,75 @@
# Basic Usage
## Setup
```typescript
import moment from 'moment';
import installHijri from 'moment-hijri-plus';
installHijri(moment);
```
## Convert today's date to Hijri
```typescript
const today = moment();
const h = today.toHijri();
// Returns null if date is outside the UAQ range; guard before use.
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 m = moment('2023-03-23');
console.log(m.hijriYear()); // 1444
console.log(m.hijriMonth()); // 9 (Ramadan is the 9th month)
console.log(m.hijriDay()); // 1
```
## Convert from Hijri to Gregorian
```typescript
const gregorian = moment.fromHijri(1444, 9, 1);
console.log(gregorian.format('YYYY-MM-DD')); // '2023-03-23'
```
## Format with Hijri tokens
```typescript
const m = moment('2023-03-23');
m.formatHijri('iD iMMMM iYYYY'); // '1 Ramadan 1444'
m.formatHijri('iDD/iMM/iYYYY'); // '01/09/1444'
m.formatHijri('YYYY-MM-DD'); // no Hijri tokens; passes through to moment.format
m.formatHijri('YYYY (iYYYY/iM/iD)'); // '2023 (1444/9/1)'
```
## Use FCNA calendar
```typescript
const m = moment('2023-03-23');
const uaqYear = m.hijriYear(); // UAQ (default)
const fcnaYear = m.hijriYear({ calendar: 'fcna' }); // FCNA
console.log(uaqYear, fcnaYear);
// Near month boundaries, UAQ and FCNA may differ by one day.
```
## CJS usage
```javascript
const moment = require('moment');
const installHijri = require('moment-hijri-plus').default;
installHijri(moment);
const m = moment('2023-03-23');
console.log(m.hijriYear()); // 1444
```

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

@ -0,0 +1,83 @@
# Formatting Examples
## Hijri format token reference
| Token | Output | Example |
|---|---|---|
| `iYYYY` | Full Hijri year | `1444` |
| `iMMMM` | Full month name | `Ramadan` |
| `iMMM` | Abbreviated month name | `Ram` |
| `iMM` | 2-digit month number | `09` |
| `iM` | Month number | `9` |
| `iDD` | 2-digit day | `01` |
| `iD` | Day number | `1` |
Tokens not prefixed with `i` are passed through to Moment.js as Gregorian tokens.
## Common format patterns
```typescript
import moment from 'moment';
import { installHijri } from 'moment-hijri-plus';
installHijri(moment);
const m = moment('2023-03-23');
// Day Month Year (long)
m.format('iD iMMMM iYYYY');
// '1 Ramadan 1444'
// Numeric date
m.format('iDD/iMM/iYYYY');
// '01/09/1444'
// Combined Gregorian and Hijri
m.format('YYYY-MM-DD (iD iMMMM iYYYY)');
// '2023-03-23 (1 Ramadan 1444)'
// ISO-style Hijri
m.format('iYYYY-iMM-iDD');
// '1444-09-01'
```
## Hijri month names
The `iMMMM` token returns the standard English transliteration for each Hijri month:
| Number | Full name | Abbreviated |
|---|---|---|
| 1 | Muharram | Muh |
| 2 | Safar | Saf |
| 3 | Rabi' al-Awwal | Rab1 |
| 4 | Rabi' al-Thani | Rab2 |
| 5 | Jumada al-Awwal | Jum1 |
| 6 | Jumada al-Thani | Jum2 |
| 7 | Rajab | Raj |
| 8 | Sha'ban | Sha |
| 9 | Ramadan | Ram |
| 10 | Shawwal | Shaw |
| 11 | Dhu al-Qa'dah | DhuQ |
| 12 | Dhu al-Hijjah | DhuH |
## Note on locales
Moment.js locale settings affect how Gregorian tokens are formatted but have no effect on Hijri tokens. The `iMMMM` token always produces the English transliterations shown above. To localize Hijri month names, build a lookup table with your own translations and use the `iM` (numeric) token to index into it.
## React example
```tsx
import moment from 'moment';
import { installHijri } from 'moment-hijri-plus';
installHijri(moment);
function HijriDate({ date }: { date: Date }) {
const m = moment(date);
return (
<time dateTime={m.format('YYYY-MM-DD')}>
{m.format('iD iMMMM iYYYY')}
</time>
);
}
```

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

@ -0,0 +1,61 @@
# Advanced Usage
## Switching calendars per call
Each method accepts an optional options argument:
```typescript
import moment from 'moment';
import hijri from 'moment-hijri-plus';
moment.extend(hijri);
const m = moment('2023-03-23');
const uaqYear = m.iYear(); // UAQ (default)
const fcnaYear = m.iYear({ calendar: 'fcna' }); // FCNA
```
Near month boundaries, UAQ and FCNA may differ by one day.
## Null safety
`m.toHijri()` returns `null` for dates outside UAQ range (approximately 1900-2076 CE). Guard before using:
```typescript
const hijri = m.toHijri();
if (hijri !== null) {
console.log(hijri.hy, hijri.hm, hijri.hd);
}
```
## Combining with Moment.js locales
Moment.js locale settings affect Gregorian formatting but not Hijri tokens. Hijri tokens always produce the same English output regardless of locale. To localize Hijri month names, use `getHijriMonthName` from `date-fns-hijri` or build your own translation layer.
## Formatting alongside Gregorian tokens
Hijri tokens (`iYYYY`, `iMM`, `iDD`, `iMMMM`, etc.) coexist with Moment Gregorian tokens:
```typescript
m.format('YYYY-MM-DD (iD iMMMM iYYYY)');
// '2023-03-23 (1 Ramadan 1444)'
```
## Moment.js tree-shaking
Moment.js does not tree-shake well. If bundle size is a concern in a new project, consider migrating to Day.js + [dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus) for the same Hijri API with significantly smaller bundles.
## TypeScript augmentation
The plugin augments Moment.js type definitions automatically:
```typescript
import moment from 'moment';
import hijri from 'moment-hijri-plus';
moment.extend(hijri);
const m = moment('2023-03-23');
const year: number = m.iYear(); // fully typed
```

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

@ -0,0 +1,85 @@
# Quick Start
This guide covers the most common use cases in moment-hijri-plus. All examples use the default Umm al-Qura (UAQ) calendar.
## Installation
```bash
pnpm add moment moment-hijri-plus hijri-core
```
`moment` and `hijri-core` are required peer dependencies. Install both alongside this package.
## Load the plugin
```typescript
import moment from 'moment';
import hijri from 'moment-hijri-plus';
moment.extend(hijri);
```
After extending, all `moment()` instances gain Hijri methods.
## Convert a Gregorian date to Hijri
```typescript
import moment from 'moment';
import hijri from 'moment-hijri-plus';
moment.extend(hijri);
const m = moment('2023-03-23'); // 1 Ramadan 1444
console.log(m.iYear()); // 1444
console.log(m.iMonth()); // 9
console.log(m.iDate()); // 1
```
## Format with Hijri tokens
```typescript
m.format('iYYYY/iMM/iDD'); // '1444/09/01'
m.format('iD iMMMM iYYYY'); // '1 Ramadan 1444'
```
Hijri format tokens are prefixed with `i` to avoid conflicts with Moment.js Gregorian tokens.
## Convert a Hijri date to a Moment object
```typescript
import moment from 'moment';
import hijri from 'moment-hijri-plus';
moment.extend(hijri);
const m = moment.fromHijri(1444, 9, 1);
console.log(m.format('YYYY-MM-DD')); // '2023-03-23'
```
## Use the FCNA calendar
```typescript
const m = moment('2023-03-23');
console.log(m.iYear({ calendar: 'fcna' })); // 1444
```
## Note on Moment.js
Moment.js is in maintenance mode. The Moment team recommends Luxon, Day.js, or date-fns for new projects. If you are starting fresh, consider [dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus) as a compatible alternative.
## CommonJS
```js
const moment = require('moment');
const hijri = require('moment-hijri-plus');
moment.extend(hijri);
const m = moment('2023-03-23');
console.log(m.iYear(), m.iMonth(), m.iDate()); // 1444 9 1
```
## Next steps
- [API Reference](API-Reference) for the full method list
- [Architecture](Architecture) for how the plugin integrates with Moment.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,25 @@ 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
- name: Coverage
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,22 +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: Sync wiki pages
- name: Sync .github/wiki/ to GitHub Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
path: .wiki/
path: .github/wiki/
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}

11
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store
@ -10,3 +11,13 @@ dist/
# AI agent directories
.cursor/
.copilot/
.aider*
.aider.chat.history.md
.continue/
.codex/
.gemini/
.vscode/*
.idea/
.aider/
.windsurf/
.codeium/

View file

@ -2,19 +2,39 @@
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 moment instance displays (year/month/day
components, respecting `.utc()` mode) rather than passing the raw instant to hijri-core.
This eliminates wrong-Hijri-day results around UTC-midnight for 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 (E6 P1 polish)
- Expand TSDoc on all exported symbols with full @param/@returns/@example blocks
- Add docs, postbuild, and improved coverage scripts
- Adopt shared config packages (@acamarata/eslint-config, tsconfig, prettier-config)
## [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
- Initial release
- `toHijri()` instance method: convert a moment to a Hijri date object
- `hijriYear()`, `hijriMonth()`, `hijriDay()` convenience accessors
- `isValidHijri()` range check
- `formatHijri()` with 13 Hijri-specific format tokens
- `moment.fromHijri()` static factory for constructing moments from Hijri dates
- Umm al-Qura (UAQ) calendar support via hijri-core (default)
- FCNA/ISNA calendar support via hijri-core
- Full TypeScript definitions with module augmentation for `moment.Moment` and `moment.MomentStatic`
- Dual CJS/ESM build with separate type declaration files

133
README.md
View file

@ -4,7 +4,9 @@
[![CI](https://github.com/acamarata/moment-hijri-plus/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/moment-hijri-plus/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Moment.js plugin for Hijri calendar conversion and formatting. Delegates all calendar logic to [hijri-core](https://github.com/acamarata/hijri-core), a zero-dependency Hijri engine with pluggable calendar support.
Moment.js plugin for Hijri calendar conversion and formatting. Delegates all calendar
logic to [hijri-core](https://github.com/acamarata/hijri-core), a zero-dependency Hijri
engine with pluggable calendar support (Umm al-Qura and FCNA/ISNA).
## Installation
@ -12,127 +14,68 @@ Moment.js plugin for Hijri calendar conversion and formatting. Delegates all cal
pnpm add moment moment-hijri-plus hijri-core
```
Both `moment` and `hijri-core` are peer dependencies and must be installed alongside this package.
Both `moment` and `hijri-core` are peer dependencies.
## Quick Start
```javascript
```typescript
import moment from 'moment';
import installHijri from 'moment-hijri-plus';
// Install the plugin once at startup.
installHijri(moment);
// Convert a Gregorian date to Hijri.
const m = moment(new Date(2023, 2, 23)); // 23 March 2023
const hijri = m.toHijri();
// => { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444 AH)
m.toHijri(); // { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444 AH)
m.formatHijri('iD iMMMM iYYYY AH'); // '1 Ramadan 1444 AH'
// Format using Hijri tokens.
m.formatHijri('iD iMMMM iYYYY AH');
// => '1 Ramadan 1444 AH'
// Construct a moment from a Hijri date.
const start = moment.fromHijri(1446, 1, 1);
// => moment representing 7 July 2024 (1 Muharram 1446 AH)
moment.fromHijri(1446, 1, 1); // moment for 7 July 2024
```
## API
## API Summary
### Instance methods
Call `installHijri(moment)` once at startup to add these methods.
All methods are added to `moment.Moment` by calling `installHijri(moment)` once.
| Method | Signature | Description |
| Method | Returns | Description |
| --- | --- | --- |
| `toHijri` | `(options?) => HijriDate \| null` | Convert to Hijri. Returns `null` if the date is outside the calendar range. |
| `hijriYear` | `(options?) => number \| null` | Hijri year, or `null` if out of range. |
| `hijriMonth` | `(options?) => number \| null` | Hijri month (1-12), or `null` if out of range. |
| `hijriDay` | `(options?) => number \| null` | Hijri day, or `null` if out of range. |
| `isValidHijri` | `(options?) => boolean` | `true` if the date falls within the supported Hijri range. |
| `formatHijri` | `(formatStr, options?) => string` | Format using Hijri tokens. Returns `''` if out of range. Non-Hijri tokens pass through to `moment.format()`. |
| `toHijri(options?)` | `HijriDate \| null` | Convert to Hijri date object |
| `hijriYear(options?)` | `number \| null` | Hijri year |
| `hijriMonth(options?)` | `number \| null` | Hijri month (1-12) |
| `hijriDay(options?)` | `number \| null` | Hijri day |
| `isValidHijri(options?)` | `boolean` | True if date is within calendar range |
| `formatHijri(fmt, options?)` | `string` | Format with Hijri tokens; non-Hijri tokens pass through |
| `moment.fromHijri(hy, hm, hd, options?)` | `Moment` | Construct moment from Hijri date |
### Static factory
Pass `{ calendar: 'fcna' }` to switch from the default Umm al-Qura calendar to FCNA/ISNA.
| Method | Signature | Description |
| --- | --- | --- |
| `moment.fromHijri` | `(hy, hm, hd, options?) => Moment` | Create a moment from a Hijri date. Throws if the date is invalid or out of range. |
Full API reference, format token table, and examples are in the
[project wiki](https://github.com/acamarata/moment-hijri-plus/wiki).
### Options
## Day boundaries and time zones
```typescript
interface ConversionOptions {
calendar?: string; // 'uaq' (default) | 'fcna'
}
```
Conversions use the calendar date the moment instance displays, not the underlying UTC
instant. A `moment("2025-03-01")` parsed in any local timezone returns the Hijri date
for March 1st, 2025. A moment created with `.utc()` uses its UTC components.
## Calendar Systems
Religious day-start at sunset is outside the scope of this package; it depends on
location and madhab, and must be handled at the application layer.
| ID | Name | Description |
| --- | --- | --- |
| `uaq` | Umm al-Qura | Official calendar of Saudi Arabia. Tabular, covers AH 1318-1500 (1900-2076 CE). Default. |
| `fcna` | FCNA/ISNA | Fiqh Council of North America calculated calendar. |
## Note on Moment.js
Pass the calendar ID via `options`:
```javascript
m.toHijri({ calendar: 'fcna' });
moment.fromHijri(1444, 9, 1, { calendar: 'fcna' });
```
## Format Tokens
`formatHijri()` recognises the following tokens. All other tokens are passed through to `moment.format()`, so you can mix Hijri and Gregorian tokens freely.
| Token | Example | Description |
| --- | --- | --- |
| `iYYYY` | `1444` | Hijri year, 4 digits |
| `iYY` | `44` | Hijri year, 2 digits |
| `iMMMM` | `Ramadan` | Month long name |
| `iMMM` | `Ramadan` | Month medium name |
| `iMM` | `09` | Month, zero-padded |
| `iM` | `9` | Month, no padding |
| `iDD` | `01` | Day, zero-padded |
| `iD` | `1` | Day, no padding |
| `iEEEE` | `Yawm al-Khamis` | Weekday long name |
| `iEEE` | `Kham` | Weekday short name |
| `iE` | `5` | Weekday numeric (1=Sun, 7=Sat) |
| `ioooo` | `AH` | Era, long |
| `iooo` | `AH` | Era, short |
### Mixed format example
```javascript
m.formatHijri('iD iMMMM iYYYY [CE:] MMMM YYYY');
// => '1 Ramadan 1444 CE: March 2023'
```
Bracket escaping (`[...]`) is handled by moment's own formatter for the Gregorian portion.
## TypeScript
The plugin augments `moment.Moment` and `moment.MomentStatic` via module declaration merging, so type safety applies after the plugin is installed. No extra imports are needed for the types.
```typescript
import moment from 'moment';
import installHijri from 'moment-hijri-plus';
import type { HijriDate, ConversionOptions } from 'moment-hijri-plus';
installHijri(moment);
const hijri: HijriDate | null = moment().toHijri();
```
## Documentation
Full API reference, architecture notes, and calendar algorithm details are in the [project wiki](https://github.com/acamarata/moment-hijri-plus/wiki).
Moment.js is in maintenance mode. For new projects,
[dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus) offers the same Hijri
support on Day.js. This package targets existing codebases already using Moment.js.
## Related
- [hijri-core](https://github.com/acamarata/hijri-core): zero-dependency Hijri calendar engine used by this plugin
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): same Hijri support for Luxon
- [hijri-core](https://github.com/acamarata/hijri-core): Hijri calendar engine used internally
- [dayjs-hijri-plus](https://github.com/acamarata/dayjs-hijri-plus): same API for Day.js
- [luxon-hijri](https://github.com/acamarata/luxon-hijri): same API for Luxon
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer time calculation
## License
MIT. Copyright (c) 2026 Aric Camarata.
## Telemetry
This package supports optional, anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry). It is **off by default**. See [TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md) for what is collected and how to enable or disable it.

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)

23
eslint.config.mjs Normal file
View file

@ -0,0 +1,23 @@
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: ['src/**/*.ts'],
plugins: { '@typescript-eslint': tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
},
...typescript.map((cfg) => ({ ...cfg, files: ['src/**/*.ts'] })),
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
},
];

View file

@ -1,6 +1,6 @@
{
"name": "moment-hijri-plus",
"version": "1.0.0",
"version": "1.0.4",
"description": "Moment.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": [
"moment",
@ -45,16 +55,43 @@
"converter",
"typescript"
],
"peerDependencies": { "moment": "^2.0.0", "hijri-core": "^1.0.0" },
"devDependencies": {
"@types/node": "^22.0.0",
"peerDependencies": {
"hijri-core": "^1.0.0",
"moment": "^2.30.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0"
"moment": "^2.0.0"
},
"devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^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",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"hijri-core": "^1.0.3",
"moment": "^2.30.0",
"prettier": "^3.8.1",
"tsup": "^8.0.0",
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.56.1",
"@acamarata/telemetry": "^0.1.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acamarata/moment-hijri-plus.git"
},
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
"repository": { "type": "git", "url": "git+https://github.com/acamarata/moment-hijri-plus.git" },
"homepage": "https://github.com/acamarata/moment-hijri-plus#readme",
"bugs": { "url": "https://github.com/acamarata/moment-hijri-plus/issues" }
"bugs": {
"url": "https://github.com/acamarata/moment-hijri-plus/issues"
},
"type": "module",
"prettier": "@acamarata/prettier-config"
}

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,25 @@
import moment from 'moment';
import type { Moment as MomentInstance } from 'moment';
import {
toHijri,
toGregorian,
hmLong,
hmMedium,
hwLong,
hwShort,
hwNumeric,
} from 'hijri-core';
import type { HijriDate, ConversionOptions } from './types';
import moment from "moment";
import type { Moment as MomentInstance } from "moment";
import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from "hijri-core";
import type { HijriDate, ConversionOptions } from "./types";
declare module 'moment' {
declare module "moment" {
interface MomentStatic {
/**
* Construct a moment from a Hijri date.
* Throws if the date is invalid or outside the supported range.
* Call installHijri(moment) before use.
*
* Delegates to hijri-core's `toGregorian()` to resolve the Gregorian equivalent,
* then constructs the moment from the explicit UTC year/month/day to avoid timezone
* drift when the Date object represents midnight UTC.
*
* @param hy - Hijri year.
* @param hm - Hijri month (1 = Muharram, 12 = Dhul Hijjah).
* @param hd - Hijri day (1-30).
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns A moment positioned at the Gregorian equivalent of the given Hijri date.
* @throws {Error} If the date is invalid or outside the chosen calendar's range.
* @example
* moment.fromHijri(1444, 9, 1).format('YYYY-MM-DD'); // '2023-03-23'
*/
fromHijri(hy: number, hm: number, hd: number, options?: ConversionOptions): MomentInstance;
}
@ -24,29 +27,93 @@ declare module 'moment' {
interface Moment {
/**
* Convert this moment to a Hijri date.
* Returns null if the date falls outside the supported calendar range.
*
* Converts the calendar date this moment instance displays (year/month/day) to a
* Hijri date via hijri-core's `toHijri()`. The conversion is independent of the
* host machine's timezone: a moment in UTC mode uses its UTC components, and a
* moment in local mode uses its local components. The raw instant (milliseconds
* since epoch) is never passed directly to the calendar engine.
*
* The calendar engine performs a table lookup (UAQ) or astronomical calculation (FCNA).
*
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns A `HijriDate` object `{ hy, hm, hd }`, or `null` if the date is outside
* the supported range (UAQ covers approximately CE 1900-2076).
* @example
* moment(new Date(2023, 2, 23)).toHijri();
* // => { hy: 1444, hm: 9, hd: 1 }
*/
toHijri(options?: ConversionOptions): HijriDate | null;
/** Return the Hijri year, or null if out of range. */
/**
* Return the Hijri year.
*
* Convenience wrapper around `toHijri()`. Use `toHijri()` when you need all three
* fields to avoid redundant calendar lookups.
*
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns The Hijri year, or `null` if the date is outside the calendar range.
* @example
* moment(new Date(2023, 2, 23)).hijriYear(); // => 1444
*/
hijriYear(options?: ConversionOptions): number | null;
/** Return the Hijri month (1-12), or null if out of range. */
/**
* Return the Hijri month (1-12).
*
* 1 = Muharram, 9 = Ramadan, 12 = Dhul Hijjah.
*
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns The Hijri month number, or `null` if out of range.
* @example
* moment(new Date(2023, 2, 23)).hijriMonth(); // => 9 (Ramadan)
*/
hijriMonth(options?: ConversionOptions): number | null;
/** Return the Hijri day, or null if out of range. */
/**
* Return the Hijri day (1-30).
*
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns The Hijri day number, or `null` if out of range.
* @example
* moment(new Date(2023, 2, 23)).hijriDay(); // => 1
*/
hijriDay(options?: ConversionOptions): number | null;
/** Return true if this moment falls within the supported Hijri range. */
/**
* Return `true` if this moment falls within the supported Hijri range.
*
* Equivalent to `moment.toHijri(opts) !== null`. Use as a guard before
* calling `toHijri()` on user-supplied dates that may predate CE 1900.
*
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns `true` if the date is within range, `false` otherwise.
* @example
* moment(new Date(2023, 2, 23)).isValidHijri(); // => true
* moment(new Date(1800, 0, 1)).isValidHijri(); // => false
*/
isValidHijri(options?: ConversionOptions): boolean;
/**
* Format this moment using Hijri-aware format tokens.
*
* Hijri tokens: iYYYY iYY iMMMM iMMM iMM iM iDD iD iEEEE iEEE iE ioooo iooo
* All other tokens are passed through to moment's own format().
* Hijri tokens (prefix `i`) are resolved to their Arabic calendar equivalents
* via a single regex pass. The residual format string is passed to `moment.format()`
* so all standard Gregorian tokens resolve normally. This allows mixing Hijri and
* Gregorian tokens in a single format string.
*
* Returns an empty string if the date is outside the Hijri range.
* Supported Hijri tokens: `iYYYY`, `iYY`, `iMMMM`, `iMMM`, `iMM`, `iM`,
* `iDD`, `iD`, `iEEEE`, `iEEE`, `iE`, `ioooo`, `iooo`.
*
* @param formatStr - Format string. Hijri tokens and Moment.js tokens may be mixed freely.
* @param options - Calendar selection. Default: `{ calendar: 'uaq' }`.
* @returns The formatted string, or `''` if the date is outside the Hijri range.
* @example
* moment(new Date(2023, 2, 23)).formatHijri('iD iMMMM iYYYY AH');
* // => '1 Ramadan 1444 AH'
*
* moment(new Date(2023, 2, 23)).formatHijri('iD iMMMM iYYYY [CE:] MMMM D, YYYY');
* // => '1 Ramadan 1444 CE: March 23, 2023'
*/
formatHijri(formatStr: string, options?: ConversionOptions): string;
}
@ -54,28 +121,44 @@ declare module 'moment' {
// Regex matching all Hijri format tokens. Ordered longest-first so iYYYY is
// matched before iYY, iMMMM before iMMM, iDD before iD, iEEEE before iEEE.
const HIJRI_TOKEN_RE =
/iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g;
const HIJRI_TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g;
/**
* Escape a literal string so moment.format() treats it as literal text.
* Wraps the value in square brackets, escaping any ] characters within.
*/
function escapeLiteral(value: string): string {
return '[' + value.replace(/]/g, '][]') + ']';
return "[" + value.replace(/]/g, "][]") + "]";
}
/**
* Install the Hijri plugin into the provided moment instance.
* Install the Hijri plugin into the provided Moment.js instance.
*
* Mutates `momentInstance.fn` to add instance methods (`toHijri`, `hijriYear`,
* `hijriMonth`, `hijriDay`, `isValidHijri`, `formatHijri`) and attaches
* `momentInstance.fromHijri` as a static factory. Call once at application startup.
*
* The call is idempotent: calling it a second time overwrites the methods with
* identical implementations.
*
* @param momentInstance - The Moment.js constructor to augment. Pass your imported
* `moment` directly. Works with any moment instance, including locale-scoped ones.
* @example
* import moment from 'moment';
* import installHijri from 'moment-hijri-plus';
*
* installHijri(moment);
*
* moment(new Date(2023, 2, 23)).toHijri();
* // => { hy: 1444, hm: 9, hd: 1 }
*/
function install(momentInstance: typeof moment): void {
momentInstance.fn.toHijri = function (opts?: ConversionOptions): HijriDate | null {
return toHijri(this.toDate(), opts);
// Use the calendar date this instance displays rather than the raw instant.
// this.year()/.month()/.date() respect utc mode automatically, so a moment
// created with .utc() uses UTC components and a local moment uses local components.
// moment.month() is 0-based, matching Date.UTC's month parameter exactly.
return toHijri(new Date(Date.UTC(this.year(), this.month(), this.date())), opts);
};
momentInstance.fn.hijriYear = function (opts?: ConversionOptions): number | null {
@ -94,43 +177,60 @@ function install(momentInstance: typeof moment): void {
return this.toHijri(opts) !== null;
};
momentInstance.fn.formatHijri = function (
formatStr: string,
opts?: ConversionOptions,
): string {
momentInstance.fn.formatHijri = function (formatStr: string, opts?: ConversionOptions): string {
const hijri = this.toHijri(opts);
if (!hijri) return '';
const m = this;
if (!hijri) return "";
const dow = this.day();
// Replace Hijri tokens with escaped literals, then pass the residual string
// to moment.format() so all standard tokens (YYYY, MMM, etc.) resolve correctly.
// Escaping is required because values like "Ramadan" would otherwise be
// interpreted by moment as format tokens (R, a, m, etc.).
const residual = formatStr.replace(HIJRI_TOKEN_RE, (token: string): string => {
switch (token) {
case 'iYYYY': return escapeLiteral(String(hijri.hy).padStart(4, '0'));
case 'iYY': return escapeLiteral(String(hijri.hy % 100).padStart(2, '0'));
case 'iMMMM': return escapeLiteral(hmLong[hijri.hm - 1]);
case 'iMMM': return escapeLiteral(hmMedium[hijri.hm - 1]);
case 'iMM': return escapeLiteral(String(hijri.hm).padStart(2, '0'));
case 'iM': return escapeLiteral(String(hijri.hm));
case 'iDD': return escapeLiteral(String(hijri.hd).padStart(2, '0'));
case 'iD': return escapeLiteral(String(hijri.hd));
case 'iEEEE': return escapeLiteral(hwLong[m.day()]);
case 'iEEE': return escapeLiteral(hwShort[m.day()]);
case 'iE': return escapeLiteral(String(hwNumeric[m.day()]));
case "iYYYY":
return escapeLiteral(String(hijri.hy).padStart(4, "0"));
case "iYY":
return escapeLiteral(String(hijri.hy % 100).padStart(2, "0"));
case "iMMMM":
// Non-null: hijri.hm is 1-12; hm-1 is always 0-11, within hmLong bounds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return escapeLiteral(hmLong[hijri.hm - 1]!);
case "iMMM":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return escapeLiteral(hmMedium[hijri.hm - 1]!);
case "iMM":
return escapeLiteral(String(hijri.hm).padStart(2, "0"));
case "iM":
return escapeLiteral(String(hijri.hm));
case "iDD":
return escapeLiteral(String(hijri.hd).padStart(2, "0"));
case "iD":
return escapeLiteral(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 escapeLiteral(hwLong[dow]!);
case "iEEE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return escapeLiteral(hwShort[dow]!);
case "iE":
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return escapeLiteral(String(hwNumeric[dow]!));
// Era tokens: both iooo and ioooo map to the common abbreviation.
case 'iooo':
case 'ioooo': return escapeLiteral('AH');
default: return token;
case "iooo":
case "ioooo":
return escapeLiteral("AH");
default:
return token;
}
});
return m.format(residual);
return this.format(residual);
};
// Attach fromHijri as a property on the constructor. We use a type assertion
// because MomentStatic augmentation produces a DTS visibility error with some
// Attach fromHijri as a property on the constructor. We use bracket notation and a type
// assertion because MomentStatic augmentation produces a DTS visibility error with some
// TypeScript configurations; attaching at runtime is equivalent and safe.
(momentInstance as unknown as Record<string, unknown>).fromHijri = function (
(momentInstance as unknown as Record<string, unknown>)["fromHijri"] = function (
hy: number,
hm: number,
hd: number,
@ -145,12 +245,21 @@ function install(momentInstance: typeof moment): void {
if (!greg) {
throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`);
}
// Construct from explicit year/month/day to avoid UTC-to-local timezone
// shift when the Date object represents midnight UTC.
// Construct from explicit year/month/day components so the moment displays
// the correct calendar date regardless of the host timezone offset.
return momentInstance([greg.getUTCFullYear(), greg.getUTCMonth(), greg.getUTCDate()]);
};
}
export default install;
export type { HijriDate, ConversionOptions, CalendarEngine } from 'hijri-core';
export { registerCalendar, getCalendar, listCalendars } from 'hijri-core';
export type { HijriDate, ConversionOptions, CalendarEngine } from "hijri-core";
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: "moment-hijri-plus", version: "1.0.4" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

View file

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

View file

@ -1,75 +1,82 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const moment = require('moment');
const installHijri = require('./dist/index.cjs');
installHijri.default(moment);
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);
}
}
// 1. Plugin installs
test('plugin installs (CJS)', () => {
assert.equal(typeof moment.fn.toHijri, 'function');
assert.equal(typeof moment.fn.formatHijri, 'function');
assert.equal(typeof moment.fromHijri, 'function');
describe('CJS: plugin installation', () => {
it('installs all methods', () => {
assert.equal(typeof moment.fn.toHijri, 'function');
assert.equal(typeof moment.fn.formatHijri, 'function');
assert.equal(typeof moment.fromHijri, 'function');
});
});
// 2. toHijri
test('toHijri: 2023-03-23 => 1444/9/1 (CJS)', () => {
const h = moment(new Date(2023, 2, 23, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1444);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
describe('CJS: toHijri', () => {
it('2023-03-23 => 1444/9/1', () => {
const h = moment(new Date(2023, 2, 23, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1444);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
});
// 3. fromHijri
test('fromHijri: 1444/9/1 => 2023-03-23 (CJS)', () => {
const d = moment.fromHijri(1444, 9, 1).toDate();
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2);
assert.equal(d.getDate(), 23);
describe('CJS: fromHijri', () => {
it('1444/9/1 => 2023-03-23', () => {
const d = moment.fromHijri(1444, 9, 1).toDate();
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2);
assert.equal(d.getDate(), 23);
});
it('throws on out-of-range date', () => {
assert.throws(() => moment.fromHijri(999, 1, 1), /Invalid or out-of-range/);
});
});
// 4. formatHijri: numeric
test('formatHijri: iYYYY-iMM-iDD (CJS)', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
describe('CJS: formatHijri', () => {
it('iYYYY-iMM-iDD', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
});
it('iMMMM => Ramadan', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
});
// 5. formatHijri: month name
test('formatHijri: iMMMM => Ramadan (CJS)', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
describe('CJS: accessors', () => {
it('hijriYear: 1444', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).hijriYear(), 1444);
});
});
// 6. fromHijri throws for invalid date
test('fromHijri throws on out-of-range date (CJS)', () => {
assert.throws(() => moment.fromHijri(999, 1, 1), /Invalid or out-of-range/);
describe('CJS: isValidHijri', () => {
it('true for valid date', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true);
});
});
// 7. hijriYear accessor
test('hijriYear: 1444 (CJS)', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).hijriYear(), 1444);
});
describe('CJS: UTC-day boundary (regression)', () => {
it('fromHijri → toHijri round-trip: 1446/9/1', () => {
const m = moment.fromHijri(1446, 9, 1);
const h = m.toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
// 8. isValidHijri
test('isValidHijri: true for valid date (CJS)', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true);
it('moment("2025-03-01") toHijri => 1446/9/1 (timezone-invariant)', () => {
const h = moment('2025-03-01').toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
});
console.log(`\n${passed}/${total} tests passed`);

220
test.mjs
View file

@ -1,129 +1,135 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import moment from 'moment';
import installHijri from './dist/index.mjs';
installHijri(moment);
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);
}
}
// 1. Plugin installs
test('plugin installs', () => {
assert.equal(typeof moment.fn.toHijri, 'function');
assert.equal(typeof moment.fn.hijriYear, 'function');
assert.equal(typeof moment.fn.hijriMonth, 'function');
assert.equal(typeof moment.fn.hijriDay, 'function');
assert.equal(typeof moment.fn.isValidHijri, 'function');
assert.equal(typeof moment.fn.formatHijri, 'function');
assert.equal(typeof moment.fromHijri, 'function');
describe('plugin installation', () => {
it('installs all methods', () => {
assert.equal(typeof moment.fn.toHijri, 'function');
assert.equal(typeof moment.fn.hijriYear, 'function');
assert.equal(typeof moment.fn.hijriMonth, 'function');
assert.equal(typeof moment.fn.hijriDay, 'function');
assert.equal(typeof moment.fn.isValidHijri, 'function');
assert.equal(typeof moment.fn.formatHijri, 'function');
assert.equal(typeof moment.fromHijri, 'function');
});
});
// 2. toHijri: 1 Ramadan 1444 AH
test('toHijri: 2023-03-23 => 1444/9/1', () => {
const h = moment(new Date(2023, 2, 23, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1444);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
describe('toHijri', () => {
it('2023-03-23 => 1444/9/1', () => {
const h = moment(new Date(2023, 2, 23, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1444);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
it('2024-07-07 => 1446/1/1', () => {
const h = moment(new Date(2024, 6, 7, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 1);
assert.equal(h.hd, 1);
});
});
// 3. toHijri: 1 Muharram 1446 AH
test('toHijri: 2024-07-07 => 1446/1/1', () => {
const h = moment(new Date(2024, 6, 7, 12)).toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 1);
assert.equal(h.hd, 1);
describe('fromHijri', () => {
it('1444/9/1 => 2023-03-23', () => {
const m = moment.fromHijri(1444, 9, 1);
const d = m.toDate();
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2);
assert.equal(d.getDate(), 23);
});
it('1446/1/1 => 2024-07-07', () => {
const m = moment.fromHijri(1446, 1, 1);
const d = m.toDate();
assert.equal(d.getFullYear(), 2024);
assert.equal(d.getMonth(), 6);
assert.equal(d.getDate(), 7);
});
it('throws on out-of-range Hijri date', () => {
assert.throws(() => moment.fromHijri(999, 1, 1), /Invalid or out-of-range/);
});
});
// 4. fromHijri: 1444/9/1 => 2023-03-23
test('fromHijri: 1444/9/1 => 2023-03-23', () => {
const m = moment.fromHijri(1444, 9, 1);
const d = m.toDate();
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2); // March = 2
assert.equal(d.getDate(), 23);
describe('accessors', () => {
it('hijriYear, hijriMonth, hijriDay on 1 Ramadan 1444', () => {
const m = moment(new Date(2023, 2, 23, 12));
assert.equal(m.hijriYear(), 1444);
assert.equal(m.hijriMonth(), 9);
assert.equal(m.hijriDay(), 1);
});
});
// 5. fromHijri: 1446/1/1 => 2024-07-07
test('fromHijri: 1446/1/1 => 2024-07-07', () => {
const m = moment.fromHijri(1446, 1, 1);
const d = m.toDate();
assert.equal(d.getFullYear(), 2024);
assert.equal(d.getMonth(), 6); // July = 6
assert.equal(d.getDate(), 7);
describe('formatHijri', () => {
it('iYYYY-iMM-iDD', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
});
it('iMMMM => Ramadan', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
it('iEEEE on Thursday 2023-03-23', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iEEEE');
assert.equal(result, 'Yawm al-Khamis');
});
it('ioooo => AH', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('ioooo');
assert.equal(result, 'AH');
});
it('mixed Hijri and Gregorian tokens', () => {
const m = moment(new Date(2023, 2, 23, 12));
const result = m.formatHijri('iYYYY [CE:] YYYY');
assert.ok(result.includes('1444'), `Expected Hijri year in: ${result}`);
assert.ok(result.includes('2023'), `Expected Gregorian year in: ${result}`);
});
});
// 6. hijriYear / hijriMonth / hijriDay
test('hijriYear, hijriMonth, hijriDay on 1 Ramadan 1444', () => {
const m = moment(new Date(2023, 2, 23, 12));
assert.equal(m.hijriYear(), 1444);
assert.equal(m.hijriMonth(), 9);
assert.equal(m.hijriDay(), 1);
describe('isValidHijri', () => {
it('true for valid date', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true);
});
});
// 7. formatHijri: numeric format
test('formatHijri: iYYYY-iMM-iDD', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iYYYY-iMM-iDD');
assert.equal(result, '1444-09-01');
describe('FCNA calendar', () => {
it('toHijri with { calendar: fcna } returns a HijriDate', () => {
const h = moment(new Date(2023, 2, 23, 12)).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');
});
});
// 8. formatHijri: long month name
test('formatHijri: iMMMM => Ramadan', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iMMMM');
assert.equal(result, 'Ramadan');
});
describe('UTC-day boundary (regression)', () => {
it('fromHijri → toHijri round-trip: 1446/9/1', () => {
// Construct from Hijri, convert back — must be exact regardless of host TZ.
const m = moment.fromHijri(1446, 9, 1);
const h = m.toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
// 9. formatHijri: long weekday name (Thursday = Yawm al-Khamis)
test('formatHijri: iEEEE on Thursday 2023-03-23', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('iEEEE');
assert.equal(result, 'Yawm al-Khamis');
it('moment("2025-03-01") toHijri => 1446/9/1 (timezone-invariant)', () => {
// moment parses date-only ISO strings as LOCAL midnight.
// toHijri must convert the displayed calendar date (2025-03-01), not the raw
// instant, so the result is the same regardless of the host timezone offset.
const h = moment('2025-03-01').toHijri();
assert.notEqual(h, null);
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
});
// 10. formatHijri: era token
test('formatHijri: ioooo => AH', () => {
const result = moment(new Date(2023, 2, 23, 12)).formatHijri('ioooo');
assert.equal(result, 'AH');
});
// 11. isValidHijri: returns true for in-range date
test('isValidHijri: true for valid date', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true);
});
// 12. FCNA calendar option
test('toHijri with { calendar: fcna } returns a HijriDate', () => {
const h = moment(new Date(2023, 2, 23, 12)).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');
});
// 13. fromHijri throws for out-of-range date
test('fromHijri throws on out-of-range Hijri date', () => {
assert.throws(() => moment.fromHijri(999, 1, 1), /Invalid or out-of-range/);
});
// 14. formatHijri: mixed Hijri and Gregorian tokens
test('formatHijri: mixed Hijri and Gregorian tokens', () => {
const m = moment(new Date(2023, 2, 23, 12));
const result = m.formatHijri('iYYYY [CE:] YYYY');
// Hijri year should be 1444; Gregorian year should be 2023.
assert.ok(result.includes('1444'), `Expected Hijri year in: ${result}`);
assert.ok(result.includes('2023'), `Expected Gregorian year in: ${result}`);
});
console.log(`\n${passed}/${total} tests passed`);

View file

@ -1,15 +1,10 @@
{
"extends": "@acamarata/tsconfig/tsconfig.library.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": 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: ['moment', '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
}