Compare commits

...

17 commits
v2.0.0 ... main

Author SHA1 Message Date
Aric Camarata
09079091ae
add opt-in anonymous telemetry (#1)
Some checks failed
CI / Test (Node 20) (push) Failing after 34s
CI / Test (Node 22) (push) Failing after 38s
CI / Test (Node 24) (push) Failing after 27s
CI / Lint & Format (push) Failing after 39s
CI / Typecheck (push) Failing after 35s
CI / Pack check (push) Failing after 31s
CI / Coverage (push) Failing after 3s
* add Forgejo CI mirror and telemetry disclosure

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

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

* chore: update lockfile for @acamarata/telemetry devDep

* chore: fix prettier formatting on telemetry import
2026-06-30 15:56:57 -04:00
Aric Camarata
a62239c019 build: use prepack hook so npm pack/publish reliably emit index.d.mts 2026-06-13 10:30:56 -04:00
Aric Camarata
49d880b751 ci: fix eslint config and add missing @typescript-eslint devDeps 2026-05-31 08:48:38 -04:00
Aric Camarata
2d18b68794 docs: update README, wiki examples, and CI coverage (P1) 2026-05-30 20:16:06 -04:00
Aric Camarata
2908ae7fa0 docs: refresh TypeDoc API output (T-E8-03 QA-A verify) 2026-05-30 17:48:48 -04:00
Aric Camarata
208f7ffba1 docs: add TypeDoc API generation (typedoc@0.28.19 + typedoc-plugin-markdown@4.11.0)
Add typedoc and typedoc-plugin-markdown as devDependencies. Add typedoc.json config
targeting src/index.ts with markdown output to .github/wiki/api. Add docs script to
package.json. Generate initial API reference pages.

Part of T-E8-03 — TypeDoc automation for all 12 JS/TS packages.
2026-05-30 16:42:00 -04:00
Aric Camarata
800a74670e chore: adopt shared config packages (tsconfig, eslint, prettier) 2026-05-30 15:13:20 -04:00
Aric Camarata
866ea69ce2 ci(moon-cycle): enable corepack before setup-node, emit d.mts 2026-05-29 20:06:53 -04:00
Aric Camarata
0284076ea0 chore: E6 polish wiki content + ADR-015 CI updates (P1) 2026-05-29 07:15:59 -04:00
Aric Camarata
53ff3db716 chore: untrack AGENTS.md (AI working memory, not source code) 2026-05-29 06:36:40 -04:00
Aric Camarata
8e1d53a440 chore(config): add AGENTS.md for dual-harness parity 2026-05-25 15:51:11 -04:00
Aric Camarata
130d025e63 chore: align repository structure with portfolio documentation standards 2026-05-15 15:27:14 -04:00
Aric Camarata
bfcdc91411 Add GitHub Sponsors funding config 2026-03-28 18:18:51 -04:00
Aric Camarata
bf92d67a56 style: fix prettier table formatting in wiki 2026-03-08 17:30:34 -04:00
Aric Camarata
0744ae0080 style: replace em dashes with colons in docs and wiki 2026-03-08 17:28:03 -04:00
Aric Camarata
c80a139d4f fix: update stale Related link from moon-calc to moon-sighting 2026-03-08 16:37:58 -04:00
Aric Camarata
9b9abb99c8 refactor: code quality improvements across the board
- Add input validation (TypeError) to cycleMonth and cycleYear
- Convert tests to node:test runner with describe/it structure
- Add ESLint + Prettier with lint, format, and format:check scripts
- Update CI workflow with lint job, frozen-lockfile, and pack-check
- Add noImplicitReturns, noFallthroughCasesInSwitch, skipLibCheck to tsconfig
- Add invalid Date tests for both ESM and CJS suites
2026-03-08 11:32:47 -04:00
49 changed files with 3100 additions and 694 deletions

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

@ -0,0 +1,138 @@
# Forgejo CI mirror — git.ariccamarata.com
# Mirrors .github/workflows/ci.yml for the self-hosted Forgejo Actions runner.
# Keep in sync with the GitHub workflow; only addition is the nSentry failure step.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: node --test test.mjs
- run: node --test test-cjs.cjs
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
pack-check:
name: Pack check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Verify pack contents
run: |
PACK=$(npm pack --dry-run 2>&1)
echo "$PACK"
for f in dist/index.cjs dist/index.mjs dist/index.d.ts dist/index.d.mts README.md LICENSE CHANGELOG.md; do
echo "$PACK" | grep -q "$f" || (echo "Missing: $f" && exit 1)
done
echo "$PACK" | grep -q "src/" && (echo "ERROR: src/ should not be in pack" && exit 1) || true
echo "Pack check passed"
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm run coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Report failure to nSentry
if: failure()
run: |
# nself sentry ci enable <repo> must be run on the CamClaw server first.
# Once registered, the runner's nself-sentry-sync hook delivers this report
# to ~/Sites/acamarata/.claude/inbox via root@sentry-errors.ariccamarata.com.
echo "CI_FAILURE repo=${{ github.repository }} job=${{ github.job }} run=${{ github.run_id }}" >&2

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

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

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

@ -0,0 +1,45 @@
# Changelog
All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.0.0] - 2025-02-25
### Added
- TypeScript source (`src/`) with full type definitions: dual CJS and ESM builds via tsup
- `imageFolder(set, size, quality)` helper to construct image directory names
- `cdnUrl(filename, set, size, quality, ref?)` helper to generate jsDelivr CDN URLs, enabling image serving without self-hosting the ~438 MB dataset
- Exported constants: `SYNODIC_MONTH`, `MONTH_IMAGES`, `YEAR_IMAGES`, `MONTH_ANCHOR`, `YEAR_ANCHOR`
- Exported types: `ImageSet`, `ImageSize`, `ImageQuality`
- Dual ESM and CJS builds with `.mjs` / `.cjs` extensions and matching `.d.ts` / `.d.mts` type definitions
- Proper `exports` map in `package.json` (types-first conditional exports)
- `test.mjs` and `test-cjs.cjs`: full assertion-based test suites covering bounds, anchor dates, edge cases, and all exported functions
- GitHub Actions CI workflow: Node 20/22/24 test matrix, typecheck job, pack-check job
- GitHub Actions wiki-sync workflow: syncs `.wiki/` to GitHub Wiki on push to `main`
- `.wiki/` documentation: Home, API Reference, Architecture, Migration Guide
- `.nvmrc`, `.editorconfig`, `.npmrc`, `pnpm-workspace.yaml`
### Changed
- Package is now npm-publishable: `files` field restricts the npm package to `dist/`, README, LICENSE, and CHANGELOG: images are excluded
- `package.json` fully updated: correct author name, accurate description, `engines`, `sideEffects`, `publishConfig`, `repository.url` with `git+https://` prefix, expanded keywords
- `repository.url` corrected to use `git+https://` prefix per npm convention
### Fixed
- **Off-by-one bug in both algorithms.** The v1 implementation mapped dates to 0-indexed filenames (`000.webp` to `707.webp` monthly, `0000.webp` to `8759.webp` yearly). The image dataset is 1-indexed (`001.webp` to `708.webp`, `0001.webp` to `8760.webp`). Both functions now return the correct 1-indexed filename. This is a breaking change for anyone who was working around the bug or had a local image set starting at `000.webp`.
- TypeScript definitions in `index.d.ts` were incorrect: both functions were typed as returning `{ result: string }` instead of `string`. The new generated types are accurate.
### Removed
- `index.js`, `cycleMonth.js`, `cycleYear.js`: replaced by `src/` TypeScript source and `dist/` build output
- `index.d.ts`: replaced by generated `dist/index.d.ts` and `dist/index.d.mts`
- `test.js`: replaced by `test.mjs` and `test-cjs.cjs`
## [1.0.1] - 2023-11-14
- Minor repository metadata updates
## [1.0.0] - 2023-11-14
- Initial release: `cycleMonth` and `cycleYear` functions, 708 monthly and 8,760 yearly NASA moon phase images in WebP format

View file

