@hebcal/icalendar 6.3.7 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # @hebcal/icalendar
2
+
2
3
  Jewish holidays and Hebrew calendar as iCalendar RFC 2445
3
4
 
4
5
  ## Installation
6
+
5
7
  ```bash
6
8
  $ npm install @hebcal/icalendar
7
9
  ```
8
10
 
9
11
  ## Synopsis
12
+
10
13
  ```javascript
11
14
  import {HebrewCalendar, Location} from '@hebcal/core';
12
15
  import {eventsToIcalendar} from '@hebcal/icalendar';
@@ -24,7 +27,7 @@ const events = HebrewCalendar.calendar(options);
24
27
  const str = await eventsToIcalendar(events, {
25
28
  locale: 'he',
26
29
  ...options,
27
- })
30
+ });
28
31
 
29
32
  const icalStream = fs.createWriteStream('feed.ics');
30
33
  icalStream.write(str);
@@ -0,0 +1 @@
1
+ export declare function foldLine(line: string): string;
@@ -0,0 +1,47 @@
1
+ import { Buffer } from 'node:buffer';
2
+ let foldSegmenter;
3
+ const char74re = /.{1,74}/g;
4
+ export function foldLine(line) {
5
+ const lineBytes = Buffer.byteLength(line);
6
+ if (lineBytes <= 74) {
7
+ return line;
8
+ }
9
+ if (lineBytes === line.length) {
10
+ // All-ASCII fast path: every UTF-16 unit encodes to one UTF-8 byte
11
+ // iff the string is pure ASCII, so we can split by JS chars.
12
+ return line.match(char74re).join('\r\n ');
13
+ }
14
+ // Walk grapheme clusters so we never split a multi-octet UTF-8
15
+ // sequence (or a combining/ZWJ/regional-indicator cluster) across
16
+ // a fold boundary. Reuse a single Segmenter to avoid per-call
17
+ // construction cost.
18
+ if (!foldSegmenter) {
19
+ foldSegmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
20
+ }
21
+ const parts = [];
22
+ let chunkStart = 0;
23
+ let len = 0;
24
+ for (const { segment, index } of foldSegmenter.segment(line)) {
25
+ const cp = segment.codePointAt(0);
26
+ // Most segments are a single code point; compute UTF-8 length
27
+ // arithmetically and skip the Buffer.byteLength call.
28
+ const cpUnits = cp >= 0x10000 ? 2 : 1;
29
+ let octets;
30
+ if (segment.length === cpUnits) {
31
+ octets = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
32
+ }
33
+ else {
34
+ octets = Buffer.byteLength(segment);
35
+ }
36
+ if (len + octets >= 75) {
37
+ parts.push(line.slice(chunkStart, index));
38
+ chunkStart = index;
39
+ len = octets;
40
+ }
41
+ else {
42
+ len += octets;
43
+ }
44
+ }
45
+ parts.push(line.slice(chunkStart));
46
+ return parts.join('\r\n ');
47
+ }
@@ -3,6 +3,13 @@ import { CalOptions } from '@hebcal/core/dist/esm/CalOptions';
3
3
  export type ICalEventOptions = {
4
4
  dtstamp?: string;
5
5
  sequence?: number;
6
+ /**
7
+ * Text for the `DESCRIPTION` property, overriding `ev.memo`. This is
8
+ * per-event, so pass a fresh options object to each `IcalEvent` (for
9
+ * example `new IcalEvent(ev, {...opts, memo})`) rather than setting it on
10
+ * an options object shared by a whole calendar.
11
+ */
12
+ memo?: string;
6
13
  emoji?: boolean;
7
14
  utmSource?: string;
8
15
  utmMedium?: string;
@@ -22,20 +29,20 @@ export type ICalOptions = CalOptions & ICalEventOptions;
22
29
  * Represents an RFC 2445 iCalendar VEVENT
23
30
  */
24
31
  export declare class IcalEvent {
25
- ev: Event;
26
- options: ICalOptions;
27
- dtstamp: string;
28
- sequence?: number;
29
- timed: boolean;
30
- locationName: string | null | undefined;
31
- startDate: string;
32
- isoDateOnly: string;
33
- dtargs: string;
34
- transp: string;
35
- busyStatus: string;
36
- endDate: string;
37
- subj: string;
38
- category: string | null;
32
+ readonly ev: Event;
33
+ readonly options: ICalOptions;
34
+ readonly dtstamp: string;
35
+ readonly sequence?: number;
36
+ readonly timed: boolean;
37
+ readonly locationName: string | null | undefined;
38
+ readonly startDate: string;
39
+ readonly isoDateOnly: string;
40
+ readonly dtargs: string;
41
+ readonly transp: string;
42
+ readonly busyStatus: string;
43
+ readonly endDate: string;
44
+ readonly subj: string;
45
+ readonly category: string | null;
39
46
  lines?: string[];
40
47
  /**
41
48
  * Builds an IcalEvent object from a Hebcal Event
package/dist/icalendar.js CHANGED
@@ -1,17 +1,14 @@
1
- /*! @hebcal/icalendar v6.3.7 */
2
- import { Buffer } from 'node:buffer';
3
1
  import { flags } from '@hebcal/core/dist/esm/event';
4
2
  import { Locale } from '@hebcal/core/dist/esm/locale';
5
3
  import { murmur32HexSync } from 'murmurhash3';
6
- import { pad2, isDate, pad4 } from '@hebcal/hdate';
7
- import { shouldRenderBrief, getEventCategories, getCalendarTitle } from '@hebcal/rest-api/dist/esm/common';
8
- import { getHolidayDescription } from '@hebcal/rest-api/dist/esm/holiday';
9
- import { makeTorahMemoText } from '@hebcal/rest-api/dist/esm/memo';
4
+ import { pad2, pad4, isDate } from '@hebcal/hdate';
5
+ import { getCalendarTitle, getEventCategories, shouldRenderBrief, } from '@hebcal/rest-api/dist/esm/common';
10
6
  import { appendIsraelAndTracking } from '@hebcal/rest-api/dist/esm/url';
11
7
  import { makeAnchor } from '@hebcal/rest-api/dist/esm/makeAnchor';
12
- import { promises } from 'node:fs';
8
+ import { promises as fs } from 'node:fs';
13
9
  import { version } from './pkgVersion.js';
14
-
10
+ import { foldLine } from './foldLine.js';
11
+ const ESC_NEWLINE = String.raw `\n`;
15
12
  const vtimezoneCache = new Map();
16
13
  const CATEGORY = {
17
14
  candles: 'Holiday',
@@ -57,7 +54,6 @@ function appendTrackingToUrl(url, options) {
57
54
  const utmCampaign = options.utmCampaign;
58
55
  return appendIsraelAndTracking(url, options.il, utmSource, utmMedium, utmCampaign);
59
56
  }
60
- const char74re = /(.{1,74})/g;
61
57
  const DAILY_LEARNING = flags.DAILY_LEARNING |
62
58
  flags.DAF_YOMI |
63
59
  flags.MISHNA_YOMI |
@@ -66,7 +62,7 @@ const DAILY_LEARNING = flags.DAILY_LEARNING |
66
62
  /**
67
63
  * Represents an RFC 2445 iCalendar VEVENT
68
64
  */
69
- class IcalEvent {
65
+ export class IcalEvent {
70
66
  ev;
71
67
  options;
72
68
  dtstamp;
@@ -161,7 +157,9 @@ class IcalEvent {
161
157
  }
162
158
  }
163
159
  this.subj = subj;
164
- this.category = ev0.category || CATEGORY[getEventCategories(ev)?.[0]];
160
+ this.category =
161
+ ev0.category ||
162
+ (CATEGORY[getEventCategories(ev)?.[0]] ?? null);
165
163
  }
166
164
  getAlarm() {
167
165
  const ev = this.ev;
@@ -236,9 +234,13 @@ class IcalEvent {
236
234
  arr.push('CLASS:PUBLIC');
237
235
  }
238
236
  const options = this.options;
239
- // create memo (holiday descr, Torah, etc)
240
- const memo = createMemo(ev, options);
241
- addOptional(arr, 'DESCRIPTION', memo);
237
+ // The caller is responsible for building the memo (holiday description,
238
+ // Torah reading, URL, etc), either as `options.memo` or on the event
239
+ // itself; we only escape newlines for RFC 5545
240
+ const memo = options.memo ?? ev.memo;
241
+ if (memo) {
242
+ addOptional(arr, 'DESCRIPTION', memo.replaceAll('\n', ESC_NEWLINE));
243
+ }
242
244
  addOptional(arr, 'LOCATION', this.locationName);
243
245
  const loc = options.location;
244
246
  if (this.timed && loc) {
@@ -266,50 +268,13 @@ class IcalEvent {
266
268
  * fold lines to 75 characters
267
269
  */
268
270
  getLines() {
269
- return this.getLongLines().map(IcalEvent.fold);
271
+ return this.getLongLines().map(foldLine);
270
272
  }
271
273
  /**
272
274
  * fold line to 75 characters
273
275
  */
274
276
  static fold(line) {
275
- if (Buffer.byteLength(line) <= 74) {
276
- return line;
277
- }
278
- let isASCII = true;
279
- for (let i = 0; i < line.length; i++) {
280
- if (line.codePointAt(i) > 255) {
281
- isASCII = false;
282
- break;
283
- }
284
- }
285
- if (isASCII) {
286
- // It's longer than 74 octets, and all characters are ASCII, so we
287
- // use a simple regex to split it into chunks of 74 characters
288
- const matches = line.match(char74re);
289
- return matches.join('\r\n ');
290
- }
291
- // iterate unicode character by character, making sure
292
- // that adding a new character would keep the line <= 75 octets
293
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
294
- const segments = segmenter.segment(line);
295
- const chars = Array.from(segments, s => s.segment);
296
- let result = '';
297
- let current = '';
298
- let len = 0;
299
- for (const ch of chars) {
300
- const octets = ch.codePointAt(0) < 256 ? 1 : Buffer.byteLength(ch);
301
- const newlen = len + octets;
302
- if (newlen < 75) {
303
- current += ch;
304
- len = newlen;
305
- }
306
- else {
307
- result += current + '\r\n ';
308
- current = ch;
309
- len = octets;
310
- }
311
- }
312
- return result + current;
277
+ return foldLine(line);
313
278
  }
314
279
  static escape(str) {
315
280
  if (str.includes(',')) {
@@ -343,96 +308,14 @@ class IcalEvent {
343
308
  * Transforms a single Event into a VEVENT string
344
309
  * @returns multi-line result, delimited by \r\n
345
310
  */
346
- function eventToIcal(ev, options) {
311
+ export function eventToIcal(ev, options) {
347
312
  const ical = new IcalEvent(ev, options);
348
313
  return ical.toString();
349
314
  }
350
- const torahMemoCache = new Map();
351
- const HOLIDAY_IGNORE_MASK = DAILY_LEARNING |
352
- flags.OMER_COUNT |
353
- flags.SHABBAT_MEVARCHIM |
354
- flags.MOLAD |
355
- flags.USER_EVENT |
356
- flags.HEBREW_DATE;
357
- const ESC_NEWLINE = String.raw `\n`;
358
- const DBL_NEWLINE = ESC_NEWLINE + ESC_NEWLINE;
359
- /**
360
- * @private
361
- */
362
- function makeTorahMemo(ev, il) {
363
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
364
- if (ev.getFlags() & HOLIDAY_IGNORE_MASK || ev.eventTime) {
365
- return '';
366
- }
367
- const hd = ev.getDate();
368
- const yy = hd.getFullYear();
369
- const mm = hd.getMonth();
370
- const dd = hd.getDate();
371
- const key = [yy, mm, dd, il ? '1' : '0', ev.getDesc()].join('-');
372
- let memo = torahMemoCache.get(key);
373
- if (typeof memo === 'string') {
374
- return memo;
375
- }
376
- memo = makeTorahMemoText(ev, il).replaceAll('\n', ESC_NEWLINE);
377
- torahMemoCache.set(key, memo);
378
- return memo;
379
- }
380
- /**
381
- * @private
382
- */
383
- function createMemo(ev, options) {
384
- let memo = ev.memo || '';
385
- if (memo.length && memo.includes('\n')) {
386
- memo = memo.replaceAll('\n', ESC_NEWLINE);
387
- }
388
- const desc = ev.getDesc();
389
- if (desc === 'Havdalah' || desc === 'Candle lighting') {
390
- return memo;
391
- }
392
- const mask = ev.getFlags();
393
- if (mask & flags.OMER_COUNT) {
394
- const omerEv = ev;
395
- const sefira = [
396
- omerEv.sefira('en'),
397
- omerEv.sefira('he'),
398
- omerEv.sefira('translit'),
399
- ].join(ESC_NEWLINE);
400
- return (omerEv.getTodayIs('en') +
401
- DBL_NEWLINE +
402
- omerEv.getTodayIs('he') +
403
- DBL_NEWLINE +
404
- sefira);
405
- }
406
- if (!memo) {
407
- memo = getHolidayDescription(ev);
408
- }
409
- if (!memo) {
410
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
411
- const linkEv = ev.linkedEvent;
412
- if (linkEv && linkEv.getDesc() !== ev.getDesc()) {
413
- memo = linkEv.render(options.locale);
414
- }
415
- }
416
- const torahMemo = makeTorahMemo(ev, options.il);
417
- if (torahMemo) {
418
- if (memo.length) {
419
- memo += DBL_NEWLINE;
420
- }
421
- memo += torahMemo;
422
- }
423
- const url = appendTrackingToUrl(ev.url(), options);
424
- if (url) {
425
- if (memo.length) {
426
- memo += DBL_NEWLINE;
427
- }
428
- memo += url;
429
- }
430
- return memo;
431
- }
432
315
  /**
433
316
  * Generates an RFC 2445 iCalendar string from an array of events
434
317
  */
435
- async function eventsToIcalendar(events, options) {
318
+ export async function eventsToIcalendar(events, options) {
436
319
  if (!events.length)
437
320
  throw new RangeError('Events can not be empty');
438
321
  if (!options)
@@ -461,7 +344,7 @@ async function getVtimezone(tzid) {
461
344
  }
462
345
  const filename = `./zoneinfo/${tzid}.ics`;
463
346
  try {
464
- const contents = await promises.readFile(filename, 'utf-8');
347
+ const contents = await fs.readFile(filename, 'utf-8');
465
348
  const lines = contents.split('\r\n');
466
349
  // ignore first 3 and last 1 lines
467
350
  const str = lines.slice(3, lines.length - 2).join('\r\n');
@@ -510,14 +393,14 @@ function makeIcalPreamble(opts) {
510
393
  /**
511
394
  * Generates an RFC 2445 iCalendar string from an array of IcalEvents
512
395
  */
513
- async function icalEventsToString(icals, options) {
396
+ export async function icalEventsToString(icals, options) {
514
397
  if (!icals.length)
515
398
  throw new RangeError('Events can not be empty');
516
399
  if (!options)
517
400
  throw new TypeError('Invalid options object');
518
401
  const stream = [];
519
402
  const preamble = makeIcalPreamble(options);
520
- for (const line of preamble.map(IcalEvent.fold)) {
403
+ for (const line of preamble.map(foldLine)) {
521
404
  stream.push(line);
522
405
  stream.push('\r\n');
523
406
  }
@@ -541,5 +424,3 @@ async function icalEventsToString(icals, options) {
541
424
  stream.push('END:VCALENDAR\r\n');
542
425
  return stream.join('');
543
426
  }
544
-
545
- export { IcalEvent, eventToIcal, eventsToIcalendar, icalEventsToString };
@@ -1 +1 @@
1
- export declare const version = "6.3.7";
1
+ export declare const version = "7.0.0";
@@ -1,5 +1,2 @@
1
- /*! @hebcal/icalendar v6.3.7 */
2
1
  // DO NOT EDIT THIS AUTO-GENERATED FILE!
3
- const version = '6.3.7';
4
-
5
- export { version };
2
+ export const version = '7.0.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hebcal/icalendar",
3
- "version": "6.3.7",
3
+ "version": "7.0.0",
4
4
  "author": "Michael J. Radwin (https://github.com/mjradwin)",
5
5
  "keywords": [
6
6
  "ical",
@@ -30,33 +30,32 @@
30
30
  },
31
31
  "homepage": "https://hebcal.github.io/api/icalendar/",
32
32
  "dependencies": {
33
- "@hebcal/core": "^6.1.0",
34
- "@hebcal/hdate": "^0.22.0",
35
- "@hebcal/rest-api": "^6.4.3",
36
- "murmurhash3": "^0.5.0",
37
- "tslib": "^2.8.1"
33
+ "@hebcal/core": "^6.8.2",
34
+ "@hebcal/hdate": "^0.22.5",
35
+ "@hebcal/rest-api": "^7.0.0",
36
+ "murmurhash3": "^0.5.0"
38
37
  },
39
38
  "scripts": {
40
- "build:rollup": "rollup -c",
41
- "build:version": "node ./version.cjs package.json src/pkgVersion.ts",
42
- "build": "npm run build:version && npm run build:rollup",
39
+ "build:version": "node ./version.js package.json src/pkgVersion.ts",
40
+ "build": "npm run build:version && tsc",
43
41
  "pretest": "npm run build",
44
- "docs": "typedoc",
42
+ "docs": "npx -y typedoc",
45
43
  "test": "vitest",
46
- "lint": "gts lint",
47
- "clean": "gts clean",
44
+ "lint": "oxlint && prettier --check .",
45
+ "clean": "rm -rf dist",
48
46
  "compile": "tsc",
49
- "fix": "gts fix"
47
+ "fix": "oxlint --fix && prettier --write ."
50
48
  },
51
49
  "license": "BSD-2-Clause",
52
50
  "devDependencies": {
53
- "@hebcal/learning": "^6.7.2",
54
- "@rollup/plugin-typescript": "^12.3.0",
55
- "@types/node": "25.6.0",
56
- "gts": "^7.0.0",
57
- "rollup": "^4.60.1",
58
- "typedoc": "^0.28.18",
59
- "typescript": "^6.0.2",
60
- "vitest": "^4.1.4"
51
+ "@hebcal/learning": "^6.9.5",
52
+ "@types/node": "26.1.1",
53
+ "oxlint": "^1.76.0",
54
+ "prettier": "^3.9.6",
55
+ "typescript": "^7.0.2",
56
+ "vitest": "^4.1.10"
57
+ },
58
+ "allowScripts": {
59
+ "murmurhash3": true
61
60
  }
62
61
  }