@craftguild/jscalendar 0.2.0 → 0.3.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.
Files changed (99) hide show
  1. package/README.md +63 -0
  2. package/dist/__tests__/builders.test.d.ts +1 -0
  3. package/dist/__tests__/builders.test.js +82 -0
  4. package/dist/__tests__/calendar-extra.test.js +36 -0
  5. package/dist/__tests__/recurrence.test.js +123 -0
  6. package/dist/__tests__/search.test.js +27 -0
  7. package/dist/__tests__/utils.test.js +3 -0
  8. package/dist/__tests__/validation.test.js +113 -0
  9. package/dist/ical.d.ts +6 -0
  10. package/dist/ical.js +71 -3
  11. package/dist/jscal/base.d.ts +90 -0
  12. package/dist/jscal/base.js +181 -0
  13. package/dist/jscal/builders.d.ts +135 -0
  14. package/dist/jscal/builders.js +220 -0
  15. package/dist/jscal/constants.d.ts +11 -0
  16. package/dist/jscal/constants.js +11 -0
  17. package/dist/jscal/datetime.d.ts +14 -0
  18. package/dist/jscal/datetime.js +42 -0
  19. package/dist/jscal/defaults.d.ts +31 -0
  20. package/dist/jscal/defaults.js +102 -0
  21. package/dist/jscal/duration.d.ts +43 -0
  22. package/dist/jscal/duration.js +72 -0
  23. package/dist/jscal/event.d.ts +17 -0
  24. package/dist/jscal/event.js +71 -0
  25. package/dist/jscal/group.d.ts +25 -0
  26. package/dist/jscal/group.js +62 -0
  27. package/dist/jscal/guards.d.ts +19 -0
  28. package/dist/jscal/guards.js +25 -0
  29. package/dist/jscal/ids.d.ts +11 -0
  30. package/dist/jscal/ids.js +77 -0
  31. package/dist/jscal/normalize.d.ts +32 -0
  32. package/dist/jscal/normalize.js +45 -0
  33. package/dist/jscal/task.d.ts +17 -0
  34. package/dist/jscal/task.js +60 -0
  35. package/dist/jscal/types.d.ts +38 -0
  36. package/dist/jscal/types.js +1 -0
  37. package/dist/jscal.d.ts +77 -70
  38. package/dist/jscal.js +77 -465
  39. package/dist/patch.d.ts +13 -0
  40. package/dist/patch.js +166 -41
  41. package/dist/recurrence/constants.d.ts +13 -0
  42. package/dist/recurrence/constants.js +13 -0
  43. package/dist/recurrence/date-utils.d.ts +125 -0
  44. package/dist/recurrence/date-utils.js +259 -0
  45. package/dist/recurrence/expand.d.ts +23 -0
  46. package/dist/recurrence/expand.js +294 -0
  47. package/dist/recurrence/rule-candidates.d.ts +21 -0
  48. package/dist/recurrence/rule-candidates.js +120 -0
  49. package/dist/recurrence/rule-generate.d.ts +11 -0
  50. package/dist/recurrence/rule-generate.js +36 -0
  51. package/dist/recurrence/rule-matchers.d.ts +34 -0
  52. package/dist/recurrence/rule-matchers.js +120 -0
  53. package/dist/recurrence/rule-normalize.d.ts +9 -0
  54. package/dist/recurrence/rule-normalize.js +57 -0
  55. package/dist/recurrence/rule-selectors.d.ts +7 -0
  56. package/dist/recurrence/rule-selectors.js +21 -0
  57. package/dist/recurrence/rules.d.ts +14 -0
  58. package/dist/recurrence/rules.js +57 -0
  59. package/dist/recurrence/types.d.ts +27 -0
  60. package/dist/recurrence/types.js +1 -0
  61. package/dist/recurrence.d.ts +2 -15
  62. package/dist/recurrence.js +1 -674
  63. package/dist/search.d.ts +30 -0
  64. package/dist/search.js +92 -8
  65. package/dist/timezones/chunk_1.d.ts +2 -0
  66. package/dist/timezones/chunk_1.js +72 -0
  67. package/dist/timezones/chunk_2.d.ts +2 -0
  68. package/dist/timezones/chunk_2.js +72 -0
  69. package/dist/timezones/chunk_3.d.ts +2 -0
  70. package/dist/timezones/chunk_3.js +72 -0
  71. package/dist/timezones/chunk_4.d.ts +2 -0
  72. package/dist/timezones/chunk_4.js +72 -0
  73. package/dist/timezones/chunk_5.d.ts +2 -0
  74. package/dist/timezones/chunk_5.js +72 -0
  75. package/dist/timezones/chunk_6.d.ts +2 -0
  76. package/dist/timezones/chunk_6.js +72 -0
  77. package/dist/timezones/chunk_7.d.ts +2 -0
  78. package/dist/timezones/chunk_7.js +6 -0
  79. package/dist/timezones.d.ts +5 -0
  80. package/dist/timezones.js +14 -3
  81. package/dist/utils.d.ts +72 -0
  82. package/dist/utils.js +85 -1
  83. package/dist/validate/asserts.d.ts +155 -0
  84. package/dist/validate/asserts.js +381 -0
  85. package/dist/validate/constants.d.ts +25 -0
  86. package/dist/validate/constants.js +33 -0
  87. package/dist/validate/error.d.ts +19 -0
  88. package/dist/validate/error.js +25 -0
  89. package/dist/validate/validators-common.d.ts +64 -0
  90. package/dist/validate/validators-common.js +385 -0
  91. package/dist/validate/validators-objects.d.ts +8 -0
  92. package/dist/validate/validators-objects.js +70 -0
  93. package/dist/validate/validators-recurrence.d.ts +15 -0
  94. package/dist/validate/validators-recurrence.js +115 -0
  95. package/dist/validate/validators.d.ts +1 -0
  96. package/dist/validate/validators.js +1 -0
  97. package/dist/validate.d.ts +2 -6
  98. package/dist/validate.js +2 -745
  99. package/package.json +1 -1
