Compare commits

..

No commits in common. "main" and "v1.0.1" have entirely different histories.
main ... v1.0.1

36 changed files with 262 additions and 1905 deletions

46
.claude/AGENTS.md Normal file
View file

@ -0,0 +1,46 @@
# moment-hijri-plus — PRI (Per-Repo Instructions)
**PPI:** `~/Sites/acamarata/.claude/CLAUDE.md`
## What This Is
Moment.js plugin for Hijri calendar conversion and formatting. Delegates all calendar
logic to hijri-core, a zero-dependency Hijri engine with pluggable calendar support.
Supports Umm al-Qura and FCNA/ISNA calendars. Installed via a function call rather than
`moment.extend()` — call `installHijri(moment)` once at startup.
**npm:** `moment-hijri-plus@1.0.0`
**Language:** TypeScript
**License:** MIT
## Key Technical Details
- Peer dependencies: `moment@^2.0.0`, `hijri-core@^1.0.0`
- Plugin pattern: `installHijri(moment)` called once at startup (not moment.fn.extend)
- Instance methods added: `toHijri()`, `hijriYear()`, `hijriMonth()`, `hijriDay()`, `isValidHijri()`, `formatHijri()`
- Static factory added: `moment.fromHijri(hy, hm, hd, options?)`
- `formatHijri()` Hijri tokens: `iD`, `iMMMM`, `iYYYY`, `iM`, `iDD`, `iMM` — non-Hijri tokens pass through to moment.format()
- Out-of-range inputs return `null` (instance methods) or empty string (formatHijri)
- `fromHijri()` throws if the date is invalid or out of range
- Dual CJS/ESM build via tsup
- Zero runtime dependencies (peer deps are provided by the consumer)
## Architecture
`src/index.ts` exports the default `installHijri` function and shared types. Built to
`dist/` (gitignored) with `.cjs` and `.mjs` outputs plus dual type declarations.
## Commands
- `pnpm install` — install dev deps
- `pnpm build` — tsup build
- `pnpm test` — run test.mjs + test-cjs.cjs
- `pnpm run typecheck` — tsc --noEmit
## Important Notes
- This is a plugin for Moment.js — call `installHijri(moment)` once before using any methods
- hijri-core provides the actual calendar engine — this package is a thin adapter
- Changes to hijri-core's API may require updates here
- moment is a peer dep — the consumer's installed moment instance is used (no bundled copy)
- Moment.js is in maintenance mode; this package targets existing moment users, not new projects

View file

@ -1,140 +0,0 @@
# 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

View file

@ -1,29 +0,0 @@
# 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.

View file

@ -1,53 +0,0 @@
# 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.

View file

@ -1,30 +0,0 @@
# 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

View file

@ -1 +0,0 @@
[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)

View file

@ -1,25 +0,0 @@
**[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)

View file

@ -1,18 +0,0 @@
**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)

View file

