@peaske7/readit 0.3.0-rc.0 → 0.3.0-rc.2
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/README.md +3 -3
- package/dist/.vite/manifest.json +1111 -0
- package/dist/assets/_basePickBy-BMMA4Tou.js +1 -0
- package/dist/assets/_baseUniq-D40qku1I.js +1 -0
- package/dist/assets/arc-Ckg65iy8.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-Dv3EY0zV.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-DQnkwSaB.js +36 -0
- package/dist/assets/array-Bjz-wYpJ.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-UB6_S1lm.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-sn3k2GND.js +10 -0
- package/dist/assets/channel-D9wPw2fQ.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-DaFfaCGO.js +1 -0
- package/dist/assets/chunk-336JU56O-C8siO5Of.js +2 -0
- package/dist/assets/chunk-426QAEUC-BB478m3j.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-DRuTD7x5.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-_l6jvVAY.js +206 -0
- package/dist/assets/chunk-55IACEB6-BExiaAoD.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-HOSFTxuG.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BRVNNRAX.js +2 -0
- package/dist/assets/chunk-67CJDMHE-DMt8LNEX.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-CzLGefVf.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-B6GFAk4U.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BK29yehL.js +1 -0
- package/dist/assets/chunk-CIAEETIT-D7hBXImP.js +1 -0
- package/dist/assets/chunk-Dlc7tRH4.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-CSvKh9DT.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-QApb5cYr.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-2FWyCCAV.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-DKFHrt4K.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-agBjBxsW.js +122 -0
- package/dist/assets/chunk-K5T4RW27-D51O7IkG.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DMHSCH4T.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-C2aANxt9.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-Db_37NRX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-HGUtT2Q9.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-8Lcg9gti.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BFYYQeuC.js +70 -0
- package/dist/assets/chunk-X2U36JSP-p8ehTP6s.js +1 -0
- package/dist/assets/chunk-XPW4576I-Bqbompq4.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-HIMez9pG.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-DRIE_0bu.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-BawhEeUl.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CLNjgH9n.js +1 -0
- package/dist/assets/clone-BBjvuERA.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-q90QeGKv.js +1 -0
- package/dist/assets/cytoscape.esm-BfXff3fb.js +321 -0
- package/dist/assets/dagre-Dxbob2Lr.js +1 -0
- package/dist/assets/dagre-KV5264BT-BuvpNxMw.js +4 -0
- package/dist/assets/defaultLocale-BwmRmqJp.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DQLsxwwt.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Jv9Eefw4.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-D-0YgNhU.js +43 -0
- package/dist/assets/diagram-TYMM5635-BHwO7zQG.js +24 -0
- package/dist/assets/dist-BNz65Ibc.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-BjZGGBJz.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CFbFUm_m.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-CXk4TcBi.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-BGFRt2qs.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-C8yZOxjo.js +106 -0
- package/dist/assets/graphlib-DGcD9J2L.js +1 -0
- package/dist/assets/index-D-m0LiFI.js +14 -0
- package/dist/assets/index-DANHO6J0.css +2 -0
- package/dist/assets/info-OMHHGYJF-DI6-Z9vh.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-p-PXDra2.js +2 -0
- package/dist/assets/init-TPm5RB77.js +1 -0
- package/dist/assets/isArrayLikeObject-69BLnVNM.js +1 -0
- package/dist/assets/isEmpty-DUS28g5f.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-BrIoEvtb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-aZpvKa9g.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-CoOAY9ji.js +89 -0
- package/dist/assets/katex-5SGEXwpi.js +261 -0
- package/dist/assets/line-4MF1lR4d.js +1 -0
- package/dist/assets/linear-CXMqTN8N.js +1 -0
- package/dist/assets/mermaid-config-C8a4L22x.js +1 -0
- package/dist/assets/mermaid-parser.core-DREsY2u4.js +4 -0
- package/dist/assets/mermaid.core-8ysLpTJi.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-CsqUJCMn.js +96 -0
- package/dist/assets/ordinal-D7l-8DAO.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-DidW3JFc.js +1 -0
- package/dist/assets/path-BVpCanzE.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-Bff2e5hg.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-k0Br4NDS.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-Be9oCSza.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CsdZBH3M.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-8ECT7dEs.js +84 -0
- package/dist/assets/rough.esm-BoTisKeL.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-CoKpeJJ0.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-BTT2fFxG.js +157 -0
- package/dist/assets/src-CrmkjRpa.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CIF47NYe.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Cy1rmPfG.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Bes4B58n.js +120 -0
- package/dist/assets/treeView-SZITEDCU-DPKseaET.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-DH-7GZe_.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-3wx2huKk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-AgyXyBN5.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DzViT1Yx.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-BO_dbU0r.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/dist/index.js +2625 -0
- package/package.json +11 -1
- package/.agents/skills/remotion-best-practices/SKILL.md +0 -61
- package/.agents/skills/remotion-best-practices/rules/3d.md +0 -86
- package/.agents/skills/remotion-best-practices/rules/animations.md +0 -27
- package/.agents/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx +0 -178
- package/.agents/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx +0 -100
- package/.agents/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx +0 -108
- package/.agents/skills/remotion-best-practices/rules/assets.md +0 -78
- package/.agents/skills/remotion-best-practices/rules/audio-visualization.md +0 -198
- package/.agents/skills/remotion-best-practices/rules/audio.md +0 -169
- package/.agents/skills/remotion-best-practices/rules/calculate-metadata.md +0 -134
- package/.agents/skills/remotion-best-practices/rules/can-decode.md +0 -75
- package/.agents/skills/remotion-best-practices/rules/charts.md +0 -120
- package/.agents/skills/remotion-best-practices/rules/compositions.md +0 -154
- package/.agents/skills/remotion-best-practices/rules/display-captions.md +0 -184
- package/.agents/skills/remotion-best-practices/rules/extract-frames.md +0 -229
- package/.agents/skills/remotion-best-practices/rules/ffmpeg.md +0 -38
- package/.agents/skills/remotion-best-practices/rules/fonts.md +0 -152
- package/.agents/skills/remotion-best-practices/rules/get-audio-duration.md +0 -58
- package/.agents/skills/remotion-best-practices/rules/get-video-dimensions.md +0 -68
- package/.agents/skills/remotion-best-practices/rules/get-video-duration.md +0 -60
- package/.agents/skills/remotion-best-practices/rules/gifs.md +0 -141
- package/.agents/skills/remotion-best-practices/rules/images.md +0 -134
- package/.agents/skills/remotion-best-practices/rules/import-srt-captions.md +0 -69
- package/.agents/skills/remotion-best-practices/rules/light-leaks.md +0 -73
- package/.agents/skills/remotion-best-practices/rules/lottie.md +0 -70
- package/.agents/skills/remotion-best-practices/rules/maps.md +0 -412
- package/.agents/skills/remotion-best-practices/rules/measuring-dom-nodes.md +0 -34
- package/.agents/skills/remotion-best-practices/rules/measuring-text.md +0 -140
- package/.agents/skills/remotion-best-practices/rules/parameters.md +0 -109
- package/.agents/skills/remotion-best-practices/rules/sequencing.md +0 -118
- package/.agents/skills/remotion-best-practices/rules/sfx.md +0 -26
- package/.agents/skills/remotion-best-practices/rules/subtitles.md +0 -36
- package/.agents/skills/remotion-best-practices/rules/tailwind.md +0 -11
- package/.agents/skills/remotion-best-practices/rules/text-animations.md +0 -20
- package/.agents/skills/remotion-best-practices/rules/timing.md +0 -179
- package/.agents/skills/remotion-best-practices/rules/transcribe-captions.md +0 -70
- package/.agents/skills/remotion-best-practices/rules/transitions.md +0 -197
- package/.agents/skills/remotion-best-practices/rules/transparent-videos.md +0 -106
- package/.agents/skills/remotion-best-practices/rules/trimming.md +0 -51
- package/.agents/skills/remotion-best-practices/rules/videos.md +0 -171
- package/.agents/skills/remotion-best-practices/rules/voiceover.md +0 -99
- package/.agents/skills/simple/SKILL.md +0 -52
- package/.agents/skills/vercel-react-best-practices/AGENTS.md +0 -3254
- package/.agents/skills/vercel-react-best-practices/README.md +0 -123
- package/.agents/skills/vercel-react-best-practices/SKILL.md +0 -141
- package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +0 -55
- package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +0 -42
- package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +0 -39
- package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +0 -38
- package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +0 -80
- package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +0 -51
- package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +0 -28
- package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +0 -99
- package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +0 -59
- package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +0 -31
- package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +0 -49
- package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +0 -35
- package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +0 -50
- package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +0 -74
- package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +0 -71
- package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +0 -48
- package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +0 -56
- package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +0 -107
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +0 -80
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +0 -28
- package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +0 -70
- package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +0 -32
- package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +0 -50
- package/.agents/skills/vercel-react-best-practices/rules/js-flatmap-filter.md +0 -60
- package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +0 -45
- package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +0 -37
- package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +0 -49
- package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +0 -82
- package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +0 -24
- package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +0 -57
- package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +0 -26
- package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +0 -47
- package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +0 -40
- package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +0 -38
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +0 -46
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +0 -82
- package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +0 -30
- package/.agents/skills/vercel-react-best-practices/rules/rendering-resource-hints.md +0 -85
- package/.agents/skills/vercel-react-best-practices/rules/rendering-script-defer-async.md +0 -68
- package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +0 -28
- package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +0 -75
- package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +0 -39
- package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +0 -45
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +0 -40
- package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +0 -29
- package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +0 -74
- package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +0 -58
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +0 -38
- package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +0 -44
- package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +0 -45
- package/.agents/skills/vercel-react-best-practices/rules/rerender-no-inline-components.md +0 -82
- package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +0 -35
- package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +0 -40
- package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +0 -73
- package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +0 -73
- package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +0 -96
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +0 -41
- package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +0 -76
- package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +0 -65
- package/.agents/skills/vercel-react-best-practices/rules/server-hoist-static-io.md +0 -142
- package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +0 -83
- package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +0 -38
- package/.claude/CLAUDE.md +0 -184
- package/.claude/commands/review.md +0 -120
- package/.claude/commands/sync-docs.md +0 -71
- package/.claude/roadmap.md +0 -121
- package/.claude/rules/style-guide.md +0 -830
- package/.claude/settings.json +0 -18
- package/.claude/user-stories.md +0 -333
- package/AGENTS.md +0 -68
- package/Makefile +0 -32
- package/biome.json +0 -79
- package/bun.lock +0 -854
- package/bunfig.toml +0 -2
- package/docs/design.md +0 -563
- package/docs/perf-baseline.md +0 -130
- package/docs/plans/2026-03-13-client-mode-design.md +0 -86
- package/docs/plans/2026-03-13-client-mode-plan.md +0 -605
- package/docs/plans/2026-03-13-keyboard-shortcuts-design.md +0 -129
- package/docs/plans/2026-03-13-keyboard-shortcuts-plan.md +0 -1471
- package/docs/plans/2026-03-13-multi-document-design.md +0 -183
- package/docs/plans/2026-03-13-performance-benchmarks-design.md +0 -121
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +0 -1176
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +0 -284
- package/e2e/comments.spec.ts +0 -81
- package/e2e/document-load.spec.ts +0 -32
- package/e2e/export.spec.ts +0 -58
- package/e2e/fixtures/sample.md +0 -7
- package/e2e/perf/add-comment.spec.ts +0 -116
- package/e2e/perf/fixtures/generate.ts +0 -327
- package/e2e/perf/initial-load.spec.ts +0 -49
- package/e2e/perf/perf.setup.ts +0 -23
- package/e2e/perf/perf.teardown.ts +0 -9
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/scroll.spec.ts +0 -39
- package/e2e/perf/tab-switch.spec.ts +0 -69
- package/e2e/perf/text-selection.spec.ts +0 -119
- package/e2e/perf/utils/metrics.ts +0 -350
- package/e2e/perf/utils/perf-cli.ts +0 -86
- package/e2e/persistence-file.spec.ts +0 -357
- package/e2e/utils/cli.ts +0 -84
- package/e2e/utils/selection.ts +0 -79
- package/go/cmd/readit/main.go +0 -416
- package/go/go.mod +0 -20
- package/go/go.sum +0 -41
- package/go/internal/server/anchor.go +0 -302
- package/go/internal/server/anchor_test.go +0 -111
- package/go/internal/server/comments.go +0 -390
- package/go/internal/server/documents.go +0 -113
- package/go/internal/server/embed.go +0 -17
- package/go/internal/server/headings.go +0 -33
- package/go/internal/server/headings_test.go +0 -75
- package/go/internal/server/htmltext.go +0 -123
- package/go/internal/server/markdown.go +0 -157
- package/go/internal/server/markdown_bench_test.go +0 -42
- package/go/internal/server/markdown_test.go +0 -79
- package/go/internal/server/server.go +0 -453
- package/go/internal/server/server_bench_test.go +0 -122
- package/go/internal/server/settings.go +0 -110
- package/go/internal/server/sse.go +0 -140
- package/go/internal/server/storage.go +0 -275
- package/go/internal/server/storage_test.go +0 -152
- package/go/internal/server/template.go +0 -66
- package/go/internal/server/types.go +0 -101
- package/go/internal/server/watcher.go +0 -74
- package/lefthook.yml +0 -8
- package/nvim-readit/lua/readit/health.lua +0 -64
- package/nvim-readit/lua/readit/init.lua +0 -463
- package/nvim-readit/plugin/readit.lua +0 -19
- package/playwright.config.ts +0 -34
- package/skills-lock.json +0 -20
- package/src/App.svelte +0 -890
- package/src/cli.ts +0 -881
- package/src/components/ActionsMenu.svelte +0 -95
- package/src/components/CommentBadge.svelte +0 -67
- package/src/components/CommentErrorBanner.svelte +0 -33
- package/src/components/CommentInput.svelte +0 -75
- package/src/components/CommentListItem.svelte +0 -95
- package/src/components/CommentManager.svelte +0 -129
- package/src/components/CommentNav.svelte +0 -109
- package/src/components/DocumentViewer.svelte +0 -233
- package/src/components/FloatingComment.svelte +0 -107
- package/src/components/Header.svelte +0 -76
- package/src/components/InlineEditor.svelte +0 -72
- package/src/components/MarginNote.svelte +0 -167
- package/src/components/MarginNotesContainer.svelte +0 -33
- package/src/components/MermaidEnhancer.svelte +0 -218
- package/src/components/MermaidModal.svelte +0 -67
- package/src/components/RawModal.svelte +0 -126
- package/src/components/ReanchorConfirm.svelte +0 -30
- package/src/components/SettingsModal.svelte +0 -220
- package/src/components/ShortcutCapture.svelte +0 -82
- package/src/components/ShortcutList.svelte +0 -145
- package/src/components/TabBar.svelte +0 -52
- package/src/components/TableOfContents.svelte +0 -125
- package/src/components/ui/ActionLink.svelte +0 -40
- package/src/components/ui/Button.svelte +0 -53
- package/src/components/ui/Dialog.svelte +0 -97
- package/src/components/ui/DropdownMenu.svelte +0 -85
- package/src/components/ui/DropdownMenuItem.svelte +0 -38
- package/src/components/ui/DropdownMenuSeparator.svelte +0 -11
- package/src/components/ui/Text.svelte +0 -42
- package/src/env.d.ts +0 -6
- package/src/index.css +0 -859
- package/src/lib/__fixtures__/bench-data.ts +0 -114
- package/src/lib/anchor.bench.ts +0 -91
- package/src/lib/anchor.test.ts +0 -527
- package/src/lib/anchor.ts +0 -381
- package/src/lib/comment-storage.bench.ts +0 -49
- package/src/lib/comment-storage.test.ts +0 -694
- package/src/lib/comment-storage.ts +0 -226
- package/src/lib/export.bench.ts +0 -21
- package/src/lib/export.ts +0 -36
- package/src/lib/fetch-or-throw.test.ts +0 -59
- package/src/lib/fetch-or-throw.ts +0 -12
- package/src/lib/headings.test.ts +0 -103
- package/src/lib/headings.ts +0 -44
- package/src/lib/highlight/core.test.ts +0 -93
- package/src/lib/highlight/dom.ts +0 -187
- package/src/lib/highlight/highlight-registry.ts +0 -221
- package/src/lib/highlight/highlight.bench.ts +0 -92
- package/src/lib/highlight/highlighter.ts +0 -247
- package/src/lib/highlight/resolver.ts +0 -38
- package/src/lib/highlight/types.ts +0 -17
- package/src/lib/html-text.test.ts +0 -162
- package/src/lib/html-text.ts +0 -161
- package/src/lib/i18n/en.ts +0 -124
- package/src/lib/i18n/index.ts +0 -3
- package/src/lib/i18n/ja.ts +0 -126
- package/src/lib/i18n/translations.ts +0 -27
- package/src/lib/i18n/types.ts +0 -130
- package/src/lib/key-lock.test.ts +0 -104
- package/src/lib/key-lock.ts +0 -23
- package/src/lib/margin-layout.bench.ts +0 -61
- package/src/lib/margin-layout.ts +0 -71
- package/src/lib/markdown-renderer.test.ts +0 -154
- package/src/lib/markdown-renderer.ts +0 -178
- package/src/lib/mermaid-config.ts +0 -38
- package/src/lib/mermaid-renderer.ts +0 -162
- package/src/lib/mermaid-worker.ts +0 -60
- package/src/lib/positions.ts +0 -157
- package/src/lib/shortcut-registry.ts +0 -244
- package/src/lib/utils.ts +0 -15
- package/src/main.ts +0 -16
- package/src/schema.ts +0 -92
- package/src/server.ts +0 -1216
- package/src/stores/app.svelte.ts +0 -231
- package/src/stores/locale.svelte.ts +0 -46
- package/src/stores/settings.svelte.ts +0 -90
- package/src/stores/shortcuts.svelte.ts +0 -104
- package/src/stores/ui.svelte.ts +0 -12
- package/src/template.ts +0 -104
- package/src/test-setup.ts +0 -48
- package/svelte.config.js +0 -5
- package/test.md +0 -74
- package/tsconfig.cli.json +0 -12
- package/tsconfig.json +0 -20
- package/vite.config.ts +0 -47
- package/vitest.config.ts +0 -15
- package/vscode-readit/.mcp.json +0 -7
- package/vscode-readit/.vscodeignore +0 -7
- package/vscode-readit/bun.lock +0 -78
- package/vscode-readit/icon.svg +0 -10
- package/vscode-readit/package.json +0 -110
- package/vscode-readit/src/extension.ts +0 -117
- package/vscode-readit/src/server-manager.ts +0 -272
- package/vscode-readit/src/webview-provider.ts +0 -204
- package/vscode-readit/tsconfig.json +0 -20
package/dist/index.js
ADDED
|
@@ -0,0 +1,2625 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __require = import.meta.require;
|
|
4
|
+
|
|
5
|
+
// src/cli.ts
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
statSync
|
|
13
|
+
} from "fs";
|
|
14
|
+
import * as fs2 from "fs/promises";
|
|
15
|
+
import * as os3 from "os";
|
|
16
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
17
|
+
import { Command } from "commander";
|
|
18
|
+
import open2 from "open";
|
|
19
|
+
|
|
20
|
+
// src/lib/comment-storage.ts
|
|
21
|
+
import * as crypto from "crypto";
|
|
22
|
+
import * as os from "os";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
var FORMAT_VERSION = 1;
|
|
25
|
+
var HASH_LENGTH = 16;
|
|
26
|
+
var MAX_SELECTION_LENGTH = 1000;
|
|
27
|
+
var TRUNCATION_MARKER = `
|
|
28
|
+
...
|
|
29
|
+
`;
|
|
30
|
+
var ANCHOR_PREFIX_LENGTH = 200;
|
|
31
|
+
function truncateSelection(text) {
|
|
32
|
+
if (text.length <= MAX_SELECTION_LENGTH) {
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
const half = Math.floor((MAX_SELECTION_LENGTH - TRUNCATION_MARKER.length) / 2);
|
|
36
|
+
return text.slice(0, half) + TRUNCATION_MARKER + text.slice(-half);
|
|
37
|
+
}
|
|
38
|
+
function getCommentPath(sourcePath) {
|
|
39
|
+
const absolute = path.resolve(sourcePath);
|
|
40
|
+
const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
|
|
41
|
+
const ext = path.extname(normalized);
|
|
42
|
+
const withoutExt = normalized.slice(0, -ext.length || undefined);
|
|
43
|
+
return path.join(os.homedir(), ".readit", "comments", `${withoutExt}.comments.md`);
|
|
44
|
+
}
|
|
45
|
+
function computeHash(content) {
|
|
46
|
+
return crypto.createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
|
|
47
|
+
}
|
|
48
|
+
function getLineNumber(content, offset) {
|
|
49
|
+
if (offset <= 0 || content.length === 0)
|
|
50
|
+
return 1;
|
|
51
|
+
const clampedOffset = Math.min(offset, content.length);
|
|
52
|
+
return content.slice(0, clampedOffset).split(`
|
|
53
|
+
`).length;
|
|
54
|
+
}
|
|
55
|
+
function getLineHint(content, startOffset, endOffset) {
|
|
56
|
+
const startLine = getLineNumber(content, startOffset);
|
|
57
|
+
const endLine = getLineNumber(content, endOffset);
|
|
58
|
+
return startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
|
|
59
|
+
}
|
|
60
|
+
function parseCommentFile(content) {
|
|
61
|
+
const result = {
|
|
62
|
+
source: "",
|
|
63
|
+
hash: "",
|
|
64
|
+
version: FORMAT_VERSION,
|
|
65
|
+
comments: []
|
|
66
|
+
};
|
|
67
|
+
if (!content.trim()) {
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
71
|
+
if (frontMatterMatch) {
|
|
72
|
+
const frontMatter = frontMatterMatch[1];
|
|
73
|
+
const sourceMatch = frontMatter.match(/^source:\s*(.+)$/m);
|
|
74
|
+
const hashMatch = frontMatter.match(/^hash:\s*(.+)$/m);
|
|
75
|
+
const versionMatch = frontMatter.match(/^version:\s*(\d+)$/m);
|
|
76
|
+
if (sourceMatch)
|
|
77
|
+
result.source = sourceMatch[1].trim();
|
|
78
|
+
if (hashMatch)
|
|
79
|
+
result.hash = hashMatch[1].trim();
|
|
80
|
+
if (versionMatch)
|
|
81
|
+
result.version = Number.parseInt(versionMatch[1], 10);
|
|
82
|
+
if (result.version > FORMAT_VERSION) {
|
|
83
|
+
throw new Error(`Comment file requires readit v${result.version} or higher. ` + `Current version supports format v${FORMAT_VERSION}.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
87
|
+
const markerRe = /<!--\s*c:[^|]+\|[^|>\s]+(?:\|[^>]*)?\s*-->/g;
|
|
88
|
+
const markerStarts = [];
|
|
89
|
+
for (const m of bodyContent.matchAll(markerRe)) {
|
|
90
|
+
if (m.index !== undefined)
|
|
91
|
+
markerStarts.push(m.index);
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0;i < markerStarts.length; i++) {
|
|
94
|
+
const start = markerStarts[i];
|
|
95
|
+
const end = i + 1 < markerStarts.length ? markerStarts[i + 1] : bodyContent.length;
|
|
96
|
+
const block = bodyContent.slice(start, end).replace(/\n+---\s*$/, "").trim();
|
|
97
|
+
if (!block)
|
|
98
|
+
continue;
|
|
99
|
+
const comment = parseCommentBlock(block);
|
|
100
|
+
if (comment) {
|
|
101
|
+
result.comments.push(comment);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
function parseCommentBlock(block) {
|
|
107
|
+
const metadataMatch = block.match(/<!--\s*c:([^|]+)\|([^|>\s]+)(?:\|([^>]*))?\s*-->/);
|
|
108
|
+
if (!metadataMatch) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const [, id, lineHint, createdAtRaw] = metadataMatch;
|
|
112
|
+
const createdAt = createdAtRaw?.trim() || undefined;
|
|
113
|
+
const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
|
|
114
|
+
const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
|
|
115
|
+
const blockquoteMatch = block.match(/^>\s*(.+(?:\n>\s*.+)*)$/m);
|
|
116
|
+
if (!blockquoteMatch) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const selectedText = blockquoteMatch[1].split(`
|
|
120
|
+
`).map((line) => line.replace(/^>\s*/, "")).join(`
|
|
121
|
+
`);
|
|
122
|
+
const afterBlockquote = block.slice(block.indexOf(blockquoteMatch[0]) + blockquoteMatch[0].length);
|
|
123
|
+
const commentBody = afterBlockquote.trim();
|
|
124
|
+
return {
|
|
125
|
+
id,
|
|
126
|
+
selectedText,
|
|
127
|
+
comment: commentBody,
|
|
128
|
+
lineHint,
|
|
129
|
+
createdAt,
|
|
130
|
+
anchorPrefix,
|
|
131
|
+
startOffset: 0,
|
|
132
|
+
endOffset: 0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function serializeComments(file) {
|
|
136
|
+
const lines = [];
|
|
137
|
+
lines.push("---");
|
|
138
|
+
lines.push(`source: ${file.source}`);
|
|
139
|
+
lines.push(`hash: ${file.hash}`);
|
|
140
|
+
lines.push(`version: ${file.version}`);
|
|
141
|
+
lines.push("---");
|
|
142
|
+
lines.push("");
|
|
143
|
+
for (const comment of file.comments) {
|
|
144
|
+
lines.push(serializeComment(comment));
|
|
145
|
+
lines.push("");
|
|
146
|
+
lines.push("---");
|
|
147
|
+
lines.push("");
|
|
148
|
+
}
|
|
149
|
+
return lines.join(`
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
function serializeComment(comment) {
|
|
153
|
+
const lines = [];
|
|
154
|
+
const lineHint = comment.lineHint || "L0";
|
|
155
|
+
const createdAt = comment.createdAt || new Date().toISOString();
|
|
156
|
+
lines.push(`<!-- c:${comment.id}|${lineHint}|${createdAt} -->`);
|
|
157
|
+
if (comment.anchorPrefix) {
|
|
158
|
+
lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
|
|
159
|
+
}
|
|
160
|
+
const quotedLines = comment.selectedText.split(`
|
|
161
|
+
`).map((line) => `> ${line}`);
|
|
162
|
+
lines.push(...quotedLines);
|
|
163
|
+
if (comment.comment) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push(comment.comment);
|
|
166
|
+
}
|
|
167
|
+
return lines.join(`
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
170
|
+
function createComment(selectedText, commentText, startOffset, endOffset, sourceContent) {
|
|
171
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
172
|
+
const lineHint = getLineHint(sourceContent, startOffset, endOffset);
|
|
173
|
+
const needsTruncation = selectedText.length > MAX_SELECTION_LENGTH;
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
selectedText: truncateSelection(selectedText),
|
|
177
|
+
comment: commentText,
|
|
178
|
+
startOffset,
|
|
179
|
+
endOffset,
|
|
180
|
+
lineHint,
|
|
181
|
+
createdAt: new Date().toISOString(),
|
|
182
|
+
anchorPrefix: needsTruncation ? selectedText.slice(0, ANCHOR_PREFIX_LENGTH) : undefined
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/lib/utils.ts
|
|
187
|
+
import { clsx } from "clsx";
|
|
188
|
+
import { twMerge } from "tailwind-merge";
|
|
189
|
+
function isMarkdownFile(filePath) {
|
|
190
|
+
return filePath.endsWith(".md") || filePath.endsWith(".markdown");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/server.ts
|
|
194
|
+
import { watch } from "fs";
|
|
195
|
+
import * as fs from "fs/promises";
|
|
196
|
+
import * as os2 from "os";
|
|
197
|
+
import * as path2 from "path";
|
|
198
|
+
import { basename, dirname as dirname2, join as join3 } from "path";
|
|
199
|
+
|
|
200
|
+
// src/schema.ts
|
|
201
|
+
var AnchorConfidences = {
|
|
202
|
+
EXACT: "exact",
|
|
203
|
+
NORMALIZED: "normalized",
|
|
204
|
+
FUZZY: "fuzzy",
|
|
205
|
+
UNRESOLVED: "unresolved"
|
|
206
|
+
};
|
|
207
|
+
var FontFamilies = {
|
|
208
|
+
SERIF: "serif",
|
|
209
|
+
SANS_SERIF: "sans-serif"
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/lib/anchor.ts
|
|
213
|
+
var DEFAULT_SEARCH_WINDOW = 500;
|
|
214
|
+
var DEFAULT_FUZZY_THRESHOLD = 5;
|
|
215
|
+
var MAX_FUZZY_TEXT_LENGTH = 200;
|
|
216
|
+
var FUZZY_SEARCH_WINDOW = 2000;
|
|
217
|
+
function normalizeWhitespace(text) {
|
|
218
|
+
return text.replace(/\s+/g, " ").trim();
|
|
219
|
+
}
|
|
220
|
+
function levenshteinDistance(a, b, maxDistance) {
|
|
221
|
+
if (a.length > b.length) {
|
|
222
|
+
[a, b] = [b, a];
|
|
223
|
+
}
|
|
224
|
+
const m = a.length;
|
|
225
|
+
const n = b.length;
|
|
226
|
+
if (m === 0)
|
|
227
|
+
return n;
|
|
228
|
+
if (n === 0)
|
|
229
|
+
return m;
|
|
230
|
+
if (maxDistance !== undefined && Math.abs(m - n) > maxDistance) {
|
|
231
|
+
return Number.POSITIVE_INFINITY;
|
|
232
|
+
}
|
|
233
|
+
let prevRow = new Array(m + 1);
|
|
234
|
+
let currRow = new Array(m + 1);
|
|
235
|
+
for (let i = 0;i <= m; i++) {
|
|
236
|
+
prevRow[i] = i;
|
|
237
|
+
}
|
|
238
|
+
for (let j = 1;j <= n; j++) {
|
|
239
|
+
currRow[0] = j;
|
|
240
|
+
let rowMin = currRow[0];
|
|
241
|
+
for (let i = 1;i <= m; i++) {
|
|
242
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
243
|
+
currRow[i] = Math.min(prevRow[i] + 1, currRow[i - 1] + 1, prevRow[i - 1] + cost);
|
|
244
|
+
rowMin = Math.min(rowMin, currRow[i]);
|
|
245
|
+
}
|
|
246
|
+
if (maxDistance !== undefined && rowMin > maxDistance) {
|
|
247
|
+
return Number.POSITIVE_INFINITY;
|
|
248
|
+
}
|
|
249
|
+
[prevRow, currRow] = [currRow, prevRow];
|
|
250
|
+
}
|
|
251
|
+
return prevRow[m];
|
|
252
|
+
}
|
|
253
|
+
function getLineOffset(content, lineNumber) {
|
|
254
|
+
if (lineNumber <= 1)
|
|
255
|
+
return 0;
|
|
256
|
+
let currentLine = 1;
|
|
257
|
+
for (let i = 0;i < content.length; i++) {
|
|
258
|
+
if (content[i] === `
|
|
259
|
+
`) {
|
|
260
|
+
currentLine++;
|
|
261
|
+
if (currentLine === lineNumber) {
|
|
262
|
+
return i + 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return content.length;
|
|
267
|
+
}
|
|
268
|
+
function parseLineHint(lineHint) {
|
|
269
|
+
const match = lineHint.match(/^L(\d+)(?:-L?(\d+))?$/);
|
|
270
|
+
if (!match) {
|
|
271
|
+
return { start: 1, end: 1 };
|
|
272
|
+
}
|
|
273
|
+
const start = Number.parseInt(match[1], 10);
|
|
274
|
+
const end = match[2] ? Number.parseInt(match[2], 10) : start;
|
|
275
|
+
return { start, end };
|
|
276
|
+
}
|
|
277
|
+
function findAnchor({
|
|
278
|
+
source,
|
|
279
|
+
selectedText,
|
|
280
|
+
lineHint,
|
|
281
|
+
searchWindow = DEFAULT_SEARCH_WINDOW
|
|
282
|
+
}) {
|
|
283
|
+
if (!selectedText || !source) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const { start: hintLine } = parseLineHint(lineHint);
|
|
287
|
+
const lineOffset = getLineOffset(source, hintLine);
|
|
288
|
+
const windowStart = Math.max(0, lineOffset - searchWindow);
|
|
289
|
+
const windowEnd = Math.min(source.length, lineOffset + searchWindow);
|
|
290
|
+
const window = source.slice(windowStart, windowEnd);
|
|
291
|
+
const localIndex = window.indexOf(selectedText);
|
|
292
|
+
if (localIndex !== -1) {
|
|
293
|
+
const start = windowStart + localIndex;
|
|
294
|
+
const end = start + selectedText.length;
|
|
295
|
+
return {
|
|
296
|
+
start,
|
|
297
|
+
end,
|
|
298
|
+
line: getLineNumber(source, start),
|
|
299
|
+
confidence: AnchorConfidences.EXACT
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const globalIndex = source.indexOf(selectedText);
|
|
303
|
+
if (globalIndex !== -1) {
|
|
304
|
+
return {
|
|
305
|
+
start: globalIndex,
|
|
306
|
+
end: globalIndex + selectedText.length,
|
|
307
|
+
line: getLineNumber(source, globalIndex),
|
|
308
|
+
confidence: AnchorConfidences.EXACT
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
function buildNormalizedPositionMap(text) {
|
|
314
|
+
const toOriginal = [];
|
|
315
|
+
let normalized = "";
|
|
316
|
+
let inWhitespace = false;
|
|
317
|
+
for (let i = 0;i < text.length; i++) {
|
|
318
|
+
const char = text[i];
|
|
319
|
+
const isSpace = /\s/.test(char);
|
|
320
|
+
if (isSpace) {
|
|
321
|
+
if (!inWhitespace && normalized.length > 0) {
|
|
322
|
+
normalized += " ";
|
|
323
|
+
toOriginal.push(i);
|
|
324
|
+
}
|
|
325
|
+
inWhitespace = true;
|
|
326
|
+
} else {
|
|
327
|
+
normalized += char;
|
|
328
|
+
toOriginal.push(i);
|
|
329
|
+
inWhitespace = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (normalized.endsWith(" ")) {
|
|
333
|
+
normalized = normalized.slice(0, -1);
|
|
334
|
+
toOriginal.pop();
|
|
335
|
+
}
|
|
336
|
+
return { normalized, toOriginal };
|
|
337
|
+
}
|
|
338
|
+
function findAnchorNormalized({
|
|
339
|
+
source,
|
|
340
|
+
selectedText,
|
|
341
|
+
lineHint,
|
|
342
|
+
searchWindow = FUZZY_SEARCH_WINDOW
|
|
343
|
+
}) {
|
|
344
|
+
if (!selectedText || !source) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const normalizedText = normalizeWhitespace(selectedText);
|
|
348
|
+
if (!normalizedText) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (normalizedText === selectedText) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const { start: hintLine } = parseLineHint(lineHint);
|
|
355
|
+
const lineOffset = getLineOffset(source, hintLine);
|
|
356
|
+
const windowStart = Math.max(0, lineOffset - searchWindow);
|
|
357
|
+
const windowEnd = Math.min(source.length, lineOffset + searchWindow);
|
|
358
|
+
const window = source.slice(windowStart, windowEnd);
|
|
359
|
+
const { normalized: normalizedWindow, toOriginal } = buildNormalizedPositionMap(window);
|
|
360
|
+
const normalizedIndex = normalizedWindow.indexOf(normalizedText);
|
|
361
|
+
if (normalizedIndex !== -1) {
|
|
362
|
+
const originalStart = windowStart + toOriginal[normalizedIndex];
|
|
363
|
+
const endNormIndex = normalizedIndex + normalizedText.length - 1;
|
|
364
|
+
let originalEnd = windowStart + toOriginal[endNormIndex] + 1;
|
|
365
|
+
while (originalEnd < source.length && /\s/.test(source[originalEnd])) {
|
|
366
|
+
originalEnd++;
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
start: originalStart,
|
|
370
|
+
end: originalEnd,
|
|
371
|
+
line: getLineNumber(source, originalStart),
|
|
372
|
+
confidence: AnchorConfidences.NORMALIZED
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const { normalized: fullNormalized, toOriginal: fullToOriginal } = buildNormalizedPositionMap(source);
|
|
376
|
+
const globalIndex = fullNormalized.indexOf(normalizedText);
|
|
377
|
+
if (globalIndex !== -1) {
|
|
378
|
+
const originalStart = fullToOriginal[globalIndex];
|
|
379
|
+
const endNormIndex = globalIndex + normalizedText.length - 1;
|
|
380
|
+
let originalEnd = fullToOriginal[endNormIndex] + 1;
|
|
381
|
+
while (originalEnd < source.length && /\s/.test(source[originalEnd])) {
|
|
382
|
+
originalEnd++;
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
start: originalStart,
|
|
386
|
+
end: originalEnd,
|
|
387
|
+
line: getLineNumber(source, originalStart),
|
|
388
|
+
confidence: AnchorConfidences.NORMALIZED
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
function findAnchorFuzzy({
|
|
394
|
+
source,
|
|
395
|
+
selectedText,
|
|
396
|
+
lineHint,
|
|
397
|
+
threshold = DEFAULT_FUZZY_THRESHOLD
|
|
398
|
+
}) {
|
|
399
|
+
if (!selectedText || !source) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const textLen = selectedText.length;
|
|
403
|
+
if (textLen > MAX_FUZZY_TEXT_LENGTH) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
let bestMatch;
|
|
407
|
+
let bestDistance = threshold + 1;
|
|
408
|
+
let searchStart = 0;
|
|
409
|
+
let searchEnd = source.length;
|
|
410
|
+
if (lineHint) {
|
|
411
|
+
const { start: hintLine } = parseLineHint(lineHint);
|
|
412
|
+
const lineOffset = getLineOffset(source, hintLine);
|
|
413
|
+
searchStart = Math.max(0, lineOffset - FUZZY_SEARCH_WINDOW);
|
|
414
|
+
searchEnd = Math.min(source.length, lineOffset + FUZZY_SEARCH_WINDOW);
|
|
415
|
+
}
|
|
416
|
+
const minLen = Math.max(1, textLen - threshold);
|
|
417
|
+
const maxLen = textLen + threshold;
|
|
418
|
+
for (let len = minLen;len <= maxLen; len++) {
|
|
419
|
+
for (let i = searchStart;i <= searchEnd - len; i++) {
|
|
420
|
+
const candidate = source.slice(i, i + len);
|
|
421
|
+
const distance = levenshteinDistance(selectedText, candidate, bestDistance - 1);
|
|
422
|
+
if (distance < bestDistance) {
|
|
423
|
+
bestDistance = distance;
|
|
424
|
+
bestMatch = {
|
|
425
|
+
start: i,
|
|
426
|
+
end: i + len,
|
|
427
|
+
line: getLineNumber(source, i),
|
|
428
|
+
confidence: AnchorConfidences.FUZZY,
|
|
429
|
+
distance
|
|
430
|
+
};
|
|
431
|
+
if (distance === 0) {
|
|
432
|
+
return bestMatch;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return bestMatch;
|
|
438
|
+
}
|
|
439
|
+
function findAnchorWithFallback({
|
|
440
|
+
source,
|
|
441
|
+
selectedText,
|
|
442
|
+
lineHint,
|
|
443
|
+
fuzzyThreshold
|
|
444
|
+
}) {
|
|
445
|
+
const exactMatch = findAnchor({ source, selectedText, lineHint });
|
|
446
|
+
if (exactMatch) {
|
|
447
|
+
return exactMatch;
|
|
448
|
+
}
|
|
449
|
+
const normalizedMatch = findAnchorNormalized({
|
|
450
|
+
source,
|
|
451
|
+
selectedText,
|
|
452
|
+
lineHint
|
|
453
|
+
});
|
|
454
|
+
if (normalizedMatch) {
|
|
455
|
+
return normalizedMatch;
|
|
456
|
+
}
|
|
457
|
+
return findAnchorFuzzy({
|
|
458
|
+
source,
|
|
459
|
+
selectedText,
|
|
460
|
+
lineHint,
|
|
461
|
+
threshold: fuzzyThreshold
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/lib/highlight/resolver.ts
|
|
466
|
+
function findTextPosition(textContent, selectedText, hintOffset) {
|
|
467
|
+
if (!selectedText || !textContent)
|
|
468
|
+
return;
|
|
469
|
+
const occurrences = [];
|
|
470
|
+
let idx = 0;
|
|
471
|
+
for (;; ) {
|
|
472
|
+
idx = textContent.indexOf(selectedText, idx);
|
|
473
|
+
if (idx === -1)
|
|
474
|
+
break;
|
|
475
|
+
occurrences.push(idx);
|
|
476
|
+
idx += 1;
|
|
477
|
+
}
|
|
478
|
+
if (occurrences.length === 0)
|
|
479
|
+
return;
|
|
480
|
+
if (occurrences.length === 1) {
|
|
481
|
+
return {
|
|
482
|
+
start: occurrences[0],
|
|
483
|
+
end: occurrences[0] + selectedText.length
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const target = hintOffset ?? 0;
|
|
487
|
+
let closest = occurrences[0];
|
|
488
|
+
let minDist = Math.abs(closest - target);
|
|
489
|
+
for (const occ of occurrences) {
|
|
490
|
+
const dist = Math.abs(occ - target);
|
|
491
|
+
if (dist < minDist) {
|
|
492
|
+
minDist = dist;
|
|
493
|
+
closest = occ;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return { start: closest, end: closest + selectedText.length };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/lib/html-text.ts
|
|
500
|
+
var BLOCK_ELEMENTS = new Set([
|
|
501
|
+
"p",
|
|
502
|
+
"div",
|
|
503
|
+
"h1",
|
|
504
|
+
"h2",
|
|
505
|
+
"h3",
|
|
506
|
+
"h4",
|
|
507
|
+
"h5",
|
|
508
|
+
"h6",
|
|
509
|
+
"pre",
|
|
510
|
+
"blockquote",
|
|
511
|
+
"li",
|
|
512
|
+
"tr",
|
|
513
|
+
"br"
|
|
514
|
+
]);
|
|
515
|
+
function extractTextFromHtml(html) {
|
|
516
|
+
const textNodes = collectTextNodesFromHtml(html);
|
|
517
|
+
if (textNodes.length === 0)
|
|
518
|
+
return "";
|
|
519
|
+
let result = "";
|
|
520
|
+
let lastBlockPath = null;
|
|
521
|
+
for (const node of textNodes) {
|
|
522
|
+
if (lastBlockPath) {
|
|
523
|
+
const lastBlock = lastBlockPath;
|
|
524
|
+
const currBlock = node.blockAncestorPath;
|
|
525
|
+
if (lastBlock.length > 0 && currBlock.length > 0 && !isNested(lastBlock, currBlock)) {
|
|
526
|
+
if (lastBlock[lastBlock.length - 1] !== currBlock[currBlock.length - 1]) {
|
|
527
|
+
result += `
|
|
528
|
+
`;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
result += node.text;
|
|
533
|
+
lastBlockPath = node.blockAncestorPath;
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
function isNested(pathA, pathB) {
|
|
538
|
+
const blockA = pathA[pathA.length - 1];
|
|
539
|
+
const blockB = pathB[pathB.length - 1];
|
|
540
|
+
if (pathB.includes(blockA))
|
|
541
|
+
return true;
|
|
542
|
+
if (pathA.includes(blockB))
|
|
543
|
+
return true;
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
function collectTextNodesFromHtml(html) {
|
|
547
|
+
const nodes = [];
|
|
548
|
+
const stack = [];
|
|
549
|
+
let idCounter = 0;
|
|
550
|
+
const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g;
|
|
551
|
+
let lastIndex = 0;
|
|
552
|
+
let match;
|
|
553
|
+
match = tagPattern.exec(html);
|
|
554
|
+
while (match !== null) {
|
|
555
|
+
if (match.index > lastIndex) {
|
|
556
|
+
const text = decodeEntities(html.slice(lastIndex, match.index));
|
|
557
|
+
if (text) {
|
|
558
|
+
nodes.push({
|
|
559
|
+
text,
|
|
560
|
+
blockAncestorPath: getBlockAncestorPath(stack)
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const fullTag = match[0];
|
|
565
|
+
const tagName = match[1].toLowerCase();
|
|
566
|
+
const isClosing = fullTag.startsWith("</");
|
|
567
|
+
const isSelfClosing = fullTag.endsWith("/>") || isVoidElement(tagName);
|
|
568
|
+
if (isClosing) {
|
|
569
|
+
for (let i = stack.length - 1;i >= 0; i--) {
|
|
570
|
+
if (stack[i].tag === tagName) {
|
|
571
|
+
stack.splice(i);
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} else if (!isSelfClosing) {
|
|
576
|
+
stack.push({ tag: tagName, id: `e${idCounter++}` });
|
|
577
|
+
}
|
|
578
|
+
lastIndex = match.index + fullTag.length;
|
|
579
|
+
match = tagPattern.exec(html);
|
|
580
|
+
}
|
|
581
|
+
if (lastIndex < html.length) {
|
|
582
|
+
const text = decodeEntities(html.slice(lastIndex));
|
|
583
|
+
if (text) {
|
|
584
|
+
nodes.push({
|
|
585
|
+
text,
|
|
586
|
+
blockAncestorPath: getBlockAncestorPath(stack)
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return nodes;
|
|
591
|
+
}
|
|
592
|
+
function getBlockAncestorPath(stack) {
|
|
593
|
+
const path2 = [];
|
|
594
|
+
for (const entry of stack) {
|
|
595
|
+
if (BLOCK_ELEMENTS.has(entry.tag)) {
|
|
596
|
+
path2.push(entry.id);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return path2;
|
|
600
|
+
}
|
|
601
|
+
var VOID_ELEMENTS = new Set([
|
|
602
|
+
"area",
|
|
603
|
+
"base",
|
|
604
|
+
"br",
|
|
605
|
+
"col",
|
|
606
|
+
"embed",
|
|
607
|
+
"hr",
|
|
608
|
+
"img",
|
|
609
|
+
"input",
|
|
610
|
+
"link",
|
|
611
|
+
"meta",
|
|
612
|
+
"param",
|
|
613
|
+
"source",
|
|
614
|
+
"track",
|
|
615
|
+
"wbr"
|
|
616
|
+
]);
|
|
617
|
+
function isVoidElement(tag) {
|
|
618
|
+
return VOID_ELEMENTS.has(tag);
|
|
619
|
+
}
|
|
620
|
+
function decodeEntities(text) {
|
|
621
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, "\xA0").replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))).replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/lib/key-lock.ts
|
|
625
|
+
var locks = new Map;
|
|
626
|
+
function namespace(name) {
|
|
627
|
+
let n = locks.get(name);
|
|
628
|
+
if (!n) {
|
|
629
|
+
n = new Map;
|
|
630
|
+
locks.set(name, n);
|
|
631
|
+
}
|
|
632
|
+
return n;
|
|
633
|
+
}
|
|
634
|
+
function createKeyLock(name) {
|
|
635
|
+
const map = namespace(name);
|
|
636
|
+
return function withLock(key, fn) {
|
|
637
|
+
const prev = map.get(key) ?? Promise.resolve();
|
|
638
|
+
const next = prev.catch(() => {}).then(fn);
|
|
639
|
+
map.set(key, next.catch(() => {}));
|
|
640
|
+
return next;
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/lib/markdown-renderer.ts
|
|
645
|
+
import MarkdownIt from "markdown-it";
|
|
646
|
+
|
|
647
|
+
// src/lib/headings.ts
|
|
648
|
+
function slugify(text) {
|
|
649
|
+
return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
650
|
+
}
|
|
651
|
+
function stripCodeBlocks(content) {
|
|
652
|
+
let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
|
|
653
|
+
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, `
|
|
654
|
+
|
|
655
|
+
`);
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
function parseMarkdownHeadings(content) {
|
|
659
|
+
const headings = [];
|
|
660
|
+
const seenIds = new Map;
|
|
661
|
+
const contentWithoutCode = stripCodeBlocks(content);
|
|
662
|
+
const regex = /^(#{1,6})\s+(.+)$/gm;
|
|
663
|
+
let match = regex.exec(contentWithoutCode);
|
|
664
|
+
while (match !== null) {
|
|
665
|
+
const level = match[1].length;
|
|
666
|
+
const text = match[2].trim();
|
|
667
|
+
const baseId = slugify(text);
|
|
668
|
+
const count = seenIds.get(baseId) ?? 0;
|
|
669
|
+
const id = count > 0 ? `${baseId}-${count}` : baseId;
|
|
670
|
+
seenIds.set(baseId, count + 1);
|
|
671
|
+
headings.push({ id, text, level });
|
|
672
|
+
match = regex.exec(contentWithoutCode);
|
|
673
|
+
}
|
|
674
|
+
return headings;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/lib/mermaid-renderer.ts
|
|
678
|
+
var RENDER_TIMEOUT_MS = 15000;
|
|
679
|
+
var MAX_CONSECUTIVE_ERRORS = 3;
|
|
680
|
+
var worker = null;
|
|
681
|
+
var workerReady = null;
|
|
682
|
+
var pendingRequests = new Map;
|
|
683
|
+
var requestCounter = 0;
|
|
684
|
+
var consecutiveErrors = 0;
|
|
685
|
+
function resetWorker(reason, currentWorker = worker) {
|
|
686
|
+
if (currentWorker)
|
|
687
|
+
currentWorker.terminate();
|
|
688
|
+
if (worker === currentWorker) {
|
|
689
|
+
worker = null;
|
|
690
|
+
workerReady = null;
|
|
691
|
+
}
|
|
692
|
+
for (const [, pending] of pendingRequests) {
|
|
693
|
+
clearTimeout(pending.timer);
|
|
694
|
+
pending.reject(reason);
|
|
695
|
+
}
|
|
696
|
+
pendingRequests.clear();
|
|
697
|
+
}
|
|
698
|
+
function createWorker() {
|
|
699
|
+
const w = new Worker(new URL("./mermaid-worker.ts", import.meta.url).href, {
|
|
700
|
+
type: "module"
|
|
701
|
+
});
|
|
702
|
+
const ready = new Promise((resolve2, reject) => {
|
|
703
|
+
const timeout = setTimeout(() => {
|
|
704
|
+
w.removeEventListener("message", onReady);
|
|
705
|
+
w.removeEventListener("error", onStartupError);
|
|
706
|
+
w.terminate();
|
|
707
|
+
if (worker === w) {
|
|
708
|
+
worker = null;
|
|
709
|
+
workerReady = null;
|
|
710
|
+
}
|
|
711
|
+
reject(new Error("Mermaid worker failed to start within 30s"));
|
|
712
|
+
}, 30000);
|
|
713
|
+
function onStartupError(event) {
|
|
714
|
+
clearTimeout(timeout);
|
|
715
|
+
w.removeEventListener("message", onReady);
|
|
716
|
+
w.removeEventListener("error", onStartupError);
|
|
717
|
+
w.terminate();
|
|
718
|
+
if (worker === w) {
|
|
719
|
+
worker = null;
|
|
720
|
+
workerReady = null;
|
|
721
|
+
}
|
|
722
|
+
reject(new Error(`Mermaid worker failed to start: ${event.message}`));
|
|
723
|
+
}
|
|
724
|
+
function onReady(event) {
|
|
725
|
+
if (event.data?.type === "ready") {
|
|
726
|
+
clearTimeout(timeout);
|
|
727
|
+
w.removeEventListener("message", onReady);
|
|
728
|
+
w.removeEventListener("error", onStartupError);
|
|
729
|
+
resolve2();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
w.addEventListener("message", onReady);
|
|
733
|
+
w.addEventListener("error", onStartupError);
|
|
734
|
+
});
|
|
735
|
+
w.addEventListener("message", (event) => {
|
|
736
|
+
const { id, svg, error } = event.data;
|
|
737
|
+
if (!id)
|
|
738
|
+
return;
|
|
739
|
+
const pending = pendingRequests.get(id);
|
|
740
|
+
if (!pending)
|
|
741
|
+
return;
|
|
742
|
+
clearTimeout(pending.timer);
|
|
743
|
+
pendingRequests.delete(id);
|
|
744
|
+
if (error) {
|
|
745
|
+
consecutiveErrors++;
|
|
746
|
+
pending.reject(new Error(error));
|
|
747
|
+
} else {
|
|
748
|
+
consecutiveErrors = 0;
|
|
749
|
+
pending.resolve(svg);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
w.addEventListener("error", (event) => {
|
|
753
|
+
resetWorker(new Error(`Worker error: ${event.message}`), w);
|
|
754
|
+
});
|
|
755
|
+
return { worker: w, ready };
|
|
756
|
+
}
|
|
757
|
+
async function ensureWorker() {
|
|
758
|
+
if (worker && workerReady) {
|
|
759
|
+
await workerReady;
|
|
760
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
761
|
+
resetWorker(new Error("Mermaid worker restarted after repeated render failures"));
|
|
762
|
+
consecutiveErrors = 0;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (!worker) {
|
|
766
|
+
const result = createWorker();
|
|
767
|
+
worker = result.worker;
|
|
768
|
+
workerReady = result.ready;
|
|
769
|
+
await workerReady;
|
|
770
|
+
}
|
|
771
|
+
return worker;
|
|
772
|
+
}
|
|
773
|
+
async function renderMermaidSvg(code) {
|
|
774
|
+
const w = await ensureWorker();
|
|
775
|
+
const id = `req-${++requestCounter}`;
|
|
776
|
+
const diagramId = `mermaid-ssr-${requestCounter}`;
|
|
777
|
+
return new Promise((resolve2, reject) => {
|
|
778
|
+
const timer = setTimeout(() => {
|
|
779
|
+
pendingRequests.delete(id);
|
|
780
|
+
resetWorker(new Error(`Mermaid render timed out after ${RENDER_TIMEOUT_MS}ms`), w);
|
|
781
|
+
reject(new Error(`Mermaid render timed out after ${RENDER_TIMEOUT_MS}ms`));
|
|
782
|
+
}, RENDER_TIMEOUT_MS);
|
|
783
|
+
pendingRequests.set(id, { resolve: resolve2, reject, timer });
|
|
784
|
+
w.postMessage({ id, code, diagramId });
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
async function renderMermaidBlocks(blocks) {
|
|
788
|
+
const results = [];
|
|
789
|
+
for (const code of blocks) {
|
|
790
|
+
try {
|
|
791
|
+
const svg = await renderMermaidSvg(code);
|
|
792
|
+
results.push(svg);
|
|
793
|
+
} catch {
|
|
794
|
+
results.push(null);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return results;
|
|
798
|
+
}
|
|
799
|
+
function disposeMermaidWorker() {
|
|
800
|
+
resetWorker(new Error("Mermaid worker disposed"));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/lib/markdown-renderer.ts
|
|
804
|
+
var SHIKI_LANGUAGES = [
|
|
805
|
+
"bash",
|
|
806
|
+
"css",
|
|
807
|
+
"diff",
|
|
808
|
+
"go",
|
|
809
|
+
"graphql",
|
|
810
|
+
"javascript",
|
|
811
|
+
"json",
|
|
812
|
+
"jsx",
|
|
813
|
+
"markdown",
|
|
814
|
+
"python",
|
|
815
|
+
"rust",
|
|
816
|
+
"sql",
|
|
817
|
+
"tsx",
|
|
818
|
+
"typescript",
|
|
819
|
+
"yaml"
|
|
820
|
+
];
|
|
821
|
+
var SHIKI_THEME = "one-dark-pro";
|
|
822
|
+
var shikiInstance = null;
|
|
823
|
+
var shikiPromise = null;
|
|
824
|
+
async function getShiki() {
|
|
825
|
+
if (shikiInstance)
|
|
826
|
+
return shikiInstance;
|
|
827
|
+
if (shikiPromise)
|
|
828
|
+
return shikiPromise;
|
|
829
|
+
shikiPromise = import("shiki").then(async ({ createHighlighter }) => {
|
|
830
|
+
const highlighter = await createHighlighter({
|
|
831
|
+
themes: [SHIKI_THEME],
|
|
832
|
+
langs: SHIKI_LANGUAGES
|
|
833
|
+
});
|
|
834
|
+
shikiInstance = highlighter;
|
|
835
|
+
return highlighter;
|
|
836
|
+
});
|
|
837
|
+
return shikiPromise;
|
|
838
|
+
}
|
|
839
|
+
function createMarkdownRenderer(shiki) {
|
|
840
|
+
const md = new MarkdownIt({
|
|
841
|
+
html: true,
|
|
842
|
+
linkify: true,
|
|
843
|
+
typographer: false,
|
|
844
|
+
highlight(code, lang) {
|
|
845
|
+
if (lang === "mermaid") {
|
|
846
|
+
return `<pre><code class="language-mermaid">${md.utils.escapeHtml(code)}</code></pre>`;
|
|
847
|
+
}
|
|
848
|
+
const language = normalizeLanguage(lang);
|
|
849
|
+
if (language && shiki.getLoadedLanguages().includes(language)) {
|
|
850
|
+
return shiki.codeToHtml(code, {
|
|
851
|
+
lang: language,
|
|
852
|
+
theme: SHIKI_THEME
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return `<pre class="shiki"><code>${md.utils.escapeHtml(code)}</code></pre>`;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
return md;
|
|
859
|
+
}
|
|
860
|
+
function normalizeLanguage(lang) {
|
|
861
|
+
const aliases = {
|
|
862
|
+
sh: "bash",
|
|
863
|
+
shell: "bash",
|
|
864
|
+
js: "javascript",
|
|
865
|
+
ts: "typescript",
|
|
866
|
+
py: "python",
|
|
867
|
+
rs: "rust",
|
|
868
|
+
yml: "yaml",
|
|
869
|
+
md: "markdown"
|
|
870
|
+
};
|
|
871
|
+
const normalized = lang.toLowerCase().trim();
|
|
872
|
+
if (SHIKI_LANGUAGES.includes(normalized)) {
|
|
873
|
+
return normalized;
|
|
874
|
+
}
|
|
875
|
+
return aliases[normalized];
|
|
876
|
+
}
|
|
877
|
+
function injectHeadingIds(html, headings) {
|
|
878
|
+
let headingIdx = 0;
|
|
879
|
+
return html.replace(/<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi, (match, tag, attrs, content) => {
|
|
880
|
+
if (headingIdx >= headings.length)
|
|
881
|
+
return match;
|
|
882
|
+
const heading = headings[headingIdx];
|
|
883
|
+
headingIdx++;
|
|
884
|
+
if (/\bid\s*=/.test(attrs))
|
|
885
|
+
return match;
|
|
886
|
+
return `<${tag} id="${heading.id}"${attrs}>${content}</${tag}>`;
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
890
|
+
function extractFrontmatter(src) {
|
|
891
|
+
const match = src.match(FRONTMATTER_RE);
|
|
892
|
+
if (!match)
|
|
893
|
+
return { rest: src, frontmatter: "" };
|
|
894
|
+
return { rest: src.slice(match[0].length), frontmatter: match[1].trim() };
|
|
895
|
+
}
|
|
896
|
+
function renderFrontmatterBlock(yaml, md, shiki) {
|
|
897
|
+
const highlighted = shiki.getLoadedLanguages().includes("yaml") ? shiki.codeToHtml(yaml, { lang: "yaml", theme: SHIKI_THEME }) : `<pre><code class="language-yaml">${md.utils.escapeHtml(yaml)}</code></pre>`;
|
|
898
|
+
return `<details class="frontmatter"><summary>Properties</summary>${highlighted}</details>`;
|
|
899
|
+
}
|
|
900
|
+
var TASK_ITEM_RE = /<li>(\s*(?:<p>)?)\[([ xX])\]\s/g;
|
|
901
|
+
function transformTaskLists(html) {
|
|
902
|
+
let idx = 0;
|
|
903
|
+
return html.replace(TASK_ITEM_RE, (_match, prefix, mark) => {
|
|
904
|
+
const checked = mark === " " ? "false" : "true";
|
|
905
|
+
const span = `<span class="task-checkbox" data-checked="${checked}" data-task-index="${idx}" role="checkbox" aria-checked="${checked}" tabindex="0"></span>`;
|
|
906
|
+
idx++;
|
|
907
|
+
return `<li>${prefix}${span}`;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
var TS_TASK_ITEM_RE = /^(\s*[-*+]\s+)\[([ xX])\](\s)/gm;
|
|
911
|
+
var TS_FENCED_CODE_RE = /^(?:```|~~~)[^\n]*\n[\s\S]*?\n(?:```|~~~)[ \t]*$/gm;
|
|
912
|
+
function maskCodeBlocks(src) {
|
|
913
|
+
return src.replace(TS_FENCED_CODE_RE, (m) => m.replace(/[^\n]/g, "X"));
|
|
914
|
+
}
|
|
915
|
+
function toggleTaskInSource(src, index, checked) {
|
|
916
|
+
const { rest } = extractFrontmatter(src);
|
|
917
|
+
const prefixLen = src.length - rest.length;
|
|
918
|
+
const masked = maskCodeBlocks(rest);
|
|
919
|
+
const matches = [];
|
|
920
|
+
TS_TASK_ITEM_RE.lastIndex = 0;
|
|
921
|
+
let m = TS_TASK_ITEM_RE.exec(masked);
|
|
922
|
+
while (m !== null) {
|
|
923
|
+
const markIndex = m.index + m[1].length + 1;
|
|
924
|
+
matches.push({ markIndex });
|
|
925
|
+
m = TS_TASK_ITEM_RE.exec(masked);
|
|
926
|
+
}
|
|
927
|
+
if (index < 0 || index >= matches.length)
|
|
928
|
+
return null;
|
|
929
|
+
const markPos = prefixLen + matches[index].markIndex;
|
|
930
|
+
const newMark = checked ? "x" : " ";
|
|
931
|
+
return src.slice(0, markPos) + newMark + src.slice(markPos + 1);
|
|
932
|
+
}
|
|
933
|
+
var MERMAID_BLOCK_RE = /<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g;
|
|
934
|
+
function unescapeHtml(str) {
|
|
935
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"');
|
|
936
|
+
}
|
|
937
|
+
async function replaceMermaidBlocks(html) {
|
|
938
|
+
MERMAID_BLOCK_RE.lastIndex = 0;
|
|
939
|
+
const matches = [];
|
|
940
|
+
let match = MERMAID_BLOCK_RE.exec(html);
|
|
941
|
+
while (match !== null) {
|
|
942
|
+
matches.push({
|
|
943
|
+
fullMatch: match[0],
|
|
944
|
+
code: unescapeHtml(match[1]),
|
|
945
|
+
index: match.index
|
|
946
|
+
});
|
|
947
|
+
match = MERMAID_BLOCK_RE.exec(html);
|
|
948
|
+
}
|
|
949
|
+
if (matches.length === 0)
|
|
950
|
+
return html;
|
|
951
|
+
const codes = matches.map((m) => m.code);
|
|
952
|
+
const svgs = await renderMermaidBlocks(codes);
|
|
953
|
+
for (let i = matches.length - 1;i >= 0; i--) {
|
|
954
|
+
const svg = svgs[i];
|
|
955
|
+
if (svg !== null) {
|
|
956
|
+
const { fullMatch, index, code } = matches[i];
|
|
957
|
+
const encodedSource = encodeURIComponent(code);
|
|
958
|
+
const replacement = `<div class="mermaid-container" data-mermaid-source="${encodedSource}">${svg}</div>`;
|
|
959
|
+
html = html.slice(0, index) + replacement + html.slice(index + fullMatch.length);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return html;
|
|
963
|
+
}
|
|
964
|
+
async function renderMarkdown(content) {
|
|
965
|
+
const shiki = await getShiki();
|
|
966
|
+
const md = createMarkdownRenderer(shiki);
|
|
967
|
+
const { rest, frontmatter } = extractFrontmatter(content);
|
|
968
|
+
const headings = parseMarkdownHeadings(rest);
|
|
969
|
+
let html = md.render(rest);
|
|
970
|
+
html = injectHeadingIds(html, headings);
|
|
971
|
+
html = await replaceMermaidBlocks(html);
|
|
972
|
+
html = transformTaskLists(html);
|
|
973
|
+
if (frontmatter) {
|
|
974
|
+
html = renderFrontmatterBlock(frontmatter, md, shiki) + html;
|
|
975
|
+
}
|
|
976
|
+
return { html, headings };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/template.ts
|
|
980
|
+
function safeJsonStringify(data) {
|
|
981
|
+
return JSON.stringify(data).replace(/</g, "\\u003c");
|
|
982
|
+
}
|
|
983
|
+
function escapeHtml(str) {
|
|
984
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
985
|
+
}
|
|
986
|
+
function escapeAttr(str) {
|
|
987
|
+
return str.replace(/&/g, "&").replace(/"/g, """);
|
|
988
|
+
}
|
|
989
|
+
function sanitizeHtml(html) {
|
|
990
|
+
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
|
991
|
+
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
992
|
+
sanitized = sanitized.replace(/\bhref\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, "");
|
|
993
|
+
sanitized = sanitized.replace(/\bsrc\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, "");
|
|
994
|
+
return sanitized;
|
|
995
|
+
}
|
|
996
|
+
function renderTemplate(options) {
|
|
997
|
+
const {
|
|
998
|
+
title,
|
|
999
|
+
cssPath,
|
|
1000
|
+
jsPath,
|
|
1001
|
+
documentHtml,
|
|
1002
|
+
inlineData,
|
|
1003
|
+
isDev,
|
|
1004
|
+
fontFamily
|
|
1005
|
+
} = options;
|
|
1006
|
+
const viteClient = isDev ? '<script type="module" src="http://127.0.0.1:24678/@vite/client"></script>' : "";
|
|
1007
|
+
const cssLink = cssPath ? `<link rel="stylesheet" href="${escapeAttr(cssPath)}">` : "";
|
|
1008
|
+
const proseClass = fontFamily === "sans-serif" ? "prose-sans" : "prose-serif";
|
|
1009
|
+
return `<!DOCTYPE html>
|
|
1010
|
+
<html lang="en">
|
|
1011
|
+
<head>
|
|
1012
|
+
<meta charset="UTF-8">
|
|
1013
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1014
|
+
<title>readit \u2014 ${escapeHtml(title)}</title>
|
|
1015
|
+
<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>\uD83D\uDCD6</text></svg>">
|
|
1016
|
+
<script>
|
|
1017
|
+
(() => {
|
|
1018
|
+
var t = localStorage.getItem("readit:theme");
|
|
1019
|
+
var d = t === "dark" || (t !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
1020
|
+
if (d) document.documentElement.classList.add("dark");
|
|
1021
|
+
})();
|
|
1022
|
+
</script>
|
|
1023
|
+
${viteClient}
|
|
1024
|
+
${cssLink}
|
|
1025
|
+
</head>
|
|
1026
|
+
<body class="min-h-screen">
|
|
1027
|
+
<article id="document-content" class="prose ${proseClass}">${sanitizeHtml(documentHtml)}</article>
|
|
1028
|
+
<div id="app"></div>
|
|
1029
|
+
<script type="application/json" id="__readit">${safeJsonStringify(inlineData)}</script>
|
|
1030
|
+
<script type="module" src="${escapeAttr(jsPath)}" defer></script>
|
|
1031
|
+
</body>
|
|
1032
|
+
</html>`;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/server.ts
|
|
1036
|
+
function isErrnoException(err) {
|
|
1037
|
+
return err instanceof Error && "code" in err;
|
|
1038
|
+
}
|
|
1039
|
+
var resolvedCommentsCache = new Map;
|
|
1040
|
+
function invalidateResolvedComments(filePath) {
|
|
1041
|
+
resolvedCommentsCache.delete(filePath);
|
|
1042
|
+
}
|
|
1043
|
+
var withCommentLock = createKeyLock("comments");
|
|
1044
|
+
var withSourceLock = createKeyLock("source");
|
|
1045
|
+
async function canonicalPath(filePath) {
|
|
1046
|
+
return fs.realpath(path2.resolve(filePath));
|
|
1047
|
+
}
|
|
1048
|
+
async function readCommentsFromFile(filePath, sourceContent, renderedHtml) {
|
|
1049
|
+
const commentPath = getCommentPath(filePath);
|
|
1050
|
+
const sourceHash = computeHash(sourceContent);
|
|
1051
|
+
try {
|
|
1052
|
+
const stats = await fs.stat(commentPath);
|
|
1053
|
+
const cached = resolvedCommentsCache.get(filePath);
|
|
1054
|
+
if (cached && cached.sourceHash === sourceHash && cached.commentMtimeMs === stats.mtimeMs) {
|
|
1055
|
+
return cached.comments;
|
|
1056
|
+
}
|
|
1057
|
+
const content = await fs.readFile(commentPath, "utf-8");
|
|
1058
|
+
const file = parseCommentFile(content);
|
|
1059
|
+
const domText = renderedHtml ? extractTextFromHtml(renderedHtml) : null;
|
|
1060
|
+
const resolvedComments = file.comments.map((comment) => {
|
|
1061
|
+
const textForMatching = comment.anchorPrefix || comment.selectedText;
|
|
1062
|
+
const anchor = findAnchorWithFallback({
|
|
1063
|
+
source: sourceContent,
|
|
1064
|
+
selectedText: textForMatching,
|
|
1065
|
+
lineHint: comment.lineHint || "L1"
|
|
1066
|
+
});
|
|
1067
|
+
if (!anchor) {
|
|
1068
|
+
return {
|
|
1069
|
+
...comment,
|
|
1070
|
+
anchorConfidence: AnchorConfidences.UNRESOLVED
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
let startOffset = anchor.start;
|
|
1074
|
+
let endOffset = anchor.end;
|
|
1075
|
+
if (domText) {
|
|
1076
|
+
const domPos = findTextPosition(domText, comment.selectedText, anchor.start);
|
|
1077
|
+
if (domPos) {
|
|
1078
|
+
startOffset = domPos.start;
|
|
1079
|
+
endOffset = domPos.end;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
1083
|
+
...comment,
|
|
1084
|
+
startOffset,
|
|
1085
|
+
endOffset,
|
|
1086
|
+
lineHint: `L${anchor.line}`,
|
|
1087
|
+
anchorConfidence: anchor.confidence
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
resolvedCommentsCache.set(filePath, {
|
|
1091
|
+
sourceHash,
|
|
1092
|
+
commentMtimeMs: stats.mtimeMs,
|
|
1093
|
+
comments: resolvedComments
|
|
1094
|
+
});
|
|
1095
|
+
return resolvedComments;
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
1098
|
+
invalidateResolvedComments(filePath);
|
|
1099
|
+
return [];
|
|
1100
|
+
}
|
|
1101
|
+
throw err;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
async function writeCommentsToFile(filePath, sourceContent, comments) {
|
|
1105
|
+
const commentPath = getCommentPath(filePath);
|
|
1106
|
+
const commentDir = dirname2(commentPath);
|
|
1107
|
+
await fs.mkdir(commentDir, { recursive: true });
|
|
1108
|
+
const file = {
|
|
1109
|
+
source: filePath,
|
|
1110
|
+
hash: computeHash(sourceContent),
|
|
1111
|
+
version: 1,
|
|
1112
|
+
comments
|
|
1113
|
+
};
|
|
1114
|
+
const content = serializeComments(file);
|
|
1115
|
+
const tempPath = `${commentPath}.tmp`;
|
|
1116
|
+
await fs.writeFile(tempPath, content, "utf-8");
|
|
1117
|
+
await fs.rename(tempPath, commentPath);
|
|
1118
|
+
invalidateResolvedComments(filePath);
|
|
1119
|
+
}
|
|
1120
|
+
async function deleteCommentFile(filePath) {
|
|
1121
|
+
const commentPath = getCommentPath(filePath);
|
|
1122
|
+
try {
|
|
1123
|
+
await fs.unlink(commentPath);
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
1126
|
+
throw err;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
invalidateResolvedComments(filePath);
|
|
1130
|
+
}
|
|
1131
|
+
var SETTINGS_PATH = path2.join(os2.homedir(), ".readit", "settings.json");
|
|
1132
|
+
var DEFAULT_SETTINGS = {
|
|
1133
|
+
version: 1,
|
|
1134
|
+
fontFamily: FontFamilies.SERIF
|
|
1135
|
+
};
|
|
1136
|
+
async function readSettings() {
|
|
1137
|
+
try {
|
|
1138
|
+
const content = await fs.readFile(SETTINGS_PATH, "utf-8");
|
|
1139
|
+
return JSON.parse(content);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
1142
|
+
return DEFAULT_SETTINGS;
|
|
1143
|
+
}
|
|
1144
|
+
throw err;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async function writeSettings(settings) {
|
|
1148
|
+
const settingsDir = dirname2(SETTINGS_PATH);
|
|
1149
|
+
await fs.mkdir(settingsDir, { recursive: true });
|
|
1150
|
+
const tempPath = `${SETTINGS_PATH}.tmp`;
|
|
1151
|
+
await fs.writeFile(tempPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
1152
|
+
await fs.rename(tempPath, SETTINGS_PATH);
|
|
1153
|
+
}
|
|
1154
|
+
function isValidFontFamily(value) {
|
|
1155
|
+
return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
|
|
1156
|
+
}
|
|
1157
|
+
var SERVER_INFO_PATH = path2.join(os2.homedir(), ".readit", "server.json");
|
|
1158
|
+
async function writeServerInfo(port) {
|
|
1159
|
+
await fs.mkdir(path2.dirname(SERVER_INFO_PATH), { recursive: true });
|
|
1160
|
+
await fs.writeFile(SERVER_INFO_PATH, JSON.stringify({ port, pid: process.pid }), "utf-8");
|
|
1161
|
+
}
|
|
1162
|
+
async function removeServerInfo() {
|
|
1163
|
+
try {
|
|
1164
|
+
await fs.unlink(SERVER_INFO_PATH);
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
if (!isErrnoException(err) || err.code !== "ENOENT") {
|
|
1167
|
+
console.error("Failed to remove server info:", err);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function json(data, status = 200) {
|
|
1172
|
+
return Response.json(data, { status });
|
|
1173
|
+
}
|
|
1174
|
+
function errorResponse(message, status) {
|
|
1175
|
+
return Response.json({ error: message }, { status });
|
|
1176
|
+
}
|
|
1177
|
+
function errorWithDetail(message, err, status = 500) {
|
|
1178
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1179
|
+
return errorResponse(`${message}: ${detail}`, status);
|
|
1180
|
+
}
|
|
1181
|
+
async function getComments(ctx, renderedHtml) {
|
|
1182
|
+
try {
|
|
1183
|
+
const currentContent = await ctx.getCurrentContent();
|
|
1184
|
+
const comments = await readCommentsFromFile(ctx.filePath, currentContent, renderedHtml);
|
|
1185
|
+
return json({ comments });
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
console.error("Failed to read comments:", err);
|
|
1188
|
+
return errorResponse("Failed to read comments", 500);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
async function addComment(ctx, req) {
|
|
1192
|
+
try {
|
|
1193
|
+
const {
|
|
1194
|
+
selectedText,
|
|
1195
|
+
comment: commentText,
|
|
1196
|
+
startOffset,
|
|
1197
|
+
endOffset
|
|
1198
|
+
} = await req.json();
|
|
1199
|
+
if (!selectedText || typeof commentText !== "string" || startOffset === undefined || endOffset === undefined) {
|
|
1200
|
+
return errorResponse("Missing required fields", 400);
|
|
1201
|
+
}
|
|
1202
|
+
const currentContent = await ctx.getCurrentContent();
|
|
1203
|
+
const newComment = createComment(selectedText, commentText, startOffset, endOffset, currentContent);
|
|
1204
|
+
await withCommentLock(ctx.filePath, async () => {
|
|
1205
|
+
const existingComments = await readCommentsFromFile(ctx.filePath, currentContent);
|
|
1206
|
+
const allComments = [...existingComments, newComment];
|
|
1207
|
+
await writeCommentsToFile(ctx.filePath, currentContent, allComments);
|
|
1208
|
+
});
|
|
1209
|
+
return json({ comment: newComment }, 201);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
console.error("Failed to add comment:", err);
|
|
1212
|
+
return errorWithDetail("Failed to add comment", err);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async function updateComment(ctx, req, id) {
|
|
1216
|
+
try {
|
|
1217
|
+
const { comment: commentText } = await req.json();
|
|
1218
|
+
if (typeof commentText !== "string") {
|
|
1219
|
+
return errorResponse("Missing comment text", 400);
|
|
1220
|
+
}
|
|
1221
|
+
const currentContent = await ctx.getCurrentContent();
|
|
1222
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
1223
|
+
const existingComments = await readCommentsFromFile(ctx.filePath, currentContent);
|
|
1224
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
1225
|
+
if (commentIndex === -1)
|
|
1226
|
+
return null;
|
|
1227
|
+
const updatedComments = existingComments.map((c, i) => i === commentIndex ? { ...c, comment: commentText.trim() } : c);
|
|
1228
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
1229
|
+
return updatedComments[commentIndex];
|
|
1230
|
+
});
|
|
1231
|
+
if (!result)
|
|
1232
|
+
return errorResponse("Comment not found", 404);
|
|
1233
|
+
return json({ comment: result });
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
console.error("Failed to update comment:", err);
|
|
1236
|
+
return errorWithDetail("Failed to update comment", err);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
async function deleteComment(ctx, id) {
|
|
1240
|
+
try {
|
|
1241
|
+
const currentContent = await ctx.getCurrentContent();
|
|
1242
|
+
const found = await withCommentLock(ctx.filePath, async () => {
|
|
1243
|
+
const existingComments = await readCommentsFromFile(ctx.filePath, currentContent);
|
|
1244
|
+
const filteredComments = existingComments.filter((c) => c.id !== id);
|
|
1245
|
+
if (filteredComments.length === existingComments.length)
|
|
1246
|
+
return false;
|
|
1247
|
+
if (filteredComments.length === 0) {
|
|
1248
|
+
await deleteCommentFile(ctx.filePath);
|
|
1249
|
+
} else {
|
|
1250
|
+
await writeCommentsToFile(ctx.filePath, currentContent, filteredComments);
|
|
1251
|
+
}
|
|
1252
|
+
return true;
|
|
1253
|
+
});
|
|
1254
|
+
if (!found)
|
|
1255
|
+
return errorResponse("Comment not found", 404);
|
|
1256
|
+
return json({ success: true });
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
console.error("Failed to delete comment:", err);
|
|
1259
|
+
return errorWithDetail("Failed to delete comment", err);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async function clearComments(ctx) {
|
|
1263
|
+
try {
|
|
1264
|
+
await withCommentLock(ctx.filePath, () => deleteCommentFile(ctx.filePath));
|
|
1265
|
+
return json({ success: true });
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
console.error("Failed to clear comments:", err);
|
|
1268
|
+
return errorWithDetail("Failed to clear comments", err);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
async function getRawComments(ctx) {
|
|
1272
|
+
const commentPath = getCommentPath(ctx.filePath);
|
|
1273
|
+
try {
|
|
1274
|
+
const content = await fs.readFile(commentPath, "utf-8");
|
|
1275
|
+
return json({ content, path: commentPath });
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
1278
|
+
return json({ content: null, path: commentPath });
|
|
1279
|
+
}
|
|
1280
|
+
console.error("Failed to read raw comments:", err);
|
|
1281
|
+
return errorResponse("Failed to read raw comments", 500);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function reanchorComment(ctx, req, id) {
|
|
1285
|
+
try {
|
|
1286
|
+
const { selectedText, startOffset, endOffset } = await req.json();
|
|
1287
|
+
if (!selectedText || startOffset === undefined || endOffset === undefined) {
|
|
1288
|
+
return errorResponse("Missing required fields", 400);
|
|
1289
|
+
}
|
|
1290
|
+
const currentContent = await ctx.getCurrentContent();
|
|
1291
|
+
const result = await withCommentLock(ctx.filePath, async () => {
|
|
1292
|
+
const existingComments = await readCommentsFromFile(ctx.filePath, currentContent);
|
|
1293
|
+
const commentIndex = existingComments.findIndex((c) => c.id === id);
|
|
1294
|
+
if (commentIndex === -1)
|
|
1295
|
+
return null;
|
|
1296
|
+
const lineHint = getLineHint(currentContent, startOffset, endOffset);
|
|
1297
|
+
const truncatedText = truncateSelection(selectedText);
|
|
1298
|
+
const updatedComment = {
|
|
1299
|
+
...existingComments[commentIndex],
|
|
1300
|
+
selectedText: truncatedText,
|
|
1301
|
+
startOffset,
|
|
1302
|
+
endOffset,
|
|
1303
|
+
lineHint,
|
|
1304
|
+
anchorConfidence: AnchorConfidences.EXACT,
|
|
1305
|
+
anchorPrefix: selectedText.length > 1000 ? selectedText.slice(0, 200) : undefined
|
|
1306
|
+
};
|
|
1307
|
+
const updatedComments = existingComments.map((c, i) => i === commentIndex ? updatedComment : c);
|
|
1308
|
+
await writeCommentsToFile(ctx.filePath, currentContent, updatedComments);
|
|
1309
|
+
return updatedComment;
|
|
1310
|
+
});
|
|
1311
|
+
if (!result)
|
|
1312
|
+
return errorResponse("Comment not found", 404);
|
|
1313
|
+
return json({ comment: result });
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
console.error("Failed to re-anchor comment:", err);
|
|
1316
|
+
return errorWithDetail("Failed to re-anchor comment", err);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async function getSettingsRoute() {
|
|
1320
|
+
try {
|
|
1321
|
+
const settings = await readSettings();
|
|
1322
|
+
return json(settings);
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
console.error("Failed to read settings:", err);
|
|
1325
|
+
return errorResponse("Failed to read settings", 500);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
async function updateSettingsRoute(req) {
|
|
1329
|
+
try {
|
|
1330
|
+
const body = await req.json();
|
|
1331
|
+
const { fontFamily } = body;
|
|
1332
|
+
if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
|
|
1333
|
+
return errorResponse("Invalid font family", 400);
|
|
1334
|
+
}
|
|
1335
|
+
const current = await readSettings();
|
|
1336
|
+
const settings = {
|
|
1337
|
+
...current,
|
|
1338
|
+
...fontFamily !== undefined && { fontFamily }
|
|
1339
|
+
};
|
|
1340
|
+
await writeSettings(settings);
|
|
1341
|
+
return json(settings);
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
console.error("Failed to save settings:", err);
|
|
1344
|
+
return errorResponse("Failed to save settings", 500);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function createDocumentStream(sseClients) {
|
|
1348
|
+
let pingInterval;
|
|
1349
|
+
let captured;
|
|
1350
|
+
const stream = new ReadableStream({
|
|
1351
|
+
start(controller) {
|
|
1352
|
+
captured = controller;
|
|
1353
|
+
controller.enqueue(`data: connected
|
|
1354
|
+
|
|
1355
|
+
`);
|
|
1356
|
+
sseClients.add(controller);
|
|
1357
|
+
pingInterval = setInterval(() => {
|
|
1358
|
+
try {
|
|
1359
|
+
controller.enqueue(`data: ping
|
|
1360
|
+
|
|
1361
|
+
`);
|
|
1362
|
+
} catch {
|
|
1363
|
+
clearInterval(pingInterval);
|
|
1364
|
+
sseClients.delete(controller);
|
|
1365
|
+
}
|
|
1366
|
+
}, 5000);
|
|
1367
|
+
},
|
|
1368
|
+
cancel() {
|
|
1369
|
+
clearInterval(pingInterval);
|
|
1370
|
+
sseClients.delete(captured);
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
return new Response(stream, {
|
|
1374
|
+
headers: {
|
|
1375
|
+
"Content-Type": "text/event-stream",
|
|
1376
|
+
"Cache-Control": "no-cache",
|
|
1377
|
+
Connection: "keep-alive"
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
function createHeartbeat(onOpen, onClose) {
|
|
1382
|
+
let interval;
|
|
1383
|
+
let captured;
|
|
1384
|
+
const stream = new ReadableStream({
|
|
1385
|
+
start(controller) {
|
|
1386
|
+
captured = controller;
|
|
1387
|
+
controller.enqueue(`data: connected
|
|
1388
|
+
|
|
1389
|
+
`);
|
|
1390
|
+
onOpen(controller);
|
|
1391
|
+
interval = setInterval(() => {
|
|
1392
|
+
try {
|
|
1393
|
+
controller.enqueue(`data: ping
|
|
1394
|
+
|
|
1395
|
+
`);
|
|
1396
|
+
} catch {
|
|
1397
|
+
clearInterval(interval);
|
|
1398
|
+
onClose(controller);
|
|
1399
|
+
}
|
|
1400
|
+
}, 5000);
|
|
1401
|
+
},
|
|
1402
|
+
cancel() {
|
|
1403
|
+
clearInterval(interval);
|
|
1404
|
+
onClose(captured);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
return new Response(stream, {
|
|
1408
|
+
headers: {
|
|
1409
|
+
"Content-Type": "text/event-stream",
|
|
1410
|
+
"Cache-Control": "no-cache",
|
|
1411
|
+
Connection: "keep-alive"
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
async function serveStaticFile(distPath, pathname) {
|
|
1416
|
+
const filePath = join3(distPath, pathname);
|
|
1417
|
+
const file = Bun.file(filePath);
|
|
1418
|
+
if (await file.exists()) {
|
|
1419
|
+
const isHashed = pathname.startsWith("/assets/");
|
|
1420
|
+
const headers = isHashed ? { "Cache-Control": "public, max-age=31536000, immutable" } : {};
|
|
1421
|
+
return new Response(file, { headers });
|
|
1422
|
+
}
|
|
1423
|
+
const indexFile = Bun.file(join3(distPath, "index.html"));
|
|
1424
|
+
if (await indexFile.exists()) {
|
|
1425
|
+
return new Response(indexFile);
|
|
1426
|
+
}
|
|
1427
|
+
return new Response("Not Found", { status: 404 });
|
|
1428
|
+
}
|
|
1429
|
+
var VITE_DEV_PORT = 24678;
|
|
1430
|
+
var VITE_DEV_ORIGIN = `http://127.0.0.1:${VITE_DEV_PORT}`;
|
|
1431
|
+
async function proxyToVite(req, pathname, search) {
|
|
1432
|
+
const target = `${VITE_DEV_ORIGIN}${pathname}${search}`;
|
|
1433
|
+
try {
|
|
1434
|
+
return await fetch(new Request(target, {
|
|
1435
|
+
method: req.method,
|
|
1436
|
+
headers: req.headers,
|
|
1437
|
+
body: req.body,
|
|
1438
|
+
redirect: "manual"
|
|
1439
|
+
}));
|
|
1440
|
+
} catch {
|
|
1441
|
+
return new Response("Vite dev server not available", { status: 502 });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function extractCommentId(pathname) {
|
|
1445
|
+
const match = pathname.match(/^\/api\/comments\/([^/]+)/);
|
|
1446
|
+
return match?.[1];
|
|
1447
|
+
}
|
|
1448
|
+
function createServer(options) {
|
|
1449
|
+
const fileMap = new Map;
|
|
1450
|
+
const fileOrder = [];
|
|
1451
|
+
for (const entry of options.files) {
|
|
1452
|
+
fileMap.set(entry.filePath, {
|
|
1453
|
+
content: entry.content ?? null,
|
|
1454
|
+
renderedHtml: null,
|
|
1455
|
+
headings: null,
|
|
1456
|
+
isLoaded: entry.content !== undefined,
|
|
1457
|
+
debounceTimer: null
|
|
1458
|
+
});
|
|
1459
|
+
fileOrder.push(entry.filePath);
|
|
1460
|
+
if (options.clean) {
|
|
1461
|
+
const commentPath = getCommentPath(entry.filePath);
|
|
1462
|
+
fs.unlink(commentPath).catch(() => {});
|
|
1463
|
+
invalidateResolvedComments(entry.filePath);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const defaultPath = fileOrder[0];
|
|
1467
|
+
const sseClients = new Set;
|
|
1468
|
+
const heartbeatClients = new Set;
|
|
1469
|
+
let shutdownTimer = null;
|
|
1470
|
+
function sendEvent(event) {
|
|
1471
|
+
const message = `data: ${JSON.stringify(event)}
|
|
1472
|
+
|
|
1473
|
+
`;
|
|
1474
|
+
for (const controller of sseClients) {
|
|
1475
|
+
try {
|
|
1476
|
+
controller.enqueue(message);
|
|
1477
|
+
} catch {
|
|
1478
|
+
sseClients.delete(controller);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function clearShutdownTimer() {
|
|
1483
|
+
if (!shutdownTimer)
|
|
1484
|
+
return;
|
|
1485
|
+
clearTimeout(shutdownTimer);
|
|
1486
|
+
shutdownTimer = null;
|
|
1487
|
+
}
|
|
1488
|
+
function onHeartbeatOpen(controller) {
|
|
1489
|
+
heartbeatClients.add(controller);
|
|
1490
|
+
clearShutdownTimer();
|
|
1491
|
+
}
|
|
1492
|
+
function onHeartbeatClose(controller) {
|
|
1493
|
+
heartbeatClients.delete(controller);
|
|
1494
|
+
if (isDev || heartbeatClients.size > 0 || shutdownTimer)
|
|
1495
|
+
return;
|
|
1496
|
+
shutdownTimer = setTimeout(() => {
|
|
1497
|
+
if (heartbeatClients.size > 0) {
|
|
1498
|
+
clearShutdownTimer();
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
console.log(`
|
|
1502
|
+
Browser disconnected, shutting down...`);
|
|
1503
|
+
process.exit(0);
|
|
1504
|
+
}, 1500);
|
|
1505
|
+
}
|
|
1506
|
+
async function ensureFileContent(filePath) {
|
|
1507
|
+
const state = fileMap.get(filePath);
|
|
1508
|
+
if (!state) {
|
|
1509
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1510
|
+
}
|
|
1511
|
+
if (state.isLoaded && state.content !== null) {
|
|
1512
|
+
return state.content;
|
|
1513
|
+
}
|
|
1514
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
1515
|
+
state.content = content;
|
|
1516
|
+
state.isLoaded = true;
|
|
1517
|
+
return content;
|
|
1518
|
+
}
|
|
1519
|
+
async function ensureRenderedHtml(filePath) {
|
|
1520
|
+
const state = fileMap.get(filePath);
|
|
1521
|
+
if (!state)
|
|
1522
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1523
|
+
if (state.renderedHtml !== null && state.headings !== null) {
|
|
1524
|
+
return { html: state.renderedHtml, headings: state.headings };
|
|
1525
|
+
}
|
|
1526
|
+
const content = await ensureFileContent(filePath);
|
|
1527
|
+
const result = await renderMarkdown(content);
|
|
1528
|
+
state.renderedHtml = result.html;
|
|
1529
|
+
state.headings = result.headings;
|
|
1530
|
+
return result;
|
|
1531
|
+
}
|
|
1532
|
+
function resolveContext(url) {
|
|
1533
|
+
const requestedPath = url.searchParams.get("path") ?? defaultPath;
|
|
1534
|
+
const state = fileMap.get(requestedPath);
|
|
1535
|
+
if (!state)
|
|
1536
|
+
return null;
|
|
1537
|
+
return {
|
|
1538
|
+
filePath: requestedPath,
|
|
1539
|
+
getCurrentContent: () => ensureFileContent(requestedPath)
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
function requireContext(url) {
|
|
1543
|
+
const ctx = resolveContext(url);
|
|
1544
|
+
if (!ctx) {
|
|
1545
|
+
return errorResponse("File not found", 404);
|
|
1546
|
+
}
|
|
1547
|
+
return ctx;
|
|
1548
|
+
}
|
|
1549
|
+
const isDev = false;
|
|
1550
|
+
const distPath = import.meta.dir;
|
|
1551
|
+
let manifestCache = null;
|
|
1552
|
+
async function getManifest() {
|
|
1553
|
+
if (manifestCache)
|
|
1554
|
+
return manifestCache;
|
|
1555
|
+
try {
|
|
1556
|
+
const manifestPath = join3(distPath, ".vite", "manifest.json");
|
|
1557
|
+
const content = await fs.readFile(manifestPath, "utf-8");
|
|
1558
|
+
manifestCache = JSON.parse(content);
|
|
1559
|
+
return manifestCache;
|
|
1560
|
+
} catch {
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
let pageCache = null;
|
|
1565
|
+
let pageCacheGz = null;
|
|
1566
|
+
function invalidatePageCache() {
|
|
1567
|
+
pageCache = null;
|
|
1568
|
+
pageCacheGz = null;
|
|
1569
|
+
}
|
|
1570
|
+
async function serveAppPage(req) {
|
|
1571
|
+
const acceptGzip = req.headers.get("accept-encoding")?.includes("gzip") ?? false;
|
|
1572
|
+
try {
|
|
1573
|
+
if (pageCache) {
|
|
1574
|
+
if (acceptGzip && pageCacheGz) {
|
|
1575
|
+
return new Response(pageCacheGz, {
|
|
1576
|
+
headers: {
|
|
1577
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1578
|
+
"Content-Encoding": "gzip"
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
return new Response(pageCache, {
|
|
1583
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
const { html, headings } = await ensureRenderedHtml(defaultPath);
|
|
1587
|
+
const content = await ensureFileContent(defaultPath);
|
|
1588
|
+
const comments = await readCommentsFromFile(defaultPath, content, html);
|
|
1589
|
+
const settings = await readSettings();
|
|
1590
|
+
const files = fileOrder.map((fp) => ({
|
|
1591
|
+
path: fp,
|
|
1592
|
+
fileName: basename(fp)
|
|
1593
|
+
}));
|
|
1594
|
+
const inlineData = {
|
|
1595
|
+
files,
|
|
1596
|
+
activeFile: defaultPath,
|
|
1597
|
+
settings,
|
|
1598
|
+
documents: {
|
|
1599
|
+
[defaultPath]: {
|
|
1600
|
+
headings,
|
|
1601
|
+
comments
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
clean: options.clean || false,
|
|
1605
|
+
workingDirectory: process.cwd()
|
|
1606
|
+
};
|
|
1607
|
+
let cssPath = "";
|
|
1608
|
+
let jsPath;
|
|
1609
|
+
if (isDev) {
|
|
1610
|
+
jsPath = `http://127.0.0.1:${VITE_DEV_PORT}/src/main.ts`;
|
|
1611
|
+
} else {
|
|
1612
|
+
const manifest = await getManifest();
|
|
1613
|
+
const entry = manifest?.["index.html"];
|
|
1614
|
+
jsPath = entry ? `/${entry.file}` : "/assets/index.js";
|
|
1615
|
+
if (entry?.css?.[0]) {
|
|
1616
|
+
cssPath = `/${entry.css[0]}`;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
const body = renderTemplate({
|
|
1620
|
+
title: basename(defaultPath),
|
|
1621
|
+
cssPath,
|
|
1622
|
+
jsPath,
|
|
1623
|
+
documentHtml: html,
|
|
1624
|
+
inlineData,
|
|
1625
|
+
isDev,
|
|
1626
|
+
fontFamily: settings.fontFamily
|
|
1627
|
+
});
|
|
1628
|
+
if (!isDev) {
|
|
1629
|
+
pageCache = body;
|
|
1630
|
+
pageCacheGz = Bun.gzipSync(new TextEncoder().encode(body));
|
|
1631
|
+
}
|
|
1632
|
+
if (acceptGzip) {
|
|
1633
|
+
const gz = pageCacheGz ?? Bun.gzipSync(new TextEncoder().encode(body));
|
|
1634
|
+
return new Response(gz, {
|
|
1635
|
+
headers: {
|
|
1636
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1637
|
+
"Content-Encoding": "gzip"
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return new Response(body, {
|
|
1642
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
1643
|
+
});
|
|
1644
|
+
} catch (err) {
|
|
1645
|
+
console.error("Failed to serve app page:", err);
|
|
1646
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
function watchFile(targetPath) {
|
|
1650
|
+
try {
|
|
1651
|
+
const watcher = watch(targetPath, async (eventType) => {
|
|
1652
|
+
if (eventType !== "change" && eventType !== "rename")
|
|
1653
|
+
return;
|
|
1654
|
+
const state = fileMap.get(targetPath);
|
|
1655
|
+
if (!state)
|
|
1656
|
+
return;
|
|
1657
|
+
if (state.debounceTimer)
|
|
1658
|
+
clearTimeout(state.debounceTimer);
|
|
1659
|
+
state.debounceTimer = setTimeout(async () => {
|
|
1660
|
+
try {
|
|
1661
|
+
const newContent = await fs.readFile(targetPath, "utf-8");
|
|
1662
|
+
if (!state.isLoaded || newContent !== state.content) {
|
|
1663
|
+
state.content = newContent;
|
|
1664
|
+
state.renderedHtml = null;
|
|
1665
|
+
state.headings = null;
|
|
1666
|
+
state.isLoaded = true;
|
|
1667
|
+
invalidateResolvedComments(targetPath);
|
|
1668
|
+
invalidatePageCache();
|
|
1669
|
+
console.log(`File changed: ${basename(targetPath)}`);
|
|
1670
|
+
sendEvent({ type: "document-updated", path: targetPath });
|
|
1671
|
+
}
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
1674
|
+
await rewatch(targetPath);
|
|
1675
|
+
} else {
|
|
1676
|
+
console.error(`Failed to read updated file ${targetPath}:`, err);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}, 100);
|
|
1680
|
+
});
|
|
1681
|
+
async function rewatch(filePath) {
|
|
1682
|
+
const maxRetries = 10;
|
|
1683
|
+
const retryInterval = 200;
|
|
1684
|
+
for (let i = 0;i < maxRetries; i++) {
|
|
1685
|
+
await new Promise((r) => setTimeout(r, retryInterval));
|
|
1686
|
+
try {
|
|
1687
|
+
await fs.access(filePath);
|
|
1688
|
+
try {
|
|
1689
|
+
watcher.close();
|
|
1690
|
+
} catch {}
|
|
1691
|
+
const idx = watchers.indexOf(watcher);
|
|
1692
|
+
const newWatcher = watchFile(filePath);
|
|
1693
|
+
if (newWatcher) {
|
|
1694
|
+
if (idx >= 0)
|
|
1695
|
+
watchers[idx] = newWatcher;
|
|
1696
|
+
else
|
|
1697
|
+
watchers.push(newWatcher);
|
|
1698
|
+
}
|
|
1699
|
+
const state = fileMap.get(filePath);
|
|
1700
|
+
if (state) {
|
|
1701
|
+
const newContent = await fs.readFile(filePath, "utf-8");
|
|
1702
|
+
if (!state.isLoaded || newContent !== state.content) {
|
|
1703
|
+
state.content = newContent;
|
|
1704
|
+
state.renderedHtml = null;
|
|
1705
|
+
state.headings = null;
|
|
1706
|
+
state.isLoaded = true;
|
|
1707
|
+
invalidateResolvedComments(filePath);
|
|
1708
|
+
invalidatePageCache();
|
|
1709
|
+
console.log(`File changed: ${basename(filePath)}`);
|
|
1710
|
+
sendEvent({ type: "document-updated", path: filePath });
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return;
|
|
1714
|
+
} catch {}
|
|
1715
|
+
}
|
|
1716
|
+
console.warn(`File did not reappear after rename: ${filePath}`);
|
|
1717
|
+
}
|
|
1718
|
+
return watcher;
|
|
1719
|
+
} catch (err) {
|
|
1720
|
+
console.warn(`File watching not available for ${targetPath}:`, err);
|
|
1721
|
+
return null;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
const watchers = [];
|
|
1725
|
+
const server = Bun.serve({
|
|
1726
|
+
port: options.port,
|
|
1727
|
+
hostname: options.host,
|
|
1728
|
+
idleTimeout: 255,
|
|
1729
|
+
async fetch(req) {
|
|
1730
|
+
const url = new URL(req.url);
|
|
1731
|
+
const { pathname } = url;
|
|
1732
|
+
const method = req.method;
|
|
1733
|
+
if (pathname === "/api/documents" && method === "GET") {
|
|
1734
|
+
const files = fileOrder.map((fp) => ({
|
|
1735
|
+
path: fp,
|
|
1736
|
+
fileName: basename(fp)
|
|
1737
|
+
}));
|
|
1738
|
+
return json({
|
|
1739
|
+
files,
|
|
1740
|
+
clean: options.clean || false,
|
|
1741
|
+
workingDirectory: process.cwd()
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
if (pathname === "/api/documents" && method === "POST") {
|
|
1745
|
+
try {
|
|
1746
|
+
const { path: requestedPath } = await req.json();
|
|
1747
|
+
if (!requestedPath || typeof requestedPath !== "string") {
|
|
1748
|
+
return errorResponse("Missing 'path' field", 400);
|
|
1749
|
+
}
|
|
1750
|
+
let filePath;
|
|
1751
|
+
try {
|
|
1752
|
+
filePath = await canonicalPath(requestedPath);
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
1755
|
+
return errorResponse(`File not found: ${requestedPath}`, 404);
|
|
1756
|
+
}
|
|
1757
|
+
throw err;
|
|
1758
|
+
}
|
|
1759
|
+
if (!isMarkdownFile(filePath)) {
|
|
1760
|
+
return errorResponse(`Unsupported file type: ${filePath} (expected .md or .markdown)`, 400);
|
|
1761
|
+
}
|
|
1762
|
+
const existingState = fileMap.get(filePath);
|
|
1763
|
+
if (existingState) {
|
|
1764
|
+
return json({
|
|
1765
|
+
path: filePath,
|
|
1766
|
+
fileName: basename(filePath),
|
|
1767
|
+
status: "present"
|
|
1768
|
+
});
|
|
1769
|
+
} else {
|
|
1770
|
+
fileMap.set(filePath, {
|
|
1771
|
+
content: null,
|
|
1772
|
+
renderedHtml: null,
|
|
1773
|
+
headings: null,
|
|
1774
|
+
isLoaded: false,
|
|
1775
|
+
debounceTimer: null
|
|
1776
|
+
});
|
|
1777
|
+
fileOrder.push(filePath);
|
|
1778
|
+
const watcher = watchFile(filePath);
|
|
1779
|
+
if (watcher)
|
|
1780
|
+
watchers.push(watcher);
|
|
1781
|
+
sendEvent({
|
|
1782
|
+
type: "document-added",
|
|
1783
|
+
path: filePath,
|
|
1784
|
+
fileName: basename(filePath)
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
return json({
|
|
1788
|
+
path: filePath,
|
|
1789
|
+
fileName: basename(filePath),
|
|
1790
|
+
status: "added"
|
|
1791
|
+
});
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
console.error("Failed to add document:", err);
|
|
1794
|
+
return errorResponse("Failed to add document", 500);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
if (pathname === "/api/document/task" && method === "PATCH") {
|
|
1798
|
+
try {
|
|
1799
|
+
const body = await req.json();
|
|
1800
|
+
if (!body?.path || typeof body.index !== "number" || typeof body.checked !== "boolean") {
|
|
1801
|
+
return errorResponse("path, index, checked required", 400);
|
|
1802
|
+
}
|
|
1803
|
+
let filePath;
|
|
1804
|
+
try {
|
|
1805
|
+
filePath = await canonicalPath(body.path);
|
|
1806
|
+
} catch {
|
|
1807
|
+
return errorResponse("file not loaded", 404);
|
|
1808
|
+
}
|
|
1809
|
+
if (!fileMap.has(filePath)) {
|
|
1810
|
+
return errorResponse("file not loaded", 404);
|
|
1811
|
+
}
|
|
1812
|
+
const result = await withSourceLock(filePath, async () => {
|
|
1813
|
+
const current = await fs.readFile(filePath, "utf-8");
|
|
1814
|
+
const updated = toggleTaskInSource(current, body.index, body.checked);
|
|
1815
|
+
if (updated === null)
|
|
1816
|
+
return { status: 400 };
|
|
1817
|
+
if (updated === current)
|
|
1818
|
+
return { status: 200, body: { status: "unchanged" } };
|
|
1819
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
1820
|
+
await fs.writeFile(tmpPath, updated, "utf-8");
|
|
1821
|
+
await fs.rename(tmpPath, filePath);
|
|
1822
|
+
return { status: 200, body: { status: "ok" } };
|
|
1823
|
+
});
|
|
1824
|
+
if (result.status === 400) {
|
|
1825
|
+
return errorResponse("task index out of range", 400);
|
|
1826
|
+
}
|
|
1827
|
+
return json(result.body);
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
console.error("Failed to toggle task:", err);
|
|
1830
|
+
return errorResponse("toggle failed", 500);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
if (pathname === "/api/document" && method === "GET") {
|
|
1834
|
+
const ctxOrRes = requireContext(url);
|
|
1835
|
+
if (ctxOrRes instanceof Response)
|
|
1836
|
+
return ctxOrRes;
|
|
1837
|
+
const { html, headings } = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
1838
|
+
return json({
|
|
1839
|
+
html,
|
|
1840
|
+
headings,
|
|
1841
|
+
filePath: ctxOrRes.filePath,
|
|
1842
|
+
fileName: basename(ctxOrRes.filePath),
|
|
1843
|
+
clean: options.clean || false
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
if (pathname === "/api/document/stream" && method === "GET") {
|
|
1847
|
+
return createDocumentStream(sseClients);
|
|
1848
|
+
}
|
|
1849
|
+
if (pathname === "/api/health" && method === "GET") {
|
|
1850
|
+
return json({ status: "ok" });
|
|
1851
|
+
}
|
|
1852
|
+
if (pathname === "/api/heartbeat" && method === "GET") {
|
|
1853
|
+
return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
|
|
1854
|
+
}
|
|
1855
|
+
if (pathname === "/api/comments" && method === "GET") {
|
|
1856
|
+
const ctxOrRes = requireContext(url);
|
|
1857
|
+
if (ctxOrRes instanceof Response)
|
|
1858
|
+
return ctxOrRes;
|
|
1859
|
+
const rendered = await ensureRenderedHtml(ctxOrRes.filePath);
|
|
1860
|
+
return getComments(ctxOrRes, rendered.html);
|
|
1861
|
+
}
|
|
1862
|
+
if (pathname === "/api/comments/raw" && method === "GET") {
|
|
1863
|
+
const ctxOrRes = requireContext(url);
|
|
1864
|
+
if (ctxOrRes instanceof Response)
|
|
1865
|
+
return ctxOrRes;
|
|
1866
|
+
return getRawComments(ctxOrRes);
|
|
1867
|
+
}
|
|
1868
|
+
if (pathname === "/api/comments" && method === "POST") {
|
|
1869
|
+
const ctxOrRes = requireContext(url);
|
|
1870
|
+
if (ctxOrRes instanceof Response)
|
|
1871
|
+
return ctxOrRes;
|
|
1872
|
+
invalidatePageCache();
|
|
1873
|
+
return addComment(ctxOrRes, req);
|
|
1874
|
+
}
|
|
1875
|
+
if (pathname === "/api/comments" && method === "DELETE") {
|
|
1876
|
+
const ctxOrRes = requireContext(url);
|
|
1877
|
+
if (ctxOrRes instanceof Response)
|
|
1878
|
+
return ctxOrRes;
|
|
1879
|
+
invalidatePageCache();
|
|
1880
|
+
return clearComments(ctxOrRes);
|
|
1881
|
+
}
|
|
1882
|
+
const commentId = extractCommentId(pathname);
|
|
1883
|
+
if (commentId) {
|
|
1884
|
+
const ctxOrRes = requireContext(url);
|
|
1885
|
+
if (ctxOrRes instanceof Response)
|
|
1886
|
+
return ctxOrRes;
|
|
1887
|
+
invalidatePageCache();
|
|
1888
|
+
if (pathname.endsWith("/reanchor") && method === "PUT") {
|
|
1889
|
+
return reanchorComment(ctxOrRes, req, commentId);
|
|
1890
|
+
}
|
|
1891
|
+
if (method === "PUT") {
|
|
1892
|
+
return updateComment(ctxOrRes, req, commentId);
|
|
1893
|
+
}
|
|
1894
|
+
if (method === "DELETE") {
|
|
1895
|
+
return deleteComment(ctxOrRes, commentId);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
if (pathname === "/api/settings" && method === "GET") {
|
|
1899
|
+
return getSettingsRoute();
|
|
1900
|
+
}
|
|
1901
|
+
if (pathname === "/api/settings" && method === "PUT") {
|
|
1902
|
+
return updateSettingsRoute(req);
|
|
1903
|
+
}
|
|
1904
|
+
if (pathname === "/") {
|
|
1905
|
+
return serveAppPage(req);
|
|
1906
|
+
}
|
|
1907
|
+
if (isDev) {
|
|
1908
|
+
return proxyToVite(req, pathname, url.search);
|
|
1909
|
+
}
|
|
1910
|
+
return serveStaticFile(distPath, pathname);
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
for (const fp of fileOrder) {
|
|
1914
|
+
const watcher = watchFile(fp);
|
|
1915
|
+
if (watcher)
|
|
1916
|
+
watchers.push(watcher);
|
|
1917
|
+
}
|
|
1918
|
+
return { server, watchers };
|
|
1919
|
+
}
|
|
1920
|
+
async function startServer(options) {
|
|
1921
|
+
getShiki();
|
|
1922
|
+
const MAX_PORT = 65535;
|
|
1923
|
+
for (let port = options.port;port <= MAX_PORT; port++) {
|
|
1924
|
+
try {
|
|
1925
|
+
const { server, watchers } = createServer({ ...options, port });
|
|
1926
|
+
const displayHost = options.host === "0.0.0.0" ? "localhost" : options.host;
|
|
1927
|
+
let stopVite;
|
|
1928
|
+
if (false) {}
|
|
1929
|
+
const originalStop = server.stop.bind(server);
|
|
1930
|
+
const wrappedServer = {
|
|
1931
|
+
stop() {
|
|
1932
|
+
disposeMermaidWorker();
|
|
1933
|
+
stopVite?.();
|
|
1934
|
+
for (const w of watchers)
|
|
1935
|
+
w.close();
|
|
1936
|
+
originalStop();
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
const actualPort = server.port ?? port;
|
|
1940
|
+
await writeServerInfo(actualPort);
|
|
1941
|
+
return {
|
|
1942
|
+
port: actualPort,
|
|
1943
|
+
url: `http://${displayHost}:${actualPort}`,
|
|
1944
|
+
server: wrappedServer
|
|
1945
|
+
};
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
if (isErrnoException(err) && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
|
|
1948
|
+
console.log(`Port ${port} is busy, trying ${port + 1}...`);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
throw err;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
throw new Error(`No available port found starting from ${options.port}`);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// src/cli.ts
|
|
1958
|
+
var program = new Command;
|
|
1959
|
+
function isPermissionError(err) {
|
|
1960
|
+
return err instanceof Error && "code" in err && err.code === "EACCES";
|
|
1961
|
+
}
|
|
1962
|
+
var READIT_DIR = join4(os3.homedir(), ".readit");
|
|
1963
|
+
var SERVER_INFO_PATH2 = join4(READIT_DIR, "server.json");
|
|
1964
|
+
var SERVER_LOCK_PATH = join4(READIT_DIR, "server.lock");
|
|
1965
|
+
var SERVER_LOCK_MAX_AGE_MS = 30000;
|
|
1966
|
+
var SERVER_LOCK_TIMEOUT_MS = 1e4;
|
|
1967
|
+
var SERVER_LOCK_WAIT_MS = 100;
|
|
1968
|
+
function isAlive(pid) {
|
|
1969
|
+
try {
|
|
1970
|
+
process.kill(pid, 0);
|
|
1971
|
+
return true;
|
|
1972
|
+
} catch {
|
|
1973
|
+
return false;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
function getErrnoCode(err) {
|
|
1977
|
+
return err instanceof Error && "code" in err ? err.code : undefined;
|
|
1978
|
+
}
|
|
1979
|
+
function sleep(ms) {
|
|
1980
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
1981
|
+
}
|
|
1982
|
+
async function clearStaleServerLock() {
|
|
1983
|
+
try {
|
|
1984
|
+
const [stats, content] = await Promise.all([
|
|
1985
|
+
fs2.stat(SERVER_LOCK_PATH),
|
|
1986
|
+
fs2.readFile(SERVER_LOCK_PATH, "utf-8").catch(() => "")
|
|
1987
|
+
]);
|
|
1988
|
+
const age = Date.now() - stats.mtimeMs;
|
|
1989
|
+
let pid;
|
|
1990
|
+
if (content) {
|
|
1991
|
+
try {
|
|
1992
|
+
const lock = JSON.parse(content);
|
|
1993
|
+
pid = lock.pid;
|
|
1994
|
+
} catch {}
|
|
1995
|
+
}
|
|
1996
|
+
if (age > SERVER_LOCK_MAX_AGE_MS || pid !== undefined && !isAlive(pid)) {
|
|
1997
|
+
await fs2.unlink(SERVER_LOCK_PATH).catch(() => {});
|
|
1998
|
+
}
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
if (getErrnoCode(err) !== "ENOENT")
|
|
2001
|
+
throw err;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
async function withServerLock(run) {
|
|
2005
|
+
await fs2.mkdir(READIT_DIR, { recursive: true });
|
|
2006
|
+
const start = Date.now();
|
|
2007
|
+
while (true) {
|
|
2008
|
+
let handle;
|
|
2009
|
+
try {
|
|
2010
|
+
handle = await fs2.open(SERVER_LOCK_PATH, "wx");
|
|
2011
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }), "utf-8");
|
|
2012
|
+
try {
|
|
2013
|
+
return await run();
|
|
2014
|
+
} finally {
|
|
2015
|
+
await handle.close().catch(() => {});
|
|
2016
|
+
await fs2.unlink(SERVER_LOCK_PATH).catch(() => {});
|
|
2017
|
+
}
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
if (handle) {
|
|
2020
|
+
await handle.close().catch(() => {});
|
|
2021
|
+
}
|
|
2022
|
+
if (getErrnoCode(err) !== "EEXIST") {
|
|
2023
|
+
throw err;
|
|
2024
|
+
}
|
|
2025
|
+
await clearStaleServerLock();
|
|
2026
|
+
if (Date.now() - start >= SERVER_LOCK_TIMEOUT_MS) {
|
|
2027
|
+
throw new Error("Timed out waiting for readit server lock");
|
|
2028
|
+
}
|
|
2029
|
+
await sleep(SERVER_LOCK_WAIT_MS);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
async function discoverServer() {
|
|
2034
|
+
try {
|
|
2035
|
+
const content = readFileSync(SERVER_INFO_PATH2, "utf-8");
|
|
2036
|
+
const info = JSON.parse(content);
|
|
2037
|
+
if (!isAlive(info.pid)) {
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
try {
|
|
2041
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/health`);
|
|
2042
|
+
if (!res.ok)
|
|
2043
|
+
return null;
|
|
2044
|
+
} catch {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
return info;
|
|
2048
|
+
} catch {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
async function attachFiles(server, files) {
|
|
2053
|
+
for (const file of files) {
|
|
2054
|
+
try {
|
|
2055
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/api/documents`, {
|
|
2056
|
+
method: "POST",
|
|
2057
|
+
headers: { "Content-Type": "application/json" },
|
|
2058
|
+
body: JSON.stringify({ path: file.path })
|
|
2059
|
+
});
|
|
2060
|
+
if (!res.ok) {
|
|
2061
|
+
const data2 = await res.json();
|
|
2062
|
+
console.error(`error: failed to add ${file.path}: ${data2.error}`);
|
|
2063
|
+
process.exit(1);
|
|
2064
|
+
}
|
|
2065
|
+
const data = await res.json();
|
|
2066
|
+
if (data.status === "added") {
|
|
2067
|
+
console.log(`Added: ${data.fileName}`);
|
|
2068
|
+
} else {
|
|
2069
|
+
console.log(`Present: ${data.fileName}`);
|
|
2070
|
+
}
|
|
2071
|
+
} catch (err) {
|
|
2072
|
+
console.error("error: failed to connect to server:", err instanceof Error ? err.message : err);
|
|
2073
|
+
process.exit(1);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
async function getServerTarget(files, port, host) {
|
|
2078
|
+
return withServerLock(async () => {
|
|
2079
|
+
const server = await discoverServer();
|
|
2080
|
+
if (server) {
|
|
2081
|
+
return {
|
|
2082
|
+
kind: "existing",
|
|
2083
|
+
port: server.port,
|
|
2084
|
+
url: `http://127.0.0.1:${server.port}`
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
const started = await startServer({ files, port, host });
|
|
2088
|
+
return {
|
|
2089
|
+
kind: "started",
|
|
2090
|
+
port: started.port,
|
|
2091
|
+
url: started.url,
|
|
2092
|
+
server: started.server
|
|
2093
|
+
};
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
function findCommentFiles(dir) {
|
|
2097
|
+
const results = [];
|
|
2098
|
+
try {
|
|
2099
|
+
const entries = readdirSync(dir);
|
|
2100
|
+
for (const entry of entries) {
|
|
2101
|
+
const fullPath = join4(dir, entry);
|
|
2102
|
+
try {
|
|
2103
|
+
const lstat = lstatSync(fullPath);
|
|
2104
|
+
if (lstat.isSymbolicLink())
|
|
2105
|
+
continue;
|
|
2106
|
+
if (lstat.isDirectory()) {
|
|
2107
|
+
results.push(...findCommentFiles(fullPath));
|
|
2108
|
+
} else if (entry.endsWith(".comments.md")) {
|
|
2109
|
+
results.push(fullPath);
|
|
2110
|
+
}
|
|
2111
|
+
} catch (err) {
|
|
2112
|
+
if (isPermissionError(err)) {
|
|
2113
|
+
console.warn(`Warning: Permission denied: ${fullPath}`);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
} catch (err) {
|
|
2118
|
+
if (isPermissionError(err)) {
|
|
2119
|
+
console.warn(`Warning: Permission denied: ${dir}`);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
return results;
|
|
2123
|
+
}
|
|
2124
|
+
function findReviewableFiles(dir) {
|
|
2125
|
+
const results = [];
|
|
2126
|
+
try {
|
|
2127
|
+
const entries = readdirSync(dir);
|
|
2128
|
+
for (const entry of entries) {
|
|
2129
|
+
if (entry.startsWith(".") || entry === "node_modules")
|
|
2130
|
+
continue;
|
|
2131
|
+
const fullPath = join4(dir, entry);
|
|
2132
|
+
try {
|
|
2133
|
+
const lstat = lstatSync(fullPath);
|
|
2134
|
+
if (lstat.isSymbolicLink())
|
|
2135
|
+
continue;
|
|
2136
|
+
if (lstat.isDirectory()) {
|
|
2137
|
+
results.push(...findReviewableFiles(fullPath));
|
|
2138
|
+
} else if (isMarkdownFile(entry)) {
|
|
2139
|
+
results.push({ filePath: fullPath });
|
|
2140
|
+
}
|
|
2141
|
+
} catch (err) {
|
|
2142
|
+
if (isPermissionError(err)) {
|
|
2143
|
+
console.warn(`Warning: Permission denied: ${fullPath}`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
if (isPermissionError(err)) {
|
|
2149
|
+
console.warn(`Warning: Permission denied: ${dir}`);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
return results;
|
|
2153
|
+
}
|
|
2154
|
+
function resolveFiles(args) {
|
|
2155
|
+
const seen = new Set;
|
|
2156
|
+
const files = [];
|
|
2157
|
+
for (const arg of args) {
|
|
2158
|
+
const inputPath = resolve3(process.cwd(), arg);
|
|
2159
|
+
if (!existsSync(inputPath)) {
|
|
2160
|
+
console.error(`error: not found: ${inputPath}`);
|
|
2161
|
+
process.exit(1);
|
|
2162
|
+
}
|
|
2163
|
+
const filePath = realpathSync(inputPath);
|
|
2164
|
+
const stat3 = statSync(filePath);
|
|
2165
|
+
if (stat3.isDirectory()) {
|
|
2166
|
+
const found = findReviewableFiles(filePath);
|
|
2167
|
+
for (const entry of found) {
|
|
2168
|
+
if (!seen.has(entry.filePath)) {
|
|
2169
|
+
seen.add(entry.filePath);
|
|
2170
|
+
files.push(entry);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
} else {
|
|
2174
|
+
if (seen.has(filePath))
|
|
2175
|
+
continue;
|
|
2176
|
+
if (!isMarkdownFile(filePath)) {
|
|
2177
|
+
console.error(`error: unsupported file type: ${arg} (expected .md or .markdown)`);
|
|
2178
|
+
process.exit(1);
|
|
2179
|
+
}
|
|
2180
|
+
seen.add(filePath);
|
|
2181
|
+
files.push({ filePath });
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return files;
|
|
2185
|
+
}
|
|
2186
|
+
var SETTINGS_PATH2 = join4(os3.homedir(), ".readit", "settings.json");
|
|
2187
|
+
function isOnboarded() {
|
|
2188
|
+
try {
|
|
2189
|
+
const content = readFileSync(SETTINGS_PATH2, "utf-8");
|
|
2190
|
+
const settings = JSON.parse(content);
|
|
2191
|
+
return settings.onboarded === true;
|
|
2192
|
+
} catch {
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
async function markOnboarded() {
|
|
2197
|
+
let settings = {};
|
|
2198
|
+
try {
|
|
2199
|
+
const content = readFileSync(SETTINGS_PATH2, "utf-8");
|
|
2200
|
+
settings = JSON.parse(content);
|
|
2201
|
+
} catch {}
|
|
2202
|
+
settings.onboarded = true;
|
|
2203
|
+
const dir = join4(os3.homedir(), ".readit");
|
|
2204
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
2205
|
+
await fs2.writeFile(SETTINGS_PATH2, JSON.stringify(settings, null, 2), "utf-8");
|
|
2206
|
+
}
|
|
2207
|
+
var WELCOME_CONTENT = `# Welcome to readit
|
|
2208
|
+
|
|
2209
|
+
A simple tool for reviewing markdown with inline comments.
|
|
2210
|
+
|
|
2211
|
+
---
|
|
2212
|
+
|
|
2213
|
+
## How It Works
|
|
2214
|
+
|
|
2215
|
+
readit follows a simple loop: **read \u2192 comment \u2192 extract**.
|
|
2216
|
+
|
|
2217
|
+
### 1. Read
|
|
2218
|
+
|
|
2219
|
+
You're already doing this. Open any markdown file with \`readit <file.md>\` and it renders in your browser with a clean reading experience.
|
|
2220
|
+
|
|
2221
|
+
### 2. Comment
|
|
2222
|
+
|
|
2223
|
+
Select any text to add a comment. Try it now \u2014 **select this sentence** and type your first comment.
|
|
2224
|
+
|
|
2225
|
+
Your comments appear as margin notes next to the highlighted text, just like reviewing a document in Google Docs. Add as many as you need.
|
|
2226
|
+
|
|
2227
|
+
### 3. Extract
|
|
2228
|
+
|
|
2229
|
+
When you're done reviewing, click the menu in the top-right and choose **Copy as Prompt**. This exports all your comments in a format ready for Claude, ChatGPT, or any AI assistant.
|
|
2230
|
+
|
|
2231
|
+
You can also export as JSON if you prefer structured data.
|
|
2232
|
+
|
|
2233
|
+
---
|
|
2234
|
+
|
|
2235
|
+
## Everything is Plain Markdown
|
|
2236
|
+
|
|
2237
|
+
Your comments are saved as \`.comments.md\` files in \`~/.readit/comments/\`. No database, no lock-in \u2014 just readable markdown files you can version control, search, or edit by hand.
|
|
2238
|
+
|
|
2239
|
+
Each comment file looks something like this:
|
|
2240
|
+
|
|
2241
|
+
\`\`\`markdown
|
|
2242
|
+
## Comment 1
|
|
2243
|
+
**Selected:** "select this sentence"
|
|
2244
|
+
**Comment:** This is my first comment!
|
|
2245
|
+
**Created:** 2024-01-15T10:30:00Z
|
|
2246
|
+
\`\`\`
|
|
2247
|
+
|
|
2248
|
+
---
|
|
2249
|
+
|
|
2250
|
+
## Navigating Comments
|
|
2251
|
+
|
|
2252
|
+
Once you have multiple comments, use the navigation bar at the bottom of the screen to jump between them. You can also use keyboard shortcuts:
|
|
2253
|
+
|
|
2254
|
+
| Shortcut | Action |
|
|
2255
|
+
|----------|--------|
|
|
2256
|
+
| \`Alt + \u2191\` | Previous comment |
|
|
2257
|
+
| \`Alt + \u2193\` | Next comment |
|
|
2258
|
+
| \`\u2318 + C\` | Copy selected text (raw) |
|
|
2259
|
+
| \`\u2318 + Shift + C\` | Copy selected text with context (for AI) |
|
|
2260
|
+
|
|
2261
|
+
---
|
|
2262
|
+
|
|
2263
|
+
## Quick Start
|
|
2264
|
+
|
|
2265
|
+
\`\`\`bash
|
|
2266
|
+
# Review a markdown file
|
|
2267
|
+
readit document.md
|
|
2268
|
+
|
|
2269
|
+
# Use a custom port
|
|
2270
|
+
readit document.md --port 3000
|
|
2271
|
+
|
|
2272
|
+
# Start fresh (clear existing comments)
|
|
2273
|
+
readit document.md --clean
|
|
2274
|
+
\`\`\`
|
|
2275
|
+
|
|
2276
|
+
---
|
|
2277
|
+
|
|
2278
|
+
## Try It Now
|
|
2279
|
+
|
|
2280
|
+
Go ahead and add a few comments to this document. When you're done, export them and see the output. That's the entire workflow \u2014 simple, transparent, and designed for reviewing AI-generated content.
|
|
2281
|
+
`;
|
|
2282
|
+
var WELCOME_PATH = join4(os3.homedir(), ".readit", "welcome.md");
|
|
2283
|
+
program.name("readit").description("Review Markdown documents with inline comments").version("0.2.0");
|
|
2284
|
+
program.command("list").description("List all files with comments").action(async () => {
|
|
2285
|
+
const readitDir = join4(os3.homedir(), ".readit", "comments");
|
|
2286
|
+
if (!existsSync(readitDir)) {
|
|
2287
|
+
console.log("No comments found.");
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const commentFiles = findCommentFiles(readitDir);
|
|
2291
|
+
if (commentFiles.length === 0) {
|
|
2292
|
+
console.log("No comments found.");
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
console.log(`
|
|
2296
|
+
Found ${commentFiles.length} file(s) with comments:
|
|
2297
|
+
`);
|
|
2298
|
+
for (const file of commentFiles) {
|
|
2299
|
+
try {
|
|
2300
|
+
const content = readFileSync(file, "utf-8");
|
|
2301
|
+
const parsed = parseCommentFile(content);
|
|
2302
|
+
const commentCount = parsed.comments.length;
|
|
2303
|
+
const sourcePath = parsed.source || "(unknown source)";
|
|
2304
|
+
console.log(` ${sourcePath}`);
|
|
2305
|
+
console.log(` ${commentCount} comment${commentCount !== 1 ? "s" : ""}`);
|
|
2306
|
+
console.log();
|
|
2307
|
+
} catch {}
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
program.command("show <file>").description("Show comments for a file").action(async (file) => {
|
|
2311
|
+
const filePath = resolve3(process.cwd(), file);
|
|
2312
|
+
const commentPath = getCommentPath(filePath);
|
|
2313
|
+
if (!existsSync(commentPath)) {
|
|
2314
|
+
console.log(`No comments found for: ${filePath}`);
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
try {
|
|
2318
|
+
const content = await fs2.readFile(commentPath, "utf-8");
|
|
2319
|
+
const parsed = parseCommentFile(content);
|
|
2320
|
+
if (parsed.comments.length === 0) {
|
|
2321
|
+
console.log(`No comments found for: ${filePath}`);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
console.log(`
|
|
2325
|
+
Comments for: ${filePath}`);
|
|
2326
|
+
console.log(`${"\u2500".repeat(60)}
|
|
2327
|
+
`);
|
|
2328
|
+
for (let i = 0;i < parsed.comments.length; i++) {
|
|
2329
|
+
const comment = parsed.comments[i];
|
|
2330
|
+
console.log(`[${i + 1}] ${comment.lineHint || "L?"}`);
|
|
2331
|
+
console.log(`Selected: "${comment.selectedText.slice(0, 80)}${comment.selectedText.length > 80 ? "..." : ""}"`);
|
|
2332
|
+
console.log(`Comment: ${comment.comment}`);
|
|
2333
|
+
console.log();
|
|
2334
|
+
}
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
console.error("error: failed to read comments:", err instanceof Error ? err.message : err);
|
|
2337
|
+
process.exit(1);
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
program.argument("[files...]", "Markdown files/directories to review").option("-p, --port <number>", "Port to run server on", "4567").option("--host <address>", "Host address to bind to", "127.0.0.1").option("--no-open", "Don't automatically open browser").option("--clean", "Clear all existing comments on startup").action(async (fileArgs, options) => {
|
|
2341
|
+
let files;
|
|
2342
|
+
if (fileArgs.length === 0) {
|
|
2343
|
+
if (isOnboarded()) {
|
|
2344
|
+
files = [];
|
|
2345
|
+
} else {
|
|
2346
|
+
files = [
|
|
2347
|
+
{
|
|
2348
|
+
content: WELCOME_CONTENT,
|
|
2349
|
+
filePath: WELCOME_PATH
|
|
2350
|
+
}
|
|
2351
|
+
];
|
|
2352
|
+
}
|
|
2353
|
+
} else {
|
|
2354
|
+
files = resolveFiles(fileArgs);
|
|
2355
|
+
if (files.length === 0) {
|
|
2356
|
+
console.error("error: no reviewable files found");
|
|
2357
|
+
process.exit(1);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
const preferredPort = Number.parseInt(options.port, 10);
|
|
2361
|
+
if (Number.isNaN(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
|
2362
|
+
console.error(`error: invalid port number: ${options.port}`);
|
|
2363
|
+
process.exit(1);
|
|
2364
|
+
}
|
|
2365
|
+
let previousPort;
|
|
2366
|
+
try {
|
|
2367
|
+
const info = JSON.parse(readFileSync(SERVER_INFO_PATH2, "utf-8"));
|
|
2368
|
+
if (!isAlive(info.pid)) {
|
|
2369
|
+
previousPort = info.port;
|
|
2370
|
+
}
|
|
2371
|
+
} catch {}
|
|
2372
|
+
try {
|
|
2373
|
+
const { url, server } = await startServer({
|
|
2374
|
+
files,
|
|
2375
|
+
port: preferredPort,
|
|
2376
|
+
host: options.host,
|
|
2377
|
+
clean: options.clean
|
|
2378
|
+
});
|
|
2379
|
+
if (files.length === 0) {
|
|
2380
|
+
console.log(`
|
|
2381
|
+
readit - Document Review Tool
|
|
2382
|
+
|
|
2383
|
+
URL: ${url}
|
|
2384
|
+
|
|
2385
|
+
No files specified. Add files with:
|
|
2386
|
+
readit open <file.md>
|
|
2387
|
+
|
|
2388
|
+
Server running. Press Ctrl+C to stop.
|
|
2389
|
+
`);
|
|
2390
|
+
} else {
|
|
2391
|
+
const fileList = files.map((f) => ` ${f.filePath}`);
|
|
2392
|
+
console.log(`
|
|
2393
|
+
readit - Document Review Tool
|
|
2394
|
+
|
|
2395
|
+
${files.length === 1 ? "File:" : "Files:"}
|
|
2396
|
+
${fileList.join(`
|
|
2397
|
+
`)}
|
|
2398
|
+
URL: ${url}
|
|
2399
|
+
|
|
2400
|
+
Server running. Close browser tab to stop.
|
|
2401
|
+
Press Ctrl+C to force stop.
|
|
2402
|
+
`);
|
|
2403
|
+
}
|
|
2404
|
+
const browserLikelyOpen = previousPort === preferredPort || false;
|
|
2405
|
+
if (options.open && !browserLikelyOpen) {
|
|
2406
|
+
open2(url);
|
|
2407
|
+
}
|
|
2408
|
+
if (fileArgs.length === 0) {
|
|
2409
|
+
await markOnboarded();
|
|
2410
|
+
}
|
|
2411
|
+
process.on("SIGINT", async () => {
|
|
2412
|
+
console.log(`
|
|
2413
|
+
|
|
2414
|
+
Shutting down...`);
|
|
2415
|
+
server.stop();
|
|
2416
|
+
await removeServerInfo();
|
|
2417
|
+
process.exit(0);
|
|
2418
|
+
});
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
console.error("error: failed to start server:", error instanceof Error ? error.message : error);
|
|
2421
|
+
process.exit(1);
|
|
2422
|
+
}
|
|
2423
|
+
});
|
|
2424
|
+
program.command("open").argument("<files...>", "Markdown files to add to running server").description("Add files to a running readit server, or start a new one").option("-p, --port <number>", "Port for new server (if starting)", "4567").option("--host <address>", "Host for new server (if starting)", "127.0.0.1").action(async (fileArgs, options) => {
|
|
2425
|
+
const resolvedFiles = [];
|
|
2426
|
+
for (const arg of fileArgs) {
|
|
2427
|
+
const inputPath = resolve3(process.cwd(), arg);
|
|
2428
|
+
if (!existsSync(inputPath)) {
|
|
2429
|
+
console.error(`error: not found: ${inputPath}`);
|
|
2430
|
+
process.exit(1);
|
|
2431
|
+
}
|
|
2432
|
+
const filePath = realpathSync(inputPath);
|
|
2433
|
+
if (!isMarkdownFile(filePath)) {
|
|
2434
|
+
console.error(`error: unsupported file type: ${arg} (expected .md or .markdown)`);
|
|
2435
|
+
process.exit(1);
|
|
2436
|
+
}
|
|
2437
|
+
resolvedFiles.push({ path: filePath });
|
|
2438
|
+
}
|
|
2439
|
+
const files = resolvedFiles.map((f) => ({
|
|
2440
|
+
filePath: f.path
|
|
2441
|
+
}));
|
|
2442
|
+
const preferredPort = Number.parseInt(options.port, 10);
|
|
2443
|
+
try {
|
|
2444
|
+
const target = await getServerTarget(files, preferredPort, options.host);
|
|
2445
|
+
if (target.kind === "existing") {
|
|
2446
|
+
await attachFiles({ port: target.port, pid: process.pid }, resolvedFiles);
|
|
2447
|
+
console.log(`
|
|
2448
|
+
Server: ${target.url}`);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
const fileList = files.map((f) => ` ${f.filePath}`);
|
|
2452
|
+
console.log(`
|
|
2453
|
+
readit - Document Review Tool
|
|
2454
|
+
|
|
2455
|
+
${files.length === 1 ? "File:" : "Files:"}
|
|
2456
|
+
${fileList.join(`
|
|
2457
|
+
`)}
|
|
2458
|
+
URL: ${target.url}
|
|
2459
|
+
|
|
2460
|
+
Server running. Close browser tab to stop.
|
|
2461
|
+
Press Ctrl+C to force stop.
|
|
2462
|
+
`);
|
|
2463
|
+
open2(target.url);
|
|
2464
|
+
process.on("SIGINT", async () => {
|
|
2465
|
+
console.log(`
|
|
2466
|
+
|
|
2467
|
+
Shutting down...`);
|
|
2468
|
+
target.server?.stop();
|
|
2469
|
+
await removeServerInfo();
|
|
2470
|
+
process.exit(0);
|
|
2471
|
+
});
|
|
2472
|
+
} catch (error) {
|
|
2473
|
+
console.error("error: failed to start server:", error instanceof Error ? error.message : error);
|
|
2474
|
+
process.exit(1);
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
program.command("completion").argument("[shell]", "Shell type (zsh, bash, fish)", "zsh").description("Output shell completion and integration script").action((shell) => {
|
|
2478
|
+
const shellDir = join4(import.meta.dir, "..", "shell");
|
|
2479
|
+
switch (shell) {
|
|
2480
|
+
case "zsh": {
|
|
2481
|
+
const widgetPath = join4(shellDir, "readit.zsh");
|
|
2482
|
+
const compPath = join4(shellDir, "_readit");
|
|
2483
|
+
if (!existsSync(widgetPath) || !existsSync(compPath)) {
|
|
2484
|
+
console.log(generateInlineZshCompletion());
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
const compdefContent = readFileSync(compPath, "utf-8");
|
|
2488
|
+
const widgetContent = readFileSync(widgetPath, "utf-8");
|
|
2489
|
+
const lines = [];
|
|
2490
|
+
lines.push("# readit shell integration for zsh");
|
|
2491
|
+
lines.push('# Add to your .zshrc: eval "$(readit completion zsh)"');
|
|
2492
|
+
lines.push("");
|
|
2493
|
+
lines.push("# \u2500\u2500 _readit compdef (autoloaded) \u2500\u2500");
|
|
2494
|
+
lines.push("# This handles: subcommand/option completion + @ file autocomplete");
|
|
2495
|
+
lines.push("# Renders [file.md] in a native multi-column grid via compadd");
|
|
2496
|
+
lines.push("");
|
|
2497
|
+
lines.push(compdefContent.replace(/^#compdef readit\n/, `autoload -Uz _readit
|
|
2498
|
+
_readit() {
|
|
2499
|
+
`).replace(/\n_readit "\$@"\n?$/, `
|
|
2500
|
+
}
|
|
2501
|
+
`));
|
|
2502
|
+
lines.push("");
|
|
2503
|
+
lines.push("# \u2500\u2500 readit.zsh (sourced) \u2500\u2500");
|
|
2504
|
+
lines.push("# This handles: @[...] bracket stripping on Enter + syntax highlighting");
|
|
2505
|
+
lines.push("");
|
|
2506
|
+
lines.push(widgetContent.replace(/^#!/, "#").replace(/\n\(\( \$\+ functions\[_readit_plugin_loaded\] \)\)/, ""));
|
|
2507
|
+
console.log(lines.join(`
|
|
2508
|
+
`));
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
case "bash":
|
|
2512
|
+
console.log(generateBashCompletion());
|
|
2513
|
+
break;
|
|
2514
|
+
case "fish":
|
|
2515
|
+
console.log(generateFishCompletion());
|
|
2516
|
+
break;
|
|
2517
|
+
default:
|
|
2518
|
+
console.error(`error: unsupported shell: ${shell}`);
|
|
2519
|
+
console.error("Supported shells: zsh, bash, fish");
|
|
2520
|
+
process.exit(1);
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
program.parse();
|
|
2524
|
+
function generateInlineZshCompletion() {
|
|
2525
|
+
return `
|
|
2526
|
+
#compdef readit
|
|
2527
|
+
|
|
2528
|
+
_readit_markdown_files() {
|
|
2529
|
+
local -a files
|
|
2530
|
+
files=( \${(f)"$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) -not -path '*/\\.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')"} )
|
|
2531
|
+
_describe -t files 'markdown files' files
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
_readit() {
|
|
2535
|
+
local context state state_descr line
|
|
2536
|
+
typeset -A opt_args
|
|
2537
|
+
_arguments -C '1:command:->cmd_or_files' '*::arg:->args'
|
|
2538
|
+
case "$state" in
|
|
2539
|
+
cmd_or_files)
|
|
2540
|
+
local -a commands=(
|
|
2541
|
+
'open:Add files to running server'
|
|
2542
|
+
'list:List files with comments'
|
|
2543
|
+
'show:Show comments for a file'
|
|
2544
|
+
'completion:Output shell completion script'
|
|
2545
|
+
)
|
|
2546
|
+
_alternative 'commands:command:compadd -a commands' 'files:markdown file:_readit_markdown_files'
|
|
2547
|
+
;;
|
|
2548
|
+
args)
|
|
2549
|
+
case "\${line[1]}" in
|
|
2550
|
+
open) _arguments '*:file:_readit_markdown_files' ;;
|
|
2551
|
+
show) _arguments '1:file:_files -g "*.md *.markdown"' ;;
|
|
2552
|
+
*) _arguments '*:file:_readit_markdown_files' ;;
|
|
2553
|
+
esac
|
|
2554
|
+
;;
|
|
2555
|
+
esac
|
|
2556
|
+
}
|
|
2557
|
+
_readit "$@"
|
|
2558
|
+
`.trim();
|
|
2559
|
+
}
|
|
2560
|
+
function generateBashCompletion() {
|
|
2561
|
+
return `
|
|
2562
|
+
# readit bash completion
|
|
2563
|
+
# Add to .bashrc: eval "$(readit completion bash)"
|
|
2564
|
+
|
|
2565
|
+
_readit_completions() {
|
|
2566
|
+
local cur prev commands
|
|
2567
|
+
COMPREPLY=()
|
|
2568
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
2569
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
2570
|
+
commands="open list show completion"
|
|
2571
|
+
|
|
2572
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
2573
|
+
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
|
2574
|
+
# Also complete markdown files
|
|
2575
|
+
local files=$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) \\
|
|
2576
|
+
-not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')
|
|
2577
|
+
COMPREPLY+=( $(compgen -W "\${files}" -- "\${cur}") )
|
|
2578
|
+
return 0
|
|
2579
|
+
fi
|
|
2580
|
+
|
|
2581
|
+
case "\${COMP_WORDS[1]}" in
|
|
2582
|
+
open|show)
|
|
2583
|
+
local files=$(find . -type f \\( -name '*.md' -o -name '*.markdown' \\) \\
|
|
2584
|
+
-not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | sed 's|^\\./||')
|
|
2585
|
+
COMPREPLY=( $(compgen -W "\${files}" -- "\${cur}") )
|
|
2586
|
+
;;
|
|
2587
|
+
completion)
|
|
2588
|
+
COMPREPLY=( $(compgen -W "zsh bash fish" -- "\${cur}") )
|
|
2589
|
+
;;
|
|
2590
|
+
esac
|
|
2591
|
+
return 0
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
complete -F _readit_completions readit
|
|
2595
|
+
`.trim();
|
|
2596
|
+
}
|
|
2597
|
+
function generateFishCompletion() {
|
|
2598
|
+
return `
|
|
2599
|
+
# readit fish completion
|
|
2600
|
+
# Add to config.fish: readit completion fish | source
|
|
2601
|
+
|
|
2602
|
+
# Disable file completions by default
|
|
2603
|
+
complete -c readit -f
|
|
2604
|
+
|
|
2605
|
+
# Subcommands
|
|
2606
|
+
complete -c readit -n '__fish_use_subcommand' -a 'open' -d 'Add files to running server'
|
|
2607
|
+
complete -c readit -n '__fish_use_subcommand' -a 'list' -d 'List files with comments'
|
|
2608
|
+
complete -c readit -n '__fish_use_subcommand' -a 'show' -d 'Show comments for a file'
|
|
2609
|
+
complete -c readit -n '__fish_use_subcommand' -a 'completion' -d 'Output shell completion script'
|
|
2610
|
+
|
|
2611
|
+
# Options
|
|
2612
|
+
complete -c readit -s p -l port -d 'Port to run server on'
|
|
2613
|
+
complete -c readit -l host -d 'Host address to bind to'
|
|
2614
|
+
complete -c readit -l no-open -d "Don't automatically open browser"
|
|
2615
|
+
complete -c readit -l clean -d 'Clear existing comments'
|
|
2616
|
+
|
|
2617
|
+
# File arguments for default command and open
|
|
2618
|
+
complete -c readit -n '__fish_use_subcommand' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
|
|
2619
|
+
complete -c readit -n '__fish_seen_subcommand_from open' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
|
|
2620
|
+
complete -c readit -n '__fish_seen_subcommand_from show' -F -a '(find . -type f \\( -name "*.md" -o -name "*.markdown" \\) -not -path "*/.*" -not -path "*/node_modules/*" 2>/dev/null | sed "s|^\\./||")'
|
|
2621
|
+
|
|
2622
|
+
# Shell completions for completion subcommand
|
|
2623
|
+
complete -c readit -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish'
|
|
2624
|
+
`.trim();
|
|
2625
|
+
}
|