@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 +1 -1
- package/package.json +4 -1
- package/personas/namuwiki.json +6 -0
- package/src/build/renderer.ts +49 -2
- package/src/build/static/search.js +33 -2
- package/src/build/static/style.css +84 -1
- package/src/build/templates.ts +297 -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 +208 -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 +133 -55
- package/src/server.ts +327 -0
- package/src/services/ingest.ts +100 -0
- package/src/store.test.ts +132 -0
- package/src/store.ts +102 -2
- package/src/pipeline/llm-linker.ts +0 -84
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.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
|
+
}
|
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,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
|
}
|