2 Commits

Author SHA1 Message Date
25977d894b terms: per-pack storage + import from another pack (v0.3.2)
- store.ts: 약관을 manifest/terms/<packKey>/ 폴더별로 저장. 첫 접근 시
  legacy 전역 .md 파일을 시드로 자동 복사한다.
- importTerms() 추가: 다른 음악퀴즈의 .md + _meta.json 을 현재 pack 으로
  복사한다. 동일 kind 는 source 값으로 덮어쓴다.
- /op/agreement 라우트를 세 단계로 분리:
  · /op/agreement → 음악퀴즈 카드 선택 페이지
  · /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가 + 불러오기
  · /op/agreement/:packName/:kind → 에디터
- 공개 라우트도 /manifest/terms/:packKey/:fileName 으로 변경.
- 설치기 main.ts: state.selectedKey 를 약관 URL 에 포함하도록 수정 (메인 +
  rp 양쪽). pack 미선택 상태에서는 에러 반환.
- termsEditor.js: PACK_KEY 를 받아 저장 URL 에 포함.
- 다른 음악퀴즈 후보 select + 확인 모달 + locale 추가.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:29:04 +09:00
c14b0507c7 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>
2026-05-20 01:12:10 +09:00
12 changed files with 646 additions and 73 deletions

View File

