shabbat-gate 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.he.md +190 -90
- package/README.md +194 -91
- package/dist/hebcal.d.ts +79 -4
- package/dist/hebcal.js +98 -23
- package/dist/holdingPage.d.ts +8 -0
- package/dist/holdingPage.js +54 -34
- package/dist/index.d.ts +47 -3
- package/dist/index.js +160 -40
- package/dist/translations.d.ts +29 -0
- package/dist/translations.js +81 -0
- package/package.json +36 -35
package/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 {
|
|
@@ -18,7 +20,29 @@ export interface ShabbatGateConfig {
|
|
|
18
20
|
/** Optional custom holding-page renderer. Defaults to a Hebrew, mobile-
|
|
19
21
|
* responsive page showing siteName + when the site reopens. */
|
|
20
22
|
renderHoldingPage?: (ctx: HoldingPageContext) => string;
|
|
23
|
+
/** Minutes to close the site *before* candle-lighting and reopen *after*
|
|
24
|
+
* havdalah, on top of the raw Hebcal window. Defaults to 0 (no buffer).
|
|
25
|
+
* Useful padding against clock drift / last-minute browsing right at the
|
|
26
|
+
* boundary - applied at decision time, not baked into the cached windows,
|
|
27
|
+
* so changing it takes effect immediately without waiting on the cache. */
|
|
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;
|
|
21
39
|
}
|
|
40
|
+
/** Internal cache key for the merged window list (~24h TTL via the Workers
|
|
41
|
+
* Cache API). Exported so consumers that do their own caching of
|
|
42
|
+
* derived/post-processed window data (e.g. after applying their own buffer)
|
|
43
|
+
* can pick a different key and avoid accidentally colliding with this one -
|
|
44
|
+
* which would silently serve stale, unprocessed windows for up to 24h. */
|
|
45
|
+
export declare const INTERNAL_CACHE_KEY_URL = "https://internal.cache/shabbat-gate-windows-v1";
|
|
22
46
|
/**
|
|
23
47
|
* Returns a Cloudflare Pages Functions-compatible handler that closes the
|
|
24
48
|
* site to human visitors during Shabbat and major Jewish holidays, while
|
|
@@ -27,3 +51,23 @@ export interface ShabbatGateConfig {
|
|
|
27
51
|
* occasional missed block during an error is a minor, invisible one.
|
|
28
52
|
*/
|
|
29
53
|
export declare function createShabbatGate(config: ShabbatGateConfig): PagesFunction;
|
|
54
|
+
/**
|
|
55
|
+
* Same gate logic as `createShabbatGate`, adapted for a plain Cloudflare
|
|
56
|
+
* Worker (with a static `assets` binding) instead of Pages Functions - there
|
|
57
|
+
* is no `next()` to call in that shape, so this returns `null` for "let the
|
|
58
|
+
* real site through" and a `Response` for "serve the holding page". Wire it
|
|
59
|
+
* up in the Worker's own `fetch` handler:
|
|
60
|
+
*
|
|
61
|
+
* const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
62
|
+
* export default {
|
|
63
|
+
* async fetch(request, env, ctx) {
|
|
64
|
+
* const blocked = await gate(request);
|
|
65
|
+
* return blocked ?? env.ASSETS.fetch(request);
|
|
66
|
+
* },
|
|
67
|
+
* };
|
|
68
|
+
*
|
|
69
|
+
* Note: a Worker with an `assets` binding skips the `fetch` handler entirely
|
|
70
|
+
* for requests matching a static asset unless `assets.run_worker_first: true`
|
|
71
|
+
* is set in `wrangler.jsonc` - without it, this gate never runs.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createShabbatGateForWorker(config: ShabbatGateConfig): (request: Request) => Promise<Response | null>;
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
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;
|
|
8
10
|
const JERUSALEM_LONGITUDE = 35.2137;
|
|
9
|
-
|
|
11
|
+
/** Internal cache key for the merged window list (~24h TTL via the Workers
|
|
12
|
+
* Cache API). Exported so consumers that do their own caching of
|
|
13
|
+
* derived/post-processed window data (e.g. after applying their own buffer)
|
|
14
|
+
* can pick a different key and avoid accidentally colliding with this one -
|
|
15
|
+
* which would silently serve stale, unprocessed windows for up to 24h. */
|
|
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';
|
|
10
22
|
const CACHE_TTL_SECONDS = 24 * 60 * 60;
|
|
11
|
-
|
|
23
|
+
/** Fetch a window list through the Workers Cache API under a fixed key. */
|
|
24
|
+
async function getCachedWindows(cacheKeyUrl, fetcher) {
|
|
12
25
|
const cache = caches.default;
|
|
13
|
-
const cacheRequest = new Request(
|
|
26
|
+
const cacheRequest = new Request(cacheKeyUrl);
|
|
14
27
|
const cached = await cache.match(cacheRequest);
|
|
15
28
|
if (cached) {
|
|
16
29
|
return (await cached.json());
|
|
17
30
|
}
|
|
18
|
-
const windows = await
|
|
31
|
+
const windows = await fetcher();
|
|
19
32
|
const cacheResponse = new Response(JSON.stringify(windows), {
|
|
20
33
|
headers: {
|
|
21
34
|
'content-type': 'application/json',
|
|
@@ -25,15 +38,117 @@ async function getWindows(latitude, longitude) {
|
|
|
25
38
|
await cache.put(cacheRequest, cacheResponse);
|
|
26
39
|
return windows;
|
|
27
40
|
}
|
|
28
|
-
|
|
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) {
|
|
29
72
|
return new Intl.DateTimeFormat('he-IL', {
|
|
30
|
-
timeZone:
|
|
73
|
+
timeZone: tzid,
|
|
31
74
|
day: '2-digit',
|
|
32
75
|
month: '2-digit',
|
|
33
76
|
hour: '2-digit',
|
|
34
77
|
minute: '2-digit',
|
|
35
78
|
}).format(new Date(epochMs));
|
|
36
79
|
}
|
|
80
|
+
function applyBuffer(windows, bufferMinutes) {
|
|
81
|
+
if (!bufferMinutes) {
|
|
82
|
+
return windows;
|
|
83
|
+
}
|
|
84
|
+
const bufferMs = bufferMinutes * 60_000;
|
|
85
|
+
return windows.map((w) => ({ ...w, start: w.start - bufferMs, end: w.end + bufferMs }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Shared core: bot allowlist, bypass check, window fetch (cached) + buffer,
|
|
89
|
+
* and the fail-open try/catch. Both `createShabbatGate` (Pages Functions) and
|
|
90
|
+
* `createShabbatGateForWorker` (plain Workers + Assets) are thin wrappers
|
|
91
|
+
* around this, so neither can drift out of sync on caching/fail-open/bypass
|
|
92
|
+
* behavior.
|
|
93
|
+
*/
|
|
94
|
+
async function evaluateGate(config, request) {
|
|
95
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
96
|
+
if (isBot(userAgent)) {
|
|
97
|
+
return { type: 'pass' };
|
|
98
|
+
}
|
|
99
|
+
if (config.bypassParam && config.bypassValue) {
|
|
100
|
+
const url = new URL(request.url);
|
|
101
|
+
if (url.searchParams.get(config.bypassParam) === config.bypassValue) {
|
|
102
|
+
return { type: 'pass' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const latitude = config.latitude ?? JERUSALEM_LATITUDE;
|
|
107
|
+
const longitude = config.longitude ?? JERUSALEM_LONGITUDE;
|
|
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
|
+
}
|
|
122
|
+
const active = findActiveWindow(windows, Date.now());
|
|
123
|
+
if (!active) {
|
|
124
|
+
return { type: 'pass' };
|
|
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
|
+
}
|
|
137
|
+
const render = config.renderHoldingPage ?? defaultRenderHoldingPage;
|
|
138
|
+
const html = render({
|
|
139
|
+
siteName: config.siteName,
|
|
140
|
+
reasonLabel: active.label,
|
|
141
|
+
closingLabel: active.closingLabel,
|
|
142
|
+
untilLabel,
|
|
143
|
+
secondary,
|
|
144
|
+
});
|
|
145
|
+
return { type: 'block', html };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error('shabbat-gate: failing open due to error', error);
|
|
149
|
+
return { type: 'pass' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
37
152
|
/**
|
|
38
153
|
* Returns a Cloudflare Pages Functions-compatible handler that closes the
|
|
39
154
|
* site to human visitors during Shabbat and major Jewish holidays, while
|
|
@@ -43,39 +158,44 @@ function formatJerusalemTime(epochMs) {
|
|
|
43
158
|
*/
|
|
44
159
|
export function createShabbatGate(config) {
|
|
45
160
|
return async (context) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return next();
|
|
50
|
-
}
|
|
51
|
-
if (config.bypassParam && config.bypassValue) {
|
|
52
|
-
const url = new URL(request.url);
|
|
53
|
-
if (url.searchParams.get(config.bypassParam) === config.bypassValue) {
|
|
54
|
-
return next();
|
|
55
|
-
}
|
|
161
|
+
const decision = await evaluateGate(config, context.request);
|
|
162
|
+
if (decision.type === 'pass') {
|
|
163
|
+
return context.next();
|
|
56
164
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
165
|
+
return new Response(decision.html, {
|
|
166
|
+
status: 200,
|
|
167
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Same gate logic as `createShabbatGate`, adapted for a plain Cloudflare
|
|
173
|
+
* Worker (with a static `assets` binding) instead of Pages Functions - there
|
|
174
|
+
* is no `next()` to call in that shape, so this returns `null` for "let the
|
|
175
|
+
* real site through" and a `Response` for "serve the holding page". Wire it
|
|
176
|
+
* up in the Worker's own `fetch` handler:
|
|
177
|
+
*
|
|
178
|
+
* const gate = createShabbatGateForWorker({ siteName: 'My Site' });
|
|
179
|
+
* export default {
|
|
180
|
+
* async fetch(request, env, ctx) {
|
|
181
|
+
* const blocked = await gate(request);
|
|
182
|
+
* return blocked ?? env.ASSETS.fetch(request);
|
|
183
|
+
* },
|
|
184
|
+
* };
|
|
185
|
+
*
|
|
186
|
+
* Note: a Worker with an `assets` binding skips the `fetch` handler entirely
|
|
187
|
+
* for requests matching a static asset unless `assets.run_worker_first: true`
|
|
188
|
+
* is set in `wrangler.jsonc` - without it, this gate never runs.
|
|
189
|
+
*/
|
|
190
|
+
export function createShabbatGateForWorker(config) {
|
|
191
|
+
return async (request) => {
|
|
192
|
+
const decision = await evaluateGate(config, request);
|
|
193
|
+
if (decision.type === 'pass') {
|
|
194
|
+
return null;
|
|
79
195
|
}
|
|
196
|
+
return new Response(decision.html, {
|
|
197
|
+
status: 200,
|
|
198
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
199
|
+
});
|
|
80
200
|
};
|
|
81
201
|
}
|
|
@@ -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,35 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "shabbat-gate",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"README.md",
|
|
11
|
-
"README.he.md",
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"cloudflare-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "shabbat-gate",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"README.he.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cloudflare-pages",
|
|
21
|
+
"cloudflare-workers",
|
|
22
|
+
"shabbat",
|
|
23
|
+
"jewish-holidays",
|
|
24
|
+
"hebcal",
|
|
25
|
+
"israel"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@cloudflare/workers-types": "^4.20250204.0",
|
|
33
|
+
"typescript": "^5.7.3",
|
|
34
|
+
"vitest": "^3.0.5"
|
|
35
|
+
}
|
|
36
|
+
}
|