@zkov/pi-md-viewer 0.1.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/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@zkov/pi-md-viewer",
3
+ "version": "0.1.0",
4
+ "description": "Local loopback Markdown viewer tool for pi with configurable browser launchers",
5
+ "author": "Zkov <zkov@yandex.ru>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/zkov96/ai-md-shower.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/zkov96/ai-md-shower/issues"
13
+ },
14
+ "homepage": "https://github.com/zkov96/ai-md-shower#readme",
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi",
19
+ "markdown",
20
+ "viewer",
21
+ "browser",
22
+ "playwright",
23
+ "windows",
24
+ "linux",
25
+ "macos",
26
+ "termux"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./dist/extensions/show-markdown.js"
37
+ ]
38
+ },
39
+ "scripts": {
40
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
41
+ "prepack": "npm run build",
42
+ "typecheck": "tsc --noEmit",
43
+ "test:runtime": "vitest run tests-ts",
44
+ "test:extension": "vitest run extensions/show-markdown.test.ts tests/viewer-actions.test.js",
45
+ "test": "npm run typecheck && npm run test:runtime && npm run test:extension"
46
+ },
47
+ "dependencies": {
48
+ "highlight.js": "^11.11.1",
49
+ "markdown-it": "^14.1.0",
50
+ "markdown-it-anchor": "^9.2.0",
51
+ "markdown-it-task-lists": "^2.1.1"
52
+ },
53
+ "bin": {
54
+ "mdview": "bin/mdview.js"
55
+ },
56
+ "files": [
57
+ "bin/mdview.js",
58
+ "dist/src-ts/",
59
+ "dist/extensions/show-markdown.js",
60
+ "extensions/show-markdown.ts",
61
+ "src/pi_md_viewer/static/",
62
+ "src/pi_md_viewer/templates/",
63
+ "README.md",
64
+ "LICENSE",
65
+ "package.json"
66
+ ],
67
+ "peerDependencies": {
68
+ "@earendil-works/pi-coding-agent": "*",
69
+ "typebox": "*"
70
+ },
71
+ "devDependencies": {
72
+ "@earendil-works/pi-coding-agent": "0.84.2",
73
+ "@types/node": "^24.0.0",
74
+ "typescript": "^5.9.0",
75
+ "typebox": "1.3.7",
76
+ "vitest": "^3.2.0"
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ export async function unloadAndMaybeClose({ remove, reload, close }) {
2
+ await remove();
3
+ const remaining = await reload();
4
+ if (remaining === 0) await close();
5
+ return remaining;
6
+ }
@@ -0,0 +1,252 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0a0d12;
4
+ --panel: #111720;
5
+ --soft: #18212d;
6
+ --text: #dce5f2;
7
+ --muted: #8d9bad;
8
+ --accent: #7cc4ff;
9
+ --accent2: #a78bfa;
10
+ --line: #263243;
11
+ --code: #090c11;
12
+ --danger: #ff8d9b;
13
+ }
14
+
15
+ * { box-sizing: border-box; }
16
+ html { scroll-behavior: smooth; }
17
+ body {
18
+ margin: 0;
19
+ background: radial-gradient(circle at 85% 0%, #152138 0, transparent 34rem), var(--bg);
20
+ color: var(--text);
21
+ font: 16px/1.72 system-ui, -apple-system, "Segoe UI", sans-serif;
22
+ }
23
+ button {
24
+ padding: .48rem .72rem;
25
+ border: 1px solid var(--line);
26
+ border-radius: 8px;
27
+ background: var(--soft);
28
+ color: var(--text);
29
+ cursor: pointer;
30
+ }
31
+ button:hover { border-color: var(--accent); }
32
+ button:disabled { cursor: default; opacity: .45; }
33
+
34
+ .progress {
35
+ position: fixed;
36
+ z-index: 30;
37
+ top: 0;
38
+ left: 0;
39
+ width: 0;
40
+ height: 3px;
41
+ background: linear-gradient(90deg, var(--accent2), var(--accent));
42
+ box-shadow: 0 0 12px var(--accent);
43
+ }
44
+ .mobile-bar { display: none; }
45
+ .layout {
46
+ display: grid;
47
+ grid-template-columns: 240px minmax(0, 820px) 210px;
48
+ justify-content: center;
49
+ gap: 36px;
50
+ padding: 36px 24px 100px;
51
+ }
52
+ .files-panel, .toc-panel {
53
+ position: sticky;
54
+ top: 28px;
55
+ align-self: start;
56
+ max-height: calc(100vh - 56px);
57
+ overflow: auto;
58
+ padding: 18px;
59
+ border: 1px solid var(--line);
60
+ border-radius: 16px;
61
+ background: rgba(17, 23, 32, .86);
62
+ backdrop-filter: blur(14px);
63
+ }
64
+ .brand {
65
+ margin-bottom: 18px;
66
+ color: #fff;
67
+ font-size: 12px;
68
+ font-weight: 750;
69
+ letter-spacing: .08em;
70
+ text-transform: uppercase;
71
+ }
72
+ .panel-heading {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ margin-bottom: 10px;
77
+ color: var(--muted);
78
+ font-size: 12px;
79
+ font-weight: 700;
80
+ letter-spacing: .06em;
81
+ text-transform: uppercase;
82
+ }
83
+ .icon-button { padding: 2px 8px; font-size: 18px; }
84
+ .danger { color: var(--danger); }
85
+ .file-button {
86
+ display: block;
87
+ width: 100%;
88
+ margin: 4px 0;
89
+ padding: 9px 10px;
90
+ overflow: hidden;
91
+ border-color: transparent;
92
+ background: transparent;
93
+ text-align: left;
94
+ text-overflow: ellipsis;
95
+ white-space: nowrap;
96
+ }
97
+ .file-button small { display: block; color: var(--muted); }
98
+ .file-button.active {
99
+ border-color: rgba(124, 196, 255, .3);
100
+ background: rgba(124, 196, 255, .09);
101
+ color: #fff;
102
+ }
103
+ main { min-width: 0; padding-top: 4px; }
104
+ .document-toolbar {
105
+ display: flex;
106
+ align-items: end;
107
+ justify-content: space-between;
108
+ gap: 18px;
109
+ margin-bottom: 28px;
110
+ }
111
+ .document-toolbar h1 { margin: 0; font-size: clamp(1.7rem, 5vw, 2.6rem); }
112
+ .source-path {
113
+ margin-top: 5px;
114
+ color: var(--muted);
115
+ font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
116
+ overflow-wrap: anywhere;
117
+ }
118
+ .toolbar-actions { display: flex; gap: 8px; }
119
+ .notice, .error {
120
+ margin: 14px 0;
121
+ padding: 10px 14px;
122
+ border-radius: 9px;
123
+ }
124
+ .notice { border: 1px solid rgba(124, 196, 255, .3); background: rgba(124, 196, 255, .08); }
125
+ .error { border: 1px solid rgba(255, 141, 155, .35); background: rgba(255, 141, 155, .09); color: #ffd5da; }
126
+ .empty-state { padding: 2rem; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); }
127
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
128
+ position: relative;
129
+ color: #f5f8fc;
130
+ line-height: 1.22;
131
+ letter-spacing: -.025em;
132
+ scroll-margin-top: 24px;
133
+ }
134
+ .markdown-body h1 { margin: 0 0 32px; font-size: clamp(2rem, 6vw, 3.3rem); }
135
+ .markdown-body h2 { margin: 58px 0 18px; padding-top: 12px; border-top: 1px solid var(--line); font-size: 1.8rem; }
136
+ .markdown-body h3 { margin: 36px 0 12px; font-size: 1.28rem; }
137
+ .markdown-body h4 { margin: 28px 0 10px; font-size: 1.05rem; }
138
+ p { margin: 14px 0; }
139
+ ul, ol { margin: 12px 0 20px; padding-left: 26px; }
140
+ li { margin: 5px 0; }
141
+ li::marker { color: var(--accent2); }
142
+ a { color: var(--accent); }
143
+ strong { color: #fff; }
144
+ code {
145
+ padding: .16em .38em;
146
+ border: 1px solid #2a3749;
147
+ border-radius: 6px;
148
+ background: var(--soft);
149
+ color: #b9ddff;
150
+ font: .88em ui-monospace, SFMono-Regular, Consolas, monospace;
151
+ }
152
+ .code-block {
153
+ position: relative;
154
+ margin: 20px 0 26px;
155
+ overflow: hidden;
156
+ border: 1px solid var(--line);
157
+ border-radius: 13px;
158
+ background: var(--code);
159
+ box-shadow: 0 15px 50px rgba(0, 0, 0, .22);
160
+ }
161
+ .code-label {
162
+ padding: 8px 14px;
163
+ border-bottom: 1px solid var(--line);
164
+ background: #101620;
165
+ color: var(--muted);
166
+ font: 11px ui-monospace, SFMono-Regular, Consolas, monospace;
167
+ text-transform: uppercase;
168
+ }
169
+ pre { margin: 0; padding: 18px; overflow-x: auto; line-height: 1.55; }
170
+ pre code { padding: 0; border: 0; background: transparent; color: #c9d8ea; }
171
+ .copy-button {
172
+ position: absolute;
173
+ top: 7px;
174
+ right: 8px;
175
+ padding: 4px 9px;
176
+ }
177
+ blockquote {
178
+ margin: 18px 0;
179
+ padding: 12px 18px;
180
+ border-left: 3px solid var(--accent2);
181
+ background: rgba(167, 139, 250, .08);
182
+ color: #cbd5e1;
183
+ }
184
+ table { width: 100%; border-collapse: collapse; overflow: auto; }
185
+ th, td { padding: 8px 11px; border: 1px solid var(--line); text-align: left; }
186
+ th { background: var(--soft); }
187
+ hr { margin: 40px 0; border: 0; border-top: 1px solid var(--line); }
188
+ .toc-link {
189
+ display: block;
190
+ margin: 2px 0;
191
+ padding: 5px 7px;
192
+ border-left: 2px solid transparent;
193
+ border-radius: 5px;
194
+ color: var(--muted);
195
+ font-size: 12px;
196
+ line-height: 1.35;
197
+ text-decoration: none;
198
+ }
199
+ .toc-link.level-2 { padding-left: 13px; }
200
+ .toc-link.level-3 { padding-left: 23px; font-size: 11px; }
201
+ .toc-link:hover, .toc-link.active {
202
+ border-left-color: var(--accent);
203
+ background: rgba(124, 196, 255, .08);
204
+ color: var(--text);
205
+ }
206
+
207
+ @media (max-width: 1180px) {
208
+ .layout { grid-template-columns: 220px minmax(0, 820px); }
209
+ .toc-panel { display: none; }
210
+ }
211
+
212
+ @media (max-width: 980px) {
213
+ .mobile-bar {
214
+ position: sticky;
215
+ z-index: 20;
216
+ top: 0;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: space-between;
220
+ padding: 10px 16px;
221
+ border-bottom: 1px solid var(--line);
222
+ background: rgba(10, 13, 18, .94);
223
+ }
224
+ .layout { display: block; padding: 20px 18px 80px; }
225
+ .files-panel {
226
+ display: none;
227
+ position: relative;
228
+ top: auto;
229
+ max-height: 48vh;
230
+ margin: 0 auto 28px;
231
+ max-width: 820px;
232
+ }
233
+ .files-panel.open { display: block; }
234
+ main { max-width: 820px; margin: auto; }
235
+ .document-toolbar { align-items: start; flex-direction: column; }
236
+ }
237
+
238
+ @media (max-width: 520px) {
239
+ .layout { padding-inline: 14px; }
240
+ .toolbar-actions { width: 100%; }
241
+ .toolbar-actions button { flex: 1; }
242
+ .markdown-body h2 { margin-top: 44px; }
243
+ }
244
+
245
+ @media print {
246
+ :root { color-scheme: light; }
247
+ body { background: white; color: #222; }
248
+ .mobile-bar, .files-panel, .toc-panel, .progress, .document-toolbar, .copy-button, .notice { display: none !important; }
249
+ .layout { display: block; padding: 0; }
250
+ main { max-width: none; }
251
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, strong { color: #111; }
252
+ }
@@ -0,0 +1,206 @@
1
+ import { unloadAndMaybeClose } from "/static/viewer-actions.js";
2
+
3
+ const csrf = document.querySelector('meta[name="csrf-token"]').content;
4
+ const clientId = crypto.randomUUID();
5
+ const state = { files: [], activeId: null, revision: null, pinned: false };
6
+
7
+ const elements = {
8
+ fileList: document.getElementById("file-list"),
9
+ toc: document.getElementById("toc"),
10
+ document: document.getElementById("document"),
11
+ title: document.getElementById("document-title"),
12
+ source: document.getElementById("source-path"),
13
+ error: document.getElementById("error"),
14
+ notice: document.getElementById("notice"),
15
+ progress: document.getElementById("progress"),
16
+ status: document.getElementById("connection-status"),
17
+ sidebar: document.getElementById("sidebar"),
18
+ toggle: document.getElementById("sidebar-toggle"),
19
+ refresh: document.getElementById("refresh-file"),
20
+ unload: document.getElementById("unload-file"),
21
+ close: document.getElementById("close-viewer"),
22
+ };
23
+
24
+ async function api(path, options = {}) {
25
+ const request = { method: options.method || "GET", headers: { ...(options.headers || {}) } };
26
+ if (options.body !== undefined) {
27
+ request.headers["Content-Type"] = "application/json";
28
+ request.body = JSON.stringify(options.body);
29
+ }
30
+ if (request.method !== "GET") request.headers["X-CSRF-Token"] = csrf;
31
+ const response = await fetch(path, request);
32
+ const payload = await response.json();
33
+ if (!response.ok) throw new Error(payload.error?.message || `HTTP ${response.status}`);
34
+ return payload;
35
+ }
36
+
37
+ function showError(error) {
38
+ elements.error.textContent = error instanceof Error ? error.message : String(error);
39
+ elements.error.hidden = false;
40
+ }
41
+
42
+ function clearError() {
43
+ elements.error.hidden = true;
44
+ elements.error.textContent = "";
45
+ }
46
+
47
+ function notify(message) {
48
+ elements.notice.textContent = message;
49
+ elements.notice.hidden = false;
50
+ setTimeout(() => { elements.notice.hidden = true; }, 2200);
51
+ }
52
+
53
+ function renderFileList() {
54
+ elements.fileList.replaceChildren();
55
+ for (const file of state.files) {
56
+ const button = document.createElement("button");
57
+ button.type = "button";
58
+ button.className = `file-button${file.id === state.activeId ? " active" : ""}`;
59
+ button.title = file.path;
60
+ button.append(document.createTextNode(file.displayName));
61
+ if (file.parentLabel) {
62
+ const parent = document.createElement("small");
63
+ parent.textContent = file.parentLabel;
64
+ button.append(parent);
65
+ }
66
+ button.addEventListener("click", () => selectFile(file.id, true));
67
+ elements.fileList.append(button);
68
+ }
69
+ }
70
+
71
+ function renderToc(items) {
72
+ elements.toc.replaceChildren();
73
+ for (const item of items) {
74
+ const link = document.createElement("a");
75
+ link.className = `toc-link level-${item.level}`;
76
+ link.href = `#${encodeURIComponent(item.id)}`;
77
+ link.textContent = item.title;
78
+ elements.toc.append(link);
79
+ }
80
+ observeHeadings();
81
+ }
82
+
83
+ function addCopyButtons() {
84
+ elements.document.querySelectorAll(".code-block pre").forEach((pre) => {
85
+ const button = document.createElement("button");
86
+ button.type = "button";
87
+ button.className = "copy-button";
88
+ button.textContent = "Copy";
89
+ button.addEventListener("click", async () => {
90
+ await navigator.clipboard.writeText(pre.innerText);
91
+ button.textContent = "Copied";
92
+ setTimeout(() => { button.textContent = "Copy"; }, 1200);
93
+ });
94
+ pre.parentElement.append(button);
95
+ });
96
+ }
97
+
98
+ function observeHeadings() {
99
+ const links = [...elements.toc.querySelectorAll(".toc-link")];
100
+ const byId = new Map(links.map((link) => [decodeURIComponent(link.hash.slice(1)), link]));
101
+ const observer = new IntersectionObserver((entries) => {
102
+ for (const entry of entries) {
103
+ if (!entry.isIntersecting) continue;
104
+ links.forEach((link) => link.classList.remove("active"));
105
+ byId.get(entry.target.id)?.classList.add("active");
106
+ }
107
+ }, { rootMargin: "-12% 0px -76%" });
108
+ elements.document.querySelectorAll("h1[id],h2[id],h3[id]").forEach((heading) => observer.observe(heading));
109
+ }
110
+
111
+ async function selectFile(fileId, pinned = false) {
112
+ clearError();
113
+ state.activeId = fileId;
114
+ state.pinned = pinned;
115
+ renderFileList();
116
+ const file = state.files.find((candidate) => candidate.id === fileId);
117
+ if (!file) return;
118
+ elements.title.textContent = file.displayName;
119
+ elements.source.textContent = file.path;
120
+ try {
121
+ const rendered = await api(`/api/v1/files/${encodeURIComponent(fileId)}`);
122
+ state.revision = rendered.revision;
123
+ elements.document.innerHTML = rendered.html;
124
+ renderToc(rendered.toc);
125
+ addCopyButtons();
126
+ } catch (error) {
127
+ showError(error);
128
+ elements.document.replaceChildren();
129
+ renderToc([]);
130
+ }
131
+ }
132
+
133
+ async function loadFiles({ announce = false } = {}) {
134
+ const previous = new Set(state.files.map((file) => file.id));
135
+ const payload = await api("/api/v1/files");
136
+ state.files = payload.files;
137
+ const newest = state.files.at(-1);
138
+ if (!state.files.some((file) => file.id === state.activeId)) {
139
+ state.activeId = newest?.id || null;
140
+ state.pinned = false;
141
+ } else if (!state.pinned && newest && !previous.has(newest.id)) {
142
+ state.activeId = newest.id;
143
+ }
144
+ renderFileList();
145
+ if (state.activeId) await selectFile(state.activeId, state.pinned);
146
+ if (announce && state.files.some((file) => !previous.has(file.id))) notify("Добавлены новые документы");
147
+ return state.files.length;
148
+ }
149
+
150
+ async function unloadActive() {
151
+ if (!state.activeId) return;
152
+ const fileId = state.activeId;
153
+ await unloadAndMaybeClose({
154
+ remove: () => api(`/api/v1/files/${encodeURIComponent(fileId)}`, { method: "DELETE" }),
155
+ reload: async () => {
156
+ state.activeId = null;
157
+ state.pinned = false;
158
+ return loadFiles();
159
+ },
160
+ close: closeViewer,
161
+ });
162
+ }
163
+
164
+ async function closeViewer() {
165
+ await api("/api/v1/shutdown", { method: "POST", body: {} });
166
+ elements.status.textContent = "Viewer закрыт";
167
+ window.close();
168
+ }
169
+
170
+ function connectEvents() {
171
+ const events = new EventSource(`/api/v1/events?clientId=${encodeURIComponent(clientId)}`);
172
+ events.addEventListener("open", () => { elements.status.textContent = "Подключено"; });
173
+ events.addEventListener("files_changed", () => loadFiles({ announce: true }).catch(showError));
174
+ events.addEventListener("file_removed", () => loadFiles().catch(showError));
175
+ events.addEventListener("server_closing", () => {
176
+ elements.status.textContent = "Viewer закрывается";
177
+ events.close();
178
+ window.close();
179
+ });
180
+ events.addEventListener("error", () => { elements.status.textContent = "Переподключение…"; });
181
+ }
182
+
183
+ function updateProgress() {
184
+ const maximum = document.documentElement.scrollHeight - innerHeight;
185
+ elements.progress.style.width = `${maximum > 0 ? (scrollY / maximum) * 100 : 0}%`;
186
+ }
187
+
188
+ elements.refresh.addEventListener("click", () => state.activeId && selectFile(state.activeId, state.pinned));
189
+ elements.unload.addEventListener("click", () => unloadActive().catch(showError));
190
+ elements.close.addEventListener("click", () => closeViewer().catch(showError));
191
+ elements.toggle.addEventListener("click", () => {
192
+ const open = elements.sidebar.classList.toggle("open");
193
+ elements.toggle.setAttribute("aria-expanded", String(open));
194
+ });
195
+ addEventListener("scroll", updateProgress, { passive: true });
196
+
197
+ await api("/api/v1/clients/open", { method: "POST", body: { clientId } });
198
+ await loadFiles();
199
+ connectEvents();
200
+ setInterval(() => api("/api/v1/clients/open", {
201
+ method: "POST",
202
+ body: { clientId, heartbeat: true },
203
+ }).catch(() => {}), 2000);
204
+ addEventListener("pagehide", () => navigator.sendBeacon(
205
+ `/api/v1/clients/close?clientId=${encodeURIComponent(clientId)}&csrf=${encodeURIComponent(csrf)}`
206
+ ));
@@ -0,0 +1,49 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="csrf-token" content="{{CSRF_TOKEN}}">
7
+ <title>Markdown Viewer</title>
8
+ <link rel="stylesheet" href="/static/viewer.css">
9
+ </head>
10
+ <body>
11
+ <div class="progress" id="progress"></div>
12
+ <header class="mobile-bar">
13
+ <button id="sidebar-toggle" type="button" aria-controls="sidebar" aria-expanded="false">Документы</button>
14
+ <span id="connection-status">Подключение…</span>
15
+ </header>
16
+ <div class="layout">
17
+ <aside class="files-panel" id="sidebar">
18
+ <div class="brand">pi · markdown viewer</div>
19
+ <div class="panel-heading">
20
+ <span>Файлы</span>
21
+ <button id="close-viewer" class="icon-button danger" type="button" title="Закрыть viewer">×</button>
22
+ </div>
23
+ <nav id="file-list" aria-label="Открытые Markdown-файлы"></nav>
24
+ </aside>
25
+ <main>
26
+ <div class="document-toolbar">
27
+ <div>
28
+ <h1 id="document-title">Markdown Viewer</h1>
29
+ <div id="source-path" class="source-path"></div>
30
+ </div>
31
+ <div class="toolbar-actions">
32
+ <button id="refresh-file" type="button">Обновить</button>
33
+ <button id="unload-file" type="button">Выгрузить</button>
34
+ </div>
35
+ </div>
36
+ <div id="notice" class="notice" role="status" hidden></div>
37
+ <div id="error" class="error" role="alert" hidden></div>
38
+ <article id="document" class="markdown-body">
39
+ <p class="empty-state">Добавьте Markdown-файл командой <code>mdview файл.md</code>.</p>
40
+ </article>
41
+ </main>
42
+ <aside class="toc-panel">
43
+ <div class="panel-heading">Содержание</div>
44
+ <nav id="toc" aria-label="Содержание документа"></nav>
45
+ </aside>
46
+ </div>
47
+ <script type="module" src="/static/viewer.js"></script>
48
+ </body>
49
+ </html>