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 +34 -0
- package/README.he.md +37 -2
- package/README.md +40 -2
- package/dist/hebcal.d.ts +39 -3
- package/dist/hebcal.js +44 -7
- package/dist/holdingPage.d.ts +7 -0
- package/dist/holdingPage.js +54 -34
- package/dist/index.d.ts +15 -3
- package/dist/index.js +72 -9
- package/dist/translations.d.ts +29 -0
- package/dist/translations.js +81 -0
- package/package.json +1 -1
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. אם
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
-
*
|
|
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
|
|
65
|
-
*
|
|
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
|
|
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
|
|
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;
|
package/dist/holdingPage.d.ts
CHANGED
|
@@ -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;
|
package/dist/holdingPage.js
CHANGED
|
@@ -1,38 +1,58 @@
|
|
|
1
|
+
/** Minimal HTML-escaping for text interpolated into the page. */
|
|
2
|
+
function escapeHtml(text) {
|
|
3
|
+
return text
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>');
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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",
|