diff --git a/src/features/tech-log/presentation/studio/components/slug-from-name.ts b/src/features/tech-log/presentation/studio/components/slug-from-name.ts new file mode 100644 index 0000000..d6eb2e5 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/slug-from-name.ts @@ -0,0 +1,59 @@ +/** + * 이름에서 slug 를 만든다. + * + *
원래는 `[^a-z0-9]` 를 전부 하이픈으로 바꿨다. 한글은 전부 거기 걸리므로 이름에 섞인 영문과 + * 숫자만 남았고, 결과는 두 가지로 나빴다 — `인증` 은 빈 문자열이 되어 저장 자체가 막혔고, + * `Redis 캐시` 와 `Redis 클러스터` 는 둘 다 `redis` 가 되어 두 번째 주제가 "같은 slug 가 이미 + * 있습니다" 로 거절됐다. 작성자에게는 "가끔 안 되다가 이름을 바꾸면 되는" 현상으로 보인다. + * + *
그래서 한글을 버리지 않고 로마자로 옮긴다. 한글 음절은 (초성, 중성, 종성) 로 산술 분해되므로 + * 표가 필요 없고 결과가 결정적이다. 표기법은 국어의 로마자 표기법의 자모 대응만 쓴다 — 음운 변동 + * (자음동화 같은 것) 은 반영하지 않는다. slug 는 읽히기 위한 것이지 발음을 옮기기 위한 것이 아니고, + * 변동 규칙을 넣으면 같은 이름이 문맥에 따라 다른 slug 가 될 수 있다. + * + *
결과는 문서 slug 와 같은 모양이다 (`^[a-z0-9]+(?:-[a-z0-9]+)*$`) — 한 저장소가 두 가지 slug + * 규칙을 갖지 않도록. + */ + +const SYLLABLE_BASE = 0xac00; +const SYLLABLE_LAST = 0xd7a3; +const MEDIAL_COUNT = 21; +const FINAL_COUNT = 28; + +const INITIALS = [ + "g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s", + "ss", "", "j", "jj", "ch", "k", "t", "p", "h", +] as const; + +const MEDIALS = [ + "a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa", + "wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i", +] as const; + +const FINALS = [ + "", "k", "k", "ks", "n", "nj", "nh", "t", "l", "lg", + "lm", "lb", "ls", "lt", "lp", "lh", "m", "b", "bs", "s", + "ss", "ng", "j", "ch", "k", "t", "p", "h", +] as const; + +/** 한글 자모가 아닌 문자는 그대로 돌려준다 — 뒤의 필터가 처리한다. */ +function romanizeSyllable(codePoint: number): string { + if (codePoint < SYLLABLE_BASE || codePoint > SYLLABLE_LAST) { + return String.fromCodePoint(codePoint); + } + const offset = codePoint - SYLLABLE_BASE; + const initial = Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)); + const medial = Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT); + const final = offset % FINAL_COUNT; + return `${INITIALS[initial]}${MEDIALS[medial]}${FINALS[final]}`; +} + +export function slugFromName(value: string): string { + const romanized = [...value.trim()] + .map((character) => romanizeSyllable(character.codePointAt(0) ?? 0)) + .join(""); + return romanized + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} diff --git a/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx b/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx index 884b86b..879f5d2 100644 --- a/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx +++ b/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts"; +import { slugFromName } from "./slug-from-name.ts"; import { useStudio } from "../use-studio.ts"; /** @@ -51,15 +52,11 @@ export function TaxonomyManager() { }, [managementGateway, generation]); /** - * slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 slug 는 ASCII 만 받으므로, 비운 채로 - * 저장하면 서버가 422 로 거절한다 — 사용자가 규칙을 몰라도 되도록 여기서 채운다. + * slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 이전 규칙은 한글을 전부 버려서, 이름에 + * 섞인 영문·숫자만 남았다 — `인증` 은 빈 slug 가 되고 `Redis 캐시` 와 `Redis 클러스터` 는 둘 다 + * `redis` 가 됐다. 지금은 로마자로 옮긴다 ({@link slugFromName}). */ - const slugify = (value: string) => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/gu, "-") - .replace(/^-+|-+$/gu, ""); + const slugify = slugFromName; const submitTopic = async (event: FormEvent) => { event.preventDefault(); diff --git a/tests/unit/slug-from-name.test.ts b/tests/unit/slug-from-name.test.ts new file mode 100644 index 0000000..ca76124 --- /dev/null +++ b/tests/unit/slug-from-name.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { slugFromName } from "../../src/features/tech-log/presentation/studio/components/slug-from-name.ts"; + +/** + * 이 파일은 작성자가 본 증상에서 나왔다 — "가끔 이미 있는 slug 라고 거절당하는데 이름을 바꾸면 + * 된다". 간헐적으로 보였을 뿐 규칙은 결정적이었다: 이전 구현이 한글을 전부 버려서, 서로 다른 두 + * 주제가 이름에 섞인 같은 영단어 하나로 같은 slug 가 됐다. + */ +describe("이름에서 slug 만들기", () => { + it("한글만으로 된 이름도 slug 를 만든다", () => { + // 이전 규칙에서는 빈 문자열이었고, 폼이 저장 자체를 막았다. + expect(slugFromName("인증")).toBe("injeung"); + expect(slugFromName("백엔드 아키텍처")).toBe("baekendeu-akitekcheo"); + }); + + it("영단어를 공유하는 다른 이름이 다른 slug 가 된다", () => { + // 작성자가 실제로 부딪힌 충돌. 둘 다 "redis" 였다. + expect(slugFromName("Redis 캐시")).not.toBe(slugFromName("Redis 클러스터")); + }); + + it("숫자를 공유하는 다른 이름도 부딪히지 않는다", () => { + expect(slugFromName("검증 주제 16089")).toBe("geomjeung-juje-16089"); + expect(slugFromName("다른 주제 16089")).toBe("dareun-juje-16089"); + }); + + it("문서 slug 와 같은 모양을 지킨다", () => { + // 백엔드의 WorkingCopyInputValidator 가 문서 slug 에 요구하는 것과 같은 정규식이다. + const shape = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + for (const name of ["인증", "Redis 캐시", "JPA 최적화", "읽기 전용 복제본", " 공백 둘러싼 이름 "]) { + expect(slugFromName(name)).toMatch(shape); + } + }); + + it("받침과 겹받침을 자모 그대로 옮긴다", () => { + expect(slugFromName("한글")).toBe("hangeul"); + expect(slugFromName("읽기")).toBe("ilggi"); + }); + + it("옮길 것이 없으면 빈 문자열이고, 폼이 그때 slug 를 요구한다", () => { + expect(slugFromName("!!!")).toBe(""); + expect(slugFromName(" ")).toBe(""); + }); +});