@@ -136,6 +136,9 @@
"terms": { "terms": {
"browserTitle": "약관 수정", "browserTitle": "약관 수정",
"title": "약관 수정", "title": "약관 수정",
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
"packBrowserTitle": "{{name}} — 약관 수정",
"packTitle": "{{name}} 약관 수정",
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.", "hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
"editorBrowserTitle": "{{label}} 편집", "editorBrowserTitle": "{{label}} 편집",
"editorTitle": "{{label}}", "editorTitle": "{{label}}",
@@ -156,7 +159,29 @@
"slashDivider": "구분선", "slashDivider": "구분선",
"slashQuote": "인용", "slashQuote": "인용",
"slashCode": "코드", "slashCode": "코드",
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?" "leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
"builtinBadge": "기본",
"addHeading": "약관 추가",
"kindLabel": "식별자",
"kindPlaceholder": "예: privacy",
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
"labelLabel": "표시 이름",
"labelPlaceholder": "예: 개인정보 처리방침",
"addButton": "추가",
"deleteButton": "삭제",
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
"createFailed": "약관 추가 실패",
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
"importHeading": "다른 음악퀴즈에서 불러오기",
"importSourceLabel": "가져올 음악퀴즈",
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
"importButton": "불러오기",
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
"importFailed": "약관 불러오기 실패",
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
}, },
"datapack": { "datapack": {
"browserTitle": "데이터팩 수정", "browserTitle": "데이터팩 수정",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.0", "version": "0.3.2",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {

View File

@@ -1,7 +1,7 @@
/* Notion 스타일 약관 편집기 전용 스타일. /* Notion 스타일 약관 편집기 전용 스타일.
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기 * 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에 * 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
* 절대 위치로 띄운다. */ * 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
.termsEditorWrap { .termsEditorWrap {
position: relative; position: relative;
@@ -12,9 +12,10 @@
width: 100%; width: 100%;
min-height: 60vh; min-height: 60vh;
padding: 16px 18px; padding: 16px 18px;
border: 1px solid #d5d5d5; border: 1px solid var(--border, #30363d);
border-radius: 8px; border-radius: 8px;
background: #fff; background: var(--bg-card, #1f242c);
color: var(--text, #e6edf3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
@@ -22,19 +23,21 @@
box-sizing: border-box; box-sizing: border-box;
outline: none; outline: none;
white-space: pre-wrap; white-space: pre-wrap;
caret-color: var(--accent, #58a6ff);
} }
.termsEditor:focus { .termsEditor:focus {
border-color: #5b8def; border-color: var(--accent, #58a6ff);
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2); box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
} }
.termsPreview { .termsPreview {
min-height: 60vh; min-height: 60vh;
padding: 16px 18px; padding: 16px 18px;
border: 1px solid #d5d5d5; border: 1px solid var(--border, #30363d);
border-radius: 8px; border-radius: 8px;
background: #fafafa; background: var(--bg-alt, #161b22);
color: var(--text, #e6edf3);
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
box-sizing: border-box; box-sizing: border-box;
@@ -46,23 +49,30 @@
.termsPreview p { margin: 6px 0; } .termsPreview p { margin: 6px 0; }
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; } .termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
.termsPreview li { margin: 2px 0; } .termsPreview li { margin: 2px 0; }
.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; } .termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
.termsPreview blockquote { .termsPreview blockquote {
margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555; margin: 8px 0; padding: 4px 12px;
border-left: 3px solid var(--border, #30363d);
color: var(--text-muted, #8b949e);
} }
.termsPreview code { .termsPreview code {
background: #eee; padding: 1px 5px; border-radius: 4px; background: rgba(255, 255, 255, 0.08);
padding: 1px 5px; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px; font-size: 13px;
} }
.termsPreview pre { .termsPreview pre {
background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto; background: rgba(0, 0, 0, 0.4);
padding: 10px 12px; border-radius: 6px; overflow: auto;
} }
.termsPreview pre code { background: transparent; padding: 0; } .termsPreview pre code { background: transparent; padding: 0; }
.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; } .termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
.termsPreview details { .termsPreview details {
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px; margin: 6px 0;
background: #fff; padding: 4px 10px; border: 1px solid var(--border, #30363d);
border-radius: 6px;
background: var(--bg-card, #1f242c);
padding: 4px 10px;
} }
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; } .termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
@@ -73,10 +83,11 @@
min-width: 220px; min-width: 220px;
max-height: 280px; max-height: 280px;
overflow-y: auto; overflow-y: auto;
background: #fff; background: var(--bg-alt, #161b22);
border: 1px solid #ccc; color: var(--text, #e6edf3);
border: 1px solid var(--border, #30363d);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
padding: 4px; padding: 4px;
font-size: 13px; font-size: 13px;
} }
@@ -89,7 +100,7 @@
} }
.slashMenu .slashItem:hover, .slashMenu .slashItem:hover,
.slashMenu .slashItem.active { .slashMenu .slashItem.active {
background: #eef2ff; background: var(--bg-card, #1f242c);
} }
.slashMenu .slashItem strong { font-size: 13px; } .slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
.slashMenu .slashItem span { color: #888; font-size: 11px; } .slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }

View File

@@ -162,7 +162,7 @@
function save() { function save() {
status.classList.remove('error') status.classList.remove('error')
status.textContent = I18N.saving status.textContent = I18N.saving
fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), { fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editor.value }) body: JSON.stringify({ content: editor.value })

View File

@@ -252,14 +252,15 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
ipcMain.handle('rp:i18n:dict', () => localeDict) ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 약관 다운로드 ────────────────────────────── // ── IPC: 약관 다운로드 ──────────────────────────────
// 사이트가 /manifest/terms/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환. // 사이트가 /manifest/terms/<packKey>/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인 // rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다. // 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
ipcMain.handle('rp:terms:get', async (_event, kind: string) => { ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' } if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
try { try {
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(kind)}.md` const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
const buf = await fetchBuffer(url) const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') } return { ok: true, content: buf.toString('utf8') }
} catch (error) { } catch (error) {

View File

@@ -154,15 +154,18 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
return results return results
}) })
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다. // 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다. // 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => { ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
if (!TERM_KIND_WHITELIST.has(kind)) { if (!TERM_KIND_WHITELIST.has(kind)) {
return { ok: false, message: 'unknown term kind' } return { ok: false, message: 'unknown term kind' }
} }
if (!state.selectedKey) {
return { ok: false, message: 'pack not selected' }
}
try { try {
const url = `${state.baseUrl}/manifest/terms/${kind}.md` const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
const buf = await fetchBuffer(url) const buf = await fetchBuffer(url)
return { ok: true, content: buf.toString('utf8') } return { ok: true, content: buf.toString('utf8') }
} catch (error) { } catch (error) {

View File

@@ -6,6 +6,7 @@ import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath fileDirPath, viewsDirPath, publicDirPath
} from '../shared/paths.js' } from '../shared/paths.js'
import { isPublicTermsFile } from '../shared/store.js'
import { loadEnv } from '../shared/env.js' import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js' import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js' import { indexRouter } from './routes/index.js'
@@ -62,16 +63,17 @@ app.get('/manifest.json', (_req, res) => {
res.sendFile(manifestRootPath) res.sendFile(manifestRootPath)
}) })
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용. // 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
app.get('/manifest/terms/:fileName', (req, res) => { // 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
const fileName = req.params.fileName // _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md app.get('/manifest/terms/:packKey/:fileName', (req, res) => {
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) { const { packKey, fileName } = req.params
if (!isPublicTermsFile(packKey, fileName)) {
res.status(404).send('Not Found') res.status(404).send('Not Found')
return return
} }
res.type('text/markdown; charset=utf-8') res.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => { res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
if (!err || res.headersSent) return if (!err || res.headersSent) return
res.status(404).send('Not Found') res.status(404).send('Not Found')
}) })

View File

@@ -2,8 +2,15 @@ import { Router } from 'express'
import archiver from 'archiver' import archiver from 'archiver'
import { import {
createPack, createPack,
createTerm,
deletePackKeys, deletePackKeys,
deleteTerm,
getTermLabel,
importTerms,
isBuiltinTermKind,
isTermKind,
listPackKeys, listPackKeys,
listTermsWithLabels,
loadPackDefinition, loadPackDefinition,
loadPackList, loadPackList,
loadTerm, loadTerm,
@@ -13,11 +20,8 @@ import {
renamePack, renamePack,
sanitizePackKey, sanitizePackKey,
saveTerm, saveTerm,
savePackList, savePackList
isTermKind,
TERM_KINDS
} from '../../shared/store.js' } from '../../shared/store.js'
import type { TermKind } from '../../shared/store.js'
import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
@@ -301,33 +305,144 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
}) })
// ─── /op/agreement ───────────────────────────────────────────────────── // ─── /op/agreement ─────────────────────────────────────────────────────
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로 // 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다. // builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
const TERM_LABELS: Record<TermKind, string> = { // 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
'map': '맵 약관',
'resourcepack': '리소스팩 약관',
'mod': '모드 약관',
'installer': '설치기 약관',
'installer-rp': '리소스팩 설치기 약관'
}
opRouter.get('/op/agreement', requireAuth, (req, res) => { // /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] })) opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
res.render('op/terms', { userId: req.session.userId, items }) try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
res.render('op/terms', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
}) })
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => { // /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => {
try { try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const items = await listTermsWithLabels(packKey)
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
const allKeys = await listPackKeys()
const sourceCandidates = await Promise.all(
allKeys
.filter((k) => k !== packKey)
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
)
res.render('op/terms-pack', {
userId: req.session.userId,
packKey,
pack: definition,
items,
sourceCandidates
})
} catch (error) {
next(error)
}
})
opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
const label = pickFirstValue(req.body.label)
if (!isTermKind(kindInput)) {
res.status(400).send(t('terms.invalidKind'))
return
}
await createTerm(packKey, kindInput, label)
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
} catch (error) {
res.status(400).send((error as Error).message || t('terms.createFailed'))
}
})
opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
if (!sourceKey || sourceKey === packKey) {
res.status(400).send(t('terms.invalidImportSource'))
return
}
const sourceDefinition = await loadPackDefinition(sourceKey)
if (!sourceDefinition) {
res.status(404).send(t('terms.invalidImportSource'))
return
}
await importTerms(packKey, sourceKey)
res.redirect(`/op/agreement/${packKey}`)
} catch (error) {
res.status(400).send((error as Error).message || t('terms.importFailed'))
}
})
opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) {
res.status(400).send(t('terms.invalidKind'))
return
}
if (isBuiltinTermKind(kind)) {
res.status(400).send(t('terms.cannotDeleteBuiltin'))
return
}
await deleteTerm(packKey, kind)
res.redirect(`/op/agreement/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const kind = pickFirstValue(req.params.kind) const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) { if (!isTermKind(kind)) {
res.status(404).send(t('errors.unknown')) res.status(404).send(t('errors.unknown'))
return return
} }
const content = await loadTerm(kind) const content = await loadTerm(packKey, kind)
const label = await getTermLabel(packKey, kind)
res.render('op/termsEditor', { res.render('op/termsEditor', {
userId: req.session.userId, userId: req.session.userId,
packKey,
pack: definition,
kind, kind,
label: TERM_LABELS[kind], label,
content content
}) })
} catch (error) { } catch (error) {
@@ -335,15 +450,21 @@ opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
} }
}) })
opRouter.post('/op/agreement/:kind', requireAuth, async (req, res, next) => { opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
try { try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
const kind = pickFirstValue(req.params.kind) const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) { if (!isTermKind(kind)) {
res.status(404).json({ ok: false, message: t('errors.unknown') }) res.status(404).json({ ok: false, message: t('errors.unknown') })
return return
} }
const content = typeof req.body?.content === 'string' ? req.body.content : '' const content = typeof req.body?.content === 'string' ? req.body.content : ''
await saveTerm(kind, content) await saveTerm(packKey, kind, content)
res.json({ ok: true }) res.json({ ok: true })
} catch (error) { } catch (error) {
next(error) next(error)

View File

@@ -296,18 +296,177 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
// ─── Terms (Markdown 약관) ───────────────────────────────────────────── // ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일. // 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// 화이트리스트로 5종만 허용한다. // - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp' // - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
export const TERM_KINDS: readonly TermKind[] = [ // - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
'map', 'resourcepack', 'mod', 'installer', 'installer-rp' // - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
] // - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
export type TermKind = string
export function isTermKind(value: unknown): value is TermKind { /** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value) 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': '리소스팩 설치기 약관'
} }
export async function loadTerm(kind: TermKind): Promise<string> { const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
export function isTermKind(value: unknown): value is TermKind {
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'
function termsDirForPack(packKey: string): string {
return path.join(manifestTermsDirPath, packKey)
}
function isValidPackKey(packKey: string): boolean {
return typeof packKey === 'string'
&& packKey.length > 0
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
}
/**
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
*/
async function ensurePackTermsDir(packKey: string): Promise<string> {
const dir = termsDirForPack(packKey)
try {
await fsp.access(dir)
return dir
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await fsp.mkdir(dir, { recursive: true })
// 레거시 전역 파일을 시드로 복사.
try {
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
for (const ent of legacyEntries) {
if (!ent.isFile()) continue
const name = ent.name
if (name === TERMS_META_FILE) {
try {
await fsp.copyFile(
path.join(manifestTermsDirPath, name),
path.join(dir, name)
)
} catch { /* ignore */ }
continue
}
if (!name.toLowerCase().endsWith('.md')) continue
const kind = name.slice(0, -3)
if (!TERM_KIND_RE.test(kind)) continue
try {
await fsp.copyFile(
path.join(manifestTermsDirPath, name),
path.join(dir, name)
)
} catch { /* ignore */ }
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
return dir
}
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
const dir = await ensurePackTermsDir(packKey)
try {
const raw = await fsp.readFile(path.join(dir, 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(packKey: string, meta: TermsMeta): Promise<void> {
const dir = await ensurePackTermsDir(packKey)
await fsp.writeFile(
path.join(dir, 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(packKey: string): Promise<TermItem[]> {
const dir = await ensurePackTermsDir(packKey)
const meta = await loadTermsMeta(packKey)
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(dir)
} 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(packKey: string, kind: string): Promise<string> {
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
const meta = await loadTermsMeta(packKey)
return meta.customLabels[kind] ?? kind
}
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
if (!isTermKind(kind)) return ''
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
try { try {
return await fsp.readFile(filePath, 'utf8') return await fsp.readFile(filePath, 'utf8')
} catch (error) { } catch (error) {
@@ -316,13 +475,106 @@ export async function loadTerm(kind: TermKind): Promise<string> {
} }
} }
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> { export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
await fsp.mkdir(manifestTermsDirPath, { recursive: true }) if (!isTermKind(kind)) throw new Error('invalid term kind')
const filePath = path.join(manifestTermsDirPath, `${kind}.md`) const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
const normalized = (markdown ?? '').replace(/\r\n/g, '\n') const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8') await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
} }
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
export async function createTerm(packKey: string, 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(packKey)
if (meta.customLabels[kind]) throw new Error('term kind already exists')
const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${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(packKey, meta)
}
/** 사용자 정의 약관 삭제. builtin 은 거부. */
export async function deleteTerm(packKey: string, 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 dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
try {
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const meta = await loadTermsMeta(packKey)
if (meta.customLabels[kind]) {
delete meta.customLabels[kind]
await saveTermsMeta(packKey, meta)
}
}
/**
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
* - source 의 모든 .md 와 _meta.json 을 target 에 덮어쓴다.
* - target 에만 있던 사용자 정의 약관은 그대로 둔다 (source 에는 없으니 안 건드림).
* - 동일한 kind 가 source 에도 있다면 source 값으로 덮어씀.
*/
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
throw new Error('invalid pack key')
}
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
const sourceDir = await ensurePackTermsDir(sourcePackKey)
const targetDir = await ensurePackTermsDir(targetPackKey)
const sourceMeta = await loadTermsMeta(sourcePackKey)
const targetMeta = await loadTermsMeta(targetPackKey)
// source 의 .md 파일을 모두 target 으로 복사.
let entries: string[] = []
try {
entries = await fsp.readdir(sourceDir)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
for (const name of entries) {
if (!name.toLowerCase().endsWith('.md')) continue
const kind = name.slice(0, -3)
if (!TERM_KIND_RE.test(kind)) continue
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
}
// 사용자 정의 라벨도 source 기준으로 머지 (덮어쓰기).
const mergedLabels: Record<string, string> = { ...targetMeta.customLabels }
for (const [k, v] of Object.entries(sourceMeta.customLabels)) {
mergedLabels[k] = v
}
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
}
/**
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
* - _meta.json 같은 시스템 파일은 차단.
*/
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
if (!isValidPackKey(packKey)) return false
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[]> { export async function readAccounts(): Promise<AccountEntry[]> {
try { try {
const raw = await fsp.readFile(accountFilePath, 'utf8') const raw = await fsp.readFile(accountFilePath, 'utf8')

147
views/op/terms-pack.ejs Normal file
View File

@@ -0,0 +1,147 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
<style>
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
.termsRow {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
background: var(--bg-card);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 14px 18px;
}
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
.builtinBadge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
background: rgba(255,255,255,0.08); color: var(--text-muted);
font-size: 11px;
}
.termsSideBySide {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
}
@media (max-width: 900px) {
.termsSideBySide { grid-template-columns: 1fr; }
}
.termsSection {
background: var(--bg-card);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 16px 18px;
}
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
.termsAddForm input, .termsImportForm select {
background: var(--bg-alt); color: var(--text);
border: 1px solid var(--border, #30363d); border-radius: 6px;
padding: 8px 10px; font-size: 13px;
}
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
</style>
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('terms.packTitle', { name: pack.name }) %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
</section>
<p class="muted"><%= t('terms.hint') %></p>
<section class="termsList">
<% items.forEach(function (item) { %>
<article class="termsRow">
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
<div class="termsRowLabel">
<h2><%= item.label %></h2>
<% if (item.builtin) { %>
<span class="builtinBadge"><%= t('terms.builtinBadge') %></span>
<% } %>
</div>
<div class="termsRowSub"><%= item.kind %>.md</div>
</a>
<div class="termsRowActions">
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
<% if (!item.builtin) { %>
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
style="margin:0;">
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
</form>
<% } %>
</div>
</article>
<% }) %>
</section>
<section class="termsSideBySide">
<div class="termsSection">
<h2><%= t('terms.addHeading') %></h2>
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
<div class="field">
<label for="newKind"><%= t('terms.kindLabel') %></label>
<input id="newKind" name="kind" type="text" required
pattern="[a-z0-9][a-z0-9-]{0,31}"
placeholder="<%= t('terms.kindPlaceholder') %>" />
<span class="hint"><%= t('terms.kindHint') %></span>
</div>
<div class="field">
<label for="newLabel"><%= t('terms.labelLabel') %></label>
<input id="newLabel" name="label" type="text" required maxlength="50"
placeholder="<%= t('terms.labelPlaceholder') %>" />
</div>
<div class="formActions">
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
</div>
</form>
</div>
<div class="termsSection">
<h2><%= t('terms.importHeading') %></h2>
<% if (sourceCandidates.length === 0) { %>
<p class="muted"><%= t('terms.importEmpty') %></p>
<% } else { %>
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
<div class="field">
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
<select id="importSource" name="source" required>
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
<% sourceCandidates.forEach(function (cand) { %>
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
<% }) %>
</select>
<span class="hint"><%= t('terms.importHint') %></span>
</div>
<div class="formActions">
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
</div>
</form>
<% } %>
</div>
</section>
</main>
</body>
</html>

View File

@@ -17,14 +17,24 @@
</div> </div>
</section> </section>
<p class="muted"><%= t('terms.hint') %></p> <p class="muted"><%= t('terms.pickPackHint') %></p>
<section class="cardRow horizontalScroll"> <section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %> <% items.forEach(function (item) { %>
<article class="packCard"> <article class="packCard">
<a class="cardLink" href="/op/agreement/<%= item.kind %>"> <a class="cardLink" href="/op/agreement/<%= item.key %>">
<h2><%= item.label %></h2> <h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.kind %>.md</p> <p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a> </a>
</article> </article>
<% }) %> <% }) %>

View File

@@ -13,9 +13,9 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="dashboardHeader"> <section class="dashboardHeader">
<div> <div>
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a> <a class="ghostLink" href="/op/agreement/<%= packKey %>"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1> <h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
<p class="muted"><%= kind %>.md</p> <p class="muted"><%= pack.name %> · <%= kind %>.md</p>
</div> </div>
<div class="dirtyMark" id="dirty-mark" hidden>*</div> <div class="dirtyMark" id="dirty-mark" hidden>*</div>
</section> </section>
@@ -39,6 +39,7 @@
</main> </main>
<script> <script>
var PACK_KEY = <%- JSON.stringify(packKey) %>;
var TERM_KIND = <%- JSON.stringify(kind) %>; var TERM_KIND = <%- JSON.stringify(kind) %>;
var INITIAL = <%- JSON.stringify(content) %>; var INITIAL = <%- JSON.stringify(content) %>;
var I18N = <%- JSON.stringify(localeDict.terms) %>; var I18N = <%- JSON.stringify(localeDict.terms) %>;