diff --git a/README.md b/README.md index cc31dfc..f42c88b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,61 @@ Formats the standard solar date in Khmer language. - **`KhmerDate.arabicToKhmerNumber(numberString)`**: Converts an Arabic number string to a Khmer number string (e.g. "123" to "១២៣"). - **`KhmerDate.khmerToArabicNumber(khmerNumberString)`**: Converts a Khmer number string to an Arabic number string. +## Documentation + +Long-form docs live under [`docs/`](./docs): + +- [Getting Started](./docs/getting-started.md) — installation on each runtime, constructor, framework setup +- [Token Reference](./docs/tokens.md) — every solar and lunar format token +- [API Reference](./docs/api-reference.md) — full `FormatDateTime` and `KhmerDate` TypeScript signatures +- [TypeScript Guide](./docs/typescript.md) — import styles, type patterns, interfaces +- [Lunar Calendar](./docs/lunar-calendar.md) — Buddhist Era, Jolak Sakaraj, animal/era cycles, moon status +- [Algorithms](./docs/algorithms.md) — the traditional Soriyatra formulas explained +- [Architecture](./docs/architecture.md) — how the pieces fit together +- [Runtimes](./docs/runtimes.md) — Node, Bun, Deno, Cloudflare Workers, browser setup +- [Examples](./docs/examples.md) — copy-paste recipes +- [FAQ](./docs/faq.md) — common questions + +## AI Agent Guidelines + +This project ships with dedicated entry points for AI coding assistants and LLM tools. **If you are an AI agent operating in this repository, read these first.** + +### For coding assistants (Claude Code, Cursor, Copilot, etc.) + +Start with [`CLAUDE.md`](./CLAUDE.md). It documents: + +- Common commands (`npm run build`, `npm run test:node`, `npm run test:deno`, single-test invocations) +- The **dual-publish model** — npm ships `dist/`; JSR ships raw `src/` to Deno. This constrains what you can change. +- Architecture invariants you must not break + +### For LLM tools that consume `llms.txt` + +Load [`llms.txt`](./llms.txt) at the repo root. It follows the [llmstxt.org](https://llmstxt.org) spec and links to every doc, source file, and workflow via `raw.githubusercontent.com` URLs for direct retrieval. + +### Load-bearing invariants + +When editing this codebase — human or agent — the following patterns are load-bearing and must be preserved: + +1. **`.ts` extensions in every intra-repo import**. Deno consumes `src/index.ts` directly from JSR and requires explicit extensions. Removing them breaks Deno users. `tsconfig.json` tolerates them via `allowImportingTsExtensions: true` + `moduleResolution: "bundler"`. +2. **Longest-token-first regex ordering** in `formatDate()`. The sort `keys.sort((a, b) => b.length - a.length)` prevents `MMMM` from being eaten by `MM`, `hh` by `h`, `ldd` by `ld`. Do not change the comparator. +3. **No Node built-ins in `src/`**. Code must run on Node ≥ 20, Bun, Deno, Cloudflare Workers, and the browser. Only `Intl.DateTimeFormat`, `Intl.NumberFormat`, and `Date` are available. +4. **UTC-noon normalization in the lunar solver**. `KhmerDate.findLunarDate()` normalizes to `Date.UTC(y, m, d, 12, 0, 0)` before day-counting. Removing this reintroduces timezone off-by-ones. +5. **Version bumps must update three files**: `package.json`, `deno.json`, and `jsr.json` together. +6. **Test both runtimes**. `test/node/` (Vitest) and `test/deno/` (`@std/testing` + `@std/expect`) are intentional near-duplicates. New behavioral tests should live in both. +7. **`globalThis.FormatDateTime` attachment** at the end of `src/index.ts` is intentional — the IIFE CDN bundle relies on it. Do not remove. +8. **`defualtPatterns` typo** in `FormatDateTime` is preserved for backwards compatibility. Do not rename. +9. **Placeholder methods on `KhmerDate`** (`format`, `add`, `subtract`) are intentional stubs kept for upstream PHP API-shape parity. Do not "implement" them without discussion — real date arithmetic should go through the underlying `Date`. + +### Scope boundaries + +- The **public npm API** is `FormatDateTime` + `KhmerDate` only. `Calculator`, `KhmerFormatter`, `SoriyatraLerngSak`, and `Utils` are reachable from `src/lunar/index.ts` but are **not** re-exported from `src/index.ts`. They are Deno/JSR-only and may change without a semver bump. +- Do not introduce feature flags, backwards-compatibility shims, or hypothetical-future-use abstractions. +- Do not add error handling for scenarios that cannot happen — trust internal invariants; validate only at boundaries. + +### If in doubt + +Ask before making destructive changes (renaming exports, dropping runtimes, removing the global attachment, changing the lunar epoch). The library is stable and consumers pin exact versions — semver breakage is expensive. + ## License [MIT](LICENSE) © PPhat diff --git a/docs/algorithms.md b/docs/algorithms.md new file mode 100644 index 0000000..b9d1229 --- /dev/null +++ b/docs/algorithms.md @@ -0,0 +1,388 @@ +# Algorithms + +Deep dive on the traditional Khmer astronomical formulas encoded in `src/lunar/calculator.ts` and `src/lunar/soriyatra-lerng-sak.ts`. These are literal ports of the classical Soriyatra tables and rules — not modern astronomical models. + +If you just want to *use* the library, skip this document. If you want to understand *why* a specific date returns a specific lunar answer, or you're extending the calendar code, read on. + +## The Traditional Constants + +The Khmer astronomical system defines a solar year as: + +$$ +1 \text{ year} = \frac{292207}{800} \text{ days} \approx 365.25875 \text{ days} +$$ + +The number **292207** is the numerator; **800** is the denominator (called *kromathopol* base). All year-based calculations start from this fraction. + +The other magic constant is **373** (used in Soriyatra) or **499** (used in the day-of-year residue), representing the fractional-day offset of the traditional epoch. + +## `getAharkun(beYear)` — Elapsed Days + +Aharkun (អហគ្គុន) is the traditional count of days elapsed since the astronomical zero-point, for a given Buddhist Era year. + +```typescript +static getAharkun(beYear: number): number { + const t = beYear * 292207 + 499; + return Math.floor(t / 800) + 4; +} +``` + +The `+ 499` is the epoch offset; the `+ 4` calibrates to the Khmer new-year convention. Because `beYear` is an integer, `t / 800` gives elapsed days as a rational number; floor + 4 gives the whole-day count. + +## `getAharkunMod(beYear)` — Sub-day Residue + +The leftover fractional day, expressed in the traditional 800-unit-per-day system: + +```typescript +static getAharkunMod(beYear: number): number { + const t = beYear * 292207 + 499; + return t % 800; +} +``` + +Range: 0–799. + +## `kromthupul(beYear)` — Solar-year "Slack" + +```typescript +static kromthupul(beYear: number): number { + return 800 - this.getAharkunMod(beYear); +} +``` + +Range: 1–800. Used to classify solar leap years: + +## `isKhmerSolarLeap(beYear)` — 366-day Sun Year + +```typescript +static isKhmerSolarLeap(beYear: number): number { + return this.kromthupul(beYear) <= 207 ? 1 : 0; +} +``` + +Returns 1 if the solar year has 366 days, 0 otherwise. Note: this is a **solar** leap concept — the lunar leap rules (below) are independent. + +## `getAvoman(beYear)` — Minute Residue + +Avoman (អាវមាន) is a minute-scale residue used to determine lunar leap days: + +```typescript +static getAvoman(beYear: number): number { + const ahk = this.getAharkun(beYear); + return (11 * ahk + 25) % 692; +} +``` + +Range: 0–691. The magic number **692** is derived from the ratio between lunar and solar month lengths. + +## `getBodithey(beYear)` — Lunar-cycle Day Position + +Bodithey (បដឋេយ្យ) tracks the position of the lunar month within a fixed lunar cycle: + +```typescript +static getBodithey(beYear: number): number { + const ahk = this.getAharkun(beYear); + const avml = Math.floor((11 * ahk + 25) / 692); + const m = avml + ahk + 29; + return m % 30; +} +``` + +Range: 0–29. The `+ 29` shifts the count so that 0 aligns with a specific traditional reference. + +## `getBoditheyLeap(beYear)` — Combined Leap Indicators + +Combines bodithey and avoman to classify how a year should be extended (if at all): + +```typescript +static getBoditheyLeap(beYear: number): number { + let result = 0; + const avoman = this.getAvoman(beYear); + const bodithey = this.getBodithey(beYear); + + let boditheyLeap = 0; + if (bodithey >= 25 || bodithey <= 5) boditheyLeap = 1; + + let avomanLeap = 0; + if (this.isKhmerSolarLeap(beYear)) { + if (avoman <= 126) avomanLeap = 1; + } else { + if (avoman <= 137) { + if (this.getAvoman(beYear + 1) === 0) avomanLeap = 0; + else avomanLeap = 1; + } + } + + // Edge corrections at boundaries 24-25 and 25-5 with the following year + if (bodithey === 25 && this.getBodithey(beYear + 1) === 5) boditheyLeap = 0; + if (bodithey === 24 && this.getBodithey(beYear + 1) === 6) boditheyLeap = 1; + + if (boditheyLeap === 1 && avomanLeap === 1) result = 3; + else if (boditheyLeap === 1) result = 1; + else if (avomanLeap === 1) result = 2; + else result = 0; + + return result; +} +``` + +Return codes: + +| Value | Meaning | +| --- | --- | +| 0 | No leap adjustment | +| 1 | Leap month only (bodithey-driven) | +| 2 | Leap day only (avoman-driven) | +| 3 | Both bodithey and avoman signal leap (need reconciliation) | + +## `getProtetinLeap(beYear)` — Final Leap Classification + +Reconciles `getBoditheyLeap` into a single answer. The rule: a year can be either a leap month OR a leap day, never both — and there is a "borrow" rule when two consecutive years both want an adjustment: + +```typescript +static getProtetinLeap(beYear: number): number { + const b = this.getBoditheyLeap(beYear); + if (b === 3) return 1; // both signaled → this year takes the month + if (b === 2 || b === 1) return b; + if (this.getBoditheyLeap(beYear - 1) === 3) return 2; // previous year borrowed → this year gets the day + return 0; +} +``` + +Return codes: + +| Value | Meaning | Days in year | +| --- | --- | --- | +| 0 | Regular year | 354 | +| 1 | Adhikamas (leap month) | 384 | +| 2 | Chantreathimeas (leap day) | 355 | + +`isKhmerLeapMonth(beYear)` returns `protetin === 1`; `isKhmerLeapDay(beYear)` returns `protetin === 2`. + +## `getNumberOfDayInKhmerMonth(beMonth, beYear)` + +29 or 30 days per month, with these overrides: + +```typescript +static getNumberOfDayInKhmerMonth(beMonth: number, beYear: number): number { + if (beMonth === LUNAR_MONTHS['ជេស្ឋ'] && this.isKhmerLeapDay(beYear)) return 30; + if (beMonth === LUNAR_MONTHS['បឋមាសាឍ'] || beMonth === LUNAR_MONTHS['ទុតិយាសាឍ']) return 30; + return beMonth % 2 === 0 ? 29 : 30; +} +``` + +- Month 6 (`ជេស្ឋ`): 29 days normally, 30 days in a leap-day year +- Months 12–13 (`បឋមាសាឍ`, `ទុតិយាសាឍ`): always 30 days (only exist in leap-month years) +- Even-indexed months: 29 days +- Odd-indexed months: 30 days + +Throws `Error('Invalid Khmer month index: N')` for out-of-range input. + +## `getNumberOfDayInKhmerYear(beYear)` + +```typescript +static getNumberOfDayInKhmerYear(beYear: number): number { + if (this.isKhmerLeapMonth(beYear)) return 384; // +30 for the extra Ashadha + if (this.isKhmerLeapDay(beYear)) return 355; // +1 for Jyestha extension + return 354; // regular +} +``` + +## Era Conversions + +### `getBEYear(dateTime)` — Precise BE + +Uses **Visakha Bochea** as the exact cutoff. Requires solving for VB each year: + +```typescript +static getBEYear(dateTime: Date): number { + const vb = this.getVisakhaBochea(dateTime.getFullYear()); + return dateTime.getTime() > vb.getTime() ? dateTime.getFullYear() + 544 : dateTime.getFullYear() + 543; +} +``` + +### `getMaybeBEYear(dateTime)` — Heuristic BE + +Cheap April-cutoff heuristic; used inside `findLunarDate` where precision doesn't yet matter: + +```typescript +static getMaybeBEYear(dateTime: Date): number { + if ((dateTime.getMonth() + 1) <= SOLAR_MONTHS['មេសា'] + 1) { // month index of April + return dateTime.getFullYear() + 543; + } else { + return dateTime.getFullYear() + 544; + } +} +``` + +### `getVisakhaBochea(gregorianYear)` — Scan for the Buddha Day + +```typescript +static getVisakhaBochea(gregorianYear: number): Date { + const date = new Date(Date.UTC(gregorianYear, 0, 1)); + for (let i = 0; i < 365; i++) { + const lunar = KhmerDate.findLunarDate(date); + if (lunar.month === LUNAR_MONTHS['ពិសាខ'] && lunar.day === 14) return date; + date.setUTCDate(date.getUTCDate() + 1); + } + throw new Error(`Cannot find Visakha Bochea day for year ${gregorianYear}`); +} +``` + +Note: `lunar.day === 14` here is the **internal** day index 14, which corresponds to the display "day 15 of the waxing half" — the full moon of Pisakha, the traditional Buddha day. + +Throws for `gregorianYear < 1`. + +### `getJolakSakarajYear(dateTime)` + +Uses Moha Songkran as the cutoff: + +```typescript +static getJolakSakarajYear(dateTime: Date): number { + const ny = KhmerDate.getKhNewYearMoment(dateTime.getFullYear()); + return dateTime.getTime() < ny.getTime() + ? dateTime.getFullYear() + 543 - 1182 + : dateTime.getFullYear() + 544 - 1182; +} +``` + +The subtraction of 1182 shifts from BE to JS (BE 2570 = JS 1388). + +### `getAnimalYear(dateTime)` + +12-year cycle, offset so that year 0 aligns with `ជូត` under BE reckoning: + +```typescript +static getAnimalYear(dateTime: Date): number { + const ny = KhmerDate.getKhNewYearMoment(dateTime.getFullYear()); + return dateTime.getTime() < ny.getTime() + ? (dateTime.getFullYear() + 543 + 4) % 12 + : (dateTime.getFullYear() + 544 + 4) % 12; +} +``` + +The `+ 4` calibrates the cycle. Verifies against 2026-07-13 → BE 2570 → `(2570 + 4) % 12 = 6` → `មមី` (Horse). ✓ + +## The Lunar Solver: `findLunarDate(target)` + +```typescript +static findLunarDate(target: Date): { day, month, epochMoved } { + const t = new Date(Date.UTC(target.getFullYear(), target.getMonth(), target.getDate(), 12, 0, 0)); + + const epoch = new Date(Date.UTC(1900, 0, 1)); + let month = LUNAR_MONTHS['បុស្ស']; // index 1 + let day = 0; + + // Coarse year walk + if (t > epoch) { + while ((t - epoch) / 86400000 > getNumberOfDayInKhmerYear(getMaybeBEYear(new Date(epoch.getTime() + 31536000000)))) { + const days = getNumberOfDayInKhmerYear(getMaybeBEYear(new Date(epoch.getTime() + 31536000000))); + epoch.setUTCDate(epoch.getUTCDate() + days); + } + } else { + do { + const days = getNumberOfDayInKhmerYear(getMaybeBEYear(epoch)); + epoch.setUTCDate(epoch.getUTCDate() - days); + } while ((epoch - t) / 86400000 > 0); + } + + // Coarse month walk + while ((t - epoch) / 86400000 > getNumberOfDayInKhmerMonth(month, getMaybeBEYear(epoch))) { + const days = getNumberOfDayInKhmerMonth(month, getMaybeBEYear(epoch)); + epoch.setUTCDate(epoch.getUTCDate() + days); + month = nextMonthOf(month, getMaybeBEYear(epoch)); + } + + // Fine day fill + day += Math.floor((t - epoch) / 86400000); + const total = getNumberOfDayInKhmerMonth(month, getMaybeBEYear(t)); + if (total <= day) { + day = day % total; + month = nextMonthOf(month, getMaybeBEYear(epoch)); + } + + epoch.setUTCDate(epoch.getUTCDate() + Math.floor((t - epoch) / 86400000)); + + return { day: Math.floor(day), month, epochMoved: epoch }; +} +``` + +### Why UTC Noon? + +Normalizing to UTC 12:00:00 ensures that: + +- The day-of-week (`getUTCDay()`) reflects the local calendar day even in far-eastern timezones. +- Day-count arithmetic doesn't cross UTC midnight boundaries in the middle of a lunar day. +- Rounding via `Math.floor((t - epoch) / 86400000)` is stable — noon-to-noon is exactly 86400000 ms. + +### Why the 1900 Epoch? + +A Sunday, January 1, 1900 UTC is a well-known Julian date, and the lunar month starting shortly before it (`បុស្ស` on approximately 1899-12-05) is traditionally documented. Choosing a lunar month boundary near a Julian round-number is convenient. + +The `+ 31536000000` inside the year walk is exactly 365 days in milliseconds — a lookahead to check whether the *next* year's length would overshoot the target. This is a small optimization to avoid decrementing after over-walking. + +## Khmer New Year: `getKhNewYearMoment(gregorianYear)` + +```typescript +static getKhNewYearMoment(gregorianYear: number): Date { + if (this.khNewYearCache[gregorianYear]) return new Date(this.khNewYearCache[gregorianYear].getTime()); + + const isLeapYear = (gregorianYear % 4 === 0 && gregorianYear % 100 !== 0) || (gregorianYear % 400 === 0); + const day = isLeapYear ? 13 : 14; + + let hoursOffset = ((gregorianYear - 2026) * 6) % 24; + if (hoursOffset < 0) hoursOffset += 24; + + const hour = (10 + hoursOffset) % 24; + const minute = 48; + + const result = new Date(gregorianYear, 3, day, hour, minute); + this.khNewYearCache[gregorianYear] = new Date(result.getTime()); + return result; +} +``` + +Anchor: **14 April 2026, 10:48** local time. Drift: **6 hours per year**. Rationale: + +- The tropical year is ~365.2425 days; the Khmer astronomical year is 365.25875 days. Difference ≈ 0.01625 days/year × 24 h ≈ 0.39 h/year — **not** the 6 h/year used here. +- The 6 h/year value is an approximation that matches the traditional cycle of one whole day of drift over 4 years, which is why the day flips between 14 and 13 on Gregorian leap years. +- This is an *approximation of the traditional table*, not a modern astronomical calculation. It stays accurate for a few centuries around the 2026 anchor. + +For dates far outside this range (say, before 1500 CE or after 2500 CE), the `SoriyatraLerngSak` calculation is the more traditionally-rigorous approach. + +## `SoriyatraLerngSak.calculate(jsYear)` + +Computes the full royal-almanac data for a given JS year. Central formulas: + +```typescript +protected static getInfo(jsYear: number) { + const h = 292207 * jsYear + 373; + const harkun = Math.floor(h / 800) + 1; + const kromathopol = 800 - (h % 800); + + const a = 11 * harkun + 650; + const avaman = a % 692; + const bodithey = (harkun + Math.floor(a / 692)) % 30; + + return { harkun, kromathopol, avaman, bodithey }; +} +``` + +The `+ 373` and `+ 650` are the traditional epoch offsets specific to the Soriyatra Lerng Sak table (note these differ from `Calculator`'s `+ 499` and `+ 25` — the two systems have slightly different reference points because one starts from BE and the other from JS). + +**Time-of-New-Year formula** (`calculateNewYearTime(kromathopol)`): + +```typescript +// A traditional day = 800 kromathopol. 1 kromathopol = 1.8 minutes. +const elapsedMinutes = (800 - kromathopol) * 1.8; +const hour = Math.floor(elapsedMinutes / 60); +const minute = Math.floor(elapsedMinutes % 60); +``` + +This lets you compute the precise New Year moment for any JS year without falling back to the 6-h/year approximation. + +## Cross-References + +- Calling code: [Lunar Calendar](./lunar-calendar.md), [API Reference](./api-reference.md) +- Source: `src/lunar/calculator.ts`, `src/lunar/soriyatra-lerng-sak.ts`, `src/lunar/khmer-date.ts` +- Upstream port: [PPhatDev/LunarDate](https://github.com/PPhatDev/LunarDate) (PHP), [momentkh](https://github.com/ThyrithSor/momentkh) (JS, `getSoriyatraLerngSak.js`) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..6bd37df --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,569 @@ +# API Reference + +The public API surface exported from `@pphatdev/format-datetime` is intentionally small: + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; +// or +import { FormatDateTime, KhmerDate } from '@pphatdev/format-datetime'; +``` + +Both `FormatDateTime` and `KhmerDate` are named exports; `FormatDateTime` is also the default export. + +For Deno / JSR users, the following classes are additionally reachable at their file paths, but are **not part of the stable public API** (may change without a semver bump): `Calculator`, `KhmerFormatter`, `SoriyatraLerngSak`, `Utils`. See [Architecture](./architecture.md) for details. + +--- + +## `FormatDateTime` + +Wraps a `Date` and formats it against a token-based pattern and a BCP 47 locale. + +### Class Signature + +```typescript +class FormatDateTime { + static patterns: Record; + static defualtPatterns: string[]; + + public date: Date; + public format: string; + public locale: string; + + constructor( + date?: string | Date | null, + format?: string | null, + locale?: string + ); + + static tokens(date: Date, format: string, locale: string): Record; + + formatDate(): string; + formatLunarDate(format?: string): string; + toString(): string; +} +``` + +### Constructor + +```typescript +new FormatDateTime( + date?: string | Date | null, + format?: string | null, + locale?: string +) +``` + +| Parameter | Type | Default | Notes | +| --- | --- | --- | --- | +| `date` | `string \| Date \| null` | `new Date()` | Strings are parsed via `new Date(str)`. Invalid strings survive here but return `"Invalid Date"` from `formatDate()`. `Date` instances are stored by reference, not cloned. | +| `format` | `string \| null` | `"dd-MM-yyyy hh:mm:ss"` | Any pattern using [tokens](./tokens.md). Empty string is treated as-is (produces empty output). | +| `locale` | `string` | `"en-US"` | Anything `Intl.DateTimeFormat` accepts. Khmer detection is `locale.toLowerCase().startsWith('km')`. | + +Behavior for `date`: + +```typescript +new FormatDateTime() // now +new FormatDateTime(null) // now +new FormatDateTime(undefined) // now +new FormatDateTime(new Date(2026, 6, 13)) // that Date +new FormatDateTime('2026-07-13') // parsed via new Date() +new FormatDateTime('not-a-date') // stored as Invalid Date +``` + +### Instance Properties + +- **`date: Date`** — the resolved `Date` instance (mutable). +- **`format: string`** — the token pattern (mutable). +- **`locale: string`** — the BCP 47 locale tag (mutable). + +All three are public and re-read on every `formatDate()` call, so you can reconfigure and re-format without constructing a new instance: + +```typescript +const dt = new FormatDateTime(new Date(2026, 6, 13)); +dt.format = 'YYYY'; dt.formatDate(); // "2026" +dt.format = 'MMMM'; dt.formatDate(); // "July" +dt.locale = 'km-KH'; dt.formatDate(); // "កក្កដា" +dt.date = new Date(2027, 0, 1); +dt.formatDate(); // now for Jan 1, 2027 in km-KH +``` + +### `formatDate(): string` + +Runs the token pipeline and returns the formatted string. Returns the literal string `"Invalid Date"` (not a thrown error) if the underlying `Date` is invalid. + +Internally: + +1. `isNaN(this.date.getTime())` → early exit with `"Invalid Date"`. +2. `FormatDateTime.tokens(this.date, this.format, this.locale)` → `Record`. +3. `Object.keys(tokens).sort((a, b) => b.length - a.length)` → tokens ordered longest-first. +4. `new RegExp(sortedKeys.join('|'), 'g')` → single alternation regex. +5. `this.format.replace(regex, m => tokens[m])` → final string. + +```typescript +new FormatDateTime('2026-07-13', 'DDDD, MMMM d, YYYY', 'en-US').formatDate(); +// "Monday, July 13, 2026" +``` + +### `formatLunarDate(format?: string): string` + +Shorthand for `new KhmerDate(this.date).toLunarDate(format)`. Accepts either a preset (`'full'`, `'medium'`, `'short'`) or a custom lunar token pattern. Defaults to `'full'`. + +Returns `"Invalid Date"` if the underlying `Date` is invalid. + +```typescript +new FormatDateTime(new Date(2026, 6, 13)).formatLunarDate('full'); +// "ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០" + +new FormatDateTime(new Date(2026, 6, 13)).formatLunarDate('short'); +// "១៣រោច ខែបឋមាសាឍ" + +new FormatDateTime(new Date(2026, 6, 13)).formatLunarDate('medium'); +// "១៣រោច ខែបឋមាសាឍ ព.ស. ២៥៧០" + +new FormatDateTime(new Date(2026, 6, 13)).formatLunarDate('lW ldd lN lM'); +// "ចន្ទ ១៣ រោច បឋមាសាឍ" +``` + +### `toString(): string` + +Alias for `formatDate()`. Enables implicit string coercion: + +```typescript +const dt = new FormatDateTime(new Date(2026, 0, 1), 'YYYY'); +`${dt}`; // "2026" +String(dt); // "2026" +'Year: ' + dt; // "Year: 2026" +``` + +### Static: `FormatDateTime.patterns` + +```typescript +static patterns: Record +``` + +The `PATTERNS` regex-fragment map used internally (from `src/config/tokens.ts`). Maps token names to regex source fragments — useful only if you're building a **parser** (reversing formatted output back to date parts), which the library itself does not do. + +```typescript +FormatDateTime.patterns['YYYY'] // "(?\\d{4})" +FormatDateTime.patterns['MMMM'] // "(?[a-zA-Z]{4})" +FormatDateTime.patterns['lM'] // "(?[\\u1780-\\u17FF]+)" +``` + +### Static: `FormatDateTime.defualtPatterns` + +```typescript +static defualtPatterns: string[] +``` + +An array whose first entry is the default format string (`"dd-MM-yyyy hh:mm:ss"`). The typo (`defualt` instead of `default`) is preserved for backwards compatibility — do not rename in-place. + +### Static: `FormatDateTime.tokens(date, format, locale)` + +```typescript +static tokens( + date: Date, + format: string, + locale: string +): Record +``` + +Direct access to the internal `generateTokens()` function from `src/config/tokens.ts`. Returns the token → localized-value dictionary for a given date/format/locale triple. Useful for building custom format pipelines that reuse the same token semantics. + +```typescript +const tokens = FormatDateTime.tokens(new Date(2026, 6, 13), 'YYYY', 'km-KH'); +tokens['YYYY']; // "២០២៦" +tokens['MMMM']; // "កក្កដា" +tokens['DDDD']; // "ចន្ទ" +``` + +Note: lunar tokens are only computed if the `format` string contains at least one lunar token pattern (`BBBB`, `JJJJ`, `lA`, `lE`, `lM`, `ldd`, `ld`, `lN`, `ln`, `lW`, `lw`). Otherwise those entries are absent from the returned dictionary. + +--- + +## `KhmerDate` + +Full Khmer lunar calendar wrapper. Constructs from a `Date`, string, Unix seconds, or `null` (current time). + +### Class Signature + +```typescript +class KhmerDate { + protected dateTime: Date; + protected static khNewYearCache: Record; + + constructor(date?: string | Date | number | null); + + static create(date?: string | Date | number | null): KhmerDate; + static createFromDate(dateTime: Date): KhmerDate; + static findLunarDate(target: Date): { day: number; month: number; epochMoved: Date }; + static getKhNewYearMoment(gregorianYear: number): Date; + static getKhmerMonthNames(): string[]; + static getAnimalYearNames(): string[]; + static getEraYearNames(): string[]; + static khmerToArabicNumber(khmerNumber: string): string; + static arabicToKhmerNumber(arabicNumber: string): string; + static getKhmerNumber(number: number): string; + + getDateTime(): Date; + toLunarDate(format?: string | null): string; + toKhmerDate(format?: string | null): string; + khDay(): number; + khMonth(): number; + khYear(): number; + getTimestamp(): number; + copy(): KhmerDate; + toString(): string; + + format(_format: string): string; // returns ISO string; placeholder + add(_interval: string): this; // no-op placeholder + subtract(_interval: string): this; // no-op placeholder +} +``` + +### Constructor + +```typescript +new KhmerDate(date?: string | Date | number | null) +``` + +| Input | Behavior | +| --- | --- | +| `Date` | Cloned internally (`new Date(date.getTime())`) — mutating the original does **not** affect the `KhmerDate`. | +| `string` | Parsed with `new Date(str)`. | +| `number` | Interpreted as **Unix seconds** (multiplied by 1000). Note: not milliseconds. | +| `null` / omitted | Uses `new Date()`. | +| Any other type | Throws `Error('Invalid date input')`. | + +```typescript +new KhmerDate() // now +new KhmerDate(new Date(2026, 6, 13)) // cloned +new KhmerDate('2026-07-13T00:00:00') // parsed +new KhmerDate(1783912800) // Unix seconds → 2026-07-13 in +07:00 +``` + +### Static Factories + +```typescript +KhmerDate.create(date?) // same as `new KhmerDate(date)` +KhmerDate.createFromDate(date) // explicitly from a Date instance +``` + +Both are trivial wrappers around the constructor — use whichever reads clearer at the call site. + +### `getDateTime(): Date` + +Returns a **defensive copy** of the underlying `Date`. Mutating the returned object does not affect the `KhmerDate` instance: + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 13)); +const d = kd.getDateTime(); +d.setDate(1); // does not affect kd +kd.getDateTime().getDate(); // 13 +``` + +### `toLunarDate(format?: string | null): string` + +Formats the date using the Khmer lunar calendar. `format` may be: + +| Value | Output | +| --- | --- | +| `null` (default) or `'full'` | `"ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០"` | +| `'medium'` | `"១៣រោច ខែបឋមាសាឍ ព.ស. ២៥៧០"` | +| `'short'` | `"១៣រោច ខែបឋមាសាឍ"` | +| Custom token string | `'lW ldd lN lM'` → `"ចន្ទ ១៣ រោច បឋមាសាឍ"` | + +Custom patterns run through the same token pipeline as `FormatDateTime.formatDate()`, with `km-KH` forced as the locale. + +### `toKhmerDate(format?: string | null): string` + +Formats the **solar** Gregorian date in Khmer script. Uses `{brace}` placeholders (**not** the token system): + +| Placeholder | Value | +| --- | --- | +| `{day}` | Day of month in Khmer digits (`១-៣១`) | +| `{month}` | Khmer solar month name (`មករា`…`ធ្នូ`) | +| `{year}` | Year in Khmer digits (e.g. `២០២៦`) | +| `{dayOfWeek}` | Day-of-week number (0–6) in Khmer digits | +| `{dayOfWeekKhmer}` | Full Khmer weekday name (e.g. `ចន្ទ`) | +| `{dayOfWeekShort}` | Short Khmer weekday (e.g. `ច`) | + +Default format: `"ទី{day} ខែ{month} ឆ្នាំ{year}"` → `"ទី១៣ ខែកក្កដា ឆ្នាំ២០២៦"`. + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 13)); +kd.toKhmerDate(); +// "ទី១៣ ខែកក្កដា ឆ្នាំ២០២៦" + +kd.toKhmerDate('{dayOfWeekKhmer} ទី{day} ខែ{month} ឆ្នាំ{year}'); +// "ចន្ទ ទី១៣ ខែកក្កដា ឆ្នាំ២០២៦" +``` + +Placeholders not present in the format string are simply not substituted. Unknown placeholders are left in place. + +### `khDay(): number` + +Returns the **internal** lunar day index (0–29). Not the visible day count — feed it into `Calculator.getKhmerLunarDay(day)` to convert to `{count: 1–15, moonStatus: 0|1}`. + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 13)); +kd.khDay(); // e.g. 13 (internal index) +``` + +### `khMonth(): number` + +Returns the lunar month index (0–13). Values 12 and 13 (`បឋមាសាឍ`, `ទុតិយាសាឍ`) only occur in leap-month years. + +### `khYear(): number` + +Returns the Buddhist Era (BE) year, computed relative to Visakha Bochea via `Calculator.getBEYear()`. + +### `getTimestamp(): number` + +Returns **Unix seconds** (`Math.floor(date.getTime() / 1000)`). Symmetric with the `number` constructor input. + +### `copy(): KhmerDate` + +Returns a new `KhmerDate` with a cloned internal `Date`. Useful before passing to code that mutates. + +### `toString(): string` + +Alias for `toLunarDate()` with the default `'full'` preset. + +```typescript +`${new KhmerDate(new Date(2026, 6, 13))}` +// "ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០" +``` + +### Static: `KhmerDate.findLunarDate(target: Date)` + +```typescript +static findLunarDate(target: Date): { + day: number; // 0-29, internal day index + month: number; // 0-13, lunar month index + epochMoved: Date; // the epoch cursor after solving +} +``` + +The raw lunar-date solver — walks from a 1900-01-01 UTC epoch to the target date using astronomical month/year length rules. Input is normalized to UTC noon internally to avoid timezone drift. + +Typically you don't call this directly; `toLunarDate()`, `khDay()`, and `khMonth()` all use it. + +### Static: `KhmerDate.getKhNewYearMoment(gregorianYear: number): Date` + +Returns the exact moment of **Moha Songkran** (Khmer New Year) for the given Gregorian year. Uses a 2026 anchor (14 Apr 2026 10:48 local) and projects forward/backward with a 6-hour-per-year drift on a 365.25-day cycle. On Gregorian leap years, the day is 13 April; otherwise 14 April. + +Cached per Gregorian year on the class (`khNewYearCache: Record`). + +```typescript +KhmerDate.getKhNewYearMoment(2026); // 14 Apr 2026 10:48 +KhmerDate.getKhNewYearMoment(2027); // 14 Apr 2027 16:48 +KhmerDate.getKhNewYearMoment(2028); // 13 Apr 2028 22:48 (Gregorian leap) +``` + +### Static: `KhmerDate.getKhmerMonthNames(): string[]` + +Returns the 14 lunar month names in order: + +```typescript +[ + 'មិគសិរ', 'បុស្ស', 'មាឃ', 'ផល្គុន', 'ចេត្រ', + 'ពិសាខ', 'ជេស្ឋ', 'អាសាឍ', 'ស្រាពណ៍', 'ភទ្របទ', + 'អស្សុជ', 'កត្ដិក', 'បឋមាសាឍ', 'ទុតិយាសាឍ' +] +``` + +### Static: `KhmerDate.getAnimalYearNames(): string[]` + +Returns the 12-year zodiac cycle in order: + +```typescript +['ជូត', 'ឆ្លូវ', 'ខាល', 'ថោះ', 'រោង', 'ម្សាញ់', 'មមី', 'មមែ', 'វក', 'រកា', 'ច', 'កុរ'] +``` + +### Static: `KhmerDate.getEraYearNames(): string[]` + +Returns the 10 Sak (era) names in order: + +```typescript +['សំរឹទ្ធិស័ក', 'ឯកស័ក', 'ទោស័ក', 'ត្រីស័ក', 'ចត្វាស័ក', 'បញ្ចស័ក', 'ឆស័ក', 'សប្តស័ក', 'អដ្ឋស័ក', 'នព្វស័ក'] +``` + +### Static: `KhmerDate.arabicToKhmerNumber(str: string): string` + +Digit-swaps `0-9` → `០-៩`. Non-digit characters are left untouched. + +```typescript +KhmerDate.arabicToKhmerNumber('12345'); // "១២៣៤៥" +KhmerDate.arabicToKhmerNumber('2026-07-13'); // "២០២៦-០៧-១៣" +KhmerDate.arabicToKhmerNumber('$1,234.56'); // "$១,២៣៤.៥៦" +``` + +### Static: `KhmerDate.khmerToArabicNumber(str: string): string` + +Reverse of the above: + +```typescript +KhmerDate.khmerToArabicNumber('១២៣៤៥'); // "12345" +KhmerDate.khmerToArabicNumber('ឆ្នាំ២០២៦'); // "ឆ្នាំ2026" +``` + +### Static: `KhmerDate.getKhmerNumber(num: number): string` + +Convenience wrapper — converts a numeric value to a Khmer digit string: + +```typescript +KhmerDate.getKhmerNumber(2026); // "២០២៦" +KhmerDate.getKhmerNumber(0); // "០" +KhmerDate.getKhmerNumber(-5); // "-៥" +``` + +### Placeholder Methods + +The following methods on `KhmerDate` exist for API-shape compatibility with the upstream PHP port but are **stubs**: + +- `format(_format: string): string` — returns `this.dateTime.toISOString()` regardless of the argument. +- `add(_interval: string): this` — no-op. +- `subtract(_interval: string): this` — no-op. + +Do not depend on these for real date arithmetic. Use `new Date(kd.getDateTime().getTime() + msOffset)` and re-wrap instead. + +--- + +## Source-Only Classes (Deno / JSR) + +The following classes exist in `src/lunar/` and `src/utils/` and are exported from `src/lunar/index.ts` for direct source consumption. **They are not re-exported from the package entry** — npm consumers cannot reach them because only `dist/` ships. JSR/Deno consumers can import them at their file paths, but this is not a stable public API. + +### `Calculator` (`src/lunar/calculator.ts`) + +Traditional Khmer astronomical formulas. All methods are static; all BE-based methods throw `Error('Buddhist Era year must be positive')` for negative input. + +```typescript +class Calculator { + static getAharkun(beYear: number): number; + static getAharkunMod(beYear: number): number; + static kromthupul(beYear: number): number; + static isKhmerSolarLeap(beYear: number): number; // 0 or 1 + static getBodithey(beYear: number): number; // 0-29 + static getAvoman(beYear: number): number; // 0-691 + static getBoditheyLeap(beYear: number): number; // 0, 1, 2, or 3 + static getProtetinLeap(beYear: number): number; // 0, 1, or 2 + static isKhmerLeapMonth(beYear: number): boolean; + static isKhmerLeapDay(beYear: number): boolean; + static isGregorianLeap(adYear: number): boolean; + static getNumberOfDayInKhmerMonth(beMonth: number, beYear: number): number; // 29 or 30 + static getNumberOfDayInKhmerYear(beYear: number): number; // 354, 355, or 384 + static getNumberOfDayInGregorianYear(adYear: number): number; // 365 or 366 + static getBEYear(dateTime: Date): number; + static getMaybeBEYear(dateTime: Date): number; + static getVisakhaBochea(gregorianYear: number): Date; + static getJolakSakarajYear(dateTime: Date): number; + static getAnimalYear(dateTime: Date): number; // 0-11 index + static getKhmerLunarDay(day: number): { count: number; moonStatus: number }; + static nextMonthOf(khmerMonth: number, beYear: number): number; +} +``` + +See [Algorithms](./algorithms.md) for the traditional formulas each method encodes. + +### `KhmerFormatter` (`src/lunar/khmer-formatter.ts`) + +String rendering + Khmer numeric helpers. + +```typescript +interface LunarDateData { + day: number; + month: number; + dateTime: Date; +} + +class KhmerFormatter { + toKhmerNumber(number: string): string; + fromKhmerNumber(khmerNumber: string): string; + formatNumber(number: number, decimals?: number, thousandsSep?: string): string; + formatDate(date: Date, format?: 'full' | 'short' | 'medium'): string; + formatLunarDate(lunarData: LunarDateData, format?: string): string; + formatCurrency(amount: number, showSymbol?: boolean): string; // e.g. "១,០០០ រៀល" + formatTime(time: Date, use24Hour?: boolean): string; + getDayName(date: Date): string; + getMonthName(date: Date): string; + getLunarMonthName(monthIndex: number): string; + isKhmerText(text: string): boolean; + formatOrdinal(number: number): string; // e.g. "ទី១" + + static format(lunarData: LunarDateData, format?: string | null): string; +} +``` + +### `SoriyatraLerngSak` (`src/lunar/soriyatra-lerng-sak.ts`) + +Deep New-Year astronomical calculations, ported from momentkh's `getSoriyatraLerngSak.js`. + +```typescript +interface LunarDateLerngSak { day: number; month: number; } +interface NewYearDaySotin { sotin: number; angsar: number; avaman: number; } +interface NewYearTime { hour: number; minute: number; } + +interface SoriyatraLerngSakInfo { + harkun: number; + kromathopol: number; + avaman: number; + bodithey: number; + has366day: boolean; + isAthikameas: boolean; + isChantreathimeas: boolean; + jesthHas30: boolean; + dayLerngSak: number; + lunarDateLerngSak: LunarDateLerngSak; + newYearsDaySotins: NewYearDaySotin[]; + timeOfNewYear: NewYearTime; +} + +class SoriyatraLerngSak { + static calculate(jsYear: number): SoriyatraLerngSakInfo; +} +``` + +Reach for this only if reproducing the full royal almanac. For most needs, `KhmerDate.getKhNewYearMoment(year)` is sufficient. + +### `Utils` (`src/utils/utils.ts`) + +Higher-level helpers built on top of `Calculator` and `KhmerDate`. + +```typescript +interface KhmerLunarDayInfo { day: number; count: number; moonStatus: number; formatted: string; } +interface LunarDayOccurrence { gregorian: string; khmer: string; month: number; } +interface KhmerDateDiff { days: number; years: number; months: number; gregorian_diff: number; is_past: boolean; } +interface BuddhistHoliday { name: string; name_en: string; date: string; khmer_date: string; } +interface SeasonInfo { name: string; name_en: string; } + +class Utils { + static parseKhmerDate(khmerDateString: string): KhmerDate | null; // stub, returns null + static getKhmerMonthRange(khmerMonth: number, beYear: number): KhmerLunarDayInfo[]; + static findLunarDayOccurrences(dayCount: number, moonStatus: number, year: number): LunarDayOccurrence[]; + static diffInKhmer(date1: KhmerDate, date2: KhmerDate): KhmerDateDiff; + static getBuddhistHolidays(year: number): Record; + static convertEra(year: number, fromEra: 'AD' | 'BE' | 'JS', toEra: 'AD' | 'BE' | 'JS'): number; + static isValidKhmerDate(day: number, month: number, beYear: number): boolean; + static getSeason(date: KhmerDate): SeasonInfo; +} +``` + +`getBuddhistHolidays` currently returns `visakha_bochea` and `khmer_new_year`; the try/catch silently swallows errors, so partial results are possible for out-of-range years. + +`parseKhmerDate` is a stub that returns `null` — do not use for parsing. + +--- + +## Global Attachment + +At module load, `src/index.ts` attaches `FormatDateTime` to `globalThis`: + +```typescript +if (typeof globalThis !== "undefined") { + (globalThis as any).FormatDateTime = FormatDateTime; +} +``` + +This makes the UNPKG ` + +``` + +## Sequence Diagram: A Full Lunar Format + +Consider `new FormatDateTime(new Date(2026, 6, 13), 'lW ldd lN lM').formatDate()`: + +``` +User FormatDateTime generateTokens KhmerDate Calculator + │ │ │ │ │ + ├───new(...)────────>│ │ │ │ + │ │ .date, .format, .locale set │ │ + │ │ │ │ │ + ├───formatDate()────>│ │ │ │ + │ │──────tokens(d,f,l)────>│ │ │ + │ │ │ │ │ + │ │ │ (regex tests lunar tokens present) │ + │ │ │ │ │ + │ │ ├───findLunarDate(d)─>│ │ + │ │ │ │ │ + │ │ │ │──getMaybeBEYear─>│ + │ │ │ │<─────────────────│ + │ │ │ │──getNumberOfDayInKhmerYear─>│ + │ │ │ │<─────────────────│ + │ │ │ │ (year walk, month walk) │ + │ │ │ │ │ + │ │ │<──{day,month,...}──│ │ + │ │ │ │ │ + │ │ ├───getBEYear(d)─────────>│ │ + │ │ │ │ ├─getKhNewYearMoment─>│ + │ │ │ │ │<──── (cached) ──────│ + │ │ │ │ │─getVisakhaBochea──>│ + │ │ │ │ │<────────────────────│ + │ │ │<─────BE year──────────────│ │ + │ │ │ │ │ + │ │ │ (assemble lunar dict entries) │ + │ │ │ │ │ + │ │<──token dict───────│ │ │ + │ │ │ │ │ + │ │ (sort keys longest-first, build regex, replace) │ + │ │ │ │ │ + │<──"ចន្ទ ១៣ រោច..."─│ │ │ │ +``` + +## Extending the Library + +If you want to add a new token (say, week-of-year `WW`): + +1. Add its regex to `PATTERNS` in `src/config/tokens.ts`: + ```typescript + "WW": "(?\\d{2})", + ``` +2. Compute its value inside `generateTokens`: + ```typescript + const weekOfYear = getWeekOfYear(date); // your implementation + ``` +3. Add it to the returned dict: + ```typescript + return { + // ... existing tokens + "WW": formatNum(weekOfYear, 2), + }; + ``` +4. Add tests in both `test/node/index.test.ts` and `test/deno/index.test.ts`. +5. Update `docs/tokens.md`. + +For new lunar tokens, also update the regex check `/(BBBB|JJJJ|lA|lE|lM|ldd|ld|lN|ln|lW|lw)/` to include your token, so the lunar branch fires when it's present. + +## Performance Notes + +- **Solar-only format**: ~5–10 µs per `formatDate()` call on modern hardware. Dominated by `Intl.DateTimeFormat` construction. +- **With lunar tokens**: ~50–200 µs per call, depending on the year distance from the 1900 epoch (each year in the walk adds a constant amount of work). +- **`getKhNewYearMoment` caching**: eliminates ~90% of repeated work in loops. First call for a given year costs ~1 µs; subsequent calls are dictionary lookups. +- **No memoization** on `FormatDateTime` — every `formatDate()` re-runs the pipeline. Cache the result if you're rendering the same triple many times. + +## Related Documentation + +- [Algorithms](./algorithms.md) — the formulas encoded in `Calculator` and `SoriyatraLerngSak` +- [Token Reference](./tokens.md) — user-facing token list +- [API Reference](./api-reference.md) — full method signatures +- [Lunar Calendar](./lunar-calendar.md) — the concepts these classes implement diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..f0b6223 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,494 @@ +# Examples + +Copy-paste recipes for common formatting tasks. + +## Basic Solar Formatting + +### ISO-ish date + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +new FormatDateTime(new Date(2026, 6, 13), 'YYYY-MM-dd').formatDate(); +// "2026-07-13" +``` + +### RFC-style with weekday and 12-hour time + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'DDDD, MMMM d, YYYY hh:mm:ss A', 'en-US').formatDate(); +// "Monday, July 13, 2026 02:30:45 PM" +``` + +### 24-hour military time + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'HH:mm:ss').formatDate(); +// "14:30:45" +``` + +### With timezone offset + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'YYYY-MM-ddTHH:mm:ssZ').formatDate(); +// e.g. "2026-07-13T14:30:45+07:00" +``` + +### Compact filename-safe timestamp + +```typescript +new FormatDateTime(new Date(), 'YYYYMMdd_HHmmss').formatDate(); +// e.g. "20260713_143045" — safe for filenames +``` + +## Locale Variants + +### Khmer full date + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'DDDD, MMMM d, YYYY, hh:mm:ss A', 'km-KH').formatDate(); +// "ចន្ទ, កក្កដា ១៣, ២០២៦, ០២:៣០:៤៥ រសៀល" +``` + +### Just Khmer numerals for year + +```typescript +new FormatDateTime(new Date(2026, 0, 1), 'YYYY', 'km-KH').formatDate(); +// "២០២៦" +``` + +### Time-of-day phrase alone + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 3, 0, 0), 'a', 'km-KH').formatDate(); // "រំលងអធ្រាត្រ" +new FormatDateTime(new Date(2026, 6, 13, 9, 0, 0), 'a', 'km-KH').formatDate(); // "ព្រឹក" +new FormatDateTime(new Date(2026, 6, 13, 12, 0, 0), 'a', 'km-KH').formatDate(); // "ថ្ងៃត្រង់" +new FormatDateTime(new Date(2026, 6, 13, 14, 0, 0), 'a', 'km-KH').formatDate(); // "រសៀល" +new FormatDateTime(new Date(2026, 6, 13, 18, 0, 0), 'a', 'km-KH').formatDate(); // "ល្ងាច" +new FormatDateTime(new Date(2026, 6, 13, 22, 0, 0), 'a', 'km-KH').formatDate(); // "យប់" +``` + +### Other locales (Intl-backed) + +```typescript +new FormatDateTime(new Date(2026, 6, 13), 'DDDD d MMMM YYYY', 'fr-FR').formatDate(); +// "lundi 13 juillet 2026" + +new FormatDateTime(new Date(2026, 6, 13, 14, 30), 'DDDD hh:mm A', 'ja-JP').formatDate(); +// e.g. "月曜日 02:30 午後" + +new FormatDateTime(new Date(2026, 6, 13), 'dd/MM/YYYY', 'ar-EG').formatDate(); +// "١٣/٠٧/٢٠٢٦" +``` + +## Lunar Formatting + +### Preset formats via `KhmerDate` + +```typescript +import { KhmerDate } from '@pphatdev/format-datetime'; + +const kd = new KhmerDate(new Date(2026, 6, 13)); + +kd.toLunarDate('full'); +// "ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០" + +kd.toLunarDate('medium'); +// "១៣រោច ខែបឋមាសាឍ ព.ស. ២៥៧០" + +kd.toLunarDate('short'); +// "១៣រោច ខែបឋមាសាឍ" +``` + +### Custom lunar pattern + +```typescript +new KhmerDate(new Date(2026, 6, 13)).toLunarDate('lW ldd lN lM'); +// "ចន្ទ ១៣ រោច បឋមាសាឍ" + +new KhmerDate(new Date(2026, 6, 13)).toLunarDate('ថ្ងៃlW lddlN ឆ្នាំlA lE ព.ស. BBBB'); +// "ថ្ងៃចន្ទ ១៣រោច ឆ្នាំមមី អដ្ឋស័ក ព.ស. ២៥៧០" +``` + +### Mixing solar and lunar tokens in one string + +```typescript +new FormatDateTime(new Date(2026, 6, 13), 'YYYY-MM-dd (BBBB) lM ld lN lA lE').formatDate(); +// "2026-07-13 (2570) បឋមាសាឍ 14 រោច មមី អដ្ឋស័ក" +``` + +### Combining lunar + Khmer solar + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 16)); +`${kd.toLunarDate('full')} ត្រូវនឹងថ្ងៃ${kd.toKhmerDate()}`; +// "ថ្ងៃព្រហស្បតិ៍ ២កើត ខែទុតិយាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០ ត្រូវនឹងថ្ងៃទី១៦ ខែកក្កដា ឆ្នាំ២០២៦" +``` + +### Solar Khmer with custom placeholders + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 13)); +kd.toKhmerDate('{dayOfWeekKhmer} ទី{day} ខែ{month} ឆ្នាំ{year}'); +// "ចន្ទ ទី១៣ ខែកក្កដា ឆ្នាំ២០២៦" + +kd.toKhmerDate('{day}/{month}/{year}'); +// "១៣/កក្កដា/២០២៦" +``` + +## Khmer New Year + +### Full New Year announcement + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +const ny = KhmerDate.getKhNewYearMoment(2026); +const kd = new KhmerDate(ny); + +`${kd.toLunarDate('full')} ត្រូវនឹងថ្ងៃ${kd.toKhmerDate()}`; +// "ថ្ងៃអង្គារ ១២រោច ខែចេត្រ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៦៩ ត្រូវនឹងថ្ងៃទី១៤ ខែមេសា ឆ្នាំ២០២៦" + +const km = new FormatDateTime(ny, 'hh:mm', 'km-KH'); +const en = new FormatDateTime(ny, 'hh:mm A', 'en-US'); +`មហាសង្រ្កាន្ត ម៉ោង ${km.formatDate()} AM (Moha Sankranta at ${en.formatDate()})`; +// "មហាសង្រ្កាន្ត ម៉ោង ១០:៤៨ AM (Moha Sankranta at 10:48 AM)" +``` + +### Next 5 New Years + +```typescript +const startYear = new Date().getFullYear(); +for (let i = 0; i < 5; i++) { + const year = startYear + i; + const ny = KhmerDate.getKhNewYearMoment(year); + const kd = new KhmerDate(ny); + console.log(`${year}: ${kd.toLunarDate('full')}`); +} +``` + +## Digit Conversion + +```typescript +import { KhmerDate } from '@pphatdev/format-datetime'; + +KhmerDate.arabicToKhmerNumber('12345'); // "១២៣៤៥" +KhmerDate.khmerToArabicNumber('១២៣៤៥'); // "12345" +KhmerDate.getKhmerNumber(2026); // "២០២៦" + +// Works with mixed content +KhmerDate.arabicToKhmerNumber('2026-07-13'); // "២០២៦-០៧-១៣" +KhmerDate.arabicToKhmerNumber('$1,234.56'); // "$១,២៣៤.៥៦" +``` + +## Parsing and Validation + +```typescript +// String parsing (via `new Date(str)`) +new FormatDateTime('2026-01-01', 'YYYY', 'en-US').formatDate(); // "2026" +new FormatDateTime('2026-07-13T14:30:45Z', 'HH:mm').formatDate(); // depends on TZ + +// Invalid date returns a sentinel string — no exception +new FormatDateTime('this-is-not-a-date').formatDate(); // "Invalid Date" + +// Omitting the date uses `new Date()` at construction time +const now = new FormatDateTime(); +now.date instanceof Date; // true + +// Guard clause pattern +function safeFormat(input: string | null): string { + const dt = new FormatDateTime(input); + if (isNaN(dt.date.getTime())) return 'N/A'; + return dt.formatDate(); +} +``` + +## React + +### Simple formatted date component + +```tsx +import FormatDateTime from '@pphatdev/format-datetime'; + +function KhmerClock({ date }: { date: Date }) { + const dt = new FormatDateTime(date, 'DDDD d MMMM YYYY, hh:mm:ss A', 'km-KH'); + return ; +} +``` + +### Live-updating clock + +```tsx +import { useEffect, useState } from 'react'; +import FormatDateTime from '@pphatdev/format-datetime'; + +export function LiveKhmerClock() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + const dt = new FormatDateTime(now, 'DDDD d MMMM YYYY hh:mm:ss A', 'km-KH'); + return {dt.formatDate()}; +} +``` + +### Memoized hook + +```tsx +import { useMemo } from 'react'; +import FormatDateTime from '@pphatdev/format-datetime'; + +export function useFormattedDate(date: Date, format: string, locale = 'en-US'): string { + return useMemo(() => new FormatDateTime(date, format, locale).formatDate(), [date, format, locale]); +} + +// usage +const formatted = useFormattedDate(new Date(), 'YYYY-MM-dd', 'km-KH'); +``` + +### i18n locale switch + +```tsx +import { useState } from 'react'; +import FormatDateTime from '@pphatdev/format-datetime'; + +export function DateWithLocaleToggle({ date }: { date: Date }) { + const [locale, setLocale] = useState<'en-US' | 'km-KH'>('en-US'); + const dt = new FormatDateTime(date, 'DDDD, MMMM d, YYYY', locale); + return ( +
+ {dt.formatDate()} + +
+ ); +} +``` + +## Vue + +```vue + + + +``` + +## Svelte + +```svelte + + + +``` + +## Cloudflare Worker + +### Simple JSON endpoint + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const locale = url.searchParams.get('locale') ?? 'en-US'; + const format = url.searchParams.get('format') ?? 'YYYY-MM-dd HH:mm:ss Z'; + + const dt = new FormatDateTime(new Date(), format, locale); + return Response.json({ + solar: dt.formatDate(), + lunar: new KhmerDate().toLunarDate('full'), + timestamp: Date.now(), + }); + }, +}; +``` + +### HTML response + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +export default { + async fetch(): Promise { + const dt = new FormatDateTime(new Date(), 'DDDD, d MMMM YYYY hh:mm A', 'km-KH'); + const kd = new KhmerDate(); + const html = ` +ថ្ងៃនេះ + +