package/README.md CHANGED
@@ -72,6 +72,69 @@ const group = new JsCal.Group({
72
72
  });
73
73
  ```
74
74
 
75
+ ## Builder Helpers (Strict, Validated Inputs)
76
+
77
+ JSCalendar objects require `@type` fields for nested objects like
78
+ participants, locations, alerts, and recurrence rules. Requiring every
79
+ caller to manually specify `@type` is noisy and error-prone. To avoid
80
+ leaking RFC-specific details into app code, this library provides
81
+ **builder helpers** that:
82
+
83
+ - Fill in the correct `@type`
84
+ - Validate the result against RFC 8984 immediately
85
+
86
+ You can still pass strict plain JSCalendar objects directly (e.g., from
87
+ your database), but builders offer a safer, clearer option for app code.
88
+
89
+ ### Using builders (recommended for app code)
90
+
91
+ ```ts
92
+ const task = new JsCal.Task({
93
+ title: "Write report",
94
+ start: "2026-02-11T09:00:00",
95
+ participants: JsCal.participants([
96
+ JsCal.Participant({ name: "Alice", email: "a@example.com" }),
97
+ JsCal.Participant({ name: "Bob" }),
98
+ ]),
99
+ locations: JsCal.locations([
100
+ JsCal.Location({ name: "Room A" }),
101
+ ]),
102
+ alerts: JsCal.alerts([
103
+ JsCal.Alert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } }),
104
+ ]),
105
+ });
106
+ ```
107
+
108
+ ### Using strict plain objects (for DB/imported data)
109
+
110
+ ```ts
111
+ const task = new JsCal.Task({
112
+ title: "Imported task",
113
+ start: "2026-02-11T09:00:00",
114
+ participants: {
115
+ p1: { "@type": "Participant", name: "Alice", email: "a@example.com" },
116
+ },
117
+ locations: {
118
+ l1: { "@type": "Location", name: "Room A" },
119
+ },
120
+ alerts: {
121
+ a1: { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } },
122
+ },
123
+ });
124
+ ```
125
+
126
+ ### Validation behavior
127
+
128
+ Builders validate on creation, and constructors validate on object
129
+ creation. That means:
130
+
131
+ - Builder inputs are validated immediately
132
+ - `new JsCal.Event/Task/Group(...)` also validates the final object
133
+
134
+ This double validation is intentional for safety: even if data originates
135
+ from an untrusted or inconsistent source, you still get strict RFC 8984
136
+ checks at the point of object creation.
137
+
75
138
  ## Ejecting to plain objects
76
139
 
77
140
  `JsCal.Event`/`JsCal.Task` are class instances with helper methods and a
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JsCal } from "../jscal.js";
3
+ import { buildAlert, buildIdMap, buildLink, buildLocation, buildNDay, buildRecurrenceRule, buildRelation, buildTimeZone, buildTimeZoneMap, buildTimeZoneRule, buildVirtualLocation, } from "../jscal/builders.js";
4
+ describe("builders", () => {
5
+ it("builds strict objects with @type", () => {
6
+ const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
7
+ const location = buildLocation({ name: "Room A" });
8
+ const vloc = buildVirtualLocation({ name: "Zoom", uri: "https://example.com" });
9
+ const link = buildLink({ href: "https://example.com" });
10
+ const relation = buildRelation({ relation: { parent: true } });
11
+ const alert = buildAlert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } });
12
+ const nday = buildNDay({ day: "mo" });
13
+ const rule = buildRecurrenceRule({ frequency: "daily" });
14
+ const tzRule = buildTimeZoneRule({
15
+ start: "2026-01-01T00:00:00",
16
+ offsetFrom: "+09:00",
17
+ offsetTo: "+09:00",
18
+ });
19
+ const tz = buildTimeZone({
20
+ tzId: "Asia/Tokyo",
21
+ standard: [tzRule],
22
+ });
23
+ expect(participant["@type"]).toBe("Participant");
24
+ expect(location["@type"]).toBe("Location");
25
+ expect(vloc["@type"]).toBe("VirtualLocation");
26
+ expect(link["@type"]).toBe("Link");
27
+ expect(relation["@type"]).toBe("Relation");
28
+ expect(alert["@type"]).toBe("Alert");
29
+ expect(nday["@type"]).toBe("NDay");
30
+ expect(rule["@type"]).toBe("RecurrenceRule");
31
+ expect(tz["@type"]).toBe("TimeZone");
32
+ });
33
+ it("builds id maps with generated ids", () => {
34
+ const participants = JsCal.participants([
35
+ { name: "Alice", roles: { attendee: true } },
36
+ { name: "Bob", roles: { attendee: true } },
37
+ ]);
38
+ expect(Object.keys(participants).length).toBe(2);
39
+ const locations = JsCal.locations([{ name: "Room A" }]);
40
+ expect(Object.keys(locations).length).toBe(1);
41
+ const links = JsCal.links([{ href: "https://example.com" }]);
42
+ expect(Object.keys(links).length).toBe(1);
43
+ const related = JsCal.relatedTo([{ relation: { parent: true } }]);
44
+ expect(Object.keys(related).length).toBe(1);
45
+ });
46
+ it("builds a time zone map keyed by tzId", () => {
47
+ const map = buildTimeZoneMap([
48
+ { tzId: "Asia/Tokyo", standard: [{ "@type": "TimeZoneRule", start: "2026-01-01T00:00:00", offsetFrom: "+09:00", offsetTo: "+09:00" }] },
49
+ ]);
50
+ expect(map["Asia/Tokyo"]?.tzId).toBe("Asia/Tokyo");
51
+ });
52
+ it("buildIdMap uses a custom id function", () => {
53
+ const map = buildIdMap([{ name: "A" }], (item) => item, (_item, index) => `id-${index}`);
54
+ expect(Object.keys(map)).toEqual(["id-0"]);
55
+ });
56
+ it("throws when @type mismatches", () => {
57
+ // Intentionally bypass type safety to assert @type validation errors.
58
+ const bad = { "@type": "Location" };
59
+ expect(() => JsCal.Participant(bad)).toThrowError("participant: must have @type Participant");
60
+ });
61
+ it("throws for mismatched @type across builders", () => {
62
+ // Intentionally bypass type safety to assert @type validation errors.
63
+ expect(() => buildLocation({ "@type": "Participant" }))
64
+ .toThrowError("location: must have @type Location");
65
+ expect(() => buildVirtualLocation({ "@type": "Location" }))
66
+ .toThrowError("virtualLocation: must have @type VirtualLocation");
67
+ expect(() => buildAlert({ "@type": "Link" }))
68
+ .toThrowError("alert: must have @type Alert");
69
+ expect(() => buildRelation({ "@type": "Link" }))
70
+ .toThrowError("relation: must have @type Relation");
71
+ expect(() => buildLink({ "@type": "Relation" }))
72
+ .toThrowError("link: must have @type Link");
73
+ expect(() => buildTimeZone({ "@type": "Alert" }))
74
+ .toThrowError("timeZone: must have @type TimeZone");
75
+ expect(() => buildTimeZoneRule({ "@type": "TimeZone" }))
76
+ .toThrowError("timeZoneRule: must have @type TimeZoneRule");
77
+ expect(() => buildRecurrenceRule({ "@type": "NDay" }))
78
+ .toThrowError("recurrenceRule: must have @type RecurrenceRule");
79
+ expect(() => buildNDay({ "@type": "RecurrenceRule" }))
80
+ .toThrowError("nday: must have @type NDay");
81
+ });
82
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { JsCal, createId, isEvent, isGroup, isTask } from "../jscal.js";
3
+ import { Base } from "../jscal/base.js";
3
4
  const fixedNow = () => "2026-02-01T00:00:00Z";
4
5
  function makeEvent() {
5
6
  return new JsCal.Event({
@@ -14,6 +15,16 @@ describe("JsCal helpers", () => {
14
15
  group.addEntry(event.data);
15
16
  expect(group.data.entries.length).toBe(1);
16
17
  });
18
+ it("accepts JsCal instances and ejected entries in groups", () => {
19
+ const event = makeEvent();
20
+ const task = new JsCal.Task({ title: "Task", start: "2026-02-02T10:00:00" }, { now: fixedNow });
21
+ const group = new JsCal.Group({
22
+ entries: [event, task.eject()],
23
+ }, { now: fixedNow });
24
+ expect(group.data.entries.length).toBe(2);
25
+ expect(group.data.entries[0]?.["@type"]).toBe("Event");
26
+ expect(group.data.entries[1]?.["@type"]).toBe("Task");
27
+ });
17
28
  it("does not expose addEntry on non-group", () => {
18
29
  const event = makeEvent();
19
30
  expect("addEntry" in event).toBe(false);
@@ -79,6 +90,31 @@ describe("JsCal helpers", () => {
79
90
  json.title = "Changed";
80
91
  expect(event.data.title).toBe("Kickoff");
81
92
  });
93
+ it("clones Base instances with deep-copied data", () => {
94
+ const base = new Base({ "@type": "Event", uid: "base", updated: "2026-02-01T00:00:00Z", start: "2026-02-01T10:00:00" });
95
+ const cloned = base.clone();
96
+ cloned.data.uid = "changed";
97
+ expect(base.data.uid).toBe("base");
98
+ });
99
+ it("builds participant inputs without @type", () => {
100
+ const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
101
+ const task = new JsCal.Task({
102
+ start: "2026-02-02T10:00:00",
103
+ participants: { p1: participant },
104
+ });
105
+ expect(task.data.participants?.p1?.["@type"]).toBe("Participant");
106
+ });
107
+ it("builds participant maps with stable ids", () => {
108
+ const participants = JsCal.participants([
109
+ { name: "Alice", roles: { attendee: true } },
110
+ { name: "Bob", roles: { attendee: true } },
111
+ ]);
112
+ const task = new JsCal.Task({
113
+ start: "2026-02-02T10:00:00",
114
+ participants,
115
+ });
116
+ expect(Object.keys(task.data.participants ?? {}).length).toBe(2);
117
+ });
82
118
  it("createId returns base64url without padding", () => {
83
119
  const id = createId();
84
120
  expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
@@ -449,6 +449,129 @@ describe("recurrence expansion", () => {
449
449
  "2026-02-02T09:00:00",
450
450
  ]);
451
451
  });
452
+ it("uses implicit byMonthDay for monthly rules", () => {
453
+ const event = new JsCal.Event({
454
+ title: "Monthly Default",
455
+ start: "2026-02-10T09:00:00",
456
+ recurrenceRules: [
457
+ { "@type": "RecurrenceRule", frequency: "monthly", count: 2 },
458
+ ],
459
+ });
460
+ const occ = collect(JsCal.expandRecurrence([event], {
461
+ from: new Date("2026-02-01"),
462
+ to: new Date("2026-03-31"),
463
+ }));
464
+ const starts = occ.map((o) => o.recurrenceId);
465
+ expect(starts).toEqual([
466
+ "2026-02-10T09:00:00",
467
+ "2026-03-10T09:00:00",
468
+ ]);
469
+ });
470
+ it("adds byMonth defaults for yearly rules with byMonthDay", () => {
471
+ const event = new JsCal.Event({
472
+ title: "Yearly ByMonthDay",
473
+ start: "2026-02-01T09:00:00",
474
+ recurrenceRules: [
475
+ { "@type": "RecurrenceRule", frequency: "yearly", byMonthDay: [1], count: 2 },
476
+ ],
477
+ });
478
+ const occ = collect(JsCal.expandRecurrence([event], {
479
+ from: new Date("2026-02-01"),
480
+ to: new Date("2027-02-02"),
481
+ }));
482
+ const starts = occ.map((o) => o.recurrenceId);
483
+ expect(starts).toEqual([
484
+ "2026-02-01T09:00:00",
485
+ "2027-02-01T09:00:00",
486
+ ]);
487
+ });
488
+ it("adds byDay defaults for yearly byWeekNo rules", () => {
489
+ const event = new JsCal.Event({
490
+ title: "Week 1 Default Day",
491
+ start: "2026-01-01T09:00:00",
492
+ recurrenceRules: [
493
+ { "@type": "RecurrenceRule", frequency: "yearly", byWeekNo: [1], count: 1 },
494
+ ],
495
+ });
496
+ const occ = collect(JsCal.expandRecurrence([event], {
497
+ from: new Date("2026-01-01"),
498
+ to: new Date("2026-01-10"),
499
+ }));
500
+ const starts = occ.map((o) => o.recurrenceId);
501
+ expect(starts).toEqual([
502
+ "2026-01-01T09:00:00",
503
+ ]);
504
+ });
505
+ it("supports negative bySetPosition values", () => {
506
+ const event = new JsCal.Event({
507
+ title: "Last Wednesday",
508
+ start: "2026-01-07T10:00:00",
509
+ recurrenceRules: [
510
+ {
511
+ "@type": "RecurrenceRule",
512
+ frequency: "monthly",
513
+ byDay: [{ "@type": "NDay", day: "we" }],
514
+ bySetPosition: [-1],
515
+ },
516
+ ],
517
+ });
518
+ const occ = collect(JsCal.expandRecurrence([event], {
519
+ from: new Date("2026-01-01"),
520
+ to: new Date("2026-03-31"),
521
+ }));
522
+ const starts = occ.map((o) => o.recurrenceId);
523
+ expect(starts).toEqual([
524
+ "2026-01-07T10:00:00",
525
+ "2026-01-28T10:00:00",
526
+ "2026-02-25T10:00:00",
527
+ "2026-03-25T10:00:00",
528
+ ]);
529
+ });
530
+ it("expands tasks that only have due dates", () => {
531
+ const task = new JsCal.Task({
532
+ title: "Due Only",
533
+ due: "2026-02-01T10:00:00",
534
+ recurrenceRules: [
535
+ { "@type": "RecurrenceRule", frequency: "daily", count: 2 },
536
+ ],
537
+ recurrenceOverrides: {
538
+ "2026-02-02T10:00:00": { "/due": "2026-02-05T10:00:00" },
539
+ },
540
+ });
541
+ const occ = Array.from(JsCal.expandRecurrence([task], {
542
+ from: new Date("2026-02-01"),
543
+ to: new Date("2026-02-03"),
544
+ }));
545
+ expect(occ.map((o) => o.recurrenceId)).toEqual([
546
+ "2026-02-01T10:00:00",
547
+ "2026-02-02T10:00:00",
548
+ ]);
549
+ // Intentional cast to Task for test-only access to due.
550
+ const second = occ[1];
551
+ expect(second?.due).toBe("2026-02-05T10:00:00");
552
+ });
553
+ it("skips tasks without start or due dates", () => {
554
+ const task = new JsCal.Task({ title: "No Dates" });
555
+ const occ = Array.from(JsCal.expandRecurrence([task], {
556
+ from: new Date("2026-02-01"),
557
+ to: new Date("2026-02-02"),
558
+ }));
559
+ expect(occ.length).toBe(0);
560
+ });
561
+ it("throws on unsupported rscale during expansion", () => {
562
+ const event = new JsCal.Event({
563
+ title: "Bad Rscale",
564
+ start: "2026-02-01T10:00:00",
565
+ recurrenceRules: [
566
+ { "@type": "RecurrenceRule", frequency: "daily", rscale: "hebrew" },
567
+ ],
568
+ }, { validate: false });
569
+ expect(() => {
570
+ for (const _ of JsCal.expandRecurrence([event], { from: new Date("2026-02-01"), to: new Date("2026-02-02") })) {
571
+ void 0;
572
+ }
573
+ }).toThrow("Unsupported rscale");
574
+ });
452
575
  it("pages recurrence expansion with cursor and limit", () => {
453
576
  const event = new JsCal.Event({
454
577
  title: "Weekly",
@@ -84,6 +84,33 @@ describe("search helpers", () => {
84
84
  throw new Error("Missing result");
85
85
  expect(first.uid).toBe("e1");
86
86
  });
87
+ it("handles Date range for UTC events without time zones", () => {
88
+ const utcEvent = {
89
+ "@type": "Event",
90
+ uid: "eUtc",
91
+ updated: "2026-02-01T00:00:00Z",
92
+ start: "2026-02-01T10:00:00Z",
93
+ duration: "PT1H",
94
+ };
95
+ const results = filterByDateRange([utcEvent], {
96
+ start: new Date("2026-02-01T09:30:00Z"),
97
+ end: new Date("2026-02-01T10:30:00Z"),
98
+ });
99
+ expect(results.map((item) => item.uid)).toEqual(["eUtc"]);
100
+ });
101
+ it("handles Date range for UTC tasks without time zones", () => {
102
+ const utcTask = {
103
+ "@type": "Task",
104
+ uid: "tUtc",
105
+ updated: "2026-02-01T00:00:00Z",
106
+ start: "2026-02-01T10:00:00Z",
107
+ };
108
+ const results = filterByDateRange([utcTask], {
109
+ start: new Date("2026-02-01T09:30:00Z"),
110
+ end: new Date("2026-02-01T10:30:00Z"),
111
+ });
112
+ expect(results.map((item) => item.uid)).toEqual(["tUtc"]);
113
+ });
87
114
  it("uses JsCal.groupByType wrapper", () => {
88
115
  const grouped = JsCal.groupByType([event, task]);
89
116
  expect(Object.keys(grouped)).toContain("Event");
@@ -25,6 +25,9 @@ describe("utils", () => {
25
25
  expect(compareDateTime("2026-02-01T00:00:00Z", "2026-02-01T01:00:00Z")).toBe(-1);
26
26
  expect(compareDateTime("2026-02-01T01:00:00Z", "2026-02-01T00:00:00Z")).toBe(1);
27
27
  });
28
+ it("returns null for invalid UTC date-times", () => {
29
+ expect(compareDateTime("2026-99-99T00:00:00Z", "2026-02-01T00:00:00Z")).toBeNull();
30
+ });
28
31
  it("compares local date-times lexicographically", () => {
29
32
  expect(compareDateTime("2026-02-01T00:00:00", "2026-02-01T00:00:00")).toBe(0);
30
33
  expect(compareDateTime("2026-02-01T00:00:00", "2026-02-02T00:00:00")).toBe(-1);
@@ -16,6 +16,12 @@ describe("validation", () => {
16
16
  descriptionContentType: "application/json",
17
17
  })).toThrowError("object.descriptionContentType: must be a text/* media type");
18
18
  });
19
+ it("rejects non-utf8 charset parameters", () => {
20
+ expect(() => new JsCal.Event({
21
+ start: "2026-02-01T10:00:00",
22
+ descriptionContentType: "text/plain; charset=ascii",
23
+ })).toThrowError("object.descriptionContentType: charset parameter must be utf-8");
24
+ });
19
25
  it("allows validation to be disabled for create, update, and patch", () => {
20
26
  const event = new JsCal.Event({ start: "2026-02-01T10:00:00Z" }, { validate: false });
21
27
  expect(event.get("start")).toBe("2026-02-01T10:00:00Z");
@@ -62,6 +68,113 @@ describe("validation", () => {
62
68
  },
63
69
  })).toThrowError("object.locations.bad id: must use base64url characters");
64
70
  });
71
+ it("rejects invalid content-id formats", () => {
72
+ expect(() => new JsCal.Event({
73
+ start: "2026-02-01T10:00:00",
74
+ links: {
75
+ l1: { "@type": "Link", href: "https://example.com", cid: "<a@example.com" },
76
+ },
77
+ })).toThrowError("object.links.l1.cid: must use matching angle brackets");
78
+ });
79
+ it("rejects boolean map entries that are not true", () => {
80
+ expect(() => new JsCal.Event({
81
+ start: "2026-02-01T10:00:00",
82
+ participants: {
83
+ // Intentionally violate the BooleanMap type to assert validation errors.
84
+ p1: { "@type": "Participant", roles: { attendee: false } },
85
+ },
86
+ })).toThrowError("object.participants.p1.roles.attendee: must be true");
87
+ });
88
+ it("rejects invalid time zone ids", () => {
89
+ expect(() => new JsCal.Event({
90
+ start: "2026-02-01T10:00:00",
91
+ // Intentionally bypass TimeZoneInput typing to test invalid IDs.
92
+ timeZone: "Mars/Phobos",
93
+ })).toThrowError("Unknown time zone: Mars/Phobos");
94
+ });
95
+ it("rejects invalid recurrence rule values", () => {
96
+ expect(() => new JsCal.Event({
97
+ start: "2026-02-01T10:00:00",
98
+ recurrenceRules: [
99
+ { "@type": "RecurrenceRule", frequency: "daily", byMonthDay: [0] },
100
+ ],
101
+ })).toThrowError("object.recurrenceRules[0].byMonthDay[0]: must be an integer between -31 and 31, excluding 0");
102
+ });
103
+ it("accepts extended common fields and nested validation", () => {
104
+ const event = new JsCal.Event({
105
+ start: "2026-02-01T10:00:00",
106
+ method: "publish",
107
+ keywords: { sprint: true },
108
+ categories: { meeting: true },
109
+ relatedTo: {
110
+ rel1: { "@type": "Relation", relation: { parent: true } },
111
+ },
112
+ replyTo: {
113
+ "mailto:team@example.com": "mailto:team@example.com",
114
+ },
115
+ locations: {
116
+ loc1: {
117
+ "@type": "Location",
118
+ name: "Room A",
119
+ links: {
120
+ link1: { "@type": "Link", href: "https://example.com" },
121
+ },
122
+ },
123
+ },
124
+ virtualLocations: {
125
+ v1: {
126
+ "@type": "VirtualLocation",
127
+ uri: "https://example.com",
128
+ features: { chat: true },
129
+ },
130
+ },
131
+ participants: {
132
+ p1: {
133
+ "@type": "Participant",
134
+ roles: { attendee: true },
135
+ sendTo: { imap: "mailto:a@example.com" },
136
+ kind: "individual",
137
+ },
138
+ },
139
+ alerts: {
140
+ a1: {
141
+ "@type": "Alert",
142
+ trigger: { "@type": "AbsoluteTrigger", when: "2026-02-01T00:00:00Z" },
143
+ },
144
+ },
145
+ localizations: {
146
+ en: { title: "Localized", keywords: ["a"] },
147
+ },
148
+ recurrenceOverrides: {
149
+ "2026-02-02T10:00:00": { title: null, locations: ["x"] },
150
+ },
151
+ timeZones: {
152
+ "Asia/Tokyo": {
153
+ "@type": "TimeZone",
154
+ tzId: "Asia/Tokyo",
155
+ aliases: { JST: true },
156
+ standard: [
157
+ {
158
+ "@type": "TimeZoneRule",
159
+ start: "2026-01-01T00:00:00",
160
+ offsetFrom: "+09:00",
161
+ offsetTo: "+09:00",
162
+ comments: ["note"],
163
+ },
164
+ ],
165
+ daylight: [
166
+ {
167
+ "@type": "TimeZoneRule",
168
+ start: "2026-06-01T00:00:00",
169
+ offsetFrom: "+09:00",
170
+ offsetTo: "+10:00",
171
+ },
172
+ ],
173
+ },
174
+ },
175
+ });
176
+ expect(event.get("method")).toBe("publish");
177
+ });
65
178
  it("rejects non-gregorian rscale values", () => {
66
179
  expect(() => new JsCal.Event({
67
180
  start: "2026-02-01T10:00:00",
package/dist/ical.d.ts CHANGED
@@ -4,4 +4,10 @@ export type ICalOptions = {
4
4
  method?: string;
5
5
  includeXJSCalendar?: boolean;
6
6
  };
7
+ /**
8
+ * Convert JSCalendar objects into an iCalendar string.
9
+ * @param objects JSCalendar objects to export.
10
+ * @param options Export options.
11
+ * @return iCalendar text.
12
+ */
7
13
  export declare function toICal(objects: JSCalendarObject[], options?: ICalOptions): string;
package/dist/ical.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { normalizeUtcDateTime } from "./utils.js";
2
+ const TYPE_EVENT = "Event";
3
+ const TYPE_GROUP = "Group";
4
+ const TYPE_TASK = "Task";
2
5
  const DEFAULT_PRODID = "-//craftguild//EN";
6
+ /**
7
+ * Convert JSCalendar objects into an iCalendar string.
8
+ * @param objects JSCalendar objects to export.
9
+ * @param options Export options.
10
+ * @return iCalendar text.
11
+ */
3
12
  export function toICal(objects, options = {}) {
4
13
  const lines = [];
5
14
  const includeX = options.includeXJSCalendar !== false;
@@ -10,7 +19,7 @@ export function toICal(objects, options = {}) {
10
19
  if (method)
11
20
  lines.push(`METHOD:${method.toUpperCase()}`);
12
21
  for (const object of objects) {
13
- if (object["@type"] === "Group") {
22
+ if (object["@type"] === TYPE_GROUP) {
14
23
  const group = object;
15
24
  if (includeX) {
16
25
  lines.push(`X-JSCALENDAR-GROUP:${escapeText(JSON.stringify(stripEntries(group)))}`);
@@ -26,6 +35,11 @@ export function toICal(objects, options = {}) {
26
35
  lines.push("END:VCALENDAR");
27
36
  return foldLines(lines).join("\r\n");
28
37
  }
38
+ /**
39
+ * Find the first METHOD value from objects.
40
+ * @param objects JSCalendar objects.
41
+ * @return METHOD value or undefined.
42
+ */
29
43
  function findMethod(objects) {
30
44
  for (const object of objects) {
31
45
  if (object.method)
@@ -33,13 +47,25 @@ function findMethod(objects) {
33
47
  }
34
48
  return undefined;
35
49
  }
50
+ /**
51
+ * Build an iCalendar component from a JSCalendar object.
52
+ * @param object JSCalendar object.
53
+ * @param includeX Whether to include X-JSCALENDAR.
54
+ * @return iCalendar lines for the component.
55
+ */
36
56
  function buildComponent(object, includeX) {
37
- if (object["@type"] === "Event")
57
+ if (object["@type"] === TYPE_EVENT)
38
58
  return buildEvent(object, includeX);
39
- if (object["@type"] === "Task")
59
+ if (object["@type"] === TYPE_TASK)
40
60
  return buildTask(object, includeX);
41
61
  return [];
42
62
  }
63
+ /**
64
+ * Build a VEVENT component.
65
+ * @param event Event object.
66
+ * @param includeX Whether to include X-JSCALENDAR.
67
+ * @return VEVENT lines.
68
+ */
43
69
  function buildEvent(event, includeX) {
44
70
  const lines = [];
45
71
  lines.push("BEGIN:VEVENT");
@@ -69,6 +95,12 @@ function buildEvent(event, includeX) {
69
95
  lines.push("END:VEVENT");
70
96
  return lines;
71
97
  }
98
+ /**
99
+ * Build a VTODO component.
100
+ * @param task Task object.
101
+ * @param includeX Whether to include X-JSCALENDAR.
102
+ * @return VTODO lines.
103
+ */
72
104
  function buildTask(task, includeX) {
73
105
  const lines = [];
74
106
  lines.push("BEGIN:VTODO");
@@ -111,6 +143,12 @@ function buildTask(task, includeX) {
111
143
  lines.push("END:VTODO");
112
144
  return lines;
113
145
  }
146
+ /**
147
+ * Append RRULE lines for recurrence rules.
148
+ * @param lines Lines to append to.
149
+ * @param rules Recurrence rules.
150
+ * @return Nothing.
151
+ */
114
152
  function appendRecurrence(lines, rules) {
115
153
  if (!rules)
116
154
  return;
@@ -120,6 +158,11 @@ function appendRecurrence(lines, rules) {
120
158
  lines.push(`RRULE:${rrule}`);
121
159
  }
122
160
  }
161
+ /**
162
+ * Convert a RecurrenceRule to an RRULE string.
163
+ * @param rule Recurrence rule.
164
+ * @return RRULE value or null.
165
+ */
123
166
  function recurrenceRuleToRRule(rule) {
124
167
  const parts = [];
125
168
  parts.push(`FREQ=${rule.frequency.toUpperCase()}`);
@@ -159,6 +202,11 @@ function recurrenceRuleToRRule(rule) {
159
202
  parts.push(`SKIP=${rule.skip.toUpperCase()}`);
160
203
  return parts.join(";");
161
204
  }
205
+ /**
206
+ * Format a UTCDateTime for iCalendar.
207
+ * @param value UTCDateTime string.
208
+ * @return iCalendar-formatted UTCDateTime.
209
+ */
162
210
  function formatUtcDateTime(value) {
163
211
  const normalized = normalizeUtcDateTime(value);
164
212
  return normalized
@@ -166,12 +214,22 @@ function formatUtcDateTime(value) {
166
214
  .replace(/\.\d+Z$/, "Z")
167
215
  .replace(/Z$/, "Z");
168
216
  }
217
+ /**
218
+ * Format a LocalDateTime for iCalendar.
219
+ * @param value LocalDateTime string.
220
+ * @return iCalendar-formatted LocalDateTime.
221
+ */
169
222
  function formatLocalDateTime(value) {
170
223
  return value
171
224
  .replace(/[-:]/g, "")
172
225
  .replace(/\.\d+$/, "")
173
226
  .replace(/Z$/, "");
174
227
  }
228
+ /**
229
+ * Escape text for iCalendar content lines.
230
+ * @param value Raw text.
231
+ * @return Escaped text.
232
+ */
175
233
  function escapeText(value) {
176
234
  return value
177
235
  .replace(/\\/g, "\\\\")
@@ -179,6 +237,11 @@ function escapeText(value) {
179
237
  .replace(/;/g, "\\;")
180
238
  .replace(/,/g, "\\,");
181
239
  }
240
+ /**
241
+ * Fold iCalendar lines to 75 octets per RFC.
242
+ * @param lines Lines to fold.
243
+ * @return Folded lines.
244
+ */
182
245
  function foldLines(lines) {
183
246
  const result = [];
184
247
  for (const line of lines) {
@@ -196,6 +259,11 @@ function foldLines(lines) {
196
259
  }
197
260
  return result;
198
261
  }
262
+ /**
263
+ * Strip group entries for X-JSCALENDAR-GROUP payload.
264
+ * @param group Group object.
265
+ * @return Group without entries.
266
+ */
199
267
  function stripEntries(group) {
200
268
  const { entries: _entries, ...rest } = group;
201
269
  return rest;