md-annotator 0.5.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/LICENSE +21 -0
- package/README.md +171 -0
- package/client/dist/favicon.svg +12 -0
- package/client/dist/index.html +2989 -0
- package/index.js +168 -0
- package/package.json +61 -0
- package/server/annotator.js +171 -0
- package/server/browser.js +20 -0
- package/server/config.js +36 -0
- package/server/feedback.js +156 -0
- package/server/file.js +43 -0
- package/server/index.js +55 -0
- package/server/notes.js +100 -0
- package/server/routes.js +151 -0
package/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { createServer } from './server/index.js'
|
|
6
|
+
import { isMarkdownFile, fileExists } from './server/file.js'
|
|
7
|
+
import { openBrowser } from './server/browser.js'
|
|
8
|
+
|
|
9
|
+
const HELP_TEXT = `
|
|
10
|
+
md-annotator — Annotate Markdown files in the browser
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
md-annotator [options] <file.md> [file2.md ...]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--help Show this help message
|
|
17
|
+
--origin <name> Set caller origin (cli, claude-code, opencode)
|
|
18
|
+
--feedback-notes <json|path> AI notes to display as read-only annotations
|
|
19
|
+
|
|
20
|
+
Environment:
|
|
21
|
+
MD_ANNOTATOR_PORT Base port (default: 3000)
|
|
22
|
+
MD_ANNOTATOR_BROWSER Custom browser app name
|
|
23
|
+
MD_ANNOTATOR_TIMEOUT Heartbeat timeout in ms (default: 30000, range: 5000–300000)
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
md-annotator README.md
|
|
27
|
+
md-annotator docs/api.md docs/guide.md
|
|
28
|
+
md-annotator --feedback-notes '[{"text":"Rewrote intro","line":5}]' README.md
|
|
29
|
+
md-annotator --feedback-notes notes.json README.md
|
|
30
|
+
`.trim()
|
|
31
|
+
|
|
32
|
+
function parseFeedbackNotes(value) {
|
|
33
|
+
const trimmed = value.trim()
|
|
34
|
+
let parsed
|
|
35
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
|
36
|
+
parsed = JSON.parse(trimmed)
|
|
37
|
+
} else {
|
|
38
|
+
// Treat as file path
|
|
39
|
+
const content = readFileSync(resolve(value), 'utf-8')
|
|
40
|
+
parsed = JSON.parse(content)
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(parsed) && (typeof parsed !== 'object' || parsed === null)) {
|
|
43
|
+
throw new Error('Expected a JSON array or object')
|
|
44
|
+
}
|
|
45
|
+
return parsed
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const args = argv.slice(2)
|
|
50
|
+
|
|
51
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
52
|
+
return { help: true }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const validOrigins = ['cli', 'claude-code', 'opencode']
|
|
56
|
+
let origin = 'cli'
|
|
57
|
+
let feedbackNotes = null
|
|
58
|
+
const filePaths = []
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
if (args[i] === '--origin') {
|
|
62
|
+
if (!args[i + 1] || args[i + 1].startsWith('-')) {
|
|
63
|
+
return { error: '--origin requires a value (cli, claude-code, opencode)' }
|
|
64
|
+
}
|
|
65
|
+
origin = args[i + 1]
|
|
66
|
+
i++
|
|
67
|
+
} else if (args[i] === '--feedback-notes') {
|
|
68
|
+
if (!args[i + 1]) {
|
|
69
|
+
return { error: '--feedback-notes requires a JSON string or file path' }
|
|
70
|
+
}
|
|
71
|
+
const value = args[i + 1]
|
|
72
|
+
i++
|
|
73
|
+
try {
|
|
74
|
+
feedbackNotes = parseFeedbackNotes(value)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { error: `--feedback-notes: ${err.message}` }
|
|
77
|
+
}
|
|
78
|
+
} else if (!args[i].startsWith('-')) {
|
|
79
|
+
filePaths.push(args[i])
|
|
80
|
+
} else {
|
|
81
|
+
return { error: `Unknown option: ${args[i]}` }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!validOrigins.includes(origin)) {
|
|
86
|
+
return { error: `Unknown origin "${origin}". Valid: ${validOrigins.join(', ')}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { filePaths, origin, feedbackNotes }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const { help, filePaths, origin, feedbackNotes, error } = parseArgs(process.argv)
|
|
94
|
+
|
|
95
|
+
if (error) {
|
|
96
|
+
process.stderr.write(`Error: ${error}\n\n`)
|
|
97
|
+
process.stderr.write(HELP_TEXT + '\n')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (help) {
|
|
102
|
+
process.stderr.write(HELP_TEXT + '\n')
|
|
103
|
+
process.exit(0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!filePaths || filePaths.length === 0) {
|
|
107
|
+
process.stderr.write('Error: No file specified.\n\n')
|
|
108
|
+
process.stderr.write(HELP_TEXT + '\n')
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const absolutePaths = []
|
|
113
|
+
for (const fp of filePaths) {
|
|
114
|
+
const abs = resolve(fp)
|
|
115
|
+
if (!isMarkdownFile(abs)) {
|
|
116
|
+
process.stderr.write(`Error: Not a Markdown file: ${fp}\n`)
|
|
117
|
+
process.exit(1)
|
|
118
|
+
}
|
|
119
|
+
if (!(await fileExists(abs))) {
|
|
120
|
+
process.stderr.write(`Error: File not found: ${abs}\n`)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
absolutePaths.push(abs)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const server = await createServer(absolutePaths, origin, { feedbackNotes })
|
|
127
|
+
const url = `http://localhost:${server.port}`
|
|
128
|
+
|
|
129
|
+
process.stderr.write(`Server running at ${url}\n`)
|
|
130
|
+
process.stderr.write(`Annotating: ${absolutePaths.join(', ')}\n`)
|
|
131
|
+
|
|
132
|
+
await openBrowser(url)
|
|
133
|
+
|
|
134
|
+
// Block until user clicks Approve or Submit Feedback (or browser disconnects)
|
|
135
|
+
const decision = await server.waitForDecision()
|
|
136
|
+
|
|
137
|
+
// Handle browser disconnect (no need to wait for browser)
|
|
138
|
+
if (decision.disconnected) {
|
|
139
|
+
process.stderr.write('Browser tab closed — no decision made.\n')
|
|
140
|
+
server.shutdown()
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Give browser time to receive response
|
|
145
|
+
await new Promise(r => setTimeout(r, 500))
|
|
146
|
+
|
|
147
|
+
// Log decision to stderr
|
|
148
|
+
if (decision.approved) {
|
|
149
|
+
process.stderr.write('Decision: Approved (no changes)\n')
|
|
150
|
+
} else {
|
|
151
|
+
process.stderr.write(`Decision: Feedback with ${decision.annotationCount} annotation(s)\n`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Output feedback to stdout — this is what Claude reads
|
|
155
|
+
const output = decision.approved
|
|
156
|
+
? 'APPROVED: No changes requested.\n'
|
|
157
|
+
: decision.feedback + '\n'
|
|
158
|
+
|
|
159
|
+
process.stdout.write(output, () => {
|
|
160
|
+
server.shutdown()
|
|
161
|
+
process.exit(0)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((error) => {
|
|
166
|
+
process.stderr.write(`Fatal: ${error.message}\n`)
|
|
167
|
+
process.exit(1)
|
|
168
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "md-annotator",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Browser-based Markdown annotator for AI-assisted review",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"md-annotator": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"server",
|
|
12
|
+
"client/dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node index.js",
|
|
16
|
+
"dev": "node --watch index.js",
|
|
17
|
+
"build": "vite build",
|
|
18
|
+
"prepack": "npm run build",
|
|
19
|
+
"dev:client": "vite dev",
|
|
20
|
+
"lint": "eslint . && stylelint '**/*.css'",
|
|
21
|
+
"lint:js": "eslint .",
|
|
22
|
+
"lint:css": "stylelint '**/*.css'",
|
|
23
|
+
"lint:fix": "eslint . --fix && stylelint '**/*.css' --fix",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"test:coverage": "vitest run --coverage"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"markdown",
|
|
30
|
+
"annotator",
|
|
31
|
+
"editor",
|
|
32
|
+
"ai",
|
|
33
|
+
"claude"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"cors": "^2.8.5",
|
|
38
|
+
"dompurify": "^3.3.1",
|
|
39
|
+
"express": "^4.21.0",
|
|
40
|
+
"highlight.js": "^11.11.0",
|
|
41
|
+
"mermaid": "^11.12.3",
|
|
42
|
+
"open": "^10.1.0",
|
|
43
|
+
"portfinder": "^1.0.32",
|
|
44
|
+
"react": "^19.0.0",
|
|
45
|
+
"react-dom": "^19.0.0",
|
|
46
|
+
"web-highlighter": "^0.7.4"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^9.39.2",
|
|
50
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
51
|
+
"eslint": "^9.39.2",
|
|
52
|
+
"eslint-plugin-react": "^7.37.5",
|
|
53
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
54
|
+
"globals": "^17.2.0",
|
|
55
|
+
"stylelint": "^17.0.0",
|
|
56
|
+
"stylelint-config-standard": "^40.0.0",
|
|
57
|
+
"vite": "^6.0.0",
|
|
58
|
+
"vite-plugin-singlefile": "^2.0.3",
|
|
59
|
+
"vitest": "^4.0.18"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotator server module.
|
|
3
|
+
* Provides startAnnotatorServer() for both CLI and plugin usage.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import express from 'express'
|
|
11
|
+
import cors from 'cors'
|
|
12
|
+
import portfinder from 'portfinder'
|
|
13
|
+
import { config } from './config.js'
|
|
14
|
+
import { createApiRouter } from './routes.js'
|
|
15
|
+
import { readMarkdownFile } from './file.js'
|
|
16
|
+
import { convertNotesToAnnotations } from './notes.js'
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const DIST_PATH = join(__dirname, '..', 'client', 'dist')
|
|
20
|
+
const DEV_PATH = join(__dirname, '..', 'client')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve notes for a specific file from the feedbackNotes array.
|
|
24
|
+
* Notes apply to the first file only (multi-file uses one invocation per file).
|
|
25
|
+
*/
|
|
26
|
+
function resolveNotesForFile(feedbackNotes, fileIndex, content) {
|
|
27
|
+
if (!Array.isArray(feedbackNotes) || fileIndex !== 0) {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
return convertNotesToAnnotations(feedbackNotes, content)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start the annotator server with configurable options.
|
|
35
|
+
*
|
|
36
|
+
* @param {Object} options - Server configuration
|
|
37
|
+
* @param {string} [options.filePath] - Absolute path to markdown file (single-file compat)
|
|
38
|
+
* @param {string[]} [options.filePaths] - Array of absolute paths to markdown files
|
|
39
|
+
* @param {string} [options.origin='cli'] - Origin identifier ('cli' | 'claude-code' | 'opencode')
|
|
40
|
+
* @param {string} [options.htmlContent] - Embedded HTML content (for plugin usage)
|
|
41
|
+
* @param {Function} [options.onReady] - Callback when server is ready: (url, port) => void
|
|
42
|
+
* @returns {Promise<Object>} Server control object
|
|
43
|
+
*/
|
|
44
|
+
export async function startAnnotatorServer(options) {
|
|
45
|
+
const {
|
|
46
|
+
filePath,
|
|
47
|
+
filePaths: filePathsOpt,
|
|
48
|
+
origin = 'cli',
|
|
49
|
+
htmlContent = null,
|
|
50
|
+
onReady = null,
|
|
51
|
+
feedbackNotes = null,
|
|
52
|
+
} = options
|
|
53
|
+
|
|
54
|
+
const filePaths = filePathsOpt || (filePath ? [filePath] : [])
|
|
55
|
+
|
|
56
|
+
const app = express()
|
|
57
|
+
|
|
58
|
+
// Middleware
|
|
59
|
+
app.use(cors())
|
|
60
|
+
app.use(express.json({ limit: config.jsonLimit }))
|
|
61
|
+
|
|
62
|
+
// Serve static files or embedded HTML
|
|
63
|
+
if (htmlContent) {
|
|
64
|
+
// Plugin mode: serve embedded HTML
|
|
65
|
+
app.get('/', (_req, res) => {
|
|
66
|
+
res.type('html').send(htmlContent)
|
|
67
|
+
})
|
|
68
|
+
} else {
|
|
69
|
+
// CLI mode: serve from disk
|
|
70
|
+
const clientPath = existsSync(DIST_PATH) ? DIST_PATH : DEV_PATH
|
|
71
|
+
app.use(express.static(clientPath))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Serve static files from markdown file directories (for relative images, etc.)
|
|
75
|
+
const servedDirs = new Set()
|
|
76
|
+
for (const fp of filePaths) {
|
|
77
|
+
const dir = dirname(fp)
|
|
78
|
+
if (!servedDirs.has(dir)) {
|
|
79
|
+
servedDirs.add(dir)
|
|
80
|
+
app.use(express.static(dir))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fallback: also serve from cwd for absolute-style paths
|
|
85
|
+
if (!servedDirs.has(process.cwd())) {
|
|
86
|
+
app.use(express.static(process.cwd()))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Health check
|
|
90
|
+
app.get('/health', (_req, res) => {
|
|
91
|
+
res.json({ status: 'ok' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Client heartbeat — detect browser tab close
|
|
95
|
+
let lastHeartbeat = 0
|
|
96
|
+
let heartbeatReceived = false
|
|
97
|
+
|
|
98
|
+
app.post('/api/heartbeat', (_req, res) => {
|
|
99
|
+
lastHeartbeat = Date.now()
|
|
100
|
+
heartbeatReceived = true
|
|
101
|
+
res.json({ status: 'ok' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Compute content hash per file for annotation persistence
|
|
105
|
+
const stores = await Promise.all(
|
|
106
|
+
filePaths.map(async (fp, index) => {
|
|
107
|
+
try {
|
|
108
|
+
const content = await readMarkdownFile(fp)
|
|
109
|
+
const contentHash = createHash('sha256').update(content).digest('hex')
|
|
110
|
+
const notes = resolveNotesForFile(feedbackNotes, index, content)
|
|
111
|
+
return { absolutePath: fp, contentHash, annotations: notes }
|
|
112
|
+
} catch (_e) {
|
|
113
|
+
return { absolutePath: fp, contentHash: null, annotations: [] }
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// Decision promise with guard against double resolution
|
|
119
|
+
let resolveDecision
|
|
120
|
+
let decided = false
|
|
121
|
+
const decisionPromise = new Promise((resolve) => {
|
|
122
|
+
resolveDecision = resolve
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
function safeResolve(value) {
|
|
126
|
+
if (decided) {return}
|
|
127
|
+
decided = true
|
|
128
|
+
resolveDecision(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// API routes with multi-file support
|
|
132
|
+
app.use(createApiRouter(filePaths, safeResolve, origin, stores))
|
|
133
|
+
|
|
134
|
+
// Find available port
|
|
135
|
+
portfinder.basePort = config.port
|
|
136
|
+
const port = await portfinder.getPortPromise()
|
|
137
|
+
|
|
138
|
+
// Start server
|
|
139
|
+
const server = await new Promise((resolve) => {
|
|
140
|
+
const s = app.listen(port, () => resolve(s))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const url = `http://localhost:${port}`
|
|
144
|
+
|
|
145
|
+
// Call onReady callback if provided
|
|
146
|
+
if (onReady) {
|
|
147
|
+
onReady(url, port)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Heartbeat monitor — resolve as disconnected if client goes silent
|
|
151
|
+
const heartbeatInterval = setInterval(() => {
|
|
152
|
+
if (heartbeatReceived && Date.now() - lastHeartbeat > config.heartbeatTimeoutMs) {
|
|
153
|
+
clearInterval(heartbeatInterval)
|
|
154
|
+
safeResolve({ disconnected: true })
|
|
155
|
+
}
|
|
156
|
+
}, 5000)
|
|
157
|
+
heartbeatInterval.unref()
|
|
158
|
+
|
|
159
|
+
// Stop function
|
|
160
|
+
function stop() {
|
|
161
|
+
clearInterval(heartbeatInterval)
|
|
162
|
+
server.close()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
port,
|
|
167
|
+
url,
|
|
168
|
+
waitForDecision: () => decisionPromise,
|
|
169
|
+
stop,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform browser opening utility.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import open from 'open'
|
|
6
|
+
import { config } from './config.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Open URL in the user's default browser.
|
|
10
|
+
* Optionally uses MD_ANNOTATOR_BROWSER environment variable.
|
|
11
|
+
*/
|
|
12
|
+
export async function openBrowser(url) {
|
|
13
|
+
const options = config.browser ? { app: { name: config.browser } } : {}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await open(url, options)
|
|
17
|
+
} catch {
|
|
18
|
+
// Silent failure — browser opening is best-effort
|
|
19
|
+
}
|
|
20
|
+
}
|
package/server/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized configuration from environment variables.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PORT = 3000
|
|
6
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
function getServerPort() {
|
|
9
|
+
const envPort = process.env.MD_ANNOTATOR_PORT
|
|
10
|
+
if (envPort) {
|
|
11
|
+
const parsed = parseInt(envPort, 10)
|
|
12
|
+
if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
13
|
+
return parsed
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return DEFAULT_PORT
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getHeartbeatTimeoutMs() {
|
|
20
|
+
const envTimeout = process.env.MD_ANNOTATOR_TIMEOUT
|
|
21
|
+
if (envTimeout) {
|
|
22
|
+
const parsed = parseInt(envTimeout, 10)
|
|
23
|
+
if (!isNaN(parsed) && parsed >= 5000 && parsed <= 300_000) {
|
|
24
|
+
return parsed
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_HEARTBEAT_TIMEOUT_MS
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const config = {
|
|
31
|
+
port: getServerPort(),
|
|
32
|
+
browser: process.env.MD_ANNOTATOR_BROWSER || null,
|
|
33
|
+
heartbeatTimeoutMs: getHeartbeatTimeoutMs(),
|
|
34
|
+
forceExitTimeoutMs: 5000,
|
|
35
|
+
jsonLimit: '10mb',
|
|
36
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a single annotation as Markdown feedback.
|
|
3
|
+
*/
|
|
4
|
+
function formatAnnotation(ann, block, heading) {
|
|
5
|
+
const blockStartLine = block?.startLine || 1
|
|
6
|
+
|
|
7
|
+
// Element-level annotations (image or diagram)
|
|
8
|
+
if (ann.targetType === 'image') {
|
|
9
|
+
const isDeletion = ann.type === 'DELETION'
|
|
10
|
+
const label = isDeletion ? 'Remove image' : 'Comment on image'
|
|
11
|
+
let output = `${heading} ${label} (Line ${blockStartLine})\n`
|
|
12
|
+
output += `Image: \`${ann.originalText}\`\n`
|
|
13
|
+
if (ann.imageAlt) { output += `Alt text: "${ann.imageAlt}"\n` }
|
|
14
|
+
if (ann.imageSrc) { output += `Source: ${ann.imageSrc}\n` }
|
|
15
|
+
if (isDeletion) {
|
|
16
|
+
output += `> User wants this image removed from the document.\n`
|
|
17
|
+
} else {
|
|
18
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n`
|
|
19
|
+
}
|
|
20
|
+
return output + '\n'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (ann.targetType === 'diagram') {
|
|
24
|
+
const isDeletion = ann.type === 'DELETION'
|
|
25
|
+
const label = isDeletion ? 'Remove Mermaid diagram' : 'Comment on Mermaid diagram'
|
|
26
|
+
let output = `${heading} ${label} (Line ${blockStartLine})\n`
|
|
27
|
+
output += `\`\`\`mermaid\n${block?.content || ann.originalText}\n\`\`\`\n`
|
|
28
|
+
if (isDeletion) {
|
|
29
|
+
output += `> User wants this diagram removed from the document.\n`
|
|
30
|
+
} else {
|
|
31
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n`
|
|
32
|
+
}
|
|
33
|
+
return output + '\n'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const blockContent = block?.content || ''
|
|
37
|
+
const textBeforeSelection = blockContent.slice(0, ann.startOffset)
|
|
38
|
+
const linesBeforeSelection = (textBeforeSelection.match(/\n/g) || []).length
|
|
39
|
+
const startLine = blockStartLine + linesBeforeSelection
|
|
40
|
+
const newlinesInSelection = (ann.originalText.match(/\n/g) || []).length
|
|
41
|
+
const endLine = startLine + newlinesInSelection
|
|
42
|
+
const lineRef = startLine === endLine ? `Line ${startLine}` : `Lines ${startLine}-${endLine}`
|
|
43
|
+
|
|
44
|
+
let output = `${heading} `
|
|
45
|
+
|
|
46
|
+
if (ann.type === 'DELETION') {
|
|
47
|
+
output += `Remove this (${lineRef})\n`
|
|
48
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
|
|
49
|
+
output += `> User wants this removed from the document.\n`
|
|
50
|
+
} else if (ann.type === 'COMMENT') {
|
|
51
|
+
output += `Comment on (${lineRef})\n`
|
|
52
|
+
output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
|
|
53
|
+
output += `> ${ann.text.replace(/\n/g, '\n> ')}\n`
|
|
54
|
+
} else if (ann.type === 'INSERTION') {
|
|
55
|
+
output += `Insert text (${lineRef})\n`
|
|
56
|
+
if (ann.afterContext) {
|
|
57
|
+
output += `After: \`${ann.afterContext}\`\n`
|
|
58
|
+
}
|
|
59
|
+
output += `\`\`\`\n${ann.text}\n\`\`\`\n`
|
|
60
|
+
output += `> User wants this text inserted at this point in the document.\n`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return output + '\n'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sortAnnotations(annotations, blocks) {
|
|
67
|
+
return [...annotations].sort((a, b) => {
|
|
68
|
+
const blockA = blocks.findIndex(blk => blk.id === a.blockId)
|
|
69
|
+
const blockB = blocks.findIndex(blk => blk.id === b.blockId)
|
|
70
|
+
if (blockA !== blockB) {return blockA - blockB}
|
|
71
|
+
return a.startOffset - b.startOffset
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Format annotations from multiple files as readable Markdown feedback.
|
|
77
|
+
* Single file delegates to exportFeedback. Multi-file groups by file.
|
|
78
|
+
*/
|
|
79
|
+
export function exportMultiFileFeedback(files) {
|
|
80
|
+
// Exclude NOTES (read-only AI notes) from feedback
|
|
81
|
+
const filesFiltered = files.map(f => ({
|
|
82
|
+
...f,
|
|
83
|
+
annotations: (f.annotations || []).filter(a => a.type !== 'NOTES')
|
|
84
|
+
}))
|
|
85
|
+
const filesWithAnnotations = filesFiltered.filter(f => f.annotations.length > 0)
|
|
86
|
+
|
|
87
|
+
if (filesWithAnnotations.length === 0) {
|
|
88
|
+
return 'No annotations.'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (filesWithAnnotations.length === 1) {
|
|
92
|
+
return exportFeedback(filesWithAnnotations[0].annotations, filesWithAnnotations[0].blocks)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const totalCount = filesWithAnnotations.reduce((sum, f) => sum + f.annotations.length, 0)
|
|
96
|
+
let output = `# Annotation Feedback\n\n`
|
|
97
|
+
output += `${totalCount} annotation${totalCount > 1 ? 's' : ''} across ${filesWithAnnotations.length} file${filesWithAnnotations.length > 1 ? 's' : ''}:\n\n`
|
|
98
|
+
|
|
99
|
+
let globalIndex = 1
|
|
100
|
+
for (const file of filesWithAnnotations) {
|
|
101
|
+
output += `---\n\n## File: ${file.path}\n\n`
|
|
102
|
+
const sorted = sortAnnotations(file.annotations, file.blocks)
|
|
103
|
+
const globalComments = sorted.filter(a => a.targetType === 'global')
|
|
104
|
+
const regularAnnotations = sorted.filter(a => a.targetType !== 'global')
|
|
105
|
+
|
|
106
|
+
if (globalComments.length > 0) {
|
|
107
|
+
output += `### General Feedback\n\n`
|
|
108
|
+
globalComments.forEach(ann => {
|
|
109
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const ann of regularAnnotations) {
|
|
114
|
+
const block = file.blocks.find(blk => blk.id === ann.blockId)
|
|
115
|
+
output += formatAnnotation(ann, block, `### ${globalIndex}.`)
|
|
116
|
+
globalIndex++
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
output += '---\n'
|
|
121
|
+
return output
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format annotations as readable Markdown feedback for Claude.
|
|
126
|
+
*/
|
|
127
|
+
export function exportFeedback(annotations, blocks) {
|
|
128
|
+
// Exclude NOTES (read-only AI notes) from feedback
|
|
129
|
+
const filtered = annotations.filter(a => a.type !== 'NOTES')
|
|
130
|
+
|
|
131
|
+
if (filtered.length === 0) {
|
|
132
|
+
return 'No annotations.'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sorted = sortAnnotations(filtered, blocks)
|
|
136
|
+
const globalComments = sorted.filter(a => a.targetType === 'global')
|
|
137
|
+
const regularAnnotations = sorted.filter(a => a.targetType !== 'global')
|
|
138
|
+
|
|
139
|
+
let output = `# Annotation Feedback\n\n`
|
|
140
|
+
output += `${filtered.length} annotation${filtered.length > 1 ? 's' : ''}:\n\n`
|
|
141
|
+
|
|
142
|
+
if (globalComments.length > 0) {
|
|
143
|
+
output += `## General Feedback\n\n`
|
|
144
|
+
globalComments.forEach(ann => {
|
|
145
|
+
output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
regularAnnotations.forEach((ann, index) => {
|
|
150
|
+
const block = blocks.find(blk => blk.id === ann.blockId)
|
|
151
|
+
output += formatAnnotation(ann, block, `## ${index + 1}.`)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
output += '---\n'
|
|
155
|
+
return output
|
|
156
|
+
}
|
package/server/file.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File I/O utilities for markdown files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { access, constants } from 'node:fs/promises'
|
|
7
|
+
import { extname, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd'])
|
|
10
|
+
|
|
11
|
+
export function isMarkdownFile(filePath) {
|
|
12
|
+
const ext = extname(filePath).toLowerCase()
|
|
13
|
+
return MARKDOWN_EXTENSIONS.has(ext)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function fileExists(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
await access(filePath, constants.R_OK)
|
|
19
|
+
return true
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readMarkdownFile(filePath) {
|
|
26
|
+
const absolutePath = resolve(filePath)
|
|
27
|
+
|
|
28
|
+
if (!isMarkdownFile(absolutePath)) {
|
|
29
|
+
throw new Error(`Not a Markdown file: ${absolutePath}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(absolutePath, 'utf-8')
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT') {
|
|
36
|
+
throw new Error(`File not found: ${absolutePath}`)
|
|
37
|
+
}
|
|
38
|
+
if (error.code === 'EACCES') {
|
|
39
|
+
throw new Error(`Permission denied: ${absolutePath}`)
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed to read file: ${error.message}`)
|
|
42
|
+
}
|
|
43
|
+
}
|