shabbat-gate 0.1.3 → 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 CHANGED
@@ -3,6 +3,40 @@
3
3
  All notable changes to this package are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/).
5
5
 
6
+ ## [0.2.0] - Unreleased
7
+
8
+ ### Added
9
+
10
+ - `ShabbatGateConfig.enforceVisitorLocation` - when `true`, the gate blocks a visitor during
11
+ Shabbat/Yom Tov in *their own* location (from Cloudflare's `request.cf` geolocation), not
12
+ only Israel's. The site is closed to them if it's Shabbat in Israel **or** where they are,
13
+ so an overseas visitor stays blocked from Israel's candle-lighting right through their own
14
+ local havdalah. Holidays for a visitor outside Israel use diaspora two-day Yom Tov reckoning
15
+ (`i=off`); Chanukah/Purim/Yom HaAtzma'ut/Chol HaMoed stay open either way. Falls back to the
16
+ Israel-only decision when a request has no geolocation (local `wrangler dev`, unplaceable IP).
17
+ Defaults to `false` (original Israel-only behavior).
18
+ - **Localized secondary holding-page message.** For a visitor physically outside Israel, the
19
+ default holding page now shows the Hebrew message, then (below a blank-line gap) a message in
20
+ the visitor's own browser language (from `Accept-Language`). Built-in languages: English
21
+ (default/fallback), French, Russian, Spanish, German, and Arabic (right-to-left).
22
+ Hebrew-speaking visitors and visitors
23
+ in Israel get no second message. Reopen time is shown in the visitor's own timezone. Works
24
+ independently of `enforceVisitorLocation` (whenever the site is closed and the visitor is
25
+ known to be abroad).
26
+ - New exports supporting the above: `fetchWindows` now takes an optional third `options`
27
+ argument (`{ israelMode?, tzid? }`) for computing a non-Israel calendar; `mergeWindows`
28
+ (coalesces overlapping window lists into continuous ones); `SUPPORTED_LANGUAGES`,
29
+ `resolveVisitorLanguage`, and the `SecondaryMessage` / `SupportedLanguage` /
30
+ `FetchWindowsOptions` types.
31
+
32
+ ### Changed
33
+
34
+ - `HoldingPageContext` gained an optional `secondary?: SecondaryMessage` field (the localized
35
+ block). Existing custom `renderHoldingPage` functions are unaffected - the field is optional
36
+ and simply ignored if unused.
37
+ - The default holding page now HTML-escapes `siteName` and all label fields before
38
+ interpolation.
39
+
6
40
  ## [0.1.3] - 2026-07-12
7
41
 
8
42
  ### Fixed
package/README.he.md CHANGED
@@ -99,15 +99,47 @@ export interface ShabbatGateConfig {
99
99
  reasonLabel: string;
100
100
  closingLabel: string;
101
101
  untilLabel: string;
102
+ /** הודעה מקומית אופציונלית שמוצגת מתחת להודעה בעברית (עם רווח), עבור גולש מחוץ
103
+ * לישראל, בשפת הדפדפן שלו. חסרה עבור גולשים בישראל / דוברי עברית / כשמיקום
104
+ * הגולש לא ידוע. */
105
+ secondary?: { dir: 'ltr' | 'rtl'; lines: string[] };
102
106
  }) => string;
103
107
 
104
108
  /** דקות לסגור את האתר *לפני* הדלקת נרות ולפתוח אותו *אחרי* הבדלה, מעל החלון הגולמי
105
109
  * מ-Hebcal. ברירת מחדל: 0. שימושי כרפידת בטחון מול סחיפת שעון / גלישה של הרגע
106
110
  * האחרון בדיוק בגבול החלון. */
107
111
  bufferMinutes?: number;
112
+
113
+ /** כשמוגדר `true`, חוסם גולש גם בזמן שבת/יום-טוב *במיקום שלו עצמו* (לפי הגאו-לוקיישן
114
+ * של Cloudflare ב-`request.cf`), לא רק בזמן של ישראל. האתר סגור לגולש אם זו שבת
115
+ * בישראל *או* במקום שבו הוא נמצא - כך שגולש בחו"ל נשאר חסום מהדלקת הנרות בישראל ועד
116
+ * צאת השבת המקומית שלו. חגים לגולש מחוץ לישראל מחושבים לפי יום-טוב שני של גלויות
117
+ * (יומיים). ברירת מחדל: `false` (שער לפי ישראל בלבד, ההתנהגות המקורית). אם אין נתוני
118
+ * מיקום לבקשה (למשל `wrangler dev` מקומי, או IP ש-Cloudflare לא ממקם) - אותה בקשה
119
+ * נופלת חזרה להחלטה לפי ישראל בלבד. */
120
+ enforceVisitorLocation?: boolean;
108
121
  }
