diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 829cf3f..aa7382d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: # run never looks at src/python.rs. cargo clippy --features python --all-targets -- -D warnings cargo clippy --features capi --all-targets -- -D warnings + rustup target add wasm32-unknown-unknown + cargo clippy --lib --features wasm --target wasm32-unknown-unknown -- -D warnings - name: Rust tests run: | @@ -97,6 +99,22 @@ jobs: EOF /tmp/venv/bin/python -m mypy --strict /tmp/typecheck.py + # The npm package is the same engine again, so it has to agree with the + # command line again. The test compares a year of days field by field + # for four jurisdictions, and type-checks the published .d.ts the way the + # Python bindings are type-checked. + - name: JavaScript package + run: | + apt-get update && apt-get install -y --no-install-recommends nodejs npm + version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock) + curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \ + | tar xz -C /tmp + export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH" + npm install --global typescript@5 + cargo build --release + npm/build.sh + node npm/test.mjs + - name: README examples run: | cargo build --release diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 1a23ecb..75bde4e 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -59,6 +59,19 @@ jobs: maturin sdist --out dist ls -l dist + - name: Build the npm package + run: | + apt-get install -y --no-install-recommends nodejs npm + rustup target add wasm32-unknown-unknown + version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock) + curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \ + | tar xz -C /tmp + export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH" + npm install --global typescript@5 + cargo build --release + npm/build.sh + node npm/test.mjs + - name: Publish env: # A project-scoped token, not the account-wide one. PyPI's trusted @@ -77,3 +90,23 @@ jobs: # instead of dying on the first file. A version cannot be reused, # so a half-published release would otherwise burn it. /tmp/venv/bin/twine upload --non-interactive --skip-existing dist/* + + - name: Publish to npm + env: + # Scoped to this package, for the same reason the PyPI token is + # scoped to this project: npm has trusted publishing too, and it does + # not know this forge either. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "::warning::NPM_TOKEN is not set; skipping the npm publish" + exit 0 + fi + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc + # A version cannot be reused on npm either, so a re-run after a + # partial release must not die on the version already being there. + npm publish --access public npm/dist || { + npm view "typikon@${GITHUB_REF_NAME#v}" version >/dev/null 2>&1 \ + && echo "::notice::already published" \ + || exit 1 + } diff --git a/.gitignore b/.gitignore index 1e973c0..1d53c04 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /dist __pycache__/ *.py[cod] +/npm/dist +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d0b2a..fd782c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ does. the usage and the discipline. The Python bindings and the C ABI now share it rather than each keeping a copy, and it interns the combinations instead of leaking one per call. +- A JavaScript and TypeScript package, behind the `wasm` feature and published + to npm as `typikon`. One WebAssembly build serves Node, Deno, Bun and the + browser. A day comes back as a plain object rather than a handle: the same + document `--json` emits, with nothing to free. The types are hand-written + rather than inferred, and `tsc --strict` is a release gate the way `mypy + --strict` already is. There is no remembered default jurisdiction there, + because a browser has nowhere to keep one; the default is the New Calendar. ## 1.0.0 diff --git a/Cargo.lock b/Cargo.lock index eb32428..ae5f3df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anstream" @@ -58,6 +58,18 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "clap" version = "4.6.7" @@ -104,6 +116,30 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "heck" version = "0.5.0" @@ -131,6 +167,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -164,6 +211,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -265,6 +318,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -298,6 +362,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -337,9 +407,12 @@ name = "typikon" version = "1.0.0" dependencies = [ "clap", + "js-sys", "pyo3", "serde", + "serde-wasm-bindgen", "serde_json", + "wasm-bindgen", ] [[package]] @@ -360,6 +433,51 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index de62a33..f449645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,12 @@ serde_json = "1" clap = { version = "4", features = ["derive"] } pyo3 = { version = "0.27.2", features = ["extension-module", "abi3-py39"], optional = true } +# The JavaScript build. `js-sys` is here only for the clock: WebAssembly has no +# time of its own, so `today()` has to ask the host what day it is. +wasm-bindgen = { version = "0.2", optional = true } +serde-wasm-bindgen = { version = "0.6", optional = true } +js-sys = { version = "0.3", optional = true } + [profile.release] strip = true lto = true @@ -38,3 +44,6 @@ python = ["dep:pyo3"] # The C ABI. Off by default so an ordinary `cargo build` of the CLI does not # also compile and link a shared library nobody asked for. capi = [] +# The JavaScript and TypeScript build, via WebAssembly. Only ever compiled for +# a wasm32 target. +wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys"] diff --git a/README.md b/README.md index df98ab9..78ac705 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,15 @@ no network either. Wheels are built for Linux on x86-64 and aarch64; everywhere else pip falls back to the source distribution, which needs a Rust toolchain of 1.85 or newer on the installing machine. +For JavaScript and TypeScript, there is a package: + +```console +$ npm install typikon +``` + +WebAssembly, so the one package serves Node, Deno, Bun and the browser with no +native build to go wrong. + ## Which calendar? Three things get conflated constantly, and keeping them apart is most of what @@ -455,6 +464,45 @@ day.title -- "The Holy Prophet Zachariah, father of the Forerunner" day.commemorations ``` +## From JavaScript and TypeScript + +```sh +npm install typikon +``` + +```js +import { today } from "typikon"; + +const day = today({ jurisdiction: "rocor" }); + +day.churchDate; // "5 September" +day.fast; // "xerophagy" +day.emoji; // the glyph for the level +day.title; // "The Holy Prophet Zachariah, father of the Forerunner" +day.season; // null in ordinary time +day.daysToPascha; +``` + +```js +import { describe, describeIso, week, pascha, jurisdictions } from "typikon"; + +describeIso("2026-04-12", { jurisdiction: "moscow" }); +describe(new Date(), { jurisdiction: "goarch", tradition: "greek" }); +week(new Date(), 7, { jurisdiction: "oca" }); // seven consecutive days +pascha(2027, "old"); // 2027-05-02 +``` + +It is WebAssembly, so it is one package for Node, Deno, Bun, a browser and +anything built on them, with no native build to go wrong. Node loads it itself; +in a browser or a bundler, `await init()` first. A day comes back as a plain +object, not a handle: the same document `--json` emits, with nothing to free +and `JSON.stringify` working on it. TypeScript types ship with the package and +`tsc --strict` is clean against them. + +There is no remembered default here, because a browser has nowhere to keep one: +the default is the New Calendar, and anything else is passed. That is the only +place this binding differs from the others. + ## The menologion The fixed calendar ships as data and is indexed by **ecclesiastical** date, so diff --git a/docs/releasing.md b/docs/releasing.md index afbcb8e..b7cb533 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -91,6 +91,17 @@ every package the account owns. Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect. +### 4. An npm token + +Also under **funkpower**, also scoped to the one package, added as the +repository secret `NPM_TOKEN`. Make it a **granular access token** with write +on `typikon` alone; a classic automation token is account-wide. + +npm has trusted publishing too, and it does not know this forge either, so the +same tradeoff applies. Without the secret the release still succeeds and the +npm step warns and skips, so the first release of a version after the token +lapses is a PyPI release and nothing else. Check the run. + ## What gets published | | | @@ -102,6 +113,12 @@ Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect. abi3, so one wheel per platform covers every Python from 3.9 up rather than one per interpreter. +And on npm, one package with two WebAssembly builds in it: `node/` for Node, +which loads the module itself, and `web/` for browsers and bundlers, which +cannot compile WebAssembly synchronously on the main thread and so has to be +awaited. The exports map picks between them. There are no per-platform +artefacts, because WebAssembly has no platforms. + **No macOS or Windows wheels.** Forgejo has no hosted runners, so those need a Mac and a Windows box registered as runners. Until then both fall back to the sdist, which needs a Rust toolchain on the installing machine — fine for @@ -119,3 +136,17 @@ $ /tmp/v/bin/python -c 'import typikon, datetime; print(typikon.today())' Installing from the sdist with `--no-binary` is the one that catches a missing file, because it builds the crate from exactly what PyPI would hand someone. + +For the npm package: + +```console +$ npm/build.sh && node npm/test.mjs +$ npm pack --dry-run npm/dist +``` + +`npm/test.mjs` resolves `typikon` through a staged `node_modules`, so it tests +the exports map and the built files rather than a lucky relative import. It +compares a year of days field by field against the command line, runs the web +build the way a browser would, and type-checks the published `.d.ts` under +`tsc --strict`. It needs `wasm-bindgen` on PATH at the version `Cargo.lock` +pins, and `tsc` for the last of those. diff --git a/docs/versioning.md b/docs/versioning.md index 0026212..103de11 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -72,6 +72,27 @@ and in Python, the module's functions and the attributes of `Day`. `Day.to_dict()` in Python carries the same fields as the JSON, in snake_case rather than camelCase, which is the one deliberate difference between them. +### The C ABI + +Every `typikon_*` symbol declared in `include/typikon.h`, and the meaning of +what it returns. `typikon_abi_version()` is bumped when any of that changes; +additions leave it alone, so a caller can require a minimum and get everything +after it. + +Dates cross as Julian Day Numbers and a day is opaque, so the ABI carries no +struct layout and a rebuilt library stays loadable against a header that has +not moved. + +### The JavaScript package + +The functions the npm package exports, and the `Day`, `Options` and +`JurisdictionInfo` types in its `.d.ts`. A day is the JSON above, with the same +field names; `undefined` never appears where the JSON has `null`. + +The one deliberate difference from every other binding: there is no remembered +default jurisdiction, because a browser has nowhere to keep one. The default is +the New Calendar. + ## What is not stable - **The text output.** It is for a person to read, and it changes when a better @@ -98,8 +119,8 @@ So: **every change to what a day reports is called out in the changelog under "Calendar"**, with the days affected, whatever the version number does. If a correction changes a day you care about, the changelog is where it will say so. -Adding a jurisdiction, a menaion or a tradition is a minor version. Correcting -an existing one is a patch, and is announced. +Adding a jurisdiction, a menaion, a tradition or a binding is a minor version. +Correcting an existing one is a patch, and is announced. ## Releasing diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..7173659 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,84 @@ +# typikon + +The Orthodox liturgical day: feasts, seasons and the fast, computed rather +than fetched. No network, no data files, no service to be up. + +```sh +npm install typikon +``` + +```js +import { today } from "typikon"; + +const day = today({ jurisdiction: "rocor" }); + +day.churchDate; // "5 September" +day.fast; // "xerophagy" +day.fastLabel; // "xerophagy — no oil or wine" +day.emoji; // the glyph for the level +day.title; // "The Holy Prophet Zachariah, father of the Forerunner" +day.season; // null in ordinary time +day.daysToPascha; +``` + +In a browser or a bundler, load the module first: + +```js +import init, { today } from "typikon"; + +await init(); +const day = today({ jurisdiction: "rocor" }); +``` + +Node needs no `init`, because it can read the WebAssembly itself. The exports +map picks the right build; `typikon/node` and `typikon/web` force one. + +## What comes back + +A plain object, not a handle: the same document `typikon --json` emits, with +the same field names and the same stability promise. Nothing has to be freed +and `JSON.stringify` works on it. TypeScript types ship with the package. + +## The rest of it + +```js +import { describe, describeIso, week, pascha, jurisdictions } from "typikon"; + +describe(new Date(2026, 3, 12), { jurisdiction: "moscow" }); +describeIso("2026-04-12", { jurisdiction: "moscow" }); + +week(new Date(), 7, { jurisdiction: "goarch" }); // seven consecutive days +pascha(2027, "old"); // 2027-05-02 +jurisdictions(); // [{ key, name, note }, ...] +``` + +`week` reckons the year's movable cycle once rather than once a day, so it is +the thing to call for a calendar grid. + +## Which calendar + +`jurisdiction` takes a church key, or a bare calendar where the church does not +matter: `"old"` (Julian throughout), `"new"` (Revised Julian fixed feasts with +the Julian Paschalion, the default) or `"western"` (Gregorian throughout, +Pascha included). `jurisdictions()` lists the rest. Two more options: + +- `tradition`, `"slavic"` or `"greek"`, overriding the church's own usage. It + moves fish in the Nativity Fast and the rank of the Protection. +- `typikon: true`, to show a day at the Typikon's full discipline rather than + at the minimum a church's own particular law requires. What binds is reported + either way, in `binds` and `obligation`. This is for the Byzantine Catholic + churches, whose law asks very much less than the tradition they keep. + +Unlike the command line and the Python bindings, there is no remembered +default: a browser has nowhere to keep one, so the default is the New Calendar +and anything else is passed. + +## Accuracy + +The Paschalion is exact. The fast follows the Typikon as the standard printed +calendars apply it, which is the part where careful people differ; a parish +calendar or a confessor overrides this, and neither is a bug report. + +## Licence + +GPL-2.0-only. Source: https://git.pumpin.online/funkpower/typikon diff --git a/npm/build.sh b/npm/build.sh new file mode 100755 index 0000000..7ac1aa8 --- /dev/null +++ b/npm/build.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build the npm package into npm/dist. +# +# npm/build.sh # needs wasm-bindgen-cli at the version below +# WASM_BINDGEN=/path/to/wasm-bindgen npm/build.sh +# +# Two builds go in: `node/` for Node, which loads the module itself, and +# `web/` for browsers and bundlers, which cannot compile WebAssembly +# synchronously on the main thread and so has to be awaited. The exports map +# in package.json picks between them. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" +bindgen="${WASM_BINDGEN:-wasm-bindgen}" +dist="npm/dist" + +command -v "$bindgen" >/dev/null || { + echo "wasm-bindgen is not on PATH. cargo install wasm-bindgen-cli --version \ +$(sed -n 's/^wasm-bindgen = { version = "\(.*\)".*/\1/p' Cargo.toml)" >&2 + exit 1 +} + +cargo build --lib --release --target wasm32-unknown-unknown --features wasm + +rm -rf "$dist" +for target in nodejs web; do + out="$dist/$([ "$target" = nodejs ] && echo node || echo web)" + "$bindgen" --target "$target" --out-dir "$out" --out-name typikon \ + target/wasm32-unknown-unknown/release/typikon.wasm + # wasm-bindgen writes an overridden parameter type without the optional + # marker, so an options argument nobody wants to pass has to be passed as + # `undefined`. The override is what gives the argument a real type at all; + # this restores the `?` it costs. + sed -i 's/options: Options/options?: Options/g' "$out/typikon.d.ts" +done + +# wasm-opt, where it is to hand. Worth about a fifth of the file. +if command -v wasm-opt >/dev/null; then + for w in "$dist"/*/typikon_bg.wasm; do + wasm-opt -Oz -o "$w.opt" "$w" && mv "$w.opt" "$w" + done +fi + +sed "s/@VERSION@/$version/" npm/package.json.in > "$dist/package.json" +# The package is ESM; the Node build is what wasm-bindgen emits for Node, which +# is CommonJS. One file says so for that directory alone. +echo '{ "type": "commonjs" }' > "$dist/node/package.json" +cp npm/README.md "$dist/README.md" +cp LICENSE "$dist/LICENSE" + +echo "built $dist, version $version" +du -h "$dist"/*/typikon_bg.wasm diff --git a/npm/package.json.in b/npm/package.json.in new file mode 100644 index 0000000..7108a62 --- /dev/null +++ b/npm/package.json.in @@ -0,0 +1,49 @@ +{ + "name": "typikon", + "version": "@VERSION@", + "description": "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched", + "license": "GPL-2.0-only", + "homepage": "https://git.pumpin.online/funkpower/typikon", + "repository": { + "type": "git", + "url": "git+https://git.pumpin.online/funkpower/typikon.git" + }, + "keywords": [ + "orthodox", + "liturgical", + "calendar", + "byzantine", + "pascha", + "fasting", + "wasm" + ], + "type": "module", + "types": "./web/typikon.d.ts", + "exports": { + ".": { + "node": { + "types": "./node/typikon.d.ts", + "default": "./node/typikon.js" + }, + "default": { + "types": "./web/typikon.d.ts", + "default": "./web/typikon.js" + } + }, + "./node": { + "types": "./node/typikon.d.ts", + "default": "./node/typikon.js" + }, + "./web": { + "types": "./web/typikon.d.ts", + "default": "./web/typikon.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "node/", + "web/", + "README.md", + "LICENSE" + ] +} diff --git a/npm/test.mjs b/npm/test.mjs new file mode 100644 index 0000000..fe40c4c --- /dev/null +++ b/npm/test.mjs @@ -0,0 +1,218 @@ +// The npm package, exercised as a consumer would use it, against the same +// answers the command line gives. +// +// npm/build.sh && node npm/test.mjs +// +// It imports by package path through a temporary node_modules link, so what is +// tested is the exports map and the built files, not a lucky relative import. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, symlinkSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +const cli = join(root, "target", "release", "typikon"); + +const stage = mkdtempSync(join(tmpdir(), "typikon-npm-")); +mkdirSync(join(stage, "node_modules")); +symlinkSync(join(root, "npm", "dist"), join(stage, "node_modules", "typikon"), "dir"); + +// Resolve "typikon" from inside the staged directory, so the exports map is +// what decides which build loads. +const { createRequire } = await import("node:module"); +const req = createRequire(join(stage, "probe.js")); +const entry = req.resolve("typikon"); +const typikon = await import(pathToFileURL(entry).href); + +let failures = 0; +function check(name, fn) { + try { + fn(); + console.log(` ok ${name}`); + } catch (e) { + failures++; + console.log(` FAIL ${name}\n ${e.message}`); + } +} + +console.log(`typikon ${typikon.version()} from ${entry}`); + +check("node resolves to the node build", () => { + assert.match(entry, /dist\/node\/typikon\.js$/); +}); + +check("a day is a plain object, not a handle", () => { + const day = typikon.describeIso("2026-04-12", { jurisdiction: "moscow" }); + assert.equal(Object.getPrototypeOf(day), Object.prototype); + assert.equal(typeof day.free, "undefined"); + assert.doesNotThrow(() => JSON.stringify(day)); +}); + +check("it answers what the command line answers", () => { + for (const [iso, jurisdiction] of [ + ["2026-04-12", "moscow"], + ["2026-01-07", "goarch"], + ["2026-02-16", "ruthenian"], + ["2025-12-25", "old"], + ["2026-08-15", "antiochian"], + ]) { + const expected = JSON.parse( + execFileSync(cli, ["--date", iso, "--jurisdiction", jurisdiction, "--json"], { + encoding: "utf8", + env: { ...process.env, XDG_CONFIG_HOME: stage, XDG_DATA_HOME: stage }, + }), + ); + const got = typikon.describeIso(iso, { jurisdiction }); + assert.deepEqual(got, expected, `${iso} ${jurisdiction}`); + } +}); + +check("a year of days matches the command line, day for day", () => { + // Five dates prove the wiring; a year proves the engine crossed the boundary + // whole. The CLI emits an array for --days, so this is one process per + // jurisdiction rather than one per day. + for (const [jurisdiction, opts] of [ + ["moscow", {}], + ["goarch", {}], + ["ruthenian", { typikon: true }], + ["finnish", {}], + ]) { + const args = ["--date", "2026-01-01", "--days", "365", "--jurisdiction", jurisdiction, "--json"]; + if (opts.typikon) args.push("--typikon"); + const expected = JSON.parse( + execFileSync(cli, args, { + encoding: "utf8", + maxBuffer: 64 << 20, + env: { ...process.env, XDG_CONFIG_HOME: stage, XDG_DATA_HOME: stage }, + }), + ).days; // a run of days is wrapped, a single day is not + const got = typikon.week(new Date(2026, 0, 1), 365, { jurisdiction, ...opts }); + assert.equal(got.length, expected.length, jurisdiction); + for (let i = 0; i < expected.length; i++) { + assert.deepEqual(got[i], expected[i], `${jurisdiction} ${expected[i].date}`); + } + } +}); + +check("a Date and an ISO string agree", () => { + // JS months are zero-based, which is the single easiest thing to get wrong + // on this boundary. + const a = typikon.describe(new Date(2026, 3, 12), { jurisdiction: "moscow" }); + const b = typikon.describeIso("2026-04-12", { jurisdiction: "moscow" }); + assert.deepEqual(a, b); +}); + +check("options are optional", () => { + const day = typikon.today(); + assert.equal(day.jurisdiction, "new"); +}); + +check("a week is consecutive and matches day by day", () => { + const days = typikon.week(new Date(2026, 3, 12), 7, { jurisdiction: "moscow" }); + assert.equal(days.length, 7); + assert.equal(days[0].date, "2026-04-12"); + assert.equal(days[6].date, "2026-04-18"); + for (const d of days) { + assert.deepEqual(d, typikon.describeIso(d.date, { jurisdiction: "moscow" })); + } +}); + +check("pascha matches the published dates", () => { + const iso = (d) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + assert.equal(iso(typikon.pascha(2026, "old")), "2026-04-12"); + assert.equal(iso(typikon.pascha(2026, "western")), "2026-04-05"); + assert.equal(iso(typikon.pascha(2027, "old")), "2027-05-02"); +}); + +check("every listed jurisdiction reckons", () => { + const all = typikon.jurisdictions(); + assert.ok(all.length > 20); + for (const j of all) { + assert.equal(typeof j.key, "string"); + assert.equal(typeof j.name, "string"); + const day = typikon.describeIso("2026-04-12", { jurisdiction: j.key }); + assert.equal(day.jurisdiction, j.key); + } +}); + +check("the discipline flag moves what is shown but not what binds", () => { + const law = typikon.describeIso("2026-03-04", { jurisdiction: "ruthenian", typikon: false }); + const full = typikon.describeIso("2026-03-04", { jurisdiction: "ruthenian", typikon: true }); + assert.notEqual(law.fast, full.fast); + assert.equal(law.binds, full.binds); +}); + +check("the usage can be overridden", () => { + const greek = typikon.describeIso("2025-12-09", { jurisdiction: "old", tradition: "greek" }); + const slavic = typikon.describeIso("2025-12-09", { jurisdiction: "old", tradition: "slavic" }); + assert.equal(greek.tradition, "greek"); + assert.equal(slavic.tradition, "slavic"); + assert.notEqual(greek.fast, slavic.fast); +}); + +check("a jurisdiction nobody keeps throws", () => { + assert.throws(() => typikon.describeIso("2026-04-12", { jurisdiction: "shire" }), /shire/); +}); + +check("a date outside the years throws", () => { + assert.throws(() => typikon.describeIso("not-a-date")); + assert.throws(() => typikon.describe(new Date(-5000, 0, 1))); +}); + +check("the web build is there", () => { + assert.match(req.resolve("typikon/web"), /dist\/web\/typikon\.js$/); +}); + +// The web build, driven the way a browser drives it. Node can hand it the +// bytes, which is enough to prove the glue and the module agree; what it +// cannot prove is that a browser will fetch them, and no test here can. +{ + const webEntry = req.resolve("typikon/web"); + const web = await import(pathToFileURL(webEntry).href); + const { readFileSync } = await import("node:fs"); + await web.default({ + module_or_path: readFileSync(join(dirname(webEntry), "typikon_bg.wasm")), + }); + check("the web build answers what the node build answers", () => { + assert.deepEqual( + web.describeIso("2026-04-12", { jurisdiction: "moscow" }), + typikon.describeIso("2026-04-12", { jurisdiction: "moscow" }), + ); + assert.equal(web.version(), typikon.version()); + }); +} + +// TypeScript, if it is to hand. The Python bindings are gated on `mypy +// --strict`; this is the same gate, and the same reason: a published type is a +// promise, and a promise nobody checks drifts. +{ + const tsc = process.env.TSC ?? "tsc"; + let have = true; + try { + execFileSync(tsc, ["--version"], { stdio: "ignore" }); + } catch { + have = false; + console.log(" skip typescript --strict (no tsc on PATH; set TSC=)"); + } + if (have) { + const { copyFileSync } = await import("node:fs"); + copyFileSync(join(here, "typecheck.ts"), join(stage, "typecheck.ts")); + check("the published types pass tsc --strict", () => { + execFileSync( + tsc, + ["--strict", "--noEmit", "--target", "es2022", "--module", "nodenext", + "--moduleResolution", "nodenext", "typecheck.ts"], + { cwd: stage, stdio: "pipe", encoding: "utf8" }, + ); + }); + } +} + +rmSync(stage, { recursive: true, force: true }); +console.log(failures ? `\n${failures} failed` : "\nall passed"); +process.exit(failures ? 1 : 0); diff --git a/npm/typecheck.ts b/npm/typecheck.ts new file mode 100644 index 0000000..ff4923d --- /dev/null +++ b/npm/typecheck.ts @@ -0,0 +1,44 @@ +// The published types, checked the way a TypeScript consumer would meet them. +// Run by npm/test.mjs. Nothing here is executed; the point is that `tsc +// --strict` accepts it, and rejects the lines below that it should. +import { + describe, + describeIso, + today, + week, + pascha, + jurisdictions, + version, + type Day, + type Options, + type JurisdictionInfo, +} from "typikon"; + +const opts: Options = { jurisdiction: "rocor", tradition: "slavic", typikon: true }; + +const day: Day = describeIso("2026-04-12", opts); +const byDate: Day = describe(new Date(), { jurisdiction: "moscow" }); +const now: Day = today(); + +const churchDate: string = day.churchDate; +const season: string | null = day.season; +const commemorations: string[] = day.commemorations; +const daysToPascha: number = day.daysToPascha; + +const seven: Day[] = week(new Date(), 7, opts); +const p: Date = pascha(2027, "old"); +const all: JurisdictionInfo[] = jurisdictions(); +const v: string = version(); + +// Referenced so nothing is unused, which --strict would otherwise let pass but +// a linter would not. +export const _ = { byDate, now, churchDate, season, commemorations, daysToPascha, seven, p, all, v }; + +// @ts-expect-error a season may be absent, so it is not a plain string +const wrong: string = day.season; +// @ts-expect-error there is no such option +const badOption: Options = { jurisdictionn: "rocor" }; +// @ts-expect-error days is a number +const badWeek = week(new Date(), "7", opts); + +export const __ = { wrong, badOption, badWeek }; diff --git a/src/lib.rs b/src/lib.rs index dfa2211..e6b911c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,9 @@ mod python; #[cfg(feature = "capi")] pub mod capi; +#[cfg(feature = "wasm")] +pub mod wasm; + /// Today, from the system clock, in local time. /// /// The local offset comes from `date +%z`, because working it out from diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 0000000..3ae7552 --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,259 @@ +//! JavaScript and TypeScript, through WebAssembly. +//! +//! # Shape +//! +//! A day comes back as a **plain object**, not a handle: the same document the +//! command line emits under `--json`, with the same field names and the same +//! stability promise. Nothing has to be freed, `JSON.stringify` works, and it +//! spreads into React state without ceremony. A wasm class would have meant a +//! `.free()` that every caller eventually forgets. +//! +//! # The clock and the config file +//! +//! WebAssembly has neither. `today()` asks the host what day it is, in the +//! host's own time zone, which is what a calendar wants. There is no config +//! file to remember a default jurisdiction in, so the default here is the New +//! Calendar and a caller who wants another passes it. That is the one place +//! this binding differs from the others, and it differs because a browser has +//! nowhere to put the answer. +//! +//! # The menologion +//! +//! The common menologion is compiled in, so the module is self-contained and +//! needs no network. The supplementary sets the command line can fetch are not +//! included: a caller that wants one passes it to `setMenaion`. + +use serde::Serialize; +use wasm_bindgen::prelude::*; + +use crate::calendars::Date; +use crate::day::{self, Day}; +use crate::jurisdictions::{self, Jurisdiction}; + +/// Hand-written because the generated `.d.ts` would otherwise say `any`: a +/// binding that types its whole return value as `any` is a binding that +/// TypeScript cannot check. +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &'static str = r#" +/** One day, fully reckoned. The same document `typikon --json` emits. */ +export interface Day { + /** The civil date, as "2026-04-12". */ + date: string; + weekday: string; + /** The key of the church whose reckoning this is. */ + jurisdiction: string; + /** "old", "new" or "western". */ + calendar: string; + /** The ecclesiastical date, as "25 December". */ + churchDate: string; + /** null in ordinary time. */ + season: string | null; + /** The place in the movable cycle, or null outside it. */ + cycle: string | null; + feast: string | null; + /** The rank of the feast, or null where there is none. */ + rank: string | null; + /** The mark the books print beside a day of this rank. */ + sign: string | null; + /** "slavic" or "greek". */ + tradition: string; + /** The feast, or the day's principal commemoration. */ + title: string; + commemorations: string[]; + /** "strict", "xerophagy", "wine_oil", "fish", "dairy" or "none". */ + fast: string; + /** The level as this tradition words it. */ + fastLabel: string; + /** The glyph for the level. */ + emoji: string; + /** Why the day fasts as it does. */ + reason: string; + /** "obligatory", "customary", or null where the church does not legislate. */ + obligation: string | null; + /** The minimum a particular law requires, or null where nothing binds. */ + binds: string | null; + /** The civil date of Pascha in this day's own year. */ + pascha: string; + /** Signed, to the Pascha of this day's own civil year. */ + daysToPascha: number; + /** How far the Julian reckoning lags the Gregorian: 13 until 14 March 2100. */ + julianOffset: number; +} + +/** Whose reckoning to keep, and how much of it to show. */ +export interface Options { + /** A jurisdiction key, or a bare calendar: "old", "new", "western". */ + jurisdiction?: string; + /** "slavic" or "greek", overriding the jurisdiction's own usage. */ + tradition?: string; + /** + * True to show the day at the Typikon's full discipline rather than at the + * minimum the church's own particular law requires. What binds is reported + * either way. This is for the Byzantine Catholic churches, whose law asks + * very much less than the tradition they keep. + */ + typikon?: boolean; +} + +export interface JurisdictionInfo { + key: string; + name: string; + note: string; +} +"#; + +/// The options object, as it arrives. +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct Options { + jurisdiction: Option, + tradition: Option, + typikon: Option, +} + +impl Options { + /// The named jurisdiction, or the New Calendar, with the usage and the + /// discipline applied. + fn resolve(&self) -> Result<&'static Jurisdiction, JsError> { + jurisdictions::resolve_full( + Some(self.jurisdiction.as_deref().unwrap_or("new")), + self.tradition.as_deref(), + self.typikon, + ) + .map_err(|e| JsError::new(&e)) + } +} + +fn parse_options(value: Option) -> Result { + let Some(value) = value else { return Ok(Options::default()) }; + serde_wasm_bindgen::from_value(value.into()).map_err(|e| JsError::new(&e.to_string())) +} + +/// Serialize with plain JS objects and arrays rather than Maps, and an absent +/// field as `null` rather than `undefined`. +/// +/// Both defaults would otherwise break the promise that this is the document +/// `--json` emits: `JSON.stringify` drops a key whose value is `undefined`, so +/// a day with no feast would round-trip without a `feast` key at all, and the +/// declared type says `string | null`. +fn to_js(value: &T) -> Result { + let ser = serde_wasm_bindgen::Serializer::new() + .serialize_maps_as_objects(true) + .serialize_missing_as_null(true); + value + .serialize(&ser) + .map_err(|e| JsError::new(&e.to_string())) +} + +fn day_to_js(d: &Day) -> Result { + to_js(&d.to_json()) +} + +/// A JS `Date`, read in the host's own time zone. +/// +/// The liturgical day turns at local midnight, so reading UTC would report +/// yesterday's fast all evening for anyone west of Greenwich. +fn from_js_date(d: &js_sys::Date) -> Result { + Date::from_ymd( + d.get_full_year() as i32, + d.get_month() + 1, // JS months are zero-based + d.get_date(), + ) + .ok_or_else(|| JsError::new("that is not a date this calendar reaches")) +} + +fn to_js_date(d: Date) -> js_sys::Date { + let (y, m, day) = d.ymd(); + js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, day as i32) +} + +// ──────────────────────────────── the api ───────────────────────────────── + +/// One day, fully reckoned. +#[wasm_bindgen(unchecked_return_type = "Day")] +pub fn describe( + date: &js_sys::Date, + #[wasm_bindgen(unchecked_param_type = "Options")] options: Option, +) -> Result { + let opts = parse_options(options)?; + day_to_js(&day::describe(from_js_date(date)?, opts.resolve()?)) +} + +/// One day, from an ISO date, as "2026-04-12". +#[wasm_bindgen(js_name = describeIso, unchecked_return_type = "Day")] +pub fn describe_iso( + date: &str, + #[wasm_bindgen(unchecked_param_type = "Options")] options: Option, +) -> Result { + let opts = parse_options(options)?; + let d = Date::parse_iso(date) + .ok_or_else(|| JsError::new(&format!("{date:?} is not a date this calendar reaches")))?; + day_to_js(&day::describe(d, opts.resolve()?)) +} + +/// Today, by the host's clock and in the host's time zone. +#[wasm_bindgen(unchecked_return_type = "Day")] +pub fn today( + #[wasm_bindgen(unchecked_param_type = "Options")] options: Option, +) -> Result { + describe(&js_sys::Date::new_0(), options) +} + +/// `days` consecutive days from `start`, which is what a week view wants and +/// what it would otherwise get by calling `describe` seven times and paying +/// for the year's movable cycle each time. +#[wasm_bindgen(unchecked_return_type = "Day[]")] +pub fn week( + start: &js_sys::Date, + days: i32, + #[wasm_bindgen(unchecked_param_type = "Options")] options: Option, +) -> Result { + if !(1..=36600).contains(&days) { + return Err(JsError::new("days must be between 1 and 36600")); + } + let opts = parse_options(options)?; + let out = day::week(from_js_date(start)?, days, opts.resolve()?); + let arr = js_sys::Array::new(); + for d in &out { + arr.push(&day_to_js(d)?); + } + Ok(arr.into()) +} + +/// Pascha in a civil year, by a jurisdiction's paschalion. Tradition and +/// discipline cannot move it, so they are not arguments. +#[wasm_bindgen] +pub fn pascha(year: i32, jurisdiction: Option) -> Result { + let jur = jurisdictions::resolve_full( + Some(jurisdiction.as_deref().unwrap_or("new")), + None, + None, + ) + .map_err(|e| JsError::new(&e))?; + let (movables, _) = day::year_of(year, jur); + Ok(to_js_date(movables.pascha)) +} + +/// Every jurisdiction this build knows: the named churches first, then the +/// three bare calendars. +#[wasm_bindgen(unchecked_return_type = "JurisdictionInfo[]")] +pub fn jurisdictions() -> Result { + #[derive(Serialize)] + struct Info<'a> { + key: &'a str, + name: &'a str, + note: &'a str, + } + let out: Vec = jurisdictions::JURISDICTIONS + .iter() + .chain(jurisdictions::CALENDARS) + .map(|j| Info { key: j.key, name: j.name, note: j.note }) + .collect(); + to_js(&out) +} + +/// The version of the crate this module was built from. +#[wasm_bindgen] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +}