projview 1.0.0 → 1.1.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  a fast, zero-config local viewer for **markdown** + **mermaid** files. run it in
4
4
  any directory, get a soft-dark cartoonish localhost view of every `.md` / `.mmd`
5
- under it - rendered diagrams, search, live reload - close it and nothing's left
5
+ (plus `.json` / `.yaml`) under it - rendered diagrams, search, live reload - close it and nothing's left
6
6
  behind.
7
7
 
8
8
  that's the product. point it at a docs folder, read comfortably, ctrl-c, gone.
@@ -33,8 +33,9 @@ projview /path/to/notes --no-open
33
33
 
34
34
  | | |
35
35
  | --- | --- |
36
- | tree nav | every `.md` / `.mmd` under the root (skips `node_modules`, `dist`, dot-dirs) |
36
+ | tree nav | every `.md` / `.mmd` / `.json` / `.yml` / `.yaml` under the root (skips `node_modules`, `dist`, dot-dirs, lockfiles) |
37
37
  | rendering | markdown + syntax-highlighted code + **mermaid diagrams** (with pan/zoom) |
38
+ | data files | JSON pretty-printed (literals kept exact), YAML shown as written - both with line numbers + indent guides |
38
39
  | search | `⌘K` palette - fuzzy filenames + full-text content with line snippets |
39
40
  | live reload | edit a file, the browser updates |
40
41
  | ephemeral | in-memory by default; nothing written to disk, nothing left behind |
package/docs/README.md CHANGED
@@ -16,6 +16,9 @@ faults. use this tree while iterating on the tool.
16
16
  | diagrams | [architecture](diagrams/architecture.mmd) | standalone valid flowchart |
17
17
  | diagrams | [sequence](diagrams/sequence.mmd) | standalone valid sequence |
18
18
  | diagrams | [broken diagram](diagrams/broken-diagram.mmd) | **invalid mermaid** - error card test |
19
+ | data | [minified json](data/minified.json) | one-line JSON -> pretty-printed, big int kept exact |
20
+ | data | [not-strict json](data/not-strict.jsonc.json) | JSONC with comments - shown as-is with a notice |
21
+ | data | [values.yaml](data/values.yaml) | helm-style YAML - indent guides, block scalar, multi-doc |
19
22
  | reference | [api](reference/api.md) | long doc - TOC + scroll-spy |
20
23
  | reference | [glossary](reference/glossary.md) | cross-links + external links |
21
24
  | edge | [syntax stress](edge-cases/syntax-stress.md) | gnarly markdown + inline broken fence |
@@ -0,0 +1 @@
1
+ {"name":"lgtm","version":"2.4.1","replicas":3,"bigId":12345678901234567890,"enabled":true,"labels":{"app.kubernetes.io/name":"lgtm","tier":"observability"},"ports":[{"name":"http","port":3000},{"name":"grpc","port":9095}],"empty":{},"tags":[],"note":"commas, colons: and {braces} inside strings stay put"}
@@ -0,0 +1,4 @@
1
+ {
2
+ // comments make this JSONC, not JSON - shown as-is with a note
3
+ "compilerOptions": { "strict": true },
4
+ }
@@ -0,0 +1,36 @@
1
+ # a helm-style values file - indentation is meaning, so it's shown as written
2
+ replicaCount: 2
3
+
4
+ image:
5
+ repository: ghcr.io/example/alloy
6
+ tag: "v1.4.2"
7
+ pullPolicy: IfNotPresent
8
+
9
+ resources:
10
+ limits:
11
+ cpu: 500m
12
+ memory: 512Mi
13
+ requests:
14
+ cpu: 100m
15
+ memory: 128Mi
16
+
17
+ config: |
18
+ logging {
19
+ level = "info"
20
+ }
21
+
22
+ discovery.kubernetes "pods" {
23
+ role = "pod"
24
+ }
25
+
26
+ tolerations:
27
+ - key: dedicated
28
+ operator: Equal
29
+ value: observability
30
+ effect: NoSchedule
31
+ ---
32
+ # second document in the same file
33
+ apiVersion: v1
34
+ kind: ConfigMap
35
+ metadata:
36
+ name: {{ .Release.Name }}-extra
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projview",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Ephemeral local previewer for markdown + mermaid files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
package/public/icons.js CHANGED
@@ -4,4 +4,10 @@ export const SVG_FOLDER = `<svg viewBox="0 0 24 24" fill="none" stroke="currentC
4
4
  export const SVG_FILE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>`;
