@rahmantech/knl-view 0.1.0 → 0.3.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.
Files changed (98) hide show
  1. package/README.md +133 -32
  2. package/bin/knl-view.js +591 -58
  3. package/dist/assets/{abnfDiagram-WEYOIA3C-B1gaBaSI.js → abnfDiagram-WEYOIA3C-hoSbsMOz.js} +1 -1
  4. package/dist/assets/{arc-NZ9uRaCl.js → arc-g87Np6E5.js} +1 -1
  5. package/dist/assets/{architecture-WOLXFQ4H-8MalLwTd.js → architecture-WOLXFQ4H-DOIq-0IA.js} +1 -1
  6. package/dist/assets/{architectureDiagram-ZHVYKT53-eZ8i5cl4.js → architectureDiagram-ZHVYKT53-Dxwe8n7m.js} +1 -1
  7. package/dist/assets/{blockDiagram-RJAE7QA3-BavOE8BM.js → blockDiagram-RJAE7QA3-BnGng59Z.js} +1 -1
  8. package/dist/assets/{c4Diagram-XDPPPE54-DqMaTAzx.js → c4Diagram-XDPPPE54-C5LjqIIo.js} +1 -1
  9. package/dist/assets/channel-BVWkRFBw.js +1 -0
  10. package/dist/assets/{chunk-2E7WHVMD-B1Hj2BjJ.js → chunk-2E7WHVMD-BJ1dyGLq.js} +1 -1
  11. package/dist/assets/{chunk-4EMNKR2T-HDscrCiq.js → chunk-4EMNKR2T-DXF02Sgw.js} +1 -1
  12. package/dist/assets/{chunk-55XCU76K-QR_ARj8I.js → chunk-55XCU76K-CM1akAbu.js} +1 -1
  13. package/dist/assets/{chunk-7S6XSTFO-B0p3o547.js → chunk-7S6XSTFO-BOUjqldL.js} +1 -1
  14. package/dist/assets/{chunk-AQDTSKZB-CCxwNyyA.js → chunk-AQDTSKZB-C4isdNLA.js} +1 -1
  15. package/dist/assets/{chunk-DSNLSHJZ-BM5PBFmz.js → chunk-DSNLSHJZ-CbOTkSVM.js} +1 -1
  16. package/dist/assets/{chunk-HAGFOTED-UCDVFuN_.js → chunk-HAGFOTED-CRfnketR.js} +1 -1
  17. package/dist/assets/{chunk-HNE4Q36P-C_1sakp2.js → chunk-HNE4Q36P-rxlpGUU8.js} +1 -1
  18. package/dist/assets/{chunk-NQOKRVIF-b2D1Z4oK.js → chunk-NQOKRVIF-BOYCgTmV.js} +1 -1
  19. package/dist/assets/{chunk-UHW7PFBP-CJs0VwBY.js → chunk-UHW7PFBP-Cnogp1Fb.js} +1 -1
  20. package/dist/assets/{chunk-VQ5W62QI-DQxjgE4_.js → chunk-VQ5W62QI-Cu1WY5Uo.js} +1 -1
  21. package/dist/assets/{chunk-WQQL4CDK-DcjPeAfQ.js → chunk-WQQL4CDK-BJc3FPE2.js} +1 -1
  22. package/dist/assets/{chunk-XXDRQBXY-glA0kB5s.js → chunk-XXDRQBXY-BlPj4H7s.js} +1 -1
  23. package/dist/assets/{chunk-YZO67HFG-DJqACL5V.js → chunk-YZO67HFG-V85H31x4.js} +1 -1
  24. package/dist/assets/{chunk-ZAUX2LTX-CWfaYtwb.js → chunk-ZAUX2LTX-C3XKXW09.js} +1 -1
  25. package/dist/assets/{chunk-ZCHMQSOR-BGX73Hho.js → chunk-ZCHMQSOR-B0mmpEgW.js} +1 -1
  26. package/dist/assets/{classDiagram-v2-4MYWR3UA-BpYXPz-e.js → classDiagram-v2-4MYWR3UA-oMd7YaHP.js} +1 -1
  27. package/dist/assets/{cose-bilkent-JH36ORCC-e1quKm0Q.js → cose-bilkent-JH36ORCC-J0kSVsDf.js} +1 -1
  28. package/dist/assets/{cynefin-EF2NZ3EQ-hb3oUmFO.js → cynefin-EF2NZ3EQ-DK2TNhel.js} +1 -1
  29. package/dist/assets/{cynefinDiagram-WF24UDRP-CnMQvLqi.js → cynefinDiagram-WF24UDRP-DDvKgieA.js} +1 -1
  30. package/dist/assets/{dagre-NK4GIZVX-BH_Y8y3F.js → dagre-NK4GIZVX-Ca2Fe-i-.js} +1 -1
  31. package/dist/assets/{diagram-CBK3MWTC-Dh79VetZ.js → diagram-CBK3MWTC-L6dmeQ2V.js} +1 -1
  32. package/dist/assets/{diagram-I5XIIV2D-D3u-o1RV.js → diagram-I5XIIV2D-DhG8R-v5.js} +1 -1
  33. package/dist/assets/{diagram-ODIL2UCW-Bfuo-OKS.js → diagram-ODIL2UCW-BpYYbYBT.js} +1 -1
  34. package/dist/assets/{diagram-PFBEJUXE-CwHasIbs.js → diagram-PFBEJUXE-DKQpHSsC.js} +1 -1
  35. package/dist/assets/{diagram-RJA3I4VV-D8ttmuE8.js → diagram-RJA3I4VV-DP7Dsnya.js} +1 -1
  36. package/dist/assets/{diagram-TYUNFSNR-CzC-8RZ0.js → diagram-TYUNFSNR-CfSFewUS.js} +1 -1
  37. package/dist/assets/{dist-CUJYGnnI.js → dist-CQNjnrld.js} +1 -1
  38. package/dist/assets/{ebnfDiagram-V4P3TTPT-9AAhU7In.js → ebnfDiagram-V4P3TTPT-DE3MZoJO.js} +1 -1
  39. package/dist/assets/{elk-QONTBCTQ-BgvnD1v_.js → elk-QONTBCTQ-p_aAQPHU.js} +1 -1
  40. package/dist/assets/{erDiagram-KYQIGQAA-RLtbiLlP.js → erDiagram-KYQIGQAA-DZO3vQrk.js} +1 -1
  41. package/dist/assets/{eventmodeling-K75KTNOO-DVY22vNZ.js → eventmodeling-K75KTNOO-BII9EPl5.js} +1 -1
  42. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  43. package/dist/assets/{ganttDiagram-R7GPNTJY-hHiBGa9W.js → ganttDiagram-R7GPNTJY-BKiH3v2r.js} +1 -1
  44. package/dist/assets/{gitGraph-VSP46ZUC-Cvxay1wh.js → gitGraph-VSP46ZUC-oHL6cr73.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-PDYL7XHY-AOhlpSqg.js → gitGraphDiagram-PDYL7XHY-CqPVIEeo.js} +1 -1
  46. package/dist/assets/{index-CBLNQvK3.js → index-CSpirV16.js} +3 -3
  47. package/dist/assets/{info-OHQRW6UA-CJ8EUO1m.js → info-OHQRW6UA-Drrbnhpd.js} +1 -1
  48. package/dist/assets/{infoDiagram-MVDRKAX5-DOQu7oYI.js → infoDiagram-MVDRKAX5-DnQQFMAd.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-HPEHV2GE-DcVMvGmx.js → ishikawaDiagram-HPEHV2GE-DIpQlxRh.js} +1 -1
  50. package/dist/assets/{journeyDiagram-BOSHW7O7-luMeqEqz.js → journeyDiagram-BOSHW7O7-EBR9I5h1.js} +1 -1
  51. package/dist/assets/{kanban-definition-FNSA4HC5-KTZbmrW2.js → kanban-definition-FNSA4HC5-CdvNsjik.js} +1 -1
  52. package/dist/assets/{line-CmPHiOVm.js → line-CDLzgxDc.js} +1 -1
  53. package/dist/assets/{linear-DHYlKXBA.js → linear-DfXKql9u.js} +1 -1
  54. package/dist/assets/{mermaid-parser.core-BRDI-WhC.js → mermaid-parser.core-DnXGphT2.js} +3 -3
  55. package/dist/assets/{mermaid.core-DPTRaYLy.js → mermaid.core-h6fiCaSZ.js} +4 -4
  56. package/dist/assets/{mindmap-definition-6OY57UG3-70EN5Kw_.js → mindmap-definition-6OY57UG3-BiNfio-j.js} +1 -1
  57. package/dist/assets/{packet-JDAUHWVQ-CXWz83oS.js → packet-JDAUHWVQ-D76byv1S.js} +1 -1
  58. package/dist/assets/{pegDiagram-ECIUJNBX-z5UXKmp4.js → pegDiagram-ECIUJNBX-Drs8Pxdt.js} +1 -1
  59. package/dist/assets/{pie-XZMESJXO-rWI1HIQ7.js → pie-XZMESJXO-D_83z1lM.js} +1 -1
  60. package/dist/assets/{pieDiagram-GNMPY74T-Bh4Kv3NQ.js → pieDiagram-GNMPY74T-wvzIphSf.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-D57K4MC3-wGM4Ckig.js → quadrantDiagram-D57K4MC3-B7EfMRWb.js} +1 -1
  62. package/dist/assets/{radar-ABXABTNO-CIJ92p5N.js → radar-ABXABTNO-Bs6dVdAR.js} +1 -1
  63. package/dist/assets/{railroad-I3PHUGI6-8bxua-Hf.js → railroad-I3PHUGI6-lKaN4miw.js} +1 -1
  64. package/dist/assets/{railroad-abnf-LNEFI6M7-B9KVI10m.js → railroad-abnf-LNEFI6M7-Bhh16b7Z.js} +1 -1
  65. package/dist/assets/{railroad-ebnf-DWYD2UWJ-Bt73rnyJ.js → railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js} +1 -1
  66. package/dist/assets/{railroad-peg-JR7BVNR7-Cie82UPT.js → railroad-peg-JR7BVNR7-DNn72e7r.js} +1 -1
  67. package/dist/assets/{railroadDiagram-Y72NRBL7-C1nmi_tl.js → railroadDiagram-Y72NRBL7-DNeop1iZ.js} +1 -1
  68. package/dist/assets/{requirementDiagram-EQYSW5RA-BSianW40.js → requirementDiagram-EQYSW5RA-BVYZ2XYJ.js} +1 -1
  69. package/dist/assets/{sankeyDiagram-SY24JHO5-DIa11-h7.js → sankeyDiagram-SY24JHO5-B2MP51on.js} +1 -1
  70. package/dist/assets/{sequenceDiagram-BLEHNHQU-Df5rZKz2.js → sequenceDiagram-BLEHNHQU-BUvxstXG.js} +1 -1
  71. package/dist/assets/{src-dClg85lX.js → src-BrR0lqlG.js} +1 -1
  72. package/dist/assets/{stateDiagram-v2-A6IESKWZ-B15PTSvX.js → stateDiagram-v2-A6IESKWZ-Ba84OGKE.js} +1 -1
  73. package/dist/assets/{swimlanes-B3ENRKFQ-C9mgRCMi.js → swimlanes-B3ENRKFQ-BMSl2PNA.js} +1 -1
  74. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  75. package/dist/assets/{timeline-definition-JXYKGLGB-BDVf_mIv.js → timeline-definition-JXYKGLGB-DOdW-dxq.js} +1 -1
  76. package/dist/assets/{treeView-D4JQ5SDB-BbBK-25J.js → treeView-D4JQ5SDB-DZ9KQyn5.js} +1 -1
  77. package/dist/assets/{treemap-SAJKECNS-CEDX8apk.js → treemap-SAJKECNS-WEC5wtDV.js} +1 -1
  78. package/dist/assets/{usecaseDiagram-WPOLBNNX-D1QkwFF8.js → usecaseDiagram-WPOLBNNX-cldTztFL.js} +1 -1
  79. package/dist/assets/{vennDiagram-Q3Y3IDCW-5PeGZvEO.js → vennDiagram-Q3Y3IDCW-c41KySoY.js} +1 -1
  80. package/dist/assets/{wardley-7MLQ67FV-BatN94pO.js → wardley-7MLQ67FV-Bf9_aSSZ.js} +1 -1
  81. package/dist/assets/{wardleyDiagram-IVAIDKWA-DyCcDS3Y.js → wardleyDiagram-IVAIDKWA-BeKEvEBG.js} +1 -1
  82. package/dist/assets/{xychartDiagram-4URNHDU4-CbCXcRlg.js → xychartDiagram-4URNHDU4-tNkAxTxG.js} +1 -1
  83. package/dist/index.html +1 -1
  84. package/package.json +5 -1
  85. package/server/config.js +140 -14
  86. package/server/consensus.js +18 -13
  87. package/server/graph/modules_builder.js +290 -0
  88. package/server/graph/modules_catalog.js +288 -0
  89. package/server/graph/parser.js +3 -2
  90. package/server/graph/sources.js +165 -24
  91. package/server/graph/syntax.js +245 -28
  92. package/server/index.js +124 -61
  93. package/server/install.js +350 -10
  94. package/server/models_registry.js +34 -9
  95. package/server/scaffold.js +109 -9
  96. package/dist/assets/channel-BZ2_TTTJ.js +0 -1
  97. package/dist/assets/flowDiagram-IUGLLWBU-l7L5DTb5.js +0 -1
  98. package/dist/assets/swimlanesDiagram-AYQRV4AK-BGFq4t7O.js +0 -8
