├── .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 |
5 |
6 |
7 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
--------------------------------------------------------------------------------