5
5
  export const SVG_DIAGRAM = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="19" r="2.6"/><line x1="8.4" y1="10.7" x2="15.6" y2="6.3"/><line x1="8.4" y1="13.3" x2="15.6" y2="17.7"/></svg>`;
6
6
 
7
- export const fileIcon = (ext) => (ext === '.mmd' ? SVG_DIAGRAM : SVG_FILE);
7
+ export const SVG_DATA = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>`;
8
+
9
+ // The client's one list of previewable types: ext -> kind (null if not previewable).
10
+ const KINDS = { '.md': 'md', '.mmd': 'mmd', '.json': 'data', '.yml': 'data', '.yaml': 'data' };
11
+ export const fileKind = (name) => KINDS[(name.match(/\.[^./]+$/)?.[0] || '').toLowerCase()] || null;
12
+
13
+ export const fileIcon = (name) => ({ mmd: SVG_DIAGRAM, data: SVG_DATA }[fileKind(name)] || SVG_FILE);
package/public/links.js CHANGED
@@ -5,6 +5,7 @@ import { state } from './store.js';
5
5
  import { resolveRelative } from './paths.js';
6
6
  import { openFile, scrollToFragment } from './viewer.js';
7
7
  import { toast } from './toast.js';
8
+ import { fileKind } from './icons.js';
8
9
 
9
10
  const contentEl = $('content');
10
11
 
@@ -26,6 +27,6 @@ contentEl.addEventListener('click', (e) => {
26
27
 
27
28
  const [pathPart, frag] = href.split('#');
28
29
  const target = resolveRelative(state.currentPath, pathPart);
29
- if (/\.(md|mmd)$/i.test(target)) openFile(target, frag);
30
- else toast(`can't preview "${pathPart}" - only .md / .mmd for now`);
30
+ if (fileKind(target)) openFile(target, frag);
31
+ else toast(`can't preview "${pathPart}" - only .md / .mmd / .json / .yaml for now`);
31
32
  });
package/public/search.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* projview cmd+K search palette - fuzzy filenames + full-text contents */
2
2
 
3
3
  import { $, esc } from './dom.js';
4
- import { SVG_FILE, SVG_DIAGRAM } from './icons.js';
4
+ import { fileIcon as icon } from './icons.js';
5
5
  import { openFile } from './viewer.js';
6
6
 
7
7
  const paletteEl = $('palette');
@@ -9,8 +9,6 @@ const inputEl = $('palette-input');
9
9
  const resultsEl = $('palette-results');
10
10
  const openBtn = $('open-palette');
11
11
 
12
- const icon = (name) => (name.endsWith('.mmd') ? SVG_DIAGRAM : SVG_FILE);
13
-
14
12
  let selectable = []; // flat list of openable results
15
13
  let selected = 0;
16
14
  let debounce;
