@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 +98 -49
- package/bin/kiwimu +1 -1
- package/package.json +4 -1
- package/personas/namuwiki.json +6 -0
- package/src/build/renderer.ts +50 -2
- package/src/build/static/search.js +33 -2
- package/src/build/static/style.css +84 -1
- package/src/build/templates.ts +353 -167
- package/src/config.ts +35 -29
- package/src/demo/sample-data.ts +70 -0
- package/src/demo/setup.ts +31 -0
- package/src/expand/llm.ts +1 -1
- package/src/index.ts +234 -458
- package/src/ingest/docx.ts +0 -8
- package/src/ingest/legacy.ts +4 -4
- package/src/ingest/pdf.ts +1 -1
- package/src/ingest/pptx.ts +0 -1
- package/src/ingest/web.test.ts +41 -0
- package/src/ingest/web.ts +61 -62
- package/src/llm-client.ts +203 -126
- package/src/pipeline/chunker.test.ts +42 -0
- package/src/pipeline/chunker.ts +1 -48
- package/src/pipeline/llm-chunker.ts +144 -59
- package/src/server.ts +327 -0
- package/src/services/ingest.ts +100 -0
- package/src/store.test.ts +132 -0
- package/src/store.ts +206 -2
- package/src/pipeline/llm-linker.ts +0 -84
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
전공책, PDF, 웹 콘텐츠를 넣으면 — LLM이 챕터/개념별로 분석하여 상호 링크된 학습 위키를 자동 생성합니다.
|
|
10
10
|
|
|
11
|
+
[](https://www.npmjs.com/package/@open330/kiwimu)
|
|
11
12
|
[](https://bun.sh)
|
|
12
13
|
[](https://typescriptlang.org)
|
|
13
14
|
[](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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
161
|
-
| `kiwimu
|
|
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
|
|
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 ]
|
|
226
|
+
[ Ingest ] ── Cheerio / pdf-parse / mammoth / jszip / textutil
|
|
190
227
|
↓
|
|
191
|
-
[ Phase 1 ]
|
|
228
|
+
[ Phase 1 ] ── LLM: 원본 구조 추출 (📖 원본 페이지) — 병렬 처리 (concurrency=3)
|
|
192
229
|
↓
|
|
193
|
-
[ Phase 2 ]
|
|
230
|
+
[ Phase 2 ] ── LLM: 개념 추출 (📝 개념 페이지)
|
|
194
231
|
↓
|
|
195
|
-
[ Phase
|
|
232
|
+
[ Phase 2.5 ] ── LLM: 학습 퀴즈 자동 생성 (📝 퀴즈) — 병렬 처리
|
|
196
233
|
↓
|
|
197
|
-
[
|
|
234
|
+
[ Phase 3 ] ── [[wiki link]] 해석 + 원본↔개념 cross-link
|
|
198
235
|
↓
|
|
199
|
-
[
|
|
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 # 홈 (문서
|
|
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
|
-
|
|
2
|
+
import "../src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open330/kiwimu",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/build/renderer.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|