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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repoview",
3
- "version": "0.1.5",
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
@@ -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
- 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;")
@@ -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="${isMarkdown ? "markdown-body markdown-wrap" : "code-wrap"}">
262
+ <div class="${wrapClass}">
259
263
  ${renderedHtml}
260
264
  </div>
261
265
  </section>`;