ko-chrono 0.1.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 (57) hide show
  1. package/README.md +237 -66
  2. package/dist/cjs/event.d.ts +20 -0
  3. package/dist/cjs/event.js +33 -0
  4. package/dist/cjs/index.d.ts +55 -0
  5. package/dist/cjs/index.js +192 -0
  6. package/dist/cjs/package.json +1 -0
  7. package/dist/cjs/parsers/absoluteDate.d.ts +3 -0
  8. package/dist/cjs/parsers/absoluteDate.js +95 -0
  9. package/dist/cjs/parsers/duration.d.ts +3 -0
  10. package/dist/cjs/parsers/duration.js +45 -0
  11. package/dist/cjs/parsers/holiday.d.ts +3 -0
  12. package/dist/cjs/parsers/holiday.js +72 -0
  13. package/dist/cjs/parsers/offset.d.ts +3 -0
  14. package/dist/cjs/parsers/offset.js +64 -0
  15. package/dist/cjs/parsers/recurrence.d.ts +5 -0
  16. package/dist/cjs/parsers/recurrence.js +117 -0
  17. package/dist/cjs/parsers/relativeDay.d.ts +3 -0
  18. package/dist/cjs/parsers/relativeDay.js +26 -0
  19. package/dist/cjs/parsers/relativeMonth.d.ts +3 -0
  20. package/dist/cjs/parsers/relativeMonth.js +30 -0
  21. package/dist/cjs/parsers/relativeYear.d.ts +3 -0
  22. package/dist/cjs/parsers/relativeYear.js +30 -0
  23. package/dist/cjs/parsers/time.d.ts +3 -0
  24. package/dist/cjs/parsers/time.js +152 -0
  25. package/dist/cjs/parsers/weekday.d.ts +3 -0
  26. package/dist/cjs/parsers/weekday.js +109 -0
  27. package/dist/cjs/resolve.d.ts +21 -0
  28. package/dist/cjs/resolve.js +446 -0
  29. package/dist/cjs/types.d.ts +125 -0
  30. package/dist/cjs/types.js +2 -0
  31. package/dist/cjs/util/lunarHolidays.d.ts +11 -0
  32. package/dist/cjs/util/lunarHolidays.js +82 -0
  33. package/dist/cjs/util/number.d.ts +5 -0
  34. package/dist/cjs/util/number.js +42 -0
  35. package/dist/cjs/util/timezone.d.ts +4 -0
  36. package/dist/cjs/util/timezone.js +55 -0
  37. package/dist/event.d.ts +20 -0
  38. package/dist/event.js +30 -0
  39. package/dist/index.d.ts +18 -1
  40. package/dist/index.js +54 -6
  41. package/dist/parsers/absoluteDate.js +6 -2
  42. package/dist/parsers/duration.js +4 -2
  43. package/dist/parsers/holiday.d.ts +3 -0
  44. package/dist/parsers/holiday.js +68 -0
  45. package/dist/parsers/offset.js +6 -3
  46. package/dist/parsers/recurrence.d.ts +5 -0
  47. package/dist/parsers/recurrence.js +112 -0
  48. package/dist/parsers/time.js +22 -2
  49. package/dist/parsers/weekday.js +10 -0
  50. package/dist/resolve.d.ts +2 -2
  51. package/dist/resolve.js +164 -13
  52. package/dist/types.d.ts +53 -0
  53. package/dist/util/lunarHolidays.d.ts +11 -0
  54. package/dist/util/lunarHolidays.js +76 -0
  55. package/dist/util/timezone.d.ts +4 -0
  56. package/dist/util/timezone.js +51 -0
  57. package/package.json +13 -5