109
122
  ```
110
123
 
124
+ ### חסימה גם לפי אזור הזמן של הגולש (`enforceVisitorLocation`)
125
+
126
+ ברירת המחדל: השער מחושב לפי לוח הזמנים של **ישראל** בלבד, לכל גולש בעולם. ברגע שיוצאת
127
+ שבת בישראל, האתר נפתח לכולם - גם לגולש בארה"ב שאצלו עדיין שבת.
128
+
129
+ עם `enforceVisitorLocation: true` השער הופך ל**איחוד של שתי שבתות**: האתר סגור לגולש אם
130
+ זו שבת/יום-טוב בישראל **או** במיקום שממנו הוא גולש. דוגמה לגולש בניו יורק: הוא נחסם כבר
131
+ מהדלקת הנרות בישראל (גם אם אצלו עדיין יום שישי בצהריים), ונשאר חסום ברצף עד צאת השבת
132
+ המקומית שלו. חגים מחושבים לו לפי חשבון גלותי (יום-טוב שני של גלויות). חנוכה, פורים, יום
133
+ העצמאות וחול המועד לא חוסמים - לא בישראל ולא בחו"ל.
134
+
135
+ ### הודעה בשפה מקומית לגולש מחו"ל
136
+
137
+ כשגולש נמצא מחוץ לישראל, דף ה"סגור" הדיפולטי מציג קודם את ההודעה בעברית, ומתחתיה (עם
138
+ רווח של שתי שורות) הודעה בשפת הדפדפן שלו (לפי כותרת `Accept-Language`). שפות מובנות:
139
+ אנגלית (ברירת מחדל), צרפתית, רוסית, ספרדית, גרמנית, וערבית (מוצגת מימין לשמאל). גולש דובר
140
+ עברית או גולש בישראל לא מקבל שורה שנייה. שעת הפתיחה מוצגת לגולש מחו"ל באזור הזמן שלו. זה עובד גם ללא
141
+ `enforceVisitorLocation` (בכל פעם שהאתר סגור וידוע שהגולש בחו"ל).
142
+
111
143
  דוגמה מלאה:
112
144
 
113
145
  ```ts
@@ -134,8 +166,11 @@ export const onRequest: PagesFunction = (context) => gate(context);
134
166
  שבועיות + `maj=on` לחגים מרכזיים), עם `latitude`/`longitude` מועברים ישירות, כך שכל
135
167
  חלון מחושב נכון למיקום שהוגדר, לא רק החלון הקרוב ביותר.
136
168
  4. אם הוגדר `bufferMinutes`, הוא מוחל מעל החלונות שנשלפו לפני בדיקת הזמן.
137
- 5. אם הזמן הנוכחי נופל בתוך חלון, מוצג דף ה"סגור" (HTTP 200). אחרת, האתר האמיתי עובר.
138
- 6. כל שגיאה בדרך גורמת למעבר לאתר האמיתי.
169
+ 5. אם הוגדר `enforceVisitorLocation`, נשלף לוח זמנים שני לפי מיקום הגולש (מ-`request.cf`,
170
+ בחשבון גלותי לגולש מחוץ לישראל) ומאוחד עם חלונות ישראל - חוסמים אם הזמן נופל באחד
171
+ מהם. חלונות חופפים ממוזגים לחלון רציף אחד כדי ששעת הפתיחה המוצגת תהיה מדויקת.
172
+ 6. אם הזמן הנוכחי נופל בתוך חלון, מוצג דף ה"סגור" (HTTP 200). אחרת, האתר האמיתי עובר.
173
+ 7. כל שגיאה בדרך גורמת למעבר לאתר האמיתי.
139
174
 
140
175
  ## מפתח קאש פנימי
141
176
 
package/README.md CHANGED
@@ -99,15 +99,49 @@ export interface ShabbatGateConfig {
99
99
  reasonLabel: string;
100
100
  closingLabel: string;
101
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[] };
102
106
  }) => string;
