@peaske7/readit 0.2.0 → 0.2.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.
Files changed (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. package/src/store.ts +0 -222
@@ -0,0 +1,74 @@
1
+ package server
2
+
3
+ import (
4
+ "sync"
5
+ "time"
6
+
7
+ "github.com/fsnotify/fsnotify"
8
+ )
9
+
10
+ type Watcher struct {
11
+ fsWatcher *fsnotify.Watcher
12
+ debounce map[string]*time.Timer
13
+ onChange func(filePath string)
14
+ mu sync.Mutex
15
+ done chan struct{}
16
+ }
17
+
18
+ func NewWatcher(onChange func(string)) (*Watcher, error) {
19
+ fsw, err := fsnotify.NewWatcher()
20
+ if err != nil {
21
+ return nil, err
22
+ }
23
+
24
+ w := &Watcher{
25
+ fsWatcher: fsw,
26
+ debounce: make(map[string]*time.Timer),
27
+ onChange: onChange,
28
+ done: make(chan struct{}),
29
+ }
30
+
31
+ go w.loop()
32
+ return w, nil
33
+ }
34
+
35
+ func (w *Watcher) loop() {
36
+ for {
37
+ select {
38
+ case event, ok := <-w.fsWatcher.Events:
39
+ if !ok {
40
+ return
41
+ }
42
+ if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
43
+ w.debounceChange(event.Name)
44
+ }
45
+ case _, ok := <-w.fsWatcher.Errors:
46
+ if !ok {
47
+ return
48
+ }
49
+ case <-w.done:
50
+ return
51
+ }
52
+ }
53
+ }
54
+
55
+ func (w *Watcher) debounceChange(path string) {
56
+ w.mu.Lock()
57
+ defer w.mu.Unlock()
58
+
59
+ if t, ok := w.debounce[path]; ok {
60
+ t.Stop()
61
+ }
62
+ w.debounce[path] = time.AfterFunc(100*time.Millisecond, func() {
63
+ w.onChange(path)
64
+ })
65
+ }
66
+
67
+ func (w *Watcher) Add(path string) error {
68
+ return w.fsWatcher.Add(path)
69
+ }
70
+
71
+ func (w *Watcher) Close() {
72
+ close(w.done)
73
+ _ = w.fsWatcher.Close()
74
+ }
package/index.html CHANGED
@@ -3,20 +3,10 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>readit - Markdown Review</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📖</text></svg>">
10
- <script>
11
- (() => {
12
- var t = localStorage.getItem("readit:theme");
13
- var d = t === "dark" || (t !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
14
- if (d) document.documentElement.classList.add("dark");
15
- })();
16
- </script>
6
+ <title>readit</title>
17
7
  </head>
18
- <body class="min-h-screen">
19
- <div id="root"></div>
20
- <script type="module" src="/src/main.tsx"></script>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
21
11
  </body>
22
12
  </html>
@@ -0,0 +1,64 @@
1
+ -- Health check for readit.nvim
2
+ -- Run with :checkhealth readit
3
+
4
+ local M = {}
5
+
6
+ function M.check()
7
+ vim.health.start("readit.nvim")
8
+
9
+ -- Check bun
10
+ local readit = require("readit")
11
+ local bun_path = readit.config.bun_path or "bun"
12
+ local bun_exec = vim.fn.exepath(bun_path)
13
+
14
+ if bun_exec ~= "" then
15
+ local version = vim.fn.system(bun_path .. " --version")
16
+ vim.health.ok("bun found: " .. vim.trim(version) .. " (" .. bun_exec .. ")")
17
+ else
18
+ vim.health.error(
19
+ "bun not found",
20
+ { "Install Bun: https://bun.sh", "Or set bun_path in setup()" }
21
+ )
22
+ end
23
+
24
+ -- Check readit CLI
25
+ local readit_exec = vim.fn.exepath("readit")
26
+ if readit_exec ~= "" then
27
+ vim.health.ok("readit CLI found: " .. readit_exec)
28
+ else
29
+ local dist = vim.fn.expand("~/.readit/dist/index.js")
30
+ if vim.fn.filereadable(dist) == 1 then
31
+ vim.health.ok("readit dist found: " .. dist)
32
+ else
33
+ vim.health.warn(
34
+ "readit CLI not in PATH",
35
+ { "Install: npm install -g @peaske7/readit", "Or: bun add -g @peaske7/readit" }
36
+ )
37
+ end
38
+ end
39
+
40
+ -- Check curl (needed for server communication)
41
+ if vim.fn.exepath("curl") ~= "" then
42
+ vim.health.ok("curl found")
43
+ else
44
+ vim.health.error("curl not found (required for server API calls)")
45
+ end
46
+
47
+ -- Check server status
48
+ if readit._server_port then
49
+ vim.health.ok("Server running on port " .. readit._server_port)
50
+ else
51
+ vim.health.info("Server not running (will start on :ReaditOpen)")
52
+ end
53
+
54
+ -- Check for comments directory
55
+ local comments_dir = vim.fn.expand("~/.readit/comments")
56
+ if vim.fn.isdirectory(comments_dir) == 1 then
57
+ local count = #vim.fn.glob(comments_dir .. "/**/*.comments.md", false, true)
58
+ vim.health.ok("Comments directory exists (" .. count .. " comment files)")
59
+ else
60
+ vim.health.info("No comments directory yet (~/.readit/comments/)")
61
+ end
62
+ end
63
+
64
+ return M
@@ -0,0 +1,463 @@
1
+ -- readit.nvim — Neovim plugin for rendering Markdown with readit
2
+ --
3
+ -- Manages a readit server process and opens documents in the browser
4
+ -- or a split terminal. Supports live-reload: edits in Neovim are
5
+ -- reflected in the browser automatically (via readit's file watcher).
6
+
7
+ local M = {}
8
+
9
+ --- @class ReaditConfig
10
+ --- @field bun_path string Path to bun executable
11
+ --- @field port number Preferred server port (0 = auto)
12
+ --- @field host string Server host
13
+ --- @field auto_open boolean Open browser automatically
14
+ --- @field auto_reload boolean Auto-reload on BufWritePost (enabled by default via readit's fs watcher)
15
+ --- @field open_cmd string|nil Custom browser open command
16
+ --- @field keymap_prefix string Key prefix for mappings
17
+ --- @field keymaps table<string, string|false> Keymap overrides (false to disable)
18
+ --- @field float_opts table Floating window options for terminal
19
+
20
+ --- @type ReaditConfig
21
+ M.config = {
22
+ bun_path = "bun",
23
+ port = 0,
24
+ host = "127.0.0.1",
25
+ auto_open = true,
26
+ auto_reload = true,
27
+ open_cmd = nil,
28
+ keymap_prefix = "<leader>r",
29
+ keymaps = {
30
+ open = "o", -- Open current file in readit
31
+ open_side = "s", -- Open in side browser (if available)
32
+ stop = "q", -- Stop the server
33
+ status = "i", -- Show server status
34
+ reload = "r", -- Force reload in browser
35
+ list = "l", -- List commented files
36
+ },
37
+ float_opts = {
38
+ relative = "editor",
39
+ width = 0.8,
40
+ height = 0.8,
41
+ border = "rounded",
42
+ },
43
+ }
44
+
45
+ --- @type number|nil
46
+ M._server_port = nil
47
+ --- @type number|nil
48
+ M._server_job_id = nil
49
+ --- @type table<string, boolean>
50
+ M._attached_files = {}
51
+
52
+ -- ── Setup ────────────────────────────────────────────────────────────
53
+
54
+ --- Setup the plugin with user configuration
55
+ --- @param opts? ReaditConfig
56
+ --- @type boolean
57
+ M._setup_done = false
58
+
59
+ function M.setup(opts)
60
+ M.config = vim.tbl_deep_extend("force", M.config, opts or {})
61
+ M._setup_commands()
62
+ M._setup_keymaps()
63
+ M._setup_autocmds()
64
+ M._setup_done = true
65
+ end
66
+
67
+ -- ── Server Management ────────────────────────────────────────────────
68
+
69
+ --- Discover an already-running readit server from ~/.readit/server.json
70
+ --- @return {port: number, pid: number}|nil
71
+ function M._discover_server()
72
+ local path = vim.fn.expand("~/.readit/server.json")
73
+ local ok, content = pcall(vim.fn.readfile, path)
74
+ if not ok or #content == 0 then
75
+ return nil
76
+ end
77
+
78
+ local decoded = vim.json.decode(table.concat(content, "\n"))
79
+ if not decoded or not decoded.port or not decoded.pid then
80
+ return nil
81
+ end
82
+
83
+ -- Check if the process is alive
84
+ local alive = vim.fn.system("kill -0 " .. decoded.pid .. " 2>/dev/null; echo $?")
85
+ if vim.trim(alive) ~= "0" then
86
+ return nil
87
+ end
88
+
89
+ -- Health check
90
+ local health = vim.fn.system(
91
+ "curl -sf http://127.0.0.1:" .. decoded.port .. "/api/health 2>/dev/null"
92
+ )
93
+ if health == "" or vim.v.shell_error ~= 0 then
94
+ return nil
95
+ end
96
+
97
+ return decoded
98
+ end
99
+
100
+ --- Start the readit server for a given file
101
+ --- @param file_path string Absolute path to the markdown file
102
+ --- @param callback? fun(port: number) Called when server is ready
103
+ function M.start_server(file_path, callback)
104
+ -- Check for existing server first
105
+ local existing = M._discover_server()
106
+ if existing then
107
+ M._server_port = existing.port
108
+ M._attach_file(file_path, callback)
109
+ return
110
+ end
111
+
112
+ -- If we already have a running job, attach to it
113
+ if M._server_job_id and vim.fn.jobwait({ M._server_job_id }, 0)[1] == -1 then
114
+ if M._server_port then
115
+ M._attach_file(file_path, callback)
116
+ return
117
+ end
118
+ end
119
+
120
+ local port = M.config.port
121
+ local args = {
122
+ M.config.bun_path,
123
+ "run",
124
+ "--bun",
125
+ vim.fn.expand("~/.readit/dist/index.js"), -- Try installed version first
126
+ file_path,
127
+ "--no-open",
128
+ }
129
+
130
+ -- Check if the global dist exists; otherwise try npx
131
+ if vim.fn.filereadable(vim.fn.expand("~/.readit/dist/index.js")) == 0 then
132
+ -- Try to find readit in the project or globally
133
+ local readit_bin = vim.fn.exepath("readit")
134
+ if readit_bin ~= "" then
135
+ args = { readit_bin, file_path, "--no-open" }
136
+ else
137
+ -- Fall back to bunx
138
+ args = { M.config.bun_path .. "x", "@peaske7/readit", file_path, "--no-open" }
139
+ end
140
+ end
141
+
142
+ if port > 0 then
143
+ table.insert(args, "--port")
144
+ table.insert(args, tostring(port))
145
+ end
146
+
147
+ vim.notify("readit: starting server...", vim.log.levels.INFO)
148
+
149
+ M._server_job_id = vim.fn.jobstart(args, {
150
+ on_stdout = function(_, data)
151
+ for _, line in ipairs(data) do
152
+ -- Parse the URL from server output
153
+ local found_port = line:match("URL:%s*http://[^:]+:(%d+)")
154
+ if found_port then
155
+ M._server_port = tonumber(found_port)
156
+ M._attached_files[file_path] = true
157
+ vim.notify("readit: server ready on port " .. M._server_port, vim.log.levels.INFO)
158
+ if callback then
159
+ vim.schedule(function()
160
+ callback(M._server_port)
161
+ end)
162
+ end
163
+ end
164
+ end
165
+ end,
166
+ on_stderr = function(_, data)
167
+ for _, line in ipairs(data) do
168
+ if line ~= "" then
169
+ vim.notify("readit: " .. line, vim.log.levels.WARN)
170
+ end
171
+ end
172
+ end,
173
+ on_exit = function(_, code)
174
+ M._server_job_id = nil
175
+ M._server_port = nil
176
+ M._attached_files = {}
177
+ if code ~= 0 then
178
+ vim.notify("readit: server exited with code " .. code, vim.log.levels.ERROR)
179
+ end
180
+ end,
181
+ detach = false,
182
+ })
183
+
184
+ if M._server_job_id <= 0 then
185
+ vim.notify("readit: failed to start server", vim.log.levels.ERROR)
186
+ M._server_job_id = nil
187
+ end
188
+ end
189
+
190
+ --- Attach a file to the running server via HTTP API
191
+ --- @param file_path string
192
+ --- @param callback? fun(port: number)
193
+ function M._attach_file(file_path, callback)
194
+ if M._attached_files[file_path] then
195
+ if callback and M._server_port then
196
+ callback(M._server_port)
197
+ end
198
+ return
199
+ end
200
+
201
+ if not M._server_port then
202
+ vim.notify("readit: no server running", vim.log.levels.WARN)
203
+ return
204
+ end
205
+
206
+ local cmd = string.format(
207
+ 'curl -sf -X POST -H "Content-Type: application/json" -d \'{"path":"%s"}\' http://127.0.0.1:%d/api/documents 2>/dev/null',
208
+ file_path:gsub("'", "'\\''"),
209
+ M._server_port
210
+ )
211
+
212
+ vim.fn.jobstart({ "sh", "-c", cmd }, {
213
+ on_exit = function(_, code)
214
+ if code == 0 then
215
+ M._attached_files[file_path] = true
216
+ vim.schedule(function()
217
+ if callback and M._server_port then
218
+ callback(M._server_port)
219
+ end
220
+ end)
221
+ else
222
+ vim.schedule(function()
223
+ vim.notify("readit: failed to attach file", vim.log.levels.ERROR)
224
+ end)
225
+ end
226
+ end,
227
+ })
228
+ end
229
+
230
+ --- Stop the readit server
231
+ function M.stop_server()
232
+ if M._server_job_id then
233
+ vim.fn.jobstop(M._server_job_id)
234
+ M._server_job_id = nil
235
+ M._server_port = nil
236
+ M._attached_files = {}
237
+ vim.notify("readit: server stopped", vim.log.levels.INFO)
238
+ else
239
+ vim.notify("readit: no server running", vim.log.levels.INFO)
240
+ end
241
+ end
242
+
243
+ --- Get server status info
244
+ --- @return string
245
+ function M.server_status()
246
+ if M._server_port then
247
+ local files = vim.tbl_keys(M._attached_files)
248
+ return string.format(
249
+ "readit server running on port %d (%d file%s)",
250
+ M._server_port,
251
+ #files,
252
+ #files == 1 and "" or "s"
253
+ )
254
+ end
255
+ return "readit server not running"
256
+ end
257
+
258
+ -- ── Actions ──────────────────────────────────────────────────────────
259
+
260
+ --- Open the current markdown buffer in readit
261
+ --- @param opts? {browser?: boolean}
262
+ function M.open(opts)
263
+ opts = opts or { browser = true }
264
+ local bufname = vim.api.nvim_buf_get_name(0)
265
+
266
+ if bufname == "" then
267
+ vim.notify("readit: buffer has no file", vim.log.levels.WARN)
268
+ return
269
+ end
270
+
271
+ if not bufname:match("%.md$") and not bufname:match("%.markdown$") then
272
+ vim.notify("readit: not a markdown file", vim.log.levels.WARN)
273
+ return
274
+ end
275
+
276
+ -- Save the buffer first to ensure file watcher picks up latest
277
+ if vim.bo.modified then
278
+ vim.cmd("write")
279
+ end
280
+
281
+ local file_path = vim.fn.fnamemodify(bufname, ":p")
282
+
283
+ M.start_server(file_path, function(port)
284
+ if opts.browser and M.config.auto_open then
285
+ M._open_browser(port)
286
+ end
287
+ end)
288
+ end
289
+
290
+ --- Open the browser to the readit server
291
+ --- @param port number
292
+ function M._open_browser(port)
293
+ local url = "http://" .. M.config.host .. ":" .. port
294
+
295
+ if M.config.open_cmd then
296
+ vim.fn.system(M.config.open_cmd .. " " .. vim.fn.shellescape(url))
297
+ return
298
+ end
299
+
300
+ -- Platform-specific open
301
+ local open_cmd
302
+ if vim.fn.has("mac") == 1 then
303
+ open_cmd = "open"
304
+ elseif vim.fn.has("unix") == 1 then
305
+ open_cmd = "xdg-open"
306
+ elseif vim.fn.has("win32") == 1 then
307
+ open_cmd = "start"
308
+ end
309
+
310
+ if open_cmd then
311
+ vim.fn.jobstart({ open_cmd, url }, { detach = true })
312
+ end
313
+ end
314
+
315
+ --- Force reload the current document in the browser
316
+ function M.reload()
317
+ if not M._server_port then
318
+ vim.notify("readit: no server running", vim.log.levels.WARN)
319
+ return
320
+ end
321
+
322
+ -- Save first so the file watcher triggers reload
323
+ if vim.bo.modified then
324
+ vim.cmd("write")
325
+ end
326
+
327
+ vim.notify("readit: document will reload via file watcher", vim.log.levels.INFO)
328
+ end
329
+
330
+ --- List files with comments (in a floating window)
331
+ function M.list_comments()
332
+ local comments_dir = vim.fn.expand("~/.readit/comments")
333
+ if vim.fn.isdirectory(comments_dir) == 0 then
334
+ vim.notify("readit: no comments found", vim.log.levels.INFO)
335
+ return
336
+ end
337
+
338
+ local cmd = string.format(
339
+ "grep -rh '^source:' %s 2>/dev/null | sed 's/^source: *//' | sort -u",
340
+ vim.fn.shellescape(comments_dir)
341
+ )
342
+ local result = vim.fn.system(cmd)
343
+ local files = vim.split(vim.trim(result), "\n", { trimempty = true })
344
+
345
+ if #files == 0 then
346
+ vim.notify("readit: no comments found", vim.log.levels.INFO)
347
+ return
348
+ end
349
+
350
+ vim.ui.select(files, {
351
+ prompt = "readit: files with comments",
352
+ format_item = function(item)
353
+ return vim.fn.fnamemodify(item, ":~:.")
354
+ end,
355
+ }, function(choice)
356
+ if choice then
357
+ vim.cmd("edit " .. vim.fn.fnameescape(choice))
358
+ M.open()
359
+ end
360
+ end)
361
+ end
362
+
363
+ -- ── Commands ─────────────────────────────────────────────────────────
364
+
365
+ function M._setup_commands()
366
+ vim.api.nvim_create_user_command("ReaditOpen", function()
367
+ M.open()
368
+ end, { desc = "Open current markdown file in readit" })
369
+
370
+ vim.api.nvim_create_user_command("ReaditStop", function()
371
+ M.stop_server()
372
+ end, { desc = "Stop the readit server" })
373
+
374
+ vim.api.nvim_create_user_command("ReaditStatus", function()
375
+ vim.notify(M.server_status(), vim.log.levels.INFO)
376
+ end, { desc = "Show readit server status" })
377
+
378
+ vim.api.nvim_create_user_command("ReaditReload", function()
379
+ M.reload()
380
+ end, { desc = "Reload current document in readit" })
381
+
382
+ vim.api.nvim_create_user_command("ReaditList", function()
383
+ M.list_comments()
384
+ end, { desc = "List files with readit comments" })
385
+
386
+ vim.api.nvim_create_user_command("ReaditOpenFile", function(cmd_opts)
387
+ local file = cmd_opts.args
388
+ if file == "" then
389
+ vim.notify("readit: specify a file path", vim.log.levels.WARN)
390
+ return
391
+ end
392
+ local abs = vim.fn.fnamemodify(file, ":p")
393
+ M.start_server(abs, function(port)
394
+ if M.config.auto_open then
395
+ M._open_browser(port)
396
+ end
397
+ end)
398
+ end, {
399
+ nargs = 1,
400
+ complete = function(_, cmd_line, _)
401
+ -- Complete markdown files
402
+ local files = vim.fn.glob("**/*.md", false, true)
403
+ local markdown_files = vim.fn.glob("**/*.markdown", false, true)
404
+ vim.list_extend(files, markdown_files)
405
+ return files
406
+ end,
407
+ desc = "Open a specific file in readit",
408
+ })
409
+ end
410
+
411
+ -- ── Keymaps ──────────────────────────────────────────────────────────
412
+
413
+ function M._setup_keymaps()
414
+ local prefix = M.config.keymap_prefix
415
+ local maps = M.config.keymaps
416
+
417
+ local function map(suffix, action, desc)
418
+ if suffix == false then
419
+ return
420
+ end
421
+ vim.keymap.set("n", prefix .. suffix, action, {
422
+ desc = "readit: " .. desc,
423
+ silent = true,
424
+ })
425
+ end
426
+
427
+ if maps.open then
428
+ map(maps.open, function() M.open() end, "Open in readit")
429
+ end
430
+ if maps.open_side then
431
+ map(maps.open_side, function() M.open({ browser = true }) end, "Open in browser")
432
+ end
433
+ if maps.stop then
434
+ map(maps.stop, function() M.stop_server() end, "Stop server")
435
+ end
436
+ if maps.status then
437
+ map(maps.status, function() vim.notify(M.server_status()) end, "Server status")
438
+ end
439
+ if maps.reload then
440
+ map(maps.reload, function() M.reload() end, "Reload document")
441
+ end
442
+ if maps.list then
443
+ map(maps.list, function() M.list_comments() end, "List commented files")
444
+ end
445
+ end
446
+
447
+ -- ── Autocommands ─────────────────────────────────────────────────────
448
+
449
+ function M._setup_autocmds()
450
+ local group = vim.api.nvim_create_augroup("readit", { clear = true })
451
+
452
+ -- Clean up server on Neovim exit
453
+ vim.api.nvim_create_autocmd("VimLeavePre", {
454
+ group = group,
455
+ callback = function()
456
+ if M._server_job_id then
457
+ vim.fn.jobstop(M._server_job_id)
458
+ end
459
+ end,
460
+ })
461
+ end
462
+
463
+ return M
@@ -0,0 +1,19 @@
1
+ -- readit.nvim plugin loader
2
+ -- Auto-loaded by Neovim's plugin system
3
+
4
+ if vim.g.loaded_readit then
5
+ return
6
+ end
7
+ vim.g.loaded_readit = true
8
+
9
+ -- The plugin is configured via require("readit").setup({...}).
10
+ -- If the user hasn't called setup() (e.g. lazy.nvim with `ft`-trigger
11
+ -- and no `config`), bootstrap with defaults on the next event-loop
12
+ -- tick so any explicit setup({...}) in user config wins, but commands
13
+ -- and keymaps still register out of the box.
14
+ vim.schedule(function()
15
+ local ok, readit = pcall(require, "readit")
16
+ if ok and not readit._setup_done then
17
+ readit.setup()
18
+ end
19
+ end)