package/dist/resolve.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { toRRule } from './parsers/recurrence.js';
2
+ import { lunarHoliday, lunarToSolar } from './util/lunarHolidays.js';
1
3
  function startOfWeek(d, weekStart) {
2
4
  const r = new Date(d);
3
5
  r.setHours(0, 0, 0, 0);
@@ -9,6 +11,22 @@ function startOfWeek(d, weekStart) {
9
11
  function lastDayOfMonth(y, m0) {
10
12
  return new Date(y, m0 + 1, 0).getDate();
11
13
  }
14
+ /** 그 달의 N번째 요일이 며칠인지. nth = -1이면 마지막. 없으면 null ("다섯째 주 토요일") */
15
+ function nthWeekdayOfMonth(y, m0, weekday, nth) {
16
+ const last = lastDayOfMonth(y, m0);
17
+ let day = 1 + ((weekday - new Date(y, m0, 1).getDay() + 7) % 7);
18
+ if (nth > 0)
19
+ day += (nth - 1) * 7;
20
+ else
21
+ while (day + 7 <= last)
22
+ day += 7;
23
+ return day > last ? null : day;
24
+ }
25
+ /** 규칙 20: 오전/오후 없는 1~6시는 오후 ("3시에 보자" → 15시). 7~11시는 그대로 */
26
+ function clockHour(c) {
27
+ const h = c.hour;
28
+ return !c.meridiem && h >= 1 && h <= 6 ? h + 12 : h;
29
+ }
12
30
  /** 기준일의 "일"을 유지하되 그 달에 없으면 말일로 (2월 29일 + 1년 → 2월 28일) */
13
31
  function sameDayIn(y, m0, day) {
14
32
  return new Date(y, m0, Math.min(day, lastDayOfMonth(y, m0)));
@@ -44,6 +62,7 @@ export function merge(list) {
44
62
  }
45
63
  const DATE_KEYS = [
46
64
  'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
65
+ 'lunar', 'lunarDate',
47
66
  ];
48
67
  /** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
49
68
  export function isTimeOnly(c) {
@@ -96,13 +115,48 @@ function hasConflict(a, b) {
96
115
  export function resolve(group, opts) {
97
116
  const c = merge(group.map((f) => f.components));
98
117
  const ref = opts.referenceDate;
118
+ // "내일 이 시간" → 기준 시각의 시:분. "다음 주 이 시간"은 7일 뒤 (그 주 월요일이 아니라)
119
+ if (c.sameTime && c.hour === undefined) {
120
+ c.hour = ref.getHours();
121
+ c.minute = ref.getMinutes();
122
+ c.meridiem = true;
123
+ if (c.weekOffset !== undefined && c.weekday === undefined)
124
+ c.sameDay = true;
125
+ }
126
+ if (c.recur)
127
+ return resolveRecurrence(group, c, opts);
99
128
  let d = new Date(ref);
100
129
  d.setSeconds(0, 0);
101
130
  let dateSet = false;
102
131
  const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
103
- // 1. 절대 날짜
132
+ // 0. 음력 명절·음력 날짜 (규칙 29, 32): 연도를 말했으면 그 음력 해, 아니면 (연휴까지) 지나지 않은 가장 가까운 것.
133
+ // 음력 12월은 양력으로 다음 해 1월이라 작년 음력부터 본다. 표에 없는 날짜는 null
104
134
  const hasYear = c.year !== undefined || c.yearOffset !== undefined;
105
- if (hasYear || c.month !== undefined || c.day !== undefined) {
135
+ const isLunar = c.lunar !== undefined || c.lunarDate !== undefined;
136
+ if (isLunar) {
137
+ const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
138
+ const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
139
+ const y0 = ref.getFullYear();
140
+ const years = said !== undefined ? [said] : [y0 - 1, y0, y0 + 1, y0 + 2];
141
+ const solarOf = (y) => c.lunar ? lunarHoliday(y, c.lunar) : lunarToSolar(y, c.lunarDate.month, c.lunarDate.day, c.lunarDate.leap);
142
+ let found = null;
143
+ for (const y of years) {
144
+ const s = solarOf(y);
145
+ if (!s)
146
+ continue; // 그 해에 없는 날 ("음력 2월 30일", 윤달)
147
+ const lastMoment = new Date(s.getFullYear(), s.getMonth(), s.getDate() + tail, 23, 59);
148
+ if (said !== undefined || lastMoment >= ref) {
149
+ found = s;
150
+ break;
151
+ }
152
+ }
153
+ if (!found)
154
+ return null;
155
+ d = found;
156
+ dateSet = true;
157
+ }
158
+ // 1. 절대 날짜
159
+ if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
106
160
  let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
107
161
  // "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
108
162
  const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
@@ -140,15 +194,9 @@ export function resolve(group, opts) {
140
194
  const y = dateSet ? d.getFullYear() : ref.getFullYear();
141
195
  const m0 = dateSet ? d.getMonth() : ref.getMonth();
142
196
  const target = c.weekday ?? (opts.weekStart === 'monday' ? 1 : 0);
143
- const last = lastDayOfMonth(y, m0);
144
- let day = 1 + ((target - new Date(y, m0, 1).getDay() + 7) % 7);
145
- if (c.nthWeek > 0)
146
- day += (c.nthWeek - 1) * 7;
147
- else
148
- while (day + 7 <= last)
149
- day += 7;
197
+ const day = nthWeekdayOfMonth(y, m0, target, c.nthWeek);
150
198
  // "11월 다섯째 주 토요일"처럼 없는 날짜는 null
151
- if (day > last)
199
+ if (day === null)
152
200
  return null;
153
201
  d = new Date(y, m0, day);
154
202
  dateSet = true;
@@ -202,8 +250,7 @@ export function resolve(group, opts) {
202
250
  }
203
251
  // 6. 시각
204
252
  if (c.hour !== undefined) {
205
- // 규칙 20: 오전/오후 없는 1~6시는 오후 ("3시에 보자" → 15시). 7~11시는 그대로.
206
- const hour = !c.meridiem && c.hour >= 1 && c.hour <= 6 ? c.hour + 12 : c.hour;
253
+ const hour = clockHour(c);
207
254
  if (!dateSet) {
208
255
  d = new Date(ref);
209
256
  // 시간만 있고 날짜가 없으면: 오늘, 이미 지났으면 내일
@@ -232,10 +279,16 @@ export function resolve(group, opts) {
232
279
  else {
233
280
  return null;
234
281
  }
282
+ // 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
283
+ if (Number.isNaN(d.getTime()))
284
+ return null;
285
+ const ambiguity = ambiguityOf(c, d, dateSet, opts);
235
286
  const first = group[0];
236
287
  const last = group[group.length - 1];
237
288
  // text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
238
289
  const result = { date: d, text: '', index: first.index, hasTime };
290
+ if (ambiguity)
291
+ result.ambiguity = ambiguity;
239
292
  result._end = last.index + last.text.length;
240
293
  // "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
241
294
  if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
@@ -260,8 +313,9 @@ export function resolve(group, opts) {
260
313
  // "~까지", "~이내": 시작은 기준 시각, 끝은 해당 시점(시간 없으면 그날 23:59)
261
314
  // "~내내": 시작은 그 기간의 시작, 끝은 기간의 끝
262
315
  result.end = hasTime ? new Date(d) : periodEnd(d, c);
316
+ // 규칙 30: "어제까지"처럼 이미 지난 마감은 구간이 아니라 그 시점 (date = end)
263
317
  if (c.isEnd)
264
- result.date = new Date(ref);
318
+ result.date = result.end < ref ? new Date(result.end) : new Date(ref);
265
319
  }
266
320
  return result;
267
321
  }
@@ -277,6 +331,103 @@ function periodEnd(d, c) {
277
331
  end.setHours(23, 59, 0, 0);
278
332
  return end;
279
333
  }
334
+ /**
335
+ * 반복 일정 (규칙 27): date = 기준 시각 이후 첫 발생.
336
+ * 시각이 있으면 지난 시각은 건너뛰고, 날짜만 있으면 오늘도 포함한다.
337
+ * "매달 31일"은 31일이 없는 달을 건너뛴다 (RRULE과 같은 동작).
338
+ */
339
+ function resolveRecurrence(group, c, opts) {
340
+ const r = c.recur;
341
+ const ref = opts.referenceDate;
342
+ const hasTime = c.hour !== undefined;
343
+ const today = new Date(ref);
344
+ today.setHours(0, 0, 0, 0);
345
+ const at = (day) => {
346
+ const x = new Date(day);
347
+ x.setHours(hasTime ? clockHour(c) : opts.defaultHour, hasTime ? c.minute ?? 0 : 0, 0, 0);
348
+ return x;
349
+ };
350
+ const ok = (x) => (hasTime ? x >= ref : x >= today);
351
+ let d = null;
352
+ if (r.freq === 'MINUTELY' || r.freq === 'HOURLY') {
353
+ d = new Date(ref);
354
+ d.setSeconds(0, 0);
355
+ d.setMinutes(d.getMinutes() + r.interval * (r.freq === 'HOURLY' ? 60 : 1));
356
+ }
357
+ else if (r.freq === 'DAILY') {
358
+ d = at(today);
359
+ if (!ok(d))
360
+ d.setDate(d.getDate() + 1);
361
+ }
362
+ else if (r.freq === 'WEEKLY') {
363
+ for (let i = 0; i < 14 && !d; i++) {
364
+ const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() + i);
365
+ if (r.byWeekday?.includes(day.getDay()) && ok(at(day)))
366
+ d = at(day);
367
+ }
368
+ }
369
+ else if (r.freq === 'MONTHLY') {
370
+ for (let k = 0; k < 24 && !d; k++) {
371
+ const y = today.getFullYear(), m0 = today.getMonth() + k;
372
+ const dom = r.byWeekdayNth !== undefined
373
+ ? nthWeekdayOfMonth(y, m0, r.byWeekday[0], r.byWeekdayNth)
374
+ : r.byMonthDay === -1 ? lastDayOfMonth(y, m0) : r.byMonthDay;
375
+ if (dom === null || dom > lastDayOfMonth(y, m0))
376
+ continue;
377
+ const x = at(new Date(y, m0, dom));
378
+ if (ok(x))
379
+ d = x;
380
+ }
381
+ }
382
+ else {
383
+ // YEARLY: 2월 29일은 다음 윤년까지
384
+ for (let k = 0; k < 9 && !d; k++) {
385
+ const y = today.getFullYear() + k, m0 = r.byMonth - 1;
386
+ if (r.byMonthDay > lastDayOfMonth(y, m0))
387
+ continue;
388
+ const x = at(new Date(y, m0, r.byMonthDay));
389
+ if (ok(x))
390
+ d = x;
391
+ }
392
+ }
393
+ if (!d)
394
+ return null;
395
+ const last = group[group.length - 1];
396
+ const result = {
397
+ date: d,
398
+ text: '',
399
+ index: group[0].index,
400
+ hasTime: hasTime || r.freq === 'HOURLY' || r.freq === 'MINUTELY',
401
+ recurrence: { ...r, rrule: toRRule(r) },
402
+ };
403
+ result._end = last.index + last.text.length;
404
+ return result;
405
+ }
406
+ /** 규칙 31: 해석이 갈릴 수 있는 지점과 다른 후보 */
407
+ function ambiguityOf(c, d, dateSet, opts) {
408
+ const ref = opts.referenceDate;
409
+ // 오전/오후 없는 1~12시: 반대쪽 12시간이 후보 (날짜가 없으면 규칙 4대로 다음 발생)
410
+ if (c.hour !== undefined && !c.meridiem && !c.midnight && c.hour >= 1 && c.hour <= 12) {
411
+ const used = clockHour(c);
412
+ const altHour = used === 12 ? 0 : used < 12 ? used + 12 : used - 12;
413
+ const alt = new Date(dateSet ? d : ref);
414
+ alt.setHours(altHour, c.minute ?? 0, 0, 0);
415
+ if (!dateSet && alt < ref)
416
+ alt.setDate(alt.getDate() + 1);
417
+ return { reason: 'meridiem', alternatives: [alt] };
418
+ }
419
+ // "다음 토요일": 규칙 23(오늘 제외 가장 가까운)과 "다음 주 토요일"이 다르면 후보로
420
+ if (c.weekdayDir === 'next' && c.weekday !== undefined) {
421
+ const alt = startOfWeek(ref, opts.weekStart);
422
+ alt.setDate(alt.getDate() + 7 + (opts.weekStart === 'monday' ? (c.weekday + 6) % 7 : c.weekday));
423
+ alt.setHours(d.getHours(), d.getMinutes(), 0, 0);
424
+ if (alt.getTime() !== d.getTime())
425
+ return { reason: 'next-weekday', alternatives: [alt] };
426
+ }
427
+ if (c.approximate)
428
+ return { reason: 'approximate-time', alternatives: [] };
429
+ return undefined;
430
+ }
280
431
  function partDay(y, m0, part) {
281
432
  if (part === 'start')
282
433
  return 1;
package/dist/types.d.ts CHANGED
@@ -6,7 +6,15 @@ export interface ParseOptions {
6
6
  weekStart?: WeekStart;
7
7
  /** 시간이 명시되지 않았을 때 넣을 시(hour). 기본값: 0 (00:00) */
8
8
  defaultHour?: number;
9
+ /**
10
+ * "오후 3시"를 어느 시간대의 3시로 볼지. IANA 이름 ('Asia/Seoul', 'UTC', 'America/New_York').
11
+ * 기본값: 실행 환경의 시간대. 서버(UTC)에서 한국 사용자 문장을 처리할 땐 'Asia/Seoul'을 넘긴다.
12
+ * 없는 이름이면 RangeError.
13
+ */
14
+ timezone?: string;
9
15
  }
16
+ /** resolve()가 쓰는 옵션. timezone은 parseAll()이 앞뒤에서 처리하므로 빠진다. */
17
+ export type ResolveOptions = Required<Omit<ParseOptions, 'timezone'>>;
10
18
  /** 파서들이 추출하는 중간 결과. 날짜/시간 중 아는 것만 채운다. */
11
19
  export interface Components {
12
20
  year?: number;
@@ -26,6 +34,20 @@ export interface Components {
26
34
  weekdayDir?: 'past' | 'next';
27
35
  /** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
28
36
  sameDay?: boolean;
37
+ /** "저녁", "아침"처럼 관례로 정한 시각 */
38
+ approximate?: boolean;
39
+ /** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
40
+ sameTime?: boolean;
41
+ /** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
42
+ lunar?: 'seollal' | 'chuseok' | 'buddha';
43
+ /** "음력 3월 3일", "음력 윤5월 1일" */
44
+ lunarDate?: {
45
+ month: number;
46
+ day: number;
47
+ leap: boolean;
48
+ };
49
+ /** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
50
+ recur?: Omit<Recurrence, 'rrule'>;
29
51
  /** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
30
52
  yearOffset?: number;
31
53
  /** 월 단위 이동 (다음달=+1) */
@@ -52,11 +74,38 @@ export interface Components {
52
74
  /** 이 조각이 "~부터" 시작점인지 */
53
75
  isStart?: boolean;
54
76
  }
77
+ export type Frequency = 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
78
+ /** 반복 일정. 필드 이름과 값은 RFC 5545 RRULE을 따른다 */
79
+ export interface Recurrence {
80
+ freq: Frequency;
81
+ /** 몇 번째마다 (격주 = 2) */
82
+ interval: number;
83
+ /** 요일들. 0=일 … 6=토 */
84
+ byWeekday?: number[];
85
+ /** 매달 N번째 요일 ("마지막 주 금요일" = -1). byWeekday와 함께 쓴다 */
86
+ byWeekdayNth?: number;
87
+ /** 매달 며칠 (말일 = -1) */
88
+ byMonthDay?: number;
89
+ /** 매년 몇 월 */
90
+ byMonth?: number;
91
+ /** 'FREQ=WEEKLY;BYDAY=MO,WE,FR'. date를 DTSTART로 함께 쓰면 캘린더에 그대로 넣을 수 있다 */
92
+ rrule: string;
93
+ }
55
94
  export interface Fragment {
56
95
  text: string;
57
96
  index: number;
58
97
  components: Components;
59
98
  }
99
+ /**
100
+ * 해석이 갈릴 수 있다는 표시 (규칙 31). 기본 해석은 결과의 date(마감이면 end)이고, 다른 후보가 alternatives.
101
+ * - meridiem: 오전/오후 없는 시각 ("3시" → 15시, 후보 3시)
102
+ * - next-weekday: "다음 토요일"이 이번 주의 다음 토요일인지 다음 주 토요일인지
103
+ * - approximate-time: "저녁", "아침"처럼 관례로 정한 시각 (후보 없음)
104
+ */
105
+ export interface Ambiguity {
106
+ reason: 'meridiem' | 'next-weekday' | 'approximate-time';
107
+ alternatives: Date[];
108
+ }
60
109
  export interface ParseResult {
61
110
  /** 해석된 시각 (구간이면 시작) */
62
111
  date: Date;
@@ -68,5 +117,9 @@ export interface ParseResult {
68
117
  index: number;
69
118
  /** 시간이 명시되었는지 */
70
119
  hasTime: boolean;
120
+ /** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
121
+ recurrence?: Recurrence;
122
+ /** 해석이 갈릴 수 있으면 그 이유와 다른 후보. 사용자에게 되묻거나 LLM에 넘길지 판단할 때 쓴다 */
123
+ ambiguity?: Ambiguity;
71
124
  }
72
125
  export type Parser = (text: string) => Fragment[];
@@ -0,0 +1,11 @@
1
+ export type LunarHoliday = 'seollal' | 'chuseok' | 'buddha';
2
+ export declare const LUNAR_YEARS: number[];
3
+ /**
4
+ * 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
5
+ * 마지막 달처럼 길이를 모르면 29일까지만 받는다.
6
+ */
7
+ export declare function lunarToSolar(year: number, month: number, day: number, leap?: boolean): Date | null;
8
+ /** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
9
+ export declare function lunarHoliday(year: number, which: LunarHoliday): Date | null;
10
+ /** 각 음력 달의 길이 (검증용). 모르면 null */
11
+ export declare function monthLengths(year: number): (number | null)[];
@@ -0,0 +1,76 @@
1
+ // 음력 → 양력 변환표. 런타임 의존성 없이 음력을 다루려면 표가 필요하다.
2
+ //
3
+ // 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
4
+ // 2025~2028년 페이지에서 음력 초하루(1일)가 되는 양력 날짜를 옮겼다.
5
+ // 설날·부처님 오신 날·추석 12개가 관공서 공휴일 목록과 일치하는지 tests/lunar.test.ts에서 확인한다.
6
+ // KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지).
7
+ // 새 연도가 공개되면 그 해의 초하루 목록을 추가하고, 직전 해 마지막 달의 길이가 확정되는지 확인할 것.
8
+ // 표에 없는 날짜는 추측하지 않고 null (규칙 10). 한국 음력은 한국 표준시 기준이라 중국과 하루 다를 수 있다.
9
+ const MONTH_STARTS = {
10
+ 2025: [
11
+ [1, false, 2025, 1, 29], [2, false, 2025, 2, 28], [3, false, 2025, 3, 29], [4, false, 2025, 4, 28],
12
+ [5, false, 2025, 5, 27], [6, false, 2025, 6, 25], [6, true, 2025, 7, 25], [7, false, 2025, 8, 23],
13
+ [8, false, 2025, 9, 22], [9, false, 2025, 10, 21], [10, false, 2025, 11, 20], [11, false, 2025, 12, 20],
14
+ [12, false, 2026, 1, 19],
15
+ ],
16
+ 2026: [
17
+ [1, false, 2026, 2, 17], [2, false, 2026, 3, 19], [3, false, 2026, 4, 17], [4, false, 2026, 5, 17],
18
+ [5, false, 2026, 6, 15], [6, false, 2026, 7, 14], [7, false, 2026, 8, 13], [8, false, 2026, 9, 11],
19
+ [9, false, 2026, 10, 11], [10, false, 2026, 11, 9], [11, false, 2026, 12, 9], [12, false, 2027, 1, 8],
20
+ ],
21
+ 2027: [
22
+ [1, false, 2027, 2, 7], [2, false, 2027, 3, 8], [3, false, 2027, 4, 7], [4, false, 2027, 5, 6],
23
+ [5, false, 2027, 6, 5], [6, false, 2027, 7, 4], [7, false, 2027, 8, 2], [8, false, 2027, 9, 1],
24
+ [9, false, 2027, 9, 30], [10, false, 2027, 10, 29], [11, false, 2027, 11, 28], [12, false, 2027, 12, 28],
25
+ ],
26
+ 2028: [
27
+ [1, false, 2028, 1, 27], [2, false, 2028, 2, 25], [3, false, 2028, 3, 26], [4, false, 2028, 4, 25],
28
+ [5, false, 2028, 5, 24], [5, true, 2028, 6, 23], [6, false, 2028, 7, 22], [7, false, 2028, 8, 20],
29
+ [8, false, 2028, 9, 19], [9, false, 2028, 10, 18], [10, false, 2028, 11, 16], [11, false, 2028, 12, 16],
30
+ [12, false, 2029, 1, 15],
31
+ ],
32
+ };
33
+ export const LUNAR_YEARS = Object.keys(MONTH_STARTS).map(Number);
34
+ const DAY = 86_400_000;
35
+ const solar = (s) => new Date(s[2], s[3] - 1, s[4]);
36
+ /** 그 음력 달의 다음 달 초하루 (모르면 null) */
37
+ function nextStart(year, index) {
38
+ const months = MONTH_STARTS[year];
39
+ if (index + 1 < months.length)
40
+ return solar(months[index + 1]);
41
+ const next = MONTH_STARTS[year + 1];
42
+ return next ? solar(next[0]) : null;
43
+ }
44
+ /**
45
+ * 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
46
+ * 마지막 달처럼 길이를 모르면 29일까지만 받는다.
47
+ */
48
+ export function lunarToSolar(year, month, day, leap = false) {
49
+ const months = MONTH_STARTS[year];
50
+ if (!months || day < 1 || day > 30)
51
+ return null;
52
+ const index = months.findIndex(([m, l]) => m === month && l === leap);
53
+ if (index < 0)
54
+ return null;
55
+ const start = solar(months[index]);
56
+ const next = nextStart(year, index);
57
+ const length = next ? Math.round((next.getTime() - start.getTime()) / DAY) : 29;
58
+ if (day > length)
59
+ return null;
60
+ return new Date(start.getFullYear(), start.getMonth(), start.getDate() + day - 1);
61
+ }
62
+ /** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
63
+ export function lunarHoliday(year, which) {
64
+ if (which === 'seollal')
65
+ return lunarToSolar(year, 1, 1);
66
+ if (which === 'buddha')
67
+ return lunarToSolar(year, 4, 8);
68
+ return lunarToSolar(year, 8, 15);
69
+ }
70
+ /** 각 음력 달의 길이 (검증용). 모르면 null */
71
+ export function monthLengths(year) {
72
+ return MONTH_STARTS[year].map((s, i) => {
73
+ const next = nextStart(year, i);
74
+ return next ? Math.round((next.getTime() - solar(s).getTime()) / DAY) : null;
75
+ });
76
+ }
@@ -0,0 +1,4 @@
1
+ /** 실제 시점 → 그 시간대의 벽시계 시각을 로컬 필드로 갖는 Date */
2
+ export declare function toWallClock(instant: Date, timeZone: string): Date;
3
+ /** 로컬 필드를 그 시간대의 벽시계 시각으로 보고 → 실제 시점 */
4
+ export declare function fromWallClock(wall: Date, timeZone: string): Date;
@@ -0,0 +1,51 @@
1
+ // IANA 시간대 변환. 런타임 의존성 없이 Intl만 쓴다.
2
+ //
3
+ // resolve.ts는 Date의 로컬 getter/setter로 계산한다. timezone 옵션이 있으면
4
+ // 기준 시각을 그 시간대의 벽시계 시각(로컬 필드가 같은 Date)으로 바꿔 계산하고,
5
+ // 결과의 벽시계 시각을 다시 실제 시점으로 되돌린다.
6
+ const formatters = new Map();
7
+ function formatter(timeZone) {
8
+ let f = formatters.get(timeZone);
9
+ if (!f) {
10
+ // 없는 시간대면 여기서 RangeError
11
+ f = new Intl.DateTimeFormat('en-US', {
12
+ timeZone,
13
+ hourCycle: 'h23',
14
+ year: 'numeric',
15
+ month: 'numeric',
16
+ day: 'numeric',
17
+ hour: 'numeric',
18
+ minute: 'numeric',
19
+ second: 'numeric',
20
+ });
21
+ formatters.set(timeZone, f);
22
+ }
23
+ return f;
24
+ }
25
+ /** 시점 t에서 그 시간대의 벽시계 필드 */
26
+ function fieldsAt(t, timeZone) {
27
+ const parts = {};
28
+ for (const p of formatter(timeZone).formatToParts(new Date(t))) {
29
+ if (p.type !== 'literal')
30
+ parts[p.type] = +p.value;
31
+ }
32
+ return [parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second];
33
+ }
34
+ /** 시점 t에서 그 시간대의 UTC 오프셋(ms). 서울 = +9h */
35
+ function offsetAt(t, timeZone) {
36
+ const [y, m0, d, h, mi, s] = fieldsAt(t, timeZone);
37
+ return Date.UTC(y, m0, d, h, mi, s) - (t - (t % 1000));
38
+ }
39
+ /** 실제 시점 → 그 시간대의 벽시계 시각을 로컬 필드로 갖는 Date */
40
+ export function toWallClock(instant, timeZone) {
41
+ const [y, m0, d, h, mi, s] = fieldsAt(instant.getTime(), timeZone);
42
+ return new Date(y, m0, d, h, mi, s, instant.getMilliseconds());
43
+ }
44
+ /** 로컬 필드를 그 시간대의 벽시계 시각으로 보고 → 실제 시점 */
45
+ export function fromWallClock(wall, timeZone) {
46
+ const asUtc = Date.UTC(wall.getFullYear(), wall.getMonth(), wall.getDate(), wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds());
47
+ // 오프셋은 시점에 따라 달라지므로(서머타임) 한 번 더 맞춘다
48
+ const first = offsetAt(asUtc, timeZone);
49
+ const second = offsetAt(asUtc - first, timeZone);
50
+ return new Date(asUtc - second);
51
+ }
package/package.json CHANGED
@@ -1,19 +1,27 @@
1
1
  {
2
2
  "name": "ko-chrono",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "한국어 자연어 날짜/시간 파서 — '다음주 화요일 오후 3시' → Date",
5
5
  "type": "module",
6
- "main": "./dist/index.js",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/index.js",
7
8
  "types": "./dist/index.d.ts",
8
9
  "exports": {
9
10
  ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/cjs/index.d.ts",
17
+ "default": "./dist/cjs/index.js"
18
+ }
12
19
  }
13
20
  },
21
+ "sideEffects": false,
14
22
  "files": ["dist", "README.md", "LICENSE"],
15
23
  "scripts": {
16
- "build": "tsc -p tsconfig.build.json",
24
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
17
25
  "test": "vitest run",
18
26
  "test:watch": "vitest",
19
27
  "typecheck": "tsc --noEmit",