package/public/style.css CHANGED
@@ -143,6 +143,7 @@ body {
143
143
  .row.dir .ico { color: var(--subtext); }
144
144
  .row .ico-md { color: var(--overlay); }
145
145
  .row .ico-mmd { color: var(--mauve); }
146
+ .row .ico-data { color: var(--peach); }
146
147
  .row .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
147
148
  .row.dir .label { font-weight: 600; color: var(--text); }
148
149
 
@@ -276,6 +277,40 @@ body {
276
277
 
277
278
  .markdown-body ul, .markdown-body ol { padding-left: 1.5em; }
278
279
 
280
+ /* json / yaml code view: line numbers + one indent guide per level */
281
+ .markdown-body pre.code-view { padding: 14px 0; line-height: 1.6; counter-reset: ln; }
282
+ .markdown-body pre.code-view code { display: block; min-width: max-content; }
283
+ .code-view .cl { display: flex; min-height: 1.6em; padding-right: 18px; }
284
+ .code-view .cl::before {
285
+ counter-increment: ln;
286
+ content: counter(ln);
287
+ flex: 0 0 5ch;
288
+ padding-right: 1.5ch;
289
+ margin-right: 1.5ch;
290
+ text-align: right;
291
+ color: var(--surface2);
292
+ border-right: 1px solid var(--surface0);
293
+ user-select: none;
294
+ }
295
+ .code-view .cl:hover { background: rgba(180, 190, 254, 0.05); }
296
+ .code-view .cl:hover::before { color: var(--overlay); }
297
+ .code-view .lc {
298
+ white-space: pre;
299
+ background-image: repeating-linear-gradient(90deg,
300
+ var(--surface2) 0 1px, transparent 1px calc(var(--step) * 1ch));
301
+ background-size: calc(var(--i) * 1ch) 100%;
302
+ background-repeat: no-repeat;
303
+ }
304
+ .data-notice {
305
+ margin: 0 0 12px;
306
+ padding: 8px 14px;
307
+ border: 1px solid var(--peach);
308
+ border-radius: var(--radius-sm);
309
+ background: rgba(250, 179, 135, 0.06);
310
+ color: var(--peach);
311
+ font-size: 0.85em;
312
+ }
313
+
279
314
  /* mermaid card */
280
315
  .mermaid {
281
316
  display: flex;
package/public/tree.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* left-nav file tree: render, expand/collapse, reveal */
2
2
 
3
3
  import { $, esc, cssEsc } from './dom.js';
4
- import { SVG_FOLDER, fileIcon } from './icons.js';
4
+ import { SVG_FOLDER, fileIcon, fileKind } from './icons.js';
5
5
  import { state } from './store.js';
6
6
  import { openFile } from './viewer.js';
7
7
 
@@ -70,7 +70,7 @@ function renderFileRow(node) {
70
70
  row.dataset.path = node.path;
71
71
  row.innerHTML =
72
72
  `<span class="twist"></span>` +
73
- `<span class="ico ico-${node.ext === '.mmd' ? 'mmd' : 'md'}">${fileIcon(node.ext)}</span>` +
73
+ `<span class="ico ico-${fileKind(node.name)}">${fileIcon(node.name)}</span>` +
74
74
  `<span class="label">${esc(node.name)}</span>`;
75
75
  row.addEventListener('click', () => openFile(node.path));
76
76
  return row;
package/src/render.js CHANGED
@@ -92,6 +92,144 @@ export function renderMermaid(content) {
92
92
  return `<div class="mermaid">${escapeHtml(content)}</div>\n`;
93
93
  }
94
94
 
95
+ // Re-indent JSON by walking its tokens rather than parse -> stringify, so the
96
+ // original literals survive untouched (big ints, 1.0, duplicate keys, key order).
97
+ // Assumes the input is already valid JSON (callers check with JSON.parse first).
98
+ export function formatJson(src, indent = 2) {
99
+ const pad = (n) => '\n' + ' '.repeat(n * indent);
100
+ let out = '';
101
+ let depth = 0;
102
+ for (let i = 0; i < src.length; i++) {
103
+ const c = src[i];
104
+ if (c === '"') {
105
+ let j = i + 1;
106
+ while (j < src.length && src[j] !== '"') j += src[j] === '\\' ? 2 : 1;
107
+ out += src.slice(i, j + 1);
108
+ i = j;
109
+ } else if (c === '{' || c === '[') {
110
+ // keep empty containers compact: {} / []
111
+ let j = i + 1;
112
+ while (/\s/.test(src[j] || '')) j++;
113
+ if (src[j] === (c === '{' ? '}' : ']')) { out += c + src[j]; i = j; continue; }
114
+ out += c + pad(++depth);
115
+ } else if (c === '}' || c === ']') {
116
+ out += pad(--depth) + c;
117
+ } else if (c === ',') {
118
+ out += ',' + pad(depth);
119
+ } else if (c === ':') {
120
+ out += ': ';
121
+ } else if (!/\s/.test(c)) {
122
+ out += c;
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // Split highlight.js output into lines, closing + reopening any <span> that
129
+ // crosses a newline so each line is self-contained HTML.
130
+ function splitHighlightedLines(html) {
131
+ const lines = [];
132
+ const open = [];
133
+ let cur = '';
134
+ for (const m of html.matchAll(/<span[^>]*>|<\/span>|\n|[^<\n]+/g)) {
135
+ const tok = m[0];
136
+ if (tok === '\n') {
137
+ lines.push(cur + '</span>'.repeat(open.length));
138
+ cur = open.join('');
139
+ } else {
140
+ if (tok.startsWith('<span')) open.push(tok);
141
+ else if (tok === '</span>') open.pop();
142
+ cur += tok;
143
+ }
144
+ }
145
+ lines.push(cur + '</span>'.repeat(open.length));
146
+ return lines;
147
+ }
148
+
149
+ const leadingIndent = (line) => line.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
150
+
151
+ // The file's indent unit: the most common step between a line and a deeper next
152
+ // line. A mode, not a gcd, so one oddly indented line can't skew it. Default 2.
153
+ function indentStep(indents) {
154
+ const counts = new Map();
155
+ let prev = 0;
156
+ for (const n of indents) {
157
+ if (n === -1) continue;
158
+ const d = n - prev;
159
+ if (d >= 2 && d <= 8) counts.set(d, (counts.get(d) || 0) + 1);
160
+ prev = n;
161
+ }
162
+ let best = 2, bestCount = 0;
163
+ for (const [d, c] of counts) if (c > bestCount || (c === bestCount && d < best)) { best = d; bestCount = c; }
164
+ return best;
165
+ }
166
+
167
+ // Big files skip syntax highlighting - hljs gets slow and nobody reads 2MB of JSON by colour.
168
+ const HIGHLIGHT_LIMIT = 2_000_000;
169
+ // Past this many lines only the head is rendered - the DOM, not the server, is the limit.
170
+ export const LINE_LIMIT = 50_000;
171
+
172
+ // A code view with line numbers and indent guides. Each line carries its
173
+ // indent depth (--i) so CSS can draw one guide per level (--step columns apart).
174
+ function renderCodeView(code, lang, note = '') {
175
+ let rawLines = code.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n');
176
+ if (rawLines.length > LINE_LIMIT) {
177
+ note += notice(`showing the first ${LINE_LIMIT.toLocaleString('en')} of ${rawLines.length.toLocaleString('en')} lines`);
178
+ rawLines = rawLines.slice(0, LINE_LIMIT);
179
+ }
180
+ const raw = rawLines.join('\n');
181
+ const html = raw.length <= HIGHLIGHT_LIMIT && hljs.getLanguage(lang)
182
+ ? hljs.highlight(raw, { language: lang, ignoreIllegals: true }).value
183
+ : escapeHtml(raw);
184
+ const lines = splitHighlightedLines(html);
185
+
186
+ // blank lines borrow the shallower neighbour's indent so guides don't break
187
+ const indents = rawLines.map((l) => (l.trim() ? leadingIndent(l) : -1));
188
+ const step = indentStep(indents);
189
+ const next = new Array(indents.length);
190
+ for (let i = indents.length - 1, n = 0; i >= 0; i--) next[i] = n = indents[i] === -1 ? n : indents[i];
191
+ for (let i = 0, prev = 0; i < indents.length; i++) {
192
+ if (indents[i] === -1) indents[i] = Math.min(prev, next[i]);
193
+ else prev = indents[i];
194
+ }
195
+
196
+ const body = lines
197
+ .map((l, i) => `<span class="cl" style="--i:${indents[i]}"><span class="lc">${l}</span></span>`)
198
+ .join('');
199
+ return `${note}<pre class="hljs code-view" data-lang="${lang}" style="--step:${step}"><code>${body}</code></pre>\n`;
200
+ }
201
+
202
+ const notice = (msg) => `<div class="data-notice">${escapeHtml(msg)}</div>\n`;
203
+
204
+ // The text a JSON file is displayed as: re-indented when valid, as written when
205
+ // not (e.g. JSONC). Shared with the search index so snippet line numbers match
206
+ // the viewer's. A leading BOM is dropped - JSON.parse rejects it.
207
+ export function jsonDisplayText(content) {
208
+ const src = content.replace(/^\uFEFF/, '');
209
+ if (!src.trim()) return { text: '' };
210
+ try {
211
+ JSON.parse(src);
212
+ } catch (err) {
213
+ return { text: src, error: err.message };
214
+ }
215
+ return { text: formatJson(src) };
216
+ }
217
+
218
+ // JSON: pretty-printed when valid; shown as-is (with a note) when it isn't.
219
+ export function renderJson(content) {
220
+ const { text, error } = jsonDisplayText(content);
221
+ return renderCodeView(text, 'json', error ? notice(`not strict JSON, shown as-is - ${error}`) : '');
222
+ }
223
+
224
+ // YAML: indentation is meaning, so it's shown exactly as written (highlighted,
225
+ // with guides) rather than re-serialised - which would also choke on Helm templates.
226
+ export function renderYaml(content) {
227
+ return renderCodeView(content, 'yaml');
228
+ }
229
+
95
230
  export function renderFile(ext, content) {
96
- return ext === '.mmd' ? renderMermaid(content) : renderMarkdown(content);
231
+ if (ext === '.mmd') return renderMermaid(content);
232
+ if (ext === '.json') return renderJson(content);
233
+ if (ext === '.yml' || ext === '.yaml') return renderYaml(content);
234
+ return renderMarkdown(content);
97
235
  }
package/src/search.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { buildTree } from './walk.js';
4
+ import { jsonDisplayText } from './render.js';
4
5
  import { createStore } from './store.js';
5
6
 
6
7
  // In-memory index: relPath -> { path, name, content, lower }. Search always runs
@@ -17,6 +18,8 @@ function flattenFiles(nodes, acc = []) {
17
18
  }
18
19
 
19
20
  function addToIndex(rel, content) {
21
+ // index JSON as displayed (re-indented) so snippet line numbers match the viewer
22
+ if (rel.toLowerCase().endsWith('.json')) content = jsonDisplayText(content).text;
20
23
  index.set(rel, { path: rel, name: rel.split('/').pop(), content, lower: content.toLowerCase() });
21
24
  }
22
25
 
package/src/server.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import chokidar from 'chokidar';
7
7
 
8
- import { buildTree, isIgnoredDir, isPreviewable } from './walk.js';
8
+ import { buildTree, isHiddenPath, isIgnoredDir, isPreviewable } from './walk.js';
9
9
  import { renderFile } from './render.js';
10
10
  import { buildIndex, updateFile, removeFile, search } from './search.js';
11
11
  import { createStore, gitOrOsUser } from './store.js';
@@ -116,7 +116,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
116
116
  if (pathname === '/api/file') {
117
117
  const rel = url.searchParams.get('p') || '';
118
118
  const abs = resolveInRoot(root, rel);
119
- if (!abs || !isPreviewable(abs)) {
119
+ if (!abs || !isPreviewable(abs) || isHiddenPath(path.relative(root, abs))) {
120
120
  sendJson(res, { error: 'invalid path' }, 400);
121
121
  return;
122
122
  }
package/src/walk.js CHANGED
@@ -7,14 +7,24 @@ const IGNORED_DIRS = new Set([
7
7
  '.cache', '.turbo', 'vendor', '.venv', '__pycache__'
8
8
  ]);
9
9
 
10
- export const PREVIEWABLE_EXTS = new Set(['.md', '.mmd']);
10
+ export const PREVIEWABLE_EXTS = new Set(['.md', '.mmd', '.json', '.yml', '.yaml']);
11
+
12
+ // Previewable by extension but machine-generated noise - huge, never read.
13
+ const IGNORED_FILES = new Set(['package-lock.json', 'pnpm-lock.yaml']);
11
14
 
12
15
  // True if a path segment should stop the walk (ignored name or any dot-dir).
13
16
  export function isIgnoredDir(name) {
14
17
  return IGNORED_DIRS.has(name) || name.startsWith('.');
15
18
  }
16
19
 
20
+ // True if any directory in a root-relative path is one the walk skips - so a
21
+ // file hidden from the tree can't be fetched by typing its path either.
22
+ export function isHiddenPath(relPath) {
23
+ return relPath.split(/[\\/]/).slice(0, -1).some(isIgnoredDir);
24
+ }
25
+
17
26
  export function isPreviewable(filePath) {
27
+ if (IGNORED_FILES.has(path.basename(filePath).toLowerCase())) return false;
18
28
  return PREVIEWABLE_EXTS.has(path.extname(filePath).toLowerCase());
19
29
  }
20
30