mirror of
https://github.com/acamarata/moment-hijri-plus.git
synced 2026-07-01 03:04:32 +00:00
Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1308ee107f | ||
|
|
a0643e0b86 | ||
|
|
ab908e6eba | ||
|
|
903fa34896 | ||
|
|
3b90105caf | ||
|
|
c6e9a49d19 | ||
|
|
1e3044c859 | ||
|
|
b1fba5f68d | ||
|
|
94d8b8f8c2 | ||
|
|
b96b21a861 | ||
|
|
50b6665035 | ||
|
|
60f2ac366a | ||
|
|
62c247b942 | ||
|
|
1ffea38e1c | ||
|
|
308e5e2c48 | ||
|
|
baf7fc616b | ||
|
|
04f7e43f3d | ||
|
|
6b89778c6a | ||
|
|
d1373ece1c | ||
|
|
efc9e60dd9 | ||
|
|
c6f5bc7211 | ||
|
|
e64150b337 | ||
|
|
4fba2ec221 | ||
|
|
7d7e723555 | ||
|
|
a846d93fe9 |
41 changed files with 2921 additions and 415 deletions
|
|
@ -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
140
.forgejo/workflows/ci.yml
Normal 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
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
github: [acamarata]
|
||||
20
.github/docs/CHANGELOG.md
vendored
Normal file
20
.github/docs/CHANGELOG.md
vendored
Normal 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
|
||||
|
|
@ -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()`.
|
||||
|
||||
|
|
@ -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
29
.github/wiki/CODE_OF_CONDUCT.md
vendored
Normal 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
53
.github/wiki/CONTRIBUTING.md
vendored
Normal 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.
|
||||
0
.wiki/Home.md → .github/wiki/Home.md
vendored
0
.wiki/Home.md → .github/wiki/Home.md
vendored
30
.github/wiki/SECURITY.md
vendored
Normal file
30
.github/wiki/SECURITY.md
vendored
Normal 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
1
.github/wiki/_Footer.md
vendored
Normal 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
25
.github/wiki/_Sidebar.md
vendored
Normal 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
18
.github/wiki/api/README.md
vendored
Normal 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
45
.github/wiki/api/functions/default.md
vendored
Normal 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 }
|
||||
```
|
||||
21
.github/wiki/api/functions/getCalendar.md
vendored
Normal file
21
.github/wiki/api/functions/getCalendar.md
vendored
Normal 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)
|
||||
15
.github/wiki/api/functions/listCalendars.md
vendored
Normal file
15
.github/wiki/api/functions/listCalendars.md
vendored
Normal 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`[]
|
||||
25
.github/wiki/api/functions/registerCalendar.md
vendored
Normal file
25
.github/wiki/api/functions/registerCalendar.md
vendored
Normal 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`
|
||||
111
.github/wiki/api/interfaces/CalendarEngine.md
vendored
Normal file
111
.github/wiki/api/interfaces/CalendarEngine.md
vendored
Normal 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`
|
||||
17
.github/wiki/api/interfaces/ConversionOptions.md
vendored
Normal file
17
.github/wiki/api/interfaces/ConversionOptions.md
vendored
Normal 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
|
||||
33
.github/wiki/api/interfaces/HijriDate.md
vendored
Normal file
33
.github/wiki/api/interfaces/HijriDate.md
vendored
Normal 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
49
.github/wiki/benchmarks/index.md
vendored
Normal 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
75
.github/wiki/examples/basic-usage.md
vendored
Normal 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
83
.github/wiki/examples/formatting.md
vendored
Normal 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
61
.github/wiki/guides/advanced.md
vendored
Normal 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
85
.github/wiki/guides/quickstart.md
vendored
Normal 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
|
||||
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/workflows/wiki-sync.yml
vendored
12
.github/workflows/wiki-sync.yml
vendored
|
|
@ -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
11
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -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
133
README.md
|
|
@ -4,7 +4,9 @@
|
|||
[](https://github.com/acamarata/moment-hijri-plus/actions/workflows/ci.yml)
|
||||
[](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
8
TELEMETRY.md
Normal 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
23
eslint.config.mjs
Normal 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'],
|
||||
},
|
||||
];
|
||||
69
package.json
69
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
1393
pnpm-lock.yaml
1393
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
221
src/index.ts
221
src/index.ts
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export type { HijriDate, ConversionOptions } from 'hijri-core';
|
||||
export type { HijriDate, ConversionOptions } from "hijri-core";
|
||||
|
|
|
|||
113
test-cjs.cjs
113
test-cjs.cjs
|
|
@ -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
220
test.mjs
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
10
typedoc.json
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue