skimmd 1.0.1 → 1.0.2

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/bin/skimmd.js CHANGED
@@ -297,6 +297,129 @@ async function createServer(options) {
297
297
  }
298
298
  });
299
299
 
300
+ // Browse directories and files
301
+ app.get("/api/browse", async (req, res) => {
302
+ const browsePath = req.query.path || process.env.HOME || "/";
303
+ try {
304
+ const resolvedPath = path.resolve(browsePath);
305
+ const stats = await fsp.stat(resolvedPath);
306
+
307
+ if (!stats.isDirectory()) {
308
+ return res.status(400).json({ error: "Path is not a directory" });
309
+ }
310
+
311
+ const entries = await fsp.readdir(resolvedPath, { withFileTypes: true });
312
+ const items = [];
313
+
314
+ for (const entry of entries) {
315
+ // Skip hidden files/folders
316
+ if (entry.name.startsWith(".")) continue;
317
+
318
+ const fullPath = path.join(resolvedPath, entry.name);
319
+ const isDir = entry.isDirectory();
320
+ const ext = path.extname(entry.name).toLowerCase();
321
+ const isMarkdown = [".md", ".markdown", ".txt"].includes(ext);
322
+
323
+ // Only include directories and markdown files
324
+ if (isDir || isMarkdown) {
325
+ items.push({
326
+ name: entry.name,
327
+ path: fullPath,
328
+ isDirectory: isDir,
329
+ });
330
+ }
331
+ }
332
+
333
+ // Sort: directories first, then files, alphabetically
334
+ items.sort((a, b) => {
335
+ if (a.isDirectory && !b.isDirectory) return -1;
336
+ if (!a.isDirectory && b.isDirectory) return 1;
337
+ return a.name.localeCompare(b.name);
338
+ });
339
+
340
+ res.json({
341
+ current: resolvedPath,
342
+ parent: path.dirname(resolvedPath),
343
+ items,
344
+ });
345
+ } catch (error) {
346
+ res.status(500).json({ error: error instanceof Error ? error.message : "Failed to browse" });
347
+ }
348
+ });
349
+
350
+ // Open any markdown file by absolute path
351
+ app.get("/api/open-file", async (req, res) => {
352
+ const filePath = req.query.path;
353
+ if (!filePath || typeof filePath !== "string") {
354
+ return res.status(400).json({ error: "Missing file path" });
355
+ }
356
+
357
+ try {
358
+ const resolvedPath = path.resolve(filePath);
359
+ const stats = await fsp.stat(resolvedPath);
360
+
361
+ if (!stats.isFile()) {
362
+ return res.status(400).json({ error: "Path is not a file" });
363
+ }
364
+
365
+ const content = await fsp.readFile(resolvedPath, "utf-8");
366
+ const html = marked.parse(content);
367
+
368
+ res.json({
369
+ path: resolvedPath,
370
+ name: path.basename(resolvedPath),
371
+ content,
372
+ html,
373
+ metadata: {
374
+ size: stats.size,
375
+ modifiedAt: stats.mtime,
376
+ createdAt: stats.birthtime,
377
+ },
378
+ });
379
+ } catch (error) {
380
+ res.status(404).json({ error: error instanceof Error ? error.message : "File not found" });
381
+ }
382
+ });
383
+
384
+ // Save any markdown file by absolute path
385
+ app.put("/api/open-file", async (req, res) => {
386
+ const filePath = req.query.path;
387
+ const { content, html } = req.body || {};
388
+
389
+ if (!filePath || typeof filePath !== "string") {
390
+ return res.status(400).json({ error: "Missing file path" });
391
+ }
392
+
393
+ let finalContent = content;
394
+ if (typeof html === "string") {
395
+ finalContent = turndownService.turndown(html);
396
+ }
397
+ if (typeof finalContent !== "string") {
398
+ return res.status(400).json({ error: "Missing content" });
399
+ }
400
+
401
+ try {
402
+ const resolvedPath = path.resolve(filePath);
403
+ await fsp.writeFile(resolvedPath, finalContent, "utf-8");
404
+ const stats = await fsp.stat(resolvedPath);
405
+ const renderedHtml = marked.parse(finalContent);
406
+
407
+ res.json({
408
+ path: resolvedPath,
409
+ name: path.basename(resolvedPath),
410
+ content: finalContent,
411
+ html: renderedHtml,
412
+ metadata: {
413
+ size: stats.size,
414
+ modifiedAt: stats.mtime,
415
+ createdAt: stats.birthtime,
416
+ },
417
+ });
418
+ } catch (error) {
419
+ res.status(500).json({ error: error instanceof Error ? error.message : "Failed to save file" });
420
+ }
421
+ });
422
+
300
423
  app.use(express.static(path.join(__dirname, "..", "public")));