ថ្ងៃនេះ

+

${dt.formatDate()}

+

${kd.toLunarDate('full')}

+`; + return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + }, +}; +``` + +## Node.js CLI + +```typescript +#!/usr/bin/env node +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +const [, , input] = process.argv; +const date = input ? new Date(input) : new Date(); + +if (isNaN(date.getTime())) { + console.error(`Invalid date: ${input}`); + process.exit(1); +} + +console.log(new FormatDateTime(date, 'DDDD, MMMM d, YYYY', 'en-US').formatDate()); +console.log(new KhmerDate(date).toLunarDate('full')); +console.log(new KhmerDate(date).toKhmerDate()); +``` + +Usage: + +```bash +$ node cli.js 2026-07-13 +Monday, July 13, 2026 +ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០ +ទី១៣ ខែកក្កដា ឆ្នាំ២០២៦ +``` + +## Batch Formatting + +### Format a list of dates + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +const dates = [ + new Date(2026, 0, 1), + new Date(2026, 3, 14), // Moha Songkran + new Date(2026, 6, 13), + new Date(2026, 11, 31), +]; + +dates.map(d => new FormatDateTime(d, 'DDDD, MMMM d, YYYY', 'km-KH').formatDate()); +// [ +// "ព្រហស្បតិ៍, មករា ១, ២០២៦", +// "អង្គារ, មេសា ១៤, ២០២៦", +// "ចន្ទ, កក្កដា ១៣, ២០២៦", +// "ព្រហស្បតិ៍, ធ្នូ ៣១, ២០២៦" +// ] +``` + +### Reuse the formatter across dates + +```typescript +const dt = new FormatDateTime(new Date(), 'YYYY-MM-dd'); +const formatted: string[] = []; +for (const d of dates) { + dt.date = d; + formatted.push(dt.formatDate()); +} +``` + +## Date-Range Picker Display + +```tsx +import FormatDateTime from '@pphatdev/format-datetime'; + +function DateRange({ start, end, locale = 'km-KH' }: { start: Date; end: Date; locale?: string }) { + const sameMonth = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth(); + const dtStart = new FormatDateTime(start, sameMonth ? 'd' : 'MMMM d', locale); + const dtEnd = new FormatDateTime(end, 'MMMM d, YYYY', locale); + return {dtStart.formatDate()} – {dtEnd.formatDate()}; +} +``` + +## Chart X-Axis Labels + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +const chartData = getMonthlyRevenue(); // [{ date: Date, revenue: number }, ...] + +const labels = chartData.map(pt => new FormatDateTime(pt.date, 'MMM YYYY', 'en-US').formatDate()); +// e.g. ["Jan 2026", "Feb 2026", "Mar 2026", ...] +``` + +## Testing Patterns + +### Vitest + +```typescript +import { describe, it, expect } from 'vitest'; +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +describe('date formatting', () => { + it('formats Khmer New Year 2026 precisely', () => { + const ny = KhmerDate.getKhNewYearMoment(2026); + expect(new FormatDateTime(ny, 'YYYY-MM-dd hh:mm', 'en-US').formatDate()) + .toBe('2026-04-14 10:48'); + }); + + it('handles Khmer digits', () => { + expect(new FormatDateTime(new Date(2026, 0, 1), 'YYYY', 'km-KH').formatDate()) + .toBe('២០២៦'); + }); +}); +``` + +### Fixing "now" in tests + +```typescript +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import FormatDateTime from '@pphatdev/format-datetime'; + +describe('with fake timers', () => { + beforeEach(() => vi.useFakeTimers().setSystemTime(new Date('2026-07-13T14:30:00Z'))); + afterEach(() => vi.useRealTimers()); + + it('defaults to `now`', () => { + expect(new FormatDateTime(null, 'YYYY-MM-dd').formatDate()).toBe('2026-07-13'); + }); +}); +``` + +## Timezone Handling + +### Force UTC output + +```typescript +const now = new Date(); +const utcMs = now.getTime() + now.getTimezoneOffset() * 60_000; +const utcDate = new Date(utcMs); +new FormatDateTime(utcDate, 'YYYY-MM-dd HH:mm:ss', 'en-US').formatDate(); +// UTC-based formatted string +``` + +### Show both local and Khmer New Year time + +```typescript +const ny = KhmerDate.getKhNewYearMoment(2026); +const local = new FormatDateTime(ny, 'YYYY-MM-dd HH:mm Z').formatDate(); +const utc = new FormatDateTime(new Date(ny.getTime() + ny.getTimezoneOffset() * 60_000), 'YYYY-MM-dd HH:mm').formatDate() + ' UTC'; + +console.log(`Local: ${local}`); +console.log(`UTC: ${utc}`); +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..c70896c --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,244 @@ +# FAQ + +## General + +### What is this library for? + +Formatting dates and times as localized strings using native `Intl` — with special care for **Khmer**, including Khmer digits, six time-of-day phrases, and the full Khmer lunar calendar (Buddhist Era, Jolak Sakaraj, animal years, waxing/waning moon, Moha Songkran). + +### Does it work outside of Khmer? + +Yes. For non-Khmer locales, it delegates to `Intl.DateTimeFormat` and `Intl.NumberFormat`, so any locale your runtime supports works — `en-US`, `fr-FR`, `ja-JP`, `zh-CN`, `ar-EG`, `th-TH`, etc. + +### Why not use `date-fns` or `dayjs`? + +Use those if you don't need Khmer or a lunar calendar. This library is complementary — its tokens are compatible-ish (`YYYY`, `MMMM`, `hh`, `A`) but the value-add is the Khmer support and lunar arithmetic. + +### Does it have dependencies? + +Zero runtime dependencies. Dev-only: `tsup`, `typescript`, `vitest`, `@types/node`. + +### How big is it? + +- Solar-only usage: ~4 KB minified+gzipped +- With lunar: ~8 KB minified+gzipped + +Tree-shakable (`sideEffects: false` in `package.json`). + +## Installation & Setup + +### Which Node versions are supported? + +**Node ≥ 20.0.0**. Enforced via `engines` in `package.json`. CI runs against 20, 22, 24, and 26. + +### How do I use it on Deno? + +```bash +deno add @pphatdev/format-datetime +``` + +Deno pulls raw TypeScript from JSR. No build step involved. See [Runtimes](./runtimes.md#deno). + +### Does it work in Cloudflare Workers? + +Yes. No Node compat flag needed — the library uses only `Intl`, `Date`, and language builtins. See [Runtimes](./runtimes.md#cloudflare-workers). + +### Does it work in React Native / Expo? + +Yes, provided Hermes ≥ 0.72 for full `Intl.NumberFormat` support with `numberingSystem: 'khmr'`. Older Hermes still produces correct output because the library has a character-remap fallback via `Constants.KHMER_NUMBERS`. + +## Usage + +### Why is my month one off? + +JavaScript `Date` months are **0-indexed**. `new Date(2026, 6, 13)` is July 13, not June 13. This is not a library bug — it's the underlying `Date` API. + +### Why does `formatDate()` return `"Invalid Date"`? + +The underlying `Date` object is invalid. Check with `isNaN(dt.date.getTime())`. Common causes: + +- Passing an unparseable string: `new Date('this is not a date')` +- Overflow: `new Date('99999-01-01')` on some runtimes + +The library returns a string sentinel instead of throwing, so downstream code doesn't crash on bad input. + +### Why don't I get Khmer output? + +Check `locale`. The Khmer branch triggers on `locale.toLowerCase().startsWith('km')`: + +```typescript +new FormatDateTime(new Date(), 'MMMM', 'km-KH').formatDate(); // "កក្កដា" +new FormatDateTime(new Date(), 'MMMM', 'KM-KH').formatDate(); // "កក្កដា" (casing OK) +new FormatDateTime(new Date(), 'MMMM', 'km').formatDate(); // "កក្កដា" +new FormatDateTime(new Date(), 'MMMM').formatDate(); // "July" (default en-US) +``` + +### How do I get just the Khmer digits without any date logic? + +```typescript +import { KhmerDate } from '@pphatdev/format-datetime'; + +KhmerDate.arabicToKhmerNumber('12345'); // "១២៣៤៥" +KhmerDate.getKhmerNumber(2026); // "២០២៦" +``` + +### How do I display a Khmer New Year countdown? + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +function countdown(): string { + const now = new Date(); + const currentYearNy = KhmerDate.getKhNewYearMoment(now.getFullYear()); + const target = now < currentYearNy + ? currentYearNy + : KhmerDate.getKhNewYearMoment(now.getFullYear() + 1); + + const msRemaining = target.getTime() - now.getTime(); + const days = Math.floor(msRemaining / 86_400_000); + const targetFmt = new FormatDateTime(target, 'DDDD, MMMM d, YYYY hh:mm A', 'km-KH'); + return `${days} ថ្ងៃ ដល់ ${targetFmt.formatDate()}`; +} +``` + +### Can I format past/future dates? + +Yes. The lunar solver handles dates before and after the 1900-01-01 UTC epoch (walks forward or backward as needed). Practical range: ~1500 CE to ~2500 CE holds well; extreme dates may drift from historical Khmer records. + +### How do I add days to a `KhmerDate`? + +The built-in `add()` and `subtract()` methods are placeholders — they do nothing. Do arithmetic on the underlying `Date`: + +```typescript +const kd = new KhmerDate(new Date(2026, 6, 13)); +const nextDay = new KhmerDate(new Date(kd.getDateTime().getTime() + 86_400_000)); +nextDay.toLunarDate('short'); // one day later +``` + +## Lunar Calendar + +### Why is `khDay()` returning something different from the display? + +`khDay()` returns the **internal** day index (0–29). The display uses a count of 1–15 plus a waxing/waning half indicator. Convert with: + +```typescript +import { KhmerDate } from '@pphatdev/format-datetime'; +// (Calculator is not exported from npm — this is a Deno-only import) + +const kd = new KhmerDate(new Date(2026, 6, 13)); +const internalDay = kd.khDay(); // e.g. 13 +// Display equivalent: `${(internalDay % 15) + 1}${internalDay > 14 ? 'រោច' : 'កើត'}` +// → "14កើត" +``` + +Or just use `toLunarDate('short')` which does this for you. + +### Why does the same date give a different Buddhist Era year in January vs July? + +The BE year changes on **Visakha Bochea** (full moon of Pisakha, ~mid-May), not on January 1. Before VB: `Gregorian + 543`. On/after VB: `Gregorian + 544`. So January 2026 is BE 2569 but July 2026 is BE 2570. + +If you want the "civil" BE (which changes on January 1), use `Gregorian + 543` naively. + +### Why does the Khmer New Year jump between April 13 and 14? + +Traditional Khmer astronomy uses a 365.25-day year, so the New Year moment drifts ~6 hours later each year. Every ~4 years the drift crosses midnight, so the day flips. On Gregorian leap years, the day lands on April 13; otherwise April 14. See [Algorithms](./algorithms.md#khmer-new-year-getkhnewyearmomentgregorianyear). + +### Is the calendar accurate? + +For dates within a few centuries of the 2026 anchor (~1500–2500 CE), yes — matches traditional Khmer records and the royal almanac. For extreme dates (far past or far future), small drifts accumulate; the traditional Soriyatra system itself is an approximation of the true tropical year. + +### Where does the algorithm come from? + +Traditional Khmer horologia (Soriyatra), same lineage as the Thai calendar. Directly ported from [PPhatDev/LunarDate](https://github.com/PPhatDev/LunarDate) (PHP) and [momentkh](https://github.com/ThyrithSor/momentkh) (JS). See [Algorithms](./algorithms.md) for formula-level detail. + +## Tokens + +### Why does `A` and `a` produce the same output in Khmer? + +Khmer script has no case. Both AM/PM tokens produce the same phrase (e.g. `រសៀល`). + +### Can I escape token characters to output them literally? + +No formal escape mechanism. Because tokens require specific character sequences (`YYYY`, `MMMM`, etc.), you can usually just insert punctuation between token-like letters and other letters. For example, `'Year: YYYY'` works fine because `Y` alone isn't a token pattern. + +Trouble spots: the letter `m` (`'YYYYmm'` would render `mm` as minutes — use a separator: `'YYYY-mm'`). + +### What's the difference between `Z` and `z`? + +Both are timezone offsets. `Z` uses ISO 8601 format with colon (`+07:00`); `z` is compact (`+0700`). See [tokens](./tokens.md#timezone-offset). + +### Why is my `MMM` (short month) returning the same as `MMMM` in Khmer? + +The Khmer month table doesn't have a distinct short form. `MMMM` and `MMM` both produce `កក្កដា` for July. If you want abbreviated Khmer month names, you'd need to maintain your own mapping. + +## Performance + +### How fast is it? + +- Solar-only format: ~5–10 µs per call +- With lunar: ~50–200 µs per call (depends on distance from 1900 epoch) + +If you're rendering the same date many times, cache the string. If you're rendering many dates in a hot loop, prefer looping and reassigning `dt.date` rather than constructing a new formatter each time. + +### Does it cache anything? + +Only `KhmerDate.getKhNewYearMoment(year)` results — cached per Gregorian year on the class. The lunar solver (`findLunarDate`) does not cache; each call re-walks from the 1900 epoch. + +### Is it thread-safe / Worker-safe? + +Yes — no mutable module-level state except the `khNewYearCache` (which is idempotent — repeated writes produce the same value). + +## Testing + +### How do I mock the current date? + +Use your test runner's fake-timers utility: + +```typescript +// Vitest +import { vi } from 'vitest'; +vi.useFakeTimers().setSystemTime(new Date('2026-07-13T14:30:00Z')); + +const dt = new FormatDateTime(); // uses the fake now +``` + +### The Deno test suite is nearly identical to the Node one. Why? + +To keep both runtimes independently verified. Node uses Vitest; Deno uses `@std/testing` + `@std/expect`. A behavioral test typically wants to live in both — if you fix a bug, add tests to both suites. + +## Publishing / Contributing + +### How is this published? + +Two channels from the same source: + +- **npm**: pre-built `dist/` via `.github/workflows/npm-publish.yml` +- **JSR**: raw `src/` via `.github/workflows/jsr.yml` + +Version bumps must be applied to `package.json`, `deno.json`, and `jsr.json` together. + +### Can I contribute? + +Yes. Open a PR against `master`. CI runs `npm run build` and `npm run test` (Node 20/22/24/26 + Deno). New tokens or behavior should be tested in both `test/node/` and `test/deno/`. + +### Is there a Discord / Slack? + +No. Use GitHub Issues at [pphatdev/khmer-datetime](https://github.com/pphatdev/khmer-datetime/issues). + +## Miscellaneous + +### Why is `defualtPatterns` misspelled? + +Historical typo preserved for backwards compatibility. Don't rename it. + +### Why is `KhmerDate.arabicToKhmerNumber` a static? Wouldn't a top-level function be cleaner? + +Because the port originated from PHP where these were static class methods. Kept as-is to preserve API shape across ports. + +### Are there Buddhist holidays included? + +`Utils.getBuddhistHolidays(year)` returns Visakha Bochea and Khmer New Year for now. Not re-exported from npm — Deno/JSR consumers can reach it at `src/utils/utils.ts`. + +### What's the difference between `formatDate()` and `formatLunarDate()`? + +`formatDate()` uses the general token pipeline — solar and lunar tokens both work. `formatLunarDate(preset)` is a shortcut that always returns a Khmer lunar string, accepting either a preset (`'full'`, `'medium'`, `'short'`) or a custom token pattern. Under the hood, `formatLunarDate(fmt)` calls `new KhmerDate(this.date).toLunarDate(fmt)`. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..cb48c9a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,201 @@ +# Getting Started + +`@pphatdev/format-datetime` is a zero-dependency utility for formatting dates and times into localized strings using native JavaScript APIs (`Intl.DateTimeFormat`, `Date`). It ships with first-class Khmer (`km-KH`) support including localized digits, six time-of-day phrases, and full Khmer lunar calendar arithmetic — Buddhist Era, Jolak Sakaraj, animal years, era years (Sak), waxing/waning moon, and precise Khmer New Year (Moha Songkran) timing. + +Everything is written in TypeScript, compiled to CJS/ESM/IIFE for npm and consumed as raw TypeScript source on Deno via JSR. + +## Requirements + +- **Runtime**: Node.js ≥ 20.0.0, Bun, Deno, Cloudflare Workers, or any modern browser (Chrome ≥ 71, Firefox ≥ 78, Safari ≥ 14) +- **`Intl` support**: The runtime must ship `Intl.DateTimeFormat` and `Intl.NumberFormat` with the `numberingSystem: 'khmr'` option (required for Khmer digits). All supported runtimes qualify. +- **TypeScript** (optional): ≥ 5.0 for the ambient types shipped in `dist/index.d.ts` and `dist/index.d.mts` + +Nothing else — the package has zero runtime dependencies. + +## Installation + +### Node.js + +```bash +npm install @pphatdev/format-datetime +# or +yarn add @pphatdev/format-datetime +# or +pnpm add @pphatdev/format-datetime +``` + +The `package.json` `exports` field automatically picks the right build: + +- `import` → `dist/index.mjs` (ESM) +- `require` → `dist/index.js` (CJS) +- `types` → `dist/index.d.ts` + +### Bun + +```bash +bun add @pphatdev/format-datetime +``` + +Bun always uses the ESM entry. TypeScript types work out of the box. + +### Deno (JSR) + +```bash +deno add @pphatdev/format-datetime +``` + +Or without adding to your import map, use the JSR specifier directly: + +```typescript +import FormatDateTime from 'jsr:@pphatdev/format-datetime'; +``` + +Deno pulls the raw TypeScript source (`src/index.ts`) directly from JSR — no build step is involved on the Deno side. This is why every intra-repo `import` uses an explicit `.ts` extension. + +### Cloudflare Workers + +```bash +npm install @pphatdev/format-datetime +``` + +```toml +# wrangler.toml +compatibility_date = "2024-01-01" +# No nodejs_compat flag needed — the library uses zero Node built-ins. +``` + +The ESM build is picked up automatically; `sideEffects: false` in `package.json` allows tree-shaking of unused paths. + +### Browser via CDN (UNPKG / jsDelivr) + +```html + + + + + + +``` + +The IIFE bundle exposes the class as the tsup global name `FormatDateTimeBundle` **and** attaches `FormatDateTime` to `globalThis` from the entry file's final statement. Both work; prefer the bare `FormatDateTime`. + +### Framework Setup + +- **Next.js**: Works in both server and client components. In client components, the class-per-render cost is negligible. Use `import FormatDateTime from '@pphatdev/format-datetime'` — the ESM build is picked up. +- **Nuxt / Vite / SvelteKit**: Same story — ESM build, no config needed. +- **Remix**: Works in loaders (server) and components (client). +- **Astro**: Same. If you ship it in a client-hydrated island, it adds ~5 KB minified+gzipped. +- **Expo / React Native**: Requires Hermes ≥ 0.72 for the `numberingSystem: 'khmr'` option. Older Hermes builds render Khmer digits with a fallback swap and still produce correct output. + +## Your First Format + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +const dt = new FormatDateTime(new Date(), "DDDD, MMMM d, YYYY, hh:mm:ss A", "km-KH"); +console.log(dt.formatDate()); +// ចន្ទ, កក្កដា ១៣, ២០២៦, ០១:៣០:៤៥ រសៀល +``` + +Three inputs, three outputs, one call. That's the whole API for the common case. + +### Constructor Signature + +```typescript +new FormatDateTime( + date?: string | Date | null, // defaults to `new Date()` + format?: string | null, // defaults to "dd-MM-yyyy hh:mm:ss" + locale?: string // defaults to "en-US" (any BCP 47 tag) +) +``` + +| Input for `date` | Behavior | +| --- | --- | +| `Date` instance | Used directly (stored by reference — the constructor does **not** clone). | +| `string` | Parsed via `new Date(str)`. Accepts ISO 8601 (`"2026-07-13"`, `"2026-07-13T14:30:45Z"`), RFC 2822, and other browser-parseable formats. | +| `null` / `undefined` | Uses `new Date()` (current wall clock at construction time). | +| Invalid string | Stored as an invalid `Date`; `formatDate()` returns the literal string `"Invalid Date"` without throwing. | + +### Instance Fields + +All three constructor arguments become public, mutable properties: + +```typescript +class FormatDateTime { + date: Date; + format: string; + locale: string; +} +``` + +Mutating them takes effect on the next `formatDate()` call, enabling patterns like: + +```typescript +const dt = new FormatDateTime(); +dt.format = 'YYYY'; // "2026" +dt.format = 'MMMM'; // "July" +dt.locale = 'km-KH'; // "កក្កដា" +``` + +Note: because `FormatDateTime` stores the `Date` by reference, mutating the original `Date` after construction affects future `formatDate()` output. If this matters, clone first: `new FormatDateTime(new Date(original.getTime()), ...)`. + +## Locale Behavior + +The library forks internally on `locale.toLowerCase().startsWith('km')`: + +- **Khmer locales** (`km`, `km-KH`, `km-Khmr`, etc.): + - Month names come from hand-coded `Constants.MONTHS` table (12 solar months in Khmer). + - Weekday names come from `Constants.WEEKDAYS` (long) and `Constants.WEEKDAYS_SHORT` (short). + - Numbers pass through `Intl.NumberFormat` with `numberingSystem: 'khmr'` and are then character-remapped through `Constants.KHMER_NUMBERS` (double-safety: works even when the runtime ignores the numbering system). + - AM/PM (`A`, `a`, `aA`) becomes one of six time-of-day phrases based on hour ranges (see [tokens](./tokens.md)). +- **All other locales**: + - Month, weekday, and AM/PM come from `Intl.DateTimeFormat(locale, ...)`. + - Numbers come from `Intl.NumberFormat(locale, ...)` (which will use the locale's native numbering system if any — e.g. Arabic-Indic digits for `ar-EG`). + - Anything the host runtime's `Intl` supports works: `en-US`, `en-GB`, `fr-FR`, `ja-JP`, `zh-CN`, `ar-EG`, `th-TH`, … + +## Lunar Formatting Shortcut + +For the common case of "just give me the Khmer lunar date," the `formatLunarDate` shortcut avoids constructing `KhmerDate` yourself: + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +const dt = new FormatDateTime(new Date(2026, 6, 13)); +console.log(dt.formatLunarDate('full')); +// ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០ + +console.log(dt.formatLunarDate('short')); +// ១៣រោច ខែបឋមាសាឍ + +console.log(dt.formatLunarDate('lW ldd lN lM')); +// ចន្ទ ១៣ រោច បឋមាសាឍ +``` + +`formatLunarDate()` delegates to `new KhmerDate(this.date).toLunarDate(format)` under the hood. See [Lunar Calendar](./lunar-calendar.md) for the full Khmer calendar API. + +## Common Pitfalls + +- **Passing month as human-readable number**: `Date` months are 0-indexed. `new Date(2026, 6, 13)` is **July** 13, not June. +- **Timezone in string parsing**: `new Date('2026-07-13')` is parsed as UTC midnight (00:00 UTC), which appears as the previous day in western hemispheres. Prefer `new Date('2026-07-13T00:00:00')` for local midnight or explicit constructor `new Date(2026, 6, 13)`. +- **Trusting the invalid-date sentinel**: `formatDate()` returns the string `"Invalid Date"` for invalid dates but doesn't throw. Downstream code that expects a real formatted string may want an explicit `!isNaN(dt.date.getTime())` check. +- **Sharing mutable `Date`**: The constructor doesn't clone. Mutating the original `Date` after construction affects the formatter. +- **Locale casing**: `'KM-KH'` works (the check is lowercased), but stick with the canonical `'km-KH'` for clarity. +- **Custom lunar tokens inside solar format string**: You can freely mix, e.g. `'YYYY-MM-dd (BBBB) lM ld lN lA lE'`. The token generator detects any lunar token and runs the lunar solver once. + +## Next Steps + +- [Token Reference](./tokens.md) — every format token, precedence rules, escaping notes +- [API Reference](./api-reference.md) — full `FormatDateTime` and `KhmerDate` API surface with TypeScript signatures +- [TypeScript Guide](./typescript.md) — all exported types and interfaces +- [Lunar Calendar](./lunar-calendar.md) — how the Khmer calendar arithmetic works +- [Algorithms](./algorithms.md) — deep dive on the traditional Soriyatra formulas +- [Runtimes](./runtimes.md) — platform-specific setup notes +- [Examples](./examples.md) — copy-paste recipes for common scenarios +- [Architecture](./architecture.md) — how the pieces fit together internally +- [FAQ](./faq.md) — answers to common questions diff --git a/docs/lunar-calendar.md b/docs/lunar-calendar.md new file mode 100644 index 0000000..8bb376d --- /dev/null +++ b/docs/lunar-calendar.md @@ -0,0 +1,339 @@ +# Khmer Lunar Calendar + +The lunar calendar arithmetic is **real astronomical arithmetic**, not table-based. Everything derives from a handful of traditional formulas from the Khmer horologia (Soriyatra), packaged for JavaScript. + +This document is a working reference. For the raw formulas, see [Algorithms](./algorithms.md). + +## Historical Context + +The Khmer calendar is a lunisolar system with roots in the ancient Indian calendar (Sūryasiddhānta). It has been in continuous use in Cambodia for more than 1,500 years and is still authoritative for: + +- Religious observances (Visakha Bochea, Meak Bochea, Pchum Ben, Kathina, etc.) +- Khmer New Year (Moha Songkran / ចូលឆ្នាំខ្មែរ) in mid-April +- Astrological calculations +- Traditional holidays and seasonal reckoning + +Two eras run in parallel with the Gregorian: + +- **Buddhist Era** (ព.ស. / BE) — begins 543/544 BCE +- **Jolak Sakaraj** (ចុ.ស. / JS) — a shorter civil era used for calendrical mathematics, begins 638 CE + +## Concepts + +### Buddhist Era (BE, ព.ស.) + +The Khmer Buddhist Era is offset from the Gregorian calendar by **543 or 544 years**, depending on whether the date is before or after **Visakha Bochea** (the traditional Buddha birth day, 14th day of the waning half of the 6th lunar month `ពិសាខ`): + +- Before Visakha Bochea: `BE = Gregorian + 543` +- On or after Visakha Bochea: `BE = Gregorian + 544` + +Computed by `Calculator.getBEYear(dateTime)`. Example: + +```typescript +Calculator.getBEYear(new Date(2026, 0, 1)); // 2569 (Jan is before VB) +Calculator.getBEYear(new Date(2026, 6, 13)); // 2570 (Jul is after VB) +``` + +The library also has `Calculator.getMaybeBEYear(dateTime)` — a cheap heuristic that uses April as the cutoff instead of computing Visakha Bochea. Used internally in tight loops. + +### Jolak Sakaraj (JS, ចុ.ស.) + +An older civil era used for calendrical calculations. Offset: + +- Before Moha Songkran (Khmer New Year): `JS = Gregorian + 543 − 1182` +- On or after Moha Songkran: `JS = Gregorian + 544 − 1182` + +Computed by `Calculator.getJolakSakarajYear(dateTime)`. + +```typescript +Calculator.getJolakSakarajYear(new Date(2026, 6, 13)); // 1388 +``` + +### Animal Year (ឆ្នាំ) + +12-year cycle beginning at `ជូត` (Rat). Rolls over on Khmer New Year. Order matches the East Asian zodiac but with Khmer names: + +| # | Khmer | English | +| --- | --- | --- | +| 0 | ជូត | Rat | +| 1 | ឆ្លូវ | Ox | +| 2 | ខាល | Tiger | +| 3 | ថោះ | Rabbit | +| 4 | រោង | Dragon | +| 5 | ម្សាញ់ | Snake | +| 6 | មមី | Horse | +| 7 | មមែ | Goat | +| 8 | វក | Monkey | +| 9 | រកា | Rooster | +| 10 | ច | Dog | +| 11 | កុរ | Pig | + +Computed by `Calculator.getAnimalYear(dateTime)` → 0–11 index into `Constants.ANIMAL_YEARS`. + +```typescript +Constants.ANIMAL_YEARS[Calculator.getAnimalYear(new Date(2026, 6, 13))]; // "មមី" +``` + +### Era Year / Sak (ស័ក) + +10-year cycle beginning at `សំរឹទ្ធិស័ក`. Derived as `getJolakSakarajYear(date) % 10`. + +| # | Khmer | Meaning | +| --- | --- | --- | +| 0 | សំរឹទ្ធិស័ក | Zero-year | +| 1 | ឯកស័ក | 1st year | +| 2 | ទោស័ក | 2nd year | +| 3 | ត្រីស័ក | 3rd year | +| 4 | ចត្វាស័ក | 4th year | +| 5 | បញ្ចស័ក | 5th year | +| 6 | ឆស័ក | 6th year | +| 7 | សប្តស័ក | 7th year | +| 8 | អដ្ឋស័ក | 8th year | +| 9 | នព្វស័ក | 9th year | + +### Moon Status (កើត / រោច) + +Each lunar month is split into two halves: + +- **Days 1–15** (internal `day` 0–14): **កើត** (waxing / bright half) +- **Days 16+** (internal `day` 15–29): **រោច** (waning / dark half) + +The display count resets each half via: + +```typescript +Calculator.getKhmerLunarDay(day: number): { count: number; moonStatus: number } +``` + +Where `count` is 1–15 and `moonStatus` is 0 for waxing (`កើត`) or 1 for waning (`រោច`). Example: + +| Internal `day` | `count` | `moonStatus` | Display | +| --- | --- | --- | --- | +| 0 | 1 | 0 | `១កើត` | +| 7 | 8 | 0 | `៨កើត` | +| 14 | 15 | 0 | `១៥កើត` (full moon) | +| 15 | 1 | 1 | `១រោច` | +| 20 | 6 | 1 | `៦រោច` | +| 29 | 15 | 1 | `១៥រោច` (new moon eve) | + +### Lunar Months + +There are **12 regular lunar months** and **2 extra Ashadha months** used only in leap-month years: + +| # | Khmer | Approx. Gregorian | +| --- | --- | --- | +| 0 | មិគសិរ | Nov–Dec | +| 1 | បុស្ស | Dec–Jan | +| 2 | មាឃ | Jan–Feb | +| 3 | ផល្គុន | Feb–Mar | +| 4 | ចេត្រ | Mar–Apr | +| 5 | ពិសាខ | Apr–May | +| 6 | ជេស្ឋ | May–Jun | +| 7 | អាសាឍ | Jun–Jul | +| 8 | ស្រាពណ៍ | Jul–Aug | +| 9 | ភទ្របទ | Aug–Sep | +| 10 | អស្សុជ | Sep–Oct | +| 11 | កត្ដិក | Oct–Nov | +| 12 | បឋមាសាឍ | (leap month year only) | +| 13 | ទុតិយាសាឍ | (leap month year only) | + +In a **leap-month year** (Adhikamas / បឋមាសាឍ · ទុតិយាសាឍ), month 7 (`អាសាឍ`) is replaced by the pair 12 → 13. The `Calculator.nextMonthOf()` state machine encodes this: + +```typescript +Calculator.nextMonthOf(6, beYear) // Jyestha → Ashadha OR Pathamasadha (depends on leap year) +Calculator.nextMonthOf(12, beYear) // Pathamasadha → Dvitiyasadha +Calculator.nextMonthOf(13, beYear) // Dvitiyasadha → Sravana +``` + +### Month Lengths + +29 or 30 days per month, with specific rules: + +- **Even-indexed months (0, 2, 4, ...)** → 29 days (`ចាន្ទ`) +- **Odd-indexed months (1, 3, 5, ...)** → 30 days (`សុរិយ`) +- **Special case**: Month 6 (`ជេស្ឋ`) gets **30 days instead of 29** in a leap-day year (`isKhmerLeapDay(beYear)`) +- **Special case**: Both `បឋមាសាឍ` (12) and `ទុតិយាសាឍ` (13) always have 30 days + +Computed by `Calculator.getNumberOfDayInKhmerMonth(beMonth, beYear)`. + +### Year Lengths + +Three possible values: + +| Kind | Days | Detection | +| --- | --- | --- | +| Regular | 354 | Neither leap classification | +| Leap-day (Chantreathimeas) | 355 | `Calculator.isKhmerLeapDay(beYear)` | +| Leap-month (Adhikamas) | 384 | `Calculator.isKhmerLeapMonth(beYear)` | + +Computed by `Calculator.getNumberOfDayInKhmerYear(beYear)`. + +Both leap kinds cannot coexist — the year is one, the other, or neither. The `Calculator.getProtetinLeap(beYear)` method returns `0` (regular), `1` (leap month), or `2` (leap day). + +## The Lunar Solver + +`KhmerDate.findLunarDate(target: Date)` is the workhorse. Algorithm: + +1. **Normalize** `target` to UTC 12:00:00 (`Date.UTC(y, m, d, 12, 0, 0)`) — avoids timezone drift during day-counting. +2. **Initialize** an epoch cursor at `1900-01-01 UTC` with the initial month `បុស្ស` (index 1) and day 0. +3. **Coarse year walk**: Jump forward (or backward) one Khmer year at a time using `getNumberOfDayInKhmerYear()` until the cursor is within one year of the target. +4. **Coarse month walk**: Walk forward one month at a time using `getNumberOfDayInKhmerMonth()` and `nextMonthOf()` until the cursor is within one month. +5. **Fine day fill**: `khmerDay = floor((target − cursor) / 86400000)`. If it overflows the current month's day count, wrap to the next month. + +Returns `{ day: 0–29, month: 0–13, epochMoved: Date }`. The `day` is the *internal* index; wrap it through `Calculator.getKhmerLunarDay(day)` to get the display `{count: 1–15, moonStatus: 0|1}` pair. + +The solver is O(years since 1900). For dates near the year 2100, expect ~200 month-length lookups per call. Cheap in practice — each lookup is arithmetic, no I/O. + +## Khmer New Year (Moha Songkran, មហាសង្រ្កាន្ត) + +`KhmerDate.getKhNewYearMoment(gregorianYear)` computes the **exact moment** of the New Year, not just the day. Projection from a 2026 anchor: + +- Anchor: **14 April 2026, 10:48** local time +- Drift: **6 hours per year forward** on a pure 365.25-day astronomical cycle +- Leap-year adjustment: on a Gregorian leap year, the day is **13 April** instead of 14 + +Formula: + +```typescript +hoursOffset = ((gregorianYear - 2026) * 6) % 24 +hour = (10 + hoursOffset) % 24 +minute = 48 +day = isGregorianLeap(gregorianYear) ? 13 : 14 +``` + +Result is cached per Gregorian year on the class (`khNewYearCache: Record`). + +Verified examples: + +| Gregorian year | Moha Songkran | BE | Animal | Sak | +| --- | --- | --- | --- | --- | +| 2026 | 14 Apr 2026 10:48 | 2569 | មមី | អដ្ឋស័ក | +| 2027 | 14 Apr 2027 16:48 | 2570 | មមែ | នព្វស័ក | +| 2028 | 13 Apr 2028 22:48 | 2571 | វក | សំរឹទ្ធិស័ក | + +## Worked Example: July 13, 2026 + +Let's trace what happens when you compute the lunar date for `new Date(2026, 6, 13)`: + +```typescript +const dt = new FormatDateTime(new Date(2026, 6, 13)); +dt.formatLunarDate('full'); +// "ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០" +``` + +Step-by-step: + +1. **Solver runs** `findLunarDate(2026-07-13)` → `{day: 28, month: 12, ...}` + - month 12 = `បឋមាសាឍ` (proves 2026 is a leap-month year — Adhikamas) + - day 28 (internal) → `getKhmerLunarDay(28)` → `{count: 14, moonStatus: 1}` → `១៤រោច` +2. **BE year**: `getBEYear(2026-07-13)` = 2570 (July is after Visakha Bochea) +3. **Animal year**: `getAnimalYear(2026-07-13)` = 6 → `មមី` (Horse) +4. **Era year**: `getJolakSakarajYear(2026-07-13) % 10` = 8 → `អដ្ឋស័ក` +5. **Weekday**: `getUTCDay()` of the UTC-noon-normalized date = 1 → `ចន្ទ` + +Full format joins these with the template `ថ្ងៃ{lW} {ldd}{lN} ខែ{lM} ឆ្នាំ{lA} {lE} ពុទ្ធសករាជ {BBBB}`. + +## The `Calculator` Class + +Full method reference (all static; all BE-based methods throw for negative input): + +| Method | Purpose | Returns | +| --- | --- | --- | +| `getAharkun(beYear)` | Elapsed days since traditional zero point | number | +| `getAharkunMod(beYear)` | Fractional-day residue | 0–799 | +| `kromthupul(beYear)` | `800 − aharkunMod` | 1–800 | +| `isKhmerSolarLeap(beYear)` | `kromthupul ≤ 207` | 0 or 1 | +| `getBodithey(beYear)` | Days-into-month indicator for New Year setup | 0–29 | +| `getAvoman(beYear)` | Minute-scale residue used for leap-day rules | 0–691 | +| `getBoditheyLeap(beYear)` | Combined leap indicators | 0, 1, 2, or 3 | +| `getProtetinLeap(beYear)` | Final leap classification | 0 (none), 1 (month), 2 (day) | +| `isKhmerLeapMonth(beYear)` | Shortcut for `protetin === 1` | boolean | +| `isKhmerLeapDay(beYear)` | Shortcut for `protetin === 2` | boolean | +| `isGregorianLeap(adYear)` | Standard Gregorian leap rule | boolean | +| `getNumberOfDayInKhmerMonth(m, beYear)` | 29, 30, or leap-adjusted | number | +| `getNumberOfDayInKhmerYear(beYear)` | 354, 355, or 384 | number | +| `getNumberOfDayInGregorianYear(adYear)` | 365 or 366 | number | +| `getBEYear(dateTime)` | 543 vs 544 offset (uses Visakha Bochea) | number | +| `getMaybeBEYear(dateTime)` | Cheap April heuristic (used inside solver) | number | +| `getVisakhaBochea(gregorianYear)` | Scans year for `ពិសាខ` day 14 waning | Date | +| `getJolakSakarajYear(dateTime)` | Uses Moha Songkran cutoff | number | +| `getAnimalYear(dateTime)` | 12-year cycle index | 0–11 | +| `getKhmerLunarDay(day)` | `{count, moonStatus}` from internal day index | object | +| `nextMonthOf(m, beYear)` | Month transition state machine | 0–13 | + +All BE-based methods throw `Error('Buddhist Era year must be positive')` for negative input. `getNumberOfDayInKhmerMonth` additionally throws `Error('Invalid Khmer month index: N')` for out-of-range month indices. + +## Traditional Seasons + +Three seasons based on lunar month (implemented in `Utils.getSeason`): + +| Season | Khmer | English | Months | +| --- | --- | --- | --- | +| Cold | រដូវរងារ | Cold Season | មិគសិរ, បុស្ស, មាឃ | +| Hot | រដូវក្ដៅ | Hot Season | ផល្គុន, ចេត្រ, ពិសាខ | +| Rainy | រដូវវស្សា | Rainy Season | ជេស្ឋ, អាសាឍ, ស្រាពណ៍, ភទ្របទ, អស្សុជ, កត្ដិក | + +## `SoriyatraLerngSak` + +Deep astronomical helper for New Year setup. `SoriyatraLerngSak.calculate(jsYear)` returns: + +```typescript +{ + harkun: number, // Elapsed days from JS epoch + kromathopol: number, // Sub-day residue + avaman: number, // Minute residue + bodithey: number, // Days-into-month for lunar cycle + has366day: boolean, // Sun-year length (366 vs 365) + isAthikameas: boolean, // Same as isKhmerLeapMonth + isChantreathimeas: boolean, // Same as isKhmerLeapDay + jesthHas30: boolean, // Whether Jyestha gets 30 days + dayLerngSak: number, // Day-of-week Lerng Sak occurs (0-6) + lunarDateLerngSak: { day, month }, // Lunar date of Lerng Sak + newYearsDaySotins: Array<{ sotin, angsar, avaman }>, // 4 sotin days + timeOfNewYear: { hour, minute } // Precise New Year time +} +``` + +Reach for this only if reproducing the full royal almanac. For most needs, `KhmerDate.getKhNewYearMoment(year)` is sufficient. + +## Timezone Warning + +The lunar solver normalizes to UTC noon internally, so `new Date(2026, 6, 13)` (local midnight, which is likely `2026-07-12T17:00:00Z` in a `+07:00` zone) yields the same lunar answer as `new Date(Date.UTC(2026, 6, 13, 12, 0, 0))`. If you pass a `Date` at or near midnight UTC boundaries and are in a far-eastern timezone, expect the lunar day to reflect the *local calendar day* — which is usually what you want, but worth knowing. + +If you need strict UTC-day-boundary semantics for a batch computation, construct dates with `Date.UTC(...)` explicitly. + +## Practical Recipes + +### Get the current lunar date + +```typescript +new KhmerDate().toLunarDate('full'); +// "ថ្ងៃចន្ទ ១៣រោច ខែបឋមាសាឍ ឆ្នាំមមី អដ្ឋស័ក ពុទ្ធសករាជ ២៥៧០" +``` + +### Check if this year has a leap month + +```typescript +import { Calculator } from '@pphatdev/format-datetime/src/lunar/calculator.ts'; +// Deno / JSR only — not available on npm + +const beYear = new KhmerDate().khYear(); +Calculator.isKhmerLeapMonth(beYear); // true or false +Calculator.isKhmerLeapDay(beYear); // true or false +Calculator.getNumberOfDayInKhmerYear(beYear); // 354, 355, or 384 +``` + +### Find all full moons in a Gregorian year + +```typescript +import { Utils } from '@pphatdev/format-datetime/src/utils/utils.ts'; // Deno only +const fullMoons = Utils.findLunarDayOccurrences(15, 0, 2026); +// Array of { gregorian: 'YYYY-MM-DD', khmer: '...', month: number } +``` + +### Convert between eras + +```typescript +Utils.convertEra(2026, 'AD', 'BE'); // 2569 (before VB heuristic — actual depends on date) +Utils.convertEra(2026, 'AD', 'JS'); // 844 +Utils.convertEra(2570, 'BE', 'AD'); // 2027 +``` diff --git a/docs/runtimes.md b/docs/runtimes.md new file mode 100644 index 0000000..e9fc272 --- /dev/null +++ b/docs/runtimes.md @@ -0,0 +1,266 @@ +# Runtimes + +The library ships to **npm** and **JSR** from the same source, but consumers reach it differently: + +| Runtime | Channel | What it loads | +| --- | --- | --- | +| Node.js ≥ 20 | npm | `dist/index.js` (CJS) or `dist/index.mjs` (ESM) via `package.json` `exports` | +| Bun | npm | `dist/index.mjs` (ESM) | +| Cloudflare Workers | npm | `dist/index.mjs` (ESM, `sideEffects: false` enables tree-shaking) | +| Deno | JSR | `src/index.ts` **directly** (no build) | +| Browser (bundler) | npm | `dist/index.mjs` | +| Browser (CDN) | UNPKG / jsDelivr | `dist/index.global.js` (IIFE, attaches `FormatDateTime` to `globalThis`) | + +The consequence: the code in `src/` must run natively on every one of these targets. Only `Intl.DateTimeFormat`, `Intl.NumberFormat`, and `Date` are used — no `fs`, no `path`, no `process`, no Node built-ins. + +## Node.js + +### Installation + +```bash +npm install @pphatdev/format-datetime +``` + +### ESM + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +const dt = new FormatDateTime(new Date(), 'YYYY-MM-dd HH:mm:ss'); +console.log(dt.formatDate()); +``` + +### CommonJS + +```javascript +const FormatDateTime = require('@pphatdev/format-datetime').default; +const { KhmerDate } = require('@pphatdev/format-datetime'); + +console.log(new FormatDateTime(new Date(), 'YYYY-MM-dd').formatDate()); +``` + +### Minimum Node version + +**20.0.0** (see `engines` in `package.json`). Required for consistent `Intl` behavior and the `numberingSystem` option on `Intl.NumberFormat`. + +### Full-ICU note + +Node.js ships with full ICU data by default since v13. If you're running a slimmed-down Node build (`--with-intl=small-icu`), non-English locale names may fall back to English. All supported runtimes tested in CI use full ICU. + +## Bun + +```bash +bun add @pphatdev/format-datetime +``` + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; +``` + +Bun always uses the ESM entry. TypeScript types are picked up automatically from `dist/index.d.mts`. + +Bun's `Intl` implementation is complete — Khmer numbering system, all locales, timezone offsets, all work identically to Node. + +## Deno + +### Installation + +```bash +deno add @pphatdev/format-datetime +``` + +This adds `"@pphatdev/format-datetime": "jsr:@pphatdev/format-datetime@^0.3.5"` to your `deno.json` imports. + +### Import + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; +``` + +Or without adding to your import map: + +```typescript +import FormatDateTime from 'jsr:@pphatdev/format-datetime'; +``` + +Or pin a specific version: + +```typescript +import FormatDateTime from 'jsr:@pphatdev/format-datetime@0.3.5'; +``` + +### How Deno consumes this package + +Deno reads `src/index.ts` **directly** from JSR — that's why every intra-project import uses the `.ts` extension. There is **no build step and no `dist/`** on the Deno side. + +The `deno.json` and `jsr.json` at the repo root both set: + +```json +{ "exports": "./src/index.ts" } +``` + +### Permissions + +The library needs **zero permissions** — no `--allow-net`, `--allow-read`, `--allow-env`. It's pure computation. + +## Cloudflare Workers + +### Installation + +```bash +npm install @pphatdev/format-datetime +``` + +### wrangler.toml + +```toml +name = "my-worker" +main = "src/index.ts" +compatibility_date = "2024-01-01" +# nodejs_compat is NOT needed — this library uses zero Node built-ins. +``` + +### Worker code + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; + +export default { + async fetch(_req: Request): Promise { + const dt = new FormatDateTime(new Date(), 'DDDD, MMMM d, YYYY hh:mm A', 'en-US'); + const kd = new KhmerDate(); + return Response.json({ + solar: dt.formatDate(), + lunar: kd.toLunarDate('full'), + }); + }, +}; +``` + +### Bundle size in Workers + +With tree-shaking (`sideEffects: false` is set in `package.json`), the effective size added to your Worker is roughly: + +- Solar-only usage: ~4 KB minified+gzipped +- With lunar: ~8 KB minified+gzipped + +The `globalThis.FormatDateTime` assignment in the entry is a side-effect statement — the bundler emits it but it costs nothing at runtime in a Worker. + +## Browser via Bundler (Vite, Rollup, Webpack, etc.) + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; +``` + +Bundlers pick up `dist/index.mjs` via the `import` condition in `exports`. Tree-shaking works — if you only use `FormatDateTime` (not `KhmerDate` or lunar tokens), the lunar module is dropped. + +Framework-specific notes: + +- **Next.js (Pages + App Router)**: works in both server and client components. Import at the top of the file; nothing further needed. +- **Nuxt 3**: works out of the box. Use ` + +``` + +The IIFE bundle exposes the class as **both**: + +- `globalThis.FormatDateTime` (from the entry file's final statement) +- `globalThis.FormatDateTimeBundle` (tsup's `--global-name` option) + +Use whichever is clearer. `KhmerDate` is not attached to the global — for lunar features in a CDN scenario, use the ESM build below. + +### ESM via CDN + +```html + +``` + +### jsDelivr + +```html + +``` + +Or ESM: + +```html + +``` + +### Version pinning + +Always pin the version in production: + +```html + +``` + +## Build Output Reference + +Running `npm run build` produces the following in `dist/`: + +| File | Format | Purpose | +| --- | --- | --- | +| `index.js` | CJS | `require()` and `package.json` `main` | +| `index.mjs` | ESM | `import` and `package.json` `module` | +| `index.global.js` | IIFE | UNPKG / jsDelivr CDN with global name `FormatDateTimeBundle` (also mirrored as `FormatDateTime` on `globalThis`) | +| `index.d.ts` | TypeScript types (CJS) | `package.json` `types` | +| `index.d.mts` | TypeScript types (ESM) | Emitted alongside the ESM build | + +All builds are **minified** via tsup with esbuild under the hood. The tsup config lives inline in the `build` script: + +```bash +tsup src/index.ts --format cjs,esm,iife --global-name FormatDateTimeBundle --dts --clean --minify +``` + +- `--format cjs,esm,iife` — three output formats +- `--global-name FormatDateTimeBundle` — the IIFE bundle's global name (in addition to the entry file's `globalThis.FormatDateTime` assignment) +- `--dts` — emit `.d.ts` files alongside JS +- `--clean` — wipe `dist/` before rebuilding +- `--minify` — minify all outputs + +## Testing Locally Across Runtimes + +```bash +npm run test:node # Vitest against test/node/ +npm run test:deno # `deno test -A test/deno/` +npm run test # both, sequentially +``` + +The two suites are **near-duplicates** on purpose. A behavioral test typically wants to live in both, so that both runtimes stay green independently. If you add a feature that behaves differently on Node vs Deno, add tests that reflect the difference in each suite. + +## CI + +`.github/workflows/ci.yml` runs `npm run test` on a matrix of Node **20 / 22 / 24 / 26** with Deno also installed. `npm-publish.yml` and `jsr.yml` handle release automation to their respective registries. + +## Runtime Feature Matrix + +| Feature | Node ≥20 | Bun | Deno | CF Workers | Browser | +| --- | --- | --- | --- | --- | --- | +| `FormatDateTime` (solar) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Khmer locale digits | ✅ | ✅ | ✅ | ✅ | ✅ | +| Khmer time-of-day phrases | ✅ | ✅ | ✅ | ✅ | ✅ | +| `KhmerDate` (lunar) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Timezone tokens (`Z`, `z`) | ✅ | ✅ | ✅ | ✅ (UTC) | ✅ | +| Global `FormatDateTime` via CDN | N/A | N/A | N/A | N/A | ✅ (IIFE) | +| Deep-import internal classes | ❌ | ❌ | ✅ (JSR) | ❌ | ❌ | + +Cloudflare Workers always run in UTC — the timezone offset tokens will always output `+00:00`. diff --git a/docs/tokens.md b/docs/tokens.md new file mode 100644 index 0000000..3188563 --- /dev/null +++ b/docs/tokens.md @@ -0,0 +1,289 @@ +# Token Reference + +Format strings are ordinary text with **tokens** interpolated in. `FormatDateTime.formatDate()` builds a token → localized-value dictionary via `generateTokens(date, format, locale)` and then does a **single regex replacement**, with tokens sorted **longest-first** so that `MMMM` is never eaten by `MM`. + +Any character that is not a token — spaces, punctuation, Khmer text, digits, parentheses, hyphens — passes through untouched. + +## Solar Tokens + +### Year + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `YYYY` | 4-digit year | `2026` | `២០២៦` | +| `yyyy` | 4-digit year (alias) | `2026` | `២០២៦` | +| `YY` | 2-digit year | `26` | `២៦` | +| `yy` | 2-digit year (alias) | `26` | `២៦` | + +Computed as `date.getFullYear()`. The 2-digit form takes `year % 100`. + +### Month + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `MMMM` | Full month name | `July` | `កក្កដា` | +| `MMM` | Short month name | `Jul` | `កក្កដា` | +| `MM` | 2-digit month | `07` | `០៧` | +| `M` | Month number | `7` | `៧` | + +Numeric month uses `date.getMonth() + 1` (1–12). Names come from `Intl.DateTimeFormat(locale, { month: 'long' | 'short' })` for non-Khmer locales, or the hand-coded `Constants.MONTHS` array for Khmer. + +For Khmer, `MMMM` and `MMM` return the same string — there is no distinct short form in the tables. + +### Day of Week + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `DDDD` | Full weekday name | `Monday` | `ចន្ទ` | +| `DDD` | Short weekday name | `Mon` | `ចន្ទ` | +| `DD` | 2-char weekday | `Mo` | `ច` | +| `D` | 1-char weekday | `M` | `ច` | + +`DDDD` and `DDD` come from `Intl.DateTimeFormat` (or `Constants.WEEKDAYS` / `Constants.WEEKDAYS_SHORT` for Khmer). `DD` and `D` are `slice(0, 2)` and `slice(0, 1)` of the short form. + +### Day of Month + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `dd` | 2-digit day (01–31) | `13` | `១៣` | +| `d` | Day (1–31) | `13` | `១៣` | + +Computed as `date.getDate()`. + +### Hour + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `HH` | 24-hour, 2 digits (00–23) | `14` | `១៤` | +| `H` | 24-hour (0–23) | `14` | `១៤` | +| `hh` | 12-hour, 2 digits (01–12) | `02` | `០២` | +| `h` | 12-hour (1–12) | `2` | `២` | + +12-hour is computed as `h % 12 || 12` — so 0 and 12 both display as `12`. + +### Minute / Second + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `mm` | Minutes, 2 digits (00–59) | `30` | `៣០` | +| `m` | Minutes (0–59) | `30` | `៣០` | +| `ss` | Seconds, 2 digits (00–59) | `45` | `៤៥` | +| `s` | Seconds (0–59) | `45` | `៤៥` | + +Note that `m` and `mm` display the same for values ≥ 10. The distinction only matters for single-digit values (`5` vs `05`). + +### AM/PM / Time-of-Day + +| Token | Description | `en-US` | `km-KH` | +| --- | --- | --- | --- | +| `A` | Uppercase AM/PM (localized) | `PM` | `រសៀល` | +| `a` | Lowercase AM/PM (localized) | `pm` | `រសៀល` | +| `aA` | Mixed-case AM/PM | `pm` | `រសៀល` | + +For non-Khmer locales, the AM/PM string comes from `Intl.DateTimeFormat(locale, { hour: 'numeric', hour12: true }).formatToParts(date)` — searching for the `dayPeriod` part. This means locales like `ja-JP` produce `午前` / `午後`, `ar-EG` produces `ص` / `م`, etc. + +For Khmer, one of six phrases based on `date.getHours()`: + +| Hour range | Phrase | Meaning | +| --- | --- | --- | +| `0–4` | `រំលងអធ្រាត្រ` | Past midnight / early morning | +| `5–11` | `ព្រឹក` | Morning | +| `12` | `ថ្ងៃត្រង់` | Noon (exact hour 12 only) | +| `13–16` | `រសៀល` | Afternoon | +| `17–19` | `ល្ងាច` | Evening | +| `20–23` | `យប់` | Night | + +Both `A` and `a` return the same phrase in Khmer — there is no case distinction for Khmer script. + +### Timezone Offset + +| Token | Description | Example | +| --- | --- | --- | +| `Z` | ISO 8601 offset with colon | `+07:00` | +| `ZZ` | ISO 8601 offset with colon and seconds | `+07:00:00` | +| `z` | Compact offset without colon | `+0700` | +| `zz` | Compact offset (extended, includes 00 seconds) | `+070000` | + +Computed from `date.getTimezoneOffset()` (which returns minutes **west of UTC** — hence the sign is flipped: `tzSign = tzOffset > 0 ? "-" : "+"`). + +Sub-minute timezones are not supported (the seconds portion always renders as `00`). + +## Lunar Tokens (Khmer only) + +Any of these tokens **triggers** the lunar solver. If none appear in the format string, no lunar work is done (cheap early exit). + +### Year Tokens + +| Token | Description | Example | +| --- | --- | --- | +| `BBBB` | Buddhist Era year (4 digits) | `២៥៧០` | +| `JJJJ` | Jolak Sakaraj year (4 digits) | `១៣៨៨` | + +`BBBB` uses `Calculator.getBEYear(date)` — computed relative to Visakha Bochea, so it's 543 or 544 above the Gregorian year depending on whether the date is before or after that lunar day. + +`JJJJ` uses `Calculator.getJolakSakarajYear(date)` — computed relative to Khmer New Year (Moha Songkran), so it's `Gregorian + 543 − 1182` before New Year or `Gregorian + 544 − 1182` after. + +### Animal & Era Year + +| Token | Description | Example | +| --- | --- | --- | +| `lA` | Animal year name | `មមី` | +| `lE` | Era year name (Sak) | `អដ្ឋស័ក` | + +12-year animal cycle: `ជូត`, `ឆ្លូវ`, `ខាល`, `ថោះ`, `រោង`, `ម្សាញ់`, `មមី`, `មមែ`, `វក`, `រកា`, `ច`, `កុរ`. + +10-year era cycle: `សំរឹទ្ធិស័ក`, `ឯកស័ក`, `ទោស័ក`, `ត្រីស័ក`, `ចត្វាស័ក`, `បញ្ចស័ក`, `ឆស័ក`, `សប្តស័ក`, `អដ្ឋស័ក`, `នព្វស័ក`. + +Both cycles roll over on Khmer New Year. + +### Lunar Month + +| Token | Description | Example | +| --- | --- | --- | +| `lM` | Lunar month name | `បឋមាសាឍ` | + +14 possible values: 12 regular months plus `បឋមាសាឍ` (12) and `ទុតិយាសាឍ` (13) which only occur in leap-month years. Full list: `មិគសិរ`, `បុស្ស`, `មាឃ`, `ផល្គុន`, `ចេត្រ`, `ពិសាខ`, `ជេស្ឋ`, `អាសាឍ`, `ស្រាពណ៍`, `ភទ្របទ`, `អស្សុជ`, `កត្ដិក`, `បឋមាសាឍ`, `ទុតិយាសាឍ`. + +### Lunar Day & Moon Status + +| Token | Description | Example | +| --- | --- | --- | +| `ldd` | Lunar day count (2 digits, 1–15) | `១៣` | +| `ld` | Lunar day count (1–15) | `១៣` | +| `lN` | Moon status | `រោច` (waning) / `កើត` (waxing) | +| `ln` | Moon status (single-char) | `រ` / `ក` | + +Each lunar month is split into two halves: + +- Days 1–15 (internal `day` 0–14): `កើត` (waxing / bright half) +- Days 16+ (internal `day` 15+): `រោច` (waning / dark half) + +The display count resets each half — see `Calculator.getKhmerLunarDay(day)`. So the 20th internal day of a 30-day month displays as `៥ រោច` (5th day of waning half). + +### Khmer Weekday + +| Token | Description | Example | +| --- | --- | --- | +| `lW` | Khmer weekday (full) | `ចន្ទ` | +| `lw` | Khmer weekday (short) | `ច` | + +Weekday order: `អាទិត្យ` (Sun), `ចន្ទ` (Mon), `អង្គារ` (Tue), `ពុធ` (Wed), `ព្រហស្បតិ៍` (Thu), `សុក្រ` (Fri), `សៅរ៍` (Sat). Short forms: first character of each. + +Computed from a UTC-normalized copy of the date to prevent timezone drift. + +## Precedence Rule + +Tokens are matched **longest-first**: + +- `MMMM` beats `MMM` beats `MM` beats `M` +- `DDDD` beats `DDD` beats `DD` beats `D` +- `hh` beats `h`, `HH` beats `H` +- `ldd` beats `ld` +- `ZZ` beats `Z`, `zz` beats `z` + +This is guaranteed at replacement time by `keys.sort((a, b) => b.length - a.length)` in `formatDate()`. If you add a new token in a fork or contribution, do **not** change this comparator. + +## Character Class Guarantees + +Every token in `PATTERNS` uses a specific character class: + +- Digit tokens (`d`, `dd`, `M`, `MM`, `YYYY`, `YY`, `h`, `hh`, `H`, `HH`, `m`, `mm`, `s`, `ss`, `BBBB`, `JJJJ`, `ldd`, `ld`) — `\d` +- Letter tokens (`D`, `DD`, `DDD`, `DDDD`, `MMM`, `MMMM`) — `[a-zA-Z]` +- Khmer text tokens (`lA`, `lE`, `lM`, `lN`, `lW`) — `[ក-៿]+` +- Single Khmer char (`ln`, `lw`) — `[ក-៿]` +- AM/PM (`a`, `A`, `aA`) — literal strings `am|pm|AM|PM` +- Timezone (`Z`, `ZZ`, `z`, `zz`) — offset patterns like `[+-]\d{2}:\d{2}` + +These regex fragments are exposed via `FormatDateTime.patterns` (the `PATTERNS` map). You rarely need them unless you're parsing formatted output back into date parts. + +## Escaping + +There is **no escape mechanism**. If you need a literal `Y`, `M`, `D`, `h`, `m`, `s`, `a`, `A`, `Z`, `z`, `B`, `J`, or `l` in the output, either: + +1. Place it inside content that doesn't match a token pattern (any punctuation or Khmer text acts as a barrier). +2. Bracket the token with characters that don't complete a token. For example, the format `'Year: YYYY'` works fine because `Y` alone isn't a token pattern — only `YY` and `YYYY` are. + +In practice this rarely bites. The format `'Time is HH:mm'` works because the `T`, `i`, `m`, `e`, `is` aren't tokens. `'YYYY-MM-dd'` works because `-` isn't consumed. `'ថ្ងៃ DDDD'` works because Khmer text isn't consumed by any `[a-zA-Z]` token. + +The main gotcha is the letter `m`: `'HH:mm'` renders correctly, but `'MMs'` would render `s` as seconds. Use a separator: `'MM/s'`. + +## Examples + +### Solar + +```typescript +// ISO date +new FormatDateTime(new Date(2026, 6, 13), 'YYYY-MM-dd', 'en-US').formatDate(); +// "2026-07-13" + +// 12-hour time with AM/PM +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'hh:mm:ss A', 'en-US').formatDate(); +// "02:30:45 PM" + +// Full date with weekday +new FormatDateTime(new Date(2026, 6, 13), 'DDDD, MMMM d, YYYY', 'en-US').formatDate(); +// "Monday, July 13, 2026" + +// ISO 8601 with timezone +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'YYYY-MM-ddTHH:mm:ssZ').formatDate(); +// e.g. "2026-07-13T14:30:45+07:00" (depends on runtime timezone) + +// Compact timezone +new FormatDateTime(new Date(), 'YYYYMMddTHHmmssz').formatDate(); +// e.g. "20260713T143045+0700" +``` + +### Khmer + +```typescript +// Full Khmer date +new FormatDateTime(new Date(2026, 6, 13, 14, 30, 45), 'DDDD, MMMM d, YYYY, hh:mm:ss A', 'km-KH').formatDate(); +// "ចន្ទ, កក្កដា ១៣, ២០២៦, ០២:៣០:៤៥ រសៀល" + +// Time-of-day only +new FormatDateTime(new Date(2026, 6, 13, 3, 0, 0), 'a', 'km-KH').formatDate(); // "រំលងអធ្រាត្រ" +new FormatDateTime(new Date(2026, 6, 13, 9, 0, 0), 'a', 'km-KH').formatDate(); // "ព្រឹក" +new FormatDateTime(new Date(2026, 6, 13, 12, 0, 0), 'a', 'km-KH').formatDate(); // "ថ្ងៃត្រង់" +new FormatDateTime(new Date(2026, 6, 13, 14, 0, 0), 'a', 'km-KH').formatDate(); // "រសៀល" +new FormatDateTime(new Date(2026, 6, 13, 18, 0, 0), 'a', 'km-KH').formatDate(); // "ល្ងាច" +new FormatDateTime(new Date(2026, 6, 13, 22, 0, 0), 'a', 'km-KH').formatDate(); // "យប់" +``` + +### Lunar + +```typescript +// Mixed solar + lunar +new FormatDateTime(new Date(2026, 6, 13), 'YYYY-MM-dd (BBBB) lM ld lN lA lE').formatDate(); +// "2026-07-13 (2570) បឋមាសាឍ 14 រោច មមី អដ្ឋស័ក" + +// Custom Khmer lunar pattern +new FormatDateTime(new Date(2026, 6, 13)).formatLunarDate('lW ldd lN lM'); +// "ចន្ទ ១៣ រោច បឋមាសាឍ" +``` + +### Other locales + +```typescript +new FormatDateTime(new Date(2026, 6, 13, 14, 30), 'DDDD, MMMM d, YYYY, hh:mm A', 'ja-JP').formatDate(); +// e.g. "月曜日, 7月 13, 2026, 02:30 午後" + +new FormatDateTime(new Date(2026, 6, 13, 14, 30), 'DDDD, MMMM d, YYYY', 'fr-FR').formatDate(); +// "lundi, juillet 13, 2026" + +new FormatDateTime(new Date(2026, 6, 13), 'dd/MM/YYYY', 'ar-EG').formatDate(); +// "١٣/٠٧/٢٠٢٦" (Arabic-Indic digits via Intl.NumberFormat) +``` + +## Advanced: Building Your Own Format Strings + +Because tokens are matched by regex, you can concatenate them freely: + +```typescript +'MMMM' → "July" +'MMMM YYYY' → "July 2026" +'MMMM d, YYYY' → "July 13, 2026" +'MMMM d YYYY' → "July 13 2026" (spaces are literal) +'MMMM/d/YYYY' → "July/13/2026" +``` + +The token generator computes every possible token value up front, so adding more tokens to a format string doesn't cost more than a longer regex — the work is done once per `formatDate()` call. diff --git a/docs/typescript.md b/docs/typescript.md new file mode 100644 index 0000000..8315fd6 --- /dev/null +++ b/docs/typescript.md @@ -0,0 +1,303 @@ +# TypeScript Guide + +The package is authored in TypeScript. tsup emits declaration files (`dist/index.d.ts` and `dist/index.d.mts`) alongside the JS bundles, so TypeScript consumers get full type inference out of the box. + +## Import Styles + +### Default + named + +```typescript +import FormatDateTime, { KhmerDate } from '@pphatdev/format-datetime'; +``` + +### All named + +```typescript +import { FormatDateTime, KhmerDate } from '@pphatdev/format-datetime'; +``` + +### Namespace (CJS interop) + +```typescript +import * as FD from '@pphatdev/format-datetime'; +const dt = new FD.default(new Date()); // default export +const kd = new FD.KhmerDate(); +``` + +### CommonJS require + +```typescript +const { FormatDateTime, KhmerDate, default: FD } = require('@pphatdev/format-datetime'); +// `FormatDateTime` and `FD` refer to the same class +``` + +## Type Signatures + +### `FormatDateTime` + +```typescript +export class FormatDateTime { + static patterns: Record; + static defualtPatterns: string[]; + + public date: Date; + public format: string; + public locale: string; + + constructor( + date?: string | Date | null, + format?: string | null, + locale?: string + ); + + static tokens(date: Date, format: string, locale: string): Record; + + formatDate(): string; + formatLunarDate(format?: string): string; + toString(): string; +} +``` + +### `KhmerDate` + +```typescript +export class KhmerDate { + protected dateTime: Date; + protected static khNewYearCache: Record; + + constructor(date?: string | Date | number | null); + + static create(date?: string | Date | number | null): KhmerDate; + static createFromDate(dateTime: Date): KhmerDate; + + static findLunarDate(target: Date): { + day: number; + month: number; + epochMoved: Date; + }; + static getKhNewYearMoment(gregorianYear: number): Date; + + static getKhmerMonthNames(): string[]; + static getAnimalYearNames(): string[]; + static getEraYearNames(): string[]; + static khmerToArabicNumber(khmerNumber: string): string; + static arabicToKhmerNumber(arabicNumber: string): string; + static getKhmerNumber(number: number): string; + + getDateTime(): Date; + toLunarDate(format?: string | null): string; + toKhmerDate(format?: string | null): string; + khDay(): number; + khMonth(): number; + khYear(): number; + getTimestamp(): number; + copy(): KhmerDate; + toString(): string; + + format(_format: string): string; // placeholder + add(_interval: string): this; // placeholder + subtract(_interval: string): this; // placeholder +} +``` + +## Type-Only Utilities + +There are no exported utility types (`type` / `interface` declarations at the package boundary). If you need type-safe format strings or token unions, define them locally: + +### Union of Supported Tokens + +```typescript +type SolarToken = + | 'YYYY' | 'yyyy' | 'YY' | 'yy' + | 'MMMM' | 'MMM' | 'MM' | 'M' + | 'DDDD' | 'DDD' | 'DD' | 'D' + | 'dd' | 'd' + | 'HH' | 'H' | 'hh' | 'h' + | 'mm' | 'm' | 'ss' | 's' + | 'A' | 'a' | 'aA' + | 'Z' | 'ZZ' | 'z' | 'zz'; + +type LunarToken = + | 'BBBB' | 'JJJJ' + | 'lA' | 'lE' | 'lM' + | 'ldd' | 'ld' | 'lN' | 'ln' + | 'lW' | 'lw'; + +type Token = SolarToken | LunarToken; +``` + +### Preset Union + +```typescript +type LunarPreset = 'full' | 'medium' | 'short'; +type LocaleTag = 'en-US' | 'km-KH' | 'fr-FR' | 'ja-JP' | 'zh-CN' | 'ar-EG' | (string & {}); +``` + +The `(string & {})` trick preserves editor autocomplete for the known values while allowing any string. + +## Interfaces from Source-Only Modules + +If you're consuming the library on Deno / JSR and importing the internal classes directly, these interfaces are available: + +### From `src/lunar/khmer-formatter.ts` + +```typescript +export interface LunarDateData { + day: number; + month: number; + dateTime: Date; +} +``` + +### From `src/lunar/soriyatra-lerng-sak.ts` + +```typescript +export interface LunarDateLerngSak { + day: number; + month: number; +} + +export interface NewYearDaySotin { + sotin: number; + angsar: number; + avaman: number; +} + +export interface NewYearTime { + hour: number; + minute: number; +} + +export interface SoriyatraLerngSakInfo { + harkun: number; + kromathopol: number; + avaman: number; + bodithey: number; + has366day: boolean; + isAthikameas: boolean; + isChantreathimeas: boolean; + jesthHas30: boolean; + dayLerngSak: number; + lunarDateLerngSak: LunarDateLerngSak; + newYearsDaySotins: NewYearDaySotin[]; + timeOfNewYear: NewYearTime; +} +``` + +### From `src/utils/utils.ts` + +```typescript +export interface KhmerLunarDayInfo { + day: number; + count: number; + moonStatus: number; + formatted: string; +} + +export interface LunarDayOccurrence { + gregorian: string; + khmer: string; + month: number; +} + +export interface KhmerDateDiff { + days: number; + years: number; + months: number; + gregorian_diff: number; + is_past: boolean; +} + +export interface BuddhistHoliday { + name: string; + name_en: string; + date: string; + khmer_date: string; +} + +export interface SeasonInfo { + name: string; + name_en: string; +} +``` + +## Strictness + +The package's own `tsconfig.json` sets: + +```json +{ + "target": "es2017", + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "emitDeclarationOnly": true, + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true +} +``` + +Consumers with looser strictness will still get correct types. Consumers with stricter settings (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`) should not see any issues — the public API surface uses only well-typed returns. + +## Common Type Patterns + +### Type-safe wrapper + +```typescript +import FormatDateTime from '@pphatdev/format-datetime'; + +interface FormatOptions { + date?: Date; + format?: string; + locale?: 'en-US' | 'km-KH'; +} + +export function formatDate({ date, format, locale = 'en-US' }: FormatOptions = {}): string { + return new FormatDateTime(date ?? new Date(), format, locale).formatDate(); +} +``` + +### Discriminated union of presets and custom formats + +```typescript +type LunarFormatArg = { kind: 'preset'; value: 'full' | 'medium' | 'short' } | { kind: 'custom'; value: string }; + +function formatLunar(date: Date, arg: LunarFormatArg): string { + return new FormatDateTime(date).formatLunarDate(arg.value); +} +``` + +### React hook + +```typescript +import { useMemo } from 'react'; +import FormatDateTime from '@pphatdev/format-datetime'; + +export function useFormatted(date: Date, format: string, locale = 'en-US'): string { + return useMemo(() => new FormatDateTime(date, format, locale).formatDate(), [date, format, locale]); +} +``` + +## Ambient Global Type + +The IIFE bundle attaches `FormatDateTime` to `globalThis`. If you're writing TypeScript for a CDN-loaded page, declare the global: + +```typescript +declare global { + const FormatDateTime: typeof import('@pphatdev/format-datetime').FormatDateTime; +} + +export {}; +``` + +Then `FormatDateTime` is available with full type inference in any browser file. + +## Common Mistakes + +- **Passing month as 1-indexed**: `new Date(2026, 7, 13)` is *August* 13. Use `new Date(2026, 6, 13)` for July. +- **Assuming `formatDate()` throws on invalid input**: It returns the string `"Invalid Date"`. If your downstream code expects a `Date`-like string, add a validity check first. +- **Deep-importing from npm**: `import { Calculator } from '@pphatdev/format-datetime/src/lunar/calculator.ts'` **does not work** on npm — only `dist/index.mjs` ships. This works on Deno/JSR. +- **Confusing internal `day` (0–29) with display count (1–15)**: `khDay()` returns the internal index. Wrap it in `Calculator.getKhmerLunarDay(day)` for the display pair. diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..491009a --- /dev/null +++ b/llms.txt @@ -0,0 +1,49 @@ +# @pphatdev/format-datetime + +> A zero-dependency utility for formatting dates and times into localized strings using native JavaScript APIs (`Intl.DateTimeFormat`, `Date`). First-class Khmer (`km-KH`) support with localized digits, six time-of-day phrases, and full Khmer lunar calendar arithmetic — Buddhist Era, Jolak Sakaraj, animal years, era years (Sak), waxing/waning moon, and precise Khmer New Year (Moha Songkran) timing. Runs natively on Node.js ≥ 20, Bun, Deno (via JSR), Cloudflare Workers, and the browser. + +The public API is intentionally small: a `FormatDateTime` class that takes `(date, format, locale)` and returns a formatted string, plus a `KhmerDate` class for the full lunar calendar. Formatting works by expanding tokens (`YYYY`, `MMMM`, `hh`, `A`, and Khmer-lunar tokens `BBBB`, `lA`, `lM`, `lN`, `lW`, etc.) via a single regex pass, sorted longest-token-first so that `MMMM` is never eaten by `MM`. The Khmer lunar solver is real astronomical arithmetic ported from the traditional Khmer horologia (Soriyatra), not a lookup table — it walks from a 1900 UTC epoch using classical formulas (aharkun, avoman, bodithey, kromthupul, protetin) and detects the two independent leap-year kinds (leap-month Adhikamas → 384 days, leap-day Chantreathimeas → 355 days). + +The package publishes to **two channels from the same source**: npm ships pre-built `dist/` (CJS + ESM + IIFE), while JSR ships raw TypeScript `src/` that Deno consumes directly. Every intra-repo import uses the `.ts` extension for this reason. Version bumps must be applied to `package.json`, `deno.json`, and `jsr.json` together. The library is tree-shakable (`sideEffects: false`), works with zero runtime dependencies, and adds ~4 KB (solar-only) or ~8 KB (with lunar) minified+gzipped to your bundle. + +## Docs + +- [Getting Started](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/getting-started.md): Installation on each runtime (npm, Bun, Deno, CF Workers, CDN), constructor signature, locale detection (`km` prefix triggers Khmer branch), first-format walkthrough, framework-specific setup (Next.js, Nuxt, SvelteKit, Astro, React Native), common pitfalls (0-indexed months, invalid-date sentinel, mutable Date sharing). +- [Token Reference](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/tokens.md): Every solar and lunar format token grouped by category (year, month, weekday, day, hour, minute/second, AM/PM, timezone, lunar year, lunar month, lunar day, moon status, Khmer weekday), with side-by-side `en-US` and `km-KH` examples, the six Khmer time-of-day phrase buckets by hour range, precedence rules (longest-first regex ordering), character class guarantees, escaping notes, and worked examples across locales. +- [API Reference](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/api-reference.md): Full `FormatDateTime` and `KhmerDate` API surface with TypeScript signatures, constructor input matrix, instance properties, all instance methods, static factories and helpers (`findLunarDate`, `getKhNewYearMoment`, `arabicToKhmerNumber`/`khmerToArabicNumber`, month/animal/era name arrays), plus the source-only classes (`Calculator`, `KhmerFormatter`, `SoriyatraLerngSak`, `Utils`) available only on Deno/JSR. +- [TypeScript Guide](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/typescript.md): Import styles (default+named, all-named, namespace, CJS require), full class signatures, ambient global declaration for CDN usage, common type patterns (type-safe wrappers, discriminated unions, React hooks), all internal interfaces from `KhmerFormatter`, `SoriyatraLerngSak`, and `Utils`, tsconfig compatibility notes. +- [Lunar Calendar](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/lunar-calendar.md): Historical context, all core concepts (Buddhist Era with Visakha Bochea cutoff, Jolak Sakaraj with Moha Songkran cutoff, 12-year animal cycle, 10-year era/Sak cycle, moon status halves), 14 lunar months table with Gregorian mapping, month/year length rules, leap-year kinds, worked example tracing July 13, 2026 through the solver, Khmer New Year projection (2026 anchor, 6-hour drift), full `Calculator` method reference, traditional seasons. +- [Algorithms](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/algorithms.md): Deep dive on the classical Soriyatra formulas — the 292207/800 year fraction, `getAharkun`, `getAvoman`, `getBodithey`, `kromthupul`, `getBoditheyLeap`, `getProtetinLeap` and their reconciliation rules, month/year length derivations, era conversions (BE, JS, animal cycle offsets), the full `findLunarDate` solver algorithm with UTC-noon normalization rationale, Khmer New Year projection formula, `SoriyatraLerngSak` internals including the traditional 800-kromathopol-per-day time system. +- [Architecture](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/architecture.md): File map of `src/`, `test/`, `dist/`; the two independent subsystems (solar formatter + lunar calendar) and their single bridge point in `generateTokens` (lunar regex check triggers `findLunarDate` only when needed); why longest-token-first regex ordering is load-bearing (with wrong-vs-right examples); why `.ts` extensions are required in imports (Deno + JSR); timezone normalization in the solver; caching strategy (`khNewYearCache`); ASCII sequence diagram of a full lunar format call; extension guide for adding new tokens; performance notes. +- [Runtimes](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/runtimes.md): Runtime × channel table, per-runtime setup for Node (ESM + CJS), Bun, Deno (JSR add + direct specifier + version pinning), Cloudflare Workers (wrangler.toml, no nodejs_compat needed, bundle-size estimates), browser via bundler (Next.js, Nuxt, SvelteKit, Astro, Remix), browser via CDN (UNPKG IIFE + ESM, jsDelivr, version pinning), build output reference (tsup config), CI matrix, runtime feature matrix. +- [Examples](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/examples.md): Copy-paste recipes — basic solar formats (ISO, RFC-style, 24h, filename-safe), locale variants (Khmer, French, Japanese, Arabic-Indic), lunar preset and custom patterns, mixed solar+lunar formats, solar-Khmer with brace placeholders, Khmer New Year (single and multi-year), digit conversion, parsing/validation guards, React (simple + live clock + memoized hook + locale toggle), Vue, Svelte, Cloudflare Worker (JSON + HTML), Node CLI, batch formatting, date-range picker, chart labels, Vitest patterns with fake timers, UTC-forcing. +- [FAQ](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/docs/faq.md): Common questions grouped by topic — general (comparison with date-fns/dayjs, dependencies, bundle size), installation (Node versions, Deno, CF Workers, React Native), usage (0-indexed months, invalid-date sentinel, missing Khmer output, digit-only conversion, New Year countdown recipe, date arithmetic), lunar calendar (internal vs display day, BE cutoff, New Year day flip, accuracy range, algorithm provenance), tokens (case in Khmer, escaping, `Z` vs `z`, short-month behavior), performance (call cost, caching, thread safety), testing (mocking `now`, duplicate suites), publishing (dual channel, version bumps). + +## Source + +- [src/index.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/index.ts): Public entry — the `FormatDateTime` class (constructor accepting `Date`/string/null, `formatDate()` doing the single-regex replacement, `formatLunarDate()` shortcut, `toString()` coercion, `patterns`/`defualtPatterns`/`tokens()` statics), the `KhmerDate` re-export, and the `globalThis.FormatDateTime` attachment for CDN usage. +- [src/config/tokens.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/config/tokens.ts): `PATTERNS` regex-fragment map (33 tokens with named capture groups) and `generateTokens(date, format, locale)` — the token-to-value dictionary builder. Contains the Khmer time-of-day phrase mapping (`0-4 → រំលងអធ្រាត្រ`, `5-11 → ព្រឹក`, `12 → ថ្ងៃត្រង់`, `13-16 → រសៀល`, `17-19 → ល្ងាច`, `20-23 → យប់`), the `isKm` locale fork (hand tables vs `Intl`), the digit remap using `numberingSystem: 'khmr'` + `Constants.KHMER_NUMBERS` fallback, and the lunar-tokens conditional branch that triggers `KhmerDate.findLunarDate` + `Calculator` helpers only when a lunar token is present. +- [src/config/constants.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/config/constants.ts): Khmer lookup tables — `LUNAR_MONTHS` (14 entries including leap `បឋមាសាឍ`/`ទុតិយាសាឍ`), `SOLAR_MONTHS` (12 entries), `ANIMAL_YEARS` (12: `ជូត`…`កុរ`), `ERA_YEARS` (10 Sak names: `សំរឹទ្ធិស័ក`…`នព្វស័ក`), `WEEKDAYS` + `WEEKDAYS_SHORT` (7 each), `MONTHS` (12 Khmer solar names), `MOON_STATUS` and `MOON_STATUS_SHORT`, bidirectional `KHMER_NUMBERS` ↔ `ARABIC_NUMBERS` digit maps. +- [src/lunar/khmer-date.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/lunar/khmer-date.ts): `KhmerDate` class — constructor accepting `Date`/string/Unix-seconds/null with type-specific handling, `findLunarDate(target)` solver that walks from 1900-01-01 UTC epoch using coarse-year → coarse-month → fine-day passes with UTC-noon normalization, `getKhNewYearMoment(gregorianYear)` with 2026 anchor + 6h/year drift + Gregorian-leap-year day flip + per-class caching, instance methods (`toLunarDate` with presets, `toKhmerDate` with `{brace}` placeholders, `khDay`/`khMonth`/`khYear`/`getTimestamp`/`copy`), placeholder methods (`format`/`add`/`subtract` — do not use for arithmetic), digit conversion statics. +- [src/lunar/calculator.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/lunar/calculator.ts): Traditional Khmer astronomical formulas — `getAharkun` (elapsed days from epoch), `getAvoman` (minute residue), `getBodithey` (0-29 lunar cycle position), `kromthupul` (solar-year slack), `isKhmerSolarLeap` (366-day sun year detection), leap classification (`getBoditheyLeap` returns 0/1/2/3, `getProtetinLeap` reconciles to 0=none/1=leap-month/2=leap-day, with previous-year borrow rule), month/year length rules (29/30 with `ជេស្ឋ` extension in leap-day year and `បឋមាសាឍ`/`ទុតិយាសាឍ` always 30, year totals 354/355/384), era arithmetic (`getBEYear` using precise Visakha Bochea vs `getMaybeBEYear` April heuristic, `getJolakSakarajYear` using Moha Songkran cutoff, `getAnimalYear` with 12-cycle offset), `getKhmerLunarDay` count/moonStatus splitter, `nextMonthOf` state machine handling leap-month insertion. +- [src/lunar/khmer-formatter.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/lunar/khmer-formatter.ts): `KhmerFormatter` — preset lunar formats (`full`: `ថ្ងៃ{DAY} {DD}{STATUS} ខែ{MONTH} ឆ្នាំ{ANIMAL} {SAK} ពុទ្ធសករាជ {BE}`, `medium`: `{DD}{STATUS} ខែ{MONTH} ព.ស. {BE}`, `short`: `{DD}{STATUS} ខែ{MONTH}`), `parseCustomFormat` dispatcher that reuses `generateTokens` with `km-KH` forced, `LunarDateData` interface, Khmer numeric helpers (`toKhmerNumber`/`fromKhmerNumber`/`formatNumber`), currency helper (`formatCurrency` → `X រៀល`), time helper (`formatTime` with 12h/24h modes and Khmer ព្រឹក/ល្ងាច phrases), day/month name accessors, `isKhmerText` script detector, `formatOrdinal` → `ទីN`. +- [src/lunar/soriyatra-lerng-sak.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/lunar/soriyatra-lerng-sak.ts): Deep New-Year astronomical calculations ported from momentkh's `getSoriyatraLerngSak.js` — `calculate(jsYear)` returns `SoriyatraLerngSakInfo` with harkun, kromathopol, avaman, bodithey plus derived flags (`has366day`, `isAthikameas`, `isChantreathimeas`, `jesthHas30`) plus `dayLerngSak`, `lunarDateLerngSak`, `newYearsDaySotins` array (4 sotins), and precise `timeOfNewYear` computed from the traditional 800-kromathopol-per-day system (1 kromathopol = 1.8 minutes). Reach for this only if reproducing the full royal almanac. +- [src/lunar/index.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/lunar/index.ts): Barrel export for the lunar subsystem (`Calculator`, `KhmerDate`, `KhmerFormatter`, `SoriyatraLerngSak`). **Not** re-exported from the package entry — accessible only via direct file import on Deno/JSR consumers. +- [src/utils/utils.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/src/utils/utils.ts): Higher-level helpers (`Utils` class, all static) — `parseKhmerDate` (stub, returns null), `getKhmerMonthRange(khmerMonth, beYear)` returning array of `KhmerLunarDayInfo`, `findLunarDayOccurrences(dayCount, moonStatus, year)` scanning a full year for matching lunar days, `diffInKhmer(date1, date2)` returning `{days, years, months, gregorian_diff, is_past}`, `getBuddhistHolidays(year)` returning Visakha Bochea + Khmer New Year, `convertEra(year, fromEra, toEra)` between AD/BE/JS, `isValidKhmerDate(day, month, beYear)`, `getSeason(date)` returning cold/hot/rainy. Not re-exported from the package entry. +- [test/node/index.test.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/test/node/index.test.ts): Vitest suite for Node — covers basic token replacement, AM/PM formatting, Khmer digit output, Khmer time-of-day phrases, string parsing, invalid-date sentinel, current-date default, `toString()` coercion, mixed solar+lunar tokens, `KhmerDate` presets, custom lunar patterns, combined lunar+solar output, precise Khmer New Year 2026 and 2027 verification. +- [test/deno/index.test.ts](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/test/deno/index.test.ts): Deno test suite — near-duplicate of the Node suite using `@std/testing/bdd` and `@std/expect`, verifies identical behavior on Deno runtime. + +## Repository + +- [README.md](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/README.md): Feature summary (flexible formatting, i18n, Khmer support, lunar calendar, zero dependencies, multi-runtime), installation snippets per runtime, usage examples (basic + Khmer lunar), condensed token table, condensed API sketch, license. +- [CLAUDE.md](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/CLAUDE.md): Guidance for AI coding assistants — commands (build, test, single test), dual-publish model explanation, architecture invariants (longest-first regex ordering, `.ts` import extensions, no Node built-ins in `src/`, dual-suite test parity, three-file version bump). +- [package.json](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/package.json): npm manifest — name `@pphatdev/format-datetime`, engines Node ≥ 20, exports map (types/import/require/default), unpkg + jsdelivr fields, `sideEffects: false`, build script (`tsup src/index.ts --format cjs,esm,iife --global-name FormatDateTimeBundle --dts --clean --minify`), test scripts, esbuild override to `^0.28.1`. +- [deno.json](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/deno.json): Deno manifest — `"exports": "./src/index.ts"` for direct source consumption, `@std/testing` and `@std/expect` imports, test include path. +- [jsr.json](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/jsr.json): JSR manifest — mirrors the Deno exports. +- [tsconfig.json](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/tsconfig.json): TypeScript config — target es2017, module esnext, `moduleResolution: "bundler"`, `allowImportingTsExtensions: true`, `emitDeclarationOnly: true` (tsup handles actual JS emission), strict mode, src rootDir. + +## Optional + +- [.github/workflows/ci.yml](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/.github/workflows/ci.yml): CI matrix — Node 20/22/24/26 with Deno also installed on ubuntu-latest, runs `npm run build` then `npm run test` on push and PR to master/main. +- [.github/workflows/npm-publish.yml](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/.github/workflows/npm-publish.yml): Automates npm and GitHub package publishing on release. +- [.github/workflows/jsr.yml](https://raw.githubusercontent.com/pphatdev/khmer-datetime/master/.github/workflows/jsr.yml): Automates JSR publishing on release.