@ -7,7 +7,7 @@ Complete reference for moon-cycle v2.
### `cycleMonth(date?)` ### `cycleMonth(date?)`
```ts ```ts
function cycleMonth(date?: Date): string function cycleMonth(date?: Date): string;
``` ```
Maps a date to an image filename in the **monthly (synodic) dataset**. Maps a date to an image filename in the **monthly (synodic) dataset**.
@ -17,7 +17,7 @@ The 708 images cover one complete synodic month at hourly resolution. The functi
**Parameters:** **Parameters:**
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | ------ | ------ | ------------ | ------------------- |
| `date` | `Date` | `new Date()` | The date to resolve | | `date` | `Date` | `new Date()` | The date to resolve |
**Returns:** A zero-padded filename string, e.g. `"354.webp"`. Always in the range `"001.webp"` to `"708.webp"`. **Returns:** A zero-padded filename string, e.g. `"354.webp"`. Always in the range `"001.webp"` to `"708.webp"`.
@ -37,7 +37,7 @@ cycleMonth(new Date('2020-06-21')); // any past date works
### `cycleYear(date?)` ### `cycleYear(date?)`
```ts ```ts
function cycleYear(date?: Date): string function cycleYear(date?: Date): string;
``` ```
Maps a date to an image filename in the **yearly dataset**. Maps a date to an image filename in the **yearly dataset**.
@ -47,7 +47,7 @@ The 8,760 images cover the full calendar year 2023 at hourly resolution. The fun
**Parameters:** **Parameters:**
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | ------ | ------ | ------------ | ------------------- |
| `date` | `Date` | `new Date()` | The date to resolve | | `date` | `Date` | `new Date()` | The date to resolve |
**Returns:** A zero-padded filename string, e.g. `"4380.webp"`. Always in the range `"0001.webp"` to `"8760.webp"`. **Returns:** A zero-padded filename string, e.g. `"4380.webp"`. Always in the range `"0001.webp"` to `"8760.webp"`.
@ -66,7 +66,7 @@ cycleYear(new Date('2025-07-04T12:00Z')); // July 4, noon
### `imageFolder(set, size, quality)` ### `imageFolder(set, size, quality)`
```ts ```ts
function imageFolder(set: ImageSet, size: ImageSize, quality: ImageQuality): string function imageFolder(set: ImageSet, size: ImageSize, quality: ImageQuality): string;
``` ```
Returns the directory name for a given combination of image set, size, and quality. Returns the directory name for a given combination of image set, size, and quality.
@ -76,7 +76,7 @@ Directory names follow the pattern `{set}-{size}-{quality}`, matching the layout
**Parameters:** **Parameters:**
| Name | Type | Description | | Name | Type | Description |
| --- | --- | --- | | --------- | -------------- | ------------------------- |
| `set` | `'mm' \| 'my'` | Monthly or yearly dataset | | `set` | `'mm' \| 'my'` | Monthly or yearly dataset |
| `size` | `256 \| 512` | Image dimension in pixels | | `size` | `256 \| 512` | Image dimension in pixels |
| `quality` | `75 \| 85` | WebP compression quality | | `quality` | `75 \| 85` | WebP compression quality |
@ -88,8 +88,8 @@ Directory names follow the pattern `{set}-{size}-{quality}`, matching the layout
```ts ```ts
import { imageFolder } from 'moon-cycle'; import { imageFolder } from 'moon-cycle';
imageFolder('mm', 256, 75) // => 'mm-256-75' imageFolder('mm', 256, 75); // => 'mm-256-75'
imageFolder('my', 512, 85) // => 'my-512-85' imageFolder('my', 512, 85); // => 'my-512-85'
// Use with a local public directory: // Use with a local public directory:
const filename = cycleMonth(); const filename = cycleMonth();
@ -107,8 +107,8 @@ function cdnUrl(
set: ImageSet, set: ImageSet,
size: ImageSize, size: ImageSize,
quality: ImageQuality, quality: ImageQuality,
ref?: string ref?: string,
): string ): string;
``` ```
Returns a jsDelivr CDN URL serving the specified image directly from the GitHub repository. jsDelivr caches GitHub content via a global CDN with no account or configuration required. Returns a jsDelivr CDN URL serving the specified image directly from the GitHub repository. jsDelivr caches GitHub content via a global CDN with no account or configuration required.
@ -116,11 +116,11 @@ Returns a jsDelivr CDN URL serving the specified image directly from the GitHub
**Parameters:** **Parameters:**
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | ---------- | -------------- | -------- | --------------------------------------------- |
| `filename` | `string` | — | Filename from `cycleMonth()` or `cycleYear()` | | `filename` | `string` | : | Filename from `cycleMonth()` or `cycleYear()` |
| `set` | `'mm' \| 'my'` | — | Monthly or yearly dataset | | `set` | `'mm' \| 'my'` | : | Monthly or yearly dataset |
| `size` | `256 \| 512` | — | Image dimension in pixels | | `size` | `256 \| 512` | : | Image dimension in pixels |
| `quality` | `75 \| 85` | — | WebP compression quality | | `quality` | `75 \| 85` | : | WebP compression quality |
| `ref` | `string` | `'main'` | Branch name, git tag, or commit SHA | | `ref` | `string` | `'main'` | Branch name, git tag, or commit SHA |
**Returns:** A full URL string, e.g. `"https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp"`. **Returns:** A full URL string, e.g. `"https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp"`.
@ -149,7 +149,7 @@ function MoonImage() {
## Constants ## Constants
| Export | Type | Value | Description | | Export | Type | Value | Description |
| --- | --- | --- | --- | | --------------- | -------- | ---------------------- | ------------------------------------ |
| `SYNODIC_MONTH` | `number` | `29.53058821398858` | IAU mean synodic month in days | | `SYNODIC_MONTH` | `number` | `29.53058821398858` | IAU mean synodic month in days |
| `MONTH_IMAGES` | `number` | `708` | Image count in the monthly dataset | | `MONTH_IMAGES` | `number` | `708` | Image count in the monthly dataset |
| `YEAR_IMAGES` | `number` | `8760` | Image count in the yearly dataset | | `YEAR_IMAGES` | `number` | `8760` | Image count in the yearly dataset |
@ -190,7 +190,7 @@ type ImageQuality = 75 | 85;
## Image Dataset Reference ## Image Dataset Reference
| Folder | Set | Images | Resolution | Quality | Approx. Size | | Folder | Set | Images | Resolution | Quality | Approx. Size |
| --- | --- | --- | --- | --- | --- | | ----------- | ------- | ------ | ---------- | ------- | ------------ |
| `mm-256-75` | monthly | 708 | 256x256 | 75 | ~4 MB | | `mm-256-75` | monthly | 708 | 256x256 | 75 | ~4 MB |
| `mm-256-85` | monthly | 708 | 256x256 | 85 | ~5 MB | | `mm-256-85` | monthly | 708 | 256x256 | 85 | ~5 MB |
| `mm-512-75` | monthly | 708 | 512x512 | 75 | ~9 MB | | `mm-512-75` | monthly | 708 | 512x512 | 75 | ~9 MB |
@ -204,4 +204,4 @@ All images are square WebP, transparent background, named with zero-padded indic
--- ---
*[Home](Home) | [Architecture](Architecture) | [Migration Guide](Migration)* _[Home](Home) | [Architecture](Architecture) | [Migration Guide](Migration)_

View file

@ -12,8 +12,8 @@ All images originate from NASA's Scientific Visualization Studio visualization "
The raw frames were converted to WebP and organized into eight folders combining two image sets, two resolutions, and two quality levels: The raw frames were converted to WebP and organized into eight folders combining two image sets, two resolutions, and two quality levels:
- `mm-*` 708 images covering one synodic month (monthly set) - `mm-*`: 708 images covering one synodic month (monthly set)
- `my-*` 8,760 images covering the full year 2023 (yearly set) - `my-*`: 8,760 images covering the full year 2023 (yearly set)
Images are named with zero-padded integers starting at 1 (`001.webp` to `708.webp` for monthly, `0001.webp` to `8760.webp` for yearly). Images are named with zero-padded integers starting at 1 (`001.webp` to `708.webp` for monthly, `0001.webp` to `8760.webp` for yearly).
@ -21,7 +21,7 @@ Images are named with zero-padded integers starting at 1 (`001.webp` to `708.web
### Concept ### Concept
The synodic month is the time between two identical lunar phases as seen from Earth new moon to new moon. Its IAU mean value is **29.53058821398858 days**. The 708 images in the monthly set span exactly one such cycle at hourly resolution. The synodic month is the time between two identical lunar phases as seen from Earth: new moon to new moon. Its IAU mean value is **29.53058821398858 days**. The 708 images in the monthly set span exactly one such cycle at hourly resolution.
To find the correct image for any date, the algorithm: To find the correct image for any date, the algorithm:
@ -45,13 +45,13 @@ index = floor(fraction * 708) + 1
### Limitation ### Limitation
The synodic month is not exactly 29.53058821398858 days — that value is the IAU *mean* (averaged over centuries). The actual length of a given synodic month varies by up to ~7 hours depending on the Moon's position in its elliptical orbit. For dates far from the 2023 reference period, accumulated drift means the image may be off by a few frames from the true observed phase. For UI purposes, this is imperceptible. The synodic month is not exactly 29.53058821398858 days: that value is the IAU _mean_ (averaged over centuries). The actual length of a given synodic month varies by up to ~7 hours depending on the Moon's position in its elliptical orbit. For dates far from the 2023 reference period, accumulated drift means the image may be off by a few frames from the true observed phase. For UI purposes, this is imperceptible.
## Algorithm 2: Calendar Year (`cycleYear`) ## Algorithm 2: Calendar Year (`cycleYear`)
### Concept ### Concept
The yearly set contains 8,760 images one per hour of the 365-day year 2023. Rather than tracking lunar phase directly, `cycleYear` maps any date to its hour-of-year equivalent and uses that to index into the 2023 imagery. The yearly set contains 8,760 images: one per hour of the 365-day year 2023. Rather than tracking lunar phase directly, `cycleYear` maps any date to its hour-of-year equivalent and uses that to index into the 2023 imagery.
The algorithm: The algorithm:
@ -75,24 +75,24 @@ index = floor(fraction * 8760) + 1
### Limitation ### Limitation
The year 2023 had 365 days (not a leap year), so the 8,760 images correspond exactly to one non-leap year. For years with 366 days, there is a subtle 24-image offset in the second half of the year the hour-of-year for December 31 in a leap year wraps around slightly differently than in 2023. This is a cosmetic artifact, not a functional error. The year 2023 had 365 days (not a leap year), so the 8,760 images correspond exactly to one non-leap year. For years with 366 days, there is a subtle 24-image offset in the second half of the year: the hour-of-year for December 31 in a leap year wraps around slightly differently than in 2023. This is a cosmetic artifact, not a functional error.
## Choosing Between the Two ## Choosing Between the Two
| Scenario | Recommendation | | Scenario | Recommendation |
| --- | --- | | -------------------------------------------------------- | --------------------------------------------------- |
| Show the actual current moon phase | `cycleMonth` | | Show the actual current moon phase | `cycleMonth` |
| Animate through a year of moon phases for a calendar app | `cycleYear` | | Animate through a year of moon phases for a calendar app | `cycleYear` |
| Show a consistent seasonal moon appearance | `cycleYear` | | Show a consistent seasonal moon appearance | `cycleYear` |
| Compute when the next full moon occurs | `cycleMonth` + `SYNODIC_MONTH` | | Compute when the next full moon occurs | `cycleMonth` + `SYNODIC_MONTH` |
| Display a decorative moon that changes daily | Either — `cycleYear` has smoother hourly progression | | Display a decorative moon that changes daily | Either: `cycleYear` has smoother hourly progression |
## Package Structure ## Package Structure
``` ```
moon-cycle/ moon-cycle/
├── src/ ├── src/
│ ├── index.ts # Public API re-exports all named exports │ ├── index.ts # Public API: re-exports all named exports
│ ├── types.ts # Types, constants, anchor dates │ ├── types.ts # Types, constants, anchor dates
│ ├── cycleMonth.ts # Synodic algorithm │ ├── cycleMonth.ts # Synodic algorithm
│ ├── cycleYear.ts # Calendar-year algorithm │ ├── cycleYear.ts # Calendar-year algorithm
@ -112,10 +112,10 @@ moon-cycle/
The source is TypeScript, compiled by [tsup](https://tsup.egoist.dev/) (esbuild-based). tsup produces four output files from `src/index.ts`: The source is TypeScript, compiled by [tsup](https://tsup.egoist.dev/) (esbuild-based). tsup produces four output files from `src/index.ts`:
- `dist/index.cjs` CommonJS for `require()` - `dist/index.cjs`: CommonJS for `require()`
- `dist/index.mjs` ESM for `import` - `dist/index.mjs`: ESM for `import`
- `dist/index.d.ts` type definitions for CJS consumers - `dist/index.d.ts`: type definitions for CJS consumers
- `dist/index.d.mts` type definitions for ESM consumers - `dist/index.d.mts`: type definitions for ESM consumers
The `exports` field in `package.json` uses types-first conditional exports so TypeScript resolves the correct declaration file regardless of `moduleResolution` setting. The `exports` field in `package.json` uses types-first conditional exports so TypeScript resolves the correct declaration file regardless of `moduleResolution` setting.
@ -131,4 +131,4 @@ The probability of hitting the exact boundary where `000.webp` would have been r
--- ---
*[Home](Home) | [API Reference](API-Reference) | [Migration Guide](Migration)* _[Home](Home) | [API Reference](API-Reference) | [Migration Guide](Migration)_

29
.github/wiki/CODE_OF_CONDUCT.md vendored Normal file
View file

@ -0,0 +1,29 @@
# Code of Conduct
## The short version
Be respectful. Be constructive. Focus on the work, not the person.
## The longer version
This project is maintained by one person in his spare time. Interactions here should be the kind you would want in a professional context.
Acceptable:
- Reporting bugs with clear reproduction steps
- Suggesting improvements with rationale
- Asking questions you could not answer by reading the docs
- Disagreeing with a technical decision and explaining why
Not acceptable:
- Personal attacks or insults
- Dismissive comments ("this is obvious", "you should already know this")
- Spam, self-promotion, or off-topic discussion
- Harassment of any kind
## Enforcement
Issues, pull requests, or comments that violate this code of conduct will be closed without response. Repeat violations result in a block.
## Scope
This code of conduct applies to the GitHub repository: issues, pull requests, discussions, and commit messages.

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

@ -0,0 +1,50 @@
# Contributing to moon-cycle
Thanks for your interest in contributing. This library maps JavaScript dates to NASA moon phase images and contributions are welcome.
## Getting started
```bash
git clone https://github.com/acamarata/moon-cycle.git
cd moon-cycle
pnpm install
pnpm build
pnpm test
```
All tests should pass before you start.
## What to work on
Check the [open issues](https://github.com/acamarata/moon-cycle/issues) for anything tagged `help wanted` or `good first issue`. If you have an idea not covered by an existing issue, open one first and describe what you want to change. That avoids duplicate work.
## Code style
- Plain JavaScript (index.js) with a TypeScript declaration file (index.d.ts). No TypeScript source.
- Pure functions. No global state. Date is always passed explicitly.
- Each function: one purpose.
- Run `pnpm run format` before committing.
- Run `pnpm run lint` before committing.
## Image dataset
The image dataset (~438 MB) is tracked in git and should not be modified. All images are NASA public domain material from the Scientific Visualization Studio. Do not add new images without updating the algorithm constants and test coverage.
If you think the dataset is wrong or a mapping is incorrect, open an issue with the specific date, expected image, and actual image.
## Tests
- Tests live in `test.mjs` (ESM) and `test-cjs.cjs` (CommonJS). Both must pass.
- Use the native Node.js `node:test` runner.
- Test specific known dates against expected filenames.
## Pull requests
- Keep PRs small and focused. One concern per PR.
- Write a clear description of what changed and why.
- Reference the issue number if one exists (`Fixes #42`).
- CI must be green before merge.
## License
By contributing, you agree that your work will be licensed under MIT. Copyright remains with Aric Camarata. NASA imagery is public domain per NASA media guidelines.

View file

@ -9,7 +9,7 @@ Given any date, you get a filename like `354.webp` or `4380.webp` that correspon
Two algorithms are provided because they answer different questions: Two algorithms are provided because they answer different questions:
| Function | Algorithm | Use when | | Function | Algorithm | Use when |
| --- | --- | --- | | ------------ | --------------------- | -------------------------------------------- |
| `cycleMonth` | Synodic (lunar) cycle | You want the actual lunar phase for the date | | `cycleMonth` | Synodic (lunar) cycle | You want the actual lunar phase for the date |
| `cycleYear` | Calendar year (2023) | You want a consistent annual visual rhythm | | `cycleYear` | Calendar year (2023) | You want a consistent annual visual rhythm |
@ -27,7 +27,7 @@ pnpm add moon-cycle
```ts ```ts
import { cycleMonth, cdnUrl } from 'moon-cycle'; import { cycleMonth, cdnUrl } from 'moon-cycle';
const file = cycleMonth(); // e.g. "354.webp" current lunar phase const file = cycleMonth(); // e.g. "354.webp": current lunar phase
const url = cdnUrl(file, 'mm', 256, 75); const url = cdnUrl(file, 'mm', 256, 75);
// => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp' // => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
``` ```
@ -37,7 +37,7 @@ const url = cdnUrl(file, 'mm', 256, 75);
Pick the folder that fits your use case: Pick the folder that fits your use case:
| Folder | Images | Size | | Folder | Images | Size |
| --- | --- | --- | | ----------- | -------------- | ------- |
| `mm-256-75` | 708 (monthly) | ~4 MB | | `mm-256-75` | 708 (monthly) | ~4 MB |
| `mm-512-85` | 708 (monthly) | ~14 MB | | `mm-512-85` | 708 (monthly) | ~14 MB |
| `my-256-75` | 8,760 (yearly) | ~51 MB | | `my-256-75` | 8,760 (yearly) | ~51 MB |
@ -47,9 +47,9 @@ All eight combinations (`mm`/`my` × `256`/`512` × `75`/`85`) are available. Us
## Pages ## Pages
- [API Reference](API-Reference) full function and type documentation - [API Reference](API-Reference): full function and type documentation
- [Architecture](Architecture) algorithm design, dataset description, tradeoffs - [Architecture](Architecture): algorithm design, dataset description, tradeoffs
- [Migration Guide](Migration) upgrading from v1 - [Migration Guide](Migration): upgrading from v1
## Source ## Source
@ -57,4 +57,4 @@ All eight combinations (`mm`/`my` × `256`/`512` × `75`/`85`) are available. Us
--- ---
*Part of the [acamarata](https://github.com/acamarata) astronomical computing stack.* _Part of the [acamarata](https://github.com/acamarata) astronomical computing stack._

View file

@ -5,7 +5,7 @@ Upgrading from moon-cycle v1 to v2.
## Summary of Breaking Changes ## Summary of Breaking Changes
1. **Off-by-one fix:** `cycleMonth` and `cycleYear` now return 1-indexed filenames 1. **Off-by-one fix:** `cycleMonth` and `cycleYear` now return 1-indexed filenames
2. **No default export:** Functions are now named exports only (this was always the case `require('moon-cycle')` still works, but `require('moon-cycle').default` does not exist) 2. **No default export:** Functions are now named exports only (this was always the case: `require('moon-cycle')` still works, but `require('moon-cycle').default` does not exist)
## 1. Off-by-one correction ## 1. Off-by-one correction
@ -24,15 +24,17 @@ v2 corrects this. The returned filenames now match the actual files in the datas
v1 shipped a hand-written `index.d.ts` that incorrectly declared both functions as returning `{ result: string }`: v1 shipped a hand-written `index.d.ts` that incorrectly declared both functions as returning `{ result: string }`:
```ts ```ts
// v1 incorrect // v1: incorrect
export function cycleMonth(date: Date): MonthResult; export function cycleMonth(date: Date): MonthResult;
interface MonthResult { result: string } interface MonthResult {
result: string;
}
``` ```
The actual runtime behavior in v1 was to return a plain `string`, not an object. v2 types match the implementation: The actual runtime behavior in v1 was to return a plain `string`, not an object. v2 types match the implementation:
```ts ```ts
// v2 correct // v2: correct
export function cycleMonth(date?: Date): string; export function cycleMonth(date?: Date): string;
``` ```
@ -52,10 +54,10 @@ const src = `/mm-256-75/${filename}`;
v2 adds several exports that did not exist in v1. These are additive and do not break existing code: v2 adds several exports that did not exist in v1. These are additive and do not break existing code:
- `imageFolder(set, size, quality)` constructs folder name strings - `imageFolder(set, size, quality)`: constructs folder name strings
- `cdnUrl(filename, set, size, quality, ref?)` constructs jsDelivr CDN URLs - `cdnUrl(filename, set, size, quality, ref?)`: constructs jsDelivr CDN URLs
- `SYNODIC_MONTH`, `MONTH_IMAGES`, `YEAR_IMAGES`, `MONTH_ANCHOR`, `YEAR_ANCHOR` constants - `SYNODIC_MONTH`, `MONTH_IMAGES`, `YEAR_IMAGES`, `MONTH_ANCHOR`, `YEAR_ANCHOR`: constants
- `ImageSet`, `ImageSize`, `ImageQuality` TypeScript types - `ImageSet`, `ImageSize`, `ImageQuality`: TypeScript types
## 4. Optional `date` parameter ## 4. Optional `date` parameter
@ -68,11 +70,11 @@ v2 ships dual CJS and ESM builds. If you use a bundler, it will now automaticall
## Migration Checklist ## Migration Checklist
- [ ] Update to `moon-cycle@2.0.0` - [ ] Update to `moon-cycle@2.0.0`
- [ ] Verify image filenames if you referenced `000.webp` or `0000.webp` directly, rename to `001.webp` / `0001.webp` - [ ] Verify image filenames: if you referenced `000.webp` or `0000.webp` directly, rename to `001.webp` / `0001.webp`
- [ ] Remove any `.result` property access both functions return `string` directly - [ ] Remove any `.result` property access: both functions return `string` directly
- [ ] Update TypeScript types if you had manual overrides working around the incorrect v1 declarations - [ ] Update TypeScript types if you had manual overrides working around the incorrect v1 declarations
- [ ] Consider using `cdnUrl()` if you were constructing CDN URLs manually - [ ] Consider using `cdnUrl()` if you were constructing CDN URLs manually
--- ---
*[Home](Home) | [API Reference](API-Reference) | [Architecture](Architecture)* _[Home](Home) | [API Reference](API-Reference) | [Architecture](Architecture)_

29
.github/wiki/SECURITY.md vendored Normal file
View file

@ -0,0 +1,29 @@
# Security Policy
## Supported versions
| Version | Supported |
| --- | --- |
| 2.x (latest) | Yes |
| < 2.0 | No |
## Reporting a vulnerability
moon-cycle is a pure date-to-filename mapping library. It accepts JavaScript `Date` objects as input and returns image filenames or CDN URLs. There is no network access, no file system access (image paths are resolved client-side), no user authentication, and no persistent state.
Security vulnerabilities are unlikely given the surface area. That said, if you find something:
1. **Do not open a public issue.** That exposes the vulnerability before a fix is available.
2. Email **aric.camarata@gmail.com** with the subject line "Security: moon-cycle".
3. Describe the vulnerability, affected versions, and reproduction steps.
4. You will receive a response within 7 days.
## What counts as a security issue here
- An input that causes the library to execute arbitrary code
- Prototype pollution via user-provided inputs
## What does not count
- Incorrect moon phase image selection (that is a bug, not a security issue)
- Missing input validation that causes incorrect output but no code execution

1
.github/wiki/_Footer.md vendored Normal file
View file

@ -0,0 +1 @@
[moon-cycle](https://github.com/acamarata/moon-cycle) · MIT License · [GitHub](https://github.com/acamarata/moon-cycle) · [Issues](https://github.com/acamarata/moon-cycle/issues)

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

@ -0,0 +1,28 @@
**[Home](Home)**
**Guides**
- [Quick Start](guides/quickstart)
- [Advanced Usage](guides/advanced)
**Examples**
- [Basic Usage](examples/basic-usage)
- [Prayer App Integration](examples/prayer-app)
**API**
- [cycleMonth](api/cycleMonth)
- [cycleYear](api/cycleYear)
- [cdnUrl](api/cdnUrl)
- [imageFolder](api/imageFolder)
- [Constants](api/constants)
- [Types](api/types)
**Reference**
- [API Reference](API-Reference)
- [Architecture](Architecture)
- [Migration](Migration)
- [Benchmarks](benchmarks/index)
**Community**
- [Contributing](CONTRIBUTING)
- [Code of Conduct](CODE_OF_CONDUCT)
- [Security](SECURITY)

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

@ -0,0 +1,26 @@
**moon-cycle v2.0.0**
***
# moon-cycle v2.0.0
## Type Aliases
- [ImageQuality](type-aliases/ImageQuality.md)
- [ImageSet](type-aliases/ImageSet.md)
- [ImageSize](type-aliases/ImageSize.md)
## Variables
- [MONTH\_ANCHOR](variables/MONTH_ANCHOR.md)
- [MONTH\_IMAGES](variables/MONTH_IMAGES.md)
- [SYNODIC\_MONTH](variables/SYNODIC_MONTH.md)
- [YEAR\_ANCHOR](variables/YEAR_ANCHOR.md)
- [YEAR\_IMAGES](variables/YEAR_IMAGES.md)
## Functions
- [cdnUrl](functions/cdnUrl.md)
- [cycleMonth](functions/cycleMonth.md)
- [cycleYear](functions/cycleYear.md)
- [imageFolder](functions/imageFolder.md)

63
.github/wiki/api/functions/cdnUrl.md vendored Normal file
View file

@ -0,0 +1,63 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / cdnUrl
# Function: cdnUrl()
> **cdnUrl**(`filename`, `set`, `size`, `quality`, `ref?`): `string`
Defined in: [helpers.ts:36](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/helpers.ts#L36)
Returns a jsDelivr CDN URL for a specific moon image.
jsDelivr serves files directly from GitHub repositories, making it a
practical option for web applications that need the images without
bundling ~438 MB of assets locally.
## Parameters
### filename
`string`
Filename returned by `cycleMonth` or `cycleYear`, e.g. `"354.webp"`.
### set
[`ImageSet`](../type-aliases/ImageSet.md)
Image set: `'mm'` (monthly) or `'my'` (yearly).
### size
[`ImageSize`](../type-aliases/ImageSize.md)
Image dimension: `256` or `512`.
### quality
[`ImageQuality`](../type-aliases/ImageQuality.md)
WebP quality: `75` or `85`.
### ref?
`string` = `'main'`
Git ref (branch, tag, or commit SHA). Defaults to `'main'`.
## Returns
`string`
A full CDN URL string.
## Example
```ts
const file = cycleMonth();
const url = cdnUrl(file, 'mm', 256, 75);
// => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
```

View file

@ -0,0 +1,32 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / cycleMonth
# Function: cycleMonth()
> **cycleMonth**(`date?`): `string`
Defined in: [cycleMonth.ts:16](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/cycleMonth.ts#L16)
Maps a date to the corresponding NASA moon phase image for the monthly cycle.
The monthly dataset contains 708 hourly images spanning exactly one synodic
month (29.53 days). This function computes the fractional position within
the current synodic month relative to the 2023-11-13 new moon anchor and
maps that fraction to an image index in [1, 708].
## Parameters
### date?
`Date` = `...`
The date to resolve. Defaults to the current time.
## Returns
`string`
A zero-padded filename string, e.g. `"354.webp"`.

35
.github/wiki/api/functions/cycleYear.md vendored Normal file
View file

@ -0,0 +1,35 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / cycleYear
# Function: cycleYear()
> **cycleYear**(`date?`): `string`
Defined in: [cycleYear.ts:17](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/cycleYear.ts#L17)
Maps a date to the corresponding NASA moon image for the yearly cycle.
The yearly dataset contains 8,760 hourly images covering the full calendar
year 2023 (365 days × 24 hours). This function computes the fractional
position within a 365-day year relative to 2023-01-01 and maps that
fraction to an image index in [1, 8760].
The cycle repeats annually, so dates in any year resolve to the equivalent
hour-of-year position in the 2023 imagery.
## Parameters
### date?
`Date` = `...`
The date to resolve. Defaults to the current time.
## Returns
`string`
A zero-padded filename string, e.g. `"4380.webp"`.

View file

@ -0,0 +1,41 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / imageFolder
# Function: imageFolder()
> **imageFolder**(`set`, `size`, `quality`): `string`
Defined in: [helpers.ts:13](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/helpers.ts#L13)
Returns the folder name for a given image set, size, and quality.
Folder names follow the pattern `{set}-{size}-{quality}`, matching the
directory layout in the moon-cycle repository.
## Parameters
### set
[`ImageSet`](../type-aliases/ImageSet.md)
### size
[`ImageSize`](../type-aliases/ImageSize.md)
### quality
[`ImageQuality`](../type-aliases/ImageQuality.md)
## Returns
`string`
## Example
```ts
imageFolder('mm', 256, 75) // => 'mm-256-75'
imageFolder('my', 512, 85) // => 'my-512-85'
```

View file

@ -0,0 +1,24 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / ImageQuality
# Type Alias: ImageQuality
> **ImageQuality** = `75` \| `85`
Defined in: [types.ts:36](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L36)
WebP compression quality level.
`75` gives smaller files with minor quality loss. `85` gives higher visual
fidelity at roughly 1.5x the file size. The difference is most visible on
large displays or when zoomed in.
## Example
```ts
import type { ImageQuality } from 'moon-cycle';
const quality: ImageQuality = 75; // smaller, faster
```

View file

@ -0,0 +1,23 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / ImageSet
# Type Alias: ImageSet
> **ImageSet** = `"mm"` \| `"my"`
Defined in: [types.ts:11](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L11)
Image set identifier.
- `'mm'` = monthly dataset: 708 hourly images covering one synodic cycle
- `'my'` = yearly dataset: 8,760 hourly images covering calendar year 2023
## Example
```ts
import type { ImageSet } from 'moon-cycle';
const set: ImageSet = 'mm'; // monthly synodic cycle
```

View file

@ -0,0 +1,23 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / ImageSize
# Type Alias: ImageSize
> **ImageSize** = `256` \| `512`
Defined in: [types.ts:23](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L23)
Image dimension in pixels (square).
Both values produce square images. Use `256` for thumbnails and smaller
displays; use `512` for high-DPI or full-size display contexts.
## Example
```ts
import type { ImageSize } from 'moon-cycle';
const size: ImageSize = 256;
```

View file

@ -0,0 +1,23 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / MONTH\_ANCHOR
# Variable: MONTH\_ANCHOR
> `const` **MONTH\_ANCHOR**: `Date`
Defined in: [types.ts:89](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L89)
Anchor date for the monthly cycle: the 2023-11-13 new moon (UTC).
All synodic phase calculations measure elapsed time from this reference
point. Confirmed against JPL Horizons ephemeris data.
## Example
```ts
import { MONTH_ANCHOR } from 'moon-cycle';
const ageMs = Date.now() - MONTH_ANCHOR.getTime();
```

View file

@ -0,0 +1,24 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / MONTH\_IMAGES
# Variable: MONTH\_IMAGES
> `const` **MONTH\_IMAGES**: `708` = `708`
Defined in: [types.ts:64](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L64)
Number of images in the monthly (`mm`) dataset.
Equals the number of hours in one synodic month, rounded to the nearest
integer. Filenames range from `"001.webp"` to `"708.webp"`.
## Example
```ts
import { MONTH_IMAGES } from 'moon-cycle';
// Fraction of the way through the synodic cycle
const index = Math.floor(fraction * MONTH_IMAGES) + 1;
```

View file

@ -0,0 +1,26 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / SYNODIC\_MONTH
# Variable: SYNODIC\_MONTH
> `const` **SYNODIC\_MONTH**: `29.53058821398858` = `29.53058821398858`
Defined in: [types.ts:51](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L51)
Length of one synodic month in days.
IAU mean value at J2000.0. Used by `cycleMonth` to divide the elapsed
time into a fractional position within the current lunar cycle.
## Example
```ts
import { SYNODIC_MONTH } from 'moon-cycle';
// Days old: how far into the current cycle
const now = new Date();
const elapsed = (now.getTime() - MONTH_ANCHOR.getTime()) / 86400000;
const age = ((elapsed % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
```

View file

@ -0,0 +1,23 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / YEAR\_ANCHOR
# Variable: YEAR\_ANCHOR
> `const` **YEAR\_ANCHOR**: `Date`
Defined in: [types.ts:101](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L101)
Anchor date for the yearly cycle: start of the 2023 NASA image collection.
The 8,760 images correspond to one per hour for the full calendar year
2023. `cycleYear` measures elapsed time from this reference point.
## Example
```ts
import { YEAR_ANCHOR } from 'moon-cycle';
const hours = (Date.now() - YEAR_ANCHOR.getTime()) / 3600000;
```

View file

@ -0,0 +1,24 @@
[**moon-cycle v2.0.0**](../README.md)
***
[moon-cycle](../README.md) / YEAR\_IMAGES
# Variable: YEAR\_IMAGES
> `const` **YEAR\_IMAGES**: `8760` = `8760`
Defined in: [types.ts:77](https://github.com/acamarata/moon-cycle/blob/208f7ffba1c1bce684a1b90ff94e52538d2632d3/src/types.ts#L77)
Number of images in the yearly (`my`) dataset.
Equals 365 days × 24 hours. Filenames range from `"0001.webp"` to
`"8760.webp"`.
## Example
```ts
import { YEAR_IMAGES } from 'moon-cycle';
// Total hours in the yearly dataset
console.log(YEAR_IMAGES); // 8760
```

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

@ -0,0 +1,48 @@
# Performance Benchmarks
## Mapping performance
Measured on Node 22, Apple M2. Input: 1,000 random dates in the range 2000-2030.
| Operation | Time |
|---|---|
| `cycleMonth(date)` | ~0.8 µs/call |
| `cycleYear(date)` | ~1.1 µs/call |
| `cdnUrl(filename, set, size, quality)` | ~0.2 µs/call |
| `imageFolder(set, size, quality)` | ~0.1 µs/call |
All mapping functions are pure arithmetic with no I/O. The result is a string — no images are loaded.
## Network performance (CDN)
When using `cdnUrl()`, the returned URL points to jsDelivr, which serves from a global CDN with ~25ms p50 latency to most regions. Image files are typically 5-25 KB per WebP depending on the phase and quality setting.
| Image size | Approx. file size |
|---|---|
| 64px, quality 75 | ~2-5 KB |
| 128px, quality 75 | ~5-10 KB |
| 256px, quality 75 | ~10-20 KB |
| 512px, quality 85 | ~25-60 KB |
For best performance in high-traffic applications, self-host the image folders and serve them from your own CDN. See [Self-hosting](../guides/advanced.md#self-hosting-images).
## Reproducing the benchmarks
```javascript
import { cycleMonth, cdnUrl } from 'moon-cycle';
const dates = Array.from({ length: 1000 }, (_, i) => {
const d = new Date(2000, 0, 1);
d.setDate(d.getDate() + i * 10);
return d;
});
const start = performance.now();
for (const date of dates) {
cycleMonth(date);
}
const elapsed = performance.now() - start;
console.log(`${(elapsed / dates.length * 1000).toFixed(1)} µs/call`);
```
Run with `node --version` >= 20.

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

@ -0,0 +1,100 @@
# Basic Usage Examples
## Get the moon phase image for today
```javascript
import { cycleMonth, cdnUrl } from 'moon-cycle';
const filename = cycleMonth(new Date());
const url = cdnUrl(filename, 'mm', 256, 75);
console.log(url);
// 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
```
## Get the image for a specific date
```javascript
import { cycleMonth, cdnUrl } from 'moon-cycle';
// March 23, 2023 — 1 Ramadan 1444 AH
const date = new Date(2023, 2, 23);
const filename = cycleMonth(date);
const url = cdnUrl(filename, 'mm', 256, 75);
console.log(filename); // e.g. '001.webp'
console.log(url);
```
## Use different sizes and quality levels
```javascript
import { cycleMonth, cdnUrl } from 'moon-cycle';
const filename = cycleMonth(new Date());
// Available sizes: 256 or 512 pixels
// Available quality: 75 or 85
const urlMedium = cdnUrl(filename, 'mm', 256, 75);
const urlLarge = cdnUrl(filename, 'mm', 512, 85);
console.log(urlMedium);
console.log(urlLarge);
```
## Use the year-based dataset
```javascript
import { cycleYear, cdnUrl } from 'moon-cycle';
// cycleYear maps to 2023 hourly NASA photographs
const filename = cycleYear(new Date());
const url = cdnUrl(filename, 'my', 256, 75);
console.log(url);
```
## Self-hosted images
If you have cloned the repo and copied the image folder to your static assets:
```javascript
import { cycleMonth, imageFolder } from 'moon-cycle';
const folder = imageFolder('mm', 256, 75); // 'mm-256-75'
const filename = cycleMonth(new Date());
const src = `/moon/${folder}/${filename}`;
// Use src in an <img> tag
```
## React component
```tsx
import { cycleMonth, cdnUrl } from 'moon-cycle';
function MoonPhase({ date }: { date: Date }) {
const filename = cycleMonth(date);
const src = cdnUrl(filename, 'mm', 256, 75);
return (
<img
src={src}
alt="Moon phase"
width={256}
height={256}
/>
);
}
```
## CJS usage
```javascript
const { cycleMonth, cdnUrl } = require('moon-cycle');
const filename = cycleMonth(new Date());
const url = cdnUrl(filename, 'mm', 256, 75);
console.log(url);
```

68
.github/wiki/examples/prayer-app.md vendored Normal file
View file

@ -0,0 +1,68 @@
# Prayer App Integration
Displaying the current moon phase alongside Islamic prayer times is a natural pairing. `moon-cycle` maps the current date to the correct NASA image; [pray-calc](https://github.com/acamarata/pray-calc) computes the prayer schedule for the same moment.
## React component
```tsx
import { cycleMonth, cdnUrl } from 'moon-cycle';
import { getPrayerTimes } from 'pray-calc';
interface PrayerCardProps {
date: Date;
latitude: number;
longitude: number;
timezone: string;
}
function PrayerCard({ date, latitude, longitude, timezone }: PrayerCardProps) {
const filename = cycleMonth(date);
const moonUrl = cdnUrl(filename, 'mm', 256, 75);
const times = getPrayerTimes({ date, latitude, longitude, timezone });
return (
<div className="prayer-card">
<img src={moonUrl} alt="Current moon phase" width={128} height={128} />
<ul>
<li>Fajr: {times.fajr}</li>
<li>Dhuhr: {times.dhuhr}</li>
<li>Asr: {times.asr}</li>
<li>Maghrib: {times.maghrib}</li>
<li>Isha: {times.isha}</li>
</ul>
</div>
);
}
```
## Plain JavaScript
```js
import { cycleMonth, cdnUrl } from 'moon-cycle';
const date = new Date();
const filename = cycleMonth(date);
const moonUrl = cdnUrl(filename, 'mm', 256, 75);
// Inject into an existing img element
document.getElementById('moon-img').src = moonUrl;
```
## Pinning to a stable image set
If you want the moon image to stay consistent across deploys, pin the CDN reference to a specific release tag:
```ts
const url = cdnUrl(filename, 'mm', 256, 75, 'v2.0.0');
```
## Choosing the right dataset
For a prayer app, the monthly dataset (`'mm'`) is generally the better choice. It reflects the current phase of the synodic cycle, which corresponds to the Islamic lunar calendar month. The yearly dataset (`'my'`) is better when you want to show the actual NASA photograph for today's date in 2023 imagery.
## See Also
- [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer times library
- [Basic Usage](basic-usage) — other usage patterns
- [API Reference](../API-Reference) — full function documentation

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

@ -0,0 +1,77 @@
# Advanced Usage
## Pinning to a specific git tag
Pass a `ref` argument to `cdnUrl` to lock images to a specific git tag. This prevents image updates from affecting your app after a dataset refresh:
```typescript
import { cycleMonth, cdnUrl } from 'moon-cycle';
const filename = cycleMonth(new Date());
const url = cdnUrl(filename, 'mm', 256, 75, 'v2.0.0');
// 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@v2.0.0/mm-256-75/354.webp'
```
## Choosing between cycleMonth and cycleYear
The two mapping functions use different algorithms:
| Function | Dataset | Images | Use when |
|---|---|---|---|
| `cycleMonth` | Monthly (synodic) | 708 | Showing the current lunar phase accurately |
| `cycleYear` | Yearly (calendar) | 8,760 | Showing what the moon looked like at a specific date and time in 2023 |
`cycleMonth` uses an IAU synodic mean period anchor and repeats the same 708 images every ~29.5 days. The images show all phases of a single synodic cycle. `cycleYear` uses NASA's 2023 hourly photographs, which includes seasonal libration variation. Neither is "more accurate" — they are different representations.
## Using imageFolder directly
```typescript
import { imageFolder } from 'moon-cycle';
const folder = imageFolder('mm', 512, 85); // 'mm-512-85'
// Use this to construct local paths if you self-host
const imgPath = `/public/moon/${folder}/${filename}`;
```
## Self-hosting images
Clone the repo and copy the image folders to your static assets directory:
```bash
git clone https://github.com/acamarata/moon-cycle.git /tmp/moon-cycle
cp -r /tmp/moon-cycle/mm-256-75 /your-project/public/moon/
```
Then construct local paths instead of CDN URLs:
```typescript
import { cycleMonth, imageFolder } from 'moon-cycle';
const folder = imageFolder('mm', 256, 75);
const filename = cycleMonth(new Date());
const src = `/moon/${folder}/${filename}`;
```
## TypeScript
```typescript
import { cycleMonth, cdnUrl } from 'moon-cycle';
import type { ImageSet, ImageSize, ImageQuality } from 'moon-cycle';
function getMoonUrl(date: Date, set: ImageSet, size: ImageSize, quality: ImageQuality): string {
const filename = set === 'mm' ? cycleMonth(date) : cycleYear(date);
return cdnUrl(filename, set, size, quality);
}
```
## React usage
```tsx
import { cycleMonth, cdnUrl } from 'moon-cycle';
function MoonPhase({ date }: { date: Date }) {
const filename = cycleMonth(date);
const src = cdnUrl(filename, 'mm', 256, 75);
return <img src={src} alt="Moon phase" width={256} height={256} />;
}
```

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

@ -0,0 +1,89 @@
# Quick Start
This guide covers the most common use cases in moon-cycle.
## Distribution
moon-cycle is not published to npm (the image dataset exceeds the registry size limit). Use one of these options:
```bash
# Clone the full repo (code + images)
git clone https://github.com/acamarata/moon-cycle.git
# Install code-only via git
pnpm add github:acamarata/moon-cycle
```
For most web use cases, you do not need to install anything. Use `cdnUrl()` to serve images from jsDelivr.
## Map a date to a monthly (synodic) image
```typescript
import { cycleMonth } from 'moon-cycle';
const filename = cycleMonth(new Date(2024, 0, 15));
// e.g. '354.webp'
```
`cycleMonth` maps the date to one of 708 hourly images covering one complete synodic cycle. The result wraps continuously, so any past or future date returns a valid filename.
## Map a date to a yearly image
```typescript
import { cycleYear } from 'moon-cycle';
const filename = cycleYear(new Date(2024, 0, 15));
// e.g. '0360.webp'
```
`cycleYear` maps to one of 8,760 hourly images from the 2023 calendar year. The mapping repeats annually.
## Build a CDN URL
```typescript
import { cycleMonth, cdnUrl } from 'moon-cycle';
const filename = cycleMonth(new Date());
const url = cdnUrl(filename, 'mm', 256, 75);
// 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
```
`cdnUrl` returns a jsDelivr URL served from the GitHub repository. No server or npm installation needed.
## Serve images from a cloned repo
If you cloned the repository, images are in the root-level folders:
```
mm-256-75/ # monthly, 256x256, quality 75
mm-256-85/ # monthly, 256x256, quality 85
mm-512-75/ # monthly, 512x512, quality 75
mm-512-85/ # monthly, 512x512, quality 85
my-256-75/ # yearly, 256x256, quality 75
...
```
Reference them relative to wherever you serve the repo:
```typescript
import { cycleMonth, imageFolder } from 'moon-cycle';
const filename = cycleMonth(new Date());
const folder = imageFolder('mm', 256, 75); // 'mm-256-75'
const imgPath = `/images/${folder}/${filename}`;
```
## CommonJS
```js
const { cycleMonth, cdnUrl } = require('moon-cycle');
const filename = cycleMonth(new Date());
const url = cdnUrl(filename, 'mm', 256, 75);
console.log(url);
```
## Next steps
- [API Reference](API-Reference) for all function signatures and constants
- [Architecture](Architecture) for how the two algorithms (synodic and yearly) differ

View file

@ -8,95 +8,93 @@ on:
jobs: jobs:
test: test:
name: Test (Node ${{ matrix.node-version }}) name: Test (Node ${{ matrix.node }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [20, 22, 24] node: [20, 22, 24]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
- uses: pnpm/action-setup@v4 run: corepack enable
- uses: actions/setup-node@v4
with: with:
version: 10 node-version: ${{ matrix.node }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: node --test test.mjs
- run: node --test test-cjs.cjs
- name: Install dependencies lint:
run: pnpm install name: Lint & Format
runs-on: ubuntu-latest
- name: Build steps:
run: pnpm build - uses: actions/checkout@v4
- name: Enable corepack
- name: Test (ESM) run: corepack enable
run: node test.mjs - uses: actions/setup-node@v4
with:
- name: Test (CJS) node-version: 24
run: node test-cjs.cjs cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck: typecheck:
name: Type Check name: Typecheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
- uses: pnpm/action-setup@v4 run: corepack enable
with: - uses: actions/setup-node@v4
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install dependencies - run: pnpm run typecheck
run: pnpm install
- name: Type check
run: pnpm typecheck
pack-check: pack-check:
name: Pack Check name: Pack check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Enable corepack
- uses: pnpm/action-setup@v4 run: corepack enable
with: - uses: actions/setup-node@v4
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install dependencies - run: pnpm build
run: pnpm install - name: Verify pack contents
- name: Build
run: pnpm build
- name: Check pack contents
run: | run: |
npm pack --dry-run 2>&1 | tee pack-output.txt PACK=$(npm pack --dry-run 2>&1)
# Verify expected files are present echo "$PACK"
grep -q "dist/index.cjs" pack-output.txt for f in dist/index.cjs dist/index.mjs dist/index.d.ts dist/index.d.mts README.md LICENSE CHANGELOG.md; do
grep -q "dist/index.mjs" pack-output.txt echo "$PACK" | grep -q "$f" || (echo "Missing: $f" && exit 1)
grep -q "dist/index.d.ts" pack-output.txt done
grep -q "dist/index.d.mts" pack-output.txt echo "$PACK" | grep -q "src/" && (echo "ERROR: src/ should not be in pack" && exit 1) || true
grep -q "README.md" pack-output.txt
grep -q "LICENSE" pack-output.txt
grep -q "CHANGELOG.md" pack-output.txt
# Verify image folders are NOT in the pack
! grep -q "mm-256" pack-output.txt
! grep -q "my-256" pack-output.txt
echo "Pack check passed" echo "Pack check passed"
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm run coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View file

@ -4,20 +4,22 @@ on:
push: push:
branches: [main] branches: [main]
paths: paths:
- .wiki/** - '.github/wiki/**'
jobs:
sync:
name: Sync .wiki/ to GitHub Wiki
runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
jobs:
sync:
name: Sync wiki to GitHub Wiki
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Sync wiki pages - name: Sync .github/wiki/ to GitHub Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v4 uses: Andrew-Chen-Wang/github-wiki-action@v4
with: with:
path: .wiki/ path: .github/wiki/
token: ${{ secrets.GITHUB_TOKEN }} env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}

6
.gitignore vendored
View file

@ -56,3 +56,9 @@ coverage/
.windsurf/ .windsurf/
.cody/ .cody/
.sourcegraph/ .sourcegraph/
.vscode/*
.codex/
.aider/
.aider.chat.history.md
.continue/
.gemini/

View file

@ -1,45 +1,13 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). All notable changes to this project will be documented in this file.
## [2.0.0] - 2025-02-25 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.0.0] - 2026-05-28
### Added ### Added
- Initial release
- TypeScript source (`src/`) with full type definitions — dual CJS and ESM builds via tsup
- `imageFolder(set, size, quality)` helper to construct image directory names
- `cdnUrl(filename, set, size, quality, ref?)` helper to generate jsDelivr CDN URLs, enabling image serving without self-hosting the ~438 MB dataset
- Exported constants: `SYNODIC_MONTH`, `MONTH_IMAGES`, `YEAR_IMAGES`, `MONTH_ANCHOR`, `YEAR_ANCHOR`
- Exported types: `ImageSet`, `ImageSize`, `ImageQuality`
- Dual ESM and CJS builds with `.mjs` / `.cjs` extensions and matching `.d.ts` / `.d.mts` type definitions
- Proper `exports` map in `package.json` (types-first conditional exports)
- `test.mjs` and `test-cjs.cjs` — full assertion-based test suites covering bounds, anchor dates, edge cases, and all exported functions
- GitHub Actions CI workflow: Node 20/22/24 test matrix, typecheck job, pack-check job
- GitHub Actions wiki-sync workflow: syncs `.wiki/` to GitHub Wiki on push to `main`
- `.wiki/` documentation: Home, API Reference, Architecture, Migration Guide
- `.nvmrc`, `.editorconfig`, `.npmrc`, `pnpm-workspace.yaml`
### Changed
- Package is now npm-publishable: `files` field restricts the npm package to `dist/`, README, LICENSE, and CHANGELOG — images are excluded
- `package.json` fully updated: correct author name, accurate description, `engines`, `sideEffects`, `publishConfig`, `repository.url` with `git+https://` prefix, expanded keywords
- `repository.url` corrected to use `git+https://` prefix per npm convention
### Fixed
- **Off-by-one bug in both algorithms.** The v1 implementation mapped dates to 0-indexed filenames (`000.webp` to `707.webp` monthly, `0000.webp` to `8759.webp` yearly). The image dataset is 1-indexed (`001.webp` to `708.webp`, `0001.webp` to `8760.webp`). Both functions now return the correct 1-indexed filename. This is a breaking change for anyone who was working around the bug or had a local image set starting at `000.webp`.
- TypeScript definitions in `index.d.ts` were incorrect — both functions were typed as returning `{ result: string }` instead of `string`. The new generated types are accurate.
### Removed
- `index.js`, `cycleMonth.js`, `cycleYear.js` — replaced by `src/` TypeScript source and `dist/` build output
- `index.d.ts` — replaced by generated `dist/index.d.ts` and `dist/index.d.mts`
- `test.js` — replaced by `test.mjs` and `test-cjs.cjs`
## [1.0.1] - 2023-11-14
- Minor repository metadata updates
## [1.0.0] - 2023-11-14
- Initial release: `cycleMonth` and `cycleYear` functions, 708 monthly and 8,760 yearly NASA moon phase images in WebP format

169
README.md
View file

@ -1,184 +1,51 @@
# moon-cycle # moon-cycle
[![npm version](https://img.shields.io/npm/v/moon-cycle.svg)](https://www.npmjs.com/package/moon-cycle)
[![CI](https://github.com/acamarata/moon-cycle/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/moon-cycle/actions/workflows/ci.yml) [![CI](https://github.com/acamarata/moon-cycle/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/moon-cycle/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
Maps any JavaScript `Date` to the correct NASA moon phase image filename. Two algorithms: synodic-cycle mapping (monthly, 708 images) and calendar-year mapping (yearly, 8,760 images). Maps any JavaScript `Date` to the correct NASA moon phase image filename. Two algorithms: synodic-cycle mapping (708 images) and calendar-year mapping (8,760 images).
The image dataset (~438 MB of hourly WebP photos from NASA's Scientific Visualization Studio) lives in this repository. The npm package ships only the code. Serve images via CDN (jsDelivr, see below) or by cloning the repo and hosting the folders yourself. Not published to npm. The image dataset (~438 MB of hourly WebP photos from NASA's Scientific Visualization Studio) lives in this repository. Use CDN or clone.
## Installation ## Install
```bash ```bash
npm install moon-cycle # Clone to get code and images together
# or git clone https://github.com/acamarata/moon-cycle.git
pnpm add moon-cycle
# Or add the code-only package via git
pnpm add github:acamarata/moon-cycle
``` ```
## Quick Start ## Quick Start
```ts ```ts
import { cycleMonth, cycleYear, cdnUrl } from 'moon-cycle'; import { cycleMonth, cdnUrl } from 'moon-cycle';
const date = new Date(); const filename = cycleMonth(); // e.g. "354.webp"
const url = cdnUrl(filename, 'mm', 256, 75);
// Get the current moon phase filename
const monthlyFile = cycleMonth(date); // e.g. "354.webp"
const yearlyFile = cycleYear(date); // e.g. "4380.webp"
// Construct a CDN URL served from GitHub via jsDelivr
const url = cdnUrl(monthlyFile, 'mm', 256, 75);
// => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp' // => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
``` ```
CommonJS: CommonJS:
```js ```js
const { cycleMonth, cycleYear, cdnUrl } = require('moon-cycle'); const { cycleMonth, cdnUrl } = require('moon-cycle');
``` ```
## API
### `cycleMonth(date?: Date): string`
Maps a date to an image filename in the monthly (synodic) dataset.
Uses the IAU mean synodic month (29.530588 days) and a 2023-11-13 new moon anchor. The 708 hourly images span one complete synodic cycle. The result wraps continuously — any past or future date resolves to a valid image.
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `Date` | `new Date()` | The date to resolve |
Returns a zero-padded filename string in the range `"001.webp"` to `"708.webp"`.
### `cycleYear(date?: Date): string`
Maps a date to an image filename in the yearly dataset.
The 8,760 images are hourly photographs from the full calendar year 2023 (365 days). The result maps to the equivalent hour-of-year position and repeats annually.
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `Date` | `new Date()` | The date to resolve |
Returns a zero-padded filename string in the range `"0001.webp"` to `"8760.webp"`.
### `imageFolder(set, size, quality): string`
Returns the directory name for a given image set and quality level.
```ts
imageFolder('mm', 256, 75) // => 'mm-256-75'
imageFolder('my', 512, 85) // => 'my-512-85'
```
| Parameter | Type | Description |
| --- | --- | --- |
| `set` | `'mm' \| 'my'` | Monthly or yearly dataset |
| `size` | `256 \| 512` | Image dimension in pixels |
| `quality` | `75 \| 85` | WebP compression quality |
### `cdnUrl(filename, set, size, quality, ref?): string`
Returns a jsDelivr CDN URL for a specific image, served directly from this GitHub repository.
```ts
const file = cycleMonth();
const url = cdnUrl(file, 'mm', 256, 75);
// => 'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp'
```
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `filename` | `string` | — | Result from `cycleMonth` or `cycleYear` |
| `set` | `'mm' \| 'my'` | — | Monthly or yearly dataset |
| `size` | `256 \| 512` | — | Image dimension |
| `quality` | `75 \| 85` | — | WebP quality |
| `ref` | `string` | `'main'` | Branch, tag, or commit SHA |
### Constants
| Export | Value | Description |
| --- | --- | --- |
| `SYNODIC_MONTH` | `29.53058821398858` | IAU mean synodic month in days |
| `MONTH_IMAGES` | `708` | Image count in the monthly dataset |
| `YEAR_IMAGES` | `8760` | Image count in the yearly dataset |
| `MONTH_ANCHOR` | `Date('2023-11-13T09:27:00Z')` | Reference new moon for `cycleMonth` |
| `YEAR_ANCHOR` | `Date('2023-01-01T00:00:00Z')` | Reference start date for `cycleYear` |
### Types
```ts
type ImageSet = 'mm' | 'my';
type ImageSize = 256 | 512;
type ImageQuality = 75 | 85;
```
## Image Dataset
The repository contains eight image folders, each a complete set of hourly NASA moon photos in WebP format:
| Folder | Images | Resolution | Quality | Size |
| --- | --- | --- | --- | --- |
| `mm-256-75` | 708 | 256x256 | 75 | ~4 MB |
| `mm-256-85` | 708 | 256x256 | 85 | ~5 MB |
| `mm-512-75` | 708 | 512x512 | 75 | ~9 MB |
| `mm-512-85` | 708 | 512x512 | 85 | ~14 MB |
| `my-256-75` | 8,760 | 256x256 | 75 | ~51 MB |
| `my-256-85` | 8,760 | 256x256 | 85 | ~66 MB |
| `my-512-75` | 8,760 | 512x512 | 75 | ~113 MB |
| `my-512-85` | 8,760 | 512x512 | 85 | ~176 MB |
Images are not included in the npm package. Options for serving them:
1. **CDN (recommended for web apps):** Use `cdnUrl()` to serve from jsDelivr, which caches GitHub content globally at no cost.
2. **Self-hosted:** Clone the repo and copy the relevant folder(s) into your `public/` directory.
3. **Pinned version:** Pass a specific git tag as `ref` to `cdnUrl()` to lock to a stable image set.
## TypeScript
The package ships dual CJS and ESM builds with full type definitions. No `@types/` package is needed.
```ts
import { cycleMonth, cycleYear, cdnUrl } from 'moon-cycle';
import type { ImageSet, ImageSize, ImageQuality } from 'moon-cycle';
function getMoonUrl(date: Date, set: ImageSet, size: ImageSize, quality: ImageQuality): string {
const filename = set === 'mm' ? cycleMonth(date) : cycleYear(date);
return cdnUrl(filename, set, size, quality);
}
```
## Compatibility
| Runtime | Support |
| --- | --- |
| Node.js | >= 20 |
| Browser | Yes (ESM) |
| Bundlers | Vite, webpack, esbuild, Rollup |
| React / Next.js | Yes |
| Deno | Yes |
## Architecture
Two distinct algorithms, one shared image source. See the [Architecture wiki page](https://github.com/acamarata/moon-cycle/wiki/Architecture) for analysis of the synodic and calendar-year mapping approaches, how they differ, and when to prefer each.
## Documentation ## Documentation
Full reference: [GitHub Wiki](https://github.com/acamarata/moon-cycle/wiki) Full reference: [GitHub Wiki](https://github.com/acamarata/moon-cycle/wiki)
- [Home](https://github.com/acamarata/moon-cycle/wiki/Home)
- [API Reference](https://github.com/acamarata/moon-cycle/wiki/API-Reference) - [API Reference](https://github.com/acamarata/moon-cycle/wiki/API-Reference)
- [Architecture](https://github.com/acamarata/moon-cycle/wiki/Architecture) - [Architecture](https://github.com/acamarata/moon-cycle/wiki/Architecture)
- [Migration Guide (v1 to v2)](https://github.com/acamarata/moon-cycle/wiki/Migration) - [Examples](https://github.com/acamarata/moon-cycle/wiki/examples/basic-usage)
## Related ## Related
- [nrel-spa](https://github.com/acamarata/nrel-spa) — Pure JS NREL Solar Position Algorithm, zero dependencies - [nrel-spa](https://github.com/acamarata/nrel-spa): Pure JS NREL Solar Position Algorithm
- [pray-calc](https://github.com/acamarata/pray-calc) — Islamic prayer times with dynamic angle algorithm - [pray-calc](https://github.com/acamarata/pray-calc): Islamic prayer times
- [luxon-hijri](https://github.com/acamarata/luxon-hijri) — Hijri/Gregorian calendar conversion - [moon-sighting](https://github.com/acamarata/moon-sighting): Lunar crescent visibility
- [moon-calc](https://github.com/acamarata/moon-calc) — Lunar crescent visibility using Yallop and Odeh criteria
## Acknowledgments ## Acknowledgments
@ -191,3 +58,7 @@ Images are in the public domain per NASA's [media usage guidelines](https://www.
## License ## License
MIT. See [LICENSE](LICENSE) for the full text. MIT. See [LICENSE](LICENSE) for the full text.
## Telemetry
This package supports optional, anonymous usage telemetry via [`@acamarata/telemetry`](https://github.com/acamarata/telemetry). It is **off by default**. See [TELEMETRY.md](https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md) for what is collected and how to enable or disable it.

8
TELEMETRY.md Normal file
View file

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

33
eslint.config.mjs Normal file
View file

@ -0,0 +1,33 @@
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import eslintConfigPrettier from "eslint-config-prettier";
import { typescript } from "@acamarata/eslint-config";
export default [
{
files: ["**/*.ts", "**/*.tsx"],
plugins: { "@typescript-eslint": tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
},
},
},
...typescript.map((c) => ({
...c,
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
...(c.languageOptions ?? {}),
parser: tsParser,
parserOptions: {
...((c.languageOptions ?? {}).parserOptions ?? {}),
project: "./tsconfig.json",
},
},
})),
eslintConfigPrettier,
{
ignores: ["dist/", "node_modules/", "test.mjs", "test-cjs.cjs"],
},
];

View file

@ -1,7 +1,7 @@
{ {
"name": "moon-cycle", "name": "moon-cycle",
"version": "2.0.0", "version": "2.0.0",
"description": "Maps any date to NASA moon phase imagery via synodic and calendar-year cycles. Lightweight npm package images served via CDN or self-hosted from the GitHub repository.", "description": "Maps any date to NASA moon phase imagery via synodic and calendar-year cycles. Lightweight npm package \u2014 images served via CDN or self-hosted from the GitHub repository.",
"author": "Aric Camarata", "author": "Aric Camarata",
"license": "MIT", "license": "MIT",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
@ -9,15 +9,11 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.cjs" "import": "./dist/index.mjs",
} "require": "./dist/index.cjs"
} },
"./package.json": "./package.json"
}, },
"files": [ "files": [
"dist/", "dist/",
@ -29,9 +25,15 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src/",
"format": "prettier --write src/ test.mjs test-cjs.cjs eslint.config.mjs tsup.config.ts",
"format:check": "prettier --check src/ test.mjs test-cjs.cjs eslint.config.mjs tsup.config.ts",
"pretest": "tsup", "pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs", "test": "node --test test.mjs && node --test test-cjs.cjs",
"prepublishOnly": "tsup" "prepack": "pnpm run build",
"coverage": "c8 --reporter=lcov --reporter=text node --test",
"docs": "typedoc --out .github/wiki/api src/index.ts",
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@ -62,8 +64,25 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"devDependencies": { "devDependencies": {
"@acamarata/eslint-config": "^0.1.0",
"@acamarata/prettier-config": "^0.1.0",
"@acamarata/telemetry": "^0.1.0",
"@acamarata/tsconfig": "^0.1.0",
"@eslint/js": "^10.0.1",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "^10.1.3",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.0.0", "tsup": "^8.0.0",
"typescript": "^5.0.0" "typedoc": "^0.28.19",
} "typedoc-plugin-markdown": "^4.11.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.56.1"
},
"type": "module",
"packageManager": "pnpm@10.11.1",
"prettier": "@acamarata/prettier-config"
} }

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { SYNODIC_MONTH, MONTH_IMAGES, MONTH_ANCHOR } from './types.js'; import { SYNODIC_MONTH, MONTH_IMAGES, MONTH_ANCHOR } from "./types.js";
const SYNODIC_SECONDS = SYNODIC_MONTH * 24 * 60 * 60; const SYNODIC_SECONDS = SYNODIC_MONTH * 24 * 60 * 60;
@ -14,6 +14,10 @@ const SYNODIC_SECONDS = SYNODIC_MONTH * 24 * 60 * 60;
* @returns A zero-padded filename string, e.g. `"354.webp"`. * @returns A zero-padded filename string, e.g. `"354.webp"`.
*/ */
export function cycleMonth(date: Date = new Date()): string { export function cycleMonth(date: Date = new Date()): string {
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError("date must be a valid Date instance");
}
// Seconds elapsed since the known new moon anchor // Seconds elapsed since the known new moon anchor
const elapsed = (date.getTime() - MONTH_ANCHOR.getTime()) / 1000; const elapsed = (date.getTime() - MONTH_ANCHOR.getTime()) / 1000;
@ -26,5 +30,5 @@ export function cycleMonth(date: Date = new Date()): string {
// Map to 1-indexed image number: 1 to MONTH_IMAGES // Map to 1-indexed image number: 1 to MONTH_IMAGES
const index = Math.floor(fraction * MONTH_IMAGES) + 1; const index = Math.floor(fraction * MONTH_IMAGES) + 1;
return index.toString().padStart(3, '0') + '.webp'; return index.toString().padStart(3, "0") + ".webp";
} }

View file

@ -1,4 +1,4 @@
import { YEAR_IMAGES, YEAR_ANCHOR } from './types.js'; import { YEAR_IMAGES, YEAR_ANCHOR } from "./types.js";
/** /**
* Maps a date to the corresponding NASA moon image for the yearly cycle. * Maps a date to the corresponding NASA moon image for the yearly cycle.
@ -15,6 +15,10 @@ import { YEAR_IMAGES, YEAR_ANCHOR } from './types.js';
* @returns A zero-padded filename string, e.g. `"4380.webp"`. * @returns A zero-padded filename string, e.g. `"4380.webp"`.
*/ */
export function cycleYear(date: Date = new Date()): string { export function cycleYear(date: Date = new Date()): string {
if (!(date instanceof Date) || isNaN(date.getTime())) {
throw new TypeError("date must be a valid Date instance");
}
// Hours elapsed since 2023-01-01T00:00:00Z // Hours elapsed since 2023-01-01T00:00:00Z
const elapsed_hours = (date.getTime() - YEAR_ANCHOR.getTime()) / (1000 * 3600); const elapsed_hours = (date.getTime() - YEAR_ANCHOR.getTime()) / (1000 * 3600);
@ -27,5 +31,5 @@ export function cycleYear(date: Date = new Date()): string {
// Map to 1-indexed image number: 1 to YEAR_IMAGES // Map to 1-indexed image number: 1 to YEAR_IMAGES
const index = Math.floor(fraction * YEAR_IMAGES) + 1; const index = Math.floor(fraction * YEAR_IMAGES) + 1;
return index.toString().padStart(4, '0') + '.webp'; return index.toString().padStart(4, "0") + ".webp";
} }

View file

@ -1,4 +1,4 @@
import { ImageSet, ImageSize, ImageQuality } from './types.js'; import { ImageSet, ImageSize, ImageQuality } from "./types.js";
/** /**
* Returns the folder name for a given image set, size, and quality. * Returns the folder name for a given image set, size, and quality.
@ -38,7 +38,7 @@ export function cdnUrl(
set: ImageSet, set: ImageSet,
size: ImageSize, size: ImageSize,
quality: ImageQuality, quality: ImageQuality,
ref: string = 'main' ref: string = "main",
): string { ): string {
const folder = imageFolder(set, size, quality); const folder = imageFolder(set, size, quality);
return `https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@${ref}/${folder}/${filename}`; return `https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@${ref}/${folder}/${filename}`;

View file

@ -1,5 +1,5 @@
export { cycleMonth } from './cycleMonth.js'; export { cycleMonth } from "./cycleMonth.js";
export { cycleYear } from './cycleYear.js'; export { cycleYear } from "./cycleYear.js";
export { export {
ImageSet, ImageSet,
ImageSize, ImageSize,
@ -9,5 +9,14 @@ export {
YEAR_IMAGES, YEAR_IMAGES,
MONTH_ANCHOR, MONTH_ANCHOR,
YEAR_ANCHOR, YEAR_ANCHOR,
} from './types.js'; } from "./types.js";
export { imageFolder, cdnUrl } from './helpers.js'; export { imageFolder, cdnUrl } from "./helpers.js";
// ── Opt-in anonymous telemetry ────────────────────────────────────────────────
// Off by default. Enable: ACAMARATA_TELEMETRY=1
// What is sent + how to disable: https://github.com/acamarata/telemetry/blob/main/TELEMETRY.md
import("@acamarata/telemetry")
.then(({ track }) => track("load", { package: "moon-cycle", version: "2.0.0" }))
.catch(() => {
// telemetry not installed or disabled — that's fine
});

View file

@ -1,40 +1,101 @@
/** /**
* Image set identifier. * Image set identifier.
* - 'mm' = moon monthly (synodic cycle, 708 images) *
* - 'my' = moon yearly (calendar year, 8,760 images) * - `'mm'` = monthly dataset: 708 hourly images covering one synodic cycle
* - `'my'` = yearly dataset: 8,760 hourly images covering calendar year 2023
*
* @example
* import type { ImageSet } from 'moon-cycle';
* const set: ImageSet = 'mm'; // monthly synodic cycle
*/ */
export type ImageSet = 'mm' | 'my'; export type ImageSet = "mm" | "my";
/** /**
* Image dimension in pixels (square). * Image dimension in pixels (square).
*
* Both values produce square images. Use `256` for thumbnails and smaller
* displays; use `512` for high-DPI or full-size display contexts.
*
* @example
* import type { ImageSize } from 'moon-cycle';
* const size: ImageSize = 256;
*/ */
export type ImageSize = 256 | 512; export type ImageSize = 256 | 512;
/** /**
* WebP compression quality level. * WebP compression quality level.
*
* `75` gives smaller files with minor quality loss. `85` gives higher visual
* fidelity at roughly 1.5x the file size. The difference is most visible on
* large displays or when zoomed in.
*
* @example
* import type { ImageQuality } from 'moon-cycle';
* const quality: ImageQuality = 75; // smaller, faster
*/ */
export type ImageQuality = 75 | 85; export type ImageQuality = 75 | 85;
/** /**
* Length of one synodic month in days. * Length of one synodic month in days.
* Source: IAU mean value (J2000.0 epoch). *
* IAU mean value at J2000.0. Used by `cycleMonth` to divide the elapsed
* time into a fractional position within the current lunar cycle.
*
* @example
* import { SYNODIC_MONTH } from 'moon-cycle';
* // Days old: how far into the current cycle
* const now = new Date();
* const elapsed = (now.getTime() - MONTH_ANCHOR.getTime()) / 86400000;
* const age = ((elapsed % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
*/ */
export const SYNODIC_MONTH = 29.53058821398858; export const SYNODIC_MONTH = 29.53058821398858;
/** Number of images in the monthly (mm) dataset. */ /**
* Number of images in the monthly (`mm`) dataset.
*
* Equals the number of hours in one synodic month, rounded to the nearest
* integer. Filenames range from `"001.webp"` to `"708.webp"`.
*
* @example
* import { MONTH_IMAGES } from 'moon-cycle';
* // Fraction of the way through the synodic cycle
* const index = Math.floor(fraction * MONTH_IMAGES) + 1;
*/
export const MONTH_IMAGES = 708; export const MONTH_IMAGES = 708;
/** Number of images in the yearly (my) dataset. */ /**
* Number of images in the yearly (`my`) dataset.
*
* Equals 365 days × 24 hours. Filenames range from `"0001.webp"` to
* `"8760.webp"`.
*
* @example
* import { YEAR_IMAGES } from 'moon-cycle';
* // Total hours in the yearly dataset
* console.log(YEAR_IMAGES); // 8760
*/
export const YEAR_IMAGES = 8760; export const YEAR_IMAGES = 8760;
/** /**
* Anchor date for the monthly cycle: the 2023-11-13 new moon (UTC). * Anchor date for the monthly cycle: the 2023-11-13 new moon (UTC).
* All synodic phase calculations are derived from this reference point. *
* All synodic phase calculations measure elapsed time from this reference
* point. Confirmed against JPL Horizons ephemeris data.
*
* @example
* import { MONTH_ANCHOR } from 'moon-cycle';
* const ageMs = Date.now() - MONTH_ANCHOR.getTime();
*/ */
export const MONTH_ANCHOR = new Date('2023-11-13T09:27:00Z'); export const MONTH_ANCHOR = new Date("2023-11-13T09:27:00Z");
/** /**
* Anchor date for the yearly cycle: start of the 2023 NASA image collection. * Anchor date for the yearly cycle: start of the 2023 NASA image collection.
* The 8,760 images correspond to one per hour for the full calendar year 2023. *
* The 8,760 images correspond to one per hour for the full calendar year
* 2023. `cycleYear` measures elapsed time from this reference point.
*
* @example
* import { YEAR_ANCHOR } from 'moon-cycle';
* const hours = (Date.now() - YEAR_ANCHOR.getTime()) / 3600000;
*/ */
export const YEAR_ANCHOR = new Date('2023-01-01T00:00:00Z'); export const YEAR_ANCHOR = new Date("2023-01-01T00:00:00Z");

View file

@ -3,9 +3,10 @@
* Verifies CommonJS compatibility of the built package. * Verifies CommonJS compatibility of the built package.
*/ */
'use strict'; "use strict";
const assert = require('node:assert/strict'); const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { const {
cycleMonth, cycleMonth,
cycleYear, cycleYear,
@ -16,64 +17,59 @@ const {
YEAR_IMAGES, YEAR_IMAGES,
MONTH_ANCHOR, MONTH_ANCHOR,
YEAR_ANCHOR, YEAR_ANCHOR,
} = require('./dist/index.cjs'); } = require("./dist/index.cjs");
let passed = 0; describe("CJS exports", () => {
let total = 0; it("all exports are available via require()", () => {
assert.strictEqual(typeof cycleMonth, "function");
function test(name, fn) { assert.strictEqual(typeof cycleYear, "function");
total++; assert.strictEqual(typeof imageFolder, "function");
try { assert.strictEqual(typeof cdnUrl, "function");
fn(); assert.strictEqual(typeof SYNODIC_MONTH, "number");
console.log(`[${name}]... PASS`); assert.strictEqual(typeof MONTH_IMAGES, "number");
passed++; assert.strictEqual(typeof YEAR_IMAGES, "number");
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
process.exitCode = 1;
}
}
test('exports are available via require()', () => {
assert.strictEqual(typeof cycleMonth, 'function');
assert.strictEqual(typeof cycleYear, 'function');
assert.strictEqual(typeof imageFolder, 'function');
assert.strictEqual(typeof cdnUrl, 'function');
assert.strictEqual(typeof SYNODIC_MONTH, 'number');
assert.strictEqual(typeof MONTH_IMAGES, 'number');
assert.strictEqual(typeof YEAR_IMAGES, 'number');
assert(MONTH_ANCHOR instanceof Date); assert(MONTH_ANCHOR instanceof Date);
assert(YEAR_ANCHOR instanceof Date); assert(YEAR_ANCHOR instanceof Date);
}); });
test('cycleMonth at anchor returns 001.webp', () => { it("cycleMonth at anchor returns 001.webp", () => {
assert.strictEqual(cycleMonth(MONTH_ANCHOR), '001.webp'); assert.strictEqual(cycleMonth(MONTH_ANCHOR), "001.webp");
}); });
test('cycleYear at anchor returns 0001.webp', () => { it("cycleYear at anchor returns 0001.webp", () => {
assert.strictEqual(cycleYear(YEAR_ANCHOR), '0001.webp'); assert.strictEqual(cycleYear(YEAR_ANCHOR), "0001.webp");
}); });
test('cycleMonth result format is correct', () => { it("cycleMonth result format is correct", () => {
const result = cycleMonth(); assert.match(cycleMonth(), /^\d{3}\.webp$/);
assert.match(result, /^\d{3}\.webp$/);
}); });
test('cycleYear result format is correct', () => { it("cycleYear result format is correct", () => {
const result = cycleYear(); assert.match(cycleYear(), /^\d{4}\.webp$/);
assert.match(result, /^\d{4}\.webp$/);
}); });
test('imageFolder constructs correct path', () => { it("imageFolder constructs correct path", () => {
assert.strictEqual(imageFolder('mm', 256, 75), 'mm-256-75'); assert.strictEqual(imageFolder("mm", 256, 75), "mm-256-75");
}); });
test('cdnUrl returns expected jsDelivr URL', () => { it("cdnUrl returns expected jsDelivr URL", () => {
const url = cdnUrl('001.webp', 'mm', 256, 75);
assert.strictEqual( assert.strictEqual(
url, cdnUrl("001.webp", "mm", 256, 75),
'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/001.webp' "https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/001.webp",
); );
}); });
console.log(`\n${passed}/${total} tests passed`); it("cycleMonth throws TypeError for invalid Date", () => {
if (passed < total) process.exit(1); assert.throws(() => cycleMonth(new Date("invalid")), {
name: "TypeError",
message: "date must be a valid Date instance",
});
});
it("cycleYear throws TypeError for invalid Date", () => {
assert.throws(() => cycleYear(new Date("invalid")), {
name: "TypeError",
message: "date must be a valid Date instance",
});
});
});

243
test.mjs
View file

@ -1,9 +1,10 @@
/** /**
* ESM test suite for moon-cycle v2. * ESM test suite for moon-cycle v2.
* Uses Node.js built-in assert no test framework required. * Uses Node.js built-in test runner.
*/ */
import assert from 'node:assert/strict'; import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { import {
cycleMonth, cycleMonth,
cycleYear, cycleYear,
@ -14,227 +15,213 @@ import {
YEAR_IMAGES, YEAR_IMAGES,
MONTH_ANCHOR, MONTH_ANCHOR,
YEAR_ANCHOR, YEAR_ANCHOR,
} from './dist/index.mjs'; } from "./dist/index.mjs";
let passed = 0;
let total = 0;
function test(name, fn) {
total++;
try {
fn();
console.log(`[${name}]... PASS`);
passed++;
} catch (err) {
console.error(`[${name}]... FAIL: ${err.message}`);
process.exitCode = 1;
}
}
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
test('SYNODIC_MONTH is correct IAU value', () => { describe("constants", () => {
it("SYNODIC_MONTH is correct IAU value", () => {
assert.strictEqual(SYNODIC_MONTH, 29.53058821398858); assert.strictEqual(SYNODIC_MONTH, 29.53058821398858);
}); });
test('MONTH_IMAGES is 708', () => { it("MONTH_IMAGES is 708", () => {
assert.strictEqual(MONTH_IMAGES, 708); assert.strictEqual(MONTH_IMAGES, 708);
}); });
test('YEAR_IMAGES is 8760', () => { it("YEAR_IMAGES is 8760", () => {
assert.strictEqual(YEAR_IMAGES, 8760); assert.strictEqual(YEAR_IMAGES, 8760);
}); });
test('MONTH_ANCHOR is 2023-11-13T09:27:00Z', () => { it("MONTH_ANCHOR is 2023-11-13T09:27:00Z", () => {
assert.strictEqual(MONTH_ANCHOR.toISOString(), '2023-11-13T09:27:00.000Z'); assert.strictEqual(MONTH_ANCHOR.toISOString(), "2023-11-13T09:27:00.000Z");
}); });
test('YEAR_ANCHOR is 2023-01-01T00:00:00Z', () => { it("YEAR_ANCHOR is 2023-01-01T00:00:00Z", () => {
assert.strictEqual(YEAR_ANCHOR.toISOString(), '2023-01-01T00:00:00.000Z'); assert.strictEqual(YEAR_ANCHOR.toISOString(), "2023-01-01T00:00:00.000Z");
});
}); });
// ─── cycleMonth: return format ──────────────────────────────────────────────── // ─── cycleMonth ──────────────────────────────────────────────────────────────
test('cycleMonth returns a string', () => { describe("cycleMonth", () => {
assert.strictEqual(typeof cycleMonth(), 'string'); it("returns a string", () => {
assert.strictEqual(typeof cycleMonth(), "string");
}); });
test('cycleMonth result matches /^\\d{3}\\.webp$/', () => { it("result matches /^\\d{3}\\.webp$/", () => {
const result = cycleMonth(); assert.match(cycleMonth(), /^\d{3}\.webp$/);
assert.match(result, /^\d{3}\.webp$/);
}); });
test('cycleMonth result is never 000.webp (images are 1-indexed)', () => { it("result is never 000.webp (images are 1-indexed)", () => {
// Test many dates across different lunar phases
const anchor = MONTH_ANCHOR.getTime(); const anchor = MONTH_ANCHOR.getTime();
const synodic_ms = SYNODIC_MONTH * 24 * 60 * 60 * 1000; const synodic_ms = SYNODIC_MONTH * 24 * 60 * 60 * 1000;
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
const date = new Date(anchor + i * (synodic_ms / 100)); const date = new Date(anchor + i * (synodic_ms / 100));
const result = cycleMonth(date); assert.notStrictEqual(cycleMonth(date), "000.webp", `Got 000.webp for offset ${i}`);
assert.notStrictEqual(result, '000.webp', `Got 000.webp for offset ${i}`);
} }
}); });
// ─── cycleMonth: anchor date ────────────────────────────────────────────────── it("at anchor date returns 001.webp (start of synodic cycle)", () => {
assert.strictEqual(cycleMonth(MONTH_ANCHOR), "001.webp");
test('cycleMonth at anchor date returns 001.webp (start of synodic cycle)', () => {
assert.strictEqual(cycleMonth(MONTH_ANCHOR), '001.webp');
}); });
test('cycleMonth one synodic month + 1 min after anchor returns near start of next cycle', () => { it("one synodic month + 1 min after anchor returns near start of next cycle", () => {
// Adding exactly one synodic month in floating-point can land on either side of the
// cycle boundary (001 or 708) due to IEEE 754 rounding. Adding 1 minute steps past it.
const oneMonthPlus = new Date( const oneMonthPlus = new Date(
MONTH_ANCHOR.getTime() + SYNODIC_MONTH * 24 * 60 * 60 * 1000 + 60_000 MONTH_ANCHOR.getTime() + SYNODIC_MONTH * 24 * 60 * 60 * 1000 + 60_000,
); );
const result = cycleMonth(oneMonthPlus); const index = parseInt(cycleMonth(oneMonthPlus).replace(".webp", ""), 10);
const index = parseInt(result.replace('.webp', ''), 10);
assert(index <= 3, `Expected near start of next cycle (index <= 3), got ${index}`); assert(index <= 3, `Expected near start of next cycle (index <= 3), got ${index}`);
}); });
test('cycleMonth at halfway through synodic month returns ~354.webp', () => { it("at halfway through synodic month returns ~354.webp", () => {
const half = new Date( const half = new Date(MONTH_ANCHOR.getTime() + (SYNODIC_MONTH / 2) * 24 * 60 * 60 * 1000);
MONTH_ANCHOR.getTime() + (SYNODIC_MONTH / 2) * 24 * 60 * 60 * 1000 const index = parseInt(cycleMonth(half).replace(".webp", ""), 10);
);
const result = cycleMonth(half);
const index = parseInt(result.replace('.webp', ''), 10);
// Allow ±1 for rounding
assert(index >= 353 && index <= 355, `Expected ~354, got ${index}`); assert(index >= 353 && index <= 355, `Expected ~354, got ${index}`);
}); });
test('cycleMonth result is always in range [001, 708]', () => { it("result is always in range [001, 708]", () => {
// Test dates spanning 5 years const start = new Date("2020-01-01T00:00:00Z");
const start = new Date('2020-01-01T00:00:00Z'); const step = 24 * 60 * 60 * 1000 * 7;
const step = 24 * 60 * 60 * 1000 * 7; // weekly
for (let i = 0; i < 260; i++) { for (let i = 0; i < 260; i++) {
const date = new Date(start.getTime() + i * step); const date = new Date(start.getTime() + i * step);
const result = cycleMonth(date); const index = parseInt(cycleMonth(date).replace(".webp", ""), 10);
const index = parseInt(result.replace('.webp', ''), 10);
assert(index >= 1, `Index ${index} below minimum (date: ${date.toISOString()})`); assert(index >= 1, `Index ${index} below minimum (date: ${date.toISOString()})`);
assert(index <= 708, `Index ${index} above maximum (date: ${date.toISOString()})`); assert(index <= 708, `Index ${index} above maximum (date: ${date.toISOString()})`);
} }
}); });
// ─── cycleMonth: past dates ─────────────────────────────────────────────────── it("handles dates before the anchor (pre-2023)", () => {
const past = new Date("2020-06-15T00:00:00Z");
test('cycleMonth handles dates before the anchor (pre-2023)', () => {
const past = new Date('2020-06-15T00:00:00Z');
const result = cycleMonth(past); const result = cycleMonth(past);
assert.match(result, /^\d{3}\.webp$/); assert.match(result, /^\d{3}\.webp$/);
const index = parseInt(result.replace('.webp', ''), 10); const index = parseInt(result.replace(".webp", ""), 10);
assert(index >= 1 && index <= 708); assert(index >= 1 && index <= 708);
}); });
// ─── cycleYear: return format ───────────────────────────────────────────────── it("with no args returns a valid result", () => {
assert.match(cycleMonth(), /^\d{3}\.webp$/);
test('cycleYear returns a string', () => {
assert.strictEqual(typeof cycleYear(), 'string');
}); });
test('cycleYear result matches /^\\d{4}\\.webp$/', () => { it("throws TypeError for invalid Date", () => {
const result = cycleYear(); assert.throws(() => cycleMonth(new Date("invalid")), {
assert.match(result, /^\d{4}\.webp$/); name: "TypeError",
message: "date must be a valid Date instance",
});
}); });
test('cycleYear result is never 0000.webp (images are 1-indexed)', () => { it("throws TypeError for non-Date input", () => {
assert.throws(() => cycleMonth("2023-01-01"), {
name: "TypeError",
message: "date must be a valid Date instance",
});
});
});
// ─── cycleYear ───────────────────────────────────────────────────────────────
describe("cycleYear", () => {
it("returns a string", () => {
assert.strictEqual(typeof cycleYear(), "string");
});
it("result matches /^\\d{4}\\.webp$/", () => {
assert.match(cycleYear(), /^\d{4}\.webp$/);
});
it("result is never 0000.webp (images are 1-indexed)", () => {
const anchor = YEAR_ANCHOR.getTime(); const anchor = YEAR_ANCHOR.getTime();
const year_ms = YEAR_IMAGES * 60 * 60 * 1000; const year_ms = YEAR_IMAGES * 60 * 60 * 1000;
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
const date = new Date(anchor + i * (year_ms / 100)); const date = new Date(anchor + i * (year_ms / 100));
const result = cycleYear(date); assert.notStrictEqual(cycleYear(date), "0000.webp", `Got 0000.webp for offset ${i}`);
assert.notStrictEqual(result, '0000.webp', `Got 0000.webp for offset ${i}`);
} }
}); });
// ─── cycleYear: anchor date ─────────────────────────────────────────────────── it("at anchor date returns 0001.webp (start of year)", () => {
assert.strictEqual(cycleYear(YEAR_ANCHOR), "0001.webp");
test('cycleYear at anchor date returns 0001.webp (start of year)', () => {
assert.strictEqual(cycleYear(YEAR_ANCHOR), '0001.webp');
}); });
test('cycleYear at exactly one year after anchor returns 0001.webp', () => { it("at exactly one year after anchor returns 0001.webp", () => {
const oneYear = new Date( const oneYear = new Date(YEAR_ANCHOR.getTime() + YEAR_IMAGES * 60 * 60 * 1000);
YEAR_ANCHOR.getTime() + YEAR_IMAGES * 60 * 60 * 1000 assert.strictEqual(cycleYear(oneYear), "0001.webp");
);
assert.strictEqual(cycleYear(oneYear), '0001.webp');
}); });
test('cycleYear at halfway through year returns ~4380.webp', () => { it("at halfway through year returns ~4380.webp", () => {
const half = new Date( const half = new Date(YEAR_ANCHOR.getTime() + (YEAR_IMAGES / 2) * 60 * 60 * 1000);
YEAR_ANCHOR.getTime() + (YEAR_IMAGES / 2) * 60 * 60 * 1000 const index = parseInt(cycleYear(half).replace(".webp", ""), 10);
);
const result = cycleYear(half);
const index = parseInt(result.replace('.webp', ''), 10);
assert(index >= 4379 && index <= 4381, `Expected ~4380, got ${index}`); assert(index >= 4379 && index <= 4381, `Expected ~4380, got ${index}`);
}); });
test('cycleYear result is always in range [0001, 8760]', () => { it("result is always in range [0001, 8760]", () => {
const start = new Date('2020-01-01T00:00:00Z'); const start = new Date("2020-01-01T00:00:00Z");
const step = 24 * 60 * 60 * 1000 * 7; const step = 24 * 60 * 60 * 1000 * 7;
for (let i = 0; i < 260; i++) { for (let i = 0; i < 260; i++) {
const date = new Date(start.getTime() + i * step); const date = new Date(start.getTime() + i * step);
const result = cycleYear(date); const index = parseInt(cycleYear(date).replace(".webp", ""), 10);
const index = parseInt(result.replace('.webp', ''), 10);
assert(index >= 1, `Index ${index} below minimum`); assert(index >= 1, `Index ${index} below minimum`);
assert(index <= 8760, `Index ${index} above maximum`); assert(index <= 8760, `Index ${index} above maximum`);
} }
}); });
test('cycleYear handles dates before 2023', () => { it("handles dates before 2023", () => {
const past = new Date('2021-06-15T00:00:00Z'); const past = new Date("2021-06-15T00:00:00Z");
const result = cycleYear(past); const result = cycleYear(past);
assert.match(result, /^\d{4}\.webp$/); assert.match(result, /^\d{4}\.webp$/);
const index = parseInt(result.replace('.webp', ''), 10); const index = parseInt(result.replace(".webp", ""), 10);
assert(index >= 1 && index <= 8760); assert(index >= 1 && index <= 8760);
}); });
// ─── cycleMonth/cycleYear default parameter ─────────────────────────────────── it("with no args returns a valid result", () => {
assert.match(cycleYear(), /^\d{4}\.webp$/);
test('cycleMonth() with no args returns a valid result', () => {
const result = cycleMonth();
assert.match(result, /^\d{3}\.webp$/);
}); });
test('cycleYear() with no args returns a valid result', () => { it("throws TypeError for invalid Date", () => {
const result = cycleYear(); assert.throws(() => cycleYear(new Date("invalid")), {
assert.match(result, /^\d{4}\.webp$/); name: "TypeError",
message: "date must be a valid Date instance",
});
}); });
// ─── imageFolder ────────────────────────────────────────────────────────────── it("throws TypeError for non-Date input", () => {
assert.throws(() => cycleYear("2023-01-01"), {
test('imageFolder returns correct folder name', () => { name: "TypeError",
assert.strictEqual(imageFolder('mm', 256, 75), 'mm-256-75'); message: "date must be a valid Date instance",
assert.strictEqual(imageFolder('mm', 512, 85), 'mm-512-85'); });
assert.strictEqual(imageFolder('my', 256, 75), 'my-256-75'); });
assert.strictEqual(imageFolder('my', 512, 85), 'my-512-85');
}); });
// ─── cdnUrl ─────────────────────────────────────────────────────────────────── // ─── imageFolder ─────────────────────────────────────────────────────────────
test('cdnUrl returns a valid jsDelivr URL', () => { describe("imageFolder", () => {
const url = cdnUrl('354.webp', 'mm', 256, 75); it("returns correct folder name", () => {
assert.strictEqual(imageFolder("mm", 256, 75), "mm-256-75");
assert.strictEqual(imageFolder("mm", 512, 85), "mm-512-85");
assert.strictEqual(imageFolder("my", 256, 75), "my-256-75");
assert.strictEqual(imageFolder("my", 512, 85), "my-512-85");
});
});
// ─── cdnUrl ──────────────────────────────────────────────────────────────────
describe("cdnUrl", () => {
it("returns a valid jsDelivr URL", () => {
assert.strictEqual( assert.strictEqual(
url, cdnUrl("354.webp", "mm", 256, 75),
'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp' "https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/354.webp",
); );
}); });
test('cdnUrl respects custom ref parameter', () => { it("respects custom ref parameter", () => {
const url = cdnUrl('001.webp', 'my', 512, 85, 'v2.0.0');
assert.strictEqual( assert.strictEqual(
url, cdnUrl("001.webp", "my", 512, 85, "v2.0.0"),
'https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@v2.0.0/my-512-85/001.webp' "https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@v2.0.0/my-512-85/001.webp",
); );
}); });
test('cdnUrl integrates with cycleMonth output', () => { it("integrates with cycleMonth output", () => {
const filename = cycleMonth(MONTH_ANCHOR); const filename = cycleMonth(MONTH_ANCHOR);
const url = cdnUrl(filename, 'mm', 256, 75); const url = cdnUrl(filename, "mm", 256, 75);
assert(url.startsWith('https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/')); assert(url.startsWith("https://cdn.jsdelivr.net/gh/acamarata/moon-cycle@main/mm-256-75/"));
assert(url.endsWith('.webp')); assert(url.endsWith(".webp"));
});
}); });
// ─── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${passed}/${total} tests passed`);
if (passed < total) process.exit(1);

View file

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

View file

@ -1,14 +1,14 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/index.ts"],
format: ['cjs', 'esm'], format: ["cjs", "esm"],
dts: true, dts: true,
clean: true, clean: true,
outDir: 'dist', outDir: "dist",
splitting: false, splitting: false,
sourcemap: true, sourcemap: true,
target: 'es2020', target: "es2020",
platform: 'neutral', platform: "neutral",
outExtension: ({ format }) => ({ js: format === 'cjs' ? '.cjs' : '.mjs' }), outExtension: ({ format }) => ({ js: format === "cjs" ? ".cjs" : ".mjs" }),
}); });

10
typedoc.json Normal file
View file

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