repoview 0.1.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repoview",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "GitHub-like repo browsing for local Git repositories (Markdown, live reload, broken link scanner).",
package/public/app.css CHANGED
@@ -1,5 +1,5 @@
1
1
  :root {
2
- color-scheme: light;
2
+ color-scheme: light dark;
3
3
  --bg: #ffffff;
4
4
  --panel: #ffffff;
5
5
  --text: #24292f;
@@ -11,6 +11,20 @@
11
11
  --accent: #0969da;
12
12
  }
13
13
 
14
+ @media (prefers-color-scheme: dark) {
15
+ :root {
16
+ --bg: #0d1117;
17
+ --panel: #151b23;
18
+ --text: #f0f6fc;
19
+ --muted: #9198a1;
20
+ --border: #3d444d;
21
+ --subtleBg: #151b23;
22
+ --btn: #21262d;
23
+ --btnHover: #30363d;
24
+ --accent: #4493f8;
25
+ }
26
+ }
27
+
14
28
  html,
15
29
  body {
16
30
  height: 100%;
@@ -39,7 +53,7 @@ a {
39
53
  position: sticky;
40
54
  top: 0;
41
55
  z-index: 10;
42
- background: rgba(255, 255, 255, 0.92);
56
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
43
57
  backdrop-filter: saturate(180%) blur(8px);
44
58
  border-bottom: 1px solid var(--border);
45
59
  }
@@ -75,6 +89,88 @@ a {
75
89
  align-items: center;
76
90
  }
77
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
+
78
174
  .meta-menu {
79
175
  display: none;
80
176
  position: relative;
@@ -117,7 +213,7 @@ a {
117
213
  padding: 3px 8px;
118
214
  border: 1px solid var(--border);
119
215
  border-radius: 999px;
120
- background: #fff;
216
+ background: var(--panel);
121
217
  }
122
218
 
123
219
  .mono {
@@ -149,7 +245,8 @@ a {
149
245
  }
150
246
 
151
247
  .crumb-sep {
152
- color: rgba(31, 35, 40, 0.28);
248
+ color: var(--muted);
249
+ opacity: 0.5;
153
250
  }
154
251
 
155
252
  .panel {
@@ -247,8 +344,26 @@ a {
247
344
  width: 160px;
248
345
  }
249
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
+
250
365
  .file-table tr:hover td {
251
- background: #f6f8fa;
366
+ background: var(--subtleBg);
252
367
  }
253
368
 
254
369
  .item {
@@ -300,19 +415,25 @@ a {
300
415
  margin: 0;
301
416
  padding: 16px;
302
417
  overflow: auto;
303
- background: #f6f8fa;
418
+ background: var(--subtleBg);
304
419
  }
305
420
 
306
421
  .note {
307
422
  padding: 12px;
308
423
  border: 1px solid var(--border);
309
424
  border-radius: 6px;
310
- background: #f6f8fa;
425
+ background: var(--subtleBg);
311
426
  }
312
427
 
313
428
  .error {
314
429
  padding: 12px;
315
- color: #cf222e;
430
+ color: var(--error, #cf222e);
431
+ }
432
+
433
+ @media (prefers-color-scheme: dark) {
434
+ .error {
435
+ color: #f85149;
436
+ }
316
437
  }
317
438
 
318
439
  .markdown-body .markdown-alert {
@@ -321,7 +442,7 @@ a {
321
442
  border: 1px solid var(--border);
322
443
  border-left-width: 4px;
323
444
  border-radius: 6px;
324
- background: #f6f8fa;
445
+ background: var(--subtleBg);
325
446
  }
326
447
 
327
448
  .markdown-body .markdown-alert-title {
@@ -353,7 +474,7 @@ a {
353
474
  padding: 12px;
354
475
  border: 1px solid var(--border);
355
476
  border-radius: 12px;
356
- background: #f6f8fa;
477
+ background: var(--subtleBg);
357
478
  overflow: auto;
358
479
  }
359
480
 
package/public/app.js CHANGED
@@ -1,6 +1,23 @@
1
1
  (() => {
2
2
  const shouldWatch = new URLSearchParams(location.search).get("watch") !== "0";
3
- if (!shouldWatch) return;
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
- if (isMarkdown) {
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("&", "&amp;")
6
6
  .replaceAll("<", "&lt;")
@@ -44,8 +44,9 @@ function pageTemplate({ title, repoName, gitInfo, relPathPosix, bodyHtml }) {
44
44
  <meta charset="utf-8" />
45
45
  <meta name="viewport" content="width=device-width, initial-scale=1" />
46
46
  <title>${escapeHtml(title)}</title>
47
- <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown-light.css" />
48
- <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" />
47
+ <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
48
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
49
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
49
50
  <link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
50
51
  <link rel="stylesheet" href="/static/app.css" />
51
52
  </head>
@@ -138,8 +139,9 @@ function pageTemplateWithLinks({
138
139
  <meta charset="utf-8" />
139
140
  <meta name="viewport" content="width=device-width, initial-scale=1" />
140
141
  <title>${escapeHtml(title)}</title>
141
- <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown-light.css" />
142
- <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" />
142
+ <link rel="stylesheet" href="/static/vendor/github-markdown-css/github-markdown.css" />
143
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github.css" media="(prefers-color-scheme: light)" />
144
+ <link rel="stylesheet" href="/static/vendor/highlight.js/styles/github-dark.css" media="(prefers-color-scheme: dark)" />
143
145
  <link rel="stylesheet" href="/static/vendor/katex/katex.min.css" />
144
146
  <link rel="stylesheet" href="/static/app.css" />
145
147
  </head>
@@ -154,6 +156,7 @@ function pageTemplateWithLinks({
154
156
  ${brokenPill}
155
157
  ${ignoredPill}
156
158
  </span>
159
+ <span id="conn-status" class="conn-status" title="Live reload: connecting..."></span>
157
160
  ${metaMenu}
158
161
  </div>
159
162
  </div>
@@ -185,9 +188,10 @@ export function renderTreePage({
185
188
  .map((r) => {
186
189
  const icon = r.isDir ? "dir" : "file";
187
190
  const name = escapeHtml(r.name);
191
+ const tsAttr = r.mtimeMs ? ` data-ts="${r.mtimeMs}"` : "";
188
192
  return `<tr>
189
193
  <td class="name"><a class="item ${icon}" href="${r.href}">${name}</a></td>
190
- <td class="mtime">${escapeHtml(r.mtime)}</td>
194
+ <td class="mtime"${tsAttr}>${escapeHtml(r.mtime)}</td>
191
195
  <td class="size">${escapeHtml(r.size)}</td>
192
196
  </tr>`;
193
197
  })
@@ -205,7 +209,7 @@ export function renderTreePage({
205
209
  <div class="table-wrap">
206
210
  <table class="file-table">
207
211
  <thead>
208
- <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>
209
213
  </thead>
210
214
  <tbody>
211
215
  ${tableRows || `<tr><td colspan="3" class="empty">Empty directory</td></tr>`}
@@ -239,6 +243,7 @@ export function renderFilePage({
239
243
  relPathPosix,
240
244
  fileName,
241
245
  isMarkdown,
246
+ mediaType,
242
247
  renderedHtml,
243
248
  }) {
244
249
  const relDir = path.posix.dirname(relPathPosix || "");
@@ -246,6 +251,7 @@ export function renderFilePage({
246
251
  const rawHref = `/raw/${encodePathForUrl(relPathPosix || "")}${suffix}`;
247
252
  const treeHref = `/tree/${encodePathForUrl(relDir === "." ? "" : relDir)}${suffix}`;
248
253
 
254
+ const wrapClass = mediaType ? `${mediaType}-wrap` : isMarkdown ? "markdown-body markdown-wrap" : "code-wrap";
249
255
  const body = `<section class="panel">
250
256
  <div class="panel-title">
251
257
  <span class="filename">${escapeHtml(fileName)}</span>
@@ -253,7 +259,7 @@ export function renderFilePage({
253
259
  <a class="btn" href="${treeHref}">Back</a>
254
260
  <a class="btn" href="${rawHref}">Raw</a>
255
261
  </div>
256
- <div class="${isMarkdown ? "markdown-body markdown-wrap" : "code-wrap"}">
262
+ <div class="${wrapClass}">
257
263
  ${renderedHtml}
258
264
  </div>
259
265
  </section>`;