md-annotator 0.7.0 → 0.9.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.
@@ -12,6 +12,12 @@ const HTML_VOID_TAGS = new Set([
12
12
  'link', 'meta', 'param', 'source', 'track', 'wbr'
13
13
  ])
14
14
 
15
+ // HTML tags that can contain markdown content (e.g. <div align="center">)
16
+ const HTML_MIXED_CONTENT_TAGS = new Set([
17
+ 'div', 'section', 'details', 'aside', 'article', 'figure', 'figcaption',
18
+ 'header', 'footer', 'main', 'nav', 'center'
19
+ ])
20
+
15
21
  /**
16
22
  * Simplified markdown parser that splits content into linear blocks.
17
23
  * Designed for predictable text-anchoring (not AST-based).
@@ -215,12 +221,56 @@ export function parseMarkdownToBlocks(markdown) {
215
221
  flush()
216
222
  const tagName = tagMatch[1].toLowerCase()
217
223
  const htmlStartLine = currentLineNum
218
- const htmlLines = [line]
219
224
 
220
225
  const isSelfClosing = trimmed.endsWith('/>')
221
226
  const isVoid = HTML_VOID_TAGS.has(tagName)
222
227
  const hasSameLineClose = new RegExp(`</${tagName}\\s*>`, 'i').test(trimmed)
223
228
 
229
+ if (!isSelfClosing && !isVoid && !hasSameLineClose && HTML_MIXED_CONTENT_TAGS.has(tagName)) {
230
+ // Mixed content wrapper: split into open tag + inner markdown + close tag
231
+ const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
232
+ const openPattern = new RegExp(`<${tagName}[\\s>/]`, 'i')
233
+ const innerLines = []
234
+ let closingLine = null
235
+ let depth = 1
236
+ i++
237
+ while (i < lines.length) {
238
+ if (openPattern.test(lines[i].trim())) { depth++ }
239
+ if (closePattern.test(lines[i].trim())) {
240
+ depth--
241
+ if (depth === 0) { closingLine = lines[i]; break }
242
+ }
243
+ innerLines.push(lines[i])
244
+ i++
245
+ }
246
+
247
+ // Emit opening tag
248
+ blocks.push({
249
+ id: `block-${currentId++}`,
250
+ type: 'html',
251
+ content: line,
252
+ order: currentId,
253
+ startLine: htmlStartLine
254
+ })
255
+ // Recursively parse inner content as markdown
256
+ for (const inner of parseMarkdownToBlocks(innerLines.join('\n'))) {
257
+ blocks.push({ ...inner, id: `block-${currentId++}`, order: currentId, startLine: htmlStartLine + inner.startLine })
258
+ }
259
+ // Emit closing tag
260
+ if (closingLine) {
261
+ blocks.push({
262
+ id: `block-${currentId++}`,
263
+ type: 'html',
264
+ content: closingLine,
265
+ order: currentId,
266
+ startLine: htmlStartLine + innerLines.length + 1
267
+ })
268
+ }
269
+ continue
270
+ }
271
+
272
+ // Non-mixed HTML: collect everything as one opaque block
273
+ const htmlLines = [line]
224
274
  if (!isSelfClosing && !isVoid && !hasSameLineClose) {
225
275
  const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
226
276
  const openPattern = new RegExp(`<${tagName}[\\s>/]`, 'i')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-annotator",
3
- "version": "0.7.0",
3
+ "version": "0.9.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"
@@ -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
@@ -79,10 +100,18 @@ function formatAnnotation(ann, block, heading) {
79
100
  return output + '\n'
80
101
  }
81
102
 
103
+ function getBlockOrder(blockId, blocks) {
104
+ const sourceMatch = blockId?.match(/^source-line-(\d+)$/)
105
+ if (sourceMatch) { return parseInt(sourceMatch[1], 10) + 1 }
106
+ const block = blocks.find(blk => blk.id === blockId)
107
+ if (block?.startLine) { return block.startLine }
108
+ return Infinity
109
+ }
110
+
82
111
  function sortAnnotations(annotations, blocks) {
83
112
  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)
113
+ const blockA = getBlockOrder(a.blockId, blocks)
114
+ const blockB = getBlockOrder(b.blockId, blocks)
86
115
  if (blockA !== blockB) {return blockA - blockB}
87
116
  return a.startOffset - b.startOffset
88
117
  })
@@ -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
- }