From 5cffe3020059a6064af6ca9b865c36d41b49ac40 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 21 Aug 2026 14:56:03 +0900 Subject: [PATCH] fix: derive a topic slug that survives a Korean name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a topic failed intermittently — "a topic with that slug already exists" — and worked when the author retried with a different name. The rule was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped everything else, so a Korean name contributed nothing. Whatever Latin word or number happened to be in it became the entire slug. Two ways that goes wrong, and the author hit both. `인증` reduced to an empty string, which the form refused before a request was ever sent. `Redis 캐시` and `Redis 클러스터` both reduced to `redis`, so the second one collided with the first — a real conflict, reported honestly, about a slug the author never chose and could not see. Hangul is now romanized rather than discarded. Syllables decompose arithmetically into initial, medial and final jamo, so this needs no table and is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the jamo mapping from Revised Romanization is applied — the sound-change rules are deliberately left out, because a slug is read, not pronounced, and those rules would make one name produce different slugs in different contexts. The output keeps the shape document slugs already use (`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than two, and the tests assert exactly that. --- .../studio/components/slug-from-name.ts | 59 +++++++++++++++++++ .../studio/components/taxonomy-manager.tsx | 13 ++-- tests/unit/slug-from-name.test.ts | 44 ++++++++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 src/features/tech-log/presentation/studio/components/slug-from-name.ts create mode 100644 tests/unit/slug-from-name.test.ts 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(""); + }); +});