├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── date ├── README.md ├── cache.test.ts ├── cache.ts ├── fn.test.ts ├── fn.ts ├── holiday.test.ts ├── holiday.ts └── mod.ts ├── deno.json ├── id ├── README.md ├── analyze.test.ts ├── analyze.ts ├── format.test.ts ├── format.ts ├── mod.ts ├── validate.test.ts └── validate.ts ├── mod.ts ├── phone ├── README.md ├── format.test.ts ├── format.ts └── mod.ts ├── scripts └── build_npm.ts └── text ├── README.md ├── jongseong.test.ts ├── jongseong.ts ├── mod.ts ├── text.test.ts └── text.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | deno-version: [v1.x] 15 | steps: 16 | - name: Git Checkout Deno Module 17 | uses: actions/checkout@v2 18 | - name: Use Deno Version ${{ matrix.deno-version }} 19 | uses: denoland/setup-deno@v1 20 | with: 21 | deno-version: ${{ matrix.deno-version }} 22 | - name: Format 23 | run: deno fmt --check 24 | - name: Lint 25 | run: deno lint 26 | - name: Unit Test 27 | run: deno test --coverage=coverage 28 | - name: Create coverage report 29 | run: deno coverage ./coverage --lcov > coverage.lcov 30 | - name: Collect coverage 31 | uses: codecov/codecov-action@v1.0.10 32 | with: 33 | file: ./coverage.lcov 34 | - name: Build Module 35 | run: deno task build:npm 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .npm 2 | 3 | deno.lock 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "denoland.vscode-deno", 6 | "cSpell.words": [ 7 | "deno", 8 | "denostack", 9 | "jongseong", 10 | "josa", 11 | "jumin", 12 | "kokr", 13 | "lieul", 14 | "webstore" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # koKR 2 | 3 |

4 | Build 5 | Coverage 6 | Language Typescript 7 | deno.land/x/kokr 8 |

9 | 10 | koKR은 한국어(ko) 및 한국(KR) 관련 유틸리티 모음으로, 타입스크립트를 기반으로 11 | 작성되었습니다. 이 라이브러리는 NPM과 Deno에서 사용할 수 있습니다. 12 | 13 | ## 목차 14 | 15 | 현재 개발된 상세 항목들은 아래 목록에 나열되어 있습니다. 앞으로도 필요한 경우 16 | 추가적인 기능을 개발하여 라이브러리를 확장할 예정입니다. 17 | 18 | - [@kokr/date](./date): 날짜 관련 유틸리티를 제공합니다. 공휴일, 절기, 그리고 19 | 잡절 정보를 확인하고, 영업일 기준 날짜 계산을 지원합니다. 20 | - [@kokr/id](./id): 주민등록번호를 분석하는 도구를 제공합니다. 생년월일과 성별 21 | 등의 정보를 확인하고, 주민등록번호의 유효성을 검증합니다. 22 | - [@kokr/phone](./phone): 전화번호 서식 변환 도구를 제공합니다. 전화번호를 23 | 일관된 형식으로 변환하는 기능을 지원합니다. 24 | - [@kokr/text](./text): 한국어 문장의 조사 처리를 도와주는 유틸리티입니다. 25 | 은/는/이/가 등의 조사를 적절하게 처리합니다. 26 | 27 | ## 문서 28 | 29 | 코드 작성 시 jsdoc을 최대한 활용하였으며, 이를 통해 Deno 페이지에서 모든 코드에 30 | 대한 문서를 확인할 수 있습니다. 자세한 사용법과 예제는 각각의 문서를 참고하세요. 31 | 32 | [모든 문서 보기](https://deno.land/x/kokr/mod.ts) 33 | 34 | ## 기여하기 35 | 36 | koKR 라이브러리에 기여하고 싶으신 분들은 환영입니다! 기능 추가나 버그 수정을 37 | 위한 이슈를 제안하거나, 풀 리퀘스트를 보내주시기 바랍니다. 또한, 문서 개선이나 38 | 테스트 케이스 추가 등 다양한 방식으로도 기여가 가능합니다. 함께 발전시켜나가는 39 | 라이브러리가 되기를 희망합니다. 40 | -------------------------------------------------------------------------------- /date/README.md: -------------------------------------------------------------------------------- 1 | # koKR - date 2 | 3 |

4 | Build 5 | Coverage 6 | License 7 | Language Typescript 8 |
9 | deno.land/x/kokr/date 10 | Version 11 | Downloads 12 |

13 | 14 | 한국의 공휴일, 기념일, 24절기 및 잡절을 제공합니다. 공휴일 정보는 외부 API를 15 | 통해 가져오며, 라이브러리 업데이트 없이도 항상 최신 정보를 가져올 수 있습니다. 16 | 또한, Web Store API를 활용(Node.js의 경우 17 | [shim-webstore](https://github.com/denostack/node-shim-webstore) 적용)하여 18 | 하루단위로 캐싱처리하기 때문에 굉장히 빠르게 사용가능합니다. 19 | 20 | @koKR date는 [distbe/holidays](https://github.com/distbe/holidays)의 21 | 공휴일정보를 사용하고 있고, 22 | [distbe/holidays](https://github.com/distbe/holidays)는 공공데이터포탈의 공휴일 23 | 정보를 활용하고 있습니다. 24 | 25 | ## 설치 26 | 27 | ```bash 28 | npm install @kokr/date 29 | ``` 30 | 31 | 만약, Deno를 사용한다면 아래와 같이 import 할 수 있습니다. 32 | 33 | ```typescript 34 | import {} from "https://deno.land/x/kokr/date/mod.ts"; 35 | ``` 36 | 37 | ## 사용법 38 | 39 | **공휴일 가져오기** 40 | 41 | 년도를 입력하면 해당 년도의 모든 공휴일, 기념일, 24절기 및 잡절 정보를 42 | 가져옵니다. 자세한 내용은 하단의 데이터 구조를 참고하세요. 43 | 44 | ```typescript 45 | import { getHolidays } from "@kokr/date"; 46 | 47 | // 공휴일 정보 가져오기 48 | const dates = await getHolidays(2022); 49 | /* 50 | [ 51 | { 52 | "date": "2022-01-01", 53 | "name": "새해", 54 | "holiday": true, 55 | "remarks": null, 56 | "kind": 1, 57 | "time": null, 58 | "sunLng": null 59 | }, 60 | { 61 | "date": "2022-01-05", 62 | "name": "소한", 63 | "holiday": false, 64 | "remarks": null, 65 | "kind": 3, 66 | "time": "18:14", 67 | "sunLng": 285 68 | }, 69 | ... 70 | ] 71 | */ 72 | ``` 73 | 74 | **영업일 계산** 75 | 76 | 공휴일(토요일, 일요일, 기념일)을 제외한 영업일을 계산합니다. 77 | 78 | ```typescript 79 | import { getNextBusinessDay } from "@kokr/date"; 80 | 81 | const date = await getNextBusinessDay("2022-01-01", 10); 82 | ``` 83 | 84 | **공휴일 여부** 85 | 86 | 공휴일(토요일, 일요일, 기념일)인 경우, true를 반환합니다. 87 | 88 | ```typescript 89 | import { isHoliday } from "@kokr/date"; 90 | 91 | const holiday = await isHoliday("2022-01-01"); // true 92 | ``` 93 | 94 | ## 데이터 구조 95 | 96 | **공휴일 상세정보 타입(DateInfo)** 97 | 98 | | 속성 | 타입 | 설명 | 99 | | ------- | ------------------ | --------------------------------------------------------------------- | 100 | | date | `string` | 공휴일 날짜 (YYYY-MM-DD) | 101 | | name | `string` | 공휴일 이름 | 102 | | holiday | `boolean` | 공휴일 여부 | 103 | | remarks | `string` \| `null` | API에서 주는 정보, 해당 기념일에 대한 기타 설명이 포함된 경우가 있음. | 104 | | kind | `DateKind` | 공휴일인지, 기념일인지, 24절기인지.. enum 참고 | 105 | | time | `string` \| `null` | HH:mm 정확한 표준 시간, DateKind.SolarTerms(24절기) 경우 반환 | 106 | | sunLng | `number` \| `null` | 태양황경(도), DateKind.SolarTerms(24절기) 경우 반환 | 107 | 108 | **공휴일 종류 (DateKind)** 109 | 110 | | 값 | 설명 | 예시 | 111 | | ------------- | ------ | ---------------------------------------- | 112 | | `Holiday` | 공휴일 | 설날, 대통령선거일, 추석 (대체공휴일) 등 | 113 | | `Anniversary` | 기념일 | 스승의 날, 국군의 날 등 | 114 | | `SolarTerms` | 24절기 | 입춘, 경칩 등 | 115 | | `Sundry` | 잡절 | 정월대보름, 초복, 중복 등 | 116 | 117 | ## API 118 | 119 | [API 문서 보기](https://deno.land/x/kokr/date/mod.ts) 120 | -------------------------------------------------------------------------------- /date/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { assertSpyCalls, spy } from "testing/mock.ts"; 3 | import { cache } from "./cache.ts"; 4 | 5 | const dataInfos = [ 6 | { 7 | date: "2022-01-01", 8 | name: "Happy New Year!", 9 | holiday: true, 10 | remarks: null, 11 | kind: 1, 12 | time: null, 13 | sunLng: null, 14 | }, 15 | ]; 16 | 17 | Deno.test("@kokr/date, cache", async () => { 18 | const originHolidaySpy = spy(() => Promise.resolve(dataInfos)); 19 | const cachedHolidays = cache(originHolidaySpy); 20 | 21 | cachedHolidays.clear(); 22 | 23 | assertEquals(await cachedHolidays(2020), dataInfos); 24 | assertEquals(await cachedHolidays(2020), dataInfos); 25 | assertEquals(await cachedHolidays(2020), dataInfos); 26 | assertEquals(await cachedHolidays(2020), dataInfos); 27 | 28 | assertSpyCalls(originHolidaySpy, 1); 29 | }); 30 | 31 | Deno.test("@kokr/date, cache expired", async () => { 32 | const originHolidaySpy = spy(() => Promise.resolve(dataInfos)); 33 | const cachedHolidays = cache(originHolidaySpy, { ttl: 3000 }); 34 | 35 | cachedHolidays.clear(); 36 | 37 | assertEquals(await cachedHolidays(2020), dataInfos); 38 | assertEquals(await cachedHolidays(2020), dataInfos); 39 | assertEquals(await cachedHolidays(2020), dataInfos); 40 | 41 | assertSpyCalls(originHolidaySpy, 1); 42 | 43 | await new Promise((resolve) => setTimeout(resolve, 3000)); 44 | assertEquals(await cachedHolidays(2020), dataInfos); 45 | assertEquals(await cachedHolidays(2020), dataInfos); 46 | assertEquals(await cachedHolidays(2020), dataInfos); 47 | 48 | assertSpyCalls(originHolidaySpy, 2); 49 | }); 50 | -------------------------------------------------------------------------------- /date/cache.ts: -------------------------------------------------------------------------------- 1 | import { DateInfo, RetrieveHolidays } from "./holiday.ts"; 2 | 3 | interface HolidayCache { 4 | [year: number]: { 5 | cached: number; // timestamp 6 | holidays: DateInfo[]; 7 | }; 8 | } 9 | 10 | export interface CacheOptions { 11 | ttl?: number; // micro seconds 12 | cacheKeyName?: string; 13 | } 14 | 15 | export function cache( 16 | fn: RetrieveHolidays, 17 | options: CacheOptions = {}, 18 | ): RetrieveHolidays & { clear: () => void } { 19 | const ttl = Math.max(options.ttl ?? 86400, 0); 20 | const cacheKeyName = options.cacheKeyName ?? 21 | "@kokr/date/holidays"; 22 | 23 | return Object.assign(async (year: number) => { 24 | const now = Date.now(); 25 | let cacheData: HolidayCache = {}; 26 | try { 27 | cacheData = JSON.parse( 28 | localStorage.getItem(cacheKeyName) ?? "", 29 | ); 30 | } catch { 31 | // ignore 32 | } 33 | const cache = cacheData[year]; 34 | if (cache && cache.cached + ttl > now) { 35 | return cache.holidays; 36 | } 37 | const holidays = await fn(year); 38 | cacheData[year] = { 39 | cached: now, 40 | holidays, 41 | }; 42 | localStorage.setItem( 43 | cacheKeyName, 44 | JSON.stringify(cacheData), 45 | ); 46 | return holidays; 47 | }, { 48 | clear() { 49 | localStorage.removeItem(cacheKeyName); 50 | }, 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /date/fn.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { assertSpyCalls, stub } from "testing/mock.ts"; 3 | import { getNextBusinessDay, isHoliday } from "./fn.ts"; 4 | 5 | const dataInfos = [ 6 | { 7 | date: "2022-10-27", 8 | name: "Something Unknown Holiday", 9 | holiday: true, 10 | remarks: null, 11 | kind: 1, 12 | time: null, 13 | sunLng: null, 14 | }, 15 | { 16 | date: "2022-11-01", 17 | name: "Something Unknown Holiday", 18 | holiday: true, 19 | remarks: null, 20 | kind: 1, 21 | time: null, 22 | sunLng: null, 23 | }, 24 | { 25 | date: "2022-11-02", 26 | name: "Nothing", 27 | holiday: false, 28 | remarks: null, 29 | kind: 1, 30 | time: null, 31 | sunLng: null, 32 | }, 33 | ]; 34 | 35 | const DATE_FRI = "2022-10-28"; 36 | const DATE_SAT = "2022-10-29"; 37 | const DATE_SUN = "2022-10-30"; 38 | const DATE_MON = "2022-10-31"; 39 | 40 | Deno.test("@kokr/date, isHoliday", async () => { 41 | localStorage.clear(); 42 | const fetchStub = stub(globalThis, "fetch", () => 43 | Promise.resolve({ 44 | status: 200, 45 | json: () => Promise.resolve(dataInfos), 46 | } as Response)); 47 | 48 | assertEquals(await isHoliday(DATE_SAT), true); 49 | assertEquals(await isHoliday(DATE_SUN), true); 50 | 51 | assertSpyCalls(fetchStub, 0); 52 | 53 | assertEquals(await isHoliday(DATE_FRI), false); 54 | assertEquals(await isHoliday(DATE_MON), false); 55 | 56 | assertSpyCalls(fetchStub, 1); 57 | 58 | assertEquals(await isHoliday("2022-11-01"), true); // in the dateInfos 59 | assertEquals(await isHoliday("2022-11-02"), false); // in the dateInfos nothing 60 | 61 | fetchStub.restore(); 62 | }); 63 | 64 | Deno.test("@kokr/date, getNextBusinessDay", async () => { 65 | const fetchStub = stub(globalThis, "fetch", () => 66 | Promise.resolve({ 67 | status: 200, 68 | json: () => Promise.resolve(dataInfos), 69 | } as Response)); 70 | 71 | assertEquals(await getNextBusinessDay(DATE_FRI, 0), DATE_FRI); 72 | assertEquals(await getNextBusinessDay(DATE_SAT, 0), DATE_SAT); 73 | assertEquals(await getNextBusinessDay(DATE_SUN, 0), DATE_SUN); 74 | assertEquals(await getNextBusinessDay(DATE_MON, 0), DATE_MON); 75 | 76 | // after 77 | assertEquals(await getNextBusinessDay("2022-10-28", 1), "2022-10-31"); 78 | assertEquals(await getNextBusinessDay("2022-10-29", 1), "2022-10-31"); 79 | assertEquals(await getNextBusinessDay("2022-10-30", 1), "2022-10-31"); 80 | 81 | assertEquals(await getNextBusinessDay("2022-10-28", 2), "2022-11-02"); // skip 2022-11-01 82 | assertEquals(await getNextBusinessDay("2022-10-28", 3), "2022-11-03"); 83 | 84 | // before 85 | assertEquals(await getNextBusinessDay("2022-10-31", -1), "2022-10-28"); 86 | assertEquals(await getNextBusinessDay("2022-10-30", -1), "2022-10-28"); 87 | assertEquals(await getNextBusinessDay("2022-10-29", -1), "2022-10-28"); 88 | 89 | assertEquals(await getNextBusinessDay("2022-10-31", -2), "2022-10-26"); // skip 2022-10-27 90 | assertEquals(await getNextBusinessDay("2022-10-31", -3), "2022-10-25"); 91 | 92 | fetchStub.restore(); 93 | }); 94 | -------------------------------------------------------------------------------- /date/fn.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "./cache.ts"; 2 | import { DateInfo as _DateInfo, getHolidaysFromHttp } from "./holiday.ts"; 3 | 4 | /** 5 | * 서버에서 공휴일 관련 정보를 가져옵니다. / Get Holidays from Http Server 6 | * @type {(year: number) => Promise<_DateInfo[]>} 7 | * @param {number} year 8 | * @return {Promise<_DateInfo[]>} 9 | */ 10 | export const getHolidays = cache(getHolidaysFromHttp); 11 | 12 | function format(date: Date) { 13 | const year = date.getFullYear(); 14 | const month = date.getMonth() + 1; 15 | const day = date.getDate(); 16 | 17 | return [ 18 | year, 19 | month >= 10 ? month : `0${month}`, 20 | day >= 10 ? day : `0${day}`, 21 | ].join("-"); 22 | } 23 | 24 | async function _isHoliday(d: Date): Promise { 25 | const dayOfWeek = d.getDay(); 26 | if (dayOfWeek === 0 || dayOfWeek === 6) { 27 | return true; 28 | } 29 | 30 | const formattedDate = format(d); 31 | const holidays = await getHolidays(d.getFullYear()); 32 | const found = holidays.find((holiday) => holiday.date === formattedDate); 33 | 34 | return !!found?.holiday; 35 | } 36 | 37 | /** 38 | * 공휴일인지 아닌지 판단 / return is holiday 39 | * @param {string} date YYYY-MM-DD 40 | * @return {Promise} 41 | */ 42 | export function isHoliday(date: string): Promise { 43 | return _isHoliday(new Date(date)); 44 | } 45 | 46 | /** 47 | * 영업일 기준 n일 후 반환 / After n business days 48 | * @param {string} date YYYY-MM-DD 형식 49 | * @param {number} daysAfter n일, 양수 음수 모두 사용 가능 50 | * @returns {string} YYYY-MM-DD 51 | */ 52 | export async function getNextBusinessDay( 53 | date: string, 54 | daysAfter: number, 55 | ): Promise { 56 | const d = new Date(date); 57 | while (daysAfter !== 0) { 58 | if (daysAfter > 0) { 59 | do { 60 | d.setDate(d.getDate() + 1); 61 | } while (await _isHoliday(d)); 62 | daysAfter--; 63 | } else { 64 | do { 65 | d.setDate(d.getDate() - 1); 66 | } while (await _isHoliday(d)); 67 | daysAfter++; 68 | } 69 | } 70 | return format(d); 71 | } 72 | -------------------------------------------------------------------------------- /date/holiday.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { assertSpyCallArgs, stub } from "testing/mock.ts"; 3 | import { getHolidaysFromHttp } from "./holiday.ts"; 4 | 5 | const dataInfos = [ 6 | { 7 | date: "2022-01-01", 8 | name: "Happy New Year!", 9 | holiday: true, 10 | remarks: null, 11 | kind: 1, 12 | time: null, 13 | sunLng: null, 14 | }, 15 | ]; 16 | 17 | Deno.test("@kokr/date, getHolidaysFromHttp", async () => { 18 | const fetchStub = stub(globalThis, "fetch", () => 19 | Promise.resolve({ 20 | status: 200, 21 | json: () => Promise.resolve(dataInfos), 22 | } as Response)); 23 | 24 | const actual = await getHolidaysFromHttp(2020, { 25 | uris: [(year) => `https://test.dist.be/${year}.json`], 26 | }); 27 | 28 | assertEquals(actual, dataInfos); 29 | assertSpyCallArgs(fetchStub, 0, ["https://test.dist.be/2020.json"]); 30 | 31 | fetchStub.restore(); 32 | }); 33 | -------------------------------------------------------------------------------- /date/holiday.ts: -------------------------------------------------------------------------------- 1 | export type RetrieveHolidays = (year: number) => Promise; 2 | 3 | /** 공휴일의 종류, Holiday 공휴일 */ 4 | export enum DateKind { 5 | /** 공휴일 - (예) 설날, 대통령선거일, 추석 (대체공휴일) .. */ 6 | Holiday = 1, 7 | /** 기념일 - (예) 스승의 날, 국군의 날 .. */ 8 | Anniversary = 2, 9 | /** 24절기 - (예) 입춘, 경칩 .. */ 10 | SolarTerms = 3, 11 | /** 잡절 - (예) 정월대보름, 초복, 중복 .. */ 12 | Sundry = 4, 13 | } 14 | 15 | /** 공휴일 상세 정보 */ 16 | export interface DateInfo { 17 | /** YYYY-MM-DD */ 18 | date: string; 19 | /** 이름 */ 20 | name: string; 21 | /** 공휴일 여부 */ 22 | holiday: boolean; 23 | /** API에서 주는 정보, 해당 기념일에 대한 기타 설명이 포함된 경우가 있음. */ 24 | remarks: string | null; 25 | /** 공휴일인지, 기념일인지, 24절기인지.. enum 참고*/ 26 | kind: DateKind; 27 | /** HH:mm 정확한 표준 시간, DateKind.SolarTerms(24절기) 경우 반환 */ 28 | time: string | null; 29 | /** 태양황경(도), DateKind.SolarTerms(24절기) 경우 반환 */ 30 | sunLng: number | null; 31 | } 32 | 33 | const defaultHolidayUri: ((year: number) => string)[] = [ 34 | (year) => `https://holidays.dist.be/${year}.json`, 35 | (year) => `https://cdn.jsdelivr.net/gh/distbe/holidays@gh-pages/${year}.json`, 36 | ]; 37 | 38 | export interface GetHolidaysFromHttpOptions { 39 | uris?: ((year: number) => string)[]; 40 | } 41 | 42 | export async function getHolidaysFromHttp( 43 | year: number, 44 | options?: GetHolidaysFromHttpOptions, 45 | ): Promise { 46 | const uris = options?.uris ?? defaultHolidayUri; 47 | if (uris.length === 0) { 48 | return []; 49 | } 50 | const uri = uris[Math.random() * uris.length | 0](year); 51 | const response = await fetch(uri); 52 | if (response.status === 200) { 53 | return response.json() as Promise; 54 | } 55 | 56 | return []; 57 | } 58 | -------------------------------------------------------------------------------- /date/mod.ts: -------------------------------------------------------------------------------- 1 | export { type DateInfo, DateKind, type RetrieveHolidays } from "./holiday.ts"; 2 | export { getHolidays, getNextBusinessDay, isHoliday } from "./fn.ts"; 3 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.4.1", 3 | "imports": { 4 | "dnt/": "https://deno.land/x/dnt@0.34.0/", 5 | "testing/": "https://deno.land/std@0.183.0/testing/" 6 | }, 7 | "tasks": { 8 | "test": "deno task test:unit && deno task test:lint && deno task test:format && deno task test:types", 9 | "test:unit": "deno test -A --unstable", 10 | "test:lint": "deno lint", 11 | "test:format": "deno fmt --check", 12 | "test:types": "find . -name '*.ts' | xargs deno check --unstable", 13 | "build:npm": "deno run -A scripts/build_npm.ts" 14 | }, 15 | "lint": { 16 | "files": { 17 | "exclude": [".npm/"] 18 | } 19 | }, 20 | "fmt": { 21 | "files": { 22 | "exclude": [".npm/"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /id/README.md: -------------------------------------------------------------------------------- 1 | # koKR - id 2 | 3 |

4 | Build 5 | Coverage 6 | License 7 | Language Typescript 8 |
9 | deno.land/x/kokr/id 10 | Version 11 | Downloads 12 |

13 | 14 | 주민등록번호와 관련한 유틸리티를 제공합니다. 15 | 16 | ## 설치 17 | 18 | ```bash 19 | npm install @kokr/id 20 | ``` 21 | 22 | 만약, Deno를 사용한다면 아래와 같이 import 할 수 있습니다. 23 | 24 | ```typescript 25 | import {} from "https://deno.land/x/kokr/id/mod.ts"; 26 | ``` 27 | 28 | ## 사용법 29 | 30 | > 테스트에 사용한 주민등록번호 및 외국인등록번호는 아무 숫자나 입력 후, 31 | > 검증코드를 계산하여 만든 번호로서 실제로 존재하지 않는 번호입니다. 32 | 33 | **주민등록번호 및 외국인등록번호 분석** 34 | 35 | 주민등록번호 및 외국인등록번호를 분석해, 등록번호의 유효성, 성별, 외국인 여부, 36 | 생일, 나이 등을 알 수 있습니다. 입력된 등록번호는 숫자를 제외한 모든 문자를 37 | 제거하고 분석합니다. 따라서 중간에 하이픈('-')여부에 관계없이 사용가능합니다. 38 | 자세한 내용은 데이터 구조를 참고하세요. 39 | 40 | ```typescript 41 | import { analyze } from "@kokr/id"; 42 | 43 | analyze("000101-1000002"); 44 | /* 45 | { 46 | valid: true, // 올바른 주민번호 여부 47 | parity: 2, // 주민번호 맨 뒤 검증코드 48 | gender: 'M', // 성별 49 | foreigner: false, // 외국인여부 50 | birth: '1900-01-01', // 생일 (2003-02-29와 같이 존재하지 않는 경우 null 반환) 51 | age: 121, // 만나이 52 | krAge: 122, // 한국나이 53 | } 54 | */ 55 | ``` 56 | 57 | 만나이와 한국식 나이는 오늘날짜 기준으로 계산합니다. 만약, 다른 날짜를 기준으로 58 | 계산하고 싶다면 `now` 옵션을 사용할 수 있습니다. 59 | 60 | ```typescript 61 | analyze("000101-1000002", { now: "2021-06-01" }); 62 | ``` 63 | 64 | **주민등록번호 및 외국인등록번호 검증** 65 | 66 | 주민등록번호 및 외국인등록번호의 유효성을 검증합니다. 검증결과는 `boolean`으로 67 | 반환됩니다. 외국인등록번호를 제외한 주민등록번호만 검증하고 싶다면 68 | `disableForeigner` 옵션을 사용할 수 있습니다. 69 | 70 | ```typescript 71 | import { validate } from "@kokr/id"; 72 | 73 | // 주민등록번호 및 외국인등록번호 검증 74 | validate("010101-0010101"); 75 | validate("010101-5010105"); 76 | validate("010101-6010108"); 77 | validate("010101-7010101"); 78 | validate("010101-8010103"); 79 | 80 | // 주민등록번호만 검증 (외국인등록번호 제외) 81 | validate("010101-5010105", { disableForeigner: true }); // false 82 | validate("010101-6010108", { disableForeigner: true }); // false 83 | validate("010101-7010101", { disableForeigner: true }); // false 84 | validate("010101-8010103", { disableForeigner: true }); // false 85 | ``` 86 | 87 | **주민등록번호 및 외국인등록번호 포매팅** 88 | 89 | 주민등록번호 및 외국인등록번호를 포매팅합니다. 포매팅된 결과는 `string`으로 90 | 반환됩니다. 숫자를 제외한 모든 문자는 제거되며, 하이픈('-')이 포함됩니다. 91 | 92 | ```typescript 93 | import { format } from "@kokr/id"; 94 | 95 | format("0101010010101"); // 010101-0010101 96 | ``` 97 | 98 | 기본적으로 잘못된 주민등록번호는 포매팅 처리하지 않습니다. 잘못된 경우 null을 99 | 반환합니다. 100 | 101 | ```typescript 102 | format("01234"); // null 103 | ``` 104 | 105 | 자릿수가 다르거나, 잘못된 주민등록번호를 포매팅하고 싶다면 `ignoreInvalid` 106 | 옵션을 사용할 수 있습니다. 107 | 108 | ```typescript 109 | format("010101", { ignoreInvalid: true }); // 010101 110 | format("0101010", { ignoreInvalid: true }); // 010101-0 111 | format("01010100101019", { ignoreInvalid: true }); // 010101-00101019 112 | ``` 113 | 114 | ## 데이터 구조 115 | 116 | **등록번호 분석결과(AnalyzeResult)** 117 | 118 | | 속성 | 타입 | 설명 | 119 | | ----------- | ------------------------ | ---------------------------------------------------- | 120 | | `valid` | `boolean` | 주어진 주민등록번호가 올바른지 | 121 | | `parity` | `number` \| `null` | 주민등록번호의 패리티 값, 잘못된 주민등록번호의 반환 | 122 | | `gender` | `"M"` \| `"F"` \| `null` | 성별, M은 남성, F는 여성 | 123 | | `foreigner` | `boolean` \| `null` | 외국인 여부 | 124 | | `birth` | `string` \| `null` | 생년월일 | 125 | | `age` | `number` \| `null` | 만나이 | 126 | | `krAge` | `number` \| `null` | 한국나이 | 127 | 128 | ## API 129 | 130 | [API 문서 보기](https://deno.land/x/kokr/id/mod.ts) 131 | -------------------------------------------------------------------------------- /id/analyze.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { analyze } from "./analyze.ts"; 3 | 4 | Deno.test("@kokr/id, analyze invalid length", () => { 5 | assertEquals(analyze(""), { 6 | valid: false, 7 | parity: null, 8 | gender: null, 9 | foreigner: null, 10 | birth: null, 11 | age: null, 12 | krAge: null, 13 | }); 14 | assertEquals(analyze("0"), { 15 | valid: false, 16 | parity: null, 17 | gender: null, 18 | foreigner: null, 19 | birth: null, 20 | age: null, 21 | krAge: null, 22 | }); 23 | assertEquals(analyze("00"), { 24 | valid: false, 25 | parity: null, 26 | gender: null, 27 | foreigner: null, 28 | birth: null, 29 | age: null, 30 | krAge: null, 31 | }); 32 | assertEquals(analyze("000"), { 33 | valid: false, 34 | parity: null, 35 | gender: null, 36 | foreigner: null, 37 | birth: null, 38 | age: null, 39 | krAge: null, 40 | }); 41 | assertEquals(analyze("0001"), { 42 | valid: false, 43 | parity: null, 44 | gender: null, 45 | foreigner: null, 46 | birth: null, 47 | age: null, 48 | krAge: null, 49 | }); 50 | assertEquals(analyze("00010"), { 51 | valid: false, 52 | parity: null, 53 | gender: null, 54 | foreigner: null, 55 | birth: null, 56 | age: null, 57 | krAge: null, 58 | }); 59 | assertEquals(analyze("000101"), { 60 | valid: false, 61 | parity: null, 62 | gender: null, 63 | foreigner: null, 64 | birth: null, 65 | age: null, 66 | krAge: null, 67 | }); 68 | assertEquals(analyze("000101-"), { 69 | valid: false, 70 | parity: null, 71 | gender: null, 72 | foreigner: null, 73 | birth: null, 74 | age: null, 75 | krAge: null, 76 | }); 77 | 78 | assertEquals(analyze("000101-1", { now: "2021-06-01" }), { 79 | valid: false, 80 | parity: null, 81 | gender: "M", 82 | foreigner: false, 83 | birth: "1900-01-01", 84 | age: 121, 85 | krAge: 122, 86 | }); 87 | assertEquals(analyze("000101-10", { now: "2021-06-01" }), { 88 | valid: false, 89 | parity: null, 90 | gender: "M", 91 | foreigner: false, 92 | birth: "1900-01-01", 93 | age: 121, 94 | krAge: 122, 95 | }); 96 | assertEquals(analyze("000101-100", { now: "2021-06-01" }), { 97 | valid: false, 98 | parity: null, 99 | gender: "M", 100 | foreigner: false, 101 | birth: "1900-01-01", 102 | age: 121, 103 | krAge: 122, 104 | }); 105 | assertEquals(analyze("000101-1000", { now: "2021-06-01" }), { 106 | valid: false, 107 | parity: null, 108 | gender: "M", 109 | foreigner: false, 110 | birth: "1900-01-01", 111 | age: 121, 112 | krAge: 122, 113 | }); 114 | assertEquals(analyze("000101-10000", { now: "2021-06-01" }), { 115 | valid: false, 116 | parity: null, 117 | gender: "M", 118 | foreigner: false, 119 | birth: "1900-01-01", 120 | age: 121, 121 | krAge: 122, 122 | }); 123 | assertEquals(analyze("000101-100000", { now: "2021-06-01" }), { 124 | valid: false, 125 | parity: null, 126 | gender: "M", 127 | foreigner: false, 128 | birth: "1900-01-01", 129 | age: 121, 130 | krAge: 122, 131 | }); 132 | assertEquals(analyze("000101-1000002", { now: "2021-06-01" }), { 133 | valid: true, 134 | parity: 2, 135 | gender: "M", 136 | foreigner: false, 137 | birth: "1900-01-01", 138 | age: 121, 139 | krAge: 122, 140 | }); // true! 141 | assertEquals(analyze("000101-10000020", { now: "2021-06-01" }), { 142 | valid: false, 143 | parity: null, 144 | gender: "M", 145 | foreigner: false, 146 | birth: "1900-01-01", 147 | age: 121, 148 | krAge: 122, 149 | }); 150 | }); 151 | 152 | Deno.test("@kokr/id, analyze date - invalid", () => { 153 | assertEquals(analyze("000000-1"), { 154 | valid: false, 155 | parity: null, 156 | gender: "M", 157 | foreigner: false, 158 | birth: null, 159 | age: null, 160 | krAge: null, 161 | }); 162 | assertEquals(analyze("010001-1"), { 163 | valid: false, 164 | parity: null, 165 | gender: "M", 166 | foreigner: false, 167 | birth: null, 168 | age: null, 169 | krAge: null, 170 | }); 171 | assertEquals(analyze("011301-1"), { 172 | valid: false, 173 | parity: null, 174 | gender: "M", 175 | foreigner: false, 176 | birth: null, 177 | age: null, 178 | krAge: null, 179 | }); 180 | assertEquals(analyze("010100-1"), { 181 | valid: false, 182 | parity: null, 183 | gender: "M", 184 | foreigner: false, 185 | birth: null, 186 | age: null, 187 | krAge: null, 188 | }); 189 | assertEquals(analyze("010132-1"), { 190 | valid: false, 191 | parity: null, 192 | gender: "M", 193 | foreigner: false, 194 | birth: null, 195 | age: null, 196 | krAge: null, 197 | }); 198 | 199 | const leapYear1800 = [ 200 | 1804, 201 | 1808, 202 | 1812, 203 | 1816, 204 | 1820, 205 | 1824, 206 | 1828, 207 | 1832, 208 | 1836, 209 | 1840, 210 | 1844, 211 | 1848, 212 | 1852, 213 | 1856, 214 | 1860, 215 | 1864, 216 | 1868, 217 | 1872, 218 | 1876, 219 | 1880, 220 | 1884, 221 | 1888, 222 | 1892, 223 | 1896, 224 | ]; 225 | const leapYear1900 = [ 226 | 1904, 227 | 1908, 228 | 1912, 229 | 1916, 230 | 1920, 231 | 1924, 232 | 1928, 233 | 1932, 234 | 1936, 235 | 1940, 236 | 1944, 237 | 1948, 238 | 1952, 239 | 1956, 240 | 1960, 241 | 1964, 242 | 1968, 243 | 1972, 244 | 1976, 245 | 1980, 246 | 1984, 247 | 1988, 248 | 1992, 249 | 1996, 250 | ]; 251 | const leapYear2000 = [ 252 | 2000, 253 | 2004, 254 | 2008, 255 | 2012, 256 | 2016, 257 | 2020, 258 | 2024, 259 | 2028, 260 | 2032, 261 | 2036, 262 | 2040, 263 | 2044, 264 | 2048, 265 | 2052, 266 | 2056, 267 | 2060, 268 | 2064, 269 | 2068, 270 | 2072, 271 | 2076, 272 | 2080, 273 | 2084, 274 | 2088, 275 | 2092, 276 | 2096, 277 | ]; 278 | for (let i = 0; i < 100; i++) { 279 | const year = `${i}`.padStart(2, "0"); 280 | if (leapYear1800.includes(1800 + i)) { 281 | assertEquals(analyze(`${year}0228-9`, { now: "2021-06-01" }), { 282 | valid: false, 283 | parity: null, 284 | gender: "M", 285 | foreigner: false, 286 | birth: `18${year}-02-28`, 287 | age: 221 - i, 288 | krAge: 222 - i, 289 | }); 290 | assertEquals(analyze(`${year}0229-0`, { now: "2021-06-01" }), { 291 | valid: false, 292 | parity: null, 293 | gender: "F", 294 | foreigner: false, 295 | birth: `18${year}-02-29`, 296 | age: 221 - i, 297 | krAge: 222 - i, 298 | }); 299 | assertEquals(analyze(`${year}0301-9`, { now: "2021-06-01" }), { 300 | valid: false, 301 | parity: null, 302 | gender: "M", 303 | foreigner: false, 304 | birth: `18${year}-03-01`, 305 | age: 221 - i, 306 | krAge: 222 - i, 307 | }); 308 | } else { 309 | assertEquals(analyze(`${year}0228-0`, { now: "2021-06-01" }), { 310 | valid: false, 311 | parity: null, 312 | gender: "F", 313 | foreigner: false, 314 | birth: `18${year}-02-28`, 315 | age: 221 - i, 316 | krAge: 222 - i, 317 | }); 318 | assertEquals(analyze(`${year}0229-9`, { now: "2021-06-01" }), { 319 | valid: false, 320 | parity: null, 321 | gender: "M", 322 | foreigner: false, 323 | birth: null, 324 | age: null, 325 | krAge: null, 326 | }); 327 | assertEquals(analyze(`${year}0301-0`, { now: "2021-06-01" }), { 328 | valid: false, 329 | parity: null, 330 | gender: "F", 331 | foreigner: false, 332 | birth: `18${year}-03-01`, 333 | age: 221 - i, 334 | krAge: 222 - i, 335 | }); 336 | } 337 | if (leapYear1900.includes(1900 + i)) { 338 | assertEquals(analyze(`${year}0228-1`, { now: "2021-06-01" }), { 339 | valid: false, 340 | parity: null, 341 | gender: "M", 342 | foreigner: false, 343 | birth: `19${year}-02-28`, 344 | age: 121 - i, 345 | krAge: 122 - i, 346 | }); 347 | assertEquals(analyze(`${year}0229-2`, { now: "2021-06-01" }), { 348 | valid: false, 349 | parity: null, 350 | gender: "F", 351 | foreigner: false, 352 | birth: `19${year}-02-29`, 353 | age: 121 - i, 354 | krAge: 122 - i, 355 | }); 356 | assertEquals(analyze(`${year}0301-1`, { now: "2021-06-01" }), { 357 | valid: false, 358 | parity: null, 359 | gender: "M", 360 | foreigner: false, 361 | birth: `19${year}-03-01`, 362 | age: 121 - i, 363 | krAge: 122 - i, 364 | }); 365 | } else { 366 | assertEquals(analyze(`${year}0228-2`, { now: "2021-06-01" }), { 367 | valid: false, 368 | parity: null, 369 | gender: "F", 370 | foreigner: false, 371 | birth: `19${year}-02-28`, 372 | age: 121 - i, 373 | krAge: 122 - i, 374 | }); 375 | assertEquals(analyze(`${year}0229-1`, { now: "2021-06-01" }), { 376 | valid: false, 377 | parity: null, 378 | gender: "M", 379 | foreigner: false, 380 | birth: null, 381 | age: null, 382 | krAge: null, 383 | }); 384 | assertEquals(analyze(`${year}0301-2`, { now: "2021-06-01" }), { 385 | valid: false, 386 | parity: null, 387 | gender: "F", 388 | foreigner: false, 389 | birth: `19${year}-03-01`, 390 | age: 121 - i, 391 | krAge: 122 - i, 392 | }); 393 | } 394 | if (leapYear2000.includes(2000 + i)) { 395 | assertEquals(analyze(`${year}0228-3`, { now: "2021-06-01" }), { 396 | valid: false, 397 | parity: null, 398 | gender: "M", 399 | foreigner: false, 400 | birth: `20${year}-02-28`, 401 | age: 21 - i, 402 | krAge: 22 - i, 403 | }); 404 | assertEquals(analyze(`${year}0229-4`, { now: "2021-06-01" }), { 405 | valid: false, 406 | parity: null, 407 | gender: "F", 408 | foreigner: false, 409 | birth: `20${year}-02-29`, 410 | age: 21 - i, 411 | krAge: 22 - i, 412 | }); 413 | assertEquals(analyze(`${year}0301-3`, { now: "2021-06-01" }), { 414 | valid: false, 415 | parity: null, 416 | gender: "M", 417 | foreigner: false, 418 | birth: `20${year}-03-01`, 419 | age: 21 - i, 420 | krAge: 22 - i, 421 | }); 422 | } else { 423 | assertEquals(analyze(`${year}0228-4`, { now: "2021-06-01" }), { 424 | valid: false, 425 | parity: null, 426 | gender: "F", 427 | foreigner: false, 428 | birth: `20${year}-02-28`, 429 | age: 21 - i, 430 | krAge: 22 - i, 431 | }); 432 | assertEquals(analyze(`${year}0229-3`, { now: "2021-06-01" }), { 433 | valid: false, 434 | parity: null, 435 | gender: "M", 436 | foreigner: false, 437 | birth: null, 438 | age: null, 439 | krAge: null, 440 | }); 441 | assertEquals(analyze(`${year}0301-4`, { now: "2021-06-01" }), { 442 | valid: false, 443 | parity: null, 444 | gender: "F", 445 | foreigner: false, 446 | birth: `20${year}-03-01`, 447 | age: 21 - i, 448 | krAge: 22 - i, 449 | }); 450 | } 451 | } 452 | }); 453 | 454 | Deno.test("@kokr/id, analyze gender, foreign, birth - invalid", () => { 455 | assertEquals(analyze("000101-1", { now: "2021-06-01" }), { 456 | valid: false, 457 | parity: null, 458 | gender: "M", 459 | foreigner: false, 460 | birth: "1900-01-01", 461 | age: 121, 462 | krAge: 122, 463 | }); 464 | assertEquals(analyze("000101-2", { now: "2021-06-01" }), { 465 | valid: false, 466 | parity: null, 467 | gender: "F", 468 | foreigner: false, 469 | birth: "1900-01-01", 470 | age: 121, 471 | krAge: 122, 472 | }); 473 | assertEquals(analyze("000101-3", { now: "2021-06-01" }), { 474 | valid: false, 475 | parity: null, 476 | gender: "M", 477 | foreigner: false, 478 | birth: "2000-01-01", 479 | age: 21, 480 | krAge: 22, 481 | }); 482 | assertEquals(analyze("000101-4", { now: "2021-06-01" }), { 483 | valid: false, 484 | parity: null, 485 | gender: "F", 486 | foreigner: false, 487 | birth: "2000-01-01", 488 | age: 21, 489 | krAge: 22, 490 | }); 491 | assertEquals(analyze("000101-5", { now: "2021-06-01" }), { 492 | valid: false, 493 | parity: null, 494 | gender: "M", 495 | foreigner: true, 496 | birth: "1900-01-01", 497 | age: 121, 498 | krAge: 122, 499 | }); 500 | assertEquals(analyze("000101-6", { now: "2021-06-01" }), { 501 | valid: false, 502 | parity: null, 503 | gender: "F", 504 | foreigner: true, 505 | birth: "1900-01-01", 506 | age: 121, 507 | krAge: 122, 508 | }); 509 | assertEquals(analyze("000101-7", { now: "2021-06-01" }), { 510 | valid: false, 511 | parity: null, 512 | gender: "M", 513 | foreigner: true, 514 | birth: "2000-01-01", 515 | age: 21, 516 | krAge: 22, 517 | }); 518 | assertEquals(analyze("000101-8", { now: "2021-06-01" }), { 519 | valid: false, 520 | parity: null, 521 | gender: "F", 522 | foreigner: true, 523 | birth: "2000-01-01", 524 | age: 21, 525 | krAge: 22, 526 | }); 527 | assertEquals(analyze("000101-9", { now: "2021-06-01" }), { 528 | valid: false, 529 | parity: null, 530 | gender: "M", 531 | foreigner: false, 532 | birth: "1800-01-01", 533 | age: 221, 534 | krAge: 222, 535 | }); 536 | assertEquals(analyze("000101-0", { now: "2021-06-01" }), { 537 | valid: false, 538 | parity: null, 539 | gender: "F", 540 | foreigner: false, 541 | birth: "1800-01-01", 542 | age: 221, 543 | krAge: 222, 544 | }); 545 | }); 546 | 547 | Deno.test("@kokr/id, analyze valid", () => { 548 | assertEquals(analyze("000101-1000002", { now: "2021-06-01" }), { 549 | valid: true, 550 | parity: 2, 551 | gender: "M", 552 | foreigner: false, 553 | birth: "1900-01-01", 554 | age: 121, 555 | krAge: 122, 556 | }); 557 | assertEquals(analyze("000101-2000005", { now: "2021-06-01" }), { 558 | valid: true, 559 | parity: 5, 560 | gender: "F", 561 | foreigner: false, 562 | birth: "1900-01-01", 563 | age: 121, 564 | krAge: 122, 565 | }); 566 | assertEquals(analyze("000101-3000008", { now: "2021-06-01" }), { 567 | valid: true, 568 | parity: 8, 569 | gender: "M", 570 | foreigner: false, 571 | birth: "2000-01-01", 572 | age: 21, 573 | krAge: 22, 574 | }); 575 | assertEquals(analyze("000101-4000001", { now: "2021-06-01" }), { 576 | valid: true, 577 | parity: 1, 578 | gender: "F", 579 | foreigner: false, 580 | birth: "2000-01-01", 581 | age: 21, 582 | krAge: 22, 583 | }); 584 | assertEquals(analyze("000101-5000005", { now: "2021-06-01" }), { 585 | valid: true, 586 | parity: 5, 587 | gender: "M", 588 | foreigner: true, 589 | birth: "1900-01-01", 590 | age: 121, 591 | krAge: 122, 592 | }); 593 | assertEquals(analyze("000101-6000008", { now: "2021-06-01" }), { 594 | valid: true, 595 | parity: 8, 596 | gender: "F", 597 | foreigner: true, 598 | birth: "1900-01-01", 599 | age: 121, 600 | krAge: 122, 601 | }); 602 | assertEquals(analyze("000101-7000001", { now: "2021-06-01" }), { 603 | valid: true, 604 | parity: 1, 605 | gender: "M", 606 | foreigner: true, 607 | birth: "2000-01-01", 608 | age: 21, 609 | krAge: 22, 610 | }); 611 | assertEquals(analyze("000101-8000003", { now: "2021-06-01" }), { 612 | valid: true, 613 | parity: 3, 614 | gender: "F", 615 | foreigner: true, 616 | birth: "2000-01-01", 617 | age: 21, 618 | krAge: 22, 619 | }); 620 | assertEquals(analyze("000101-9000004", { now: "2021-06-01" }), { 621 | valid: true, 622 | parity: 4, 623 | gender: "M", 624 | foreigner: false, 625 | birth: "1800-01-01", 626 | age: 221, 627 | krAge: 222, 628 | }); 629 | assertEquals(analyze("000101-0000000", { now: "2021-06-01" }), { 630 | valid: true, 631 | parity: 0, 632 | gender: "F", 633 | foreigner: false, 634 | birth: "1800-01-01", 635 | age: 221, 636 | krAge: 222, 637 | }); 638 | }); 639 | 640 | Deno.test("@kokr/id, analyze age, krAge", () => { 641 | assertEquals(analyze("900531-1", { now: "2021-06-01" }).age, 31); 642 | assertEquals(analyze("900601-1", { now: "2021-06-01" }).age, 31); 643 | assertEquals(analyze("900602-1", { now: "2021-06-01" }).age, 30); 644 | 645 | assertEquals(analyze("900601-1", { now: "2021-05-31" }).age, 30); 646 | assertEquals(analyze("900601-1", { now: "2021-06-01" }).age, 31); 647 | assertEquals(analyze("900601-1", { now: "2021-06-02" }).age, 31); 648 | 649 | assertEquals(analyze("900531-1", { now: "2021-06-01" }).krAge, 32); 650 | assertEquals(analyze("900601-1", { now: "2021-06-01" }).krAge, 32); 651 | assertEquals(analyze("900602-1", { now: "2021-06-01" }).krAge, 32); 652 | 653 | assertEquals(analyze("900601-1", { now: "2020-12-31" }).krAge, 31); 654 | assertEquals(analyze("900601-1", { now: "2021-01-01" }).krAge, 32); 655 | }); 656 | -------------------------------------------------------------------------------- /id/analyze.ts: -------------------------------------------------------------------------------- 1 | const codeMap: Record = { 2 | 1: [false, 1900], 3 | 2: [false, 1900], 4 | 3: [false, 2000], 5 | 4: [false, 2000], 6 | 5: [true, 1900], 7 | 6: [true, 1900], 8 | 7: [true, 2000], 9 | 8: [true, 2000], 10 | 9: [false, 1800], 11 | 0: [false, 1800], 12 | }; 13 | 14 | function sanitizeDate( 15 | year: number, 16 | month: number, 17 | date: number, 18 | ): string | undefined { 19 | if ( 20 | year < 1800 || year > 2099 || month < 1 || month > 12 || date < 1 || 21 | date > 31 22 | ) { 23 | return; 24 | } 25 | const d = new Date(0); // 오늘이 30일인데, 2월 넣으면 3월 1일 되면서 에러남. 26 | d.setFullYear(year); 27 | d.setMonth(month - 1); 28 | d.setDate(date); 29 | if ( 30 | d.getFullYear() === year && d.getMonth() === month - 1 && 31 | d.getDate() === date 32 | ) { 33 | return `${year}-${`${month}`.padStart(2, "0")}-${ 34 | `${date}`.padStart(2, "0") 35 | }`; 36 | } 37 | return undefined; 38 | } 39 | 40 | function findParity(n: number[], isForeign?: boolean): number | undefined { 41 | if (n.length !== 13) { 42 | return; 43 | } 44 | 45 | let parity = (11 - 46 | Array.from({ length: 12 }).reduce( 47 | (carry, _, i) => carry + n[i] * (i % 8 + 2), 48 | 0, 49 | ) % 11) % 10; 50 | if (!isForeign) { 51 | return parity; 52 | } 53 | 54 | parity = (parity + 2) % 10; 55 | return parity; 56 | } 57 | 58 | export interface AnalyzeOptions { 59 | now?: Date | number | string; 60 | } 61 | 62 | /** 주민(외국인)등록번호를 분석한 결과 */ 63 | export interface AnalyzeResult { 64 | /** 주어진 주민등록번호가 올바른지 */ 65 | valid: boolean; 66 | /** 주민등록번호의 패리티 값, 잘못된 주민등록번호의 반환 */ 67 | parity: number | null; 68 | /** 성별, M은 남성, F는 여성 */ 69 | gender: "M" | "F" | null; 70 | /** 외국인 여부 */ 71 | foreigner: boolean | null; 72 | /** 생년월일 */ 73 | birth: string | null; 74 | /** 만나이 */ 75 | age: number | null; 76 | /** 한국나이 */ 77 | krAge: number | null; 78 | } 79 | 80 | /** 주민등록번호를 분석합니다. */ 81 | export function analyze( 82 | id: string, 83 | options: AnalyzeOptions = {}, 84 | ): AnalyzeResult { 85 | id = id.replace(/[^\d]/g, ""); 86 | const n = id.split("").map((n) => +n); 87 | 88 | let birth: string | undefined; 89 | let gender: "M" | "F" | undefined; 90 | let foreigner: boolean | undefined; 91 | let age: number | undefined; 92 | let krAge: number | undefined; 93 | 94 | if (typeof n[6] === "number") { 95 | gender = n[6] % 2 === 1 ? "M" : "F"; 96 | 97 | let yearPrefix: number; 98 | [foreigner, yearPrefix] = codeMap[n[6]]; 99 | const birthYear = yearPrefix + n[0] * 10 + n[1]; 100 | const birthMonth = n[2] * 10 + n[3]; 101 | const birthDate = n[4] * 10 + n[5]; 102 | birth = sanitizeDate(birthYear, birthMonth, birthDate); 103 | if (birth) { 104 | const now = options.now ? new Date(options.now) : new Date(); 105 | const ageBase = now.getFullYear() - birthYear; 106 | age = (now.getMonth() + 1) * 100 + now.getDate() >= 107 | birthMonth * 100 + birthDate 108 | ? ageBase 109 | : ageBase - 1; 110 | krAge = ageBase + 1; 111 | } 112 | } 113 | 114 | const parity = findParity(n, foreigner); 115 | 116 | return { 117 | valid: !!(typeof parity === "number" && birth && n[12] === parity), 118 | parity: parity ?? null, 119 | gender: gender ?? null, 120 | foreigner: foreigner ?? null, 121 | birth: birth ?? null, 122 | age: age ?? null, 123 | krAge: krAge ?? null, 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /id/format.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { format } from "./format.ts"; 3 | 4 | Deno.test("@kokr/id, format default case", () => { 5 | assertEquals(format(null), null); 6 | assertEquals(format("0"), null); 7 | assertEquals(format("00"), null); 8 | assertEquals(format("000"), null); 9 | assertEquals(format("0001"), null); 10 | assertEquals(format("00010"), null); 11 | assertEquals(format("000101"), null); 12 | assertEquals(format("0001011"), null); 13 | assertEquals(format("00010110"), null); 14 | assertEquals(format("000101100"), null); 15 | assertEquals(format("0001011000"), null); 16 | assertEquals(format("00010110000"), null); 17 | assertEquals(format("000101100000"), null); 18 | assertEquals(format("0001011000002"), "000101-1000002"); 19 | assertEquals(format("00010110000029"), null); 20 | }); 21 | 22 | Deno.test("@kokr/id, format with ignore invalid", () => { 23 | const opt = { ignoreInvalid: true }; 24 | assertEquals(format("", opt), null); 25 | assertEquals(format("0", opt), "0"); 26 | assertEquals(format("00", opt), "00"); 27 | assertEquals(format("000", opt), "000"); 28 | assertEquals(format("0001", opt), "0001"); 29 | assertEquals(format("00010", opt), "00010"); 30 | assertEquals(format("000101", opt), "000101"); 31 | assertEquals(format("0001011", opt), "000101-1"); 32 | assertEquals(format("00010110", opt), "000101-10"); 33 | assertEquals(format("000101100", opt), "000101-100"); 34 | assertEquals(format("0001011000", opt), "000101-1000"); 35 | assertEquals(format("00010110000", opt), "000101-10000"); 36 | assertEquals(format("000101100000", opt), "000101-100000"); 37 | assertEquals(format("0001011000002", opt), "000101-1000002"); 38 | assertEquals(format("00010110000029", opt), "000101-10000029"); 39 | }); 40 | 41 | Deno.test("@kokr/id, format with special characters in id", () => { 42 | assertEquals( 43 | format("^^ 0 0 0 1 0 1 _ 1 0 0 0 0 0 2 !!!"), 44 | "000101-1000002", 45 | ); 46 | 47 | const opt = { ignoreInvalid: true }; 48 | 49 | assertEquals(format(" ", opt), null); 50 | assertEquals(format(" 0 ", opt), "0"); 51 | assertEquals(format(" 00 ", opt), "00"); 52 | assertEquals(format(" 000 ", opt), "000"); 53 | assertEquals(format(" 0001 ", opt), "0001"); 54 | assertEquals(format(" 00010 ", opt), "00010"); 55 | assertEquals(format(" 000101 ", opt), "000101"); 56 | assertEquals(format(" 000101 - 1 ", opt), "000101-1"); 57 | assertEquals(format(" 000101 - 10 ", opt), "000101-10"); 58 | assertEquals(format(" 000101 - 100 ", opt), "000101-100"); 59 | assertEquals(format(" 000101 - 1000 ", opt), "000101-1000"); 60 | assertEquals(format(" 000101 - 10000 ", opt), "000101-10000"); 61 | assertEquals(format(" 000101 - 100000 ", opt), "000101-100000"); 62 | assertEquals(format(" 000101 - 1000002 ", opt), "000101-1000002"); 63 | assertEquals(format(" 000101 - 10000029 ", opt), "000101-10000029"); 64 | }); 65 | -------------------------------------------------------------------------------- /id/format.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "./validate.ts"; 2 | 3 | export interface FormatOptions { 4 | /** 올바르지 않은 주민등록번호를 무시할지 여부를 지정합니다. */ 5 | ignoreInvalid?: boolean; 6 | } 7 | 8 | export function format( 9 | id?: string | null, 10 | options: FormatOptions = {}, 11 | ): string | null { 12 | if (!options.ignoreInvalid && !validate(id)) { 13 | return null; 14 | } 15 | id = (id ?? "").replace(/[^0-9]/g, ""); 16 | const len = id.length; 17 | 18 | if (len === 0) { 19 | return null; 20 | } 21 | 22 | if (len <= 6) { 23 | return id; 24 | } 25 | return [id.slice(0, 6), id.slice(6)].filter((c) => c).join("-"); 26 | } 27 | -------------------------------------------------------------------------------- /id/mod.ts: -------------------------------------------------------------------------------- 1 | export { analyze, type AnalyzeOptions, type AnalyzeResult } from "./analyze.ts"; 2 | export { validate, type ValidateOptions } from "./validate.ts"; 3 | export { format } from "./format.ts"; 4 | -------------------------------------------------------------------------------- /id/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { validate } from "./validate.ts"; 3 | 4 | Deno.test("@kokr/id, validate korean", () => { 5 | assertEquals(validate("010101"), false); // wrong length 6 | assertEquals(validate("010001-1010104"), false); // wrong month 7 | assertEquals(validate("011301-1010104"), false); // wrong month 8 | assertEquals(validate("010100-1010104"), false); // wrong day 9 | assertEquals(validate("010132-1010104"), false); // wrong day 10 | 11 | assertEquals(validate("0101011010104"), true); 12 | assertEquals(validate("0101012010107"), true); 13 | assertEquals(validate("0101013010100"), true); 14 | assertEquals(validate("0101014010102"), true); 15 | assertEquals(validate("0101019010106"), true); 16 | assertEquals(validate("0101010010101"), true); 17 | 18 | assertEquals(validate("010101-1010104"), true); 19 | assertEquals(validate("010101-2010107"), true); 20 | assertEquals(validate("010101-3010100"), true); 21 | assertEquals(validate("010101-4010102"), true); 22 | assertEquals(validate("010101-9010106"), true); 23 | assertEquals(validate("010101-0010101"), true); 24 | 25 | assertEquals(validate("010101 - 1010104"), true); 26 | assertEquals(validate("010101 - 2010107"), true); 27 | assertEquals(validate("010101 - 3010100"), true); 28 | assertEquals(validate("010101 - 4010102"), true); 29 | assertEquals(validate("010101 - 9010106"), true); 30 | assertEquals(validate("010101 - 0010101"), true); 31 | }); 32 | 33 | Deno.test("@kokr/id, validate foreigner", () => { 34 | assertEquals(validate("010101-5010105"), false); 35 | assertEquals(validate("010101-6010108"), false); 36 | assertEquals(validate("010101-7010101"), false); 37 | assertEquals(validate("010101-8010103"), false); 38 | 39 | assertEquals(validate("010101-5010107"), true); 40 | assertEquals(validate("010101-6010100"), true); 41 | assertEquals(validate("010101-7010103"), true); 42 | assertEquals(validate("010101-8010105"), true); 43 | 44 | assertEquals(validate("010101-8010105"), true); 45 | 46 | assertEquals(validate("010101-5010105", { disableForeigner: true }), false); 47 | assertEquals(validate("010101-6010108", { disableForeigner: true }), false); 48 | assertEquals(validate("010101-7010101", { disableForeigner: true }), false); 49 | assertEquals(validate("010101-8010103", { disableForeigner: true }), false); 50 | 51 | assertEquals(validate("010101-5010107", { disableForeigner: true }), false); 52 | assertEquals(validate("010101-6010100", { disableForeigner: true }), false); 53 | assertEquals(validate("010101-7010103", { disableForeigner: true }), false); 54 | assertEquals(validate("010101-8010105", { disableForeigner: true }), false); 55 | }); 56 | -------------------------------------------------------------------------------- /id/validate.ts: -------------------------------------------------------------------------------- 1 | import { analyze } from "./analyze.ts"; 2 | 3 | const RE_QUICK_ID = 4 | /^\d{2}([0][1-9]|[1][0-2])([0][1-9]|[1-2][0-9]|[3][0-1])\d{7}$/; 5 | 6 | export interface ValidateOptions { 7 | /** 외국인등록번호를 허용할지 여부를 지정합니다. */ 8 | disableForeigner?: boolean; 9 | } 10 | 11 | /** 주민등록번호가 올바른지 확인 후, Boolean을 반환합니다. */ 12 | export function validate(id?: string | null, options: ValidateOptions = {}) { 13 | id = (id ?? "").replace(/[^\d]/g, ""); 14 | 15 | if (!RE_QUICK_ID.test(id)) { 16 | return false; 17 | } 18 | 19 | const { valid, foreigner } = analyze(id); 20 | if (!valid) { 21 | return false; 22 | } 23 | if (options.disableForeigner && foreigner) { 24 | return false; 25 | } 26 | return true; 27 | } 28 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type DateInfo, 3 | DateKind, 4 | getHolidays, 5 | getNextBusinessDay, 6 | isHoliday, 7 | type RetrieveHolidays, 8 | } from "./date/mod.ts"; 9 | export { 10 | analyze as analyzeId, 11 | type AnalyzeOptions, 12 | type AnalyzeResult, 13 | format as formatId, 14 | type ValidateOptions, 15 | } from "./id/mod.ts"; 16 | export { format } from "./phone/mod.ts"; 17 | export { text } from "./text/mod.ts"; 18 | -------------------------------------------------------------------------------- /phone/README.md: -------------------------------------------------------------------------------- 1 | # koKR - phone 2 | 3 |

4 | Build 5 | Coverage 6 | License 7 | Language Typescript 8 |
9 | deno.land/x/kokr/phone 10 | Version 11 | Downloads 12 |

13 | 14 | 전화번호와 관련된 유틸리티 함수를 제공합니다. 15 | 16 | ## 설치 17 | 18 | ```bash 19 | npm install @kokr/phone 20 | ``` 21 | 22 | 만약, Deno를 사용한다면 아래와 같이 import 할 수 있습니다. 23 | 24 | ```typescript 25 | import {} from "https://deno.land/x/kokr/phone/mod.ts"; 26 | ``` 27 | 28 | ## 사용법 29 | 30 | **전화번호 포매팅** 31 | 32 | 전화번호를 포매팅합니다. 33 | 34 | ```typescript 35 | import { format } from "@kokr/phone"; 36 | 37 | format("0212341234"); // '02-1234-1234' 38 | format("021231234"); // '02-123-1234' 39 | format("16881234"); // '1688-1234' 40 | ``` 41 | 42 | 특수문자(`#`, `*`)를 포함한 포매팅도 가능합니다. 43 | 44 | ```typescript 45 | format("02****####"); // '02-****-####' 46 | ``` 47 | 48 | ## API 49 | 50 | [API 문서 보기](https://deno.land/x/kokr/phone/mod.ts) 51 | -------------------------------------------------------------------------------- /phone/format.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { format } from "./format.ts"; 3 | 4 | Deno.test("@kokr/phone, format", () => { 5 | // empty 6 | assertEquals(format(""), null); 7 | 8 | const prefix = [ 9 | "02", // seoul 10 | "0505", // LG+ 11 | "010", 12 | "011", 13 | "012", 14 | "015", 15 | "016", 16 | "017", 17 | "018", 18 | "019", // mobile 19 | "030", 20 | "050", 21 | "060", 22 | "070", 23 | "080", // etc 24 | "031", 25 | "032", 26 | "033", 27 | "041", 28 | "042", 29 | "043", 30 | "044", 31 | "051", 32 | "052", 33 | "053", 34 | "054", 35 | "055", 36 | "061", 37 | "062", 38 | "063", 39 | "064", // local 40 | ]; 41 | for (const no of prefix) { 42 | assertEquals(format(`${no}`), `${no}`); 43 | assertEquals(format(`${no}1`), `${no}-1`); 44 | assertEquals(format(`${no}12`), `${no}-12`); 45 | assertEquals(format(`${no}123`), `${no}-123`); 46 | assertEquals(format(`${no}1234`), `${no}-123-4`); 47 | assertEquals(format(`${no}12345`), `${no}-123-45`); 48 | assertEquals(format(`${no}123456`), `${no}-123-456`); 49 | assertEquals(format(`${no}1234567`), `${no}-123-4567`); 50 | assertEquals(format(`${no}12345678`), `${no}-1234-5678`); 51 | assertEquals(format(`${no}123456789`), `${no}123456789`); // unknown 52 | 53 | assertEquals(format(`${no}####****`), `${no}-####-****`); // special chars 54 | assertEquals(format(`${no}!@#$%^&*()-_=+\\|`), `${no}-#*`); // escape special chars 55 | } 56 | }); 57 | 58 | Deno.test("@kokr/phone, format 15xx ~ 19xx", () => { 59 | const prefix = [ 60 | "1588", 61 | "1577", 62 | "1899", 63 | "1544", 64 | "1644", 65 | "1661", 66 | "1566", 67 | "1600", 68 | "1670", 69 | "1688", 70 | "1666", 71 | "1599", 72 | "1877", 73 | "1855", 74 | "1800", 75 | ]; 76 | for (const no of prefix) { 77 | assertEquals(format(`${no}`), `${no}`); 78 | assertEquals(format(`${no}1`), `${no}-1`); 79 | assertEquals(format(`${no}12`), `${no}-12`); 80 | assertEquals(format(`${no}123`), `${no}-123`); 81 | assertEquals(format(`${no}1234`), `${no}-1234`); 82 | assertEquals(format(`${no}12345`), `${no}12345`); // unknown 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /phone/format.ts: -------------------------------------------------------------------------------- 1 | export function format(phone?: string | null): string | null { 2 | if (!phone) { 3 | return null; 4 | } 5 | phone = phone.replace(/[^0-9*#]/g, ""); 6 | const len = phone.length; 7 | 8 | if (len === 0) { 9 | return null; 10 | } 11 | 12 | // 15xx ~ 19xx 13 | if (/^(15|16|17|18|19)/.exec(phone)) { 14 | if (len <= 4) { 15 | return phone; 16 | } 17 | if (len <= 8) { 18 | return `${phone.slice(0, 4)}-${phone.slice(4)}`; 19 | } 20 | return phone; 21 | } 22 | 23 | let n0 = 3; 24 | if (phone.startsWith("02")) { // 02-xxxx 25 | n0 = 2; 26 | } else if (phone.startsWith("0505")) { 27 | n0 = 4; 28 | } 29 | 30 | if (len <= n0) { 31 | return phone; 32 | } 33 | if (len <= n0 + 3) { 34 | return [phone.slice(0, n0), phone.slice(n0)].filter((c) => c).join("-"); 35 | } 36 | if (len <= n0 + 7) { 37 | return [phone.slice(0, n0), phone.slice(n0, n0 + 3), phone.slice(n0 + 3)] 38 | .filter((c) => c).join("-"); 39 | } 40 | if (len <= n0 + 8) { 41 | return [phone.slice(0, n0), phone.slice(n0, n0 + 4), phone.slice(n0 + 4)] 42 | .filter((c) => c).join("-"); 43 | } 44 | return phone; 45 | } 46 | -------------------------------------------------------------------------------- /phone/mod.ts: -------------------------------------------------------------------------------- 1 | export { format } from "./format.ts"; 2 | -------------------------------------------------------------------------------- /scripts/build_npm.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "dnt/mod.ts"; 2 | 3 | const denoInfo = JSON.parse( 4 | Deno.readTextFileSync(new URL("../deno.json", import.meta.url)), 5 | ); 6 | const version = denoInfo.version; 7 | 8 | await emptyDir("./.npm"); 9 | 10 | const services = [ 11 | { 12 | name: "date", 13 | description: 14 | "Provides utilities for Korean dates. 날짜 관련 유틸리티를 제공합니다. 공휴일, 절기, 그리고 잡절 정보를 확인하고, 영업일 기준 날짜 계산을 지원합니다.", 15 | keywords: [ 16 | "business day", 17 | "anniversary", 18 | "holiday", 19 | "기념일", 20 | "공휴일", 21 | "typescript", 22 | ], 23 | }, 24 | { 25 | name: "id", 26 | description: 27 | "Provides utility to analyze Korean id numbers. / 주민등록번호를 분석하는 도구를 제공합니다. 생년월일과 성별 등의 정보를 확인하고, 주민등록번호의 유효성을 검증합니다.", 28 | keywords: [ 29 | "주민등록번호", 30 | "주민번호", 31 | "외국인등록번호", 32 | "jumin", 33 | "typescript", 34 | ], 35 | }, 36 | { 37 | name: "phone", 38 | description: 39 | "Provides a phone number format conversion tool. / 전화번호 서식 변환 도구를 제공합니다. 전화번호를 일관된 형식으로 변환하는 기능을 지원합니다.", 40 | keywords: [ 41 | "전화번호", 42 | "phone", 43 | "formatter", 44 | "typescript", 45 | ], 46 | }, 47 | { 48 | name: "text", 49 | description: 50 | "A utility to help handle investigations in Korean sentences. / 한국어 문장의 조사 처리를 도와주는 유틸리티입니다. 은/는/이/가 등의 조사를 적절하게 처리합니다.", 51 | keywords: [ 52 | "한국어", 53 | "한글", 54 | "조사", 55 | "은는이가", 56 | "dedent", 57 | "korean", 58 | "hangul", 59 | "typescript", 60 | ], 61 | }, 62 | ]; 63 | 64 | await Promise.all(services.map(({ name, description, keywords }) => 65 | build({ 66 | entryPoints: [`./${name}/mod.ts`], 67 | outDir: `./.npm/${name}`, 68 | shims: { 69 | deno: false, 70 | custom: [ 71 | { 72 | package: { 73 | name: "node-fetch", 74 | version: "~3.1.0", 75 | }, 76 | globalNames: [{ 77 | name: "fetch", 78 | exportName: "default", 79 | }, { 80 | name: "RequestInit", 81 | typeOnly: true, // only used in type declarations 82 | }], 83 | }, 84 | { 85 | package: { 86 | name: "@denostack/shim-webstore", 87 | version: "~0.1.0", 88 | }, 89 | globalNames: ["localStorage"], 90 | }, 91 | ], 92 | }, 93 | test: false, 94 | package: { 95 | name: `@kokr/${name}`, 96 | version, 97 | description, 98 | keywords, 99 | license: "MIT", 100 | repository: { 101 | type: "git", 102 | url: "git+https://github.com/wan2land/kokr.git", 103 | }, 104 | bugs: { 105 | url: "https://github.com/wan2land/kokr/issues", 106 | }, 107 | }, 108 | }).then(() => Deno.copyFile(`${name}/README.md`, `.npm/${name}/README.md`)) 109 | )); 110 | 111 | // post build steps 112 | -------------------------------------------------------------------------------- /text/README.md: -------------------------------------------------------------------------------- 1 | # koKR - text 2 | 3 |

4 | Build 5 | Coverage 6 | License 7 | Language Typescript 8 |
9 | deno.land/x/kokr/text 10 | Version 11 | Downloads 12 |

13 | 14 | ## 설치 15 | 16 | ```bash 17 | npm install @kokr/text 18 | ``` 19 | 20 | 만약, Deno를 사용한다면 아래와 같이 import 할 수 있습니다. 21 | 22 | ```typescript 23 | import text from "https://deno.land/x/kokr/text/mod.ts"; 24 | ``` 25 | 26 | ## 사용법 27 | 28 | ### 조사 29 | 30 | 은/는/이/가와 같은 조사를 자동으로 붙여주는 기능을 템플릿 리터럴(Template 31 | Literal)을 통해 제공합니다. 32 | 33 | ```typescript 34 | import text from "@kokr/text"; 35 | 36 | let name1 = "사과"; 37 | let name2 = "코코넛"; 38 | 39 | // 은/는 40 | console.log(text`${name1}는 코딩 합니다.`); // 사과는 코딩 합니다. 41 | console.log(text`${name1}은 코딩 합니다.`); // 사과는 코딩 합니다. 42 | console.log(text`${name2}는 코딩 합니다.`); // 코코넛은 코딩 합니다. 43 | console.log(text`${name2}은 코딩 합니다.`); // 코코넛은 코딩 합니다. 44 | 45 | // 이/가 46 | console.log(text`${name1}가 코딩 했습니다.`); // 사과가 코딩 했습니다. 47 | console.log(text`${name1}이 코딩 했습니다.`); // 사과가 코딩 했습니다. 48 | console.log(text`${name2}가 코딩 했습니다.`); // 코코넛이 코딩 했습니다. 49 | console.log(text`${name2}이 코딩 했습니다.`); // 코코넛이 코딩 했습니다. 50 | 51 | // 을/를 52 | console.log(text`${name1}을 가르쳤습니다.`); // 사과를 가르쳤습니다. 53 | console.log(text`${name1}를 가르쳤습니다.`); // 사과를 가르쳤습니다. 54 | console.log(text`${name2}을 가르쳤습니다.`); // 코코넛을 가르쳤습니다. 55 | console.log(text`${name2}를 가르쳤습니다.`); // 코코넛을 가르쳤습니다. 56 | 57 | // 로/으로 58 | let place = "대구"; 59 | console.log(text`${place}으로 갑시다.`); // 대구로 갑시다. 60 | console.log(text`${place}로 갑시다.`); // 대구로 갑시다. 61 | 62 | place = "부산"; 63 | console.log(text`${place}으로 갑시다.`); // 부산으로 갑시다. 64 | console.log(text`${place}로 갑시다.`); // 부산으로 갑시다. 65 | 66 | place = "서울"; // 예외케이스 (ㄹ로 끝나는 경우) 67 | console.log(text`${place}으로 갑시다.`); // 서울로 갑시다. 68 | console.log(text`${place}로 갑시다.`); // 서울로 갑시다. 69 | ``` 70 | 71 | 제공하는 조사는 다음과 같습니다. 72 | 73 | - 은/는 74 | - 이/가 75 | - 을/를 76 | - 과/와 77 | - 아/야 (모호한 케이스, 하단 설명 참고) 78 | - 이나/나 79 | - 이다/다 80 | - 이든/든 81 | - 이라/라 82 | - 이란/란 83 | - 이랑/랑 84 | - 으로/로 85 | - 이며/며 86 | - 이셨/셨 87 | - 이시/시 88 | - 이야/야 (모호한 케이스, 하단 설명 참고) 89 | - 이여/여 90 | - 이었/였 91 | - 이어요/여요 92 | - 이에요/예요 93 | 94 | **모호한 케이스** 95 | 96 | "아/야"와 "이야/야"는 모호한 케이스가 있습니다. "야"로 끝나는 경우에는 "이야/야" 97 | 규칙이 우선적으로 적용됩니다. 명확하게 사용하려면 "이야", "아"를 사용해야 98 | 합니다. 99 | 100 | ```typescript 101 | // 명확한 케이스 102 | console.log(text`${"완두"}아!`); // "완두야!" 103 | console.log(text`${"완삼"}아!`); // "완삼아!" 104 | 105 | console.log(text`${"완두"}이야!`); // "완두야!" 106 | console.log(text`${"완삼"}이야!`); // "완삼이야!" 107 | 108 | // 모호한 케이스 109 | console.log(text`${"완두"}야!`); // "완두야!" 110 | console.log(text`${"완삼"}야!`); // "완삼이야!" // "완삼아!"를 원했지만 "이야/야" 규칙이 우선. 111 | ``` 112 | 113 | ### 숫자 판단 및 영어 114 | 115 | 숫자의 경우 한국어 발음 기준으로 변환하여 제공합니다. 116 | 117 | ```typescript 118 | console.log(text`정답은 ${"100"}과 같다.`); // 정답은 100과 같다. 119 | console.log(text`정답은 ${"100"}와 같다.`); // 정답은 100과 같다. 120 | 121 | console.log(text`정답은 ${"72"}과 같다.`); // 정답은 72와 같다. 122 | console.log(text`정답은 ${"72"}와 같다.`); // 정답은 72와 같다. 123 | ``` 124 | 125 | 영어 또한 한국어 발음 기준으로 변환하여 제공합니다. 126 | 127 | ```typescript 128 | console.log(text`${"Apple"}는 코딩 합니다.`); // Apple은 코딩 합니다. 129 | console.log(text`${"Apple"}은 코딩 합니다.`); // Apple은 코딩 합니다. 130 | 131 | console.log(text`${"Banana"}는 코딩 합니다.`); // Banana는 코딩 합니다. 132 | console.log(text`${"Banana"}은 코딩 합니다.`); // Banana는 코딩 합니다. 133 | ``` 134 | 135 | ### 괄호 무시 136 | 137 | 괄호가 포함된 경우, 괄호안의 텍스트는 무시합니다. 138 | 139 | ```typescript 140 | console.log(text`${"코코넛(바나나)"}은 코딩 합니다.`); // 코코넛(바나나)은 코딩 합니다. 141 | console.log(text`${"코코넛(바나나)"}는 코딩 합니다.`); // 코코넛(바나나)은 코딩 합니다. 142 | ``` 143 | 144 | ### Dedent 145 | 146 | 여러 줄의 문자열을 입력할 때, 들여쓰기를 제거해주는 기능을 제공합니다. 147 | 148 | ```typescript 149 | function printResult(winner: string, loser: string) { 150 | return text` 151 | 결과는 다음과 같습니다. 152 | 153 | - 승: ${winner} 154 | - 패: ${loser} 155 | 156 | ${winner}님 축하드립니다! 157 | `; 158 | } 159 | ``` 160 | 161 | ### 조사 변환 없이 그대로 사용하기 162 | 163 | 조사 변환없이 그대로 사용하려면 다음과 같이 사용하면 됩니다. 164 | 165 | ```typescript 166 | console.log(text`${name}는 가나다라..`); // 이렇게 되면 '는'은 '은/는'으로 변환됩니다. 167 | 168 | console.log(text`${name}{'는'} 가나다라..`); // 이렇게 되면 '는'은 변환 없이 그대로 출력됩니다. 169 | ``` 170 | 171 | ## API 172 | 173 | [API 문서 보기](https://deno.land/x/kokr/text/mod.ts) 174 | 175 | ## 함께 보면 좋아요 176 | 177 | - [Hangul.js](https://github.com/e-/Hangul.js) 한글 문장의 자/모음을 분리하는 178 | 라이브러리 179 | - [inko](https://github.com/738/inko) 영타를 한글로, 혹은 한타를 영어로 180 | 변환해주는 라이브러리 181 | - [josa-complete](https://github.com/rycont/josa-complete) String.prototype을 182 | 확장해서 조사를 깔끔하게 붙여주는 라이브러리 183 | -------------------------------------------------------------------------------- /text/jongseong.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { jongseong } from "./jongseong.ts"; 3 | 4 | Deno.test("@kokr/text, jongseong hangul", () => { 5 | assertEquals(jongseong("가(각)"), 0); // ignore paren 6 | 7 | assertEquals(jongseong("가"), 0); 8 | assertEquals(jongseong("납"), 17); 9 | assertEquals(jongseong("닿"), 27); 10 | assertEquals(jongseong("힣"), 27); 11 | }); 12 | 13 | Deno.test("@kokr/text, jongseong number", () => { 14 | assertEquals(jongseong("0"), jongseong("영")); 15 | assertEquals(jongseong("1"), jongseong("일")); 16 | assertEquals(jongseong("2"), jongseong("이")); 17 | assertEquals(jongseong("3"), jongseong("삼")); 18 | assertEquals(jongseong("4"), jongseong("사")); 19 | assertEquals(jongseong("5"), jongseong("오")); 20 | assertEquals(jongseong("6"), jongseong("육")); 21 | assertEquals(jongseong("7"), jongseong("칠")); 22 | assertEquals(jongseong("8"), jongseong("팔")); 23 | assertEquals(jongseong("9"), jongseong("구")); 24 | 25 | assertEquals(jongseong("00"), jongseong("영")); 26 | }); 27 | 28 | Deno.test("@kokr/text, jongseong number with zeros", () => { 29 | assertEquals(jongseong("10"), jongseong("십")); 30 | assertEquals(jongseong("100"), jongseong("백")); 31 | assertEquals(jongseong("1000"), jongseong("천")); 32 | assertEquals(jongseong("10000"), jongseong("만")); 33 | assertEquals(jongseong("100000000"), jongseong("억")); 34 | assertEquals(jongseong("1000000000000"), jongseong("조")); 35 | assertEquals(jongseong("10000000000000000"), jongseong("경")); 36 | assertEquals(jongseong("100000000000000000000"), jongseong("해")); 37 | }); 38 | 39 | Deno.test("@kokr/text, jongseong english word", () => { 40 | assertEquals(jongseong("job"), jongseong("잡")); 41 | assertEquals(jongseong("public"), jongseong("퍼블릭")); 42 | assertEquals(jongseong("good"), jongseong("굿")); 43 | assertEquals(jongseong("check"), jongseong("첵")); 44 | assertEquals(jongseong("signal"), jongseong("시그널")); 45 | assertEquals(jongseong("bottom"), jongseong("바텀")); 46 | assertEquals(jongseong("vision"), jongseong("비전")); 47 | assertEquals(jongseong("group"), jongseong("그룹")); 48 | assertEquals(jongseong("yet"), jongseong("옛")); 49 | 50 | assertEquals(jongseong("fine"), jongseong("파인")); 51 | assertEquals(jongseong("scale"), jongseong("스케일")); 52 | assertEquals(jongseong("song"), jongseong("송")); 53 | 54 | assertEquals(jongseong("coffee"), jongseong("커피")); 55 | }); 56 | 57 | Deno.test("@kokr/text, jongseong english character", () => { 58 | assertEquals(jongseong("l"), jongseong("엘")); 59 | assertEquals(jongseong("r"), jongseong("알")); 60 | assertEquals(jongseong("m"), jongseong("엠")); 61 | assertEquals(jongseong("n"), jongseong("엔")); 62 | assertEquals(jongseong("x"), jongseong("엑스")); 63 | 64 | assertEquals(jongseong("L"), jongseong("엘")); 65 | assertEquals(jongseong("R"), jongseong("알")); 66 | assertEquals(jongseong("M"), jongseong("엠")); 67 | assertEquals(jongseong("N"), jongseong("엔")); 68 | assertEquals(jongseong("X"), jongseong("엑스")); 69 | }); 70 | 71 | Deno.test("@kokr/text, jongseong random word", () => { 72 | assertEquals(jongseong("서울"), 8); 73 | assertEquals(jongseong("서울 "), 8); 74 | assertEquals(jongseong("서울!"), 8); 75 | 76 | assertEquals(jongseong("완두"), 0); 77 | assertEquals(jongseong("완두 "), 0); 78 | assertEquals(jongseong("완두!!!"), 0); 79 | 80 | assertEquals(jongseong("!@#"), 0); 81 | }); 82 | -------------------------------------------------------------------------------- /text/jongseong.ts: -------------------------------------------------------------------------------- 1 | const RE_DIGIT_ZEROS = /[1-9](0+)$/; 2 | const RE_ENG_LAST_TWO = /[a-z]{2}$/i; 3 | 4 | const ㄱ = 1; 5 | const ㄴ = 4; 6 | const ㄹ = 8; 7 | const ㅁ = 16; 8 | const ㅂ = 17; 9 | const ㅅ = 19; 10 | const ㅇ = 21; 11 | 12 | const digitZerosMap = [ 13 | ㅂ, // 십 14 | ㄱ, // 백 15 | ㄴ, // 천 16 | ㄴ, // 만 17 | ㄴ, // 십만 18 | ㄴ, // 백만 19 | ㄴ, // 천만 20 | ㄱ, // 억 21 | ㄱ, // 십억 22 | ㄱ, // 백억 23 | ㄱ, // 천억 24 | 0, // 조 25 | 0, // 십조 26 | 0, // 백조 27 | 0, // 천조 28 | ㅇ, // 경 29 | ㅇ, // 십경 30 | ㅇ, // 백경 31 | ㅇ, // 천경 32 | 0, // 해 33 | 0, // 십해 34 | 0, // 백해 35 | 0, // 천해 36 | ]; 37 | const digitMap = [ 38 | ㅇ, // 영 39 | ㄹ, // 일 40 | 0, // 이 41 | ㅁ, // 삼 42 | 0, // 사 43 | 0, // 오 44 | ㄱ, // 육 45 | ㄹ, // 칠 46 | ㄹ, // 팔 47 | 0, // 구 48 | ]; 49 | 50 | const engSuffix2Map: Record = { 51 | nd: 0, 52 | ne: ㄴ, 53 | le: ㄹ, 54 | ng: ㅇ, 55 | }; 56 | 57 | const engSuffixMap: Record = { 58 | b: ㅂ, 59 | c: ㄱ, 60 | d: ㅅ, 61 | k: ㄱ, 62 | l: ㄹ, 63 | m: ㅁ, 64 | n: ㄴ, 65 | p: ㅂ, 66 | t: ㅅ, 67 | }; 68 | 69 | const engCharMap: Record = { 70 | l: ㄹ, 71 | m: ㅁ, 72 | n: ㄴ, 73 | r: ㄹ, 74 | }; 75 | 76 | /** @internal */ 77 | export function jongseong(word: string): number { 78 | let w = word; 79 | while (w.length) { 80 | // strip paren ABC(D) => ABC 81 | w = w.replace(/\([^)]*\)$/, ""); 82 | 83 | const last = w[w.length - 1]; 84 | const lastCharCode = last.charCodeAt(0); 85 | 86 | if (lastCharCode >= 44032 && lastCharCode <= 55203) { // 가-힣 87 | return (lastCharCode - 44032) % 28; 88 | } 89 | 90 | // digit 91 | if (lastCharCode >= 48 && lastCharCode <= 57) { // 0-9 92 | const zerosMatch = RE_DIGIT_ZEROS.exec(w); 93 | if (zerosMatch) { 94 | return digitZerosMap[zerosMatch[1].length - 1] ?? 0; 95 | } 96 | return digitMap[lastCharCode - 48]; 97 | } 98 | 99 | // english 100 | if ( 101 | lastCharCode >= 65 && lastCharCode <= 90 || 102 | lastCharCode >= 97 && lastCharCode <= 122 103 | ) { 104 | const match = RE_ENG_LAST_TWO.exec(w); 105 | if (match) { 106 | const suffix2 = match[0].toLowerCase(); 107 | const code = engSuffix2Map[suffix2]; 108 | if (typeof code === "number") { 109 | return code; 110 | } 111 | return engSuffixMap[suffix2[1]] || 0; 112 | } 113 | return engCharMap[last.toLowerCase()] ?? 0; 114 | } 115 | 116 | w = w.slice(0, w.length - 1); 117 | } 118 | 119 | return 0; 120 | } 121 | -------------------------------------------------------------------------------- /text/mod.ts: -------------------------------------------------------------------------------- 1 | import { text } from "./text.ts"; 2 | 3 | export default text; 4 | 5 | export { text }; 6 | -------------------------------------------------------------------------------- /text/text.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "testing/asserts.ts"; 2 | import { text } from "./text.ts"; 3 | 4 | Deno.test("@kokr/text, text empty", () => { 5 | assertEquals(text``, ""); 6 | }); 7 | 8 | Deno.test("@kokr/text, text 은/는", () => { 9 | assertEquals(text`${"완두"}는 코딩을 합니다.`, "완두는 코딩을 합니다."); 10 | assertEquals(text`${"완두"}은 코딩을 합니다.`, "완두는 코딩을 합니다."); 11 | 12 | assertEquals(text`${"완삼"}는 코딩을 합니다.`, "완삼은 코딩을 합니다."); 13 | assertEquals(text`${"완삼"}은 코딩을 합니다.`, "완삼은 코딩을 합니다."); 14 | }); 15 | 16 | Deno.test("@kokr/text, text 이/가", () => { 17 | assertEquals(text`${"완두"}가 코딩을 했습니다.`, "완두가 코딩을 했습니다."); 18 | assertEquals(text`${"완두"}이 코딩을 했습니다.`, "완두가 코딩을 했습니다."); 19 | 20 | assertEquals(text`${"완삼"}가 코딩을 했습니다.`, "완삼이 코딩을 했습니다."); 21 | assertEquals(text`${"완삼"}이 코딩을 했습니다.`, "완삼이 코딩을 했습니다."); 22 | }); 23 | 24 | Deno.test("@kokr/text, text 을/를", () => { 25 | assertEquals(text`${"완두"}을 가르쳤습니다.`, "완두를 가르쳤습니다."); 26 | assertEquals(text`${"완두"}를 가르쳤습니다.`, "완두를 가르쳤습니다."); 27 | 28 | assertEquals(text`${"완삼"}을 가르쳤습니다.`, "완삼을 가르쳤습니다."); 29 | assertEquals(text`${"완삼"}를 가르쳤습니다.`, "완삼을 가르쳤습니다."); 30 | }); 31 | 32 | Deno.test("@kokr/text, text 과/와", () => { 33 | assertEquals(text`${"완두"}과 코딩을 했습니다.`, "완두와 코딩을 했습니다."); 34 | assertEquals(text`${"완두"}와 코딩을 했습니다.`, "완두와 코딩을 했습니다."); 35 | 36 | assertEquals(text`${"완삼"}과 코딩을 했습니다.`, "완삼과 코딩을 했습니다."); 37 | assertEquals(text`${"완삼"}와 코딩을 했습니다.`, "완삼과 코딩을 했습니다."); 38 | }); 39 | 40 | Deno.test("@kokr/text, text 아/야", () => { 41 | assertEquals(text`${"완두"}아!`, "완두야!"); 42 | assertEquals(text`${"완두"}야!`, "완두야!"); 43 | 44 | assertEquals(text`${"완삼"}아!`, "완삼아!"); 45 | // assertEquals(text`${"완삼"}야!`, "완삼아!"); -> 이야/야 규칙이 우선 46 | }); 47 | 48 | // 2글자 가나다순 49 | 50 | Deno.test("@kokr/text, text 이나/나", () => { 51 | assertEquals(text`${"완두"}이나..`, "완두나.."); 52 | assertEquals(text`${"완두"}나..`, "완두나.."); 53 | 54 | assertEquals(text`${"완삼"}이나..`, "완삼이나.."); 55 | assertEquals(text`${"완삼"}나..`, "완삼이나.."); 56 | }); 57 | 58 | Deno.test("@kokr/text, text 이다/다", () => { 59 | assertEquals(text`${"완두"}이다!!`, "완두다!!"); 60 | assertEquals(text`${"완두"}다!!`, "완두다!!"); 61 | 62 | assertEquals(text`${"완삼"}이다!!`, "완삼이다!!"); 63 | assertEquals(text`${"완삼"}다!!`, "완삼이다!!"); 64 | }); 65 | 66 | Deno.test("@kokr/text, text 이든/든", () => { 67 | assertEquals(text`${"완두"}이든..`, "완두든.."); 68 | assertEquals(text`${"완두"}든..`, "완두든.."); 69 | 70 | assertEquals(text`${"완삼"}이든..`, "완삼이든.."); 71 | assertEquals(text`${"완삼"}든..`, "완삼이든.."); 72 | }); 73 | 74 | Deno.test("@kokr/text, text 이라/라", () => { 75 | assertEquals(text`${"완두"}이라니!!`, "완두라니!!"); 76 | assertEquals(text`${"완두"}라니!!`, "완두라니!!"); 77 | 78 | assertEquals(text`${"완삼"}이라니!!`, "완삼이라니!!"); 79 | assertEquals(text`${"완삼"}라니!!`, "완삼이라니!!"); 80 | }); 81 | 82 | Deno.test("@kokr/text, text 이란/란", () => { 83 | assertEquals(text`${"완두"}이란..`, "완두란.."); 84 | assertEquals(text`${"완두"}란..`, "완두란.."); 85 | 86 | assertEquals(text`${"완삼"}이란..`, "완삼이란.."); 87 | assertEquals(text`${"완삼"}란..`, "완삼이란.."); 88 | }); 89 | 90 | Deno.test("@kokr/text, text 이랑/랑", () => { 91 | assertEquals(text`${"완두"}이랑!`, "완두랑!"); 92 | assertEquals(text`${"완두"}랑!`, "완두랑!"); 93 | 94 | assertEquals(text`${"완삼"}이랑!`, "완삼이랑!"); 95 | assertEquals(text`${"완삼"}랑!`, "완삼이랑!"); 96 | }); 97 | 98 | Deno.test("@kokr/text, text 으로/로", () => { 99 | assertEquals(text`${"대구"}으로 갑시다.`, "대구로 갑시다."); 100 | assertEquals(text`${"대구"}로 갑시다.`, "대구로 갑시다."); 101 | 102 | assertEquals(text`${"부산"}으로 갑시다.`, "부산으로 갑시다."); 103 | assertEquals(text`${"부산"}로 갑시다.`, "부산으로 갑시다."); 104 | 105 | // ㄹ 탈락 106 | assertEquals(text`${"서울"}으로 갑시다.`, "서울로 갑시다."); 107 | assertEquals(text`${"서울"}로 갑시다.`, "서울로 갑시다."); 108 | }); 109 | 110 | Deno.test("@kokr/text, text 이며/며", () => { 111 | assertEquals(text`${"완두"}이며,`, "완두며,"); 112 | assertEquals(text`${"완두"}며,`, "완두며,"); 113 | 114 | assertEquals(text`${"완삼"}이며,`, "완삼이며,"); 115 | assertEquals(text`${"완삼"}며,`, "완삼이며,"); 116 | }); 117 | 118 | Deno.test("@kokr/text, text 이셨/셨", () => { 119 | assertEquals(text`${"완두"}이셨어!`, "완두셨어!"); 120 | assertEquals(text`${"완두"}셨어!`, "완두셨어!"); 121 | 122 | assertEquals(text`${"완삼"}이셨어!`, "완삼이셨어!"); 123 | assertEquals(text`${"완삼"}셨어!`, "완삼이셨어!"); 124 | 125 | assertEquals(text`${"완두"}이셨구나!`, "완두셨구나!"); 126 | assertEquals(text`${"완두"}셨구나!`, "완두셨구나!"); 127 | 128 | assertEquals(text`${"완삼"}이셨구나!`, "완삼이셨구나!"); 129 | assertEquals(text`${"완삼"}셨구나!`, "완삼이셨구나!"); 130 | }); 131 | 132 | Deno.test("@kokr/text, text 이시/시", () => { 133 | assertEquals(text`${"완두"}이시여!`, "완두시여!"); 134 | assertEquals(text`${"완두"}시여!`, "완두시여!"); 135 | 136 | assertEquals(text`${"완삼"}이시여!`, "완삼이시여!"); 137 | assertEquals(text`${"완삼"}시여!`, "완삼이시여!"); 138 | 139 | assertEquals(text`${"완두"}이시구나!`, "완두시구나!"); 140 | assertEquals(text`${"완두"}시구나!`, "완두시구나!"); 141 | 142 | assertEquals(text`${"완삼"}이시구나!`, "완삼이시구나!"); 143 | assertEquals(text`${"완삼"}시구나!`, "완삼이시구나!"); 144 | }); 145 | 146 | Deno.test("@kokr/text, text 이야/야", () => { 147 | assertEquals(text`역시, ${"완두"}이야!`, "역시, 완두야!"); 148 | assertEquals(text`역시, ${"완두"}야!`, "역시, 완두야!"); 149 | 150 | assertEquals(text`역시, ${"완삼"}이야!`, "역시, 완삼이야!"); 151 | assertEquals(text`역시, ${"완삼"}야!`, "역시, 완삼이야!"); 152 | }); 153 | 154 | Deno.test("@kokr/text, text 이여/여", () => { 155 | assertEquals(text`${"완두"}이여!`, "완두여!"); 156 | assertEquals(text`${"완두"}여!`, "완두여!"); 157 | 158 | assertEquals(text`${"완삼"}이여!`, "완삼이여!"); 159 | assertEquals(text`${"완삼"}여!`, "완삼이여!"); 160 | }); 161 | 162 | Deno.test("@kokr/text, text 이었/였", () => { 163 | assertEquals(text`${"완두"}이었어요.`, "완두였어요."); 164 | assertEquals(text`${"완두"}였어요.`, "완두였어요."); 165 | 166 | assertEquals(text`${"완삼"}이었어요.`, "완삼이었어요."); 167 | assertEquals(text`${"완삼"}였어요.`, "완삼이었어요."); 168 | }); 169 | 170 | // 3글자 171 | Deno.test("@kokr/text, text 이어요/여요", () => { 172 | assertEquals(text`${"완두"}이어요.`, "완두여요."); 173 | assertEquals(text`${"완두"}여요.`, "완두여요."); 174 | 175 | assertEquals(text`${"완삼"}이어요.`, "완삼이어요."); 176 | assertEquals(text`${"완삼"}여요.`, "완삼이어요."); 177 | }); 178 | 179 | Deno.test("@kokr/text, text 이에요/예요", () => { 180 | assertEquals(text`${"완두"}이에요.`, "완두예요."); 181 | assertEquals(text`${"완두"}예요.`, "완두예요."); 182 | 183 | assertEquals(text`${"완삼"}이에요.`, "완삼이에요."); 184 | assertEquals(text`${"완삼"}예요.`, "완삼이에요."); 185 | }); 186 | 187 | Deno.test("@kokr/text, text digit", () => { 188 | assertEquals(text`정답은 ${"100"}과 같다.`, "정답은 100과 같다."); 189 | assertEquals(text`정답은 ${"100"}와 같다.`, "정답은 100과 같다."); 190 | 191 | assertEquals(text`정답은 ${"72"}과 같다.`, "정답은 72와 같다."); 192 | assertEquals(text`정답은 ${"72"}와 같다.`, "정답은 72와 같다."); 193 | }); 194 | 195 | Deno.test("@kokr/text, text english words", () => { 196 | assertEquals(text`${"Apple"}는 코딩 합니다.`, "Apple은 코딩 합니다."); 197 | assertEquals(text`${"Apple"}은 코딩 합니다.`, "Apple은 코딩 합니다."); 198 | 199 | assertEquals(text`${"Banana"}는 코딩 합니다.`, "Banana는 코딩 합니다."); 200 | assertEquals(text`${"Banana"}은 코딩 합니다.`, "Banana는 코딩 합니다."); 201 | }); 202 | 203 | Deno.test("@kokr/text, text with paren", () => { 204 | assertEquals( 205 | text`${"코코넛(바나나)"}은 코딩 합니다.`, 206 | "코코넛(바나나)은 코딩 합니다.", 207 | ); 208 | assertEquals( 209 | text`${"코코넛(바나나)"}는 코딩 합니다.`, 210 | "코코넛(바나나)은 코딩 합니다.", 211 | ); 212 | }); 213 | 214 | Deno.test("@kokr/text, dedent", () => { 215 | function printResult(winner: string, loser: string) { 216 | return text` 217 | 결과는 다음과 같습니다. 218 | 219 | - 승: ${winner} 220 | - 패: ${loser} 221 | 222 | ${winner}님 축하드립니다! 223 | `; 224 | } 225 | 226 | assertEquals( 227 | printResult("완두", "완삼"), 228 | "결과는 다음과 같습니다.\n\n- 승: 완두\n- 패: 완삼\n\n완두님 축하드립니다!", 229 | ); 230 | }); 231 | -------------------------------------------------------------------------------- /text/text.ts: -------------------------------------------------------------------------------- 1 | import { jongseong } from "./jongseong.ts"; 2 | 3 | const allowWithoutJongseong = new Set([0]); 4 | const allowWithLieul = new Set([0, 8]); 5 | const patterns: [allowed: Set, valid: string, invalid: string][] = [ 6 | // 3 7 | [allowWithoutJongseong, "이어요", "여요"], 8 | [allowWithoutJongseong, "이에요", "예요"], 9 | 10 | // 2 11 | [allowWithoutJongseong, "이나", "나"], 12 | [allowWithoutJongseong, "이다", "다"], 13 | [allowWithoutJongseong, "이든", "든"], 14 | [allowWithoutJongseong, "이라", "라"], 15 | [allowWithoutJongseong, "이란", "란"], 16 | [allowWithoutJongseong, "이랑", "랑"], 17 | [allowWithLieul, "으로", "로"], 18 | [allowWithoutJongseong, "이며", "며"], 19 | [allowWithoutJongseong, "이셨", "셨"], 20 | [allowWithoutJongseong, "이시", "시"], 21 | [allowWithoutJongseong, "이야", "야"], 22 | [allowWithoutJongseong, "이여", "여"], 23 | [allowWithoutJongseong, "이었", "였"], 24 | 25 | // 1 26 | [allowWithoutJongseong, "은", "는"], 27 | [allowWithoutJongseong, "이", "가"], 28 | [allowWithoutJongseong, "을", "를"], 29 | [allowWithoutJongseong, "과", "와"], 30 | [allowWithoutJongseong, "아", "야"], 31 | ]; 32 | 33 | const detectPattern = patterns.map(([_, valid, invalid]) => { 34 | return `(${valid}|${invalid})`; 35 | }).join("|"); 36 | const RE_DETECT = new RegExp(`^(?:${detectPattern})`); 37 | 38 | /** 39 | * @example 40 | * let userName = "완두"; 41 | * 42 | * // 은/는 43 | * text`${userName}는 코딩 합니다.` // 완두는 코딩 합니다. 44 | * text`${userName}은 코딩 합니다.` // 완두는 코딩 합니다. 45 | * 46 | * // 이/가 47 | * text`${userName}가 코딩 했습니다.` // 완두가 코딩 했습니다. 48 | * text`${userName}이 코딩 했습니다.` // 완두가 코딩 했습니다. 49 | * 50 | * // 을/를 51 | * text`${userName}을 가르쳤습니다.` // 완두를 가르쳤습니다. 52 | * text`${userName}를 가르쳤습니다.` // 완두를 가르쳤습니다. 53 | * 54 | * // 와/과 55 | * text`${userName}와 코딩을 했습니다.` // 완두와 코딩을 했습니다. 56 | * text`${userName}과 코딩을 했습니다.` // 완두와 코딩을 했습니다. 57 | * 58 | * // 아/야 59 | * text`${userName}아!` // 완두야! 60 | * text`${userName}야!` // 완두야! 61 | * 62 | * // 이었/였 63 | * text`${userName}이었어요.` // 완두였어요. 64 | * text`${userName}였어요.` // 완두였어요. 65 | * 66 | * // 이에요/예요 67 | * text`${userName}이에요.` // 완두예요. 68 | * text`${userName}예요.` // 완두예요. 69 | * 70 | * // 이어요/여요 71 | * text`${userName}이어요.` // 완두여요. 72 | * text`${userName}여요.` // 완두여요. 73 | * 74 | * // 로/으로 75 | * let place = "대구"; 76 | * text`${place}으로 갑시다.` // 대구로 갑시다. 77 | * text`${place}로 갑시다.` // 대구로 갑시다. 78 | * 79 | * place = "부산"; 80 | * text`${place}으로 갑시다.` // 부산으로 갑시다. 81 | * text`${place}로 갑시다.` // 부산으로 갑시다. 82 | * 83 | * place = "서울"; // ㄹ로 끝나는 경우 84 | * text`${place}으로 갑시다.` // 서울로 갑시다. 85 | * text`${place}로 갑시다.` // 서울로 갑시다. 86 | */ 87 | export function text( 88 | strings: TemplateStringsArray, 89 | ...interpolation: unknown[] 90 | ): string { 91 | return dedent(strings.reduce( 92 | (acc: string, string: string, index: number) => { 93 | string = string.normalize(); // normalize NFC 94 | const word = String(interpolation[index - 1]).normalize(); // normalize NFC 95 | 96 | const matched = string.match(RE_DETECT); 97 | if (matched) { 98 | const [allowed, valid, invalid] = 99 | patterns[[...matched].slice(1).findIndex((v) => v)]; 100 | 101 | const josa = allowed.has(jongseong(word)) ? invalid : valid; 102 | const remain = string.slice(matched[0].length); 103 | return `${acc}${word}${josa}${remain}`; 104 | } 105 | return `${acc}${word}${string}`; 106 | }, 107 | )); 108 | } 109 | 110 | /** @internal */ 111 | function dedent(text: string) { 112 | const lines = text.split("\n"); 113 | let min: number | undefined; 114 | for (const line of lines) { 115 | const m = line.match(/^(\s+)\S+/); 116 | if (m) { 117 | const indent = m[1].length; 118 | if (!min) { 119 | min = indent; 120 | } else { 121 | min = Math.min(min, indent); 122 | } 123 | } 124 | } 125 | 126 | if (typeof min === "number") { 127 | return lines.map((l) => l[0] === " " ? l.slice(min) : l).join("\n").trim(); 128 | } 129 | 130 | return text.trim(); 131 | } 132 | --------------------------------------------------------------------------------