ko-chrono 0.2.0 → 0.3.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
@@ -14,6 +14,8 @@
14
14
  <img alt="'그럼 다음주 화요일 오후 3시에 볼까?'에서 '다음주 화요일 오후 3시'를 찾아 2026-11-03 15:00으로 바꾼다" src="./docs/banner-light.svg" width="760">
15
15
  </picture>
16
16
 
17
+ **[▶ 브라우저에서 바로 해 보기](https://jwn7.github.io/ko-chrono/)**
18
+
17
19
  [설치](#설치) · [사용법](#사용법) · [지원 표현](#지원-표현) · [해석 규칙](#해석-규칙) · [예제](#예제) · [문장 제보](https://github.com/jwn7/ko-chrono/issues/new?template=sentence.yml)
18
20
 
19
21
  </div>
@@ -88,6 +90,26 @@ parseEvent('우유 사기');
88
90
  // { title: '우유 사기', date: null, hasTime: false }
89
91
  ```
90
92
 
93
+ ### 애매한 해석: `ambiguity`
94
+
95
+ "3시"가 오후인지 새벽인지처럼 해석이 갈릴 수 있으면, 기본 해석은 그대로 두고 다른 후보를 함께 알려 줍니다.
96
+ 사용자에게 되묻거나, 이런 문장만 LLM에 넘길 때 쓰면 됩니다.
97
+
98
+ ```ts
99
+ const r = parse('3시에 보자');
100
+ // r.date → 오늘 15:00 (오전/오후가 없는 1~6시는 오후로)
101
+ // r.ambiguity → { reason: 'meridiem', alternatives: [내일 03:00] }
102
+
103
+ if (!r) askLLM(text); // 날짜를 못 찾음
104
+ else if (r.ambiguity?.alternatives.length) confirm(r); // "오후 3시 맞으세요?"
105
+ ```
106
+
107
+ | `reason` | 언제 | `alternatives` |
108
+ |---|---|---|
109
+ | `meridiem` | 오전/오후 없는 1\~12시 ("3시", "내일 7시") | 반대쪽 12시간 |
110
+ | `next-weekday` | "다음 토요일"이 이번 주 토요일일 수도, 다음 주 토요일일 수도 | 다른 쪽 날짜 |
111
+ | `approximate-time` | "저녁"(19시), "아침"(8시)처럼 관례로 정한 시각 | 없음 |
112
+
91
113
  ### 반복 일정
92
114
 
93
115
  `recurrence.rrule`은 [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html)이라 Google·Apple 캘린더, iCal에 그대로 넣을 수 있습니다. `date`는 기준 시각 이후 첫 발생입니다.
@@ -121,6 +143,7 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
121
143
  | `index` | 문장 안에서의 위치 |
122
144
  | `hasTime` | 시각까지 말했는지. `false`면 `date`는 그날 `defaultHour`시 |
123
145
  | `recurrence` | 반복 일정이면 규칙 (`freq`, `interval`, `byWeekday`, …, `rrule`) |
146
+ | `ambiguity` | 해석이 갈릴 수 있으면 `{ reason, alternatives }` |
124
147
 
125
148
  ### 옵션
126
149
 
@@ -154,6 +177,8 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
154
177
  | 마감 | \~까지 · \~안에 · \~중으로 · 이번 주 안에 · 오전 중으로 · 퇴근 전까지 |
155
178
  | 구간 | A부터 B까지 · 3\~5시 · 월\~금 · 오늘부터 3일간 · 3박 4일 · 이번 주 내내 |
156
179
  | 공휴일·명절 | 크리스마스 · 빼빼로데이 · 광복절 전날 · 설날 · 설 연휴 · 추석 전날 · 부처님 오신 날 |
180
+ | 음력 | 음력 3월 3일 · 음력 윤5월 1일 · 2028년 음력 9월 9일 |
181
+ | 업무용 한자어 | 금일 · 명일 · 익일 · 작일 · 금주 · 차주 · 전주 수요일 · 익월 초 · 금월 말까지 · 익년 |
157
182
  | 반복 | 매일 · 매주 월수금 · 매주 월\~금 · 격주 · 평일마다 · 매달 15일 · 매월 말일 · 매달 마지막 주 금요일 · 매년 3월 1일 · 2시간마다 |
158
183
  | 문장 | 띄어쓰기 생략 · 조사(은·는·도) · 어림말(쯤·경·께) · 여러 개 추출 |
159
184
 
@@ -195,12 +220,14 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
195
220
  - "오늘부터 3일간" → `end`는 3일째 되는 날 23:59
196
221
 
197
222
  **명절**
223
+ - "음력 3월 3일"처럼 음력이라고 말한 날짜도 같은 자료로 변환합니다
198
224
  - 설날·추석·부처님 오신 날은 [한국천문연구원](https://astro.kasi.re.kr/life/post/calendarData) 자료가 있는 연도(현재 2025\~2028)만. 그 뒤는 추측하지 않고 `null`
199
225
  - "설 연휴", "추석 연휴" = 전날부터 다음날까지 3일
200
226
 
201
227
  **잡지 않는 것**
202
228
  - 없는 날짜 (2월 30일, 11월 31일), 날짜와 요일이 안 맞는 경우 ("11월 5일 수요일")
203
229
  - 기간만 있는 표현 ("3일간", "3박 4일"), 날짜가 아닌 말 ("3일 연속", "1월생", "내가 낼게")
230
+ - 한자어가 다른 뜻일 때: "금주 결심"(禁酒), "전주 여행"(도시), "차주에게 연락"(車主), "전월세"
204
231
  - "내일 30분 후"처럼 날짜와 상대 시간이 섞이면 두 개의 결과로 나눔
205
232
 
206
233
  </details>
@@ -213,7 +240,7 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
213
240
  |---|---|---|
214
241
  | ✅ | [**할 일 빠른 입력**](./examples/quick-add.mjs)<br>`parseEvent`로 "다음주 금요일까지 보고서 제출" → 제목 "보고서 제출" + 마감 11/6 23:59 | `node examples/quick-add.mjs "..."` |
215
242
  | 💬 | [**Slack 봇**](./examples/slack-bot)<br>채널 메시지에서 날짜를 찾아 스레드로 알려줌 (UTC 서버 + `timezone`) | `node examples/slack-bot/try.mjs` |
216
- | 🧪 | [**Playground**](./playground/index.html)<br>브라우저에서 직접 입력해 보기 | `npm run playground` |
243
+ | 🧪 | [**Playground**](https://jwn7.github.io/ko-chrono/)<br>브라우저에서 직접 입력해 보기 (문장을 넣으면 공유 링크가 생김) | [jwn7.github.io/ko-chrono](https://jwn7.github.io/ko-chrono/) · 로컬은 `npm run playground` |
217
244
 
218
245
  <br>
219
246
 
@@ -221,16 +248,18 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
221
248
 
222
249
  날짜를 LLM에 물어볼 수도 있지만, 정해진 규칙으로 풀리는 문제라면 이쪽이 빠르고 공짜이고 매번 같은 답을 냅니다.
223
250
 
224
- | | ko-chrono | LLM API |
251
+ 메신저 문장 112개로 측정한 결과 ([자세히](./bench/RESULTS.md), Gemini Flash-Lite **무료 티어**, 2026-10-31):
252
+
253
+ | | ko-chrono | LLM API (Gemini Flash-Lite) |
225
254
  |---|---|---|
226
- | 지연 | 문장당 약 **0.01ms** | 문장당 수백 ms \~ 수 초 |
227
- | 비용 | **0** | 토큰당 과금 |
255
+ | 지연 | 문장당 **0.017ms** | 문장당 **987ms** (네트워크만 40ms 이상) |
256
+ | 비용 | **0** | 토큰당 과금 (무료 티어는 분당·일일 한도) |
228
257
  | 같은 문장 → 같은 답 | **항상** | 보장 안 됨 |
229
258
  | 오프라인 · 브라우저 | **됨** | 안 됨 |
259
+ | 시제·문맥 ("깨서 못 잤어") | 모름 | 앎 |
230
260
  | 처음 보는 표현 | 못 함 (`null`) | 대체로 함 |
231
261
 
232
- 같은 메신저 문장 112개를 양쪽에 넣어 비교하는 벤치마크가 [bench/](./bench)에 있습니다 (Claude, Gemini 지원).
233
- 규칙으로 풀리는 날짜는 ko-chrono로, 그 밖의 자유로운 문장만 LLM으로 보내는 조합을 권합니다.
262
+ 유료 티어나 다른 모델이면 LLM 쪽이 더 빠를 수 있지만, 네트워크 왕복만으로도 ko-chrono보다 수천 배 느립니다. 정확도는 비슷한 수준이고 차이의 절반은 "저녁이 몇 시냐" 같은 관례 차이였습니다. 규칙으로 풀리는 날짜는 ko-chrono로, `null`이 나오거나 `ambiguity`가 있는 문장만 LLM으로 보내는 조합을 권합니다.
234
263
 
235
264
  <br>
236
265
 
@@ -239,8 +268,9 @@ parse('격주 화요일')?.recurrence?.rrule; // 'FREQ=WEEKLY;IN
239
268
  - [x] 반복 일정 (매주 월요일 9시, 격주) → RRULE
240
269
  - [x] 제목 분리 (`parseEvent`)
241
270
  - [x] 명절·공휴일 (설 연휴, 추석 전날, 크리스마스) — 음력 명절은 한국천문연구원 자료가 있는 2025\~2028년
242
- - [ ] 일반 음력 날짜 (음력 3월 3일)
243
- - [ ] chrono-node `ko` 로케일 기여
271
+ - [x] 일반 음력 날짜 (음력 3월 3일) — 한국천문연구원 자료가 있는 2025\~2028년
272
+ - [x] 모호성 정보 (`ambiguity`)
273
+ - [ ] chrono-node `ko` 로케일 기여 — 첫 PR 분량 준비됨 ([contrib/chrono-ko](./contrib/chrono-ko))
244
274
 
245
275
  ## 기여
246
276
 
@@ -1,4 +1,4 @@
1
- import type { ParseResult, Recurrence } from './types.js';
1
+ import type { Ambiguity, ParseResult, Recurrence } from './types.js';
2
2
  /** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
3
3
  export interface ParsedEvent {
4
4
  /** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
@@ -10,6 +10,8 @@ export interface ParsedEvent {
10
10
  hasTime: boolean;
11
11
  /** 반복 일정이면 그 규칙 */
12
12
  recurrence?: Recurrence;
13
+ /** 해석이 갈릴 수 있으면 그 이유와 다른 후보 ("3시" → 15시, 후보 3시) */
14
+ ambiguity?: Ambiguity;
13
15
  }
14
16
  /**
15
17
  * 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
package/dist/cjs/event.js CHANGED
@@ -27,5 +27,7 @@ function toEvent(text, results) {
27
27
  event.end = first.end;
28
28
  if (first.recurrence)
29
29
  event.recurrence = first.recurrence;
30
+ if (first.ambiguity)
31
+ event.ambiguity = first.ambiguity;
30
32
  return event;
31
33
  }
@@ -1,6 +1,6 @@
1
1
  import type { ParseOptions, ParseResult } from './types.js';
2
2
  import { type ParsedEvent } from './event.js';
3
- export type { Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
3
+ export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
4
4
  export type { ParsedEvent } from './event.js';
5
5
  /**
6
6
  * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
package/dist/cjs/index.js CHANGED
@@ -64,6 +64,9 @@ function parseAll(text, options) {
64
64
  const out = { ...r, date: (0, timezone_js_1.fromWallClock)(r.date, timezone) };
65
65
  if (r.end)
66
66
  out.end = (0, timezone_js_1.fromWallClock)(r.end, timezone);
67
+ if (r.ambiguity) {
68
+ out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => (0, timezone_js_1.fromWallClock)(a, timezone)) };
69
+ }
67
70
  return out;
68
71
  });
69
72
  }
@@ -31,6 +31,8 @@ const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
31
31
  const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
32
32
  // "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
33
33
  const LUNAR_RE = /(설날|구정|설(?=\s*(?:연휴|전날|다음\s*날|당일))|추석|한가위|부처님\s*오신\s*날|석가\s*탄신일|석탄일|초파일)(?:\s*(연휴))?(?:\s*(전날|다음\s*날|이튿날|당일))?/g;
34
+ // "음력 3월 3일", "음력 윤5월 1일" (음력이라고 말한 경우만)
35
+ const LUNAR_DATE_RE = /음력\s*(윤\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
34
36
  const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
35
37
  const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
36
38
  /** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
@@ -44,6 +46,12 @@ const holiday = (text) => {
44
46
  c.dayOffset = shift;
45
47
  out.push({ text: m[0], index: m.index, components: c });
46
48
  }
49
+ for (const m of text.matchAll(LUNAR_DATE_RE)) {
50
+ const month = +m[2], day = +m[3];
51
+ if (month < 1 || month > 12 || day < 1 || day > 30)
52
+ continue;
53
+ out.push({ text: m[0], index: m.index, components: { lunarDate: { month, day, leap: !!m[1] } } });
54
+ }
47
55
  for (const m of text.matchAll(LUNAR_RE)) {
48
56
  const name = m[1].replace(/\s/g, '');
49
57
  const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
@@ -3,6 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.relativeDay = void 0;
4
4
  const WORDS = {
5
5
  그저께: -2,
6
+ // 업무용 한자어 (규칙 33)
7
+ 금일: 0,
8
+ 명일: 1,
9
+ 익일: 1,
10
+ 작일: -1,
6
11
  내일모레: 2,
7
12
  낼모레: 2,
8
13
  낼: 1,
@@ -13,8 +18,8 @@ const WORDS = {
13
18
  모레: 2,
14
19
  글피: 3,
15
20
  };
16
- // "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
17
- const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
21
+ // "금일", "익일 배송", "작일" (업무용 한자어). "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
22
+ const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])(?:금일|명일|익일|작일)|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
18
23
  /** 오늘/내일/모레/글피/어제/그저께 */
19
24
  const relativeDay = (text) => {
20
25
  const out = [];
@@ -7,6 +7,9 @@ const PART = { 초: 'start', 중순: 'middle', 말: 'end' };
7
7
  const RE = /(지난|저번|이번|다다음|다음|담)\s*달\s*(초|중순|말|이맘때)?|(?<![가-힣])월(초|말)/g;
8
8
  // "11월 말", "12월 초" (absoluteDate의 MONTH_ONLY와 겹치지만 part가 있는 경우 우선)
9
9
  const ABS_PART = /(?<![\d가-힣])(\d{1,2})\s*월\s*(초|중순|말)/g;
10
+ // 업무용 한자어 달 (규칙 33): "익월 초", "금월 말까지", "전월 실적". "전월세"는 제외
11
+ const FORMAL_MONTH = /(?<![가-힣])(금|익|차|전)월(?!세)(?:\s*(초|중순|말))?/g;
12
+ const FORMAL_MONTH_OFFSET = { 금: 0, 익: 1, 차: 1, 전: -1 };
10
13
  /** 이번달/다음달 (초/중순/말) */
11
14
  const relativeMonth = (text) => {
12
15
  const out = [];
@@ -22,6 +25,12 @@ const relativeMonth = (text) => {
22
25
  c.monthPart = PART[m[2]];
23
26
  out.push({ text: m[0], index: m.index, components: c });
24
27
  }
28
+ for (const m of text.matchAll(FORMAL_MONTH)) {
29
+ const c = { monthOffset: FORMAL_MONTH_OFFSET[m[1]] };
30
+ if (m[2])
31
+ c.monthPart = PART[m[2]];
32
+ out.push({ text: m[0], index: m.index, components: c });
33
+ }
25
34
  for (const m of text.matchAll(ABS_PART)) {
26
35
  out.push({ text: m[0], index: m.index, components: { month: +m[1], monthPart: PART[m[2]] } });
27
36
  }
@@ -2,10 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.relativeYear = void 0;
4
4
  const WORDS = {
5
- 재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 내후년: 2,
5
+ 재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 익년: 1, 내후년: 2,
6
6
  };
7
7
  // "올해 말", "내년 초", "작년", "재작년", "내후년", "지난해", "내년 이맘때", "연말", "연초"
8
- const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
8
+ const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년|익년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
9
9
  /** 올해/내년/작년 (초/말) */
10
10
  const relativeYear = (text) => {
11
11
  const out = [];
@@ -134,7 +134,7 @@ const time = (text) => {
134
134
  for (const m of text.matchAll(HALF_WORD)) {
135
135
  if (!take(m.index, m[0].length))
136
136
  continue;
137
- out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true } });
137
+ out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true, approximate: true } });
138
138
  }
139
139
  for (const m of text.matchAll(WORD)) {
140
140
  if (!take(m.index, m[0].length))
@@ -142,6 +142,9 @@ const time = (text) => {
142
142
  const c = { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true };
143
143
  if (m[1] === '자정')
144
144
  c.midnight = true;
145
+ // 정오·자정은 정확한 시각, 나머지(아침·저녁·밤·점심·낮·퇴근)는 관례
146
+ else if (m[1] !== '정오')
147
+ c.approximate = true;
145
148
  out.push({ text: m[0], index: m.index, components: c });
146
149
  }
147
150
  return out;
@@ -20,6 +20,13 @@ const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷
20
20
  const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
21
21
  // "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
22
22
  const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
23
+ // 업무용 한자어 주 (규칙 33): "차주 금요일", "금주 월요일", "전주 수요일". 금주(禁酒)·전주(도시)·차주(車主)와
24
+ // 겹치므로 요일은 "요일/욜"까지 붙어야 하고, 요일 없이 쓸 때는 뒤에 날짜 말(내로·중으로·에·까지…)이 와야 한다.
25
+ // "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
26
+ const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
27
+ const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
28
+ // "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
29
+ const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|내로|내|안에|안으로|까지|초|말|부터)))/g;
23
30
  // "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
24
31
  const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
25
32
  // "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
@@ -35,6 +42,16 @@ const weekday = (text) => {
35
42
  const out = [];
36
43
  const taken = [];
37
44
  const overlaps = (i, len) => taken.some(([s, e]) => i < e && i + len > s);
45
+ for (const m of text.matchAll(FORMAL_WITH_DAY)) {
46
+ out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1]], weekday: WEEKDAY[m[2]] } });
47
+ taken.push([m.index, m.index + m[0].length]);
48
+ }
49
+ for (const m of text.matchAll(FORMAL_WEEK_ONLY)) {
50
+ if (overlaps(m.index, m[0].length))
51
+ continue;
52
+ out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1] ?? m[2]] } });
53
+ taken.push([m.index, m.index + m[0].length]);
54
+ }
38
55
  for (const m of text.matchAll(WEEKS_LATER)) {
39
56
  const n = (0, number_js_1.parseKoreanNumber)(m[1]);
40
57
  if (n == null)
@@ -71,6 +71,7 @@ function merge(list) {
71
71
  }
72
72
  const DATE_KEYS = [
73
73
  'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
74
+ 'lunar', 'lunarDate',
74
75
  ];
75
76
  /** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
76
77
  function isTimeOnly(c) {
@@ -137,21 +138,24 @@ function resolve(group, opts) {
137
138
  d.setSeconds(0, 0);
138
139
  let dateSet = false;
139
140
  const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
140
- // 0. 음력 명절 (규칙 29): 연도를 말했으면 그 해, 아니면 올해 것이 (연휴까지) 지났을 때 내년.
141
- // 공식 자료가 없는 연도는 null
141
+ // 0. 음력 명절·음력 날짜 (규칙 29, 32): 연도를 말했으면 그 음력 해, 아니면 (연휴까지) 지나지 않은 가장 가까운 것.
142
+ // 음력 12월은 양력으로 다음 해 1월이라 작년 음력부터 본다. 표에 없는 날짜는 null
142
143
  const hasYear = c.year !== undefined || c.yearOffset !== undefined;
143
- if (c.lunar) {
144
+ const isLunar = c.lunar !== undefined || c.lunarDate !== undefined;
145
+ if (isLunar) {
144
146
  const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
145
147
  const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
146
- const years = said !== undefined ? [said] : [ref.getFullYear(), ref.getFullYear() + 1];
148
+ const y0 = ref.getFullYear();
149
+ const years = said !== undefined ? [said] : [y0 - 1, y0, y0 + 1, y0 + 2];
150
+ const solarOf = (y) => c.lunar ? (0, lunarHolidays_js_1.lunarHoliday)(y, c.lunar) : (0, lunarHolidays_js_1.lunarToSolar)(y, c.lunarDate.month, c.lunarDate.day, c.lunarDate.leap);
147
151
  let found = null;
148
152
  for (const y of years) {
149
- const md = lunarHolidays_js_1.LUNAR_HOLIDAYS[y]?.[c.lunar];
150
- if (!md)
151
- break;
152
- const lastMoment = new Date(y, md[0] - 1, md[1] + tail, 23, 59);
153
+ const s = solarOf(y);
154
+ if (!s)
155
+ continue; // 그 해에 없는 날 ("음력 2월 30일", 윤달)
156
+ const lastMoment = new Date(s.getFullYear(), s.getMonth(), s.getDate() + tail, 23, 59);
153
157
  if (said !== undefined || lastMoment >= ref) {
154
- found = new Date(y, md[0] - 1, md[1]);
158
+ found = s;
155
159
  break;
156
160
  }
157
161
  }
@@ -161,7 +165,7 @@ function resolve(group, opts) {
161
165
  dateSet = true;
162
166
  }
163
167
  // 1. 절대 날짜
164
- if (!c.lunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
168
+ if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
165
169
  let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
166
170
  // "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
167
171
  const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
@@ -287,10 +291,13 @@ function resolve(group, opts) {
287
291
  // 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
288
292
  if (Number.isNaN(d.getTime()))
289
293
  return null;
294
+ const ambiguity = ambiguityOf(c, d, dateSet, opts);
290
295
  const first = group[0];
291
296
  const last = group[group.length - 1];
292
297
  // text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
293
298
  const result = { date: d, text: '', index: first.index, hasTime };
299
+ if (ambiguity)
300
+ result.ambiguity = ambiguity;
294
301
  result._end = last.index + last.text.length;
295
302
  // "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
296
303
  if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
@@ -405,6 +412,31 @@ function resolveRecurrence(group, c, opts) {
405
412
  result._end = last.index + last.text.length;
406
413
  return result;
407
414
  }
415
+ /** 규칙 31: 해석이 갈릴 수 있는 지점과 다른 후보 */
416
+ function ambiguityOf(c, d, dateSet, opts) {
417
+ const ref = opts.referenceDate;
418
+ // 오전/오후 없는 1~12시: 반대쪽 12시간이 후보 (날짜가 없으면 규칙 4대로 다음 발생)
419
+ if (c.hour !== undefined && !c.meridiem && !c.midnight && c.hour >= 1 && c.hour <= 12) {
420
+ const used = clockHour(c);
421
+ const altHour = used === 12 ? 0 : used < 12 ? used + 12 : used - 12;
422
+ const alt = new Date(dateSet ? d : ref);
423
+ alt.setHours(altHour, c.minute ?? 0, 0, 0);
424
+ if (!dateSet && alt < ref)
425
+ alt.setDate(alt.getDate() + 1);
426
+ return { reason: 'meridiem', alternatives: [alt] };
427
+ }
428
+ // "다음 토요일": 규칙 23(오늘 제외 가장 가까운)과 "다음 주 토요일"이 다르면 후보로
429
+ if (c.weekdayDir === 'next' && c.weekday !== undefined) {
430
+ const alt = startOfWeek(ref, opts.weekStart);
431
+ alt.setDate(alt.getDate() + 7 + (opts.weekStart === 'monday' ? (c.weekday + 6) % 7 : c.weekday));
432
+ alt.setHours(d.getHours(), d.getMinutes(), 0, 0);
433
+ if (alt.getTime() !== d.getTime())
434
+ return { reason: 'next-weekday', alternatives: [alt] };
435
+ }
436
+ if (c.approximate)
437
+ return { reason: 'approximate-time', alternatives: [] };
438
+ return undefined;
439
+ }
408
440
  function partDay(y, m0, part) {
409
441
  if (part === 'start')
410
442
  return 1;
@@ -34,10 +34,18 @@ export interface Components {
34
34
  weekdayDir?: 'past' | 'next';
35
35
  /** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
36
36
  sameDay?: boolean;
37
+ /** "저녁", "아침"처럼 관례로 정한 시각 */
38
+ approximate?: boolean;
37
39
  /** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
38
40
  sameTime?: boolean;
39
41
  /** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
40
42
  lunar?: 'seollal' | 'chuseok' | 'buddha';
43
+ /** "음력 3월 3일", "음력 윤5월 1일" */
44
+ lunarDate?: {
45
+ month: number;
46
+ day: number;
47
+ leap: boolean;
48
+ };
41
49
  /** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
42
50
  recur?: Omit<Recurrence, 'rrule'>;
43
51
  /** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
@@ -88,6 +96,16 @@ export interface Fragment {
88
96
  index: number;
89
97
  components: Components;
90
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
+ }
91
109
  export interface ParseResult {
92
110
  /** 해석된 시각 (구간이면 시작) */
93
111
  date: Date;
@@ -101,5 +119,7 @@ export interface ParseResult {
101
119
  hasTime: boolean;
102
120
  /** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
103
121
  recurrence?: Recurrence;
122
+ /** 해석이 갈릴 수 있으면 그 이유와 다른 후보. 사용자에게 되묻거나 LLM에 넘길지 판단할 때 쓴다 */
123
+ ambiguity?: Ambiguity;
104
124
  }
105
125
  export type Parser = (text: string) => Fragment[];
@@ -1,3 +1,11 @@
1
1
  export type LunarHoliday = 'seollal' | 'chuseok' | 'buddha';
2
- /** 연도 → [월, 일] (양력) */
3
- export declare const LUNAR_HOLIDAYS: Record<number, Record<LunarHoliday, [number, number]>>;
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)[];
@@ -1,16 +1,82 @@
1
1
  "use strict";
2
- // 음력 명절의 양력 날짜. 런타임 의존성 없이 음력 변환을 하려면 표가 필요하다.
2
+ // 음력 → 양력 변환표. 런타임 의존성 없이 음력을 다루려면 표가 필요하다.
3
3
  //
4
4
  // 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
5
- // 2025년은 관공서 공휴일 목록. 2026년 부처님 오신 날·추석은 공휴일 목록으로 교차 확인.
6
- // KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지). 새 연도가 공개되면 한 줄 추가할 것.
7
- // 표에 없는 연도는 추측하지 않고 null (규칙 10). 한국 설날은 한국 표준시 기준이라 중국 춘절과 하루 다를 수 있다.
5
+ // 2025~2028년 페이지에서 음력 초하루(1일)가 되는 양력 날짜를 옮겼다.
6
+ // 설날·부처님 오신 날·추석 12개가 관공서 공휴일 목록과 일치하는지 tests/lunar.test.ts에서 확인한다.
7
+ // KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지).
8
+ // 새 연도가 공개되면 그 해의 초하루 목록을 추가하고, 직전 해 마지막 달의 길이가 확정되는지 확인할 것.
9
+ // 표에 없는 날짜는 추측하지 않고 null (규칙 10). 한국 음력은 한국 표준시 기준이라 중국과 하루 다를 수 있다.
8
10
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.LUNAR_HOLIDAYS = void 0;
10
- /** 연도 → [월, 일] (양력) */
11
- exports.LUNAR_HOLIDAYS = {
12
- 2025: { seollal: [1, 29], buddha: [5, 5], chuseok: [10, 6] },
13
- 2026: { seollal: [2, 17], buddha: [5, 24], chuseok: [9, 25] },
14
- 2027: { seollal: [2, 7], buddha: [5, 13], chuseok: [9, 15] },
15
- 2028: { seollal: [1, 27], buddha: [5, 2], chuseok: [10, 3] },
11
+ exports.LUNAR_YEARS = void 0;
12
+ exports.lunarToSolar = lunarToSolar;
13
+ exports.lunarHoliday = lunarHoliday;
14
+ exports.monthLengths = monthLengths;
15
+ const MONTH_STARTS = {
16
+ 2025: [
17
+ [1, false, 2025, 1, 29], [2, false, 2025, 2, 28], [3, false, 2025, 3, 29], [4, false, 2025, 4, 28],
18
+ [5, false, 2025, 5, 27], [6, false, 2025, 6, 25], [6, true, 2025, 7, 25], [7, false, 2025, 8, 23],
19
+ [8, false, 2025, 9, 22], [9, false, 2025, 10, 21], [10, false, 2025, 11, 20], [11, false, 2025, 12, 20],
20
+ [12, false, 2026, 1, 19],
21
+ ],
22
+ 2026: [
23
+ [1, false, 2026, 2, 17], [2, false, 2026, 3, 19], [3, false, 2026, 4, 17], [4, false, 2026, 5, 17],
24
+ [5, false, 2026, 6, 15], [6, false, 2026, 7, 14], [7, false, 2026, 8, 13], [8, false, 2026, 9, 11],
25
+ [9, false, 2026, 10, 11], [10, false, 2026, 11, 9], [11, false, 2026, 12, 9], [12, false, 2027, 1, 8],
26
+ ],
27
+ 2027: [
28
+ [1, false, 2027, 2, 7], [2, false, 2027, 3, 8], [3, false, 2027, 4, 7], [4, false, 2027, 5, 6],
29
+ [5, false, 2027, 6, 5], [6, false, 2027, 7, 4], [7, false, 2027, 8, 2], [8, false, 2027, 9, 1],
30
+ [9, false, 2027, 9, 30], [10, false, 2027, 10, 29], [11, false, 2027, 11, 28], [12, false, 2027, 12, 28],
31
+ ],
32
+ 2028: [
33
+ [1, false, 2028, 1, 27], [2, false, 2028, 2, 25], [3, false, 2028, 3, 26], [4, false, 2028, 4, 25],
34
+ [5, false, 2028, 5, 24], [5, true, 2028, 6, 23], [6, false, 2028, 7, 22], [7, false, 2028, 8, 20],
35
+ [8, false, 2028, 9, 19], [9, false, 2028, 10, 18], [10, false, 2028, 11, 16], [11, false, 2028, 12, 16],
36
+ [12, false, 2029, 1, 15],
37
+ ],
16
38
  };
39
+ exports.LUNAR_YEARS = Object.keys(MONTH_STARTS).map(Number);
40
+ const DAY = 86_400_000;
41
+ const solar = (s) => new Date(s[2], s[3] - 1, s[4]);
42
+ /** 그 음력 달의 다음 달 초하루 (모르면 null) */
43
+ function nextStart(year, index) {
44
+ const months = MONTH_STARTS[year];
45
+ if (index + 1 < months.length)
46
+ return solar(months[index + 1]);
47
+ const next = MONTH_STARTS[year + 1];
48
+ return next ? solar(next[0]) : null;
49
+ }
50
+ /**
51
+ * 음력 → 양력. 그 달에 없는 날(29일인 달의 30일)이나 표에 없는 날짜는 null.
52
+ * 마지막 달처럼 길이를 모르면 29일까지만 받는다.
53
+ */
54
+ function lunarToSolar(year, month, day, leap = false) {
55
+ const months = MONTH_STARTS[year];
56
+ if (!months || day < 1 || day > 30)
57
+ return null;
58
+ const index = months.findIndex(([m, l]) => m === month && l === leap);
59
+ if (index < 0)
60
+ return null;
61
+ const start = solar(months[index]);
62
+ const next = nextStart(year, index);
63
+ const length = next ? Math.round((next.getTime() - start.getTime()) / DAY) : 29;
64
+ if (day > length)
65
+ return null;
66
+ return new Date(start.getFullYear(), start.getMonth(), start.getDate() + day - 1);
67
+ }
68
+ /** 설날(1/1)·부처님 오신 날(4/8)·추석(8/15)의 양력 날짜 */
69
+ function lunarHoliday(year, which) {
70
+ if (which === 'seollal')
71
+ return lunarToSolar(year, 1, 1);
72
+ if (which === 'buddha')
73
+ return lunarToSolar(year, 4, 8);
74
+ return lunarToSolar(year, 8, 15);
75
+ }
76
+ /** 각 음력 달의 길이 (검증용). 모르면 null */
77
+ function monthLengths(year) {
78
+ return MONTH_STARTS[year].map((s, i) => {
79
+ const next = nextStart(year, i);
80
+ return next ? Math.round((next.getTime() - solar(s).getTime()) / DAY) : null;
81
+ });
82
+ }
package/dist/event.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ParseResult, Recurrence } from './types.js';
1
+ import type { Ambiguity, ParseResult, Recurrence } from './types.js';
2
2
  /** parseEvent()의 결과: 할 일·일정 한 줄을 제목과 시점으로 나눈 것 */
3
3
  export interface ParsedEvent {
4
4
  /** 날짜 부분을 뺀 나머지 ("내일 3시 치과" → "치과"). 없으면 '' */
@@ -10,6 +10,8 @@ export interface ParsedEvent {
10
10
  hasTime: boolean;
11
11
  /** 반복 일정이면 그 규칙 */
12
12
  recurrence?: Recurrence;
13
+ /** 해석이 갈릴 수 있으면 그 이유와 다른 후보 ("3시" → 15시, 후보 3시) */
14
+ ambiguity?: Ambiguity;
13
15
  }
14
16
  /**
15
17
  * 문장에서 날짜로 인식된 부분과 거기 붙은 조사를 빼서 제목을 만든다 (규칙 28).
package/dist/event.js CHANGED
@@ -24,5 +24,7 @@ export function toEvent(text, results) {
24
24
  event.end = first.end;
25
25
  if (first.recurrence)
26
26
  event.recurrence = first.recurrence;
27
+ if (first.ambiguity)
28
+ event.ambiguity = first.ambiguity;
27
29
  return event;
28
30
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ParseOptions, ParseResult } from './types.js';
2
2
  import { type ParsedEvent } from './event.js';
3
- export type { Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
3
+ export type { Ambiguity, Frequency, ParseOptions, ParseResult, Recurrence, WeekStart } from './types.js';
4
4
  export type { ParsedEvent } from './event.js';
5
5
  /**
6
6
  * 문장에서 모든 날짜/시간 표현을 찾는다. 등장 순서대로 반환하며, 없으면 빈 배열.
package/dist/index.js CHANGED
@@ -58,6 +58,9 @@ export function parseAll(text, options) {
58
58
  const out = { ...r, date: fromWallClock(r.date, timezone) };
59
59
  if (r.end)
60
60
  out.end = fromWallClock(r.end, timezone);
61
+ if (r.ambiguity) {
62
+ out.ambiguity = { ...r.ambiguity, alternatives: r.ambiguity.alternatives.map((a) => fromWallClock(a, timezone)) };
63
+ }
61
64
  return out;
62
65
  });
63
66
  }
@@ -28,6 +28,8 @@ const MODIFIER = '(?:\\s*(전날|다음\\s*날|이튿날|당일))?';
28
28
  const FIXED_RE = new RegExp(`(${Object.keys(FIXED).map((k) => k.replace(/ /g, '\\s*')).join('|')})${MODIFIER}`, 'g');
29
29
  // "설날", "구정", "설 연휴", "추석 전날", "한가위", "부처님 오신 날". "설"만은 "설명", "설거지"와 겹쳐서 뒤에 연휴·전날 등이 올 때만
30
30
  const LUNAR_RE = /(설날|구정|설(?=\s*(?:연휴|전날|다음\s*날|당일))|추석|한가위|부처님\s*오신\s*날|석가\s*탄신일|석탄일|초파일)(?:\s*(연휴))?(?:\s*(전날|다음\s*날|이튿날|당일))?/g;
31
+ // "음력 3월 3일", "음력 윤5월 1일" (음력이라고 말한 경우만)
32
+ const LUNAR_DATE_RE = /음력\s*(윤\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
31
33
  const fixedKey = (name) => Object.keys(FIXED).find((k) => k.replace(/ /g, '') === name.replace(/\s/g, ''));
32
34
  const shiftOf = (m) => (m ? SHIFT[m.replace(/\s/g, '')] : 0);
33
35
  /** 공휴일·기념일·명절. 음력 명절의 양력 날짜는 resolve가 표에서 찾는다 */
@@ -41,6 +43,12 @@ export const holiday = (text) => {
41
43
  c.dayOffset = shift;
42
44
  out.push({ text: m[0], index: m.index, components: c });
43
45
  }
46
+ for (const m of text.matchAll(LUNAR_DATE_RE)) {
47
+ const month = +m[2], day = +m[3];
48
+ if (month < 1 || month > 12 || day < 1 || day > 30)
49
+ continue;
50
+ out.push({ text: m[0], index: m.index, components: { lunarDate: { month, day, leap: !!m[1] } } });
51
+ }
44
52
  for (const m of text.matchAll(LUNAR_RE)) {
45
53
  const name = m[1].replace(/\s/g, '');
46
54
  const lunar = name === '설날' || name === '구정' || name === '설' ? 'seollal'
@@ -1,5 +1,10 @@
1
1
  const WORDS = {
2
2
  그저께: -2,
3
+ // 업무용 한자어 (규칙 33)
4
+ 금일: 0,
5
+ 명일: 1,
6
+ 익일: 1,
7
+ 작일: -1,
3
8
  내일모레: 2,
4
9
  낼모레: 2,
5
10
  낼: 1,
@@ -10,8 +15,8 @@ const WORDS = {
10
15
  모레: 2,
11
16
  글피: 3,
12
17
  };
13
- // "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
14
- const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
18
+ // "금일", "익일 배송", "작일" (업무용 한자어). "내일모레"는 "내일"+"모레"가 아니라 모레. "낼"은 "내가 낼게"(내다)와 겹쳐서 뒤에 시각·약속 표현이 올 때만
19
+ const RE = /(그저께|그제|어제|오늘|내일모레|낼모레|내일|모레|글피|(?<![가-힣])(?:금일|명일|익일|작일)|(?<![가-힣])낼(?=\s*(?:오전|오후|아침|점심|저녁|밤|새벽|낮|\d|봐|보자|만나|까지)))/g;
15
20
  /** 오늘/내일/모레/글피/어제/그저께 */
16
21
  export const relativeDay = (text) => {
17
22
  const out = [];
@@ -4,6 +4,9 @@ const PART = { 초: 'start', 중순: 'middle', 말: 'end' };
4
4
  const RE = /(지난|저번|이번|다다음|다음|담)\s*달\s*(초|중순|말|이맘때)?|(?<![가-힣])월(초|말)/g;
5
5
  // "11월 말", "12월 초" (absoluteDate의 MONTH_ONLY와 겹치지만 part가 있는 경우 우선)
6
6
  const ABS_PART = /(?<![\d가-힣])(\d{1,2})\s*월\s*(초|중순|말)/g;
7
+ // 업무용 한자어 달 (규칙 33): "익월 초", "금월 말까지", "전월 실적". "전월세"는 제외
8
+ const FORMAL_MONTH = /(?<![가-힣])(금|익|차|전)월(?!세)(?:\s*(초|중순|말))?/g;
9
+ const FORMAL_MONTH_OFFSET = { 금: 0, 익: 1, 차: 1, 전: -1 };
7
10
  /** 이번달/다음달 (초/중순/말) */
8
11
  export const relativeMonth = (text) => {
9
12
  const out = [];
@@ -19,6 +22,12 @@ export const relativeMonth = (text) => {
19
22
  c.monthPart = PART[m[2]];
20
23
  out.push({ text: m[0], index: m.index, components: c });
21
24
  }
25
+ for (const m of text.matchAll(FORMAL_MONTH)) {
26
+ const c = { monthOffset: FORMAL_MONTH_OFFSET[m[1]] };
27
+ if (m[2])
28
+ c.monthPart = PART[m[2]];
29
+ out.push({ text: m[0], index: m.index, components: c });
30
+ }
22
31
  for (const m of text.matchAll(ABS_PART)) {
23
32
  out.push({ text: m[0], index: m.index, components: { month: +m[1], monthPart: PART[m[2]] } });
24
33
  }
@@ -1,8 +1,8 @@
1
1
  const WORDS = {
2
- 재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 내후년: 2,
2
+ 재작년: -2, 작년: -1, 지난해: -1, 올해: 0, 금년: 0, 내년: 1, 명년: 1, 익년: 1, 내후년: 2,
3
3
  };
4
4
  // "올해 말", "내년 초", "작년", "재작년", "내후년", "지난해", "내년 이맘때", "연말", "연초"
5
- const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
5
+ const RE = /(재작년|작년|지난해|올해|금년|내후년|내년|명년|익년)(?:\s*(초|말|이맘때))?|(?<![가-힣])(연말(?!정산)|연초)/g;
6
6
  /** 올해/내년/작년 (초/말) */
7
7
  export const relativeYear = (text) => {
8
8
  const out = [];
@@ -131,7 +131,7 @@ export const time = (text) => {
131
131
  for (const m of text.matchAll(HALF_WORD)) {
132
132
  if (!take(m.index, m[0].length))
133
133
  continue;
134
- out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true } });
134
+ out.push({ text: m[0], index: m.index, components: { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true, approximate: true } });
135
135
  }
136
136
  for (const m of text.matchAll(WORD)) {
137
137
  if (!take(m.index, m[0].length))
@@ -139,6 +139,9 @@ export const time = (text) => {
139
139
  const c = { hour: WORD_HOUR[m[1]], minute: 0, meridiem: true };
140
140
  if (m[1] === '자정')
141
141
  c.midnight = true;
142
+ // 정오·자정은 정확한 시각, 나머지(아침·저녁·밤·점심·낮·퇴근)는 관례
143
+ else if (m[1] !== '정오')
144
+ c.approximate = true;
142
145
  out.push({ text: m[0], index: m.index, components: c });
143
146
  }
144
147
  return out;
@@ -17,6 +17,13 @@ const NTH = new RegExp(`(?:(\\d{1,2})\\s*월\\s*)?(첫째|첫|둘째|셋째|넷
17
17
  const NTH_NUM = { 첫째: 1, 첫: 1, 둘째: 2, 셋째: 3, 넷째: 4, 다섯째: 5, 마지막: -1 };
18
18
  // "2주 뒤 월요일", "3주 후 금요일" → N주 뒤의 그 주의 X요일 ("다다음주 월요일"과 같은 계산)
19
19
  const WEEKS_LATER = new RegExp(`(\\d{1,2}|한|두|세|네|다섯)\\s*주\\s*(?:뒤|후)\\s*(${DAY})(?:요일|욜)`, 'g');
20
+ // 업무용 한자어 주 (규칙 33): "차주 금요일", "금주 월요일", "전주 수요일". 금주(禁酒)·전주(도시)·차주(車主)와
21
+ // 겹치므로 요일은 "요일/욜"까지 붙어야 하고, 요일 없이 쓸 때는 뒤에 날짜 말(내로·중으로·에·까지…)이 와야 한다.
22
+ // "전주"는 도시 이름과 너무 자주 겹쳐서 요일이 붙을 때만
23
+ const FORMAL_WEEK = { 전전: -2, 전: -1, 금: 0, 차: 1, 익: 1, 차차: 2 };
24
+ const FORMAL_WITH_DAY = new RegExp(`(?<![가-힣])(전전|차차|전|금|차|익)주\\s*(${DAY})(?:요일|욜)`, 'g');
25
+ // "차주 중으로", "금주 내로", "차주에 회의", "익주까지". "금주에"는 "금주에 성공했어"(禁酒)와 겹쳐서 제외
26
+ const FORMAL_WEEK_ONLY = /(?<![가-힣])(?:(전전|차차|차|익)주(?=\s*(?:에(?!게|서)|에는|중으로|중에|내로|내|안에|안으로|까지|초|말|부터))|(금)주(?=\s*(?:중으로|중에|내로|내|안에|안으로|까지|초|말|부터)))/g;
20
27
  // "지난 금요일" = 오늘 제외 가장 가까운 과거, "다음 금요일" = 오늘 제외 가장 가까운 미래
21
28
  const DIR = new RegExp(`(지난|저번|다음)\\s*(${DAY})(?:요일|욜)`, 'g');
22
29
  // "주말에 보자" ("이번 주말"은 위 WEEKEND가 먼저 잡는다)
@@ -32,6 +39,16 @@ export const weekday = (text) => {
32
39
  const out = [];
33
40
  const taken = [];
34
41
  const overlaps = (i, len) => taken.some(([s, e]) => i < e && i + len > s);
42
+ for (const m of text.matchAll(FORMAL_WITH_DAY)) {
43
+ out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1]], weekday: WEEKDAY[m[2]] } });
44
+ taken.push([m.index, m.index + m[0].length]);
45
+ }
46
+ for (const m of text.matchAll(FORMAL_WEEK_ONLY)) {
47
+ if (overlaps(m.index, m[0].length))
48
+ continue;
49
+ out.push({ text: m[0], index: m.index, components: { weekOffset: FORMAL_WEEK[m[1] ?? m[2]] } });
50
+ taken.push([m.index, m.index + m[0].length]);
51
+ }
35
52
  for (const m of text.matchAll(WEEKS_LATER)) {
36
53
  const n = parseKoreanNumber(m[1]);
37
54
  if (n == null)
package/dist/resolve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { toRRule } from './parsers/recurrence.js';
2
- import { LUNAR_HOLIDAYS } from './util/lunarHolidays.js';
2
+ import { lunarHoliday, lunarToSolar } from './util/lunarHolidays.js';
3
3
  function startOfWeek(d, weekStart) {
4
4
  const r = new Date(d);
5
5
  r.setHours(0, 0, 0, 0);
@@ -62,6 +62,7 @@ export function merge(list) {
62
62
  }
63
63
  const DATE_KEYS = [
64
64
  'year', 'yearOffset', 'month', 'day', 'dayOffset', 'weekOffset', 'weekday', 'monthOffset', 'monthPart',
65
+ 'lunar', 'lunarDate',
65
66
  ];
66
67
  /** 날짜 정보 없이 시각만 있는 조각인지 ("5시까지", "17:00") */
67
68
  export function isTimeOnly(c) {
@@ -128,21 +129,24 @@ export function resolve(group, opts) {
128
129
  d.setSeconds(0, 0);
129
130
  let dateSet = false;
130
131
  const hasTime = c.hour !== undefined || c.hourOffset !== undefined || c.minuteOffset !== undefined;
131
- // 0. 음력 명절 (규칙 29): 연도를 말했으면 그 해, 아니면 올해 것이 (연휴까지) 지났을 때 내년.
132
- // 공식 자료가 없는 연도는 null
132
+ // 0. 음력 명절·음력 날짜 (규칙 29, 32): 연도를 말했으면 그 음력 해, 아니면 (연휴까지) 지나지 않은 가장 가까운 것.
133
+ // 음력 12월은 양력으로 다음 해 1월이라 작년 음력부터 본다. 표에 없는 날짜는 null
133
134
  const hasYear = c.year !== undefined || c.yearOffset !== undefined;
134
- if (c.lunar) {
135
+ const isLunar = c.lunar !== undefined || c.lunarDate !== undefined;
136
+ if (isLunar) {
135
137
  const said = c.year ?? (c.yearOffset !== undefined ? ref.getFullYear() + c.yearOffset : undefined);
136
138
  const tail = (c.dayOffset ?? 0) + (c.durationDays ? c.durationDays - 1 : 0);
137
- const years = said !== undefined ? [said] : [ref.getFullYear(), ref.getFullYear() + 1];
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);
138
142
  let found = null;
139
143
  for (const y of years) {
140
- const md = LUNAR_HOLIDAYS[y]?.[c.lunar];
141
- if (!md)
142
- break;
143
- const lastMoment = new Date(y, md[0] - 1, md[1] + tail, 23, 59);
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);
144
148
  if (said !== undefined || lastMoment >= ref) {
145
- found = new Date(y, md[0] - 1, md[1]);
149
+ found = s;
146
150
  break;
147
151
  }
148
152
  }
@@ -152,7 +156,7 @@ export function resolve(group, opts) {
152
156
  dateSet = true;
153
157
  }
154
158
  // 1. 절대 날짜
155
- if (!c.lunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
159
+ if (!isLunar && (hasYear || c.month !== undefined || c.day !== undefined)) {
156
160
  let y = c.year ?? ref.getFullYear() + (c.yearOffset ?? 0);
157
161
  // "작년", "내년"처럼 연도만 있으면 1월 1일. "내년 이맘때", "1년 뒤"는 오늘 날짜 유지
158
162
  const m0 = c.month !== undefined ? c.month - 1 : hasYear && !c.sameDay ? 0 : ref.getMonth();
@@ -278,10 +282,13 @@ export function resolve(group, opts) {
278
282
  // 안전장치: 어떤 경로로든 계산이 깨진 날짜는 내보내지 않는다
279
283
  if (Number.isNaN(d.getTime()))
280
284
  return null;
285
+ const ambiguity = ambiguityOf(c, d, dateSet, opts);
281
286
  const first = group[0];
282
287
  const last = group[group.length - 1];
283
288
  // text는 parseAll()에서 원문 slice로 채운다. _end는 그때 쓰는 내부 값.
284
289
  const result = { date: d, text: '', index: first.index, hasTime };
290
+ if (ambiguity)
291
+ result.ambiguity = ambiguity;
285
292
  result._end = last.index + last.text.length;
286
293
  // "오늘부터 3일간", "3시부터 2시간 동안": 시작일을 포함한 기간의 끝
287
294
  if (c.durationDays !== undefined || c.durationMonths !== undefined || c.durationMinutes !== undefined) {
@@ -396,6 +403,31 @@ function resolveRecurrence(group, c, opts) {
396
403
  result._end = last.index + last.text.length;
397
404
  return result;
398
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
+ }
399
431
  function partDay(y, m0, part) {
400
432
  if (part === 'start')
401
433
  return 1;
package/dist/types.d.ts CHANGED
@@ -34,10 +34,18 @@ export interface Components {
34
34
  weekdayDir?: 'past' | 'next';
35
35
  /** "1년 뒤", "내년 이맘때": 연/월/주 이동 시 기준일의 날짜를 유지 */
36
36
  sameDay?: boolean;
37
+ /** "저녁", "아침"처럼 관례로 정한 시각 */
38
+ approximate?: boolean;
37
39
  /** "내일 이 시간": 기준 시각의 시:분을 쓴다 */
38
40
  sameTime?: boolean;
39
41
  /** 음력 명절 (설날·추석·부처님 오신 날). 양력 날짜는 resolve가 util/lunarHolidays 표에서 찾는다 */
40
42
  lunar?: 'seollal' | 'chuseok' | 'buddha';
43
+ /** "음력 3월 3일", "음력 윤5월 1일" */
44
+ lunarDate?: {
45
+ month: number;
46
+ day: number;
47
+ leap: boolean;
48
+ };
41
49
  /** "매주 월요일", "매달 15일": 반복 규칙 (rrule은 resolve가 만든다) */
42
50
  recur?: Omit<Recurrence, 'rrule'>;
43
51
  /** 연 단위 이동 (내년=+1). 지정되면 "지난 월일 → 내년" 규칙을 적용하지 않는다 */
@@ -88,6 +96,16 @@ export interface Fragment {
88
96
  index: number;
89
97
  components: Components;
90
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
+ }
91
109
  export interface ParseResult {
92
110
  /** 해석된 시각 (구간이면 시작) */
93
111
  date: Date;
@@ -101,5 +119,7 @@ export interface ParseResult {
101
119
  hasTime: boolean;
102
120
  /** 반복 일정이면 그 규칙. date는 기준 시각 이후 첫 발생 */
103
121
  recurrence?: Recurrence;
122
+ /** 해석이 갈릴 수 있으면 그 이유와 다른 후보. 사용자에게 되묻거나 LLM에 넘길지 판단할 때 쓴다 */
123
+ ambiguity?: Ambiguity;
104
124
  }
105
125
  export type Parser = (text: string) => Fragment[];
@@ -1,3 +1,11 @@
1
1
  export type LunarHoliday = 'seollal' | 'chuseok' | 'buddha';
2
- /** 연도 → [월, 일] (양력) */
3
- export declare const LUNAR_HOLIDAYS: Record<number, Record<LunarHoliday, [number, number]>>;
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)[];
@@ -1,13 +1,76 @@
1
- // 음력 명절의 양력 날짜. 런타임 의존성 없이 음력 변환을 하려면 표가 필요하다.
1
+ // 음력 → 양력 변환표. 런타임 의존성 없이 음력을 다루려면 표가 필요하다.
2
2
  //
3
3
  // 출처: 한국천문연구원 천문우주지식정보 "월별 양음력 대조" (astro.kasi.re.kr/life/post/calendarData)
4
- // 2025년은 관공서 공휴일 목록. 2026년 부처님 오신 날·추석은 공휴일 목록으로 교차 확인.
5
- // KASI는 2~3년 앞까지만 공식 자료를 낸다 (2026-10 기준 2028년까지). 새 연도가 공개되면 한 줄 추가할 것.
6
- // 표에 없는 연도는 추측하지 않고 null (규칙 10). 한국 설날은 한국 표준시 기준이라 중국 춘절과 하루 다를 수 있다.
7
- /** 연도 → [월, 일] (양력) */
8
- export const LUNAR_HOLIDAYS = {
9
- 2025: { seollal: [1, 29], buddha: [5, 5], chuseok: [10, 6] },
10
- 2026: { seollal: [2, 17], buddha: [5, 24], chuseok: [9, 25] },
11
- 2027: { seollal: [2, 7], buddha: [5, 13], chuseok: [9, 15] },
12
- 2028: { seollal: [1, 27], buddha: [5, 2], chuseok: [10, 3] },
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
+ ],
13
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ko-chrono",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "한국어 자연어 날짜/시간 파서 — '다음주 화요일 오후 3시' → Date",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -22,6 +22,7 @@
22
22
  "files": ["dist", "README.md", "LICENSE"],
23
23
  "scripts": {
24
24
  "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node scripts/mark-cjs.mjs",
25
+ "prepublishOnly": "npm run build && npm test",
25
26
  "test": "vitest run",
26
27
  "test:watch": "vitest",
27
28
  "typecheck": "tsc --noEmit",