rvlib-shipkit 0.1.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.
@@ -0,0 +1,108 @@
1
+ // Generic markdown-repo scanner → MdGraph, so any repo gets a graph with zero
2
+ // custom parsing. Detected link forms (fenced code is invisible):
3
+ // @path eager — loaded with the file that names it
4
+ // → path.md lazy — resolved from the scan root, then file-relative
5
+ // [text](path.md) lazy — file-relative
6
+ // `path.md` lazy — file-relative, then from the scan root
7
+ // Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
8
+ // unreachable files stay in the graph with no depth (lint-md.ts turns those,
9
+ // plus dangling refs, into failures — the graph itself is a viewer).
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
11
+ import { dirname, join, relative, resolve } from 'path'
12
+ import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
13
+
14
+ export type ScanMdConfig = {
15
+ root: string // absolute dir to scan
16
+ index?: string // root-relative entry the BFS starts from (default: index.md AND README.md, both)
17
+ skipDirs?: string[] // dir names to skip on top of node_modules/.git/dist/tmp
18
+ }
19
+
20
+ export type MdDanglingRef = { from: string; token: string } // an @/→/[](…) ref whose target is not in the scan
21
+ export type MdScan = {
22
+ graph: MdGraph
23
+ index: string | null // resolved entrypoint(s), ' + '-joined when both defaults exist; null = none found
24
+ dangling: MdDanglingRef[]
25
+ }
26
+
27
+ export function scanMdGraph(cfg: ScanMdConfig): MdGraph {
28
+ return scanMd(cfg).graph
29
+ }
30
+
31
+ export function scanMd(cfg: ScanMdConfig): MdScan {
32
+ const skip = new Set(['node_modules', '.git', 'dist', 'tmp', ...(cfg.skipDirs ?? [])])
33
+ const files: string[] = []
34
+ const walk = (rel: string) => {
35
+ for (const e of readdirSync(join(cfg.root, rel))) {
36
+ const r = rel === '' ? e : `${rel}/${e}`
37
+ if (statSync(join(cfg.root, r)).isDirectory()) {
38
+ if (!skip.has(e) && !e.startsWith('.')) walk(r)
39
+ } else if (e.endsWith('.md')) files.push(r)
40
+ }
41
+ }
42
+ walk('')
43
+ files.sort()
44
+ const inGraph = new Set(files)
45
+
46
+ const resolveRef = (fromRel: string, token: string): string | null => {
47
+ for (const cand of [resolve(join(cfg.root, dirname(fromRel)), token), resolve(cfg.root, token)]) {
48
+ const rel = relative(cfg.root, cand)
49
+ if (!rel.startsWith('..') && inGraph.has(rel)) return rel
50
+ }
51
+ return null
52
+ }
53
+ const edgesOf = (rel: string): MdEdge[] => {
54
+ const raw = readFileSync(join(cfg.root, rel), 'utf8')
55
+ const body = raw.startsWith('---\n') ? raw.slice(4).replace(/^[\s\S]*?^---\s*$/m, '') : raw
56
+ const spans: string[] = []
57
+ const prose = body.replace(/^```[\s\S]*?^```\s*$/gm, '').replace(/`([^`\n]+)`/g, (_, s: string) => { spans.push(s); return ' ' })
58
+ const out: MdEdge[] = []
59
+ // strict: @/→/[](…) are deliberate wiki refs — a miss is a dangling ref.
60
+ // Backtick spans mention paths casually (other repos, deleted examples) — resolve or ignore, never fail.
61
+ const push = (token: string, eager: boolean, strict: boolean) => {
62
+ const to = resolveRef(rel, token)
63
+ if (to != null && to !== rel) out.push({ from: rel, to, eager })
64
+ else if (to == null && strict) dangling.push({ from: rel, token })
65
+ }
66
+ for (const m of prose.matchAll(/(?:^|[\s(])@([\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
67
+ for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true)
68
+ for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true)
69
+ for (const s of spans) for (const m of s.matchAll(/[\w][\w./-]*\.md/g)) push(m[0], false, false)
70
+ return out
71
+ }
72
+ const dangling: MdDanglingRef[] = []
73
+ const edges: MdEdge[] = []
74
+ const seen = new Set<string>()
75
+ for (const f of files) for (const e of edgesOf(f)) {
76
+ const key = `${e.from}>${e.to}:${e.eager}`
77
+ if (!seen.has(key)) { seen.add(key); edges.push(e) }
78
+ }
79
+
80
+ // explicit index = the only entry; default = index.md AND README.md both
81
+ // seed the walk (a repo readme is an entrypoint too, never an orphan)
82
+ const seeds = (cfg.index != null ? [cfg.index] : ['index.md', 'README.md']).filter((s) => inGraph.has(s))
83
+ const depths = new Map<string, number>()
84
+ {
85
+ const deque: string[] = []
86
+ for (const s of seeds) { depths.set(s, 0); deque.push(s) }
87
+ while (deque.length > 0) {
88
+ const cur = deque.shift()!
89
+ for (const e of edges) {
90
+ if (e.from !== cur) continue
91
+ const d = depths.get(cur)! + (e.eager ? 0 : 1)
92
+ const known = depths.get(e.to)
93
+ if (known != null && known <= d) continue
94
+ depths.set(e.to, d)
95
+ if (e.eager) deque.unshift(e.to)
96
+ else deque.push(e.to)
97
+ }
98
+ }
99
+ }
100
+ const nodes: MdNode[] = files.map((f) => ({
101
+ id: f,
102
+ label: f.replace(/\.md$/, '').split('/').at(-1)!,
103
+ group: f.includes('/') ? f.split('/')[0]! : null,
104
+ eager: depths.get(f) === 0,
105
+ ...(depths.has(f) ? { depth: depths.get(f)! } : {}),
106
+ }))
107
+ return { graph: { nodes, edges }, index: seeds.join(' + ') || null, dangling }
108
+ }
@@ -0,0 +1,19 @@
1
+ // Local graph server: `load()` runs on EVERY request, so a reload always shows
2
+ // the wiki as it is on disk right now — no stale exports, no build step.
3
+ import { graphHtml } from 'rvlib-shipkit/src/wiki/graph-page.ts'
4
+ import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
5
+
6
+ export type ServeMdGraphConfig = { port: number; load: () => MdGraph; title?: string }
7
+
8
+ // routes: `/` the interactive graph · `/graph.json` the raw graph data
9
+ export function serveMdGraph(cfg: ServeMdGraphConfig) {
10
+ return Bun.serve({
11
+ port: cfg.port,
12
+ fetch(req) {
13
+ const path = new URL(req.url).pathname
14
+ if (path === '/') return new Response(graphHtml(cfg.load(), { title: cfg.title }), { headers: { 'content-type': 'text/html; charset=utf-8' } })
15
+ if (path === '/graph.json') return Response.json(cfg.load())
16
+ return new Response('not found', { status: 404 })
17
+ },
18
+ })
19
+ }