mirror of
https://github.com/acamarata/dayjs-hijri-plus.git
synced 2026-06-30 18:54:26 +00:00
Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ec33fbc0 | ||
|
|
b5b5c9313a | ||
|
|
04d72ac223 | ||
|
|
3d20009b30 | ||
|
|
f9ad1e52ed | ||
|
|
f8ab0772b9 | ||
|
|
7a57010e7c |
10 changed files with 109 additions and 48 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ dist/
|
|||
.claude/
|
||||
.env
|
||||
.env.*
|
||||
coverage/
|
||||
|
||||
# AI agent directories
|
||||
.cursor/
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,7 +5,15 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
## [1.0.4] - 2026-06-13
|
||||
|
||||
### Fixed
|
||||
- Published package now includes `dist/index.d.mts` so ESM type resolution under `node16`/`nodenext` resolves the import condition.
|
||||
|
||||
## [1.0.3] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- `.toHijri()` now converts the calendar date the dayjs instance displays (via `Date.UTC(year, month, date)`) instead of passing the raw instant to hijri-core. Fixes wrong-Hijri-day results around UTC-midnight instants on hosts east or west of UTC. Requires hijri-core 1.0.3.
|
||||
|
||||
## [1.0.2] - 2026-05-30
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ dayjs.fromHijri(1444, 10, 1).format('YYYY-MM-DD'); // '2023-04-21'
|
|||
|
||||
Full API reference, examples, and architecture notes are on the [GitHub Wiki](https://github.com/acamarata/dayjs-hijri-plus/wiki).
|
||||
|
||||
## Day boundaries and time zones
|
||||
|
||||
`.toHijri()` converts the calendar date the dayjs instance displays — the same date you would read off the screen — regardless of the host's system timezone or whether the dayjs `utc` plugin is active. A call like `dayjs('2025-03-01').toHijri()` always maps the 1st of March 2025, not whatever local instant that string resolves to in UTC.
|
||||
|
||||
Religious start-of-day at sunset is out of scope. Sunset-aware day boundaries require external prayer-time data and are not handled here.
|
||||
|
||||
## Related
|
||||
|
||||
- [hijri-core](https://github.com/acamarata/hijri-core): the zero-dependency Hijri calendar engine this plugin wraps
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@ import { typescript } from '@acamarata/eslint-config';
|
|||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
plugins: { '@typescript-eslint': tsPlugin },
|
||||
languageOptions: { parser: tsParser },
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
|
||||
},
|
||||
},
|
||||
...typescript,
|
||||
...typescript.map((cfg) => ({ ...cfg, files: ['**/*.ts', '**/*.tsx'] })),
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'],
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dayjs-hijri-plus",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"description": "Day.js plugin for Hijri calendar conversion and formatting. Supports Umm al-Qura and FCNA calendars via hijri-core.",
|
||||
"author": "Aric Camarata",
|
||||
"license": "MIT",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
"lint": "eslint src/",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"prepublishOnly": "tsup",
|
||||
"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"
|
||||
|
|
@ -60,15 +60,17 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@acamarata/eslint-config": "^0.1.0",
|
||||
"c8": "^10.1.3",
|
||||
"@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",
|
||||
"dayjs": "^1.11.0",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"hijri-core": "^1.0.0",
|
||||
"hijri-core": "^1.0.3",
|
||||
"prettier": "^3.8.1",
|
||||
"tsup": "^8.0.0",
|
||||
"typedoc": "^0.28.19",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ importers:
|
|||
'@types/node':
|
||||
specifier: ^25.3.5
|
||||
version: 25.3.5
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.56.1
|
||||
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.56.1
|
||||
version: 8.56.1(eslint@10.0.3)(typescript@5.9.3)
|
||||
c8:
|
||||
specifier: ^10.1.3
|
||||
version: 10.1.3
|
||||
|
|
@ -36,8 +42,8 @@ importers:
|
|||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@10.0.3)
|
||||
hijri-core:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
|
|
@ -818,8 +824,8 @@ packages:
|
|||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hijri-core@1.0.0:
|
||||
resolution: {integrity: sha512-wImBZLBKbEWEEUE1nrc1CFY/uvx4XjGNWYChImJZlswXIVhrBCzSVaj6DP1AU2gUMJ6KDh2ygXo/u/Qx232CXA==}
|
||||
hijri-core@1.0.3:
|
||||
resolution: {integrity: sha512-ONT5gp+Z/lzT/BbWKS0F8lcKK9T/H6jc95NLQE4Angdt7Uimo4KkmSt/qn9odaQRp1pX0RjA65QRcR4miF7XxA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
|
|
@ -1880,7 +1886,7 @@ snapshots:
|
|||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hijri-core@1.0.0: {}
|
||||
hijri-core@1.0.3: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
|
|
|
|||
76
src/index.ts
76
src/index.ts
|
|
@ -1,9 +1,9 @@
|
|||
import type { PluginFunc } from 'dayjs';
|
||||
import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from 'hijri-core';
|
||||
import type { ConversionOptions, HijriDate } from './types';
|
||||
import type { PluginFunc } from "dayjs";
|
||||
import { toHijri, toGregorian, hmLong, hmMedium, hwLong, hwShort, hwNumeric } from "hijri-core";
|
||||
import type { ConversionOptions, HijriDate } from "./types";
|
||||
|
||||
// Augment Day.js to expose plugin methods on the instance type.
|
||||
declare module 'dayjs' {
|
||||
declare module "dayjs" {
|
||||
interface Dayjs {
|
||||
/**
|
||||
* Convert the Day.js date to a Hijri date.
|
||||
|
|
@ -91,7 +91,7 @@ declare module 'dayjs' {
|
|||
// Using the function declaration form (same pattern as dayjs timezone plugin)
|
||||
// because dayjs does not export an IStatic interface for module augmentation.
|
||||
// import('dayjs').Dayjs is used explicitly to satisfy the tsup DTS emitter.
|
||||
declare module 'dayjs' {
|
||||
declare module "dayjs" {
|
||||
/**
|
||||
* Construct a Day.js instance from a Hijri date.
|
||||
*
|
||||
|
|
@ -117,7 +117,7 @@ declare module 'dayjs' {
|
|||
hm: number,
|
||||
hd: number,
|
||||
opts?: ConversionOptions,
|
||||
): import('dayjs').Dayjs;
|
||||
): import("dayjs").Dayjs;
|
||||
}
|
||||
|
||||
// Hijri-specific format tokens, ordered longest-first to prevent partial matches.
|
||||
|
|
@ -138,7 +138,7 @@ const HIJRI_TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|i
|
|||
* @returns The bracket-escaped string.
|
||||
*/
|
||||
function lit(value: string): string {
|
||||
return '[' + value.split(']').join(']][') + ']';
|
||||
return "[" + value.split("]").join("]][") + "]";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,7 +167,11 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
|
|||
// ------------------------------------------------------------------ //
|
||||
|
||||
dayjsClass.prototype.toHijri = function (opts?: ConversionOptions): HijriDate | null {
|
||||
return toHijri(this.toDate(), opts);
|
||||
// Build a UTC-noon Date from the calendar date this instance displays so
|
||||
// that hijri-core's UTC-day contract reads the correct day regardless of
|
||||
// the host timezone or whether the dayjs utc plugin is active.
|
||||
// dayjs .month() is 0-based, matching Date.UTC's month parameter.
|
||||
return toHijri(new Date(Date.UTC(this.year(), this.month(), this.date())), opts);
|
||||
};
|
||||
|
||||
dayjsClass.prototype.isValidHijri = function (opts?: ConversionOptions): boolean {
|
||||
|
|
@ -191,7 +195,7 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
|
|||
opts?: ConversionOptions,
|
||||
): string {
|
||||
const hijri = this.toHijri(opts);
|
||||
if (!hijri) return '';
|
||||
if (!hijri) return "";
|
||||
|
||||
// Day.js .day() returns 0 (Sunday) ... 6 (Saturday), matching the index
|
||||
// layout of hwLong, hwShort, and hwNumeric from hijri-core.
|
||||
|
|
@ -199,38 +203,38 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
|
|||
|
||||
const replaced = formatStr.replace(HIJRI_TOKEN_RE, (token) => {
|
||||
switch (token) {
|
||||
case 'iYYYY':
|
||||
return lit(String(hijri.hy).padStart(4, '0'));
|
||||
case 'iYY':
|
||||
return lit(String(hijri.hy % 100).padStart(2, '0'));
|
||||
case 'iMMMM':
|
||||
case "iYYYY":
|
||||
return lit(String(hijri.hy).padStart(4, "0"));
|
||||
case "iYY":
|
||||
return lit(String(hijri.hy % 100).padStart(2, "0"));
|
||||
case "iMMMM":
|
||||
// Non-null: hijri.hm is a valid Hijri month 1-12; hm-1 is always within hmLong bounds.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return lit(hmLong[hijri.hm - 1]!);
|
||||
case 'iMMM':
|
||||
case "iMMM":
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return lit(hmMedium[hijri.hm - 1]!);
|
||||
case 'iMM':
|
||||
return lit(String(hijri.hm).padStart(2, '0'));
|
||||
case 'iM':
|
||||
case "iMM":
|
||||
return lit(String(hijri.hm).padStart(2, "0"));
|
||||
case "iM":
|
||||
return lit(String(hijri.hm));
|
||||
case 'iDD':
|
||||
return lit(String(hijri.hd).padStart(2, '0'));
|
||||
case 'iD':
|
||||
case "iDD":
|
||||
return lit(String(hijri.hd).padStart(2, "0"));
|
||||
case "iD":
|
||||
return lit(String(hijri.hd));
|
||||
case 'iEEEE':
|
||||
case "iEEEE":
|
||||
// Non-null: dow is always 0-6 (day of week), within hwLong bounds.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return lit(hwLong[dow]!);
|
||||
case 'iEEE':
|
||||
case "iEEE":
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return lit(hwShort[dow]!);
|
||||
case 'iE':
|
||||
case "iE":
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return lit(String(hwNumeric[dow]!));
|
||||
case 'ioooo':
|
||||
case 'iooo':
|
||||
return lit('AH');
|
||||
case "ioooo":
|
||||
case "iooo":
|
||||
return lit("AH");
|
||||
default:
|
||||
return token;
|
||||
}
|
||||
|
|
@ -262,12 +266,14 @@ const plugin: PluginFunc = (_option, dayjsClass, dayjsFactory) => {
|
|||
if (!greg) {
|
||||
throw new Error(`Invalid or out-of-range Hijri date: ${hy}/${hm}/${hd}`);
|
||||
}
|
||||
// Construct from ISO date string to avoid timezone offset issues.
|
||||
// dayjsFactory(Date) interprets the Date in local time; a UTC-midnight Date
|
||||
// in western timezones would resolve to the previous local day.
|
||||
// Construct from an ISO date string (YYYY-MM-DD) so the result is the
|
||||
// Gregorian calendar day that corresponds to the Hijri date, at local
|
||||
// midnight in whatever timezone the consumer uses. Passing a raw Date
|
||||
// object to dayjsFactory() would interpret it as a UTC instant and could
|
||||
// land on the previous local day for hosts west of UTC.
|
||||
const y = greg.getUTCFullYear();
|
||||
const mo = String(greg.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dy = String(greg.getUTCDate()).padStart(2, '0');
|
||||
const mo = String(greg.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dy = String(greg.getUTCDate()).padStart(2, "0");
|
||||
return dayjsFactory(`${y}-${mo}-${dy}`);
|
||||
};
|
||||
};
|
||||
|
|
@ -278,13 +284,13 @@ export default plugin;
|
|||
* Re-exported from hijri-core for consumers who import from dayjs-hijri-plus.
|
||||
* Avoids requiring hijri-core as a direct dependency just to use these types.
|
||||
*/
|
||||
export type { HijriDate, ConversionOptions } from './types';
|
||||
export type { HijriDate, ConversionOptions } from "./types";
|
||||
|
||||
/**
|
||||
* Re-exported CalendarEngine interface from hijri-core.
|
||||
* Use this type to implement custom calendar engines for `registerCalendar`.
|
||||
*/
|
||||
export type { CalendarEngine } from 'hijri-core';
|
||||
export type { CalendarEngine } from "hijri-core";
|
||||
|
||||
/**
|
||||
* Re-exported registry API from hijri-core.
|
||||
|
|
@ -296,4 +302,4 @@ export type { CalendarEngine } from 'hijri-core';
|
|||
* registerCalendar('my-cal', myEngine);
|
||||
* listCalendars(); // ['uaq', 'fcna', 'my-cal']
|
||||
*/
|
||||
export { registerCalendar, getCalendar, listCalendars } from 'hijri-core';
|
||||
export { registerCalendar, getCalendar, listCalendars } from "hijri-core";
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
import type { HijriDate, ConversionOptions } from 'hijri-core';
|
||||
import type { HijriDate, ConversionOptions } from "hijri-core";
|
||||
export type { HijriDate, ConversionOptions };
|
||||
|
|
|
|||
13
test-cjs.cjs
13
test-cjs.cjs
|
|
@ -59,3 +59,16 @@ describe('CJS: isValidHijri', () => {
|
|||
assert.equal(dayjs(D_RAMADAN_1444).isValidHijri(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CJS: UTC-day boundary (regression)', () => {
|
||||
it('dayjs("2025-03-01").toHijri() -> 1 Ramadan 1446', () => {
|
||||
const h = dayjs('2025-03-01').toHijri();
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
it('round-trip: fromHijri(1446,9,1) then toHijri() -> {1446,9,1}', () => {
|
||||
const d = dayjs.fromHijri(1446, 9, 1);
|
||||
const h = d.toHijri();
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
15
test.mjs
15
test.mjs
|
|
@ -102,3 +102,18 @@ describe('isValidHijri', () => {
|
|||
assert.equal(valid, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTC-day boundary (regression)', () => {
|
||||
// dayjs("YYYY-MM-DD") parses as local midnight — timezone-invariant anchor
|
||||
// for toHijri now that the adapter reads the displayed calendar date.
|
||||
it('dayjs("2025-03-01").toHijri() -> 1 Ramadan 1446', () => {
|
||||
const h = dayjs('2025-03-01').toHijri();
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
|
||||
it('round-trip: fromHijri(1446,9,1) then toHijri() -> {1446,9,1}', () => {
|
||||
const d = dayjs.fromHijri(1446, 9, 1);
|
||||
const h = d.toHijri();
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue