@open330/kiwimu 0.4.1 → 0.7.1

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/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.7.1",
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,38 @@ 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
+ quiz_type: q.quiz_type,
145
+ page_title: q.page_title,
146
+ page_slug: q.page_slug,
147
+ })),
148
+ sourcePages: sourcePages.map((p) => ({ slug: p.slug, title: p.title })),
149
+ conceptPages: conceptPages.map((p) => ({ slug: p.slug, title: p.title })),
150
+ })
151
+ );
152
+
153
+ // Random page redirect
154
+ mkdirSync(join(wikiDir), { recursive: true });
155
+ await Bun.write(
156
+ join(wikiDir, "random.html"),
157
+ `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>임의 문서</title></head><body><script>
158
+ fetch('/search-index.json').then(r=>r.json()).then(pages=>{
159
+ const p = pages[Math.floor(Math.random()*pages.length)];
160
+ if(p) location.href='/wiki/'+p.slug+'.html';
161
+ else location.href='/';
162
+ });
163
+ </script></body></html>`
164
+ );
165
+
119
166
  const searchData = pages.map((p) => ({
120
167
  slug: p.slug,
121
168
  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
  }