@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/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, '&amp;')
41
+ .replace(/</g, '&lt;')
42
+ .replace(/>/g, '&gt;')
43
+ .replace(/"/g, '&quot;')
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
+ }