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/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
- ## Config
41
-
42
- ```ts
43
- export interface ShabbatGateConfig {
44
- siteName: string;
45
-
46
- /** Decimal lat/long for zmanim. Both default to Jerusalem (31.7683, 35.2137) if
47
- * omitted - a fine single reference point for all of Israel at this granularity. */
48
- latitude?: number;
49
- longitude?: number;
50
-
51
- /** Query param name + required value that bypasses the gate entirely, so the site
52
- * owner can preview/test on any day. Keep the value non-guessable - this is a
53
- * testing convenience, not real auth. */
54
- bypassParam?: string;
55
- bypassValue?: string;
56
-
57
- /** Optional custom holding-page renderer. Defaults to a Hebrew, mobile-responsive
58
- * page showing siteName and when the site reopens. */
59
- renderHoldingPage?: (ctx: { siteName: string; reasonLabel: string; untilLabel: string }) => string;
60
- }
61
- ```
62
-
63
- Full example:
64
-
65
- ```ts
66
- import { createShabbatGate } from 'shabbat-gate';
67
-
68
- const gate = createShabbatGate({
69
- siteName: 'tehila·games',
70
- latitude: 31.7683,
71
- longitude: 35.2137,
72
- bypassParam: 'preview',
73
- bypassValue: 'letmein-9f3a7c',
74
- });
75
-
76
- export const onRequest: PagesFunction = (context) => gate(context);
77
- ```
78
-
79
- ## How it works
80
-
81
- 1. Bot check (allowlist regex on the `user-agent` header) - matches pass straight through.
82
- 2. Bypass check - if the bypass query param + value match, pass straight through.
83
- 3. Fetch (with ~24h caching via the Workers Cache API) the merged list of Shabbat and major
84
- holiday windows from Hebcal, ~45 days into the future.
85
- 4. If the current time falls inside a window, serve the holding page (HTTP 200). Otherwise let
86
- the real site through.
87
- 5. Any error along the way falls through to the real site.
88
-
89
- ## License
90
-
91
- MIT
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[]): Window[];
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
- * Fetches and merges Shabbat + major-holiday (Israel single-day Yom Tov mode)
22
- * windows for the next ~45 days from Hebcal's free public JSON API.
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 fetchWindows(latitude: number, longitude: number): Promise<Window[]>;
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
- const HEBCAL_JERUSALEM_GEONAME_ID = 281184;
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
- openLabel = item.hebrew ?? item.title;
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({ start: openStart, end: new Date(item.date).getTime(), label: openLabel });
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 (Israel single-day Yom Tov mode)
33
- * windows for the next ~45 days from Hebcal's free public JSON API.
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
- const shabbatUrl = `https://www.hebcal.com/shabbat?cfg=json&latitude=${latitude}&longitude=${longitude}` +
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 holidayUrl = `https://www.hebcal.com/hebcal?cfg=json&v=1&maj=on&min=off&mod=off&nx=off&mf=off&ss=off` +
46
- `&c=on&i=on&geonameid=${HEBCAL_JERUSALEM_GEONAME_ID}&start=${startParam}&end=${endParam}`;
47
- const [shabbatRes, holidayRes] = await Promise.all([fetch(shabbatUrl), fetch(holidayUrl)]);
48
- if (!shabbatRes.ok || !holidayRes.ok) {
49
- throw new Error(`hebcal fetch failed: shabbat=${shabbatRes.status} holiday=${holidayRes.status}`);
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 [shabbatData, holidayData] = (await Promise.all([
52
- shabbatRes.json(),
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;
@@ -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;