md-annotator 0.8.0 → 0.10.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.
@@ -80,7 +80,8 @@ export function formatAnnotationsForExport(annotations, blocks, filePath) {
80
80
  output += `Remove (${lineRef})\n\n`
81
81
  output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
82
82
  } else if (ann.type === 'COMMENT') {
83
- output += `Comment (${lineRef})\n\n`
83
+ const labelTag = ann.label ? ` [${ann.label.emoji} ${ann.label.text}]` : ''
84
+ output += `Comment (${lineRef})${labelTag}\n\n`
84
85
  output += `\`\`\`\n${ann.originalText}\n\`\`\`\n\n`
85
86
  output += `> ${ann.text}\n\n`
86
87
  } else if (ann.type === 'INSERTION') {
@@ -159,6 +160,7 @@ export function formatAnnotationsForJsonExport(annotations, filePath, contentHas
159
160
  endMeta: ann.endMeta
160
161
  }
161
162
  if (ann.targetType) { base.targetType = ann.targetType }
163
+ if (ann.label) { base.label = ann.label }
162
164
  if (ann.imageAlt !== undefined) { base.imageAlt = ann.imageAlt }
163
165
  if (ann.imageSrc !== undefined) { base.imageSrc = ann.imageSrc }
164
166
  if (ann.afterContext !== undefined) { base.afterContext = ann.afterContext }
@@ -22,7 +22,7 @@ const HTML_MIXED_CONTENT_TAGS = new Set([
22
22
  * Simplified markdown parser that splits content into linear blocks.
23
23
  * Designed for predictable text-anchoring (not AST-based).
24
24
  */
25
- export function parseMarkdownToBlocks(markdown) {
25
+ export function parseMarkdownToBlocks(markdown, { allowFrontmatter = true } = {}) {
26
26
  const lines = markdown.split('\n')
27
27
  const blocks = []
28
28
  let currentId = 0
@@ -30,6 +30,42 @@ export function parseMarkdownToBlocks(markdown) {
30
30
  let currentType = 'paragraph'
31
31
  const currentLevel = 0
32
32
  let bufferStartLine = 1
33
+ let startIndex = 0
34
+
35
+ // YAML frontmatter: must start at line 0 with exactly '---'
36
+ if (allowFrontmatter && lines[0]?.trim() === '---') {
37
+ let closeIndex = -1
38
+ for (let j = 1; j < lines.length; j++) {
39
+ if (lines[j].trim() === '---') {
40
+ closeIndex = j
41
+ break
42
+ }
43
+ }
44
+ if (closeIndex > 1) {
45
+ const fmLines = lines.slice(1, closeIndex)
46
+ const entries = []
47
+ for (const fmLine of fmLines) {
48
+ const colonIdx = fmLine.indexOf(':')
49
+ if (colonIdx > 0) {
50
+ entries.push({
51
+ key: fmLine.slice(0, colonIdx).trim(),
52
+ value: fmLine.slice(colonIdx + 1).trim()
53
+ })
54
+ }
55
+ }
56
+ if (entries.length > 0) {
57
+ blocks.push({
58
+ id: `block-${currentId++}`,
59
+ type: 'frontmatter',
60
+ content: fmLines.join('\n'),
61
+ entries,
62
+ order: currentId,
63
+ startLine: 1
64
+ })
65
+ startIndex = closeIndex + 1
66
+ }
67
+ }
68
+ }
33
69
 
34
70
  const flush = () => {
35
71
  if (buffer.length > 0) {
@@ -46,7 +82,7 @@ export function parseMarkdownToBlocks(markdown) {
46
82
  }
47
83
  }
48
84
 
49
- for (let i = 0; i < lines.length; i++) {
85
+ for (let i = startIndex; i < lines.length; i++) {
50
86
  const line = lines[i]
51
87
  const trimmed = line.trim()
52
88
  const currentLineNum = i + 1
@@ -253,7 +289,7 @@ export function parseMarkdownToBlocks(markdown) {
253
289
  startLine: htmlStartLine
254
290
  })
255
291
  // Recursively parse inner content as markdown
256
- for (const inner of parseMarkdownToBlocks(innerLines.join('\n'))) {
292
+ for (const inner of parseMarkdownToBlocks(innerLines.join('\n'), { allowFrontmatter: false })) {
257
293
  blocks.push({ ...inner, id: `block-${currentId++}`, order: currentId, startLine: htmlStartLine + inner.startLine })
258
294
  }
259
295
  // Emit closing tag
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Quick annotation labels for fast categorization.
3
+ * Alt+1–0 shortcuts apply a label directly to the current selection.
4
+ */
5
+
6
+ const STORAGE_KEY = 'md-annotator-quick-labels'
7
+
8
+ export const LABEL_COLORS = {
9
+ yellow: { bg: 'rgb(235 203 139 / 18%)', text: '#ebcb8b', darkText: '#ebcb8b' },
10
+ blue: { bg: 'rgb(94 129 172 / 15%)', text: '#5e81ac', darkText: '#88c0d0' },
11
+ red: { bg: 'rgb(191 97 106 / 12%)', text: '#bf616a', darkText: '#bf616a' },
12
+ green: { bg: 'rgb(163 190 140 / 15%)', text: '#a3be8c', darkText: '#a3be8c' },
13
+ orange: { bg: 'rgb(208 135 112 / 12%)', text: '#d08770', darkText: '#d08770' },
14
+ purple: { bg: 'rgb(180 142 173 / 15%)', text: '#b48ead', darkText: '#b48ead' },
15
+ cyan: { bg: 'rgb(136 192 208 / 15%)', text: '#88c0d0', darkText: '#88c0d0' },
16
+ teal: { bg: 'rgb(143 188 187 / 15%)', text: '#8fbcbb', darkText: '#8fbcbb' },
17
+ pink: { bg: 'rgb(180 142 173 / 18%)', text: '#b48ead', darkText: '#d196d0' },
18
+ amber: { bg: 'rgb(235 203 139 / 22%)', text: '#c9a227', darkText: '#ebcb8b' },
19
+ }
20
+
21
+ export const DEFAULT_LABELS = [
22
+ { id: 'unclear', emoji: '\u2753', text: 'Unclear', color: 'yellow' },
23
+ { id: 'rephrase', emoji: '\u270F\uFE0F', text: 'Rephrase', color: 'blue' },
24
+ { id: 'missing-context', emoji: '\uD83D\uDCDD', text: 'Missing Context', color: 'orange' },
25
+ { id: 'factual-error', emoji: '\u274C', text: 'Factual Error', color: 'red' },
26
+ { id: 'restructure', emoji: '\uD83D\uDD04', text: 'Restructure', color: 'purple' },
27
+ { id: 'expand', emoji: '\u2795', text: 'Expand', color: 'cyan' },
28
+ { id: 'shorten', emoji: '\u2796', text: 'Shorten', color: 'teal' },
29
+ { id: 'suggestion', emoji: '\uD83D\uDCA1', text: 'Suggestion', color: 'amber' },
30
+ { id: 'good', emoji: '\u2705', text: 'Good', color: 'green' },
31
+ { id: 'reference', emoji: '\uD83D\uDD17', text: 'Reference', color: 'pink' },
32
+ ]
33
+
34
+ export function getQuickLabels() {
35
+ try {
36
+ const stored = localStorage.getItem(STORAGE_KEY)
37
+ if (stored) {
38
+ const parsed = JSON.parse(stored)
39
+ if (Array.isArray(parsed) && parsed.length > 0) {
40
+ return parsed
41
+ }
42
+ }
43
+ } catch { /* ignore */ }
44
+ return DEFAULT_LABELS
45
+ }
46
+
47
+ export function getLabelColors(colorName) {
48
+ const entry = LABEL_COLORS[colorName]
49
+ if (!entry) { return { bg: 'rgb(94 129 172 / 15%)', text: '#5e81ac' } }
50
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
51
+ return { bg: entry.bg, text: isDark ? entry.darkText : entry.text }
52
+ }
53
+
54
+ export function formatLabelText(label) {
55
+ return `${label.emoji} ${label.text}`
56
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * DOM-based search highlighting.
3
+ * Injects/removes <mark> elements for text search matches.
4
+ */
5
+
6
+ const MATCH_CLASS = 'search-match'
7
+ const ACTIVE_CLASS = 'search-match-active'
8
+
9
+ // Selectors for containers whose text content should not be searched
10
+ // (rendered diagrams, hidden source blocks, UI chrome, line numbers, etc.)
11
+ const SKIP_SELECTOR = [
12
+ 'svg',
13
+ '.source-line-number',
14
+ '.annotation-toolbar',
15
+ '.comment-popover',
16
+ '.diagram-render-area',
17
+ '.diagram-source',
18
+ '.diagram-controls',
19
+ '.code-copy-btn',
20
+ ].join(', ')
21
+
22
+ /**
23
+ * Walk all text nodes in container, find case-insensitive matches for query,
24
+ * and wrap them in <mark class="search-match">.
25
+ * Skips text nodes inside <mark> elements (avoids double-wrapping).
26
+ * Returns an array of the created <mark> elements.
27
+ */
28
+ export function highlightMatches(container, query) {
29
+ if (!container || !query) { return [] }
30
+
31
+ const lowerQuery = query.toLowerCase()
32
+ const marks = []
33
+
34
+ // Collect text nodes first to avoid walking newly inserted marks
35
+ const textNodes = []
36
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
37
+ acceptNode(node) {
38
+ if (node.parentElement?.closest('mark')) { return NodeFilter.FILTER_REJECT }
39
+ if (node.parentElement?.closest(SKIP_SELECTOR)) { return NodeFilter.FILTER_REJECT }
40
+ return NodeFilter.FILTER_ACCEPT
41
+ }
42
+ })
43
+ while (walker.nextNode()) {
44
+ textNodes.push(walker.currentNode)
45
+ }
46
+
47
+ for (const node of textNodes) {
48
+ const text = node.textContent
49
+ const lowerText = text.toLowerCase()
50
+ const indices = []
51
+ let idx = 0
52
+
53
+ while ((idx = lowerText.indexOf(lowerQuery, idx)) !== -1) {
54
+ indices.push(idx)
55
+ idx += lowerQuery.length
56
+ }
57
+
58
+ if (indices.length === 0) { continue }
59
+
60
+ const parent = node.parentNode
61
+ const frag = document.createDocumentFragment()
62
+ let lastEnd = 0
63
+
64
+ for (const start of indices) {
65
+ if (start > lastEnd) {
66
+ frag.appendChild(document.createTextNode(text.slice(lastEnd, start)))
67
+ }
68
+ const mark = document.createElement('mark')
69
+ mark.className = MATCH_CLASS
70
+ mark.textContent = text.slice(start, start + query.length)
71
+ frag.appendChild(mark)
72
+ marks.push(mark)
73
+ lastEnd = start + query.length
74
+ }
75
+
76
+ if (lastEnd < text.length) {
77
+ frag.appendChild(document.createTextNode(text.slice(lastEnd)))
78
+ }
79
+
80
+ parent.replaceChild(frag, node)
81
+ }
82
+
83
+ return marks
84
+ }
85
+
86
+ /**
87
+ * Set the active match class on matches[activeIndex] and scroll it into view.
88
+ * Removes active class from all other matches.
89
+ */
90
+ export function setActiveMatch(matches, activeIndex) {
91
+ for (let i = 0; i < matches.length; i++) {
92
+ matches[i].classList.toggle(ACTIVE_CLASS, i === activeIndex)
93
+ }
94
+ if (matches[activeIndex]) {
95
+ matches[activeIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Remove all search-match <mark> elements, restoring original text nodes.
101
+ * Calls normalize() only on direct parents of removed marks (not the entire container)
102
+ * to avoid disrupting web-highlighter's [data-highlight-id] spans.
103
+ */
104
+ export function clearSearchHighlights(container) {
105
+ if (!container) { return }
106
+
107
+ const marks = container.querySelectorAll(`mark.${MATCH_CLASS}`)
108
+ const parentsToNormalize = new Set()
109
+
110
+ for (const mark of marks) {
111
+ const parent = mark.parentNode
112
+ if (!parent) { continue }
113
+ const text = document.createTextNode(mark.textContent)
114
+ parent.replaceChild(text, mark)
115
+ parentsToNormalize.add(parent)
116
+ }
117
+
118
+ for (const parent of parentsToNormalize) {
119
+ parent.normalize()
120
+ }
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-annotator",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Browser-based Markdown annotator for AI-assisted review",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,6 @@
43
43
  "open": "^10.1.0",
44
44
  "pako": "^2.1.0",
45
45
  "plantuml-encoder": "^1.4.0",
46
- "portfinder": "^1.0.32",
47
46
  "react": "^19.0.0",
48
47
  "react-dom": "^19.0.0",
49
48
  "web-highlighter": "^0.7.4"
@@ -55,6 +54,7 @@
55
54
  "eslint-plugin-react": "^7.37.5",
56
55
  "eslint-plugin-react-hooks": "^7.0.1",
57
56
  "globals": "^17.2.0",
57
+ "jsdom": "^29.0.1",
58
58
  "stylelint": "^17.0.0",
59
59
  "stylelint-config-standard": "^40.0.0",
60
60
  "vite": "^6.0.0",
@@ -3,13 +3,12 @@
3
3
  * Provides startAnnotatorServer() for both CLI and plugin usage.
4
4
  */
5
5
 
6
- import { existsSync } from 'node:fs'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { dirname, join } from 'node:path'
10
10
  import express from 'express'
11
11
  import cors from 'cors'
12
- import portfinder from 'portfinder'
13
12
  import { config } from './config.js'
14
13
  import { createApiRouter } from './routes.js'
15
14
  import { readMarkdownFile } from './file.js'
@@ -19,6 +18,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
19
18
  const DIST_PATH = join(__dirname, '..', 'client', 'dist')
20
19
  const DEV_PATH = join(__dirname, '..', 'client')
21
20
 
21
+ // Pre-load index.html into memory at module load time (avoids per-request disk I/O)
22
+ const DIST_INDEX = join(DIST_PATH, 'index.html')
23
+ const DEV_INDEX = join(DEV_PATH, 'index.html')
24
+ const preloadedHtml = existsSync(DIST_INDEX)
25
+ ? readFileSync(DIST_INDEX, 'utf-8')
26
+ : existsSync(DEV_INDEX)
27
+ ? readFileSync(DEV_INDEX, 'utf-8')
28
+ : null
29
+
22
30
  /**
23
31
  * Resolve notes for a specific file from the feedbackNotes array.
24
32
  * Notes apply to the first file only (multi-file uses one invocation per file).
@@ -59,14 +67,14 @@ export async function startAnnotatorServer(options) {
59
67
  app.use(cors())
60
68
  app.use(express.json({ limit: config.jsonLimit }))
61
69
 
62
- // Serve static files or embedded HTML
63
- if (htmlContent) {
64
- // Plugin mode: serve embedded HTML
70
+ // Serve HTML from memory (pre-loaded or embedded)
71
+ const html = htmlContent || preloadedHtml
72
+ if (html) {
65
73
  app.get('/', (_req, res) => {
66
- res.type('html').send(htmlContent)
74
+ res.type('html').send(html)
67
75
  })
68
76
  } else {
69
- // CLI mode: serve from disk
77
+ // Fallback: serve from disk (dev mode without built index.html)
70
78
  const clientPath = existsSync(DIST_PATH) ? DIST_PATH : DEV_PATH
71
79
  app.use(express.static(clientPath))
72
80
  }
@@ -131,15 +139,14 @@ export async function startAnnotatorServer(options) {
131
139
  // API routes with multi-file support
132
140
  app.use(createApiRouter(filePaths, safeResolve, origin, stores))
133
141
 
134
- // Find available port
135
- portfinder.basePort = config.port
136
- const port = await portfinder.getPortPromise()
137
-
138
- // Start server
142
+ // Start server — use port 0 to let the OS assign a free port instantly,
143
+ // falling back to configured port if explicitly set via MD_ANNOTATOR_PORT
144
+ const requestedPort = config.portExplicit ? config.port : 0
139
145
  const server = await new Promise((resolve) => {
140
- const s = app.listen(port, () => resolve(s))
146
+ const s = app.listen(requestedPort, () => resolve(s))
141
147
  })
142
148
 
149
+ const port = server.address().port
143
150
  const url = `http://localhost:${port}`
144
151
 
145
152
  // Call onReady callback if provided
package/server/config.js CHANGED
@@ -45,6 +45,7 @@ function getKrokiServerUrl() {
45
45
 
46
46
  export const config = {
47
47
  port: getServerPort(),
48
+ portExplicit: !!process.env.MD_ANNOTATOR_PORT,
48
49
  browser: process.env.MD_ANNOTATOR_BROWSER || null,
49
50
  heartbeatTimeoutMs: getHeartbeatTimeoutMs(),
50
51
  forceExitTimeoutMs: 5000,
@@ -49,6 +49,27 @@ function formatAnnotation(ann, block, heading) {
49
49
  return output + '\n'
50
50
  }
51
51
 
52
+ // Source view annotations — blockId is "source-line-N" (0-indexed)
53
+ if (ann.targetType === 'source') {
54
+ const lineMatch = ann.blockId?.match(/^source-line-(\d+)$/)
55
+ const startLine = lineMatch ? parseInt(lineMatch[1], 10) + 1 : 1
56
+ const newlinesInSelection = (ann.originalText.match(/\n/g) || []).length
57
+ const endLine = startLine + newlinesInSelection
58
+ const lineRef = startLine === endLine ? `Line ${startLine}` : `Lines ${startLine}-${endLine}`
59
+
60
+ let output = `${heading} `
61
+ if (ann.type === 'DELETION') {
62
+ output += `Remove this (${lineRef}, source)\n`
63
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
64
+ output += `> User wants this removed from the document.\n`
65
+ } else if (ann.type === 'COMMENT') {
66
+ output += `Comment on (${lineRef}, source)\n`
67
+ output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
68
+ output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n`
69
+ }
70
+ return output + '\n'
71
+ }
72
+
52
73
  const blockContent = block?.content || ''
53
74
  const textBeforeSelection = blockContent.slice(0, ann.startOffset)
54
75
  const linesBeforeSelection = (textBeforeSelection.match(/\n/g) || []).length
@@ -64,7 +85,8 @@ function formatAnnotation(ann, block, heading) {
64
85
  output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
65
86
  output += `> User wants this removed from the document.\n`
66
87
  } else if (ann.type === 'COMMENT') {
67
- output += `Comment on (${lineRef})\n`
88
+ const labelTag = ann.label ? ` [${ann.label.emoji} ${ann.label.text}]` : ''
89
+ output += `Comment on (${lineRef})${labelTag}\n`
68
90
  output += `\`\`\`\n${ann.originalText}\n\`\`\`\n`
69
91
  output += `> ${(ann.text ?? '').replace(/\n/g, '\n> ')}\n`
70
92
  } else if (ann.type === 'INSERTION') {
@@ -79,10 +101,18 @@ function formatAnnotation(ann, block, heading) {
79
101
  return output + '\n'
80
102
  }
81
103
 
104
+ function getBlockOrder(blockId, blocks) {
105
+ const sourceMatch = blockId?.match(/^source-line-(\d+)$/)
106
+ if (sourceMatch) { return parseInt(sourceMatch[1], 10) + 1 }
107
+ const block = blocks.find(blk => blk.id === blockId)
108
+ if (block?.startLine) { return block.startLine }
109
+ return Infinity
110
+ }
111
+
82
112
  function sortAnnotations(annotations, blocks) {
83
113
  return [...annotations].sort((a, b) => {
84
- const blockA = blocks.findIndex(blk => blk.id === a.blockId)
85
- const blockB = blocks.findIndex(blk => blk.id === b.blockId)
114
+ const blockA = getBlockOrder(a.blockId, blocks)
115
+ const blockB = getBlockOrder(b.blockId, blocks)
86
116
  if (blockA !== blockB) {return blockA - blockB}
87
117
  return a.startOffset - b.startOffset
88
118
  })
@@ -1,44 +0,0 @@
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
- }