shabbat-gate 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.he.md +190 -90
- package/README.md +194 -91
- package/dist/hebcal.d.ts +79 -4
- package/dist/hebcal.js +98 -23
- package/dist/holdingPage.d.ts +8 -0
- package/dist/holdingPage.js +54 -34
- package/dist/index.d.ts +47 -3
- package/dist/index.js +160 -40
- package/dist/translations.d.ts +29 -0
- package/dist/translations.js +81 -0
- package/package.json +36 -35
package/README.md
CHANGED
|
@@ -1,91 +1,194 @@
|
|
|
1
|
-
# shabbat-gate
|
|
2
|
-
|
|
3
|
-
[קריאה בעברית](README.he.md)
|
|
4
|
-
|
|
5
|
-
Cloudflare Pages / Workers middleware that automatically closes a site to human visitors
|
|
6
|
-
during Shabbat and major Jewish holidays (Israel-observance rules) - while always letting
|
|
7
|
-
search engines and AI crawlers through, so SEO stays unaffected.
|
|
8
|
-
|
|
9
|
-
## Why
|
|
10
|
-
|
|
11
|
-
- **Israel single-day Yom Tov, not diaspora 2-day.** The holiday calendar is fetched from
|
|
12
|
-
Hebcal's free public API with `i=on`, which is critical - without it you'd get the diaspora
|
|
13
|
-
reckoning (an extra blocked day) instead of the correct single-day Yom Tov used in Israel.
|
|
14
|
-
- **Bots always get through.** A broad, case-insensitive user-agent allowlist (Googlebot,
|
|
15
|
-
Bingbot, GPTBot, ClaudeBot, and many others) is checked first, before any other logic runs.
|
|
16
|
-
The gate only ever affects human visitors - crawlers and indexers see the real site 24/7, so
|
|
17
|
-
ranking and AI-search visibility are never impacted by the site being "closed."
|
|
18
|
-
- **Fails open.** Any error (network failure, bad API response, whatever) falls through to the
|
|
19
|
-
real site rather than showing an error page. An accidental block on a regular Tuesday would be
|
|
20
|
-
a real, visible bug; an occasional missed block during a rare error is a minor, invisible one.
|
|
21
|
-
|
|
22
|
-
## Install
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
npm install shabbat-gate
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Usage
|
|
29
|
-
|
|
30
|
-
In a Cloudflare Pages project, add `functions/_middleware.ts`:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
-
|
|
35
|
-
const gate = createShabbatGate({ siteName: 'My Site' });
|
|
36
|
-
|
|
37
|
-
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
# shabbat-gate
|
|
2
|
+
|
|
3
|
+
[קריאה בעברית](README.he.md)
|
|
4
|
+
|
|
5
|
+
Cloudflare Pages / Workers middleware that automatically closes a site to human visitors
|
|
6
|
+
during Shabbat and major Jewish holidays (Israel-observance rules) - while always letting
|
|
7
|
+
search engines and AI crawlers through, so SEO stays unaffected.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
- **Israel single-day Yom Tov, not diaspora 2-day.** The holiday calendar is fetched from
|
|
12
|
+
Hebcal's free public API with `i=on`, which is critical - without it you'd get the diaspora
|
|
13
|
+
reckoning (an extra blocked day) instead of the correct single-day Yom Tov used in Israel.
|
|
14
|
+
- **Bots always get through.** A broad, case-insensitive user-agent allowlist (Googlebot,
|
|
15
|
+
Bingbot, GPTBot, ClaudeBot, and many others) is checked first, before any other logic runs.
|
|
16
|
+
The gate only ever affects human visitors - crawlers and indexers see the real site 24/7, so
|
|
17
|
+
ranking and AI-search visibility are never impacted by the site being "closed."
|
|
18
|
+
- **Fails open.** Any error (network failure, bad API response, whatever) falls through to the
|
|
19
|
+
real site rather than showing an error page. An accidental block on a regular Tuesday would be
|
|
20
|
+
a real, visible bug; an occasional missed block during a rare error is a minor, invisible one.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install shabbat-gate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
In a Cloudflare Pages project, add `functions/_middleware.ts`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
34
|
+
|
|
35
|
+
const gate = createShabbatGate({ siteName: 'My Site' });
|
|
36
|
+
|
|
37
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Using with a plain Worker + Assets binding (not Pages)
|
|
41
|
+
|
|
42
|
+
`createShabbatGate` returns a Pages-Functions-shaped handler (`(context) => Response`), which
|
|
43
|
+
doesn't fit a plain Worker's `fetch(request, env)` signature (there's no `next()`). Use
|
|
44
|
+
`createShabbatGateForWorker` instead - it returns `null` for "let the real site through" and a
|
|
45
|
+
`Response` for "serve the holding page":
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createShabbatGateForWorker } from 'shabbat-gate';
|
|
49
|
+
|
|
50
|
+
const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
async fetch(request: Request, env: { ASSETS: Fetcher }) {
|
|
54
|
+
const blocked = await gate(request);
|
|
55
|
+
return blocked ?? env.ASSETS.fetch(request);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Gotcha that silently defeats the whole gate:** a Cloudflare Worker with an `assets` binding
|
|
61
|
+
serves any request matching a file in the assets directory *directly*, without invoking the
|
|
62
|
+
Worker's `fetch` handler at all - unless `run_worker_first: true` is set. Without it, the gate
|
|
63
|
+
code runs and looks correctly wired up, tests pass, but real page requests (which almost always
|
|
64
|
+
match a static asset) never reach it, so the site never actually closes. In `wrangler.jsonc`:
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
{
|
|
68
|
+
"assets": {
|
|
69
|
+
"directory": "./dist",
|
|
70
|
+
"binding": "ASSETS",
|
|
71
|
+
"run_worker_first": true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Config
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export interface ShabbatGateConfig {
|
|
80
|
+
siteName: string;
|
|
81
|
+
|
|
82
|
+
/** Decimal lat/long for zmanim. Both default to Jerusalem (31.7683, 35.2137) if
|
|
83
|
+
* omitted - a fine single reference point for all of Israel at this granularity. */
|
|
84
|
+
latitude?: number;
|
|
85
|
+
longitude?: number;
|
|
86
|
+
|
|
87
|
+
/** Query param name + required value that bypasses the gate entirely, so the site
|
|
88
|
+
* owner can preview/test on any day. Keep the value non-guessable - this is a
|
|
89
|
+
* testing convenience, not real auth. */
|
|
90
|
+
bypassParam?: string;
|
|
91
|
+
bypassValue?: string;
|
|
92
|
+
|
|
93
|
+
/** Optional custom holding-page renderer. Defaults to a Hebrew, mobile-responsive
|
|
94
|
+
* page showing siteName and when the site reopens. `reasonLabel` and `closingLabel`
|
|
95
|
+
* differ grammatically for plain Shabbat ("שבת קודש" opening vs. "השבת" closing) -
|
|
96
|
+
* use `closingLabel` for "back after ___", not `reasonLabel` again. */
|
|
97
|
+
renderHoldingPage?: (ctx: {
|
|
98
|
+
siteName: string;
|
|
99
|
+
reasonLabel: string;
|
|
100
|
+
closingLabel: string;
|
|
101
|
+
untilLabel: string;
|
|
102
|
+
/** Optional localized message shown below the Hebrew one (with a blank-line
|
|
103
|
+
* gap), for a visitor outside Israel, in their own browser language. Absent
|
|
104
|
+
* for visitors in Israel, Hebrew-speaking visitors, or unknown location. */
|
|
105
|
+
secondary?: { dir: 'ltr' | 'rtl'; lines: string[] };
|
|
106
|
+
}) => string;
|
|
107
|
+
|
|
108
|
+
/** Minutes to close the site *before* candle-lighting and reopen *after*
|
|
109
|
+
* havdalah, on top of the raw Hebcal window. Defaults to 0. Useful padding
|
|
110
|
+
* against clock drift / last-minute browsing right at the boundary. */
|
|
111
|
+
bufferMinutes?: number;
|
|
112
|
+
|
|
113
|
+
/** When `true`, also block a visitor during Shabbat/Yom Tov in *their own*
|
|
114
|
+
* location (from Cloudflare's `request.cf` geolocation), not only Israel's.
|
|
115
|
+
* Closed to them if it's Shabbat in Israel *or* where they are - so an
|
|
116
|
+
* overseas visitor stays blocked from Israel's candle-lighting through their
|
|
117
|
+
* own local havdalah. Holidays for a visitor outside Israel use diaspora
|
|
118
|
+
* two-day Yom Tov reckoning. Defaults to `false` (Israel-only). Falls back to
|
|
119
|
+
* the Israel-only decision when a request has no geolocation (local dev,
|
|
120
|
+
* unplaceable IP). */
|
|
121
|
+
enforceVisitorLocation?: boolean;
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Blocking by the visitor's timezone too (`enforceVisitorLocation`)
|
|
126
|
+
|
|
127
|
+
By default the gate uses **Israel's** calendar for every visitor worldwide: the moment
|
|
128
|
+
Shabbat ends in Israel, the site reopens for everyone - including a US visitor for whom it's
|
|
129
|
+
still Shabbat. Set `enforceVisitorLocation: true` to make it the **union of two Shabbatot**:
|
|
130
|
+
the site is closed to a visitor if it's Shabbat/Yom Tov in Israel **or** where they are. A New
|
|
131
|
+
York visitor is then blocked from Israel's candle-lighting (even if it's still Friday afternoon
|
|
132
|
+
for them) continuously through their own local havdalah. Holidays are reckoned diaspora-style
|
|
133
|
+
(two-day Yom Tov) for visitors abroad. Chanukah, Purim, Yom HaAtzma'ut and Chol HaMoed never
|
|
134
|
+
block, in Israel or abroad.
|
|
135
|
+
|
|
136
|
+
### Localized message for visitors abroad
|
|
137
|
+
|
|
138
|
+
When a visitor is outside Israel, the default holding page shows the Hebrew message first,
|
|
139
|
+
then (below a two-line gap) a message in their browser language (from `Accept-Language`).
|
|
140
|
+
Built-in languages: English (default/fallback), French, Russian, Spanish, German, and Arabic
|
|
141
|
+
(rendered right-to-left). Hebrew speakers and visitors in Israel get no second message; the
|
|
142
|
+
reopen time is shown in the visitor's own timezone. This works even without `enforceVisitorLocation` (whenever the site is
|
|
143
|
+
closed and the visitor is known to be abroad).
|
|
144
|
+
|
|
145
|
+
Full example:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createShabbatGate } from 'shabbat-gate';
|
|
149
|
+
|
|
150
|
+
const gate = createShabbatGate({
|
|
151
|
+
siteName: 'tehila·games',
|
|
152
|
+
latitude: 31.7683,
|
|
153
|
+
longitude: 35.2137,
|
|
154
|
+
bypassParam: 'preview',
|
|
155
|
+
bypassValue: 'letmein-9f3a7c',
|
|
156
|
+
bufferMinutes: 10,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const onRequest: PagesFunction = (context) => gate(context);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## How it works
|
|
163
|
+
|
|
164
|
+
1. Bot check (allowlist regex on the `user-agent` header) - matches pass straight through.
|
|
165
|
+
2. Bypass check - if the bypass query param + value match, pass straight through.
|
|
166
|
+
3. Fetch (with ~24h caching via the Workers Cache API) the merged list of Shabbat and major
|
|
167
|
+
holiday windows from Hebcal, ~45 days into the future - one call to Hebcal's `/hebcal`
|
|
168
|
+
endpoint (`ss=on` for weekly Shabbat + `maj=on` for major holidays), passing `latitude`/
|
|
169
|
+
`longitude` directly so every window is correctly localized, not just the nearest one.
|
|
170
|
+
4. `bufferMinutes` (if set) is applied on top of the fetched windows before the time check.
|
|
171
|
+
5. If `enforceVisitorLocation` is set, a second window list is fetched for the visitor's own
|
|
172
|
+
location (from `request.cf`, diaspora reckoning when abroad) and unioned with Israel's -
|
|
173
|
+
blocking if the time falls inside either. Overlapping windows are coalesced into one
|
|
174
|
+
continuous window so the shown reopen time is accurate.
|
|
175
|
+
6. If the current time falls inside a window, serve the holding page (HTTP 200). Otherwise let
|
|
176
|
+
the real site through.
|
|
177
|
+
7. Any error along the way falls through to the real site.
|
|
178
|
+
|
|
179
|
+
## Internal cache key
|
|
180
|
+
|
|
181
|
+
The merged window list is cached under a fixed internal key
|
|
182
|
+
(`https://internal.cache/shabbat-gate-windows-v1`, exported as `INTERNAL_CACHE_KEY_URL`) for
|
|
183
|
+
~24h via the Workers Cache API. If your own code also caches derived data (e.g. windows with
|
|
184
|
+
your own buffer applied) via `caches.default`, use a different key - reusing this one will
|
|
185
|
+
silently serve stale, unprocessed data for up to 24h.
|
|
186
|
+
|
|
187
|
+
## Changelog
|
|
188
|
+
|
|
189
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed in each release, including root-cause
|
|
190
|
+
explanations for fixed bugs.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/dist/hebcal.d.ts
CHANGED
|
@@ -1,27 +1,102 @@
|
|
|
1
1
|
export interface Window {
|
|
2
2
|
start: number;
|
|
3
3
|
end: number;
|
|
4
|
+
/** Hebrew label for "the site is closed for ___". */
|
|
4
5
|
label: string;
|
|
6
|
+
/** Hebrew label for "back after ___ [ends]" - grammatically distinct from
|
|
7
|
+
* `label` for Shabbat ("שבת קודש" opening vs. "השבת" closing). */
|
|
8
|
+
closingLabel: string;
|
|
5
9
|
}
|
|
6
10
|
interface HebcalItem {
|
|
7
11
|
title: string;
|
|
8
12
|
hebrew?: string;
|
|
9
13
|
date: string;
|
|
10
14
|
category: string;
|
|
15
|
+
/** Present on `candles`/`havdalah` items: the title of the holiday/parasha
|
|
16
|
+
* it belongs to (e.g. "Erev Rosh Hashana", or "Parashat Devarim" for a
|
|
17
|
+
* plain Shabbat week). Used to cross-reference against `holiday` items'
|
|
18
|
+
* own `title` to find the right Hebrew label for *this specific* window,
|
|
19
|
+
* instead of trusting whichever `holiday` item happened to appear most
|
|
20
|
+
* recently in the feed (which leaks into unrelated windows - see below). */
|
|
21
|
+
memo?: string;
|
|
11
22
|
}
|
|
23
|
+
/** Hebcal's own "candles"/"havdalah" items always carry the generic literal
|
|
24
|
+
* "הדלקת נרות"/"הבדלה" in their `hebrew` field, never the occasion name - so
|
|
25
|
+
* it's unusable as a display label on its own. For a plain Shabbat week
|
|
26
|
+
* (no accompanying `holiday` item), fall back to this fixed pair instead. */
|
|
27
|
+
export declare const SHABBAT_LABEL = "\u05E9\u05D1\u05EA \u05E7\u05D5\u05D3\u05E9";
|
|
28
|
+
export declare const SHABBAT_CLOSING_LABEL = "\u05D4\u05E9\u05D1\u05EA";
|
|
12
29
|
/**
|
|
13
30
|
* Pairs candles/havdalah events into continuous windows. Multi-day holidays
|
|
14
31
|
* (e.g. Rosh Hashana) emit two "candles" events but only one "havdalah" at the
|
|
15
32
|
* very end, so candles cannot simply be paired 1:1 with the next havdalah.
|
|
16
33
|
* Instead: open a window on the first candles seen while none is open, ignore
|
|
17
34
|
* further candles while one is open, and close on the next havdalah.
|
|
35
|
+
*
|
|
36
|
+
* `defaults` overrides the label for windows that have no matching `holiday`
|
|
37
|
+
* item (i.e. plain Shabbat weeks) - pass it when calling this with a merged
|
|
38
|
+
* feed that mixes weekly Shabbat and holiday items together.
|
|
39
|
+
*
|
|
40
|
+
* Holiday windows pick up the matching `holiday` item's own Hebrew name (e.g.
|
|
41
|
+
* "ערב ראש השנה") by matching the opening `candles` item's `memo` field
|
|
42
|
+
* against a `holiday` item's `title` - not just "the most recent holiday item
|
|
43
|
+
* seen so far". Some `holiday`-category items (e.g. fast days like תשעה באב,
|
|
44
|
+
* which are `maj=on` but have no candle-lighting of their own) never get
|
|
45
|
+
* consumed by a window; naively tracking "last holiday label seen" would leak
|
|
46
|
+
* their label into the next, unrelated Shabbat window instead of falling back
|
|
47
|
+
* to `defaults`.
|
|
18
48
|
*/
|
|
19
|
-
export declare function pairWindows(items: HebcalItem[]
|
|
49
|
+
export declare function pairWindows(items: HebcalItem[], defaults?: {
|
|
50
|
+
label: string;
|
|
51
|
+
closingLabel: string;
|
|
52
|
+
}): Window[];
|
|
53
|
+
export interface FetchWindowsOptions {
|
|
54
|
+
/** `true` (default) = Israel single-day Yom Tov reckoning (`i=on`). `false` =
|
|
55
|
+
* diaspora two-day Yom Tov reckoning (`i=off`), correct for a visitor
|
|
56
|
+
* physically outside Israel. Only affects how many days a *Torah* Yom Tov
|
|
57
|
+
* spans - the `maj=on&min=off&mod=off` filter is independent of `i`, so
|
|
58
|
+
* Chanukah/Purim/Yom HaAtzma'ut/Chol HaMoed stay excluded either way. */
|
|
59
|
+
israelMode?: boolean;
|
|
60
|
+
/** IANA timezone the candle-lighting/havdalah times are computed against
|
|
61
|
+
* (defaults to `'Asia/Jerusalem'`). Pass the visitor's own timezone when
|
|
62
|
+
* computing their local windows so day boundaries line up with their sunset,
|
|
63
|
+
* not Jerusalem's. */
|
|
64
|
+
tzid?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetches and merges Shabbat + major-holiday windows for the next ~45 days from
|
|
68
|
+
* Hebcal's free public JSON API. Defaults to Israel single-day Yom Tov mode at
|
|
69
|
+
* Jerusalem's timezone; pass `options` to compute windows for a visitor's own
|
|
70
|
+
* location/reckoning instead (see {@link FetchWindowsOptions}).
|
|
71
|
+
*
|
|
72
|
+
* Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
|
|
73
|
+
* endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
|
|
74
|
+
* instead of a `geonameid`. Two real bugs motivated this over the previous
|
|
75
|
+
* two-call approach: (1) `/shabbat?start=...&end=...` silently ignores the
|
|
76
|
+
* requested range and only ever returns the single nearest Shabbat,
|
|
77
|
+
* regardless of how far out `end` is; (2) `geonameid` always resolves to a
|
|
78
|
+
* fixed city (Jerusalem), so every week after the nearest one was computed
|
|
79
|
+
* for the wrong location instead of the coordinates passed in. Querying
|
|
80
|
+
* `/hebcal` with `ss=on` + lat/long returns every Shabbat and holiday in the
|
|
81
|
+
* range, correctly localized, in one chronologically-ordered, already-merged
|
|
82
|
+
* list - which also means there's nothing left to de-duplicate.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fetchWindows(latitude: number, longitude: number, options?: FetchWindowsOptions): Promise<Window[]>;
|
|
20
85
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
86
|
+
* Coalesces overlapping/touching windows into continuous ones. Needed when two
|
|
87
|
+
* independently-computed window lists are unioned (e.g. Israel's Shabbat and a
|
|
88
|
+
* foreign visitor's local Shabbat, which partially overlap): naively searching
|
|
89
|
+
* the concatenated list with {@link findActiveWindow} would return whichever
|
|
90
|
+
* matching window comes first and report *its* `end`, so a visitor sitting
|
|
91
|
+
* inside both windows could be told the site reopens at Israel's (earlier)
|
|
92
|
+
* havdalah while they're still blocked by their own later one. Merging first
|
|
93
|
+
* makes the reported reopen time the true end of the combined block.
|
|
94
|
+
*
|
|
95
|
+
* When two windows overlap, the merged window keeps the label of whichever one
|
|
96
|
+
* ends *later* - that's the occasion actually keeping the visitor blocked, and
|
|
97
|
+
* the one whose end time is shown.
|
|
23
98
|
*/
|
|
24
|
-
export declare function
|
|
99
|
+
export declare function mergeWindows(windows: Window[]): Window[];
|
|
25
100
|
/** Pure function: is `now` inside any of the given windows? */
|
|
26
101
|
export declare function isBlocked(windows: Window[], now: number): boolean;
|
|
27
102
|
/** Pure function: the window covering `now`, if any. */
|
package/dist/hebcal.js
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
/** Hebcal's own "candles"/"havdalah" items always carry the generic literal
|
|
2
|
+
* "הדלקת נרות"/"הבדלה" in their `hebrew` field, never the occasion name - so
|
|
3
|
+
* it's unusable as a display label on its own. For a plain Shabbat week
|
|
4
|
+
* (no accompanying `holiday` item), fall back to this fixed pair instead. */
|
|
5
|
+
export const SHABBAT_LABEL = 'שבת קודש';
|
|
6
|
+
export const SHABBAT_CLOSING_LABEL = 'השבת';
|
|
2
7
|
/**
|
|
3
8
|
* Pairs candles/havdalah events into continuous windows. Multi-day holidays
|
|
4
9
|
* (e.g. Rosh Hashana) emit two "candles" events but only one "havdalah" at the
|
|
5
10
|
* very end, so candles cannot simply be paired 1:1 with the next havdalah.
|
|
6
11
|
* Instead: open a window on the first candles seen while none is open, ignore
|
|
7
12
|
* further candles while one is open, and close on the next havdalah.
|
|
13
|
+
*
|
|
14
|
+
* `defaults` overrides the label for windows that have no matching `holiday`
|
|
15
|
+
* item (i.e. plain Shabbat weeks) - pass it when calling this with a merged
|
|
16
|
+
* feed that mixes weekly Shabbat and holiday items together.
|
|
17
|
+
*
|
|
18
|
+
* Holiday windows pick up the matching `holiday` item's own Hebrew name (e.g.
|
|
19
|
+
* "ערב ראש השנה") by matching the opening `candles` item's `memo` field
|
|
20
|
+
* against a `holiday` item's `title` - not just "the most recent holiday item
|
|
21
|
+
* seen so far". Some `holiday`-category items (e.g. fast days like תשעה באב,
|
|
22
|
+
* which are `maj=on` but have no candle-lighting of their own) never get
|
|
23
|
+
* consumed by a window; naively tracking "last holiday label seen" would leak
|
|
24
|
+
* their label into the next, unrelated Shabbat window instead of falling back
|
|
25
|
+
* to `defaults`.
|
|
8
26
|
*/
|
|
9
|
-
export function pairWindows(items) {
|
|
27
|
+
export function pairWindows(items, defaults) {
|
|
10
28
|
const windows = [];
|
|
29
|
+
const holidayTitles = new Map();
|
|
11
30
|
let openStart = null;
|
|
12
31
|
let openLabel = '';
|
|
32
|
+
let openClosingLabel = '';
|
|
13
33
|
for (const item of items) {
|
|
34
|
+
if (item.category === 'holiday') {
|
|
35
|
+
holidayTitles.set(item.title, item.hebrew ?? item.title);
|
|
36
|
+
}
|
|
14
37
|
if (item.category === 'candles') {
|
|
15
38
|
if (openStart === null) {
|
|
16
39
|
openStart = new Date(item.date).getTime();
|
|
17
|
-
|
|
40
|
+
const matchedHoliday = item.memo ? holidayTitles.get(item.memo) : undefined;
|
|
41
|
+
const label = matchedHoliday ?? defaults?.label ?? item.hebrew ?? item.title;
|
|
42
|
+
openLabel = label;
|
|
43
|
+
openClosingLabel = matchedHoliday ? label : (defaults?.closingLabel ?? label);
|
|
18
44
|
}
|
|
19
45
|
}
|
|
20
46
|
else if (item.category === 'havdalah' && openStart !== null) {
|
|
21
|
-
windows.push({
|
|
47
|
+
windows.push({
|
|
48
|
+
start: openStart,
|
|
49
|
+
end: new Date(item.date).getTime(),
|
|
50
|
+
label: openLabel,
|
|
51
|
+
closingLabel: openClosingLabel,
|
|
52
|
+
});
|
|
22
53
|
openStart = null;
|
|
23
54
|
openLabel = '';
|
|
55
|
+
openClosingLabel = '';
|
|
24
56
|
}
|
|
25
57
|
}
|
|
26
58
|
return windows;
|
|
@@ -29,35 +61,78 @@ function toISODate(date) {
|
|
|
29
61
|
return date.toISOString().slice(0, 10);
|
|
30
62
|
}
|
|
31
63
|
/**
|
|
32
|
-
* Fetches and merges Shabbat + major-holiday
|
|
33
|
-
*
|
|
64
|
+
* Fetches and merges Shabbat + major-holiday windows for the next ~45 days from
|
|
65
|
+
* Hebcal's free public JSON API. Defaults to Israel single-day Yom Tov mode at
|
|
66
|
+
* Jerusalem's timezone; pass `options` to compute windows for a visitor's own
|
|
67
|
+
* location/reckoning instead (see {@link FetchWindowsOptions}).
|
|
68
|
+
*
|
|
69
|
+
* Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
|
|
70
|
+
* endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
|
|
71
|
+
* instead of a `geonameid`. Two real bugs motivated this over the previous
|
|
72
|
+
* two-call approach: (1) `/shabbat?start=...&end=...` silently ignores the
|
|
73
|
+
* requested range and only ever returns the single nearest Shabbat,
|
|
74
|
+
* regardless of how far out `end` is; (2) `geonameid` always resolves to a
|
|
75
|
+
* fixed city (Jerusalem), so every week after the nearest one was computed
|
|
76
|
+
* for the wrong location instead of the coordinates passed in. Querying
|
|
77
|
+
* `/hebcal` with `ss=on` + lat/long returns every Shabbat and holiday in the
|
|
78
|
+
* range, correctly localized, in one chronologically-ordered, already-merged
|
|
79
|
+
* list - which also means there's nothing left to de-duplicate.
|
|
34
80
|
*/
|
|
35
|
-
export async function fetchWindows(latitude, longitude) {
|
|
81
|
+
export async function fetchWindows(latitude, longitude, options = {}) {
|
|
82
|
+
const israelMode = options.israelMode ?? true;
|
|
83
|
+
const tzid = options.tzid ?? 'Asia/Jerusalem';
|
|
36
84
|
const start = new Date();
|
|
37
85
|
const end = new Date(start.getTime() + 45 * 24 * 60 * 60 * 1000);
|
|
38
86
|
const startParam = toISODate(start);
|
|
39
87
|
const endParam = toISODate(end);
|
|
40
|
-
|
|
41
|
-
`&tzid=Asia/Jerusalem&M=on&start=${startParam}&end=${endParam}`;
|
|
42
|
-
// i=on = Israel single-day Yom Tov reckoning (not diaspora 2-day).
|
|
88
|
+
// i=on = Israel single-day Yom Tov reckoning; i=off = diaspora 2-day.
|
|
43
89
|
// c=on = attach candles/havdalah entries to holidays, not just bare dates.
|
|
90
|
+
// ss=on = weekly Shabbat candle-lighting/havdalah, localized to lat/long.
|
|
44
91
|
// maj=on + everything else off = only real work-restricted Yom Tov days.
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
const iParam = israelMode ? 'on' : 'off';
|
|
93
|
+
const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&maj=on&min=off&mod=off&nx=off&mf=off&ss=on` +
|
|
94
|
+
`&c=on&i=${iParam}&latitude=${latitude}&longitude=${longitude}&tzid=${encodeURIComponent(tzid)}` +
|
|
95
|
+
`&start=${startParam}&end=${endParam}`;
|
|
96
|
+
const res = await fetch(url);
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(`hebcal fetch failed: ${res.status}`);
|
|
50
99
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
holidayRes.json(),
|
|
54
|
-
]));
|
|
55
|
-
const windows = [
|
|
56
|
-
...pairWindows(shabbatData.items ?? []),
|
|
57
|
-
...pairWindows(holidayData.items ?? []),
|
|
58
|
-
];
|
|
100
|
+
const data = (await res.json());
|
|
101
|
+
const windows = pairWindows(data.items ?? [], { label: SHABBAT_LABEL, closingLabel: SHABBAT_CLOSING_LABEL });
|
|
59
102
|
return windows.sort((a, b) => a.start - b.start);
|
|
60
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Coalesces overlapping/touching windows into continuous ones. Needed when two
|
|
106
|
+
* independently-computed window lists are unioned (e.g. Israel's Shabbat and a
|
|
107
|
+
* foreign visitor's local Shabbat, which partially overlap): naively searching
|
|
108
|
+
* the concatenated list with {@link findActiveWindow} would return whichever
|
|
109
|
+
* matching window comes first and report *its* `end`, so a visitor sitting
|
|
110
|
+
* inside both windows could be told the site reopens at Israel's (earlier)
|
|
111
|
+
* havdalah while they're still blocked by their own later one. Merging first
|
|
112
|
+
* makes the reported reopen time the true end of the combined block.
|
|
113
|
+
*
|
|
114
|
+
* When two windows overlap, the merged window keeps the label of whichever one
|
|
115
|
+
* ends *later* - that's the occasion actually keeping the visitor blocked, and
|
|
116
|
+
* the one whose end time is shown.
|
|
117
|
+
*/
|
|
118
|
+
export function mergeWindows(windows) {
|
|
119
|
+
const sorted = [...windows].sort((a, b) => a.start - b.start);
|
|
120
|
+
const merged = [];
|
|
121
|
+
for (const w of sorted) {
|
|
122
|
+
const last = merged[merged.length - 1];
|
|
123
|
+
if (last && w.start <= last.end) {
|
|
124
|
+
if (w.end > last.end) {
|
|
125
|
+
last.end = w.end;
|
|
126
|
+
last.label = w.label;
|
|
127
|
+
last.closingLabel = w.closingLabel;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
merged.push({ ...w });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return merged;
|
|
135
|
+
}
|
|
61
136
|
/** Pure function: is `now` inside any of the given windows? */
|
|
62
137
|
export function isBlocked(windows, now) {
|
|
63
138
|
return findActiveWindow(windows, now) !== undefined;
|
package/dist/holdingPage.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import type { SecondaryMessage } from './translations.js';
|
|
2
|
+
export type { SecondaryMessage } from './translations.js';
|
|
1
3
|
export interface HoldingPageContext {
|
|
2
4
|
siteName: string;
|
|
3
5
|
reasonLabel: string;
|
|
6
|
+
closingLabel: string;
|
|
4
7
|
untilLabel: string;
|
|
8
|
+
/** Optional localized message shown below the Hebrew one (with a blank-line
|
|
9
|
+
* gap), for a visitor outside Israel, in their own browser language. Absent
|
|
10
|
+
* for visitors in Israel, Hebrew-speaking visitors, or when the visitor's
|
|
11
|
+
* location is unknown. */
|
|
12
|
+
secondary?: SecondaryMessage;
|
|
5
13
|
}
|
|
6
14
|
/** Simple, centered, mobile-responsive holding page. Inline CSS only, no external assets. */
|
|
7
15
|
export declare function defaultRenderHoldingPage(ctx: HoldingPageContext): string;
|