301
424
 
302
425
  app.get("*", (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimmd",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Instant markdown preview in your browser. Zero config, GitHub-style rendering, inline editing.",
5
5
  "type": "module",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -912,9 +912,281 @@
912
912
  align-items: flex-start;
913
913
  }
914
914
  }
915
+
916
+ /* File browser modal */
917
+ .modal-overlay {
918
+ display: none;
919
+ position: fixed;
920
+ inset: 0;
921
+ background: rgba(0, 0, 0, 0.5);
922
+ z-index: 200;
923
+ align-items: center;
924
+ justify-content: center;
925
+ }
926
+
927
+ .modal-overlay.open {
928
+ display: flex;
929
+ }
930
+
931
+ .modal {
932
+ background: var(--panel);
933
+ border-radius: var(--radius);
934
+ box-shadow: var(--shadow);
935
+ width: 90%;
936
+ max-width: 600px;
937
+ max-height: 80vh;
938
+ display: flex;
939
+ flex-direction: column;
940
+ overflow: hidden;
941
+ }
942
+
943
+ .modal-header {
944
+ display: flex;
945
+ align-items: center;
946
+ justify-content: space-between;
947
+ padding: 16px 20px;
948
+ border-bottom: 1px solid var(--border);
949
+ }
950
+
951
+ .modal-title {
952
+ font-size: 16px;
953
+ font-weight: 600;
954
+ }
955
+
956
+ .modal-close {
957
+ width: 32px;
958
+ height: 32px;
959
+ border: none;
960
+ background: transparent;
961
+ border-radius: 6px;
962
+ cursor: pointer;
963
+ color: var(--muted);
964
+ display: flex;
965
+ align-items: center;
966
+ justify-content: center;
967
+ transition: background 0.15s ease, color 0.15s ease;
968
+ }
969
+
970
+ .modal-close:hover {
971
+ background: var(--hover-bg);
972
+ color: var(--ink);
973
+ }
974
+
975
+ .modal-close svg {
976
+ width: 18px;
977
+ height: 18px;
978
+ stroke: currentColor;
979
+ stroke-width: 2;
980
+ fill: none;
981
+ }
982
+
983
+ .modal-body {
984
+ flex: 1;
985
+ overflow-y: auto;
986
+ padding: 16px 20px;
987
+ display: flex;
988
+ flex-direction: column;
989
+ gap: 16px;
990
+ }
991
+
992
+ .path-input-wrap {
993
+ display: flex;
994
+ gap: 8px;
995
+ }
996
+
997
+ .path-input {
998
+ flex: 1;
999
+ border: 1px solid var(--border);
1000
+ border-radius: 8px;
1001
+ padding: 10px 12px;
1002
+ font-size: 14px;
1003
+ background: var(--panel-strong);
1004
+ color: var(--ink);
1005
+ font-family: var(--font-mono);
1006
+ outline: none;
1007
+ }
1008
+
1009
+ .path-input:focus {
1010
+ border-color: var(--accent);
1011
+ box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.15);
1012
+ }
1013
+
1014
+ .path-go-btn {
1015
+ padding: 10px 16px;
1016
+ border: 1px solid var(--accent);
1017
+ background: var(--accent);
1018
+ color: #fff;
1019
+ border-radius: 8px;
1020
+ cursor: pointer;
1021
+ font-size: 14px;
1022
+ font-family: var(--font-ui);
1023
+ transition: background 0.15s ease;
1024
+ }
1025
+
1026
+ .path-go-btn:hover {
1027
+ background: var(--accent-strong);
1028
+ }
1029
+
1030
+ .browser-nav {
1031
+ display: flex;
1032
+ align-items: center;
1033
+ gap: 8px;
1034
+ padding: 8px 12px;
1035
+ background: var(--folder-bg);
1036
+ border-radius: 8px;
1037
+ border: 1px solid var(--border);
1038
+ }
1039
+
1040
+ .browser-back {
1041
+ width: 28px;
1042
+ height: 28px;
1043
+ border: none;
1044
+ background: transparent;
1045
+ border-radius: 6px;
1046
+ cursor: pointer;
1047
+ color: var(--muted);
1048
+ display: flex;
1049
+ align-items: center;
1050
+ justify-content: center;
1051
+ transition: background 0.15s ease, color 0.15s ease;
1052
+ }
1053
+
1054
+ .browser-back:hover {
1055
+ background: var(--hover-bg);
1056
+ color: var(--ink);
1057
+ }
1058
+
1059
+ .browser-back svg {
1060
+ width: 16px;
1061
+ height: 16px;
1062
+ stroke: currentColor;
1063
+ stroke-width: 2;
1064
+ fill: none;
1065
+ }
1066
+
1067
+ .browser-path {
1068
+ flex: 1;
1069
+ font-size: 12px;
1070
+ color: var(--muted);
1071
+ font-family: var(--font-mono);
1072
+ overflow: hidden;
1073
+ text-overflow: ellipsis;
1074
+ white-space: nowrap;
1075
+ }
1076
+
1077
+ .browser-list {
1078
+ list-style: none;
1079
+ padding: 0;
1080
+ margin: 0;
1081
+ display: flex;
1082
+ flex-direction: column;
1083
+ gap: 2px;
1084
+ max-height: 300px;
1085
+ overflow-y: auto;
1086
+ }
1087
+
1088
+ .browser-item {
1089
+ display: flex;
1090
+ align-items: center;
1091
+ gap: 10px;
1092
+ padding: 10px 12px;
1093
+ border-radius: 8px;
1094
+ cursor: pointer;
1095
+ transition: background 0.15s ease;
1096
+ }
1097
+
1098
+ .browser-item:hover {
1099
+ background: var(--hover-bg);
1100
+ }
1101
+
1102
+ .browser-item svg {
1103
+ width: 18px;
1104
+ height: 18px;
1105
+ stroke: currentColor;
1106
+ stroke-width: 2;
1107
+ fill: none;
1108
+ flex-shrink: 0;
1109
+ color: var(--muted);
1110
+ }
1111
+
1112
+ .browser-item.folder svg {
1113
+ color: var(--accent);
1114
+ }
1115
+
1116
+ .browser-item-name {
1117
+ flex: 1;
1118
+ font-size: 14px;
1119
+ overflow: hidden;
1120
+ text-overflow: ellipsis;
1121
+ white-space: nowrap;
1122
+ }
1123
+
1124
+ .browser-empty {
1125
+ padding: 20px;
1126
+ text-align: center;
1127
+ color: var(--muted);
1128
+ font-size: 14px;
1129
+ }
1130
+
1131
+ .open-file-btn {
1132
+ display: flex;
1133
+ align-items: center;
1134
+ justify-content: center;
1135
+ gap: 6px;
1136
+ width: 100%;
1137
+ padding: 10px;
1138
+ border: 1px dashed var(--border);
1139
+ background: transparent;
1140
+ border-radius: 8px;
1141
+ cursor: pointer;
1142
+ font-size: 13px;
1143
+ color: var(--muted);
1144
+ font-family: var(--font-ui);
1145
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
1146
+ margin-bottom: 8px;
1147
+ }
1148
+
1149
+ .open-file-btn:hover {
1150
+ background: var(--hover-bg);
1151
+ border-color: var(--accent);
1152
+ color: var(--accent);
1153
+ }
1154
+
1155
+ .open-file-btn svg {
1156
+ width: 16px;
1157
+ height: 16px;
1158
+ stroke: currentColor;
1159
+ stroke-width: 2;
1160
+ fill: none;
1161
+ }
915
1162
  </style>