package/server/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import http from 'node:http'
2
- import { promises as fs } from 'node:fs'
3
- import { createReadStream } from 'node:fs'
2
+ import { promises as fs, createReadStream } from 'node:fs'
4
3
  import path from 'node:path'
5
4
  import { readMap, normalizeMap, decorateNode } from './map.js'
6
5
  import { buildGraphFromRoot } from './graph/parser.js'
@@ -13,7 +12,7 @@ import {
13
12
  inboundConsensus,
14
13
  outboundConsensus
15
14
  } from './consensus.js'
16
- import { anchordbClientOptions, syncStatePath, DEFAULT_RELATIONS } from './config.js'
15
+ import { anchordbClientOptions, syncStatePath, DEFAULT_RELATIONS, createPathMatcher } from './config.js'
17
16
 
18
17
  const MIME = {
19
18
  '.html': 'text/html; charset=utf-8',
@@ -43,7 +42,7 @@ const MD_EXTS = new Set(['md', 'markdown', 'prd'])
43
42
  const MAX_DOC_BYTES = 8 * 1024 * 1024
44
43
  const TITLE_READ_BYTES = 8192
45
44
 
46
- export function createKnowledgeServer({ root, dist = null, host = '127.0.0.1', port = 8085, anchordb = null, consensusModel = null, sourcesModel = null }) {
45
+ export function createKnowledgeServer({ root, dist = null, host = '127.0.0.1', port = 8085, anchordb = null, consensusModel = null, sourcesModel = null, include = null, exclude = null }) {
47
46
  const rootAbs = path.resolve(root)
48
47
  const distAbs = dist ? path.resolve(dist) : null
49
48
  // Live AnchorDB connection status lives here, not a snapshot: attachAnchorDB
@@ -63,7 +62,7 @@ export function createKnowledgeServer({ root, dist = null, host = '127.0.0.1', p
63
62
  }
64
63
 
65
64
  const server = http.createServer((req, res) => {
66
- route(req, res, rootAbs, distAbs, { host, port, anchordb: anchordbState }).catch((err) => {
65
+ route(req, res, rootAbs, distAbs, { host, port, anchordb: anchordbState, include, exclude }).catch((err) => {
67
66
  if (!res.headersSent) sendJson(res, 500, { error: `Failed to handle request: ${err.message}` })
68
67
  else res.end()
69
68
  })
@@ -184,11 +183,16 @@ async function writeSyncState(model, edges) {
184
183
  }
185
184
 
186
185
  async function route(req, res, rootAbs, distAbs, listen) {
186
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
187
+ res.setHeader('Allow', 'GET, HEAD')
188
+ return sendJson(res, 405, { error: 'Only GET is supported.' })
189
+ }
190
+
187
191
  const url = new URL(req.url, 'http://localhost')
188
192
  const pathname = url.pathname
189
193
 
190
194
  if (pathname === '/api/meta') return sendJson(res, 200, await buildMeta(rootAbs, listen))
191
- if (pathname === '/api/tree') return sendJson(res, 200, await buildTree(rootAbs, '', await loadMapNorm(rootAbs)))
195
+ if (pathname === '/api/tree') return sendJson(res, 200, await buildTree(rootAbs, '', await loadMapNorm(rootAbs), listen))
192
196
  if (pathname === '/api/doc') return sendDoc(res, rootAbs, url)
193
197
  if (pathname === '/api/raw') return sendRaw(res, rootAbs, url)
194
198
  if (pathname === '/api/graph') {
@@ -200,14 +204,15 @@ async function route(req, res, rootAbs, distAbs, listen) {
200
204
  if (!target) {
201
205
  return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
202
206
  }
203
- const hops = parseInt(url.searchParams.get('hops') || '2', 10)
207
+ const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
208
+ const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
204
209
  const rawDirection = url.searchParams.get('direction')
205
210
  if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
206
211
  return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
207
212
  }
208
213
  const direction = rawDirection || 'both'
209
214
  const graph = await buildGraphFromRoot(rootAbs)
210
- const subGraph = graph.extractSubGraph(target, Number.isInteger(hops) && hops > 0 ? hops : 2, direction)
215
+ const subGraph = graph.extractSubGraph(target, hops, direction)
211
216
  return sendJson(res, 200, subGraph)
212
217
  }
213
218
  if (pathname === '/api/anchordb/trace') {
@@ -223,11 +228,6 @@ async function route(req, res, rootAbs, distAbs, listen) {
223
228
  return sendJson(res, 404, { error: `Unknown endpoint: ${pathname}` })
224
229
  }
225
230
 
226
- if (req.method !== 'GET' && req.method !== 'HEAD') {
227
- res.setHeader('Allow', 'GET, HEAD')
228
- return sendJson(res, 405, { error: 'Only GET is supported.' })
229
- }
230
-
231
231
  return sendStatic(req, res, distAbs, pathname)
232
232
  }
233
233
 
@@ -251,8 +251,10 @@ async function handleAnchorTrace(res, state, url) {
251
251
  const relations = url.searchParams.get('relations')
252
252
  ? url.searchParams.get('relations').split(',').map((s) => s.trim()).filter(Boolean)
253
253
  : (state.config.relations?.length ? state.config.relations : DEFAULT_RELATIONS)
254
- const limit = parseInt(url.searchParams.get('limit') || '20', 10)
255
- const hops = parseInt(url.searchParams.get('hops') || '2', 10)
254
+ const rawLimit = parseInt(url.searchParams.get('limit') || '20', 10)
255
+ const limit = Math.min(Math.max(1, Number.isInteger(rawLimit) ? rawLimit : 20), 100)
256
+ const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
257
+ const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
256
258
  const rawDirection = url.searchParams.get('direction')
257
259
  if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
258
260
  return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
@@ -386,7 +388,7 @@ async function handleSourcesTrace(res, state, url) {
386
388
  direction = rawDirection
387
389
  }
388
390
  const hops = Number.parseInt(url.searchParams.get('hops') || '2', 10)
389
- const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
391
+ const maxHops = Math.min(Math.max(1, Number.isInteger(hops) ? hops : 2), 10)
390
392
  try {
391
393
  const { out, in: inc } = await traceSources(state.sourcesClient, target, direction, maxHops)
392
394
  return sendJson(res, 200, {
@@ -401,23 +403,35 @@ async function handleSourcesTrace(res, state, url) {
401
403
  }
402
404
  }
403
405
 
404
- function safeJoin(base, relPath) {
405
- if (!relPath) return null
406
- let decoded
406
+ async function safeJoin(base, relPath) {
407
+ if (!relPath || typeof relPath !== 'string') return null
408
+ if (relPath.includes('\0')) return null
409
+ const abs = path.resolve(base, relPath)
410
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null
411
+
407
412
  try {
408
- decoded = decodeURIComponent(relPath)
409
- } catch {
413
+ const realBase = await fs.realpath(base)
414
+ const realAbs = await fs.realpath(abs)
415
+ if (realAbs !== realBase && !realAbs.startsWith(realBase + path.sep)) return null
416
+ return realAbs
417
+ } catch (err) {
418
+ if (err.code === 'ENOENT') {
419
+ try {
420
+ const realParent = await fs.realpath(path.dirname(abs))
421
+ const realBase = await fs.realpath(base)
422
+ if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) return null
423
+ return abs
424
+ } catch {
425
+ return null
426
+ }
427
+ }
410
428
  return null
411
429
  }
412
- if (decoded.includes('\0')) return null
413
- const abs = path.resolve(base, decoded)
414
- if (abs !== base && !abs.startsWith(base + path.sep)) return null
415
- return abs
416
430
  }
417
431
 
418
432
  async function buildMeta(rootAbs, listen = {}) {
419
433
  const norm = await loadMapNorm(rootAbs)
420
- const tree = await buildTree(rootAbs, '', norm)
434
+ const tree = await buildTree(rootAbs, '', norm, listen)
421
435
  const counts = { files: 0, dirs: 0, newest: 0 }
422
436
  walkStats(tree, counts)
423
437
  const host = listen.host || '127.0.0.1'
@@ -453,7 +467,9 @@ function walkStats(nodes, acc) {
453
467
  }
454
468
  }
455
469
 
456
- async function buildTree(dirAbs, rel, norm) {
470
+ async function buildTree(dirAbs, rel, norm, options = {}) {
471
+ const excludeFilter = options.exclude ? createPathMatcher(options.exclude) : null
472
+ const includeFilter = options.include ? createPathMatcher(options.include) : null
457
473
  const entries = await fs.readdir(dirAbs, { withFileTypes: true })
458
474
  const nodes = []
459
475
 
@@ -464,37 +480,52 @@ async function buildTree(dirAbs, rel, norm) {
464
480
  // The map may hide entries (`ignore`). A hidden folder hides its whole
465
481
  // contents with it: never walk into it at all.
466
482
  if (norm?.entries?.get(childRel)?.ignore) continue
483
+
484
+ // 1. Hard exclude: knl-config exclude is truly excluded, always wins
485
+ if (excludeFilter && excludeFilter.matches(childRel, entry.name)) continue
486
+
487
+ // 2. Explicit include: if includeFilter is set, only included items or parent dirs are allowed
488
+ if (includeFilter) {
489
+ const isExplicitlyIncluded = includeFilter.matches(childRel, entry.name)
490
+ const isDirPrefixOfInclude = entry.isDirectory() && includeFilter.isPrefixOfAny(childRel)
491
+ if (!isExplicitlyIncluded && !isDirPrefixOfInclude) continue
492
+ }
493
+
467
494
  const childAbs = path.join(dirAbs, entry.name)
468
495
 
469
- if (entry.isDirectory()) {
496
+ try {
497
+ if (entry.isDirectory()) {
498
+ const stat = await fs.stat(childAbs)
499
+ const node = {
500
+ name: entry.name,
501
+ path: childRel,
502
+ type: 'dir',
503
+ mtime: stat.mtimeMs,
504
+ children: await buildTree(childAbs, childRel, norm, options)
505
+ }
506
+ nodes.push(decorateNode(node, norm))
507
+ continue
508
+ }
509
+ if (!entry.isFile()) continue
510
+
470
511
  const stat = await fs.stat(childAbs)
512
+ const ext = path.extname(entry.name).slice(1).toLowerCase()
471
513
  const node = {
472
514
  name: entry.name,
473
515
  path: childRel,
474
- type: 'dir',
475
- mtime: stat.mtimeMs,
476
- children: await buildTree(childAbs, childRel, norm)
516
+ type: 'file',
517
+ ext,
518
+ size: stat.size,
519
+ mtime: stat.mtimeMs
520
+ }
521
+ if (MD_EXTS.has(ext)) {
522
+ const title = await readFirstHeading(childAbs, stat.size)
523
+ if (title) node.title = title
477
524
  }
478
525
  nodes.push(decorateNode(node, norm))
479
- continue
526
+ } catch {
527
+ // Skip unreadable or concurrently deleted entries
480
528
  }
481
- if (!entry.isFile()) continue
482
-
483
- const stat = await fs.stat(childAbs)
484
- const ext = path.extname(entry.name).slice(1).toLowerCase()
485
- const node = {
486
- name: entry.name,
487
- path: childRel,
488
- type: 'file',
489
- ext,
490
- size: stat.size,
491
- mtime: stat.mtimeMs
492
- }
493
- if (MD_EXTS.has(ext)) {
494
- const title = await readFirstHeading(childAbs, stat.size)
495
- if (title) node.title = title
496
- }
497
- nodes.push(decorateNode(node, norm))
498
529
  }
499
530
 
500
531
  // One sort rule, same as orderForIndex in the views: documents first, then
@@ -505,15 +536,16 @@ async function buildTree(dirAbs, rel, norm) {
505
536
  const bo = b.order ?? Number.POSITIVE_INFINITY
506
537
  if (ao !== bo) return ao - bo
507
538
  if (a.type !== b.type) return a.type === 'file' ? -1 : 1
508
- return a.name.localeCompare(b.name, 'id', { numeric: true, sensitivity: 'base' })
539
+ return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' })
509
540
  })
510
541
  return nodes
511
542
  }
512
543
 
513
544
  async function readFirstHeading(abs, size) {
514
545
  if (size === 0 || size > 512 * 1024) return null
515
- const handle = await fs.open(abs, 'r')
546
+ let handle
516
547
  try {
548
+ handle = await fs.open(abs, 'r')
517
549
  const buffer = Buffer.alloc(TITLE_READ_BYTES)
518
550
  const { bytesRead } = await handle.read(buffer, 0, TITLE_READ_BYTES, 0)
519
551
  const text = buffer.subarray(0, bytesRead).toString('utf8')
@@ -521,8 +553,10 @@ async function readFirstHeading(abs, size) {
521
553
  if (!match) return null
522
554
  const title = cleanTitle(match[1])
523
555
  return title || null
556
+ } catch {
557
+ return null
524
558
  } finally {
525
- await handle.close()
559
+ if (handle) await handle.close()
526
560
  }
527
561
  }
528
562
 
@@ -542,7 +576,7 @@ async function sendDoc(res, rootAbs, url) {
542
576
  const rel = url.searchParams.get('path')
543
577
  if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
544
578
 
545
- const abs = safeJoin(rootAbs, rel)
579
+ const abs = await safeJoin(rootAbs, rel)
546
580
  if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
547
581
 
548
582
  let stat
@@ -571,7 +605,7 @@ async function sendRaw(res, rootAbs, url) {
571
605
  const rel = url.searchParams.get('path')
572
606
  if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
573
607
 
574
- const abs = safeJoin(rootAbs, rel)
608
+ const abs = await safeJoin(rootAbs, rel)
575
609
  if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
576
610
 
577
611
  let stat
@@ -583,11 +617,23 @@ async function sendRaw(res, rootAbs, url) {
583
617
  if (!stat.isFile()) return sendJson(res, 400, { error: `Not a file: ${rel}` })
584
618
 
585
619
  const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream'
586
- res.writeHead(200, { 'Content-Type': type, 'Content-Length': stat.size, 'Cache-Control': 'no-store' })
620
+ res.writeHead(200, {
621
+ 'Content-Type': type,
622
+ 'Content-Length': stat.size,
623
+ 'Cache-Control': 'no-store',
624
+ 'X-Content-Type-Options': 'nosniff',
625
+ 'X-Frame-Options': 'SAMEORIGIN'
626
+ })
587
627
  if (res.req.method === 'HEAD') return res.end()
588
628
  return new Promise((resolve, reject) => {
589
629
  const stream = createReadStream(abs)
590
- stream.on('error', reject)
630
+ stream.on('error', (err) => {
631
+ res.destroy(err)
632
+ reject(err)
633
+ })
634
+ res.on('close', () => {
635
+ stream.destroy()
636
+ })
591
637
  stream.on('end', resolve)
592
638
  stream.pipe(res)
593
639
  })
@@ -600,10 +646,17 @@ async function sendStatic(req, res, distAbs, pathname) {
600
646
  return
601
647
  }
602
648
 
603
- let rel = decodeURIComponent(pathname)
649
+ let rel
650
+ try {
651
+ rel = decodeURIComponent(pathname)
652
+ } catch {
653
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
654
+ res.end('Bad Request: Malformed URI path.')
655
+ return
656
+ }
604
657
  if (rel.endsWith('/')) rel += 'index.html'
605
658
 
606
- let abs = safeJoin(distAbs, rel.replace(/^\/+/, ''))
659
+ let abs = await safeJoin(distAbs, rel.replace(/^\/+/, ''))
607
660
  if (!abs) {
608
661
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
609
662
  res.end('Access denied.')
@@ -636,12 +689,20 @@ async function sendStatic(req, res, distAbs, pathname) {
636
689
  res.writeHead(200, {
637
690
  'Content-Type': MIME[ext] || 'application/octet-stream',
638
691
  'Content-Length': stat.size,
639
- 'Cache-Control': cache
692
+ 'Cache-Control': cache,
693
+ 'X-Content-Type-Options': 'nosniff',
694
+ 'X-Frame-Options': 'SAMEORIGIN'
640
695
  })
641
696
  if (req.method === 'HEAD') return res.end()
642
697
  return new Promise((resolve, reject) => {
643
698
  const stream = createReadStream(abs)
644
- stream.on('error', reject)
699
+ stream.on('error', (err) => {
700
+ res.destroy(err)
701
+ reject(err)
702
+ })
703
+ res.on('close', () => {
704
+ stream.destroy()
705
+ })
645
706
  stream.on('end', resolve)
646
707
  stream.pipe(res)
647
708
  })
@@ -660,7 +721,9 @@ function sendJson(res, status, body) {
660
721
  res.writeHead(status, {
661
722
  'Content-Type': 'application/json; charset=utf-8',
662
723
  'Content-Length': Buffer.byteLength(payload),
663
- 'Cache-Control': 'no-store'
724
+ 'Cache-Control': 'no-store',
725
+ 'X-Content-Type-Options': 'nosniff',
726
+ 'X-Frame-Options': 'SAMEORIGIN'
664
727
  })
665
728
  res.end(payload)
666
729
  }