repoview 0.1.5 → 0.2.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 +1 -1
- package/public/app.css +100 -0
- package/public/app.js +64 -1
- package/src/markdown.js +20 -0
- package/src/server.js +111 -1
- package/src/views.js +8 -4
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -89,6 +89,88 @@ a {
|
|
|
89
89
|
align-items: center;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
.conn-status {
|
|
93
|
+
width: 8px;
|
|
94
|
+
height: 8px;
|
|
95
|
+
border-radius: 50%;
|
|
96
|
+
background: var(--muted);
|
|
97
|
+
flex-shrink: 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.conn-status[data-status="connected"] {
|
|
101
|
+
background: #2da44e;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.conn-status[data-status="polling"] {
|
|
105
|
+
background: #bf8700;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.conn-status[data-status="disconnected"] {
|
|
109
|
+
background: #cf222e;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.pdf-wrap {
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-direction: column;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.pdf-frame {
|
|
118
|
+
width: 100%;
|
|
119
|
+
height: 80vh;
|
|
120
|
+
border: none;
|
|
121
|
+
border-radius: 6px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.image-wrap {
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
align-items: center;
|
|
128
|
+
padding: 16px;
|
|
129
|
+
background: var(--subtleBg);
|
|
130
|
+
border-radius: 6px;
|
|
131
|
+
min-height: 200px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.image-preview {
|
|
135
|
+
max-width: 100%;
|
|
136
|
+
max-height: 80vh;
|
|
137
|
+
object-fit: contain;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.csv-wrap {
|
|
141
|
+
overflow: auto;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.csv-table-wrap {
|
|
145
|
+
overflow-x: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.csv-table {
|
|
149
|
+
width: 100%;
|
|
150
|
+
border-collapse: collapse;
|
|
151
|
+
font-size: 13px;
|
|
152
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.csv-table th,
|
|
156
|
+
.csv-table td {
|
|
157
|
+
padding: 6px 12px;
|
|
158
|
+
border: 1px solid var(--border);
|
|
159
|
+
text-align: left;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.csv-table th {
|
|
164
|
+
background: var(--subtleBg);
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
position: sticky;
|
|
167
|
+
top: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.csv-table tr:hover td {
|
|
171
|
+
background: var(--subtleBg);
|
|
172
|
+
}
|
|
173
|
+
|
|
92
174
|
.meta-menu {
|
|
93
175
|
display: none;
|
|
94
176
|
position: relative;
|
|
@@ -262,6 +344,24 @@ a {
|
|
|
262
344
|
width: 160px;
|
|
263
345
|
}
|
|
264
346
|
|
|
347
|
+
.tz-toggle {
|
|
348
|
+
margin-left: 6px;
|
|
349
|
+
padding: 2px 6px;
|
|
350
|
+
font-size: 11px;
|
|
351
|
+
font-weight: 500;
|
|
352
|
+
color: var(--muted);
|
|
353
|
+
background: var(--btn);
|
|
354
|
+
border: 1px solid var(--border);
|
|
355
|
+
border-radius: 4px;
|
|
356
|
+
cursor: pointer;
|
|
357
|
+
vertical-align: middle;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.tz-toggle:hover {
|
|
361
|
+
background: var(--btnHover);
|
|
362
|
+
color: var(--text);
|
|
363
|
+
}
|
|
364
|
+
|
|
265
365
|
.file-table tr:hover td {
|
|
266
366
|
background: var(--subtleBg);
|
|
267
367
|
}
|
package/public/app.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
const shouldWatch = new URLSearchParams(location.search).get("watch") !== "0";
|
|
3
|
-
|
|
3
|
+
const statusEl = document.getElementById("conn-status");
|
|
4
|
+
|
|
5
|
+
function setStatus(state) {
|
|
6
|
+
if (!statusEl) return;
|
|
7
|
+
statusEl.dataset.status = state;
|
|
8
|
+
const titles = {
|
|
9
|
+
connected: "Live reload: connected",
|
|
10
|
+
connecting: "Live reload: connecting...",
|
|
11
|
+
polling: "Live reload: polling",
|
|
12
|
+
disconnected: "Live reload: disconnected",
|
|
13
|
+
};
|
|
14
|
+
statusEl.title = titles[state] || "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!shouldWatch) {
|
|
18
|
+
if (statusEl) statusEl.style.display = "none";
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
let pollingTimer = null;
|
|
6
23
|
let lastRevision = null;
|
|
@@ -14,6 +31,7 @@
|
|
|
14
31
|
|
|
15
32
|
async function ensurePolling() {
|
|
16
33
|
if (pollingTimer) return;
|
|
34
|
+
setStatus("polling");
|
|
17
35
|
try {
|
|
18
36
|
lastRevision = await fetchRevision();
|
|
19
37
|
} catch {
|
|
@@ -32,10 +50,14 @@
|
|
|
32
50
|
|
|
33
51
|
try {
|
|
34
52
|
const es = new EventSource("/events");
|
|
53
|
+
es.addEventListener("open", () => {
|
|
54
|
+
setStatus("connected");
|
|
55
|
+
});
|
|
35
56
|
es.addEventListener("reload", () => {
|
|
36
57
|
location.reload();
|
|
37
58
|
});
|
|
38
59
|
es.addEventListener("error", () => {
|
|
60
|
+
setStatus("disconnected");
|
|
39
61
|
// Some environments/proxies break SSE; fall back to polling.
|
|
40
62
|
void ensurePolling();
|
|
41
63
|
});
|
|
@@ -113,8 +135,49 @@ function renderMath() {
|
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
|
|
138
|
+
function formatDateTime(ms, useUtc) {
|
|
139
|
+
const d = new Date(ms);
|
|
140
|
+
const opts = {
|
|
141
|
+
year: "numeric",
|
|
142
|
+
month: "short",
|
|
143
|
+
day: "2-digit",
|
|
144
|
+
hour: "2-digit",
|
|
145
|
+
minute: "2-digit",
|
|
146
|
+
timeZone: useUtc ? "UTC" : undefined,
|
|
147
|
+
};
|
|
148
|
+
const formatted = d.toLocaleString(undefined, opts);
|
|
149
|
+
return useUtc ? `${formatted} UTC` : formatted;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function initTimezoneToggle() {
|
|
153
|
+
const toggle = document.querySelector(".tz-toggle");
|
|
154
|
+
if (!toggle) return;
|
|
155
|
+
|
|
156
|
+
const cells = document.querySelectorAll(".mtime[data-ts]");
|
|
157
|
+
if (!cells.length) return;
|
|
158
|
+
|
|
159
|
+
let useUtc = localStorage.getItem("tz") === "utc";
|
|
160
|
+
|
|
161
|
+
function update() {
|
|
162
|
+
toggle.textContent = useUtc ? "UTC" : "Local";
|
|
163
|
+
for (const cell of cells) {
|
|
164
|
+
const ts = Number(cell.dataset.ts);
|
|
165
|
+
if (ts) cell.textContent = formatDateTime(ts, useUtc);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
toggle.addEventListener("click", () => {
|
|
170
|
+
useUtc = !useUtc;
|
|
171
|
+
localStorage.setItem("tz", useUtc ? "utc" : "local");
|
|
172
|
+
update();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
update();
|
|
176
|
+
}
|
|
177
|
+
|
|
116
178
|
window.addEventListener("load", () => {
|
|
117
179
|
preserveQueryParamsOnInternalLinks(["ignored", "watch"]);
|
|
118
180
|
renderMath();
|
|
119
181
|
renderMermaid();
|
|
182
|
+
initTimezoneToggle();
|
|
120
183
|
});
|
package/src/markdown.js
CHANGED
|
@@ -16,6 +16,26 @@ function escapeHtml(s) {
|
|
|
16
16
|
.replaceAll("'", "'");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// CommonMark only allows "1." to interrupt a paragraph, but GitHub allows any number.
|
|
20
|
+
// This preprocessor adds blank lines before ordered lists starting with numbers other than 1.
|
|
21
|
+
function normalizeOrderedLists(text) {
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
const result = [];
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
const prevLine = i > 0 ? lines[i - 1] : "";
|
|
27
|
+
// Check if current line starts an ordered list with number > 1
|
|
28
|
+
if (/^[2-9]\d*\. /.test(line)) {
|
|
29
|
+
// Insert blank line if previous line is non-empty and not a list item
|
|
30
|
+
if (prevLine.trim() && !/^\d+\. /.test(prevLine) && !/^[-*+] /.test(prevLine)) {
|
|
31
|
+
result.push("");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
result.push(line);
|
|
35
|
+
}
|
|
36
|
+
return result.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
function isExternalHref(href) {
|
|
20
40
|
return /^(?:[a-z]+:)?\/\//i.test(href) || href.startsWith("mailto:") || href.startsWith("tel:");
|
|
21
41
|
}
|
package/src/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createMarkdownRenderer } from "./markdown.js";
|
|
|
12
12
|
import { loadGitIgnoreMatcher } from "./gitignore.js";
|
|
13
13
|
import { createRepoLinkScanner } from "./linkcheck.js";
|
|
14
14
|
import {
|
|
15
|
+
escapeHtml,
|
|
15
16
|
renderBrokenLinksPage,
|
|
16
17
|
renderErrorPage,
|
|
17
18
|
renderFilePage,
|
|
@@ -35,6 +36,63 @@ function isWithinRoot(rootReal, candidateReal) {
|
|
|
35
36
|
return candidateReal.startsWith(rootWithSep);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function parseCsv(text, delimiter = ",") {
|
|
40
|
+
const rows = [];
|
|
41
|
+
let current = [];
|
|
42
|
+
let cell = "";
|
|
43
|
+
let inQuotes = false;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < text.length; i++) {
|
|
46
|
+
const ch = text[i];
|
|
47
|
+
if (inQuotes) {
|
|
48
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
49
|
+
cell += '"';
|
|
50
|
+
i++;
|
|
51
|
+
} else if (ch === '"') {
|
|
52
|
+
inQuotes = false;
|
|
53
|
+
} else {
|
|
54
|
+
cell += ch;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
inQuotes = true;
|
|
59
|
+
} else if (ch === delimiter) {
|
|
60
|
+
current.push(cell);
|
|
61
|
+
cell = "";
|
|
62
|
+
} else if (ch === "\n" || (ch === "\r" && text[i + 1] === "\n")) {
|
|
63
|
+
if (ch === "\r") i++;
|
|
64
|
+
current.push(cell);
|
|
65
|
+
rows.push(current);
|
|
66
|
+
current = [];
|
|
67
|
+
cell = "";
|
|
68
|
+
} else if (ch === "\r") {
|
|
69
|
+
current.push(cell);
|
|
70
|
+
rows.push(current);
|
|
71
|
+
current = [];
|
|
72
|
+
cell = "";
|
|
73
|
+
} else {
|
|
74
|
+
cell += ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (cell || current.length) {
|
|
79
|
+
current.push(cell);
|
|
80
|
+
rows.push(current);
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderCsvTable(rows, escFn) {
|
|
86
|
+
if (!rows.length) return "<p>Empty file</p>";
|
|
87
|
+
const header = rows[0];
|
|
88
|
+
const body = rows.slice(1);
|
|
89
|
+
const ths = header.map((h) => `<th>${escFn(h)}</th>`).join("");
|
|
90
|
+
const trs = body
|
|
91
|
+
.map((row) => `<tr>${row.map((c) => `<td>${escFn(c)}</td>`).join("")}</tr>`)
|
|
92
|
+
.join("\n");
|
|
93
|
+
return `<div class="csv-table-wrap"><table class="csv-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
38
96
|
async function getGitInfo(repoRootReal) {
|
|
39
97
|
const gitDir = path.join(repoRootReal, ".git");
|
|
40
98
|
try {
|
|
@@ -311,6 +369,7 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
311
369
|
href,
|
|
312
370
|
size: isDir ? "" : formatBytes(info.size),
|
|
313
371
|
mtime: formatDate(info.mtimeMs),
|
|
372
|
+
mtimeMs: info.mtimeMs,
|
|
314
373
|
};
|
|
315
374
|
}),
|
|
316
375
|
);
|
|
@@ -388,7 +447,51 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
388
447
|
const fileName = path.basename(resolved);
|
|
389
448
|
const ext = path.extname(fileName).toLowerCase();
|
|
390
449
|
const isMarkdown = [".md", ".markdown", ".mdown", ".mkd", ".mkdn"].includes(ext);
|
|
450
|
+
const isPdf = ext === ".pdf";
|
|
451
|
+
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp"].includes(ext);
|
|
452
|
+
const isCsv = [".csv", ".tsv"].includes(ext);
|
|
391
453
|
const maxBytes = 2 * 1024 * 1024;
|
|
454
|
+
const rawSrc = `/raw/${encodePathForUrl(toPosixPath(stripped))}`;
|
|
455
|
+
|
|
456
|
+
if (isPdf) {
|
|
457
|
+
res.status(200).send(
|
|
458
|
+
renderFilePage({
|
|
459
|
+
title: `${repoName}/${stripped}`,
|
|
460
|
+
repoName,
|
|
461
|
+
gitInfo,
|
|
462
|
+
brokenLinks: linkScanner.getState(),
|
|
463
|
+
relPathPosix: toPosixPath(stripped),
|
|
464
|
+
querySuffix,
|
|
465
|
+
toggleIgnoredHref,
|
|
466
|
+
showIgnored,
|
|
467
|
+
fileName,
|
|
468
|
+
isMarkdown: false,
|
|
469
|
+
mediaType: "pdf",
|
|
470
|
+
renderedHtml: `<iframe class="pdf-frame" src="${rawSrc}"></iframe>`,
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (isImage) {
|
|
477
|
+
res.status(200).send(
|
|
478
|
+
renderFilePage({
|
|
479
|
+
title: `${repoName}/${stripped}`,
|
|
480
|
+
repoName,
|
|
481
|
+
gitInfo,
|
|
482
|
+
brokenLinks: linkScanner.getState(),
|
|
483
|
+
relPathPosix: toPosixPath(stripped),
|
|
484
|
+
querySuffix,
|
|
485
|
+
toggleIgnoredHref,
|
|
486
|
+
showIgnored,
|
|
487
|
+
fileName,
|
|
488
|
+
isMarkdown: false,
|
|
489
|
+
mediaType: "image",
|
|
490
|
+
renderedHtml: `<img class="image-preview" src="${rawSrc}" alt="${escapeHtml(fileName)}" />`,
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
392
495
|
|
|
393
496
|
if (st.size > maxBytes) {
|
|
394
497
|
res.status(200).send(
|
|
@@ -417,7 +520,13 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
417
520
|
const text = raw.toString("utf8");
|
|
418
521
|
|
|
419
522
|
let renderedHtml;
|
|
420
|
-
|
|
523
|
+
let mediaType;
|
|
524
|
+
if (isCsv) {
|
|
525
|
+
const delimiter = ext === ".tsv" ? "\t" : ",";
|
|
526
|
+
const rows = parseCsv(text, delimiter);
|
|
527
|
+
renderedHtml = renderCsvTable(rows, escapeHtml);
|
|
528
|
+
mediaType = "csv";
|
|
529
|
+
} else if (isMarkdown) {
|
|
421
530
|
const baseDir = toPosixPath(path.posix.dirname(toPosixPath(stripped)));
|
|
422
531
|
renderedHtml = md.render(text, { baseDirPosix: baseDir === "." ? "" : baseDir });
|
|
423
532
|
} else {
|
|
@@ -438,6 +547,7 @@ export async function startServer({ repoRoot, host, port, watch }) {
|
|
|
438
547
|
showIgnored,
|
|
439
548
|
fileName,
|
|
440
549
|
isMarkdown,
|
|
550
|
+
mediaType,
|
|
441
551
|
renderedHtml,
|
|
442
552
|
}),
|
|
443
553
|
);
|
package/src/views.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
function escapeHtml(s) {
|
|
3
|
+
export function escapeHtml(s) {
|
|
4
4
|
return String(s)
|
|
5
5
|
.replaceAll("&", "&")
|
|
6
6
|
.replaceAll("<", "<")
|
|
@@ -156,6 +156,7 @@ function pageTemplateWithLinks({
|
|
|
156
156
|
${brokenPill}
|
|
157
157
|
${ignoredPill}
|
|
158
158
|
</span>
|
|
159
|
+
<span id="conn-status" class="conn-status" title="Live reload: connecting..."></span>
|
|
159
160
|
${metaMenu}
|
|
160
161
|
</div>
|
|
161
162
|
</div>
|
|
@@ -187,9 +188,10 @@ export function renderTreePage({
|
|
|
187
188
|
.map((r) => {
|
|
188
189
|
const icon = r.isDir ? "dir" : "file";
|
|
189
190
|
const name = escapeHtml(r.name);
|
|
191
|
+
const tsAttr = r.mtimeMs ? ` data-ts="${r.mtimeMs}"` : "";
|
|
190
192
|
return `<tr>
|
|
191
193
|
<td class="name"><a class="item ${icon}" href="${r.href}">${name}</a></td>
|
|
192
|
-
<td class="mtime">${escapeHtml(r.mtime)}</td>
|
|
194
|
+
<td class="mtime"${tsAttr}>${escapeHtml(r.mtime)}</td>
|
|
193
195
|
<td class="size">${escapeHtml(r.size)}</td>
|
|
194
196
|
</tr>`;
|
|
195
197
|
})
|
|
@@ -207,7 +209,7 @@ export function renderTreePage({
|
|
|
207
209
|
<div class="table-wrap">
|
|
208
210
|
<table class="file-table">
|
|
209
211
|
<thead>
|
|
210
|
-
<tr><th class="name">Name</th><th class="mtime">Last modified</th><th class="size">Size</th></tr>
|
|
212
|
+
<tr><th class="name">Name</th><th class="mtime">Last modified <button type="button" class="tz-toggle" title="Toggle local/UTC time">Local</button></th><th class="size">Size</th></tr>
|
|
211
213
|
</thead>
|
|
212
214
|
<tbody>
|
|
213
215
|
${tableRows || `<tr><td colspan="3" class="empty">Empty directory</td></tr>`}
|
|
@@ -241,6 +243,7 @@ export function renderFilePage({
|
|
|
241
243
|
relPathPosix,
|
|
242
244
|
fileName,
|
|
243
245
|
isMarkdown,
|
|
246
|
+
mediaType,
|
|
244
247
|
renderedHtml,
|
|
245
248
|
}) {
|
|
246
249
|
const relDir = path.posix.dirname(relPathPosix || "");
|
|
@@ -248,6 +251,7 @@ export function renderFilePage({
|
|
|
248
251
|
const rawHref = `/raw/${encodePathForUrl(relPathPosix || "")}${suffix}`;
|
|
249
252
|
const treeHref = `/tree/${encodePathForUrl(relDir === "." ? "" : relDir)}${suffix}`;
|
|
250
253
|
|
|
254
|
+
const wrapClass = mediaType ? `${mediaType}-wrap` : isMarkdown ? "markdown-body markdown-wrap" : "code-wrap";
|
|
251
255
|
const body = `<section class="panel">
|
|
252
256
|
<div class="panel-title">
|
|
253
257
|
<span class="filename">${escapeHtml(fileName)}</span>
|
|
@@ -255,7 +259,7 @@ export function renderFilePage({
|
|
|
255
259
|
<a class="btn" href="${treeHref}">Back</a>
|
|
256
260
|
<a class="btn" href="${rawHref}">Raw</a>
|
|
257
261
|
</div>
|
|
258
|
-
<div class="${
|
|
262
|
+
<div class="${wrapClass}">
|
|
259
263
|
${renderedHtml}
|
|
260
264
|
</div>
|
|
261
265
|
</section>`;
|