md-copilot-viewer 1.0.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/.env.template +12 -0
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/cli.js +2 -0
- package/dist/index.js +93 -0
- package/dist/markdown.js +67 -0
- package/dist/types.js +1 -0
- package/dist/watcher.js +105 -0
- package/package.json +39 -0
- package/web/app.js +109 -0
- package/web/index.html +31 -0
- package/web/screenshots/screenshot-1.png +0 -0
- package/web/screenshots/screenshot-2.png +0 -0
- package/web/styles.css +18 -0
package/.env.template
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Server port used by `npm run dev` / `npm start` (defaults to 3000)
|
|
2
|
+
PORT=3000
|
|
3
|
+
|
|
4
|
+
# true: include existing .md files at startup, false: only track newly created files after startup
|
|
5
|
+
LOAD_EXISTING_MD=true
|
|
6
|
+
|
|
7
|
+
# Comma-separated quoted path substrings to exclude from results
|
|
8
|
+
# Example: "checkpoints/index.md","tmp/","node_modules/"
|
|
9
|
+
EXCLUDE_PATTERN='"checkpoints/index.md"'
|
|
10
|
+
|
|
11
|
+
# Maximum number of most recently updated files sent to the frontend
|
|
12
|
+
FILE_MAX_LIMIT=200
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Markdown File Viewer for Copilot
|
|
2
|
+
Simple markdown file viewer for Copilot/session notes, with DOCX export.
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
|
|
6
|
+
- Realtime monitoring of `.md` files, so new and updated notes appear automatically.
|
|
7
|
+
- Live markdown preview with a quick file list for fast context switching.
|
|
8
|
+
- Optional DOCX export for sharing notes outside the app.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
1. A Node watcher tracks markdown file changes (create/update) in realtime.
|
|
13
|
+
2. The backend serves a capped, recent file list plus rendered markdown content.
|
|
14
|
+
3. The web UI refreshes the list/preview automatically and supports DOCX export.
|
|
15
|
+
|
|
16
|
+
## Screenshots
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Run
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install
|
|
25
|
+
cp .env.template .env
|
|
26
|
+
npm run dev
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`npm run dev` uses `PORT` from `.env` (default `3000`), so open:
|
|
30
|
+
- `http://localhost:3000` (default), or
|
|
31
|
+
- `http://localhost:<your PORT value>`
|
|
32
|
+
|
|
33
|
+
## `.env` config
|
|
34
|
+
|
|
35
|
+
`.env.template` includes:
|
|
36
|
+
|
|
37
|
+
```env
|
|
38
|
+
PORT=3000
|
|
39
|
+
LOAD_EXISTING_MD=true
|
|
40
|
+
EXCLUDE_PATTERN='"checkpoints/index.md"'
|
|
41
|
+
FILE_MAX_LIMIT=200
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `PORT`
|
|
45
|
+
Port used by `npm run dev` and `npm start` (default `3000`).
|
|
46
|
+
- `LOAD_EXISTING_MD`
|
|
47
|
+
- `true`: load markdown files that already exist when the app starts.
|
|
48
|
+
- `false`: only watch new markdown files created after startup.
|
|
49
|
+
- `EXCLUDE_PATTERN`
|
|
50
|
+
Comma-separated path substrings to ignore.
|
|
51
|
+
Example: `"checkpoints/index.md","tmp/","node_modules/"`
|
|
52
|
+
- `FILE_MAX_LIMIT`
|
|
53
|
+
Maximum number of most recently updated markdown files returned to the frontend (default `200`).
|
|
54
|
+
|
|
55
|
+
## API endpoints
|
|
56
|
+
|
|
57
|
+
- `GET /api/files`
|
|
58
|
+
Returns recent markdown files (max `FILE_MAX_LIMIT`) with `id`, display `path`, and `mtimeMs`.
|
|
59
|
+
- `GET /api/files/:id`
|
|
60
|
+
Returns one file as `{ path, markdown, html }`.
|
|
61
|
+
- `GET /api/files/:id/docx`
|
|
62
|
+
Downloads the selected markdown file as `.docx`.
|
package/dist/cli.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { markdownToDocx, renderMarkdown } from "./markdown.js";
|
|
8
|
+
import { defaultRoots, MarkdownIndex } from "./watcher.js";
|
|
9
|
+
dotenv.config();
|
|
10
|
+
function parseExcludePatterns(rawValue) {
|
|
11
|
+
if (!rawValue) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const matches = rawValue.matchAll(/"([^"]+)"/g);
|
|
15
|
+
return [...matches].map((match) => match[1]).filter((item) => item.length > 0);
|
|
16
|
+
}
|
|
17
|
+
function parseFileMaxLimit(rawValue) {
|
|
18
|
+
const parsed = Number(rawValue);
|
|
19
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
20
|
+
return 200;
|
|
21
|
+
}
|
|
22
|
+
return Math.floor(parsed);
|
|
23
|
+
}
|
|
24
|
+
function toDisplayPath(filePath) {
|
|
25
|
+
const homePath = os.homedir();
|
|
26
|
+
if (filePath === homePath) {
|
|
27
|
+
return "~";
|
|
28
|
+
}
|
|
29
|
+
if (filePath.startsWith(`${homePath}/`)) {
|
|
30
|
+
return `~/${filePath.slice(homePath.length + 1)}`;
|
|
31
|
+
}
|
|
32
|
+
return filePath;
|
|
33
|
+
}
|
|
34
|
+
const app = express();
|
|
35
|
+
const port = Number(process.env.PORT || 3000);
|
|
36
|
+
const loadExistingMd = process.env.LOAD_EXISTING_MD !== "false";
|
|
37
|
+
const excludePatterns = parseExcludePatterns(process.env.EXCLUDE_PATTERN);
|
|
38
|
+
const fileMaxLimit = parseFileMaxLimit(process.env.FILE_MAX_LIMIT);
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const roots = defaultRoots(cwd);
|
|
41
|
+
const index = new MarkdownIndex(roots, loadExistingMd, excludePatterns);
|
|
42
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
43
|
+
const webDir = path.resolve(path.dirname(currentFile), "../web");
|
|
44
|
+
app.use(express.static(webDir));
|
|
45
|
+
app.get("/api/files", (_req, res) => {
|
|
46
|
+
res.json(index.list().slice(0, fileMaxLimit).map((entry) => ({
|
|
47
|
+
...entry,
|
|
48
|
+
path: toDisplayPath(entry.path)
|
|
49
|
+
})));
|
|
50
|
+
});
|
|
51
|
+
app.get("/api/files/:id", async (req, res) => {
|
|
52
|
+
const filePath = index.resolve(req.params.id);
|
|
53
|
+
if (!filePath) {
|
|
54
|
+
res.status(404).json({ error: "File not found" });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const markdown = await fs.readFile(filePath, "utf8");
|
|
59
|
+
res.json({ path: toDisplayPath(filePath), markdown, html: renderMarkdown(markdown) });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
res.status(500).json({ error: error.message });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
app.get("/api/files/:id/docx", async (req, res) => {
|
|
66
|
+
const filePath = index.resolve(req.params.id);
|
|
67
|
+
if (!filePath) {
|
|
68
|
+
res.status(404).json({ error: "File not found" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const markdown = await fs.readFile(filePath, "utf8");
|
|
73
|
+
const buffer = await markdownToDocx(markdown);
|
|
74
|
+
const fileName = `${path.basename(filePath, ".md")}.docx`;
|
|
75
|
+
res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
|
76
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
77
|
+
res.send(buffer);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
res.status(500).json({ error: error.message });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
async function start() {
|
|
84
|
+
await index.start();
|
|
85
|
+
app.listen(port, () => {
|
|
86
|
+
console.log(`Server running on http://localhost:${port}`);
|
|
87
|
+
console.log(`LOAD_EXISTING_MD=${String(loadExistingMd)}`);
|
|
88
|
+
console.log(`EXCLUDE_PATTERN=${excludePatterns.join(",") || "(none)"}`);
|
|
89
|
+
console.log(`FILE_MAX_LIMIT=${String(fileMaxLimit)}`);
|
|
90
|
+
console.log(`Watching roots:\n- ${roots.join("\n- ")}`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
void start();
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
import texmath from "markdown-it-texmath";
|
|
3
|
+
import katex from "katex";
|
|
4
|
+
import HTMLtoDOCX from "html-to-docx";
|
|
5
|
+
const md = new MarkdownIt({ html: true, linkify: true, breaks: true })
|
|
6
|
+
.use(texmath, { engine: katex, delimiters: "dollars" });
|
|
7
|
+
const defaultFence = md.renderer.rules.fence;
|
|
8
|
+
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
|
9
|
+
const token = tokens[idx];
|
|
10
|
+
const info = (token.info || "").trim();
|
|
11
|
+
if (info === "mermaid") {
|
|
12
|
+
return `<pre class="mermaid">${md.utils.escapeHtml(token.content)}</pre>`;
|
|
13
|
+
}
|
|
14
|
+
if (defaultFence) {
|
|
15
|
+
return defaultFence(tokens, idx, options, env, self);
|
|
16
|
+
}
|
|
17
|
+
return self.renderToken(tokens, idx, options);
|
|
18
|
+
};
|
|
19
|
+
function extractTitle(markdown) {
|
|
20
|
+
const match = markdown.match(/^#\s+(.+)$/m);
|
|
21
|
+
return match ? match[1].trim() : "Document";
|
|
22
|
+
}
|
|
23
|
+
function generateStyledHtml(htmlContent, title) {
|
|
24
|
+
return `<!DOCTYPE html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="UTF-8">
|
|
28
|
+
<meta name="color-scheme" content="light">
|
|
29
|
+
<title>${title}</title>
|
|
30
|
+
<style>
|
|
31
|
+
:root { color-scheme: light; }
|
|
32
|
+
body { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: #333; background: #fff; }
|
|
33
|
+
h1 { font-size: 24pt; margin: 24pt 0 12pt; color: #2c3e50; }
|
|
34
|
+
h2 { font-size: 18pt; margin: 18pt 0 10pt; color: #34495e; }
|
|
35
|
+
h3 { font-size: 14pt; margin: 14pt 0 8pt; }
|
|
36
|
+
p { margin: 0 0 10pt; text-align: justify; }
|
|
37
|
+
ul, ol { margin: 0 0 10pt; padding-left: 20pt; }
|
|
38
|
+
blockquote { margin: 10pt 0; padding-left: 15pt; border-left: 3px solid #3498db; color: #666; font-style: italic; }
|
|
39
|
+
code { font-family: 'Courier New', Courier, monospace; font-size: 10pt; background-color: #f4f4f4; padding: 2pt 4pt; }
|
|
40
|
+
pre { font-family: 'Courier New', Courier, monospace; font-size: 10pt; background-color: #f4f4f4; padding: 10pt; margin: 10pt 0; white-space: pre-wrap; word-wrap: break-word; }
|
|
41
|
+
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
|
42
|
+
th, td { border: 1px solid #ddd; padding: 8pt; text-align: left; }
|
|
43
|
+
th { background-color: #f5f5f5; }
|
|
44
|
+
a { color: #3498db; text-decoration: underline; }
|
|
45
|
+
img { max-width: 100%; height: auto; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
${htmlContent}
|
|
50
|
+
</body>
|
|
51
|
+
</html>`;
|
|
52
|
+
}
|
|
53
|
+
export function renderMarkdown(markdown) {
|
|
54
|
+
return md.render(markdown);
|
|
55
|
+
}
|
|
56
|
+
export async function markdownToDocx(markdown) {
|
|
57
|
+
const title = extractTitle(markdown);
|
|
58
|
+
const htmlContent = md.render(markdown);
|
|
59
|
+
const fullHtml = generateStyledHtml(htmlContent, title);
|
|
60
|
+
return HTMLtoDOCX(fullHtml, null, {
|
|
61
|
+
title,
|
|
62
|
+
subject: "Converted from Markdown",
|
|
63
|
+
creator: "Marky-compatible exporter",
|
|
64
|
+
font: "Times New Roman",
|
|
65
|
+
fontSize: 24
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import chokidar from "chokidar";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
function toId(filePath) {
|
|
6
|
+
return Buffer.from(filePath, "utf8").toString("base64url");
|
|
7
|
+
}
|
|
8
|
+
function isMarkdown(filePath) {
|
|
9
|
+
return filePath.toLowerCase().endsWith(".md");
|
|
10
|
+
}
|
|
11
|
+
export class MarkdownIndex {
|
|
12
|
+
roots;
|
|
13
|
+
loadExistingOnStart;
|
|
14
|
+
excludePatterns;
|
|
15
|
+
byPath = new Map();
|
|
16
|
+
byId = new Map();
|
|
17
|
+
watcher;
|
|
18
|
+
constructor(roots, loadExistingOnStart, excludePatterns) {
|
|
19
|
+
this.roots = roots;
|
|
20
|
+
this.loadExistingOnStart = loadExistingOnStart;
|
|
21
|
+
this.excludePatterns = excludePatterns;
|
|
22
|
+
}
|
|
23
|
+
async start() {
|
|
24
|
+
if (this.loadExistingOnStart) {
|
|
25
|
+
await this.seedExisting();
|
|
26
|
+
}
|
|
27
|
+
this.watcher = chokidar.watch(this.roots, {
|
|
28
|
+
ignored: (p) => p.includes("/node_modules/") || p.includes("/.git/"),
|
|
29
|
+
persistent: true,
|
|
30
|
+
ignoreInitial: true,
|
|
31
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 }
|
|
32
|
+
});
|
|
33
|
+
this.watcher.on("add", (filePath) => void this.upsert(filePath));
|
|
34
|
+
this.watcher.on("change", (filePath) => void this.upsert(filePath));
|
|
35
|
+
this.watcher.on("unlink", (filePath) => this.remove(filePath));
|
|
36
|
+
}
|
|
37
|
+
async seedExisting() {
|
|
38
|
+
await Promise.all(this.roots.map((root) => this.walk(root)));
|
|
39
|
+
}
|
|
40
|
+
async walk(dir) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await Promise.all(entries.map(async (entry) => {
|
|
49
|
+
const fullPath = path.join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await this.walk(fullPath);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (entry.isFile() && isMarkdown(fullPath)) {
|
|
58
|
+
await this.upsert(fullPath);
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
async upsert(filePath) {
|
|
63
|
+
if (!isMarkdown(filePath)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (this.isExcluded(filePath)) {
|
|
67
|
+
this.remove(filePath);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const stat = await fs.stat(filePath);
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const id = toId(filePath);
|
|
76
|
+
const entry = { id, path: filePath, mtimeMs: stat.mtimeMs };
|
|
77
|
+
this.byPath.set(filePath, entry);
|
|
78
|
+
this.byId.set(id, filePath);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
this.remove(filePath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
remove(filePath) {
|
|
85
|
+
const existing = this.byPath.get(filePath);
|
|
86
|
+
if (!existing) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.byPath.delete(filePath);
|
|
90
|
+
this.byId.delete(existing.id);
|
|
91
|
+
}
|
|
92
|
+
list() {
|
|
93
|
+
return [...this.byPath.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
94
|
+
}
|
|
95
|
+
resolve(id) {
|
|
96
|
+
return this.byId.get(id);
|
|
97
|
+
}
|
|
98
|
+
isExcluded(filePath) {
|
|
99
|
+
const normalizedPath = filePath.replaceAll("\\", "/");
|
|
100
|
+
return this.excludePatterns.some((pattern) => normalizedPath.includes(pattern));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function defaultRoots(cwd) {
|
|
104
|
+
return [cwd, path.join(os.homedir(), ".copilot")];
|
|
105
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "md-copilot-viewer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Watch markdown files and view them in a browser with DOCX export",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"md-copilot-viewer": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"web",
|
|
13
|
+
".env.template",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"dev": "tsx src/index.ts",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"chokidar": "^4.0.3",
|
|
25
|
+
"dotenv": "^16.4.7",
|
|
26
|
+
"express": "^4.21.2",
|
|
27
|
+
"html-to-docx": "^1.8.0",
|
|
28
|
+
"katex": "^0.16.25",
|
|
29
|
+
"markdown-it": "^14.1.0",
|
|
30
|
+
"markdown-it-texmath": "^1.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/express": "^4.17.21",
|
|
34
|
+
"@types/markdown-it": "^14.1.2",
|
|
35
|
+
"@types/node": "^22.13.10",
|
|
36
|
+
"tsx": "^4.19.3",
|
|
37
|
+
"typescript": "^5.8.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/web/app.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const fileListEl = document.getElementById("file-list");
|
|
2
|
+
const searchInputEl = document.getElementById("file-search");
|
|
3
|
+
const previewEl = document.getElementById("preview");
|
|
4
|
+
const currentFileEl = document.getElementById("current-file");
|
|
5
|
+
const downloadBtn = document.getElementById("download-docx");
|
|
6
|
+
|
|
7
|
+
let files = [];
|
|
8
|
+
let selectedId = null;
|
|
9
|
+
let searchQuery = "";
|
|
10
|
+
let initialized = false;
|
|
11
|
+
const knownIds = new Set();
|
|
12
|
+
const unreadIds = new Set();
|
|
13
|
+
|
|
14
|
+
function renderList() {
|
|
15
|
+
fileListEl.innerHTML = "";
|
|
16
|
+
const query = searchQuery.toLowerCase();
|
|
17
|
+
const visibleFiles = query ? files.filter((file) => file.path.toLowerCase().includes(query)) : files;
|
|
18
|
+
for (const file of visibleFiles) {
|
|
19
|
+
const li = document.createElement("li");
|
|
20
|
+
li.className = selectedId === file.id ? "active" : "";
|
|
21
|
+
const label = document.createElement("span");
|
|
22
|
+
label.className = "file-item-label";
|
|
23
|
+
label.textContent = `${file.path} (${new Date(file.mtimeMs).toLocaleString()})`;
|
|
24
|
+
li.appendChild(label);
|
|
25
|
+
if (unreadIds.has(file.id)) {
|
|
26
|
+
const dot = document.createElement("span");
|
|
27
|
+
dot.className = "unread-dot";
|
|
28
|
+
dot.setAttribute("aria-label", "New file");
|
|
29
|
+
li.appendChild(dot);
|
|
30
|
+
}
|
|
31
|
+
li.addEventListener("click", () => selectFile(file.id));
|
|
32
|
+
fileListEl.appendChild(li);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function refreshFiles() {
|
|
37
|
+
const res = await fetch("/api/files");
|
|
38
|
+
files = await res.json();
|
|
39
|
+
const currentIds = new Set(files.map((f) => f.id));
|
|
40
|
+
if (!initialized) {
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
knownIds.add(file.id);
|
|
43
|
+
}
|
|
44
|
+
initialized = true;
|
|
45
|
+
} else {
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (!knownIds.has(file.id)) {
|
|
48
|
+
unreadIds.add(file.id);
|
|
49
|
+
knownIds.add(file.id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const id of [...unreadIds]) {
|
|
54
|
+
if (!currentIds.has(id)) {
|
|
55
|
+
unreadIds.delete(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!selectedId && files.length > 0) {
|
|
59
|
+
selectedId = files[0].id;
|
|
60
|
+
}
|
|
61
|
+
if (selectedId && !files.some((f) => f.id === selectedId)) {
|
|
62
|
+
selectedId = files[0]?.id ?? null;
|
|
63
|
+
}
|
|
64
|
+
renderList();
|
|
65
|
+
if (selectedId) {
|
|
66
|
+
await loadPreview(selectedId);
|
|
67
|
+
} else {
|
|
68
|
+
previewEl.innerHTML = "<p>No markdown files found yet.</p>";
|
|
69
|
+
currentFileEl.textContent = "";
|
|
70
|
+
downloadBtn.disabled = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadPreview(id) {
|
|
75
|
+
const res = await fetch(`/api/files/${encodeURIComponent(id)}`);
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
previewEl.textContent = "Failed to load file.";
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
selectedId = id;
|
|
82
|
+
unreadIds.delete(id);
|
|
83
|
+
renderList();
|
|
84
|
+
previewEl.innerHTML = data.html;
|
|
85
|
+
currentFileEl.textContent = data.path;
|
|
86
|
+
downloadBtn.disabled = false;
|
|
87
|
+
const mermaid = window.__mermaid;
|
|
88
|
+
if (mermaid) {
|
|
89
|
+
mermaid.initialize({ startOnLoad: false });
|
|
90
|
+
await mermaid.run({ nodes: previewEl.querySelectorAll(".mermaid") });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function selectFile(id) {
|
|
95
|
+
await loadPreview(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
downloadBtn.addEventListener("click", () => {
|
|
99
|
+
if (!selectedId) return;
|
|
100
|
+
window.location.href = `/api/files/${encodeURIComponent(selectedId)}/docx`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
searchInputEl.addEventListener("input", (event) => {
|
|
104
|
+
searchQuery = event.target.value;
|
|
105
|
+
renderList();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
refreshFiles();
|
|
109
|
+
setInterval(refreshFiles, 2000);
|
package/web/index.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Markdown Copilot Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.css" />
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div class="app">
|
|
12
|
+
<aside class="sidebar">
|
|
13
|
+
<h2>Markdown files</h2>
|
|
14
|
+
<input id="file-search" type="text" placeholder="Search files..." aria-label="Search markdown files" />
|
|
15
|
+
<ul id="file-list"></ul>
|
|
16
|
+
</aside>
|
|
17
|
+
<main class="content">
|
|
18
|
+
<div class="toolbar">
|
|
19
|
+
<button id="download-docx" disabled>Download as DOCX</button>
|
|
20
|
+
<span id="current-file"></span>
|
|
21
|
+
</div>
|
|
22
|
+
<article id="preview"></article>
|
|
23
|
+
</main>
|
|
24
|
+
</div>
|
|
25
|
+
<script type="module" src="/app.js"></script>
|
|
26
|
+
<script type="module">
|
|
27
|
+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
|
28
|
+
window.__mermaid = mermaid;
|
|
29
|
+
</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
Binary file
|
|
Binary file
|
package/web/styles.css
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
body { margin: 0; font-family: Arial, sans-serif; color: #1a1a1a; background: #fff; }
|
|
3
|
+
.app { display: grid; grid-template-columns: 320px 1fr; height: 100vh; }
|
|
4
|
+
.sidebar { border-right: 1px solid #ddd; padding: 12px; overflow: auto; }
|
|
5
|
+
.sidebar h2 { margin: 0 0 12px; font-size: 18px; }
|
|
6
|
+
#file-search { width: 100%; margin: 0 0 10px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
|
|
7
|
+
#file-list { list-style: none; padding: 0; margin: 0; }
|
|
8
|
+
#file-list li { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
|
|
9
|
+
#file-list li:hover, #file-list li.active { background: #eef4ff; }
|
|
10
|
+
.file-item-label { overflow-wrap: anywhere; flex: 1; }
|
|
11
|
+
.unread-dot { width: 9px; height: 9px; border-radius: 50%; background: #8be28b; flex: 0 0 auto; }
|
|
12
|
+
.content { display: flex; flex-direction: column; min-width: 0; }
|
|
13
|
+
.toolbar { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #ddd; }
|
|
14
|
+
#preview { padding: 20px; overflow: auto; }
|
|
15
|
+
#preview pre { background: #f5f5f5; padding: 10px; border-radius: 6px; overflow: auto; }
|
|
16
|
+
#preview table { border-collapse: collapse; width: 100%; }
|
|
17
|
+
#preview th, #preview td { border: 1px solid #ccc; padding: 8px; }
|
|
18
|
+
#preview .mermaid { background: #fff; }
|