916
1163
  </head>
917
1164
  <body>
1165
+ <!-- File browser modal -->
1166
+ <div class="modal-overlay" id="file-modal">
1167
+ <div class="modal">
1168
+ <div class="modal-header">
1169
+ <div class="modal-title">Open File</div>
1170
+ <button class="modal-close" id="modal-close">
1171
+ <svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"></path></svg>
1172
+ </button>
1173
+ </div>
1174
+ <div class="modal-body">
1175
+ <div class="path-input-wrap">
1176
+ <input type="text" class="path-input" id="path-input" placeholder="Paste file path or type to search...">
1177
+ <button class="path-go-btn" id="path-go-btn">Open</button>
1178
+ </div>
1179
+ <div class="browser-nav">
1180
+ <button class="browser-back" id="browser-back" title="Go up">
1181
+ <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
1182
+ </button>
1183
+ <div class="browser-path" id="browser-path">~</div>
1184
+ </div>
1185
+ <ul class="browser-list" id="browser-list"></ul>
1186
+ </div>
1187
+ </div>
1188
+ </div>
1189
+
918
1190
  <div class="app">
919
1191
  <aside class="sidebar">
920
1192
  <div class="brand">
@@ -922,6 +1194,10 @@
922
1194
  <div class="pill" id="file-count">0 files</div>