@ -1,45 +0,0 @@
[**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

@ -1,21 +0,0 @@
[**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

@ -1,15 +0,0 @@
[**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

@ -1,25 +0,0 @@
[**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

@ -1,111 +0,0 @@
[**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

@ -1,17 +0,0 @@
[**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

@ -1,33 +0,0 @@
[**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

View file

@ -1,49 +0,0 @@
# 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.

View file

@ -1,75 +0,0 @@
# 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
```

View file

@ -1,83 +0,0 @@
# 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>
);
}
```

View file

@ -1,61 +0,0 @@
# 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
```

View file

@ -1,85 +0,0 @@
# 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,8 +15,7 @@ jobs:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
@ -31,8 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
@ -46,8 +44,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
@ -60,8 +57,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
@ -78,25 +74,3 @@ 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

@ -11,15 +11,15 @@ permissions:
jobs:
sync:
name: Sync wiki to GitHub Wiki
name: Sync .github/wiki/ to GitHub Wiki
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sync .github/wiki/ to GitHub Wiki
- name: Sync wiki pages
uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
path: .github/wiki/
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store

6
.prettierrc Normal file
View file

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

View file

@ -5,26 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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)
## [Unreleased]
## [1.0.1] - 2026-05-28

164
README.md
View file

@ -4,9 +4,7 @@
[![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 (Umm al-Qura and FCNA/ISNA).
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.
## Installation
@ -14,68 +12,144 @@ engine with pluggable calendar support (Umm al-Qura and FCNA/ISNA).
pnpm add moment moment-hijri-plus hijri-core
```
Both `moment` and `hijri-core` are peer dependencies.
Both `moment` and `hijri-core` are peer dependencies and must be installed alongside this package.
## Quick Start
```javascript
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)
// 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)
```
## API
### Instance methods
All methods are added to `moment.Moment` by calling `installHijri(moment)` once.
| Method | Signature | 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()`. |
### Static factory
| 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. |
### Options
```typescript
interface ConversionOptions {
calendar?: string; // 'uaq' (default) | 'fcna'
}
```
## Calendar Systems
| 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. |
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 m = moment(new Date(2023, 2, 23)); // 23 March 2023
m.toHijri(); // { hy: 1444, hm: 9, hd: 1 } (1 Ramadan 1444 AH)
m.formatHijri('iD iMMMM iYYYY AH'); // '1 Ramadan 1444 AH'
moment.fromHijri(1446, 1, 1); // moment for 7 July 2024
const hijri: HijriDate | null = moment().toHijri();
```
## API Summary
## Architecture
Call `installHijri(moment)` once at startup to add these methods.
A thin plugin wrapper over [hijri-core](https://github.com/acamarata/hijri-core). The plugin augments the Moment.js prototype with Hijri methods, each delegating to the registered calendar engine. Zero global state.
| Method | Returns | Description |
| --- | --- | --- |
| `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 |
For more detail see the [Architecture wiki page](https://github.com/acamarata/moment-hijri-plus/wiki/Architecture).
Pass `{ calendar: 'fcna' }` to switch from the default Umm al-Qura calendar to FCNA/ISNA.
## Documentation
Full API reference, format token table, and examples are in the
[project wiki](https://github.com/acamarata/moment-hijri-plus/wiki).
## Day boundaries and time zones
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.
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.
## Note on Moment.js
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.
Full API reference, architecture notes, and calendar algorithm details are in the [project wiki](https://github.com/acamarata/moment-hijri-plus/wiki).
## Related
- [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
- [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
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer time calculation
## Compatibility
- Node.js 20, 22, 24
- Moment.js 2.x (peer dependency)
- ESM and CJS builds included
- TypeScript definitions bundled
## Acknowledgments
Calendar data and algorithms provided by [hijri-core](https://github.com/acamarata/hijri-core). The Umm al-Qura table is derived from data published by the King Abdulaziz City for Science and Technology (KACST). FCNA new moon calculations follow Jean Meeus, "Astronomical Algorithms," 2nd ed., Chapter 49.
## License
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.

View file

@ -1,8 +0,0 @@
# Telemetry Disclosure
This package supports opt-in anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry).
Telemetry is **off by default**. No data is sent unless you set `ACAMARATA_TELEMETRY=1`.
Full disclosure (what is sent, where it goes, how to disable):
[github.com/acamarata/telemetry/blob/main/TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md)

View file

@ -1,23 +1,12 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
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'] })),
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
},
];
);

View file

@ -1,6 +1,6 @@
{
"name": "moment-hijri-plus",
"version": "1.0.4",
"version": "1.0.1",
"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",
@ -37,10 +37,8 @@
"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"
"prepublishOnly": "tsup",
"coverage": "c8 --reporter=lcov --reporter=text node --test"
},
"keywords": [
"moment",
@ -60,25 +58,16 @@
"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",
"hijri-core": "^1.0.0",
"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"
"typescript-eslint": "^8.56.1"
},
"publishConfig": {
"access": "public",
@ -92,6 +81,5 @@
"bugs": {
"url": "https://github.com/acamarata/moment-hijri-plus/issues"
},
"type": "module",
"prettier": "@acamarata/prettier-config"
"type": "module"
}

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,14 @@
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.
*
* 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'
* Throws if the date is invalid or outside the supported range.
* Call installHijri(moment) before use.
*/
fromHijri(hy: number, hm: number, hd: number, options?: ConversionOptions): MomentInstance;
}
@ -27,93 +16,29 @@ declare module "moment" {
interface Moment {
/**
* Convert this moment to a Hijri date.
*
* 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 }
* Returns null if the date falls outside the supported calendar range.
*/
toHijri(options?: ConversionOptions): HijriDate | null;
/**
* 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
*/
/** Return the Hijri year, or null if out of range. */
hijriYear(options?: ConversionOptions): number | null;
/**
* 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)
*/
/** Return the Hijri month (1-12), or null if out of range. */
hijriMonth(options?: ConversionOptions): number | null;
/**
* 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
*/
/** Return the Hijri day, or null if out of range. */
hijriDay(options?: ConversionOptions): number | null;
/**
* 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
*/
/** Return true if this moment falls within the supported Hijri range. */
isValidHijri(options?: ConversionOptions): boolean;
/**
* Format this moment using Hijri-aware format tokens.
*
* 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.
* 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().
*
* 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'
* Returns an empty string if the date is outside the Hijri range.
*/
formatHijri(formatStr: string, options?: ConversionOptions): string;
}
@ -128,37 +53,20 @@ const HIJRI_TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|i
* 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.js instance.
* Install the Hijri plugin into the provided moment 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 {
// 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);
return toHijri(this.toDate(), opts);
};
momentInstance.fn.hijriYear = function (opts?: ConversionOptions): number | null {
@ -179,7 +87,7 @@ function install(momentInstance: typeof moment): void {
momentInstance.fn.formatHijri = function (formatStr: string, opts?: ConversionOptions): string {
const hijri = this.toHijri(opts);
if (!hijri) return "";
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.
@ -187,39 +95,32 @@ function install(momentInstance: typeof moment): void {
// 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":
// 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":
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":
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]!));
case 'iEEEE':
return escapeLiteral(hwLong[dow]);
case 'iEEE':
return escapeLiteral(hwShort[dow]);
case 'iE':
return escapeLiteral(String(hwNumeric[dow]));
// Era tokens: both iooo and ioooo map to the common abbreviation.
case "iooo":
case "ioooo":
return escapeLiteral("AH");
case 'iooo':
case 'ioooo':
return escapeLiteral('AH');
default:
return token;
}
@ -227,10 +128,10 @@ function install(momentInstance: typeof moment): void {
return this.format(residual);
};
// 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
// Attach fromHijri as a property on the constructor. We use 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,
@ -245,21 +146,12 @@ 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 components so the moment displays
// the correct calendar date regardless of the host timezone offset.
// Construct from explicit year/month/day to avoid UTC-to-local timezone
// shift when the Date object represents midnight UTC.
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";
// ── 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
});
export type { HijriDate, ConversionOptions, CalendarEngine } from 'hijri-core';
export { registerCalendar, getCalendar, listCalendars } from 'hijri-core';

View file

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

View file

@ -61,22 +61,3 @@ describe('CJS: isValidHijri', () => {
assert.equal(moment(new Date(2023, 2, 23, 12)).isValidHijri(), true);
});
});
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);
});
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);
});
});

View file

@ -110,26 +110,3 @@ describe('FCNA calendar', () => {
assert.equal(typeof h.hd, 'number');
});
});
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);
});
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);
});
});

View file

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

View file

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