projview 0.1.0 โ†’ 0.2.4

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/projview.js CHANGED
@@ -2,10 +2,12 @@
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { readFileSync } from 'node:fs';
5
6
  import { startServer } from '../src/server.js';
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const DEMO_DIR = path.join(__dirname, '..', 'docs'); // bundled sample docs
10
+ const VERSION = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
9
11
 
10
12
  function parseArgs(argv) {
11
13
  const opts = {
@@ -20,7 +22,9 @@ function parseArgs(argv) {
20
22
  if (arg === '--port' || arg === '-p') opts.port = Number(argv[++i]) || opts.port;
21
23
  else if (arg === '--no-open') opts.open = false;
22
24
  else if (arg === '--demo') opts.demo = true;
25
+ else if (arg === '--version' || arg === '-v') opts.version = true;
23
26
  else if (arg === '--help' || arg === '-h') opts.help = true;
27
+ else if (arg.startsWith('-')) opts.unknown = arg; // unrecognised flag -> error, don't treat as a path
24
28
  else rest.push(arg);
25
29
  }
26
30
  if (rest[0]) opts.root = path.resolve(process.cwd(), rest[0]);
@@ -49,15 +53,18 @@ options:
49
53
  -p, --port <n> preferred port (default: 4321, climbs if taken)
50
54
  --demo preview projview's bundled sample docs
51
55
  --no-open do not auto-open the browser
56
+ -v, --version print the version and exit
52
57
  -h, --help show this help
53
58
 
54
59
  nothing is written to disk - ctrl-c and it's gone.`;
55
60
 
56
61
  async function main() {
57
62
  const opts = parseArgs(process.argv.slice(2));
58
- if (opts.help) {
59
- console.log(HELP);
60
- return;
63
+ if (opts.help) { console.log(HELP); return; }
64
+ if (opts.version) { console.log(VERSION); return; }
65
+ if (opts.unknown) {
66
+ console.error(`projview: unknown option "${opts.unknown}"\nrun "projview --help" for usage.`);
67
+ process.exit(1);
61
68
  }
62
69
 
63
70
  const { port, close } = await startServer(opts.root, { port: opts.port, host: opts.host });
package/docs/README.md CHANGED
@@ -8,6 +8,7 @@ faults. use this tree while iterating on the tool.
8
8
 
9
9
  | area | doc | what it exercises |
10
10
  | --- | --- | --- |
11
+ | **setup** | [**how to set up**](how-to-set-up.md) | install / clone / use as a library + links to the repo & npm |
11
12
  | start | [getting started](getting-started.md) | headings h1-h6, code, tables, anchors, a valid diagram |
12
13
  | guides | [installation](guides/installation.md) | relative links up/down, one dead link |
13
14
  | guides | [configuration](guides/configuration.md) | explicit `{#id}` anchors, a missing-anchor link |
@@ -0,0 +1,130 @@
1
+ # how to set up projview
2
+
3
+ Everything you need to **install, run, or hack on** projview - a tiny local
4
+ previewer for markdown + mermaid files.
5
+
6
+ - ๐Ÿ“ฆ npm: <https://www.npmjs.com/package/projview>
7
+ - ๐Ÿ™ repo: <https://github.com/vikingsearth/projview>
8
+ - ๐Ÿ› issues: <https://github.com/vikingsearth/projview/issues>
9
+
10
+ ## requirements
11
+
12
+ | need | version |
13
+ | --- | --- |
14
+ | Node.js | **18+** |
15
+ | npm | ships with Node |
16
+
17
+ No build step, no database - indexing is in-memory and nothing is written to disk.
18
+
19
+ ## pick your path
20
+
21
+ ```mermaid
22
+ flowchart TD
23
+ Q{what do you want to do?}
24
+ Q -->|just try it, zero install| A["npx projview --demo"]
25
+ Q -->|preview my own docs often| B["npm i -g projview"]
26
+ Q -->|modify it / contribute| C["clone the repo"]
27
+ A --> R[browser opens on a local port]
28
+ B --> R
29
+ C --> R
30
+ ```
31
+
32
+ ## 1. run instantly with npx (no install)
33
+
34
+ ```bash
35
+ # preview the bundled sample docs (this very doc set)
36
+ npx projview --demo
37
+
38
+ # preview the current directory
39
+ npx projview
40
+
41
+ # point it somewhere, on a chosen port, without auto-opening the browser
42
+ npx projview ./docs --port 5000 --no-open
43
+ ```
44
+
45
+ `npx` downloads + runs the latest published version, then cleans up.
46
+
47
+ ## 2. install the CLI globally
48
+
49
+ ```bash
50
+ npm i -g projview # adds `projview` to your PATH
51
+ projview --demo # try the sample docs
52
+ projview ~/notes # preview any directory
53
+ ```
54
+
55
+ To update later: `npm i -g projview@latest`. To remove: `npm rm -g projview`.
56
+
57
+ ## 3. clone the repo + run from source
58
+
59
+ For hacking on projview itself, or running an unpublished build:
60
+
61
+ ```bash
62
+ git clone https://github.com/vikingsearth/projview.git
63
+ cd projview
64
+ npm install
65
+
66
+ npm run demo # preview the bundled docs/ (alias for --demo)
67
+ npm start # index the current directory
68
+ node bin/projview.js ./docs # or call the CLI directly
69
+ npm test # run the test suite (node:test, 23 tests)
70
+ ```
71
+
72
+ See the [project README](README.md) for the architecture and the
73
+ [api reference](reference/api.md) for the HTTP surface.
74
+
75
+ ## CLI reference
76
+
77
+ | argument / flag | meaning |
78
+ | --- | --- |
79
+ | `[path]` | directory to index (default: current directory) |
80
+ | `--demo` | preview projview's own bundled sample docs |
81
+ | `-p`, `--port <n>` | preferred port (default `4321`, climbs if taken) |
82
+ | `--no-open` | don't auto-open the browser |
83
+ | `-h`, `--help` | show help |
84
+
85
+ **Environment variables** (handy for containers / servers):
86
+
87
+ | var | default | use |
88
+ | --- | --- | --- |
89
+ | `PORT` | `4321` | port to serve on |
90
+ | `HOST` | `127.0.0.1` | interface to bind (`0.0.0.0` to expose) |
91
+
92
+ ## use the source directly (advanced)
93
+
94
+ The server is a small ES module you can embed. There's no stable public API yet,
95
+ so treat this as internal:
96
+
97
+ ```js
98
+ import { startServer } from 'projview/src/server.js';
99
+
100
+ const { port, close } = await startServer(process.cwd(), { port: 4321, host: '127.0.0.1' });
101
+ console.log(`serving on http://127.0.0.1:${port}`);
102
+ // ... later
103
+ await close();
104
+ ```
105
+
106
+ The client is plain ES modules under `public/` (`main.js` wires it together) -
107
+ no bundler, so you can read and tweak it directly.
108
+
109
+ ## run it as a container (optional)
110
+
111
+ The repo ships a `Dockerfile` that serves the demo docs on port `8080`:
112
+
113
+ ```bash
114
+ docker build -t projview-demo .
115
+ docker run -p 8080:8080 projview-demo # then open http://localhost:8080
116
+ ```
117
+
118
+ > Behind a TLS-intercepting proxy? Pass your corporate CA as a build secret:
119
+ > `docker build --secret id=cacert,src=corp-ca.pem -t projview-demo .` - the
120
+ > cert is used only during install and never written into an image layer.
121
+
122
+ ## links & references
123
+
124
+ | | |
125
+ | --- | --- |
126
+ | repository | <https://github.com/vikingsearth/projview> |
127
+ | npm package | <https://www.npmjs.com/package/projview> |
128
+ | report a bug | <https://github.com/vikingsearth/projview/issues> |
129
+ | license | MIT |
130
+ | more docs | [getting started](getting-started.md) ยท [api reference](reference/api.md) ยท [glossary](reference/glossary.md) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projview",
3
- "version": "0.1.0",
3
+ "version": "0.2.4",
4
4
  "description": "Ephemeral local previewer for markdown + mermaid files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -9,15 +9,24 @@
9
9
  <title>projview</title>
10
10
  <link rel="stylesheet" href="/vendor/hljs.css" />
11
11
  <link rel="stylesheet" href="/style.css" />
12
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐Ÿ““</text></svg>" />
12
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'><rect x='2' y='4.5' width='20' height='15' rx='4' stroke='%23b4befe' stroke-width='1.8'/><line x1='8.5' y1='4.5' x2='8.5' y2='19.5' stroke='%23b4befe' stroke-width='1.8'/><circle cx='13.4' cy='9.4' r='1.25' fill='%23cba6f7'/><circle cx='18' cy='9.4' r='1.25' fill='%23cba6f7'/><circle cx='15.7' cy='14.6' r='1.25' fill='%23cba6f7'/></svg>" />
13
13
  </head>
14
14
  <body class="toc-empty">
15
15
  <aside id="sidebar">
16
16
  <header id="brand">
17
- <span class="logo">๐Ÿ““</span>
17
+ <span class="logo" aria-hidden="true">
18
+ <svg viewBox="0 0 24 24" width="26" height="26" fill="none">
19
+ <rect x="2" y="4.5" width="20" height="15" rx="4" stroke="#b4befe" stroke-width="1.8"/>
20
+ <line x1="8.5" y1="4.5" x2="8.5" y2="19.5" stroke="#b4befe" stroke-width="1.8"/>
21
+ <circle cx="13.4" cy="9.4" r="1.25" fill="#cba6f7"/>
22
+ <circle cx="18" cy="9.4" r="1.25" fill="#cba6f7"/>
23
+ <circle cx="15.7" cy="14.6" r="1.25" fill="#cba6f7"/>
24
+ <path d="M13.4 9.4 15.7 14.6 18 9.4" stroke="#cba6f7" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
25
+ </svg>
26
+ </span>
18
27
  <div class="brand-text">
19
- <strong id="root-name">projview</strong>
20
- <small id="live-status"><span class="dot"></span> live</small>
28
+ <strong class="wordmark"><span class="wm-proj">proj</span><span class="wm-view">view</span></strong>
29
+ <small id="version" class="version"></small>
21
30
  </div>
22
31
  </header>
23
32
  <button id="open-palette" class="open-palette" title="search (โŒ˜K)">
package/public/live.js CHANGED
@@ -5,7 +5,7 @@ import { state } from './store.js';
5
5
  import { loadTree } from './tree.js';
6
6
  import { openFile } from './viewer.js';
7
7
 
8
- const liveStatusEl = $('live-status');
8
+ const versionEl = $('version');
9
9
 
10
10
  export function connectEvents() {
11
11
  const es = new EventSource('/api/events');
@@ -14,18 +14,12 @@ export function connectEvents() {
14
14
  if (path === state.currentPath) { pulse(); openFile(path); }
15
15
  });
16
16
  es.addEventListener('tree', async () => { pulse(); await loadTree(); });
17
- es.onerror = () => setLive(false);
18
- es.addEventListener('ready', () => setLive(true));
19
- }
20
-
21
- function setLive(on) {
22
- liveStatusEl.classList.toggle('off', !on);
23
- liveStatusEl.querySelector('.dot')?.classList.toggle('off', !on);
24
17
  }
25
18
 
19
+ // brief flash of the version line as live-reload feedback
26
20
  let pulseTimer;
27
21
  function pulse() {
28
- liveStatusEl.classList.add('pulse');
22
+ versionEl.classList.add('pulse');
29
23
  clearTimeout(pulseTimer);
30
- pulseTimer = setTimeout(() => liveStatusEl.classList.remove('pulse'), 600);
24
+ pulseTimer = setTimeout(() => versionEl.classList.remove('pulse'), 700);
31
25
  }
package/public/style.css CHANGED
@@ -58,40 +58,23 @@ body {
58
58
  #brand {
59
59
  display: flex;
60
60
  align-items: center;
61
- gap: 12px;
62
- padding: 18px 16px;
61
+ gap: 11px;
62
+ padding: 16px 16px;
63
63
  border-bottom: 2px solid var(--surface0);
64
64
  }
65
65
 
66
- #brand .logo {
67
- font-size: 28px;
68
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
69
- }
66
+ #brand .logo svg { display: block; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35)); }
70
67
 
71
- .brand-text { display: flex; flex-direction: column; line-height: 1.2; }
72
- .brand-text strong { font-size: 17px; color: var(--lavender); letter-spacing: 0.2px; }
68
+ .brand-text { display: flex; flex-direction: column; line-height: 1.25; }
69
+ .wordmark { font-size: 17px; font-weight: 800; letter-spacing: 0.2px; }
70
+ .wordmark .wm-proj { color: var(--lavender); }
71
+ .wordmark .wm-view { color: var(--mauve); }
73
72
 
74
- #live-status {
75
- display: inline-flex;
76
- align-items: center;
77
- gap: 6px;
78
- color: var(--green);
79
- font-size: 12px;
80
- font-weight: 600;
81
- }
82
- #live-status .dot {
83
- width: 8px; height: 8px; border-radius: 50%;
84
- background: var(--green);
85
- box-shadow: 0 0 8px var(--green);
86
- transition: background 0.2s, box-shadow 0.2s;
87
- }
88
- #live-status.off { color: var(--overlay); }
89
- #live-status .dot.off { background: var(--overlay); box-shadow: none; }
90
- #live-status.pulse .dot { animation: blip 0.6s ease; }
91
- @keyframes blip {
92
- 0% { transform: scale(1); }
93
- 40% { transform: scale(2.2); }
94
- 100% { transform: scale(1); }
73
+ .version { color: var(--overlay); font-size: 11px; font-weight: 500; letter-spacing: 0.3px; }
74
+ .version.pulse { animation: vpulse 0.7s ease; }
75
+ @keyframes vpulse {
76
+ 0% { color: var(--green); }
77
+ 100% { color: var(--overlay); }
95
78
  }
96
79
 
97
80
  .open-palette {
package/public/toc.js CHANGED
@@ -8,6 +8,11 @@ const viewerEl = $('viewer');
8
8
  const tocListEl = $('toc-list');
9
9
 
10
10
  let tocEntries = []; // [{ id, el, link }]
11
+ let spyLock = 0; // timestamp until which scroll-spy yields to a click
12
+
13
+ function setActive(current) {
14
+ for (const entry of tocEntries) entry.link.classList.toggle('active', entry === current);
15
+ }
11
16
 
12
17
  export function buildToc() {
13
18
  const headings = [...contentEl.querySelectorAll('h1, h2, h3')].filter((h) => h.id);
@@ -23,22 +28,27 @@ export function buildToc() {
23
28
  const link = document.createElement('button');
24
29
  link.className = `toc-item lvl-${h.tagName.toLowerCase()}`;
25
30
  link.textContent = h.textContent;
26
- link.addEventListener('click', () => scrollToFragment(h.id));
31
+ const entry = { id: h.id, el: h, link };
32
+ link.addEventListener('click', () => {
33
+ scrollToFragment(h.id);
34
+ setActive(entry); // clicked item wins immediately
35
+ spyLock = performance.now() + 700; // and holds briefly against scroll-spy
36
+ });
27
37
  frag.appendChild(link);
28
- tocEntries.push({ id: h.id, el: h, link });
38
+ tocEntries.push(entry);
29
39
  }
30
40
  tocListEl.replaceChildren(frag);
31
41
  updateScrollSpy();
32
42
  }
33
43
 
34
44
  function updateScrollSpy() {
35
- if (!tocEntries.length) return;
45
+ if (!tocEntries.length || performance.now() < spyLock) return; // a recent click wins
36
46
  const top = viewerEl.scrollTop + 90;
37
47
  let current = tocEntries[0];
38
48
  for (const entry of tocEntries) {
39
49
  if (entry.el.offsetTop <= top) current = entry; else break;
40
50
  }
41
- for (const entry of tocEntries) entry.link.classList.toggle('active', entry === current);
51
+ setActive(current);
42
52
  }
43
53
 
44
54
  let spyRaf = 0;
package/public/tree.js CHANGED
@@ -7,16 +7,16 @@ import { openFile } from './viewer.js';
7
7
 
8
8
  const treeEl = $('tree');
9
9
  const fileCountEl = $('file-count');
10
- const rootNameEl = $('root-name');
10
+ const versionEl = $('version');
11
11
  let fileCount = 0;
12
12
 
13
13
  export async function loadTree() {
14
14
  const res = await fetch('/api/tree');
15
- const { root, tree } = await res.json();
15
+ const { root, tree, version } = await res.json();
16
16
  state.treeData = tree;
17
17
  state.rootName = root;
18
- rootNameEl.textContent = root;
19
18
  document.title = `${root} ยท projview`;
19
+ if (version) versionEl.textContent = `version ${version}`;
20
20
  renderTree();
21
21
  }
22
22
 
package/public/viewer.js CHANGED
@@ -37,7 +37,9 @@ export async function openFile(p, fragment, highlight) {
37
37
  }
38
38
 
39
39
  export function scrollToFragment(frag) {
40
- const el = document.getElementById(frag);
40
+ // headings are rendered with a "pv-" id prefix (collision-proof); try that
41
+ // first, then fall back to a raw id and an already-prefixed value (TOC).
42
+ const el = document.getElementById(`pv-${frag}`) || document.getElementById(frag);
41
43
  // instant, not smooth: smooth scrollIntoView is a no-op on this overflow container
42
44
  if (el) el.scrollIntoView({ block: 'start' });
43
45
  else toast(`no section "#${frag}" on this page`);
package/src/render.js CHANGED
@@ -77,7 +77,9 @@ md.core.ruler.push('heading_anchors', (state) => {
77
77
  let n = 1;
78
78
  while (used[unique]) unique = `${id}-${n++}`;
79
79
  used[unique] = true;
80
- tokens[i].attrSet('id', unique);
80
+ // prefix so heading ids can never collide with the app's own element ids
81
+ // (e.g. a "## viewer" heading vs the <main id="viewer"> container)
82
+ tokens[i].attrSet('id', `pv-${unique}`);
81
83
  }
82
84
  });
83
85
 
package/src/server.js CHANGED
@@ -11,6 +11,7 @@ import { buildIndex, updateFile, removeFile, search } from './search.js';
11
11
 
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  const PUBLIC_DIR = path.join(__dirname, '..', 'public');
14
+ const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
14
15
  // vendored client libs (mermaid bundle, hljs theme) live in public/vendor/ and
15
16
  // are served by the static handler below - no node_modules resolution needed.
16
17
 
@@ -72,7 +73,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1' } = {}
72
73
 
73
74
  // --- file tree ---
74
75
  if (pathname === '/api/tree') {
75
- sendJson(res, { root: path.basename(root) || root, tree: buildTree(root) });
76
+ sendJson(res, { root: path.basename(root) || root, tree: buildTree(root), version: VERSION });
76
77
  return;
77
78
  }
78
79