923
1195
  </div>
924
1196
  <div class="folder" id="folder"></div>
1197
+ <button class="open-file-btn" id="open-file-btn">
1198
+ <svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
1199
+ Open File...
1200
+ </button>
925
1201
  <div class="search">
926
1202
  <input class="filter" id="filter" placeholder="Filter files..." />
927
1203
  <div class="search-hint">Cmd+K</div>
@@ -1057,6 +1333,8 @@
1057
1333
  currentContent: "",
1058
1334
  currentHtml: "",
1059
1335
  tocHeadings: [],
1336
+ openedFile: null, // For files opened via file browser (absolute path)
1337
+ browserPath: "",
1060
1338
  };
1061
1339
 
1062
1340
  const fileListEl = document.getElementById("file-list");
@@ -1081,6 +1359,14 @@
1081
1359
  const linkBtn = document.getElementById("link-btn");
1082
1360
  const widthToggle = document.getElementById("width-toggle");
1083
1361
  const focusBtn = document.getElementById("focus-btn");
1362
+ const openFileBtnEl = document.getElementById("open-file-btn");
1363
+ const fileModal = document.getElementById("file-modal");
1364
+ const modalCloseBtn = document.getElementById("modal-close");
1365
+ const pathInput = document.getElementById("path-input");
1366
+ const pathGoBtn = document.getElementById("path-go-btn");
1367
+ const browserBackBtn = document.getElementById("browser-back");
1368
+ const browserPathEl = document.getElementById("browser-path");
1369
+ const browserListEl = document.getElementById("browser-list");
1084
1370
 
1085
1371
  let tocTimer = null;
1086
1372
  let scrollRaf = null;
@@ -1168,6 +1454,7 @@
1168
1454
 
1169
1455
  async function openFile(fileId) {
1170
1456
  state.selectedId = fileId;
1457
+ state.openedFile = null; // Clear externally opened file
1171
1458
  renderList();
1172
1459
  setEditMode(false);
1173
1460
  metaEl.textContent = "";
@@ -1246,6 +1533,11 @@
1246
1533
  }
