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 +3 -2
- package/docs/README.md +3 -0
- package/docs/data/minified.json +1 -0
- package/docs/data/not-strict.jsonc.json +4 -0
- package/docs/data/values.yaml +36 -0
- package/package.json +1 -1
- package/public/icons.js +7 -1
- package/public/links.js +3 -2
- package/public/search.js +1 -3
- package/public/style.css +35 -0
- package/public/tree.js +2 -2
- package/src/render.js +139 -1
- package/src/search.js +3 -0
- package/src/server.js +2 -2
- package/src/walk.js +11 -1
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,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
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
|
|
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 (
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|