md-annotator 0.5.2 → 0.5.4

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,244 @@
1
+ /**
2
+ * Export utilities for annotations.
3
+ * Mirrors server/feedback.js format for consistency.
4
+ */
5
+
6
+ /**
7
+ * Format annotations as exportable markdown.
8
+ */
9
+ export function formatAnnotationsForExport(annotations, blocks, filePath) {
10
+ if (annotations.length === 0) {
11
+ return 'No annotations.'
12
+ }
13
+
14
+ const sorted = [...annotations].sort((a, b) => {
15
+ const blockA = blocks.findIndex(blk => blk.id === a.blockId)
16
+ const blockB = blocks.findIndex(blk => blk.id === b.blockId)
17
+ if (blockA !== blockB) {return blockA - blockB}
18
+ return a.startOffset - b.startOffset
19
+ })
20
+
21
+ const globalComments = sorted.filter(a => a.targetType === 'global')
22
+ const regularAnnotations = sorted.filter(a => a.targetType !== 'global')
23
+
24
+ let output = `# Annotation Feedback\n\n`
25
+ output += `**File:** \`${filePath}\`\n\n`
26
+ output += `**Count:** ${annotations.length} annotation${annotations.length > 1 ? 's' : ''}\n\n`
27
+ output += `---\n\n`
28
+
29
+ if (globalComments.length > 0) {
30
+ output += `## General Feedback\n\n`
31
+ globalComments.forEach(ann => {
32
+ output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
33
+ })
34
+ }
35
+
36
+ regularAnnotations.forEach((ann, index) => {
37
+ const block = blocks.find(blk => blk.id === ann.blockId)
38
+ const blockStartLine = block?.startLine || 1
39
+
40
+ // Element-level annotations
41
+ if (ann.targetType === 'image') {
42
+ const isDeletion = ann.type === 'DELETION'
43
+ const label = isDeletion ? 'Remove image' : 'Comment on image'
44
+ output += `## ${index + 1}. ${label} (Line ${blockStartLine})\n\n`
45
+ output += `Image: \`${ann.originalText}\`\n\n`
46
+ if (ann.imageAlt) { output += `Alt text: "${ann.imageAlt}"\n\n` }
47
+ if (ann.imageSrc) { output += `Source: ${ann.imageSrc}\n\n` }
48
+ if (isDeletion) {
49
+ output += `> User wants this image removed from the document.\n\n`
50
+ } else {
51
+ output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
52
+ }
53
+ return
54
+ }
55
+ if (ann.targetType === 'diagram') {
56
+ const isDeletion = ann.type === 'DELETION'
57
+ const label = isDeletion ? 'Remove Mermaid diagram' : 'Comment on Mermaid diagram'
58
+ output += `## ${index + 1}. ${label} (Line ${blockStartLine})\n\n`
59
+ output += `\`\`\`mermaid\n${block?.content || ann.originalText}\n\`\`\`\n\n`
60
+ if (isDeletion) {
61
+ output += `> User wants this diagram removed from the document.\n\n`
62
+ } else {
63
+ output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n\n`
64
+ }
65
+ return
66
+ }
67
+
68
+ const blockContent = block?.content || ''
69
+ const textBeforeSelection = blockContent.slice(0, ann.startOffset)
70
+ const linesBeforeSelection = (textBeforeSelection.match(/\n/g) || []).length
71
+ const startLine = blockStartLine + linesBeforeSelection
72
+ const newlinesInSelection = (ann.originalText.match(/\n/g) || []).length
73
+ const endLine = startLine + newlinesInSelection
74
+
75
+ const lineRef = startLine === endLine ? `Line ${startLine}` : `Lines ${startLine}-${endLine}`
76
+
77
+ output += `## ${index + 1}. `
78
+
79
+ if (ann.type === 'DELETION') {
80
+ output += `Remove (${lineRef})\n\n`
81
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
82
+ } else if (ann.type === 'COMMENT') {
83
+ output += `Comment (${lineRef})\n\n`
84
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
85
+ output += `> ${ann.text}\n\n`
86
+ } else if (ann.type === 'INSERTION') {
87
+ output += `Insert text (${lineRef})\n\n`
88
+ if (ann.afterContext) {
89
+ output += `After: \`${ann.afterContext}\`\n\n`
90
+ }
91
+ output += `\`\`\`\n${ann.text}\n\`\`\`\n\n`
92
+ }
93
+ })
94
+
95
+ return output
96
+ }
97
+
98
+ /**
99
+ * Copy text to clipboard with fallback.
100
+ */
101
+ export async function copyToClipboard(text) {
102
+ try {
103
+ await navigator.clipboard.writeText(text)
104
+ return true
105
+ } catch {
106
+ const textarea = document.createElement('textarea')
107
+ textarea.value = text
108
+ textarea.style.position = 'fixed'
109
+ textarea.style.opacity = '0'
110
+ document.body.appendChild(textarea)
111
+ textarea.select()
112
+ document.execCommand('copy')
113
+ document.body.removeChild(textarea)
114
+ return true
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Trigger a browser file download from a Blob.
120
+ */
121
+ function downloadBlob(blob, filename) {
122
+ const url = URL.createObjectURL(blob)
123
+ const a = document.createElement('a')
124
+ a.href = url
125
+ a.download = filename
126
+ document.body.appendChild(a)
127
+ a.click()
128
+ document.body.removeChild(a)
129
+ URL.revokeObjectURL(url)
130
+ }
131
+
132
+ /**
133
+ * Download text as Markdown file.
134
+ */
135
+ export function downloadAsFile(content, filename = 'annotations.md') {
136
+ downloadBlob(new Blob([content], { type: 'text/markdown' }), filename)
137
+ }
138
+
139
+ /**
140
+ * Format annotations as JSON export with metadata.
141
+ */
142
+ export function formatAnnotationsForJsonExport(annotations, filePath, contentHash) {
143
+ return {
144
+ version: 1,
145
+ filePath,
146
+ contentHash,
147
+ exportedAt: new Date().toISOString(),
148
+ annotations: annotations.map(ann => {
149
+ const base = {
150
+ id: ann.id,
151
+ blockId: ann.blockId,
152
+ startOffset: ann.startOffset,
153
+ endOffset: ann.endOffset,
154
+ type: ann.type,
155
+ text: ann.text,
156
+ originalText: ann.originalText,
157
+ createdAt: ann.createdAt,
158
+ startMeta: ann.startMeta,
159
+ endMeta: ann.endMeta
160
+ }
161
+ if (ann.targetType) { base.targetType = ann.targetType }
162
+ if (ann.imageAlt !== undefined) { base.imageAlt = ann.imageAlt }
163
+ if (ann.imageSrc !== undefined) { base.imageSrc = ann.imageSrc }
164
+ if (ann.afterContext !== undefined) { base.afterContext = ann.afterContext }
165
+ return base
166
+ })
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Download data as JSON file.
172
+ */
173
+ export function downloadAsJsonFile(data, filename = 'annotations.json') {
174
+ downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), filename)
175
+ }
176
+
177
+ /**
178
+ * Validate imported JSON annotation data.
179
+ */
180
+ const REQUIRED_ANNOTATION_FIELDS = [
181
+ 'id', 'blockId', 'startOffset', 'endOffset',
182
+ 'type', 'originalText'
183
+ ]
184
+
185
+ export function validateAnnotationImport(data) {
186
+ if (!data || typeof data !== 'object') {
187
+ return { valid: false, error: 'Invalid JSON format' }
188
+ }
189
+ if (data.version !== 1) {
190
+ return { valid: false, error: `Unsupported version: ${data.version}` }
191
+ }
192
+ if (!Array.isArray(data.annotations)) {
193
+ return { valid: false, error: 'Missing annotations array' }
194
+ }
195
+ if (data.annotations.length > 10000) {
196
+ return { valid: false, error: 'Too many annotations (max 10,000)' }
197
+ }
198
+ for (const ann of data.annotations) {
199
+ for (const field of REQUIRED_ANNOTATION_FIELDS) {
200
+ if (ann[field] === undefined) {
201
+ return { valid: false, error: `Annotation missing required field: ${field}` }
202
+ }
203
+ }
204
+ if (typeof ann.id !== 'string' || typeof ann.blockId !== 'string') {
205
+ return { valid: false, error: 'Annotation id and blockId must be strings' }
206
+ }
207
+ if (typeof ann.startOffset !== 'number' || typeof ann.endOffset !== 'number') {
208
+ return { valid: false, error: 'Annotation offsets must be numbers' }
209
+ }
210
+ if (ann.startOffset < 0 || ann.endOffset < 0 || ann.endOffset < ann.startOffset) {
211
+ return { valid: false, error: 'Invalid annotation offset values' }
212
+ }
213
+ if (ann.type !== 'DELETION' && ann.type !== 'COMMENT' && ann.type !== 'INSERTION') {
214
+ return { valid: false, error: `Invalid annotation type: ${ann.type}` }
215
+ }
216
+ if (typeof ann.originalText !== 'string') {
217
+ return { valid: false, error: 'Annotation originalText must be a string' }
218
+ }
219
+ if (ann.text !== null && ann.text !== undefined && typeof ann.text !== 'string') {
220
+ return { valid: false, error: 'Annotation text must be a string or null' }
221
+ }
222
+ if (ann.targetType && ann.targetType !== 'image' && ann.targetType !== 'diagram' && ann.targetType !== 'global') {
223
+ return { valid: false, error: `Invalid annotation targetType: ${ann.targetType}` }
224
+ }
225
+ // Element annotations (image/diagram/global) and insertions have null startMeta/endMeta
226
+ const isElement = ann.targetType === 'image' || ann.targetType === 'diagram' || ann.targetType === 'global'
227
+ const isInsertion = ann.type === 'INSERTION'
228
+ if (!isElement && !isInsertion) {
229
+ if (!ann.startMeta || typeof ann.startMeta !== 'object') {
230
+ return { valid: false, error: 'Annotation startMeta must be an object' }
231
+ }
232
+ if (!ann.endMeta || typeof ann.endMeta !== 'object') {
233
+ return { valid: false, error: 'Annotation endMeta must be an object' }
234
+ }
235
+ }
236
+ }
237
+ return {
238
+ valid: true,
239
+ error: null,
240
+ annotations: data.annotations,
241
+ filePath: data.filePath || null,
242
+ contentHash: data.contentHash || null
243
+ }
244
+ }
@@ -0,0 +1,245 @@
1
+ const HTML_BLOCK_TAGS = new Set([
2
+ 'address', 'article', 'aside', 'blockquote', 'center', 'dd', 'details',
3
+ 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure',
4
+ 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup',
5
+ 'hr', 'iframe', 'main', 'menu', 'nav', 'ol', 'p', 'picture', 'pre',
6
+ 'section', 'source', 'summary', 'table', 'tbody', 'td', 'template',
7
+ 'tfoot', 'th', 'thead', 'tr', 'ul', 'video'
8
+ ])
9
+
10
+ const HTML_VOID_TAGS = new Set([
11
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
12
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
13
+ ])
14
+
15
+ /**
16
+ * Simplified markdown parser that splits content into linear blocks.
17
+ * Designed for predictable text-anchoring (not AST-based).
18
+ */
19
+ export function parseMarkdownToBlocks(markdown) {
20
+ const lines = markdown.split('\n')
21
+ const blocks = []
22
+ let currentId = 0
23
+ let buffer = []
24
+ let currentType = 'paragraph'
25
+ const currentLevel = 0
26
+ let bufferStartLine = 1
27
+
28
+ const flush = () => {
29
+ if (buffer.length > 0) {
30
+ const content = buffer.join('\n')
31
+ blocks.push({
32
+ id: `block-${currentId++}`,
33
+ type: currentType,
34
+ content,
35
+ level: currentLevel,
36
+ order: currentId,
37
+ startLine: bufferStartLine
38
+ })
39
+ buffer = []
40
+ }
41
+ }
42
+
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i]
45
+ const trimmed = line.trim()
46
+ const currentLineNum = i + 1
47
+
48
+ // Headings
49
+ if (trimmed.startsWith('#')) {
50
+ flush()
51
+ const level = trimmed.match(/^#+/)?.[0].length || 1
52
+ blocks.push({
53
+ id: `block-${currentId++}`,
54
+ type: 'heading',
55
+ content: trimmed.replace(/^#+\s*/, ''),
56
+ level,
57
+ order: currentId,
58
+ startLine: currentLineNum
59
+ })
60
+ continue
61
+ }
62
+
63
+ // Horizontal Rule
64
+ if (trimmed === '---' || trimmed === '***') {
65
+ flush()
66
+ blocks.push({
67
+ id: `block-${currentId++}`,
68
+ type: 'hr',
69
+ content: '',
70
+ order: currentId,
71
+ startLine: currentLineNum
72
+ })
73
+ continue
74
+ }
75
+
76
+ // List Items
77
+ if (trimmed.match(/^(\*|-|\d+\.)\s/)) {
78
+ flush()
79
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1] || ''
80
+ const spaceCount = leadingWhitespace.replace(/\t/g, ' ').length
81
+ const listLevel = Math.floor(spaceCount / 2)
82
+
83
+ let content = trimmed.replace(/^(\*|-|\d+\.)\s/, '')
84
+
85
+ let checked = undefined
86
+ const checkboxMatch = content.match(/^\[([ xX])\]\s*/)
87
+ if (checkboxMatch) {
88
+ checked = checkboxMatch[1].toLowerCase() === 'x'
89
+ content = content.replace(/^\[([ xX])\]\s*/, '')
90
+ }
91
+
92
+ blocks.push({
93
+ id: `block-${currentId++}`,
94
+ type: 'list-item',
95
+ content,
96
+ level: listLevel,
97
+ checked,
98
+ order: currentId,
99
+ startLine: currentLineNum
100
+ })
101
+ continue
102
+ }
103
+
104
+ // Blockquotes
105
+ if (trimmed.startsWith('>')) {
106
+ flush()
107
+ blocks.push({
108
+ id: `block-${currentId++}`,
109
+ type: 'blockquote',
110
+ content: trimmed.replace(/^>\s*/, ''),
111
+ order: currentId,
112
+ startLine: currentLineNum
113
+ })
114
+ continue
115
+ }
116
+
117
+ // Code blocks
118
+ if (trimmed.startsWith('```')) {
119
+ flush()
120
+ const codeStartLine = currentLineNum
121
+ const language = trimmed.slice(3).trim() || undefined
122
+ const codeContent = []
123
+ i++
124
+ while (i < lines.length && !lines[i].trim().startsWith('```')) {
125
+ codeContent.push(lines[i])
126
+ i++
127
+ }
128
+ blocks.push({
129
+ id: `block-${currentId++}`,
130
+ type: 'code',
131
+ content: codeContent.join('\n'),
132
+ language,
133
+ order: currentId,
134
+ startLine: codeStartLine
135
+ })
136
+ continue
137
+ }
138
+
139
+ // Tables
140
+ if (trimmed.startsWith('|') || (trimmed.includes('|') && trimmed.match(/^\|?.+\|.+\|?$/))) {
141
+ flush()
142
+ const tableStartLine = currentLineNum
143
+ const tableLines = [line]
144
+
145
+ while (i + 1 < lines.length) {
146
+ const nextLine = lines[i + 1].trim()
147
+ if (nextLine.startsWith('|') || (nextLine.includes('|') && nextLine.match(/^\|?.+\|.+\|?$/))) {
148
+ i++
149
+ tableLines.push(lines[i])
150
+ } else {
151
+ break
152
+ }
153
+ }
154
+
155
+ blocks.push({
156
+ id: `block-${currentId++}`,
157
+ type: 'table',
158
+ content: tableLines.join('\n'),
159
+ order: currentId,
160
+ startLine: tableStartLine
161
+ })
162
+ continue
163
+ }
164
+
165
+ // HTML blocks
166
+ if (trimmed.startsWith('<')) {
167
+ // HTML comments
168
+ if (trimmed.startsWith('<!--')) {
169
+ flush()
170
+ const htmlStartLine = currentLineNum
171
+ const htmlLines = [line]
172
+ if (!trimmed.includes('-->')) {
173
+ i++
174
+ while (i < lines.length) {
175
+ htmlLines.push(lines[i])
176
+ if (lines[i].includes('-->')) { break }
177
+ i++
178
+ }
179
+ }
180
+ blocks.push({
181
+ id: `block-${currentId++}`,
182
+ type: 'html',
183
+ content: htmlLines.join('\n'),
184
+ order: currentId,
185
+ startLine: htmlStartLine
186
+ })
187
+ continue
188
+ }
189
+
190
+ // Block-level HTML tags
191
+ const tagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)[\s>/]/)
192
+ if (tagMatch && HTML_BLOCK_TAGS.has(tagMatch[1].toLowerCase())) {
193
+ flush()
194
+ const tagName = tagMatch[1].toLowerCase()
195
+ const htmlStartLine = currentLineNum
196
+ const htmlLines = [line]
197
+
198
+ const isSelfClosing = trimmed.endsWith('/>')
199
+ const isVoid = HTML_VOID_TAGS.has(tagName)
200
+ const hasSameLineClose = new RegExp(`</${tagName}\\s*>`, 'i').test(trimmed)
201
+
202
+ if (!isSelfClosing && !isVoid && !hasSameLineClose) {
203
+ const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
204
+ const openPattern = new RegExp(`<${tagName}[\\s>/]`, 'i')
205
+ let depth = 1
206
+ i++
207
+ while (i < lines.length) {
208
+ htmlLines.push(lines[i])
209
+ if (openPattern.test(lines[i])) { depth++ }
210
+ if (closePattern.test(lines[i])) {
211
+ depth--
212
+ if (depth === 0) { break }
213
+ }
214
+ i++
215
+ }
216
+ }
217
+
218
+ blocks.push({
219
+ id: `block-${currentId++}`,
220
+ type: 'html',
221
+ content: htmlLines.join('\n'),
222
+ order: currentId,
223
+ startLine: htmlStartLine
224
+ })
225
+ continue
226
+ }
227
+ }
228
+
229
+ // Empty lines separate paragraphs
230
+ if (trimmed === '') {
231
+ flush()
232
+ currentType = 'paragraph'
233
+ continue
234
+ }
235
+
236
+ // Accumulate paragraph text
237
+ if (buffer.length === 0) {
238
+ bufferStartLine = currentLineNum
239
+ }
240
+ buffer.push(line)
241
+ }
242
+
243
+ flush()
244
+ return blocks
245
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cookie-based storage utility.
3
+ *
4
+ * Uses cookies instead of localStorage so settings persist across
5
+ * different ports (each session uses a random port).
6
+ * Cookies are scoped by domain, not port, so localhost:54321 and
7
+ * localhost:54322 share the same cookies.
8
+ */
9
+
10
+ const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365
11
+
12
+ function escapeRegex(str) {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14
+ }
15
+
16
+ function getItem(key) {
17
+ try {
18
+ const match = document.cookie.match(new RegExp(`(?:^|; )${escapeRegex(key)}=([^;]*)`))
19
+ return match ? decodeURIComponent(match[1]) : null
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ function setItem(key, value) {
26
+ try {
27
+ const encoded = encodeURIComponent(value)
28
+ document.cookie = `${key}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`
29
+ } catch {
30
+ // Cookie not available
31
+ }
32
+ }
33
+
34
+ const AUTO_CLOSE_KEY = 'md-annotator-auto-close'
35
+
36
+ export function getAutoCloseDelay() {
37
+ const val = getItem(AUTO_CLOSE_KEY)
38
+ if (val === '0' || val === '3' || val === '5') {return val}
39
+ return 'off'
40
+ }
41
+
42
+ export function setAutoCloseDelay(delay) {
43
+ setItem(AUTO_CLOSE_KEY, delay)
44
+ }
@@ -0,0 +1,8 @@
1
+ const WORDS_PER_MINUTE = 200
2
+
3
+ export function getTextStats(text) {
4
+ const lines = text.split('\n').length
5
+ const words = text.split(/\s+/).filter(Boolean).length
6
+ const readingTime = Math.max(1, Math.ceil(words / WORDS_PER_MINUTE))
7
+ return { lines, words, readingTime }
8
+ }
package/index.js CHANGED
@@ -18,9 +18,10 @@ Options:
18
18
  --feedback-notes <json|path> AI notes to display as read-only annotations
19
19
 
20
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)
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
+ MD_ANNOTATOR_FEEDBACK_NOTES JSON string or file path for feedback notes
24
25
 
25
26
  Examples:
26
27
  md-annotator README.md
@@ -86,6 +87,14 @@ function parseArgs(argv) {
86
87
  return { error: `Unknown origin "${origin}". Valid: ${validOrigins.join(', ')}` }
87
88
  }
88
89
 
90
+ if (!feedbackNotes && process.env.MD_ANNOTATOR_FEEDBACK_NOTES) {
91
+ try {
92
+ feedbackNotes = parseFeedbackNotes(process.env.MD_ANNOTATOR_FEEDBACK_NOTES)
93
+ } catch (err) {
94
+ return { error: `MD_ANNOTATOR_FEEDBACK_NOTES: ${err.message}` }
95
+ }
96
+ }
97
+
89
98
  return { filePaths, origin, feedbackNotes }
90
99
  }
91
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-annotator",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Browser-based Markdown annotator for AI-assisted review",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "index.js",
11
11
  "server",
12
- "client/dist"
12
+ "client/dist",
13
+ "client/src/utils"
13
14
  ],
14
15
  "scripts": {
15
16
  "start": "node index.js",