@henryavila/mdprobe 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.
- package/README.md +404 -0
- package/bin/cli.js +335 -0
- package/dist/assets/index-DPysqH1p.js +2 -0
- package/dist/assets/index-nl9v2RuJ.css +1 -0
- package/dist/index.html +19 -0
- package/package.json +75 -0
- package/schema.json +104 -0
- package/skills/mdprobe/SKILL.md +358 -0
- package/src/anchoring.js +262 -0
- package/src/annotations.js +504 -0
- package/src/cli-utils.js +58 -0
- package/src/config.js +76 -0
- package/src/export.js +211 -0
- package/src/handler.js +229 -0
- package/src/hash.js +51 -0
- package/src/renderer.js +247 -0
- package/src/server.js +849 -0
- package/src/ui/app.jsx +152 -0
- package/src/ui/components/AnnotationForm.jsx +72 -0
- package/src/ui/components/Content.jsx +334 -0
- package/src/ui/components/ExportMenu.jsx +62 -0
- package/src/ui/components/LeftPanel.jsx +99 -0
- package/src/ui/components/Popover.jsx +94 -0
- package/src/ui/components/ReplyThread.jsx +28 -0
- package/src/ui/components/RightPanel.jsx +171 -0
- package/src/ui/components/SectionApproval.jsx +31 -0
- package/src/ui/components/ThemePicker.jsx +18 -0
- package/src/ui/hooks/useAnnotations.js +160 -0
- package/src/ui/hooks/useClientLibs.js +97 -0
- package/src/ui/hooks/useKeyboard.js +128 -0
- package/src/ui/hooks/useTheme.js +57 -0
- package/src/ui/hooks/useWebSocket.js +126 -0
- package/src/ui/index.html +19 -0
- package/src/ui/state/store.js +76 -0
- package/src/ui/styles/themes.css +1243 -0
package/src/export.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export functions for annotation files.
|
|
3
|
+
*
|
|
4
|
+
* Each function accepts a duck-typed annotationFile object (or the real
|
|
5
|
+
* AnnotationFile class) -- anything with `.source`, `.sourceHash`, `.version`,
|
|
6
|
+
* `.annotations`, `.sections`, and `.toJSON()`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// SARIF severity mapping
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const LEVEL_MAP = {
|
|
14
|
+
bug: 'error',
|
|
15
|
+
question: 'note',
|
|
16
|
+
suggestion: 'warning',
|
|
17
|
+
nitpick: 'note',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// exportReport
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generates a human-readable markdown review report.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} af - AnnotationFile (or duck-typed equivalent)
|
|
28
|
+
* @param {string} _sourceContent - Original markdown (unused but kept for API symmetry)
|
|
29
|
+
* @returns {string} Markdown report
|
|
30
|
+
*/
|
|
31
|
+
export function exportReport(af, _sourceContent) {
|
|
32
|
+
const annotations = af.annotations ?? []
|
|
33
|
+
|
|
34
|
+
if (annotations.length === 0) {
|
|
35
|
+
return `# Review Report: ${af.source}\n\nNo annotations found.\n`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const openCount = annotations.filter(a => a.status === 'open').length
|
|
39
|
+
const resolvedCount = annotations.filter(a => a.status === 'resolved').length
|
|
40
|
+
const total = annotations.length
|
|
41
|
+
|
|
42
|
+
const lines = []
|
|
43
|
+
|
|
44
|
+
// Title
|
|
45
|
+
lines.push(`# Review Report: ${af.source}`)
|
|
46
|
+
lines.push('')
|
|
47
|
+
|
|
48
|
+
// Summary
|
|
49
|
+
lines.push('## Summary')
|
|
50
|
+
lines.push('')
|
|
51
|
+
lines.push(`- **Total annotations:** ${total}`)
|
|
52
|
+
lines.push(`- **Open:** ${openCount}`)
|
|
53
|
+
lines.push(`- **Resolved:** ${resolvedCount}`)
|
|
54
|
+
lines.push('')
|
|
55
|
+
|
|
56
|
+
// Sections table
|
|
57
|
+
const sections = af.sections ?? []
|
|
58
|
+
if (sections.length > 0) {
|
|
59
|
+
lines.push('## Sections')
|
|
60
|
+
lines.push('')
|
|
61
|
+
lines.push('| Section | Status |')
|
|
62
|
+
lines.push('|---------|--------|')
|
|
63
|
+
for (const sec of sections) {
|
|
64
|
+
lines.push(`| ${sec.heading} | ${sec.status} |`)
|
|
65
|
+
}
|
|
66
|
+
lines.push('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Annotations detail
|
|
70
|
+
lines.push('## Annotations')
|
|
71
|
+
lines.push('')
|
|
72
|
+
|
|
73
|
+
for (const ann of annotations) {
|
|
74
|
+
lines.push(`### [${ann.tag}] ${ann.quote?.exact ?? ann.selectors?.quote?.exact ?? ''} (${ann.status})`)
|
|
75
|
+
lines.push('')
|
|
76
|
+
lines.push(`> ${ann.selectors?.quote?.exact ?? ''}`)
|
|
77
|
+
lines.push('')
|
|
78
|
+
lines.push(`**Comment:** ${ann.comment}`)
|
|
79
|
+
lines.push(`**Author:** ${ann.author} | **Status:** ${ann.status}`)
|
|
80
|
+
lines.push('')
|
|
81
|
+
|
|
82
|
+
if (ann.replies && ann.replies.length > 0) {
|
|
83
|
+
lines.push('**Replies:**')
|
|
84
|
+
for (const reply of ann.replies) {
|
|
85
|
+
lines.push(`- **${reply.author}:** ${reply.comment}`)
|
|
86
|
+
}
|
|
87
|
+
lines.push('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('---')
|
|
91
|
+
lines.push('')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// exportInline
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Inserts annotations as HTML comments into the original markdown source.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} af - AnnotationFile (or duck-typed equivalent)
|
|
105
|
+
* @param {string} sourceContent - Original markdown text
|
|
106
|
+
* @returns {string} Markdown with inline annotation comments
|
|
107
|
+
*/
|
|
108
|
+
export function exportInline(af, sourceContent) {
|
|
109
|
+
const annotations = af.annotations ?? []
|
|
110
|
+
|
|
111
|
+
if (annotations.length === 0) {
|
|
112
|
+
return sourceContent
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sourceLines = sourceContent.split('\n')
|
|
116
|
+
|
|
117
|
+
// Sort annotations by startLine descending so that insertions don't shift
|
|
118
|
+
// line indices of subsequent annotations.
|
|
119
|
+
const sorted = [...annotations].sort((a, b) => {
|
|
120
|
+
const lineA = a.selectors?.position?.startLine ?? 0
|
|
121
|
+
const lineB = b.selectors?.position?.startLine ?? 0
|
|
122
|
+
return lineB - lineA
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
for (const ann of sorted) {
|
|
126
|
+
const startLine = ann.selectors?.position?.startLine
|
|
127
|
+
if (startLine == null) continue
|
|
128
|
+
|
|
129
|
+
// Build the comment line
|
|
130
|
+
const prefix = ann.status === 'resolved' ? '[RESOLVED] ' : ''
|
|
131
|
+
const comment = `<!-- ${prefix}[${ann.tag}] ${ann.comment} -->`
|
|
132
|
+
|
|
133
|
+
// Insert after the annotated line (startLine is 1-based)
|
|
134
|
+
const insertIdx = startLine // after line at (startLine - 1), which is index startLine
|
|
135
|
+
sourceLines.splice(insertIdx, 0, comment)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return sourceLines.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// exportJSON
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns the annotationFile's JSON representation.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} af - AnnotationFile (or duck-typed equivalent)
|
|
149
|
+
* @returns {object} Plain JSON-serializable object
|
|
150
|
+
*/
|
|
151
|
+
export function exportJSON(af) {
|
|
152
|
+
return af.toJSON()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// exportSARIF
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generates a SARIF 2.1.0 report from annotations.
|
|
161
|
+
*
|
|
162
|
+
* Resolved annotations are excluded from results.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} af - AnnotationFile (or duck-typed equivalent)
|
|
165
|
+
* @param {string} sourceFilePath - Path to the source file (used in artifact URIs)
|
|
166
|
+
* @returns {object} SARIF 2.1.0 object
|
|
167
|
+
*/
|
|
168
|
+
export function exportSARIF(af, sourceFilePath) {
|
|
169
|
+
const annotations = af.annotations ?? []
|
|
170
|
+
|
|
171
|
+
// Only include open annotations; resolved are excluded.
|
|
172
|
+
const openAnnotations = annotations.filter(a => a.status === 'open')
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
$schema:
|
|
176
|
+
'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
|
|
177
|
+
version: '2.1.0',
|
|
178
|
+
runs: [
|
|
179
|
+
{
|
|
180
|
+
tool: {
|
|
181
|
+
driver: {
|
|
182
|
+
name: 'mdprobe',
|
|
183
|
+
version: '0.1.0',
|
|
184
|
+
informationUri: 'https://github.com/henryavila/mdprobe',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
results: openAnnotations.map(ann => {
|
|
188
|
+
const pos = ann.selectors?.position ?? {}
|
|
189
|
+
return {
|
|
190
|
+
ruleId: ann.tag,
|
|
191
|
+
level: LEVEL_MAP[ann.tag] ?? 'note',
|
|
192
|
+
message: { text: ann.comment },
|
|
193
|
+
locations: [
|
|
194
|
+
{
|
|
195
|
+
physicalLocation: {
|
|
196
|
+
artifactLocation: { uri: sourceFilePath },
|
|
197
|
+
region: {
|
|
198
|
+
startLine: pos.startLine,
|
|
199
|
+
startColumn: pos.startColumn,
|
|
200
|
+
endLine: pos.endLine,
|
|
201
|
+
endColumn: pos.endColumn,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/handler.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { render } from './renderer.js'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Embedded CSS for the review UI
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const EMBEDDED_CSS = `
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 2rem; line-height: 1.6; color: #24292f; }
|
|
9
|
+
h1, h2, h3 { border-bottom: 1px solid #d0d7de; padding-bottom: .3em; }
|
|
10
|
+
pre { background: #f6f8fa; padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
|
11
|
+
code { font-size: 0.9em; }
|
|
12
|
+
table { border-collapse: collapse; width: 100%; }
|
|
13
|
+
th, td { border: 1px solid #d0d7de; padding: .5rem; text-align: left; }
|
|
14
|
+
a { color: #0969da; text-decoration: none; }
|
|
15
|
+
a:hover { text-decoration: underline; }
|
|
16
|
+
ul.file-list { list-style: none; padding: 0; }
|
|
17
|
+
ul.file-list li { padding: .5rem 0; border-bottom: 1px solid #eee; }
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helper: wrap rendered HTML in a full page shell
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function htmlPage(title, bodyHtml) {
|
|
24
|
+
return `<!DOCTYPE html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
29
|
+
<title>${escapeHtml(title)}</title>
|
|
30
|
+
<style>${EMBEDDED_CSS}</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
${bodyHtml}
|
|
34
|
+
</body>
|
|
35
|
+
</html>`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeHtml(str) {
|
|
39
|
+
return String(str)
|
|
40
|
+
.replace(/&/g, '&')
|
|
41
|
+
.replace(/</g, '<')
|
|
42
|
+
.replace(/>/g, '>')
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helper: collect POST body
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
function collectBody(req) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const chunks = []
|
|
52
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
53
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
|
54
|
+
req.on('error', reject)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Public API
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates an HTTP request handler for the mdprobe review UI.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} options
|
|
66
|
+
* @param {function} [options.resolveFile] - (req) => string Resolves a file path from the request
|
|
67
|
+
* @param {function} [options.listFiles] - () => Array<{id, path, label}> Lists available files
|
|
68
|
+
* @param {string} [options.basePath='/'] - URL prefix the handler owns
|
|
69
|
+
* @param {string} [options.author] - Default author name
|
|
70
|
+
* @param {function} [options.onComplete] - Callback receiving {file, annotations, open, resolved}
|
|
71
|
+
* @returns {function(req, res): void}
|
|
72
|
+
*/
|
|
73
|
+
export function createHandler(options = {}) {
|
|
74
|
+
const {
|
|
75
|
+
resolveFile,
|
|
76
|
+
listFiles,
|
|
77
|
+
basePath = '/',
|
|
78
|
+
author,
|
|
79
|
+
onComplete,
|
|
80
|
+
} = options
|
|
81
|
+
|
|
82
|
+
// Normalise basePath: remove trailing slash (unless it IS just '/')
|
|
83
|
+
const base = basePath === '/' ? '' : basePath.replace(/\/+$/, '')
|
|
84
|
+
|
|
85
|
+
return function handler(req, res) {
|
|
86
|
+
// Parse the URL
|
|
87
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
88
|
+
const pathname = url.pathname
|
|
89
|
+
|
|
90
|
+
// ----- basePath guard -----
|
|
91
|
+
// If basePath is '/' (base === ''), everything matches.
|
|
92
|
+
// Otherwise, pathname must equal base or start with base + '/'.
|
|
93
|
+
if (base !== '') {
|
|
94
|
+
if (pathname !== base && !pathname.startsWith(base + '/')) {
|
|
95
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
96
|
+
res.end('Not Found')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Sub-path relative to basePath
|
|
102
|
+
let subPath
|
|
103
|
+
if (base === '') {
|
|
104
|
+
subPath = pathname
|
|
105
|
+
} else {
|
|
106
|
+
subPath = pathname.slice(base.length) || '/'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ensure subPath starts with '/'
|
|
110
|
+
if (!subPath.startsWith('/')) {
|
|
111
|
+
subPath = '/' + subPath
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ----- Route: static assets -----
|
|
115
|
+
if (subPath === '/assets/style.css' && req.method === 'GET') {
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'text/css' })
|
|
117
|
+
res.end(EMBEDDED_CSS)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ----- Route: GET /api/files -----
|
|
122
|
+
if (subPath === '/api/files' && req.method === 'GET') {
|
|
123
|
+
if (listFiles) {
|
|
124
|
+
const files = listFiles()
|
|
125
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
126
|
+
res.end(JSON.stringify(files))
|
|
127
|
+
} else {
|
|
128
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
129
|
+
res.end('[]')
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ----- Route: GET /api/annotations -----
|
|
135
|
+
if (subPath === '/api/annotations' && req.method === 'GET') {
|
|
136
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
137
|
+
res.end('[]')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ----- Route: POST /api/complete -----
|
|
142
|
+
if (subPath === '/api/complete' && req.method === 'POST') {
|
|
143
|
+
collectBody(req).then((bodyStr) => {
|
|
144
|
+
let data
|
|
145
|
+
try {
|
|
146
|
+
data = bodyStr ? JSON.parse(bodyStr) : {}
|
|
147
|
+
} catch {
|
|
148
|
+
data = {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (onComplete) {
|
|
152
|
+
const result = {
|
|
153
|
+
file: typeof data.file === 'string' ? data.file : '',
|
|
154
|
+
annotations: typeof data.annotations === 'number' ? data.annotations : 0,
|
|
155
|
+
open: typeof data.open === 'number' ? data.open : 0,
|
|
156
|
+
resolved: typeof data.resolved === 'number' ? data.resolved : 0,
|
|
157
|
+
}
|
|
158
|
+
onComplete(result)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
162
|
+
res.end(JSON.stringify({ ok: true }))
|
|
163
|
+
}).catch(() => {
|
|
164
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
165
|
+
res.end(JSON.stringify({ error: 'Bad request' }))
|
|
166
|
+
})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ----- Route: GET / (root) -----
|
|
171
|
+
if (subPath === '/' && req.method === 'GET') {
|
|
172
|
+
if (listFiles) {
|
|
173
|
+
// File picker mode
|
|
174
|
+
const files = listFiles()
|
|
175
|
+
const listHtml = files.map((f) => {
|
|
176
|
+
const href = (base || '') + '/' + f.id
|
|
177
|
+
const label = f.label || f.path || f.id
|
|
178
|
+
return `<li><a href="${escapeHtml(href)}">${escapeHtml(label)}</a></li>`
|
|
179
|
+
}).join('\n')
|
|
180
|
+
|
|
181
|
+
const body = htmlPage('mdprobe - Files', `<h1>Files</h1>\n<ul class="file-list">\n${listHtml}\n</ul>`)
|
|
182
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
183
|
+
res.end(body)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Single-file mode via resolveFile
|
|
188
|
+
if (resolveFile) {
|
|
189
|
+
const filePath = resolveFile(req)
|
|
190
|
+
readFile(filePath, 'utf-8').then((content) => {
|
|
191
|
+
const { html } = render(content)
|
|
192
|
+
const body = htmlPage('mdprobe', html)
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
194
|
+
res.end(body)
|
|
195
|
+
}).catch(() => {
|
|
196
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
197
|
+
res.end('File not found')
|
|
198
|
+
})
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Nothing configured
|
|
203
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
204
|
+
res.end(htmlPage('mdprobe', '<p>No files configured.</p>'))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ----- Route: GET /<anything> ---- resolve file by id/path -----
|
|
209
|
+
if (req.method === 'GET' && !subPath.startsWith('/api/') && !subPath.startsWith('/assets/')) {
|
|
210
|
+
if (resolveFile) {
|
|
211
|
+
const filePath = resolveFile(req)
|
|
212
|
+
readFile(filePath, 'utf-8').then((content) => {
|
|
213
|
+
const { html } = render(content)
|
|
214
|
+
const body = htmlPage('mdprobe', html)
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
216
|
+
res.end(body)
|
|
217
|
+
}).catch(() => {
|
|
218
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
219
|
+
res.end('File not found')
|
|
220
|
+
})
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ----- 404 fallback -----
|
|
226
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
227
|
+
res.end('Not Found')
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/hash.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import yaml from 'js-yaml'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compute SHA-256 hex digest of a string.
|
|
7
|
+
* @param {string} str
|
|
8
|
+
* @returns {string} 64-char lowercase hex hash
|
|
9
|
+
*/
|
|
10
|
+
export function hashContent(str) {
|
|
11
|
+
return createHash('sha256').update(str, 'utf8').digest('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read a file as UTF-8 and return its SHA-256 hex digest.
|
|
16
|
+
* @param {string} filePath
|
|
17
|
+
* @returns {Promise<string>}
|
|
18
|
+
*/
|
|
19
|
+
export async function hashFile(filePath) {
|
|
20
|
+
const content = await readFile(filePath, 'utf8')
|
|
21
|
+
return hashContent(content)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Detect whether the markdown source has drifted from the hash
|
|
26
|
+
* stored in its YAML sidecar annotation file.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} yamlPath - path to the YAML annotation sidecar
|
|
29
|
+
* @param {string} mdPath - path to the markdown source file
|
|
30
|
+
* @returns {Promise<{drifted: boolean, savedHash: string|null, currentHash: string}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function detectDrift(yamlPath, mdPath) {
|
|
33
|
+
const [yamlRaw, mdRaw] = await Promise.all([
|
|
34
|
+
readFile(yamlPath, 'utf8'),
|
|
35
|
+
readFile(mdPath, 'utf8'),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
const doc = yaml.load(yamlRaw)
|
|
39
|
+
const currentHash = hashContent(mdRaw)
|
|
40
|
+
|
|
41
|
+
let savedHash = null
|
|
42
|
+
if (doc && typeof doc.source_hash === 'string' && doc.source_hash.startsWith('sha256:')) {
|
|
43
|
+
savedHash = doc.source_hash.slice('sha256:'.length)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
drifted: savedHash !== currentHash,
|
|
48
|
+
savedHash,
|
|
49
|
+
currentHash,
|
|
50
|
+
}
|
|
51
|
+
}
|