terms: dark editor BG, vertical row layout, add/delete custom kinds
- 약관 편집기 배경/슬래시 메뉴를 사이트 다크 팔레트로 통일 (흰 배경 + 흰 글씨 가시성 문제 해결) - 약관 목록을 가로 풀폭 1줄씩 세로로 쌓이는 레이아웃으로 변경 - 사용자 정의 약관 추가/삭제 지원 - manifest/terms/_meta.json 에 라벨 저장 - builtin 5종(map/resourcepack/mod/installer/installer-rp)은 삭제 불가, "기본" 배지 표시 - kind 식별자 규칙: 소문자/숫자/하이픈 32자 이내 - 공개 라우트 /manifest/terms/<file>.md 는 isPublicTermsFile() 로 _meta.json 차단 - 0.3.0 → 0.3.1 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -296,17 +296,116 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// 화이트리스트로 5종만 허용한다.
|
||||
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
|
||||
export const TERM_KINDS: readonly TermKind[] = [
|
||||
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
|
||||
]
|
||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
export type TermKind = string
|
||||
|
||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
||||
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
|
||||
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
|
||||
|
||||
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
|
||||
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
|
||||
export function isTermKind(value: unknown): value is TermKind {
|
||||
return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value)
|
||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||
}
|
||||
|
||||
export function isBuiltinTermKind(value: string): value is BuiltinTermKind {
|
||||
return (BUILTIN_TERM_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
interface TermsMeta {
|
||||
/** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */
|
||||
customLabels: Record<string, string>
|
||||
}
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
|
||||
async function loadTermsMeta(): Promise<TermsMeta> {
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const customLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v
|
||||
}
|
||||
}
|
||||
return { customLabels }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTermsMeta(meta: TermsMeta): Promise<void> {
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
await fsp.writeFile(
|
||||
path.join(manifestTermsDirPath, TERMS_META_FILE),
|
||||
`${JSON.stringify(meta, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export interface TermItem {
|
||||
kind: string
|
||||
label: string
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다.
|
||||
* - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로).
|
||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
||||
*/
|
||||
export async function listTermsWithLabels(): Promise<TermItem[]> {
|
||||
const meta = await loadTermsMeta()
|
||||
const items: TermItem[] = []
|
||||
for (const kind of BUILTIN_TERM_KINDS) {
|
||||
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
|
||||
}
|
||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(manifestTermsDirPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const customKinds = new Set<string>()
|
||||
for (const fname of onDisk) {
|
||||
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||
const kind = fname.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
if (isBuiltinTermKind(kind)) continue
|
||||
customKinds.add(kind)
|
||||
}
|
||||
// _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시).
|
||||
for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) {
|
||||
if (!customKinds.has(kind)) continue
|
||||
items.push({ kind, label: meta.customLabels[kind], builtin: false })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(kind: string): Promise<string> {
|
||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
||||
const meta = await loadTermsMeta()
|
||||
return meta.customLabels[kind] ?? kind
|
||||
}
|
||||
|
||||
export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
if (!isTermKind(kind)) return ''
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8')
|
||||
@@ -317,12 +416,60 @@ export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
}
|
||||
|
||||
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
||||
export async function createTerm(kind: string, label: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
|
||||
const cleanLabel = label.trim()
|
||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||
const meta = await loadTermsMeta()
|
||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
throw new Error('term file already exists')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||
meta.customLabels[kind] = cleanLabel
|
||||
await saveTermsMeta(meta)
|
||||
}
|
||||
|
||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
||||
export async function deleteTerm(kind: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta()
|
||||
if (meta.customLabels[kind]) {
|
||||
delete meta.customLabels[kind]
|
||||
await saveTermsMeta(meta)
|
||||
}
|
||||
}
|
||||
|
||||
/** 공개 라우트(`/manifest/terms/<file>`)에서 호출. _meta.json 같은 시스템 파일을 차단하기 위함. */
|
||||
export function isPublicTermsFile(fileName: string): boolean {
|
||||
// .md 만 허용, 이름 규칙 일치, builtin 또는 정상 kind 패턴.
|
||||
if (!fileName.toLowerCase().endsWith('.md')) return false
|
||||
const kind = fileName.slice(0, -3)
|
||||
return TERM_KIND_RE.test(kind)
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
|
||||
Reference in New Issue
Block a user