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/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
+ }
@@ -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
+ }