mihrab-methods 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,13 +204,13 @@ npm install mihrab-methods
204
204
  ```
205
205
 
206
206
  One entry point per authority, so an app ships only the data it needs
207
- (Indonesia's is 100 KB; Diyanet's worldwide points and list are 1.4 MB):
207
+ (Indonesia's is 90 KB; Diyanet's worldwide points and list are 1.6 MB):
208
208
 
209
209
  | Import | Loads |
210
210
  |---|---|
211
211
  | `mihrab-methods` | all five |
212
212
  | `mihrab-methods/sweden`, `/diyanet`, `/morocco`, `/algeria`, `/indonesia` | one authority: its places, list and certification |
213
- | `mihrab-methods/locate` | `countryAt`, `defaultAuthorityFor` — the country at a point, and the authority whose table it prays by (400 KB of outlines) |
213
+ | `mihrab-methods/locate` | `countryAt`, `defaultAuthorityFor` — the country at a point, and the authority whose table it prays by (470 KB of outlines) |
214
214
  | `mihrab-methods/core` | the calls and types with no data (load authorities yourself) |
215
215
 
216
216
  CommonJS and ES modules, with types. Node 18+, or any bundler; nothing is
@@ -231,7 +231,7 @@ timesFor(place, '2026-10-15'); // or { year: 2026, month: 10, day
231
231
  // on the printed minute all but rarely, never a minute further
232
232
  // date: '2026-10-15', certifiedFrom: '2024-01-01', certifiedUntil: '2027-12-31', revision: 1,
233
233
  // clock: { zone: 'Europe/Stockholm', offsetMinutes: 120, label: 'Europe/Stockholm' },
234
- // times: { fajr: { instant: Date, printed: '05:13' }, sunrise: …, dhuhr: …, asr: …, maghrib: …, isha: … }
234
+ // times: { fajr: { instant: Date, printed: '05:09' }, sunrise: …, dhuhr: …, asr: …, maghrib: …, isha: … }
235
235
  // }
236
236
  timesFor(place, '2031-01-01', { strict: true }); // throws UncertifiedMethodError instead
237
237
  timesFor(place, '2026-10-15', { asrShadow: 2 }); // the Hanafi Asr (the tables print the standard one)
@@ -1,3 +1,3 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare const algeria: import("../core/registry.js").AuthorityData;
3
- export declare const places: Place[];
3
+ export declare const places: readonly Place[];
@@ -1,4 +1,4 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare function indonesiaZone(province: string): string;
3
3
  export declare const indonesia: import("../core/registry.js").AuthorityData;
4
- export declare const places: Place[];
4
+ export declare const places: readonly Place[];
@@ -2,4 +2,4 @@ import type { Place } from '../core/types.js';
2
2
  /** The ministry's clock: GMT+1, then GMT — its tables print GMT from 17 September 2026, three days before the clocks moved. */
3
3
  export declare const MOROCCO_CLOCK: [string, number][];
4
4
  export declare const morocco: import("../core/registry.js").AuthorityData;
5
- export declare const places: Place[];
5
+ export declare const places: readonly Place[];
@@ -1,3 +1,3 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare const sweden: import("../core/registry.js").AuthorityData;
3
- export declare const places: Place[];
3
+ export declare const places: readonly Place[];
@@ -17,7 +17,7 @@ export declare function certifiedSpan(authority: Authority): {
17
17
  to: string;
18
18
  } | null;
19
19
  /** The authority's places, each at the point its method is computed at. */
20
- export declare function placesOf(authority: Authority): Place[];
20
+ export declare function placesOf(authority: Authority): readonly Place[];
21
21
  /** The authorities this build has loaded. */
22
22
  export declare function loadedAuthorityIds(): Authority[];
23
23
  /** A place by its stable id (`<authority>:<key>`), among the loaded authorities. */
@@ -38,7 +38,8 @@ function certifiedSpan(authority) {
38
38
  if (!isCertified(authority))
39
39
  return null;
40
40
  const checks = certificationOf(authority).checks;
41
- return { from: checks.map(c => c.from).sort()[0], to: checks.map(c => c.to).sort().at(-1) };
41
+ const tos = checks.map(c => c.to).sort();
42
+ return { from: checks.map(c => c.from).sort()[0], to: tos[tos.length - 1] };
42
43
  }
43
44
  /** The authority's places, each at the point its method is computed at. */
44
45
  function placesOf(authority) {
@@ -5,7 +5,14 @@
5
5
  * the clocks go forward a Fajr before the change is printed in the new time.
6
6
  */
7
7
  import type { Place, TableClock } from './types.js';
8
- /** Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based). */
8
+ /**
9
+ * Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based).
10
+ *
11
+ * Read from the zone's wall clock (year … minute, as every Intl has them),
12
+ * not from `timeZoneName: 'longOffset'`, which not every runtime supports:
13
+ * where it is missing the name comes back as 'GMT+1' or a zone's own
14
+ * abbreviation, and an offset parsed from that is silently wrong.
15
+ */
9
16
  export declare function clockOffsetAt(tz: string, year: number, month: number, day: number): number;
10
17
  /** The clock a place's table prints a date in: its own (`Place.clock`) where it has one, else its zone's. Month 0-based. */
11
18
  export declare function placeClockAt(place: Place, year: number, month: number, day: number): number;
@@ -5,15 +5,36 @@ exports.placeClockAt = placeClockAt;
5
5
  exports.offsetLabel = offsetLabel;
6
6
  exports.tableClockAt = tableClockAt;
7
7
  exports.printedAt = printedAt;
8
- const offsetFormats = new Map();
9
- /** Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based). */
8
+ const formats = new Map();
9
+ /**
10
+ * Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based).
11
+ *
12
+ * Read from the zone's wall clock (year … minute, as every Intl has them),
13
+ * not from `timeZoneName: 'longOffset'`, which not every runtime supports:
14
+ * where it is missing the name comes back as 'GMT+1' or a zone's own
15
+ * abbreviation, and an offset parsed from that is silently wrong.
16
+ */
10
17
  function clockOffsetAt(tz, year, month, day) {
11
- let f = offsetFormats.get(tz);
12
- if (!f)
13
- offsetFormats.set(tz, (f = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'longOffset' })));
14
- const z = f.formatToParts(new Date(Date.UTC(year, month, day, 12))).find(x => x.type === 'timeZoneName').value;
15
- const m = /GMT([+-])(\d\d):?(\d\d)?/.exec(z);
16
- return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0;
18
+ const at = Date.UTC(year, month, day, 12);
19
+ let f = formats.get(tz);
20
+ if (!f) {
21
+ f = new Intl.DateTimeFormat('en-US', {
22
+ timeZone: tz,
23
+ year: 'numeric',
24
+ month: 'numeric',
25
+ day: 'numeric',
26
+ hour: 'numeric',
27
+ minute: 'numeric',
28
+ hourCycle: 'h23',
29
+ });
30
+ formats.set(tz, f);
31
+ }
32
+ const parts = f.formatToParts(new Date(at));
33
+ const n = (type) => Number(parts.find(p => p.type === type)?.value);
34
+ const wall = Date.UTC(n('year'), n('month') - 1, n('day'), n('hour') % 24, n('minute'));
35
+ if (!Number.isFinite(wall))
36
+ throw new RangeError(`The zone ${tz} could not be read on this runtime.`);
37
+ return Math.round((wall - at) / 60_000);
17
38
  }
18
39
  /** The clock a place's table prints a date in: its own (`Place.clock`) where it has one, else its zone's. Month 0-based. */
19
40
  function placeClockAt(place, year, month, day) {
@@ -9,7 +9,7 @@ export type FetchOptions = {
9
9
  baseUrl?: string;
10
10
  /** The fetch to use (default: the global one). */
11
11
  fetch?: typeof fetch;
12
- /** SHA-256 of a UTF-8 string as lowercase hex; default: WebCrypto, else node:crypto, else the hash is not checked. */
12
+ /** SHA-256 of a UTF-8 string as lowercase hex; default: WebCrypto where there is one, else a built-in digest. */
13
13
  sha256?: (text: string) => Promise<string>;
14
14
  /** Put a newer list in force in this process (default true). */
15
15
  apply?: boolean;
@@ -52,6 +52,10 @@ export type FetchResult =
52
52
  have: ListSummary;
53
53
  };
54
54
  export declare function summarize(list: CorrectionsFile): ListSummary;
55
+ /** UTF-8 bytes of a string, without TextEncoder (not every runtime has it). */
56
+ export declare function utf8(text: string): Uint8Array;
57
+ /** SHA-256 (FIPS 180-4) of bytes. */
58
+ export declare function sha256(data: Uint8Array): Uint8Array;
55
59
  /** Put a list in force for the process — one verified by `fetchLatestCorrections`, or restored from the app's own storage. */
56
60
  export declare function applyCorrections(authority: Authority, list: CorrectionsFile, revision: number): boolean;
57
61
  export declare function fetchLatestCorrections(opts: FetchOptions): Promise<FetchResult>;
@@ -1,40 +1,9 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.DEFAULT_FEED_URL = void 0;
37
4
  exports.summarize = summarize;
5
+ exports.utf8 = utf8;
6
+ exports.sha256 = sha256;
38
7
  exports.applyCorrections = applyCorrections;
39
8
  exports.fetchLatestCorrections = fetchLatestCorrections;
40
9
  /**
@@ -52,20 +21,107 @@ function summarize(list) {
52
21
  return { from: list.from, to: list.to, entries: Object.values(list.points).reduce((n, e) => n + e.length, 0) };
53
22
  }
54
23
  const same = (a, b) => a.from === b.from && a.to === b.to && a.entries === b.entries;
24
+ /**
25
+ * SHA-256 as lowercase hex. WebCrypto where the runtime has it (browsers,
26
+ * Node 18+, Deno, Bun); elsewhere — Hermes in React Native has none — the
27
+ * plain-JavaScript digest below. No `node:crypto` import: a bundler for a
28
+ * phone or a browser fails on the import itself, used or not.
29
+ */
55
30
  async function defaultSha256(text) {
56
- const bytes = new TextEncoder().encode(text);
31
+ const bytes = utf8(text);
57
32
  const subtle = globalThis.crypto?.subtle;
58
33
  if (subtle) {
59
- const buf = await subtle.digest('SHA-256', bytes);
60
- return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
34
+ try {
35
+ const buf = await subtle.digest('SHA-256', bytes);
36
+ return hex(new Uint8Array(buf));
37
+ }
38
+ catch {
39
+ /* a subtle that refuses (an insecure context): the digest below */
40
+ }
61
41
  }
62
- try {
63
- const { createHash } = await Promise.resolve().then(() => __importStar(require('node:crypto')));
64
- return createHash('sha256').update(bytes).digest('hex');
42
+ return hex(sha256(bytes));
43
+ }
44
+ const hex = (b) => Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
45
+ /** UTF-8 bytes of a string, without TextEncoder (not every runtime has it). */
46
+ function utf8(text) {
47
+ const out = [];
48
+ for (let i = 0; i < text.length; i++) {
49
+ let c = text.charCodeAt(i);
50
+ if (c >= 0xd800 && c < 0xdc00 && i + 1 < text.length) {
51
+ const d = text.charCodeAt(i + 1);
52
+ if (d >= 0xdc00 && d < 0xe000) {
53
+ c = 0x10000 + ((c - 0xd800) << 10) + (d - 0xdc00);
54
+ i++;
55
+ }
56
+ }
57
+ if (c < 0x80)
58
+ out.push(c);
59
+ else if (c < 0x800)
60
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
61
+ else if (c < 0x10000)
62
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
63
+ else
64
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
65
65
  }
66
- catch {
67
- return null;
66
+ return Uint8Array.from(out);
67
+ }
68
+ const K = new Uint32Array([
69
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
70
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
71
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
72
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
73
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
74
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
75
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
76
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
77
+ ]);
78
+ /** SHA-256 (FIPS 180-4) of bytes. */
79
+ function sha256(data) {
80
+ const bitLen = data.length * 8;
81
+ const padded = new Uint8Array(((data.length + 9 + 63) >> 6) << 6);
82
+ padded.set(data);
83
+ padded[data.length] = 0x80;
84
+ const view = new DataView(padded.buffer);
85
+ view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
86
+ view.setUint32(padded.length - 4, bitLen >>> 0);
87
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
88
+ const w = new Uint32Array(64);
89
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
90
+ for (let off = 0; off < padded.length; off += 64) {
91
+ for (let i = 0; i < 16; i++)
92
+ w[i] = view.getUint32(off + i * 4);
93
+ for (let i = 16; i < 64; i++) {
94
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
95
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
96
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
97
+ }
98
+ let [a, b, c, d, e, f, g, hh] = h;
99
+ for (let i = 0; i < 64; i++) {
100
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
101
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
102
+ hh = g;
103
+ g = f;
104
+ f = e;
105
+ e = (d + t1) >>> 0;
106
+ d = c;
107
+ c = b;
108
+ b = a;
109
+ a = (t1 + t2) >>> 0;
110
+ }
111
+ h[0] += a;
112
+ h[1] += b;
113
+ h[2] += c;
114
+ h[3] += d;
115
+ h[4] += e;
116
+ h[5] += f;
117
+ h[6] += g;
118
+ h[7] += hh;
68
119
  }
120
+ const out = new Uint8Array(32);
121
+ const ov = new DataView(out.buffer);
122
+ for (let i = 0; i < 8; i++)
123
+ ov.setUint32(i * 4, h[i]);
124
+ return out;
69
125
  }
70
126
  /** Put a list in force for the process — one verified by `fetchLatestCorrections`, or restored from the app's own storage. */
71
127
  function applyCorrections(authority, list, revision) {
@@ -123,14 +179,13 @@ async function fetchLatestCorrections(opts) {
123
179
  const summary = summarize(list);
124
180
  if (!same(summary, named))
125
181
  return { status: 'rejected', reason: 'the list is not the one the certification names', have };
182
+ // A certification without a hash (one written before 1.0.0) is taken on its identity alone.
126
183
  let integrity = 'unchecked';
127
184
  if (named.sha256) {
128
185
  const hash = opts.sha256 ? await opts.sha256(text) : await defaultSha256(text);
129
- if (hash !== null) {
130
- if (hash.toLowerCase() !== named.sha256.toLowerCase())
131
- return { status: 'rejected', reason: 'the list’s SHA-256 is not the one the certification names', have };
132
- integrity = 'verified';
133
- }
186
+ if (hash.toLowerCase() !== named.sha256.toLowerCase())
187
+ return { status: 'rejected', reason: 'the list’s SHA-256 is not the one the certification names', have };
188
+ integrity = 'verified';
134
189
  }
135
190
  const applied = opts.apply === false ? false : applyCorrections(opts.authority, list, revision);
136
191
  return { status: 'updated', list, summary, integrity, applied };
@@ -10,7 +10,7 @@ export type MethodDescription = Pick<MethodInfo, 'name' | 'method' | 'countries'
10
10
  export type AuthorityData = {
11
11
  authority: Authority;
12
12
  revision: number;
13
- places: Place[];
13
+ places: readonly Place[];
14
14
  corrections: CorrectionList;
15
15
  certification: Certification | null;
16
16
  description: MethodDescription;
@@ -10,6 +10,11 @@ function registerAuthority(entry) {
10
10
  const have = registry.get(entry.authority);
11
11
  if (have)
12
12
  return have;
13
+ // Frozen: every caller shares these objects, and a place moved by one
14
+ // (`place.lat = …`) would move the corrections it is looked up by for all.
15
+ for (const p of entry.places)
16
+ Object.freeze(p);
17
+ Object.freeze(entry.places);
13
18
  const data = { ...entry, corrections: new correctionList_js_1.CorrectionList(entry.corrections) };
14
19
  registry.set(entry.authority, data);
15
20
  return data;
@@ -18,7 +18,7 @@ exports.defaultAuthorityFor = defaultAuthorityFor;
18
18
  * that into the authority whose table the point's community prays by, or
19
19
  * null where none of the five applies.
20
20
  *
21
- * Its own entry point because the outlines are 400 KB an app that already
21
+ * Its own entry point because the outlines are 470 KB an app that already
22
22
  * knows its country does not need.
23
23
  */
24
24
  const countryOutlines_js_1 = __importDefault(require("./data/generated/countryOutlines.js"));
@@ -1,3 +1,3 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare const algeria: import("../core/registry.js").AuthorityData;
3
- export declare const places: Place[];
3
+ export declare const places: readonly Place[];
@@ -1,4 +1,4 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare function indonesiaZone(province: string): string;
3
3
  export declare const indonesia: import("../core/registry.js").AuthorityData;
4
- export declare const places: Place[];
4
+ export declare const places: readonly Place[];
@@ -2,4 +2,4 @@ import type { Place } from '../core/types.js';
2
2
  /** The ministry's clock: GMT+1, then GMT — its tables print GMT from 17 September 2026, three days before the clocks moved. */
3
3
  export declare const MOROCCO_CLOCK: [string, number][];
4
4
  export declare const morocco: import("../core/registry.js").AuthorityData;
5
- export declare const places: Place[];
5
+ export declare const places: readonly Place[];
@@ -1,3 +1,3 @@
1
1
  import type { Place } from '../core/types.js';
2
2
  export declare const sweden: import("../core/registry.js").AuthorityData;
3
- export declare const places: Place[];
3
+ export declare const places: readonly Place[];
@@ -17,7 +17,7 @@ export declare function certifiedSpan(authority: Authority): {
17
17
  to: string;
18
18
  } | null;
19
19
  /** The authority's places, each at the point its method is computed at. */
20
- export declare function placesOf(authority: Authority): Place[];
20
+ export declare function placesOf(authority: Authority): readonly Place[];
21
21
  /** The authorities this build has loaded. */
22
22
  export declare function loadedAuthorityIds(): Authority[];
23
23
  /** A place by its stable id (`<authority>:<key>`), among the loaded authorities. */
@@ -24,7 +24,8 @@ export function certifiedSpan(authority) {
24
24
  if (!isCertified(authority))
25
25
  return null;
26
26
  const checks = certificationOf(authority).checks;
27
- return { from: checks.map(c => c.from).sort()[0], to: checks.map(c => c.to).sort().at(-1) };
27
+ const tos = checks.map(c => c.to).sort();
28
+ return { from: checks.map(c => c.from).sort()[0], to: tos[tos.length - 1] };
28
29
  }
29
30
  /** The authority's places, each at the point its method is computed at. */
30
31
  export function placesOf(authority) {
@@ -5,7 +5,14 @@
5
5
  * the clocks go forward a Fajr before the change is printed in the new time.
6
6
  */
7
7
  import type { Place, TableClock } from './types.js';
8
- /** Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based). */
8
+ /**
9
+ * Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based).
10
+ *
11
+ * Read from the zone's wall clock (year … minute, as every Intl has them),
12
+ * not from `timeZoneName: 'longOffset'`, which not every runtime supports:
13
+ * where it is missing the name comes back as 'GMT+1' or a zone's own
14
+ * abbreviation, and an offset parsed from that is silently wrong.
15
+ */
9
16
  export declare function clockOffsetAt(tz: string, year: number, month: number, day: number): number;
10
17
  /** The clock a place's table prints a date in: its own (`Place.clock`) where it has one, else its zone's. Month 0-based. */
11
18
  export declare function placeClockAt(place: Place, year: number, month: number, day: number): number;
@@ -1,12 +1,33 @@
1
- const offsetFormats = new Map();
2
- /** Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based). */
1
+ const formats = new Map();
2
+ /**
3
+ * Minutes east of UTC that a zone keeps at noon UTC of a date (month 0-based).
4
+ *
5
+ * Read from the zone's wall clock (year … minute, as every Intl has them),
6
+ * not from `timeZoneName: 'longOffset'`, which not every runtime supports:
7
+ * where it is missing the name comes back as 'GMT+1' or a zone's own
8
+ * abbreviation, and an offset parsed from that is silently wrong.
9
+ */
3
10
  export function clockOffsetAt(tz, year, month, day) {
4
- let f = offsetFormats.get(tz);
5
- if (!f)
6
- offsetFormats.set(tz, (f = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'longOffset' })));
7
- const z = f.formatToParts(new Date(Date.UTC(year, month, day, 12))).find(x => x.type === 'timeZoneName').value;
8
- const m = /GMT([+-])(\d\d):?(\d\d)?/.exec(z);
9
- return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0;
11
+ const at = Date.UTC(year, month, day, 12);
12
+ let f = formats.get(tz);
13
+ if (!f) {
14
+ f = new Intl.DateTimeFormat('en-US', {
15
+ timeZone: tz,
16
+ year: 'numeric',
17
+ month: 'numeric',
18
+ day: 'numeric',
19
+ hour: 'numeric',
20
+ minute: 'numeric',
21
+ hourCycle: 'h23',
22
+ });
23
+ formats.set(tz, f);
24
+ }
25
+ const parts = f.formatToParts(new Date(at));
26
+ const n = (type) => Number(parts.find(p => p.type === type)?.value);
27
+ const wall = Date.UTC(n('year'), n('month') - 1, n('day'), n('hour') % 24, n('minute'));
28
+ if (!Number.isFinite(wall))
29
+ throw new RangeError(`The zone ${tz} could not be read on this runtime.`);
30
+ return Math.round((wall - at) / 60_000);
10
31
  }
11
32
  /** The clock a place's table prints a date in: its own (`Place.clock`) where it has one, else its zone's. Month 0-based. */
12
33
  export function placeClockAt(place, year, month, day) {
@@ -9,7 +9,7 @@ export type FetchOptions = {
9
9
  baseUrl?: string;
10
10
  /** The fetch to use (default: the global one). */
11
11
  fetch?: typeof fetch;
12
- /** SHA-256 of a UTF-8 string as lowercase hex; default: WebCrypto, else node:crypto, else the hash is not checked. */
12
+ /** SHA-256 of a UTF-8 string as lowercase hex; default: WebCrypto where there is one, else a built-in digest. */
13
13
  sha256?: (text: string) => Promise<string>;
14
14
  /** Put a newer list in force in this process (default true). */
15
15
  apply?: boolean;
@@ -52,6 +52,10 @@ export type FetchResult =
52
52
  have: ListSummary;
53
53
  };
54
54
  export declare function summarize(list: CorrectionsFile): ListSummary;
55
+ /** UTF-8 bytes of a string, without TextEncoder (not every runtime has it). */
56
+ export declare function utf8(text: string): Uint8Array;
57
+ /** SHA-256 (FIPS 180-4) of bytes. */
58
+ export declare function sha256(data: Uint8Array): Uint8Array;
55
59
  /** Put a list in force for the process — one verified by `fetchLatestCorrections`, or restored from the app's own storage. */
56
60
  export declare function applyCorrections(authority: Authority, list: CorrectionsFile, revision: number): boolean;
57
61
  export declare function fetchLatestCorrections(opts: FetchOptions): Promise<FetchResult>;
@@ -13,20 +13,107 @@ export function summarize(list) {
13
13
  return { from: list.from, to: list.to, entries: Object.values(list.points).reduce((n, e) => n + e.length, 0) };
14
14
  }
15
15
  const same = (a, b) => a.from === b.from && a.to === b.to && a.entries === b.entries;
16
+ /**
17
+ * SHA-256 as lowercase hex. WebCrypto where the runtime has it (browsers,
18
+ * Node 18+, Deno, Bun); elsewhere — Hermes in React Native has none — the
19
+ * plain-JavaScript digest below. No `node:crypto` import: a bundler for a
20
+ * phone or a browser fails on the import itself, used or not.
21
+ */
16
22
  async function defaultSha256(text) {
17
- const bytes = new TextEncoder().encode(text);
23
+ const bytes = utf8(text);
18
24
  const subtle = globalThis.crypto?.subtle;
19
25
  if (subtle) {
20
- const buf = await subtle.digest('SHA-256', bytes);
21
- return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
26
+ try {
27
+ const buf = await subtle.digest('SHA-256', bytes);
28
+ return hex(new Uint8Array(buf));
29
+ }
30
+ catch {
31
+ /* a subtle that refuses (an insecure context): the digest below */
32
+ }
22
33
  }
23
- try {
24
- const { createHash } = await import('node:crypto');
25
- return createHash('sha256').update(bytes).digest('hex');
34
+ return hex(sha256(bytes));
35
+ }
36
+ const hex = (b) => Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
37
+ /** UTF-8 bytes of a string, without TextEncoder (not every runtime has it). */
38
+ export function utf8(text) {
39
+ const out = [];
40
+ for (let i = 0; i < text.length; i++) {
41
+ let c = text.charCodeAt(i);
42
+ if (c >= 0xd800 && c < 0xdc00 && i + 1 < text.length) {
43
+ const d = text.charCodeAt(i + 1);
44
+ if (d >= 0xdc00 && d < 0xe000) {
45
+ c = 0x10000 + ((c - 0xd800) << 10) + (d - 0xdc00);
46
+ i++;
47
+ }
48
+ }
49
+ if (c < 0x80)
50
+ out.push(c);
51
+ else if (c < 0x800)
52
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
53
+ else if (c < 0x10000)
54
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
55
+ else
56
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
26
57
  }
27
- catch {
28
- return null;
58
+ return Uint8Array.from(out);
59
+ }
60
+ const K = new Uint32Array([
61
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
62
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
63
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
64
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
65
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
66
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
67
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
68
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
69
+ ]);
70
+ /** SHA-256 (FIPS 180-4) of bytes. */
71
+ export function sha256(data) {
72
+ const bitLen = data.length * 8;
73
+ const padded = new Uint8Array(((data.length + 9 + 63) >> 6) << 6);
74
+ padded.set(data);
75
+ padded[data.length] = 0x80;
76
+ const view = new DataView(padded.buffer);
77
+ view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
78
+ view.setUint32(padded.length - 4, bitLen >>> 0);
79
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
80
+ const w = new Uint32Array(64);
81
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
82
+ for (let off = 0; off < padded.length; off += 64) {
83
+ for (let i = 0; i < 16; i++)
84
+ w[i] = view.getUint32(off + i * 4);
85
+ for (let i = 16; i < 64; i++) {
86
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
87
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
88
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
89
+ }
90
+ let [a, b, c, d, e, f, g, hh] = h;
91
+ for (let i = 0; i < 64; i++) {
92
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
93
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
94
+ hh = g;
95
+ g = f;
96
+ f = e;
97
+ e = (d + t1) >>> 0;
98
+ d = c;
99
+ c = b;
100
+ b = a;
101
+ a = (t1 + t2) >>> 0;
102
+ }
103
+ h[0] += a;
104
+ h[1] += b;
105
+ h[2] += c;
106
+ h[3] += d;
107
+ h[4] += e;
108
+ h[5] += f;
109
+ h[6] += g;
110
+ h[7] += hh;
29
111
  }
112
+ const out = new Uint8Array(32);
113
+ const ov = new DataView(out.buffer);
114
+ for (let i = 0; i < 8; i++)
115
+ ov.setUint32(i * 4, h[i]);
116
+ return out;
30
117
  }
31
118
  /** Put a list in force for the process — one verified by `fetchLatestCorrections`, or restored from the app's own storage. */
32
119
  export function applyCorrections(authority, list, revision) {
@@ -84,14 +171,13 @@ export async function fetchLatestCorrections(opts) {
84
171
  const summary = summarize(list);
85
172
  if (!same(summary, named))
86
173
  return { status: 'rejected', reason: 'the list is not the one the certification names', have };
174
+ // A certification without a hash (one written before 1.0.0) is taken on its identity alone.
87
175
  let integrity = 'unchecked';
88
176
  if (named.sha256) {
89
177
  const hash = opts.sha256 ? await opts.sha256(text) : await defaultSha256(text);
90
- if (hash !== null) {
91
- if (hash.toLowerCase() !== named.sha256.toLowerCase())
92
- return { status: 'rejected', reason: 'the list’s SHA-256 is not the one the certification names', have };
93
- integrity = 'verified';
94
- }
178
+ if (hash.toLowerCase() !== named.sha256.toLowerCase())
179
+ return { status: 'rejected', reason: 'the list’s SHA-256 is not the one the certification names', have };
180
+ integrity = 'verified';
95
181
  }
96
182
  const applied = opts.apply === false ? false : applyCorrections(opts.authority, list, revision);
97
183
  return { status: 'updated', list, summary, integrity, applied };
@@ -10,7 +10,7 @@ export type MethodDescription = Pick<MethodInfo, 'name' | 'method' | 'countries'
10
10
  export type AuthorityData = {
11
11
  authority: Authority;
12
12
  revision: number;
13
- places: Place[];
13
+ places: readonly Place[];
14
14
  corrections: CorrectionList;
15
15
  certification: Certification | null;
16
16
  description: MethodDescription;
@@ -4,6 +4,11 @@ export function registerAuthority(entry) {
4
4
  const have = registry.get(entry.authority);
5
5
  if (have)
6
6
  return have;
7
+ // Frozen: every caller shares these objects, and a place moved by one
8
+ // (`place.lat = …`) would move the corrections it is looked up by for all.
9
+ for (const p of entry.places)
10
+ Object.freeze(p);
11
+ Object.freeze(entry.places);
7
12
  const data = { ...entry, corrections: new CorrectionList(entry.corrections) };
8
13
  registry.set(entry.authority, data);
9
14
  return data;
@@ -10,7 +10,7 @@
10
10
  * that into the authority whose table the point's community prays by, or
11
11
  * null where none of the five applies.
12
12
  *
13
- * Its own entry point because the outlines are 400 KB an app that already
13
+ * Its own entry point because the outlines are 470 KB an app that already
14
14
  * knows its country does not need.
15
15
  */
16
16
  import outlines from './data/generated/countryOutlines.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihrab-methods",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "The prayer-time authorities' own calculation methods, each reproducing its published timetable to the minute: Sweden (Diyanet, as Islamiska Förbundet prints it), Diyanet worldwide, Morocco (Habous), Algeria (Ministry of Religious Affairs), Indonesia (Kemenag).",
5
5
  "license": "MPL-2.0",
6
6
  "author": "Hassan El Ghamri",