103
107
 
104
108
  /** Minutes to close the site *before* candle-lighting and reopen *after*
105
109
  * havdalah, on top of the raw Hebcal window. Defaults to 0. Useful padding
106
110
  * against clock drift / last-minute browsing right at the boundary. */
107
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;
108
122
  }
109
123
  ```
110
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
+
111
145
  Full example:
112
146
 
113
147
  ```ts
@@ -134,9 +168,13 @@ export const onRequest: PagesFunction = (context) => gate(context);
134
168
  endpoint (`ss=on` for weekly Shabbat + `maj=on` for major holidays), passing `latitude`/
135
169
  `longitude` directly so every window is correctly localized, not just the nearest one.
136
170
  4. `bufferMinutes` (if set) is applied on top of the fetched windows before the time check.
137
- 5. If the current time falls inside a window, serve the holding page (HTTP 200). Otherwise let
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
138
176
  the real site through.
139
- 6. Any error along the way falls through to the real site.
177
+ 7. Any error along the way falls through to the real site.
140
178
 
141
179
  ## Internal cache key
142
180
 
package/dist/hebcal.d.ts CHANGED
@@ -20,6 +20,12 @@ interface HebcalItem {
20
20
  * recently in the feed (which leaks into unrelated windows - see below). */
21
21
  memo?: string;
22
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";
23
29
  /**
24
30
  * Pairs candles/havdalah events into continuous windows. Multi-day holidays
25
31
  * (e.g. Rosh Hashana) emit two "candles" events but only one "havdalah" at the
@@ -44,9 +50,24 @@ export declare function pairWindows(items: HebcalItem[], defaults?: {
44
50
  label: string;
45
51
  closingLabel: string;
46
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
+ }
47
66
  /**
48
- * Fetches and merges Shabbat + major-holiday (Israel single-day Yom Tov mode)
49
- * windows for the next ~45 days from Hebcal's free public JSON API.
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}).
50
71
  *
51
72
  * Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
52
73
  * endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
@@ -60,7 +81,22 @@ export declare function pairWindows(items: HebcalItem[], defaults?: {
60
81
  * range, correctly localized, in one chronologically-ordered, already-merged
61
82
  * list - which also means there's nothing left to de-duplicate.
62
83
  */
63
- export declare function fetchWindows(latitude: number, longitude: number): Promise<Window[]>;
84
+ export declare function fetchWindows(latitude: number, longitude: number, options?: FetchWindowsOptions): Promise<Window[]>;
85
+ /**
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.
98
+ */
99
+ export declare function mergeWindows(windows: Window[]): Window[];
64
100
  /** Pure function: is `now` inside any of the given windows? */
65
101
  export declare function isBlocked(windows: Window[], now: number): boolean;
66
102
  /** Pure function: the window covering `now`, if any. */
package/dist/hebcal.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * "הדלקת נרות"/"הבדלה" in their `hebrew` field, never the occasion name - so
3
3
  * it's unusable as a display label on its own. For a plain Shabbat week
4
4
  * (no accompanying `holiday` item), fall back to this fixed pair instead. */
5
- const SHABBAT_LABEL = 'שבת קודש';
6
- const SHABBAT_CLOSING_LABEL = 'השבת';
5
+ export const SHABBAT_LABEL = 'שבת קודש';
6
+ export const SHABBAT_CLOSING_LABEL = 'השבת';
7
7
  /**
8
8
  * Pairs candles/havdalah events into continuous windows. Multi-day holidays
9
9
  * (e.g. Rosh Hashana) emit two "candles" events but only one "havdalah" at the
@@ -61,8 +61,10 @@ function toISODate(date) {
61
61
  return date.toISOString().slice(0, 10);
62
62
  }
63
63
  /**
64
- * Fetches and merges Shabbat + major-holiday (Israel single-day Yom Tov mode)
65
- * 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}).
66
68
  *
67
69
  * Uses a *single* call to the `/hebcal` endpoint (not the separate `/shabbat`
68
70
  * endpoint) with `ss=on` added, passing `latitude`/`longitude` directly
@@ -76,17 +78,20 @@ function toISODate(date) {
76
78
  * range, correctly localized, in one chronologically-ordered, already-merged
77
79
  * list - which also means there's nothing left to de-duplicate.
78
80
  */
79
- 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';
80
84
  const start = new Date();
81
85
  const end = new Date(start.getTime() + 45 * 24 * 60 * 60 * 1000);
82
86
  const startParam = toISODate(start);
83
87
  const endParam = toISODate(end);
84
- // 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.
85
89
  // c=on = attach candles/havdalah entries to holidays, not just bare dates.
86
90
  // ss=on = weekly Shabbat candle-lighting/havdalah, localized to lat/long.
87
91
  // maj=on + everything else off = only real work-restricted Yom Tov days.
92
+ const iParam = israelMode ? 'on' : 'off';
88
93
  const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&maj=on&min=off&mod=off&nx=off&mf=off&ss=on` +
89
- `&c=on&i=on&latitude=${latitude}&longitude=${longitude}&tzid=Asia/Jerusalem` +
94
+ `&c=on&i=${iParam}&latitude=${latitude}&longitude=${longitude}&tzid=${encodeURIComponent(tzid)}` +
90
95
  `&start=${startParam}&end=${endParam}`;
91
96
  const res = await fetch(url);
92
97
  if (!res.ok) {
@@ -96,6 +101,38 @@ export async function fetchWindows(latitude, longitude) {
96
101
  const windows = pairWindows(data.items ?? [], { label: SHABBAT_LABEL, closingLabel: SHABBAT_CLOSING_LABEL });
97
102
  return windows.sort((a, b) => a.start - b.start);
98
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
+ }
99
136
  /** Pure function: is `now` inside any of the given windows? */
100
137
  export function isBlocked(windows, now) {
101
138
  return findActiveWindow(windows, now) !== undefined;
@@ -1,8 +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;
4
6
  closingLabel: string;
5
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;
6
13
  }
7
14
  /** Simple, centered, mobile-responsive holding page. Inline CSS only, no external assets. */
8
15
  export declare function defaultRenderHoldingPage(ctx: HoldingPageContext): string;
@@ -1,38 +1,58 @@
1
+ /** Minimal HTML-escaping for text interpolated into the page. */
2
+ function escapeHtml(text) {
3
+ return text
4
+ .replace(/&/g, '&amp;')
5
+ .replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;');
7
+ }
8
+ function renderSecondary(secondary) {
9
+ if (!secondary) {
10
+ return '';
11
+ }
12
+ const lines = secondary.lines.map((line) => ` <p>${escapeHtml(line)}</p>`).join('\n');
13
+ return `
14
+ <div class="secondary" dir="${secondary.dir}">
15
+ ${lines}
16
+ </div>`;
17
+ }
1
18
  /** Simple, centered, mobile-responsive holding page. Inline CSS only, no external assets. */
2
19
  export function defaultRenderHoldingPage(ctx) {
3
- return `<!doctype html>
4
- <html lang="he" dir="rtl">
5
- <head>
6
- <meta charset="utf-8">
7
- <meta name="viewport" content="width=device-width, initial-scale=1">
8
- <meta name="robots" content="noindex">
9
- <title>${ctx.siteName}</title>
10
- <style>
11
- * { box-sizing: border-box; }
12
- body {
13
- margin: 0;
14
- min-height: 100vh;
15
- display: flex;
16
- align-items: center;
17
- justify-content: center;
18
- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
19
- background: #0f2138;
20
- color: #f3efe4;
21
- text-align: center;
22
- padding: 24px;
23
- }
24
- .card { max-width: 480px; }
25
- h1 { font-size: 1.5rem; margin: 0 0 12px; }
26
- p { font-size: 1rem; line-height: 1.6; color: #c8a951; margin: 0 0 8px; }
27
- .until { font-size: 0.9rem; color: #9fb0c4; }
28
- </style>
29
- </head>
30
- <body>
31
- <div class="card">
32
- <h1>${ctx.siteName}</h1>
33
- <p>האתר סגור לכבוד ${ctx.reasonLabel}, ניפגש שוב אחרי צאת ${ctx.closingLabel}.</p>
34
- <p class="until">שעת פתיחה משוערת: ${ctx.untilLabel}</p>
35
- </div>
36
- </body>
20
+ return `<!doctype html>
21
+ <html lang="he" dir="rtl">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <meta name="robots" content="noindex">
26
+ <title>${escapeHtml(ctx.siteName)}</title>
27
+ <style>
28
+ * { box-sizing: border-box; }
29
+ body {
30
+ margin: 0;
31
+ min-height: 100vh;
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
36
+ background: #0f2138;
37
+ color: #f3efe4;
38
+ text-align: center;
39
+ padding: 24px;
40
+ }
41
+ .card { max-width: 480px; }
42
+ h1 { font-size: 1.5rem; margin: 0 0 12px; }
43
+ p { font-size: 1rem; line-height: 1.6; color: #c8a951; margin: 0 0 8px; }
44
+ .until { font-size: 0.9rem; color: #9fb0c4; }
45
+ /* The requested two blank lines of separation before the localized block. */
46
+ .secondary { margin-top: 3rem; }
47
+ .secondary p { font-size: 0.95rem; color: #d9d2c2; }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div class="card">
52
+ <h1>${escapeHtml(ctx.siteName)}</h1>
53
+ <p>האתר סגור לכבוד ${escapeHtml(ctx.reasonLabel)}, ניפגש שוב אחרי צאת ${escapeHtml(ctx.closingLabel)}.</p>
54
+ <p class="until">שעת פתיחה משוערת: ${escapeHtml(ctx.untilLabel)}</p>${renderSecondary(ctx.secondary)}
55
+ </div>
56
+ </body>
37
57
  </html>`;
38
58
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { type HoldingPageContext } from './holdingPage.js';
2
- export type { Window } from './hebcal.js';
3
- export type { HoldingPageContext } from './holdingPage.js';
4
- export { isBlocked, findActiveWindow, pairWindows, fetchWindows } from './hebcal.js';
2
+ export type { Window, FetchWindowsOptions } from './hebcal.js';
3
+ export type { HoldingPageContext, SecondaryMessage } from './holdingPage.js';
4
+ export type { SupportedLanguage } from './translations.js';
5
+ export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows } from './hebcal.js';
6
+ export { SUPPORTED_LANGUAGES, resolveVisitorLanguage } from './translations.js';
5
7
  export { isBot, BOT_PATTERN } from './botPattern.js';
6
8
  export { defaultRenderHoldingPage } from './holdingPage.js';
7
9
  export interface ShabbatGateConfig {
@@ -24,6 +26,16 @@ export interface ShabbatGateConfig {
24
26
  * boundary - applied at decision time, not baked into the cached windows,
25
27
  * so changing it takes effect immediately without waiting on the cache. */
26
28
  bufferMinutes?: number;
29
+ /** When `true`, also block a visitor during Shabbat/Yom Tov in *their own*
30
+ * location (derived from Cloudflare's `request.cf` geolocation), not only
31
+ * during Israel's. The site is then closed to them if it's Shabbat in Israel
32
+ * *or* where they are - so an overseas visitor stays blocked from Israel's
33
+ * candle-lighting right through their own local havdalah. Holidays for a
34
+ * visitor outside Israel use diaspora two-day Yom Tov reckoning. Defaults to
35
+ * `false` (Israel-only gate, the original behavior). If geolocation is
36
+ * unavailable for a request (e.g. local `wrangler dev`, or an IP Cloudflare
37
+ * can't place), that request falls back to the Israel-only decision. */
38
+ enforceVisitorLocation?: boolean;
27
39
  }
28
40
  /** Internal cache key for the merged window list (~24h TTL via the Workers
29
41
  * Cache API). Exported so consumers that do their own caching of
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { isBot } from './botPattern.js';
2
- import { fetchWindows, findActiveWindow } from './hebcal.js';
2
+ import { fetchWindows, findActiveWindow, mergeWindows, SHABBAT_LABEL } from './hebcal.js';
3
3
  import { defaultRenderHoldingPage } from './holdingPage.js';
4
- export { isBlocked, findActiveWindow, pairWindows, fetchWindows } from './hebcal.js';
4
+ import { buildSecondaryMessage, resolveVisitorLanguage } from './translations.js';
5
+ export { isBlocked, findActiveWindow, mergeWindows, pairWindows, fetchWindows } from './hebcal.js';
6
+ export { SUPPORTED_LANGUAGES, resolveVisitorLanguage } from './translations.js';
5
7
  export { isBot, BOT_PATTERN } from './botPattern.js';
6
8
  export { defaultRenderHoldingPage } from './holdingPage.js';
7
9
  const JERUSALEM_LATITUDE = 31.7683;
@@ -12,15 +14,21 @@ const JERUSALEM_LONGITUDE = 35.2137;
12
14
  * can pick a different key and avoid accidentally colliding with this one -
13
15
  * which would silently serve stale, unprocessed windows for up to 24h. */
14
16
  export const INTERNAL_CACHE_KEY_URL = 'https://internal.cache/shabbat-gate-windows-v1';
17
+ /** Cache-key prefix for per-visitor-location window lists. Keyed by rounded
18
+ * coordinates + timezone + reckoning so all visitors within ~1° of each other
19
+ * share one cached fetch (sunset differs by only a few minutes across a cell -
20
+ * immaterial at "block the whole site or not" granularity). */
21
+ const VISITOR_CACHE_KEY_PREFIX = 'https://internal.cache/shabbat-gate-visitor-v1';
15
22
  const CACHE_TTL_SECONDS = 24 * 60 * 60;
16
- async function getWindows(latitude, longitude) {
23
+ /** Fetch a window list through the Workers Cache API under a fixed key. */
24
+ async function getCachedWindows(cacheKeyUrl, fetcher) {
17
25
  const cache = caches.default;
18
- const cacheRequest = new Request(INTERNAL_CACHE_KEY_URL);
26
+ const cacheRequest = new Request(cacheKeyUrl);
19
27
  const cached = await cache.match(cacheRequest);
20
28
  if (cached) {
21
29
  return (await cached.json());
22
30
  }
23
- const windows = await fetchWindows(latitude, longitude);
31
+ const windows = await fetcher();
24
32
  const cacheResponse = new Response(JSON.stringify(windows), {
25
33
  headers: {
26
34
  'content-type': 'application/json',
@@ -30,9 +38,39 @@ async function getWindows(latitude, longitude) {
30
38
  await cache.put(cacheRequest, cacheResponse);
31
39
  return windows;
32
40
  }
33
- function formatJerusalemTime(epochMs) {
41
+ /** Reads the visitor's geolocation from Cloudflare's `request.cf`. Returns
42
+ * `null` when any needed field is missing/unparseable (local dev, an IP CF
43
+ * can't place) so callers can fall back to the Israel-only decision. A visitor
44
+ * physically in Israel gets Israel single-day reckoning; everyone else gets
45
+ * diaspora two-day Yom Tov. */
46
+ function readVisitorLocation(request) {
47
+ const cf = request.cf;
48
+ if (!cf) {
49
+ return null;
50
+ }
51
+ const latitude = Number(cf.latitude);
52
+ const longitude = Number(cf.longitude);
53
+ const tzid = typeof cf.timezone === 'string' ? cf.timezone : '';
54
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || !tzid) {
55
+ return null;
56
+ }
57
+ return { latitude, longitude, tzid, israelMode: cf.country === 'IL' };
58
+ }
59
+ /** Israel/Jerusalem windows - the base gate, always computed. */
60
+ function getIsraelWindows(latitude, longitude) {
61
+ return getCachedWindows(INTERNAL_CACHE_KEY_URL, () => fetchWindows(latitude, longitude));
62
+ }
63
+ /** Windows for a specific visitor location, cached per rounded cell. */
64
+ function getVisitorWindows(loc) {
65
+ const rlat = Math.round(loc.latitude);
66
+ const rlon = Math.round(loc.longitude);
67
+ const iParam = loc.israelMode ? 'on' : 'off';
68
+ const cacheKey = `${VISITOR_CACHE_KEY_PREFIX}?lat=${rlat}&lon=${rlon}&tz=${encodeURIComponent(loc.tzid)}&i=${iParam}`;
69
+ return getCachedWindows(cacheKey, () => fetchWindows(loc.latitude, loc.longitude, { israelMode: loc.israelMode, tzid: loc.tzid }));
70
+ }
71
+ function formatTime(epochMs, tzid) {
34
72
  return new Intl.DateTimeFormat('he-IL', {
35
- timeZone: 'Asia/Jerusalem',
73
+ timeZone: tzid,
36
74
  day: '2-digit',
37
75
  month: '2-digit',
38
76
  hour: '2-digit',
@@ -67,17 +105,42 @@ async function evaluateGate(config, request) {
67
105
  try {
68
106
  const latitude = config.latitude ?? JERUSALEM_LATITUDE;
69
107
  const longitude = config.longitude ?? JERUSALEM_LONGITUDE;
70
- const windows = applyBuffer(await getWindows(latitude, longitude), config.bufferMinutes ?? 0);
108
+ const bufferMinutes = config.bufferMinutes ?? 0;
109
+ // Read the visitor's location once - it drives both the optional extra
110
+ // enforcement (their local Shabbat windows) and the localized message /
111
+ // local-time display shown to a visitor outside Israel.
112
+ const visitor = readVisitorLocation(request);
113
+ const isAbroad = visitor !== null && !visitor.israelMode;
114
+ let windows = applyBuffer(await getIsraelWindows(latitude, longitude), bufferMinutes);
115
+ if (config.enforceVisitorLocation && visitor) {
116
+ const visitorWindows = applyBuffer(await getVisitorWindows(visitor), bufferMinutes);
117
+ // Union of both calendars: block if it's Shabbat/Yom Tov in Israel OR
118
+ // where the visitor is. Merge coalesces the overlap into one continuous
119
+ // block so the shown reopen time is the true end of both.
120
+ windows = mergeWindows([...windows, ...visitorWindows]);
121
+ }
71
122
  const active = findActiveWindow(windows, Date.now());
72
123
  if (!active) {
73
124
  return { type: 'pass' };
74
125
  }
126
+ // For a visitor abroad, show times in their own timezone (that's who is
127
+ // looking at the page) and append a message in their browser language.
128
+ const displayTzid = isAbroad ? visitor.tzid : 'Asia/Jerusalem';
129
+ const untilLabel = formatTime(active.end, displayTzid);
130
+ let secondary;
131
+ if (isAbroad) {
132
+ const language = resolveVisitorLanguage(request.headers.get('accept-language') ?? '');
133
+ if (language !== 'he') {
134
+ secondary = buildSecondaryMessage(language, active.label === SHABBAT_LABEL, untilLabel);
135
+ }
136
+ }
75
137
  const render = config.renderHoldingPage ?? defaultRenderHoldingPage;
76
138
  const html = render({
77
139
  siteName: config.siteName,
78
140
  reasonLabel: active.label,
79
141
  closingLabel: active.closingLabel,
80
- untilLabel: formatJerusalemTime(active.end),
142
+ untilLabel,
143
+ secondary,
81
144
  });
82
145
  return { type: 'block', html };
83
146
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Localized secondary holding-page messages, shown *below* the Hebrew message
3
+ * to a visitor physically outside Israel, in their own browser language.
4
+ *
5
+ * Deliberately generic: each pack only distinguishes "Shabbat" from "a Jewish
6
+ * holiday" and states the approximate reopen time - it never tries to name the
7
+ * specific holiday (Shmini Atzeret, Shavuot, ...), since reliably translating
8
+ * every occasion name into every language is out of scope. The exact occasion
9
+ * name stays on the Hebrew line only.
10
+ */
11
+ export type SupportedLanguage = 'en' | 'fr' | 'ru' | 'es' | 'de' | 'ar';
12
+ export declare const SUPPORTED_LANGUAGES: readonly SupportedLanguage[];
13
+ /**
14
+ * Picks the visitor's display language from an `Accept-Language` header value.
15
+ * Walks the header's tags in the browser's stated order and returns the first
16
+ * one we have a pack for. Returns `'he'` (a signal to skip the second block
17
+ * entirely) if Hebrew is the visitor's top preference - they already read the
18
+ * Hebrew message, a translated copy would be redundant. Falls back to `'en'`
19
+ * when nothing matches.
20
+ */
21
+ export declare function resolveVisitorLanguage(acceptLanguage: string): SupportedLanguage | 'he';
22
+ export interface SecondaryMessage {
23
+ /** Text direction for the localized block (all current packs are `ltr`). */
24
+ dir: 'ltr' | 'rtl';
25
+ /** Localized lines to render, in order: [closed-reason, reopen-time]. */
26
+ lines: string[];
27
+ }
28
+ /** Builds the localized two-line block for a given language + occasion. */
29
+ export declare function buildSecondaryMessage(language: SupportedLanguage, isShabbat: boolean, untilTime: string): SecondaryMessage;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Localized secondary holding-page messages, shown *below* the Hebrew message
3
+ * to a visitor physically outside Israel, in their own browser language.
4
+ *
5
+ * Deliberately generic: each pack only distinguishes "Shabbat" from "a Jewish
6
+ * holiday" and states the approximate reopen time - it never tries to name the
7
+ * specific holiday (Shmini Atzeret, Shavuot, ...), since reliably translating
8
+ * every occasion name into every language is out of scope. The exact occasion
9
+ * name stays on the Hebrew line only.
10
+ */
11
+ export const SUPPORTED_LANGUAGES = ['en', 'fr', 'ru', 'es', 'de', 'ar'];
12
+ const PACKS = {
13
+ en: {
14
+ dir: 'ltr',
15
+ shabbat: 'This site is closed in observance of Shabbat.',
16
+ holiday: 'This site is closed in observance of a Jewish holiday.',
17
+ until: (time) => `We'll be back after nightfall, around ${time}.`,
18
+ },
19
+ fr: {
20
+ dir: 'ltr',
21
+ shabbat: 'Ce site est fermé en observance du Chabbat.',
22
+ holiday: 'Ce site est fermé en observance d’une fête juive.',
23
+ until: (time) => `Nous serons de retour après la tombée de la nuit, vers ${time}.`,
24
+ },
25
+ ru: {
26
+ dir: 'ltr',
27
+ shabbat: 'Этот сайт закрыт в честь субботы (Шаббата).',
28
+ holiday: 'Этот сайт закрыт в честь еврейского праздника.',
29
+ until: (time) => `Мы вернёмся после наступления темноты, примерно в ${time}.`,
30
+ },
31
+ es: {
32
+ dir: 'ltr',
33
+ shabbat: 'Este sitio está cerrado en observancia del Shabat.',
34
+ holiday: 'Este sitio está cerrado en observancia de una festividad judía.',
35
+ until: (time) => `Volveremos después del anochecer, alrededor de las ${time}.`,
36
+ },
37
+ de: {
38
+ dir: 'ltr',
39
+ shabbat: 'Diese Website ist zu Ehren des Schabbat geschlossen.',
40
+ holiday: 'Diese Website ist zu Ehren eines jüdischen Feiertags geschlossen.',
41
+ until: (time) => `Wir sind nach Einbruch der Nacht wieder da, gegen ${time}.`,
42
+ },
43
+ ar: {
44
+ dir: 'rtl',
45
+ shabbat: 'هذا الموقع مغلق احترامًا ليوم السبت (شابات).',
46
+ holiday: 'هذا الموقع مغلق احترامًا لعيد يهودي.',
47
+ until: (time) => `سنعود بعد حلول الظلام، حوالي ${time}.`,
48
+ },
49
+ };
50
+ /**
51
+ * Picks the visitor's display language from an `Accept-Language` header value.
52
+ * Walks the header's tags in the browser's stated order and returns the first
53
+ * one we have a pack for. Returns `'he'` (a signal to skip the second block
54
+ * entirely) if Hebrew is the visitor's top preference - they already read the
55
+ * Hebrew message, a translated copy would be redundant. Falls back to `'en'`
56
+ * when nothing matches.
57
+ */
58
+ export function resolveVisitorLanguage(acceptLanguage) {
59
+ const tags = acceptLanguage
60
+ .split(',')
61
+ .map((part) => part.trim().split(';')[0].toLowerCase())
62
+ .filter(Boolean);
63
+ for (const tag of tags) {
64
+ const primary = tag.split('-')[0];
65
+ if (primary === 'he' || primary === 'iw') {
66
+ return 'he';
67
+ }
68
+ if (SUPPORTED_LANGUAGES.includes(primary)) {
69
+ return primary;
70
+ }
71
+ }
72
+ return 'en';
73
+ }
74
+ /** Builds the localized two-line block for a given language + occasion. */
75
+ export function buildSecondaryMessage(language, isShabbat, untilTime) {
76
+ const pack = PACKS[language];
77
+ return {
78
+ dir: pack.dir,
79
+ lines: [isShabbat ? pack.shabbat : pack.holiday, pack.until(untilTime)],
80
+ };
81
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shabbat-gate",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Cloudflare Pages/Workers middleware that closes a site to human visitors during Shabbat and major Jewish holidays (Israel-observance rules), while always letting search engines and AI crawlers through.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",