@open330/kiwimu 0.4.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  전공책, PDF, 웹 콘텐츠를 넣으면 — LLM이 챕터/개념별로 분석하여 상호 링크된 학습 위키를 자동 생성합니다.
10
10
 
11
+ [![npm](https://img.shields.io/npm/v/@open330/kiwimu?style=flat-square&color=cb3837&logo=npm)](https://www.npmjs.com/package/@open330/kiwimu)
11
12
  [![Bun](https://img.shields.io/badge/Bun-1.0+-fbf0df?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
12
13
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://typescriptlang.org)
13
14
  [![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE)
@@ -16,16 +17,28 @@
16
17
 
17
18
  ---
18
19
 
20
+ ## 30초 데모 체험
21
+
22
+ API key 없이 바로 체험할 수 있습니다:
23
+
24
+ ```bash
25
+ mkdir my-wiki && cd my-wiki
26
+ bunx @open330/kiwimu init --demo
27
+ # → 양자역학 샘플 위키 + 학습 퀴즈가 즉시 생성됩니다
28
+ # → http://localhost:8000 에서 확인하세요
29
+ ```
30
+
31
+ ---
32
+
19
33
  ## Quickstart for Agents
20
34
 
21
35
  <div><img src="https://quickstart-for-agents.vercel.app/api/header.svg?theme=claude-code&logo=kiwimu&title=Set+up+a+learning+wiki+from+any+textbook+or+URL&lang=Agents&font=mono&mascot=hat" width="100%" /></div>
22
36
 
23
37
  ```
24
- git clone https://github.com/Open330/kiwimu.git && cd kiwimu && bun install
25
38
  mkdir my-wiki && cd my-wiki
26
- bunx kiwimu init
27
- bunx kiwimu add "<YOUR_URL_OR_PDF>"
28
- bunx kiwimu serve
39
+ bunx @open330/kiwimu init
40
+ bunx @open330/kiwimu add "<YOUR_URL_OR_PDF>"
41
+ bunx @open330/kiwimu serve
29
42
  ```
30
43
 
31
44
  <div><img src="https://quickstart-for-agents.vercel.app/api/footer.svg?theme=claude-code&text=copy+this+prompt+%C2%B7+paste+into+your+agent+%C2%B7+get+a+learning+wiki&font=mono" width="100%" /></div>
@@ -40,10 +53,14 @@ Kiwi Mu는 LLM을 활용해 이 연결을 **자동으로** 만들어, 지식을
40
53
  - **LLM 기반 문서 분석** — 챕터/섹션 구조를 보존한 원본 페이지 + 핵심 개념별 자동 생성 페이지
41
54
  - **원본/개념 분리** — 📖 원본 문서와 📝 개념 문서를 시각적으로 구분
42
55
  - **자동 상호 링크** — 원본↔개념 간 유기적 cross-link + 외부 참고 자료 (Wikipedia 등)
56
+ - **학습 퀴즈** — 개념 페이지 기반 빈칸 채우기 / OX / 단답형 퀴즈 자동 생성
43
57
  - **지식 그래프** — D3.js 인터랙티브 그래프 (원본: 파란색, 개념: 초록색)
58
+ - **데모 모드** — API key 없이 `--demo`로 즉시 체험
59
+ - **다양한 파일 지원** — URL, PDF, DOCX, PPTX, PPT, DOC, KEY, RTF
60
+ - **4개 LLM 프로바이더** — Google Gemini, Azure OpenAI, OpenAI, Anthropic
61
+ - **다크 모드** — 시스템 테마에 자동 대응
62
+ - **모바일 지원** — 햄버거 메뉴 + 슬라이드 사이드바
44
63
  - **웹 UI** — 브라우저에서 문서 추가, 설정 변경, 빌드 실행
45
- - **다양한 파일 지원** — URL, PDF, DOCX, PPTX, PPT, DOC, KEY
46
- - **다중 LLM 프로바이더** — Google Gemini, Azure OpenAI, OpenAI, Anthropic
47
64
  - **토큰 사용량 추적** — API 호출 수, 토큰, 예상 비용을 웹에서 확인
48
65
  - **원클릭 배포** — GitHub Pages / Vercel
49
66
 
@@ -52,15 +69,32 @@ Kiwi Mu는 LLM을 활용해 이 연결을 **자동으로** 만들어, 지식을
52
69
  ### 설치
53
70
 
54
71
  ```bash
55
- git clone https://github.com/Open330/kiwimu.git
56
- cd kiwimu && bun install
72
+ # npm/bunx로 바로 사용 (설치 불필요)
73
+ bunx @open330/kiwimu init
74
+
75
+ # 또는 글로벌 설치
76
+ bun add -g @open330/kiwimu
77
+ ```
78
+
79
+ ### 데모 모드 (API key 불필요)
80
+
81
+ ```bash
82
+ mkdir my-wiki && cd my-wiki
83
+ bunx @open330/kiwimu init --demo
57
84
  ```
58
85
 
86
+ 양자역학 샘플 위키가 생성되어 바로 체험할 수 있습니다:
87
+ - 📖 원본 문서 + 📝 개념 페이지
88
+ - 🔗 자동 상호 링크
89
+ - 📊 지식 그래프
90
+ - 📝 학습 퀴즈
91
+ - 🎲 임의 문서 탐험
92
+
59
93
  ### 프로젝트 생성 (Interactive CLI)
60
94
 
61
95
  ```bash
62
96
  mkdir my-wiki && cd my-wiki
63
- bunx kiwimu init
97
+ bunx @open330/kiwimu init
64
98
  ```
65
99
 
66
100
  Interactive 프롬프트가 실행됩니다:
@@ -86,68 +120,68 @@ Interactive 프롬프트가 실행됩니다:
86
120
  🥝 'Radio Astronomy Wiki' 위키가 생성되었습니다!
87
121
  ```
88
122
 
89
- 이름을 바로 지정할 수도 있습니다:
90
-
91
- ```bash
92
- bunx kiwimu init "My Study Wiki"
93
- ```
94
-
95
123
  ### 문서 추가
96
124
 
97
125
  ```bash
98
126
  # URL 추가
99
- bunx kiwimu add "https://www.cv.nrao.edu/~sransom/web/Ch1.html"
127
+ bunx @open330/kiwimu add "https://www.cv.nrao.edu/~sransom/web/Ch1.html"
100
128
 
101
- # PDF 추가
102
- bunx kiwimu add textbook.pdf
129
+ # 파일 추가 (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF)
130
+ bunx @open330/kiwimu add textbook.pdf
131
+ bunx @open330/kiwimu add lecture.pptx
103
132
  ```
104
133
 
105
134
  LLM이 문서를 분석하여:
106
135
  1. 📖 **원본 페이지** — 원래 챕터/섹션 구조 보존
107
136
  2. 📝 **개념 페이지** — 핵심 용어·정의·법칙 자동 생성
108
137
  3. 🔗 **Cross-link** — 원본↔개념 간 유기적 연결
138
+ 4. 📝 **퀴즈** — 개념별 학습 퀴즈 자동 생성
139
+
140
+ ### 학습 퀴즈
141
+
142
+ ```bash
143
+ # 터미널에서 퀴즈 풀기
144
+ bunx @open330/kiwimu quiz
145
+
146
+ # 문제 수 지정
147
+ bunx @open330/kiwimu quiz -n 10
148
+ ```
149
+
150
+ 웹에서도 `http://localhost:8000/quiz.html`에서 카드 플립 방식으로 퀴즈를 풀 수 있습니다.
109
151
 
110
152
  ### 빌드 및 서버
111
153
 
112
154
  ```bash
113
155
  # 정적 사이트 빌드
114
- bunx kiwimu build
156
+ bunx @open330/kiwimu build
115
157
 
116
158
  # 로컬 서버 실행 (웹에서 문서 추가 가능)
117
- bunx kiwimu serve
159
+ bunx @open330/kiwimu serve
118
160
  # → http://localhost:8000
119
161
 
120
162
  # 포트 변경
121
- bunx kiwimu serve -p 3000
122
-
123
- # 네트워크에 공개 (0.0.0.0)
124
- bunx kiwimu serve --host 0.0.0.0
163
+ bunx @open330/kiwimu serve -p 3000
125
164
  ```
126
165
 
127
- ### 웹 UI에서 문서 추가
128
-
129
- `kiwimu serve` 실행 후 http://localhost:8000 에서:
130
- - **🔗 URL 탭** — URL 입력 후 추가
131
- - **📄 파일 업로드 탭** — PDF, DOCX, PPTX 등 드래그앤드롭 업로드
132
- - 진행 상태 실시간 표시, 완료 시 자동 새로고침
133
-
134
166
  ### 관리 페이지
135
167
 
136
- http://localhost:8000/admin 에서:
168
+ `kiwimu serve` 실행 후 콘솔에 표시되는 admin URL로 접속:
137
169
  - 위키 이름 변경
138
170
  - LLM 프로바이더/모델/API Key 설정
139
171
  - 토큰 사용량 및 예상 비용 확인
140
- - 등록된 소스 목록
172
+ - 파일 업로드 (PDF, DOCX, PPTX 등)
173
+ - URL 추가
141
174
  - 수동 빌드 실행
175
+ - 페르소나 관리
142
176
 
143
177
  ### 배포
144
178
 
145
179
  ```bash
146
180
  # GitHub Pages (기본)
147
- bunx kiwimu deploy
181
+ bunx @open330/kiwimu deploy
148
182
 
149
183
  # Vercel
150
- bunx kiwimu deploy --target vercel
184
+ bunx @open330/kiwimu deploy --target vercel
151
185
  ```
152
186
 
153
187
  ## Commands
@@ -155,10 +189,13 @@ bunx kiwimu deploy --target vercel
155
189
  | 명령 | 설명 |
156
190
  |------|------|
157
191
  | `kiwimu init [name]` | 새 위키 프로젝트 생성 (interactive CLI) |
158
- | `kiwimu add <source>` | URL 또는 파일 추가 (LLM 분석 + 링크 생성) |
192
+ | `kiwimu init --demo` | 샘플 데이터로 즉시 체험 (API key 불필요) |
193
+ | `kiwimu add <source>` | URL 또는 파일 추가 (PDF, DOCX, PPTX, DOC, PPT, KEY, RTF) |
159
194
  | `kiwimu build` | 정적 위키 사이트 빌드 |
160
- | `kiwimu serve [-p port] [--host host]` | 웹 서버 실행 (문서 추가/관리 가능) |
161
- | `kiwimu deploy` | GitHub Pages / Vercel에 배포 |
195
+ | `kiwimu serve [-p port]` | 웹 서버 실행 (문서 추가/관리 가능) |
196
+ | `kiwimu quiz [-n count]` | 터미널에서 학습 퀴즈 풀기 |
197
+ | `kiwimu expand [--provider]` | LLM으로 문서 내용 확장 (선택) |
198
+ | `kiwimu deploy [--target]` | GitHub Pages / Vercel에 배포 |
162
199
  | `kiwimu status` | 현재 위키 상태 표시 |
163
200
 
164
201
  ## Supported File Formats
@@ -178,36 +215,40 @@ bunx kiwimu deploy --target vercel
178
215
  |-----------|----------|------|
179
216
  | **Google Gemini** | `gemini-2.0-flash-lite` | [무료 API key](https://aistudio.google.com/) |
180
217
  | Azure OpenAI | `gpt-5-nano` | Azure 구독 필요 |
181
- | OpenAI | `gpt-4o-mini` | API key 필요 |
218
+ | OpenAI | `gpt-4o` | API key 필요 |
182
219
  | Anthropic | `claude-sonnet-4-20250514` | API key 필요 |
183
220
 
184
221
  ## Architecture
185
222
 
186
223
  ```
187
- 소스 (URL / PDF / DOCX / PPTX)
224
+ 소스 (URL / PDF / DOCX / PPTX / DOC / PPT / KEY / RTF)
188
225
 
189
- [ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip
226
+ [ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip / textutil
190
227
 
191
- [ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지)
228
+ [ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지) — 병렬 처리 (concurrency=3)
192
229
 
193
- [ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
230
+ [ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
194
231
 
195
- [ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
232
+ [ Phase 2.5 ] ── LLM: 학습 퀴즈 자동 생성 (📝 퀴즈) — 병렬 처리
196
233
 
197
- [ Build ] ── 정적 HTML 생성 (탭 사이드바, KaTeX, 지식 그래프)
234
+ [ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
198
235
 
199
- [ Deploy ] ── GitHub Pages / Vercel
236
+ [ Build ] ── 정적 HTML (사이드바, KaTeX, 지식 그래프, 퀴즈, 다크 모드)
237
+
238
+ [ Deploy ] ── GitHub Pages / Vercel
200
239
  ```
201
240
 
202
241
  ```
203
242
  project-dir/
204
243
  ├── kiwi.toml # 프로젝트 + LLM 설정
205
- ├── kiwi.db # SQLite (문서, 링크, 사용량)
244
+ ├── kiwi.db # SQLite (문서, 링크, 퀴즈, 사용량)
206
245
  ├── uploads/ # 업로드된 파일
207
246
  └── _site/ # 빌드 결과
208
- ├── index.html # 홈 (문서 추가 UI + 사용량 대시보드)
247
+ ├── index.html # 홈 (문서 목록)
209
248
  ├── graph.html # 지식 그래프
249
+ ├── quiz.html # 학습 퀴즈
210
250
  ├── wiki/ # 각 문서 페이지
251
+ │ └── random.html # 임의 문서
211
252
  ├── static/ # CSS, JS, 로고
212
253
  └── search-index.json
213
254
  ```
@@ -220,11 +261,19 @@ project-dir/
220
261
  - **Cheerio** — 웹 페이지 파싱
221
262
  - **Mammoth** — DOCX 파싱
222
263
  - **JSZip** — PPTX 파싱
223
- - **Marked** — Markdown → HTML
264
+ - **Marked** + **sanitize-html** — Markdown → 안전한 HTML
224
265
  - **D3.js** — 지식 그래프
225
266
  - **KaTeX** — 수학 수식 렌더링
226
267
  - **gh-pages** — GitHub Pages 배포
227
268
 
269
+ ## Security
270
+
271
+ - Bearer 토큰 인증 (serve 모드)
272
+ - SSRF 방지 (프라이빗 IP 차단, 리다이렉트 재검증)
273
+ - Path Traversal 방지 (resolve 검증)
274
+ - XSS 방지 (sanitize-html, CSP 헤더, escapeHtml)
275
+ - 파일 업로드 제한 (50MB)
276
+
228
277
  ## License
229
278
 
230
279
  MIT
package/bin/kiwimu CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env bun
2
- await import("../src/index.ts");
2
+ import "../src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open330/kiwimu",
3
- "version": "0.4.1",
3
+ "version": "0.8.0",
4
4
  "description": "Turn textbooks, PDFs, and web content into your own interlinked learning wiki powered by LLM",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/**/*",
11
11
  "src/**/*",
12
+ "personas/**/*",
12
13
  "assets/**/*",
13
14
  "README.md",
14
15
  "LICENSE"
@@ -48,6 +49,7 @@
48
49
  "mammoth": "^1.12.0",
49
50
  "marked": "^15.0.0",
50
51
  "pdf-parse": "1.1.1",
52
+ "sanitize-html": "^2.17.2",
51
53
  "smol-toml": "^1.3.1",
52
54
  "turndown": "^7.2.0"
53
55
  },
@@ -58,6 +60,7 @@
58
60
  "devDependencies": {
59
61
  "@types/bun": "latest",
60
62
  "@types/pdf-parse": "^1.1.5",
63
+ "@types/sanitize-html": "^2.16.1",
61
64
  "@types/turndown": "^5.0.5"
62
65
  }
63
66
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "나무위키",
3
+ "description": "나무위키 특유의 문체와 스타일로 문서를 작성합니다",
4
+ "system_prompt": "당신은 나무위키 스타일의 위키 편집자입니다. 다음 특징을 반드시 지켜주세요:\n\n1. **문체**: 해요체(~입니다/~합니다)를 기본으로 하되, 가끔 반말(~이다/~한다)을 섞어 사용\n2. **유머**: 적절한 곳에 ~~취소선 드립~~, (괄호 안의 부연설명), [1] 각주 스타일의 코멘트를 삽입\n3. **강조**: 중요한 키워드는 **굵게** 처리하고, 핵심 개념은 반복 강조\n4. **서술 톤**: 백과사전적이면서도 친근한 톤. \"~라고 한다\", \"~라고 카더라\" 등의 표현 활용\n5. **구조**: 목차가 잘 정리된 체계적 구조. 소제목을 적극 활용\n6. **부가 정보**: \"여담으로~\", \"참고로~\", \"사실~\" 등의 표현으로 부가 정보 추가\n7. **링크**: 관련 개념에 적극적으로 [[위키 링크]]를 사용\n\n절대 딱딱한 교과서 문체로 쓰지 마세요. 읽는 사람이 재미있게 학습할 수 있도록 작성해주세요.",
5
+ "content_style": "Write in Korean 나무위키 style:\n- Use 해요체 with occasional 반말 mix\n- Add ~~strikethrough humor~~ and (parenthetical asides)\n- Bold **key terms** generously\n- Use phrases like \"~라고 한다\", \"여담으로~\", \"참고로~\"\n- Be encyclopedic yet friendly and entertaining\n- Structure with clear subsections\n- Use [[wiki links]] actively for related concepts"
6
+ }
@@ -1,10 +1,11 @@
1
1
  import { mkdirSync, rmSync, cpSync, existsSync } from "fs";
2
2
  import { join, dirname } from "path";
3
3
  import { marked } from "marked";
4
+ import sanitizeHtml from "sanitize-html";
4
5
  import type { KiwiConfig } from "../config";
5
6
  import type { Store } from "../store";
6
7
  import { buildGraphData } from "../pipeline/graph";
7
- import { renderPage, renderIndex, renderGraph } from "./templates";
8
+ import { renderPage, renderIndex, renderGraph, renderQuizPage } from "./templates";
8
9
 
9
10
  // Fix internal wiki links: /wiki/slug → /wiki/slug.html
10
11
  function fixWikiLinks(html: string): string {
@@ -68,14 +69,28 @@ export async function buildSite(store: Store, config: KiwiConfig, projectRoot: s
68
69
  const sourcePages = store.listSourcePages();
69
70
  const conceptPages = store.listConceptPages();
70
71
  const wikiName = config.project.name;
72
+ const backlinksMap = store.getAllBacklinksGrouped();
71
73
 
72
74
  for (const page of pages) {
73
75
  let htmlContent = await marked(page.content);
76
+ htmlContent = sanitizeHtml(htmlContent, {
77
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
78
+ 'img', 'details', 'summary', 'kbd', 'del', 's', 'sup', 'sub',
79
+ 'span', 'div', 'section', 'figure', 'figcaption', 'mark'
80
+ ]),
81
+ allowedAttributes: {
82
+ ...sanitizeHtml.defaults.allowedAttributes,
83
+ '*': ['id', 'class', 'style'],
84
+ 'img': ['src', 'alt', 'title', 'width', 'height'],
85
+ 'a': ['href', 'title', 'target', 'rel'],
86
+ },
87
+ allowedSchemes: ['http', 'https', 'mailto'],
88
+ });
74
89
  htmlContent = fixWikiLinks(htmlContent);
75
90
 
76
91
  const { body, externalRefs } = extractExternalRefs(htmlContent);
77
92
  const toc = generateToc(page.content);
78
- const backlinks = store.getBacklinks(page.id).map((bl) => ({
93
+ const backlinks = (backlinksMap.get(page.id) || []).map((bl) => ({
79
94
  slug: bl.slug,
80
95
  title: bl.title,
81
96
  pageType: bl.page_type,
@@ -116,6 +131,39 @@ export async function buildSite(store: Store, config: KiwiConfig, projectRoot: s
116
131
  })
117
132
  );
118
133
 
134
+ // Quiz page
135
+ const quizzes = store.getAllQuizzes();
136
+ await Bun.write(
137
+ join(outputDir, "quiz.html"),
138
+ renderQuizPage({
139
+ wikiName,
140
+ quizzes: quizzes.map((q) => ({
141
+ id: q.id,
142
+ question: q.question,
143
+ answer: q.answer,
144
+ explanation: q.explanation || "",
145
+ quiz_type: q.quiz_type,
146
+ page_title: q.page_title,
147
+ page_slug: q.page_slug,
148
+ })),
149
+ sourcePages: sourcePages.map((p) => ({ slug: p.slug, title: p.title })),
150
+ conceptPages: conceptPages.map((p) => ({ slug: p.slug, title: p.title })),
151
+ })
152
+ );
153
+
154
+ // Random page redirect
155
+ mkdirSync(join(wikiDir), { recursive: true });
156
+ await Bun.write(
157
+ join(wikiDir, "random.html"),
158
+ `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>임의 문서</title></head><body><script>
159
+ fetch('/search-index.json').then(r=>r.json()).then(pages=>{
160
+ const p = pages[Math.floor(Math.random()*pages.length)];
161
+ if(p) location.href='/wiki/'+p.slug+'.html';
162
+ else location.href='/';
163
+ });
164
+ </script></body></html>`
165
+ );
166
+
119
167
  const searchData = pages.map((p) => ({
120
168
  slug: p.slug,
121
169
  title: p.title,
@@ -5,6 +5,7 @@ document.addEventListener("DOMContentLoaded", async () => {
5
5
  if (!input || !dropdown) return;
6
6
 
7
7
  let searchData = [];
8
+ let selectedIndex = -1;
8
9
  try {
9
10
  const resp = await fetch("/search-index.json");
10
11
  searchData = await resp.json();
@@ -12,6 +13,12 @@ document.addEventListener("DOMContentLoaded", async () => {
12
13
  return;
13
14
  }
14
15
 
16
+ function escapeHtml(text) {
17
+ const div = document.createElement('div');
18
+ div.textContent = text;
19
+ return div.innerHTML;
20
+ }
21
+
15
22
  function fuzzyMatch(query, text) {
16
23
  query = query.toLowerCase();
17
24
  text = text.toLowerCase();
@@ -31,6 +38,7 @@ document.addEventListener("DOMContentLoaded", async () => {
31
38
  }
32
39
 
33
40
  input.addEventListener("input", () => {
41
+ selectedIndex = -1;
34
42
  const results = search(input.value);
35
43
  if (results.length === 0) {
36
44
  dropdown.classList.remove("active");
@@ -39,8 +47,8 @@ document.addEventListener("DOMContentLoaded", async () => {
39
47
  }
40
48
  dropdown.innerHTML = results.map(r =>
41
49
  `<a href="/wiki/${r.slug}.html">
42
- <strong>${r.title}</strong>
43
- <div style="font-size:12px;color:#6c757d;margin-top:2px;">${r.preview.slice(0, 80)}...</div>
50
+ <strong>${escapeHtml(r.title)}</strong>
51
+ <div style="font-size:12px;color:#6c757d;margin-top:2px;">${escapeHtml(r.preview.slice(0, 80))}...</div>
44
52
  </a>`
45
53
  ).join("");
46
54
  dropdown.classList.add("active");
@@ -61,6 +69,29 @@ document.addEventListener("DOMContentLoaded", async () => {
61
69
  if (e.key === "Escape") {
62
70
  dropdown.classList.remove("active");
63
71
  input.blur();
72
+ } else if (e.key === "ArrowDown") {
73
+ e.preventDefault();
74
+ const items = dropdown.querySelectorAll("a");
75
+ selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
76
+ items.forEach((a, i) => a.classList.toggle("selected", i === selectedIndex));
77
+ items[selectedIndex]?.scrollIntoView({ block: "nearest" });
78
+ } else if (e.key === "ArrowUp") {
79
+ e.preventDefault();
80
+ const items = dropdown.querySelectorAll("a");
81
+ selectedIndex = Math.max(selectedIndex - 1, 0);
82
+ items.forEach((a, i) => a.classList.toggle("selected", i === selectedIndex));
83
+ } else if (e.key === "Enter" && selectedIndex >= 0) {
84
+ e.preventDefault();
85
+ dropdown.querySelectorAll("a")[selectedIndex]?.click();
86
+ }
87
+ });
88
+
89
+ // Global "/" shortcut to focus search
90
+ document.addEventListener('keydown', (e) => {
91
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
92
+ if (e.key === '/') {
93
+ e.preventDefault();
94
+ input.focus();
64
95
  }
65
96
  });
66
97
  });
@@ -24,10 +24,18 @@
24
24
  margin: 0;
25
25
  padding: 0;
26
26
  box-sizing: border-box;
27
- border-radius: 0; /* Enforce sharp flat edges everywhere */
28
27
  box-shadow: none; /* Enforce no shadows */
29
28
  }
30
29
 
30
+ /* Targeted border-radius reset for kiwimu elements only */
31
+ .topbar, .sidebar, .content, .page-card, .toc-box,
32
+ .search-dropdown, .backlinks li a, .stat-card, .quick-link,
33
+ .file-drop, .add-form input, .add-form button,
34
+ .config-card, .badge, .page-type-badge, .page-body table,
35
+ .page-body blockquote, .page-body pre {
36
+ border-radius: 0;
37
+ }
38
+
31
39
  body {
32
40
  font-family: "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Malgun Gothic", sans-serif;
33
41
  color: var(--text);
@@ -137,6 +145,10 @@ a:hover {
137
145
  text-decoration: none;
138
146
  }
139
147
 
148
+ .search-dropdown a.selected {
149
+ background: var(--accent-light);
150
+ }
151
+
140
152
  .search-dropdown .search-highlight {
141
153
  color: var(--namu-green);
142
154
  font-weight: 600;
@@ -527,6 +539,16 @@ a:hover {
527
539
  margin-bottom: 28px;
528
540
  }
529
541
 
542
+ .empty-state {
543
+ grid-column: 1 / -1;
544
+ padding: 32px;
545
+ text-align: center;
546
+ color: var(--text-muted);
547
+ font-size: 14px;
548
+ border: 2px dashed var(--border);
549
+ margin: 8px 0;
550
+ }
551
+
530
552
  .page-card {
531
553
  display: block;
532
554
  padding: 10px 14px;
@@ -946,11 +968,49 @@ h4 .headerlink {
946
968
  background: var(--namu-green);
947
969
  }
948
970
 
971
+ /* Hamburger menu button */
972
+ .topbar-menu-btn {
973
+ display: none;
974
+ background: none;
975
+ border: none;
976
+ color: white;
977
+ font-size: 22px;
978
+ cursor: pointer;
979
+ padding: 0 8px;
980
+ }
981
+
982
+ /* Sidebar overlay */
983
+ .sidebar-overlay {
984
+ display: none;
985
+ }
986
+
949
987
  /* Responsive */
950
988
  @media (max-width: 768px) {
989
+ .topbar-menu-btn { display: flex; align-items: center; }
990
+
951
991
  .sidebar {
992
+ position: fixed;
993
+ left: -260px;
994
+ top: 46px;
995
+ bottom: 0;
996
+ width: 260px;
997
+ z-index: 99;
998
+ transition: left 0.25s ease;
999
+ background: var(--bg);
1000
+ display: block !important;
1001
+ }
1002
+ .sidebar.open { left: 0; }
1003
+
1004
+ .sidebar-overlay {
952
1005
  display: none;
1006
+ position: fixed;
1007
+ inset: 0;
1008
+ top: 46px;
1009
+ background: rgba(0,0,0,0.4);
1010
+ z-index: 98;
953
1011
  }
1012
+ .sidebar-overlay.active { display: block; }
1013
+
954
1014
  .content {
955
1015
  margin-left: 0;
956
1016
  padding: 16px 14px;
@@ -958,4 +1018,27 @@ h4 .headerlink {
958
1018
  .topbar-search {
959
1019
  max-width: 180px;
960
1020
  }
1021
+ }
1022
+
1023
+ /* Dark mode */
1024
+ @media (prefers-color-scheme: dark) {
1025
+ :root {
1026
+ --bg: #1a1a2e;
1027
+ --bg-alt: #16213e;
1028
+ --bg-hover: #0f3460;
1029
+ --text: #e0e0e0;
1030
+ --text-muted: #8e8e8e;
1031
+ --text-dark: #ffffff;
1032
+ --border: #2a2a4a;
1033
+ --accent-light: #1a3a3a;
1034
+ --namu-green: #00b4a6;
1035
+ --namu-green-dark: #008c7e;
1036
+ }
1037
+
1038
+ .topbar { background: #004d40; }
1039
+ .page-body h2 { background: #006050; }
1040
+ .page-card { background: var(--bg-alt); }
1041
+ .toc-box { background: var(--bg-alt); }
1042
+ .sidebar { background: var(--bg); border-right-color: var(--border); }
1043
+ img { opacity: 0.9; }
961
1044
  }