1247
1534
 
1248
1535
  async function saveFile() {
1536
+ // Handle externally opened files
1537
+ if (state.openedFile) {
1538
+ return saveExternalFile();
1539
+ }
1540
+
1249
1541
  if (!state.selectedId) return;
1250
1542
  const html = contentEl.innerHTML;
1251
1543
  saveEl.disabled = true;
@@ -1618,11 +1910,194 @@
1618
1910
  toggleFocusMode();
1619
1911
  });
1620
1912
 
1621
- // Escape key to exit focus mode
1913
+ // Escape key to exit focus mode or close modal
1622
1914
  document.addEventListener('keydown', (event) => {
1623
- if (event.key === 'Escape' && document.body.classList.contains('focus-mode')) {
1624
- document.body.classList.remove('focus-mode');
1915
+ if (event.key === 'Escape') {
1916
+ if (fileModal.classList.contains('open')) {
1917
+ closeFileModal();
1918
+ } else if (document.body.classList.contains('focus-mode')) {
1919
+ document.body.classList.remove('focus-mode');
1920
+ }
1921
+ }
1922
+ });
1923
+
1924
+ // File browser modal
1925
+ function openFileModal() {
1926
+ fileModal.classList.add('open');
1927
+ pathInput.value = '';
1928
+ pathInput.focus();
1929
+ browsePath(state.browserPath || '');
1930
+ }
1931
+
1932
+ function closeFileModal() {
1933
+ fileModal.classList.remove('open');
1934
+ }
1935
+
1936
+ async function browsePath(dirPath) {
1937
+ try {
1938
+ const res = await fetch(`/api/browse?path=${encodeURIComponent(dirPath)}`);
1939
+ if (!res.ok) {
1940
+ const err = await res.json();
1941
+ console.error('Browse error:', err.error);
1942
+ return;
1943
+ }
1944
+ const data = await res.json();
1945
+ state.browserPath = data.current;
1946
+ browserPathEl.textContent = data.current;
1947
+ renderBrowserList(data.items, data.parent);
1948
+ } catch (err) {
1949
+ console.error('Browse error:', err);
1625
1950
  }
1951
+ }
1952
+
1953
+ function renderBrowserList(items, parentPath) {
1954
+ browserListEl.innerHTML = '';
1955
+
1956
+ if (items.length === 0) {
1957
+ const empty = document.createElement('li');
1958
+ empty.className = 'browser-empty';
1959
+ empty.textContent = 'No markdown files in this folder';
1960
+ browserListEl.appendChild(empty);
1961
+ return;
1962
+ }
1963
+
1964
+ items.forEach(item => {
1965
+ const li = document.createElement('li');
1966
+ li.className = 'browser-item' + (item.isDirectory ? ' folder' : '');
1967
+
1968
+ const icon = item.isDirectory
1969
+ ? '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'
1970
+ : '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>';
1971
+
1972
+ li.innerHTML = `${icon}<span class="browser-item-name">${item.name}</span>`;
1973
+
1974
+ li.addEventListener('click', () => {
1975
+ if (item.isDirectory) {
1976
+ browsePath(item.path);
1977
+ } else {
1978
+ openExternalFile(item.path);
1979
+ closeFileModal();
1980
+ }
1981
+ });
1982
+
1983
+ browserListEl.appendChild(li);
1984
+ });
1985
+ }
1986
+
1987
+ async function openExternalFile(filePath) {
1988
+ try {
1989
+ const res = await fetch(`/api/open-file?path=${encodeURIComponent(filePath)}`);
1990
+ if (!res.ok) {
1991
+ alert('Failed to open file');
1992
+ return;
1993
+ }
1994
+ const data = await res.json();
1995
+
1996
+ // Clear selection from file list
1997
+ state.selectedId = null;
1998
+ state.openedFile = {
1999
+ path: data.path,
2000
+ name: data.name,
2001
+ };
2002
+
2003
+ renderList();
2004
+
2005
+ state.currentContent = data.content || '';
2006
+ state.currentHtml = data.html || '';
2007
+ contentEl.classList.remove('empty');
2008
+ contentEl.innerHTML = state.currentHtml;
2009
+ editToggleEl.disabled = false;
2010
+
2011
+ // Update header
2012
+ const displayName = data.name.replace(/\.md$/i, '');
2013
+ docTitleEl.textContent = displayName || 'Untitled';
2014
+ docPathEl.textContent = data.path;
2015
+ document.title = displayName ? `${displayName} - skimmd` : 'skimmd';
2016
+
2017
+ if (data.metadata) {
2018
+ const updated = new Date(data.metadata.modifiedAt).toLocaleString();
2019
+ metaEl.textContent = `Last updated ${updated}`;
2020
+ }
2021
+
2022
+ buildToc(contentEl);
2023
+ setEditMode(false);
2024
+ } catch (err) {
2025
+ console.error('Open file error:', err);
2026
+ alert('Failed to open file');
2027
+ }
2028
+ }
2029
+
2030
+ async function saveExternalFile() {
2031
+ if (!state.openedFile) return;
2032
+ const html = contentEl.innerHTML;
2033
+ saveEl.disabled = true;
2034
+ try {
2035
+ const res = await fetch(`/api/open-file?path=${encodeURIComponent(state.openedFile.path)}`, {
2036
+ method: 'PUT',
2037
+ headers: { 'Content-Type': 'application/json' },
2038
+ body: JSON.stringify({ html }),
2039
+ });
2040
+ if (!res.ok) {
2041
+ alert('Failed to save file');
2042
+ return;
2043
+ }
2044
+ const data = await res.json();
2045
+ state.currentContent = data.content || '';
2046
+ state.currentHtml = data.html || html;
2047
+ contentEl.innerHTML = state.currentHtml;
2048
+ if (data.metadata) {
2049
+ const updated = new Date(data.metadata.modifiedAt).toLocaleString();
2050
+ metaEl.textContent = `Last updated ${updated}`;
2051
+ }
2052
+ buildToc(contentEl);
2053
+ setEditMode(false);
2054
+ } finally {
2055
+ saveEl.disabled = false;
2056
+ }
2057
+ }
2058
+
2059
+ // Open file button
2060
+ openFileBtnEl.addEventListener('click', openFileModal);
2061
+
2062
+ // Modal close
2063
+ modalCloseBtn.addEventListener('click', closeFileModal);
2064
+ fileModal.addEventListener('click', (e) => {
2065
+ if (e.target === fileModal) closeFileModal();
2066
+ });
2067
+
2068
+ // Path input
2069
+ pathInput.addEventListener('keydown', (e) => {
2070
+ if (e.key === 'Enter') {
2071
+ const value = pathInput.value.trim();
2072
+ if (value) {
2073
+ // Check if it looks like a file path
2074
+ if (value.endsWith('.md') || value.endsWith('.markdown') || value.endsWith('.txt')) {
2075
+ openExternalFile(value);
2076
+ closeFileModal();
2077
+ } else {
2078
+ browsePath(value);
2079
+ }
2080
+ }
2081
+ }
2082
+ });
2083
+
2084
+ pathGoBtn.addEventListener('click', () => {
2085
+ const value = pathInput.value.trim();
2086
+ if (value) {
2087
+ if (value.endsWith('.md') || value.endsWith('.markdown') || value.endsWith('.txt')) {
2088
+ openExternalFile(value);
2089
+ closeFileModal();
2090
+ } else {
2091
+ browsePath(value);
2092
+ }
2093
+ }
2094
+ });
2095
+
2096
+ // Browser back button
2097
+ browserBackBtn.addEventListener('click', () => {
2098
+ const current = state.browserPath;
2099
+ const parent = current.split('/').slice(0, -1).join('/') || '/';
2100
+ browsePath(parent);
1626
2101
  });
1627
2102
 
1628
2103
  // Live reload via Server-Sent Events