Remove small markdown helper dependencies (#2467)

This commit is contained in:
Neil 2026-05-20 19:01:22 -07:00 committed by GitHub
parent 56cb5237a1
commit 942bcf7065
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 360 additions and 197 deletions

View File

@ -98,14 +98,10 @@
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
"emoji-picker-react": "^4.19.1",
"entities": "^6.0.1",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
"katex": "^0.16.45",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",

View File

@ -142,15 +142,6 @@ importers:
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.5)
entities:
specifier: ^6.0.1
version: 6.0.1
github-slugger:
specifier: ^2.0.0
version: 2.0.0
hosted-git-info:
specifier: ^9.0.3
version: 9.0.3
html-to-image:
specifier: ^1.11.13
version: 1.11.13
@ -163,9 +154,6 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
mermaid:
specifier: ^11.15.0
version: 11.15.0
@ -331,7 +319,7 @@ importers:
version: 19.2.5(react@19.2.5)
react-grab:
specifier: ^0.1.33
version: 0.1.33(react@19.2.5)
version: 0.1.34(react@19.2.5)
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
@ -369,11 +357,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/ni@30.1.0':
resolution: {integrity: sha512-3VuAbPjgY52rQNn4wABaXMhBU2Oq91uy6L8nX49eJ35OLI68CyckGU+HZxcaHix4ymuGM2nFL1D6sLpgODK5xw==}
engines: {node: '>=20.19.0'}
hasBin: true
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@ -553,8 +536,8 @@ packages:
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.65.0':
resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==}
'@dotenvx/dotenvx@1.66.0':
resolution: {integrity: sha512-qlQFhHUjhRDybrinqLAD0MClVZDOrsq80O8eD5iSjz3Qa/4f3Jg7SQrOaSobrRyP1QaWIYLGtGpj2c7H0D8NUw==}
hasBin: true
'@ecies/ciphers@0.2.6':
@ -2228,8 +2211,8 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-grab/cli@0.1.33':
resolution: {integrity: sha512-UOc3PwN11Osw0NzaxRLK8trP4X+5iW1Dst3gvHRCafe3wXHyadzHYH8H1hdkcXdlIx3gsoD9ASJ+G/JH+A/jqA==}
'@react-grab/cli@0.1.34':
resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==}
hasBin: true
'@rolldown/pluginutils@1.0.0-rc.3':
@ -3215,8 +3198,8 @@ packages:
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bippy@0.5.39:
resolution: {integrity: sha512-8hE8rKSl8JWyeaY+JjpnmceWAZPpLEyzOZQpWXM5Rc7861c5WotMJHy2aRZKZrGA8nMpvLNF01t4yQQ+HcZG3w==}
bippy@0.5.40:
resolution: {integrity: sha512-3QPSDG5tgd7FCIkcKsUqmbGdlHxBxP7II5drXPNp1I0rpA9+4+/VjQOigDTbzeqTi6Xkaf1Dq7x4E8vbOuwOkA==}
peerDependencies:
react: '>=17.0.1'
@ -3463,6 +3446,10 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -3999,8 +3986,8 @@ packages:
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
express-rate-limit@8.5.0:
resolution: {integrity: sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==}
express-rate-limit@8.5.2:
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
@ -4160,9 +4147,6 @@ packages:
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
fzf@0.5.2:
resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@ -4314,18 +4298,14 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.12.17:
resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==}
hono@4.12.19:
resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==}
engines: {node: '>=16.9.0'}
hosted-git-info@4.1.0:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
hosted-git-info@9.0.3:
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
engines: {node: ^20.17.0 || >=22.9.0}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
@ -4415,8 +4395,8 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
@ -4747,10 +4727,6 @@ packages:
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@11.3.6:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -5442,8 +5418,8 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@ -5483,8 +5459,8 @@ packages:
peerDependencies:
react: ^19.2.5
react-grab@0.1.33:
resolution: {integrity: sha512-ER919JMsE4TTrb2CpEivqsIjNMSycD4HtS8v7mS3pq67U7WL1K3+C8m9AYOwW4dpuYh+EanC2eJBmfuczHJZ0A==}
react-grab@0.1.34:
resolution: {integrity: sha512-jtdOdv0kb90oqL+pMszSh9DOLgVRaX4ZE6XN4GkDEpNNUqveQfZT014+EeJraljpqQfuWKW+96NrrRqUD93D2g==}
hasBin: true
peerDependencies:
react: '>=17.0.0'
@ -6077,9 +6053,9 @@ packages:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
type-is@2.1.0:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
@ -6458,13 +6434,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.2
'@antfu/ni@30.1.0':
dependencies:
fzf: 0.5.2
package-manager-detector: 1.6.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@ -6700,7 +6669,7 @@ snapshots:
react: 19.2.5
tslib: 2.8.1
'@dotenvx/dotenvx@1.65.0':
'@dotenvx/dotenvx@1.66.0':
dependencies:
commander: 11.1.0
dotenv: 17.4.2
@ -6999,9 +6968,9 @@ snapshots:
dependencies:
graphql: 16.13.2
'@hono/node-server@1.19.14(hono@4.12.17)':
'@hono/node-server@1.19.14(hono@4.12.19)':
dependencies:
hono: 4.12.17
hono: 4.12.19
'@iconify/types@2.0.0': {}
@ -7094,7 +7063,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.17)
'@hono/node-server': 1.19.14(hono@4.12.19)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@ -7103,8 +7072,8 @@ snapshots:
eventsource: 3.0.7
eventsource-parser: 3.0.8
express: 5.2.1
express-rate-limit: 8.5.0(express@5.2.1)
hono: 4.12.17
express-rate-limit: 8.5.2(express@5.2.1)
hono: 4.12.19
jose: 6.2.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@ -8160,16 +8129,17 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-grab/cli@0.1.33':
'@react-grab/cli@0.1.34':
dependencies:
'@antfu/ni': 30.1.0
commander: 14.0.3
ignore: 7.0.5
jsonc-parser: 3.3.1
ora: 9.4.0
package-manager-detector: 1.6.0
picocolors: 1.1.1
prompts: 2.4.2
smol-toml: 1.6.1
tinyexec: 1.1.2
'@rolldown/pluginutils@1.0.0-rc.3': {}
@ -9133,7 +9103,7 @@ snapshots:
dependencies:
file-uri-to-path: 1.0.0
bippy@0.5.39(react@19.2.5):
bippy@0.5.40(react@19.2.5):
dependencies:
react: 19.2.5
@ -9151,9 +9121,9 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.1
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
type-is: 2.1.0
transitivePeerDependencies:
- supports-color
@ -9384,6 +9354,8 @@ snapshots:
content-type@1.0.5: {}
content-type@2.0.0: {}
convert-source-map@2.0.0: {}
cookie-signature@1.2.2: {}
@ -10021,10 +9993,10 @@ snapshots:
exponential-backoff@3.1.3: {}
express-rate-limit@8.5.0(express@5.2.1):
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
ip-address: 10.2.0
express@5.2.1:
dependencies:
@ -10048,13 +10020,13 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.1
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.0.1
type-is: 2.1.0
vary: 1.1.2
transitivePeerDependencies:
- supports-color
@ -10190,7 +10162,6 @@ snapshots:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
optional: true
fs-extra@7.0.1:
dependencies:
@ -10223,8 +10194,6 @@ snapshots:
fuzzysort@3.1.0: {}
fzf@0.5.2: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@ -10463,16 +10432,12 @@ snapshots:
highlight.js@11.11.1: {}
hono@4.12.17: {}
hono@4.12.19: {}
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
hosted-git-info@9.0.3:
dependencies:
lru-cache: 11.3.6
html-to-image@1.11.13: {}
html-url-attributes@3.0.1: {}
@ -10554,7 +10519,7 @@ snapshots:
internmap@2.0.3: {}
ip-address@10.1.0: {}
ip-address@10.2.0: {}
ipaddr.js@1.9.1: {}
@ -10805,8 +10770,6 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@11.3.6: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -11812,7 +11775,7 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.1:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@ -11904,10 +11867,10 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
react-grab@0.1.33(react@19.2.5):
react-grab@0.1.34(react@19.2.5):
dependencies:
'@react-grab/cli': 0.1.33
bippy: 0.5.39(react@19.2.5)
'@react-grab/cli': 0.1.34
bippy: 0.5.40(react@19.2.5)
optionalDependencies:
react: 19.2.5
@ -12245,7 +12208,7 @@ snapshots:
'@babel/parser': 7.29.3
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@dotenvx/dotenvx': 1.65.0
'@dotenvx/dotenvx': 1.66.0
'@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
@ -12256,7 +12219,7 @@ snapshots:
diff: 8.0.4
execa: 9.6.1
fast-glob: 3.3.3
fs-extra: 11.3.4
fs-extra: 11.3.5
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
@ -12624,9 +12587,9 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
type-is@2.0.1:
type-is@2.1.0:
dependencies:
content-type: 1.0.5
content-type: 2.0.0
media-typer: 1.1.0
mime-types: 3.0.2

View File

@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { buildHostedRemoteFileUrl, parseHostedRemote } from './hosted-remote-url'
describe('hosted remote URLs', () => {
it('parses common GitHub remote formats', () => {
expect(parseHostedRemote('https://github.com/Org/Repo.git')).toEqual({
host: 'github.com',
path: 'Org/Repo',
provider: 'github'
})
expect(parseHostedRemote('git@github.com:Org/Repo.git')).toEqual({
host: 'github.com',
path: 'Org/Repo',
provider: 'github'
})
expect(parseHostedRemote('ssh://git@github.com/Org/Repo.git')).toEqual({
host: 'github.com',
path: 'Org/Repo',
provider: 'github'
})
expect(parseHostedRemote('github:Org/Repo')).toEqual({
host: 'github.com',
path: 'Org/Repo',
provider: 'github'
})
})
it('parses nested GitLab and Bitbucket remotes', () => {
expect(parseHostedRemote('git@gitlab.com:group/sub/repo.git')).toEqual({
host: 'gitlab.com',
path: 'group/sub/repo',
provider: 'gitlab'
})
expect(parseHostedRemote('https://bitbucket.org/team/repo.git')).toEqual({
host: 'bitbucket.org',
path: 'team/repo',
provider: 'bitbucket'
})
})
it('builds file URLs with encoded branches and paths', () => {
expect(
buildHostedRemoteFileUrl('git@github.com:Org/Repo.git', 'src/a file.ts', 'feature/x', 42)
).toBe('https://github.com/Org/Repo/blob/feature%2Fx/src/a%20file.ts#L42')
expect(
buildHostedRemoteFileUrl('git@gitlab.com:group/sub/repo.git', 'src/a.ts', 'feature/x', 9)
).toBe('https://gitlab.com/group/sub/repo/tree/feature%2Fx/src/a.ts#L9')
expect(
buildHostedRemoteFileUrl('git@bitbucket.org:team/repo.git', 'src/a.ts', 'feature/x', 7)
).toBe('https://bitbucket.org/team/repo/src/feature%2Fx/src/a.ts#L7')
})
it('rejects unsupported hosts and incomplete repo paths', () => {
expect(parseHostedRemote('git@example.com:team/repo.git')).toBeNull()
expect(parseHostedRemote('git@github.com:repo.git')).toBeNull()
expect(parseHostedRemote('ftp://github.com/Org/Repo.git')).toBeNull()
})
})

View File

@ -0,0 +1,116 @@
// Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used.
// Why: source links only need GitHub/GitLab/Bitbucket remote parsing and file
// URL construction, so we keep that small surface local and tested.
type HostedRemoteProvider = 'github' | 'gitlab' | 'bitbucket'
type HostedRemote = {
host: string
path: string
provider: HostedRemoteProvider
}
const shorthandHosts: Record<string, { host: string; provider: HostedRemoteProvider }> = {
bitbucket: { host: 'bitbucket.org', provider: 'bitbucket' },
github: { host: 'github.com', provider: 'github' },
gitlab: { host: 'gitlab.com', provider: 'gitlab' }
}
function providerForHost(host: string): HostedRemoteProvider | null {
const normalized = host.toLowerCase()
if (normalized === 'github.com') {
return 'github'
}
if (normalized === 'gitlab.com') {
return 'gitlab'
}
if (normalized === 'bitbucket.org') {
return 'bitbucket'
}
return null
}
function trimGitSuffix(path: string): string {
return path.replace(/\.git$/i, '')
}
function decodeRemotePathPart(pathPart: string): string {
try {
return decodeURIComponent(pathPart)
} catch {
return pathPart
}
}
function cleanRemotePath(path: string): string | null {
const normalized = trimGitSuffix(path.replace(/^\/+/, '').replace(/\/+$/, ''))
const parts = normalized.split('/').filter(Boolean)
if (parts.length < 2) {
return null
}
return parts.map(decodeRemotePathPart).join('/')
}
export function parseHostedRemote(remoteUrl: string): HostedRemote | null {
const trimmed = remoteUrl.trim().replace(/^git\+/, '')
const shorthand = trimmed.match(/^([a-z]+):([^/].+)$/i)
if (shorthand) {
const host = shorthandHosts[shorthand[1].toLowerCase()]
const path = cleanRemotePath(shorthand[2])
return host && path ? { ...host, path } : null
}
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+)$/)
if (scpLike) {
const provider = providerForHost(scpLike[1])
const path = cleanRemotePath(scpLike[2])
return provider && path ? { host: scpLike[1].toLowerCase(), path, provider } : null
}
}
try {
const url = new URL(trimmed)
if (!['git:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
const provider = providerForHost(url.hostname)
const path = cleanRemotePath(url.pathname)
return provider && path ? { host: url.hostname.toLowerCase(), path, provider } : null
} catch {
return null
}
}
function encodeRemotePath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}
function encodeRelativePath(path: string): string {
return path.replaceAll('\\', '/').split('/').filter(Boolean).map(encodeURIComponent).join('/')
}
export function buildHostedRemoteFileUrl(
remoteUrl: string,
relativePath: string,
branch: string,
line: number
): string | null {
const remote = parseHostedRemote(remoteUrl)
if (!remote) {
return null
}
const encodedRepoPath = encodeRemotePath(remote.path)
const encodedBranch = encodeURIComponent(branch)
const encodedFilePath = encodeRelativePath(relativePath)
const filePathSuffix = encodedFilePath ? `/${encodedFilePath}` : ''
const baseUrl = `https://${remote.host}/${encodedRepoPath}`
if (remote.provider === 'github') {
return `${baseUrl}/blob/${encodedBranch}${filePathSuffix}#L${line}`
}
if (remote.provider === 'gitlab') {
return `${baseUrl}/tree/${encodedBranch}${filePathSuffix}#L${line}`
}
return `${baseUrl}/src/${encodedBranch}${filePathSuffix}#L${line}`
}

View File

@ -2,9 +2,9 @@
import { execSync } from 'child_process'
import { existsSync, statSync } from 'fs'
import { join, basename } from 'path'
import hostedGitInfo from 'hosted-git-info'
import { gitExecFileSync, gitExecFileAsync } from './runner'
import type { BaseRefSearchResult } from '../../shared/types'
import { buildHostedRemoteFileUrl } from './hosted-remote-url'
const GH_LOGIN_TIMEOUT_MS = 2500
@ -672,9 +672,6 @@ function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefi
/**
* Build a hosted URL (e.g. GitHub, GitLab, Bitbucket) for a specific file
* and line in the repo. Returns null when the remote isn't a recognized host.
*
* Why hosted-git-info: it handles SSH, HTTPS, and shorthand remote URLs
* across multiple providers, so we don't have to maintain our own URL parser.
*/
export function getRemoteFileUrl(
repoPath: string,
@ -686,22 +683,11 @@ export function getRemoteFileUrl(
return null
}
const info = hostedGitInfo.fromUrl(remoteUrl)
if (!info) {
return null
}
const defaultBaseRef = getDefaultBaseRef(repoPath)
if (!defaultBaseRef) {
return null
}
const defaultBranch = defaultBaseRef.replace(/^origin\//, '')
const browseUrl = info.browseFile(relativePath, { committish: defaultBranch })
if (!browseUrl) {
return null
}
// Why: hosted-git-info lowercases the fragment, but GitHub convention
// uses uppercase L for line links (e.g. #L42). Append manually.
return `${browseUrl}#L${line}`
return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line)
}

View File

@ -1183,9 +1183,8 @@ export function registerFilesystemHandlers(
_event,
args: { worktreePath: string; relativePath: string; line: number; connectionId?: string }
): Promise<string | null> => {
// Why: remote repos can't use the local hosted-git-info approach because
// the .git/config lives on the remote. Route through the relay's git.exec
// to fetch the remote URL and build the file link server-side.
// Why: remote repos can't read relay-side .git/config locally. Delegate
// URL construction to the SSH provider, which can fetch remote metadata.
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {

View File

@ -4,7 +4,6 @@
small amount of param plumbing. */
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IGitProvider } from './types'
import hostedGitInfo from 'hosted-git-info'
import type {
GitStatusResult,
GitDiffResult,
@ -16,6 +15,7 @@ import type {
GitWorktreeInfo
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import { buildHostedRemoteFileUrl } from '../git/hosted-remote-url'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
@ -274,9 +274,8 @@ export class SshGitProvider implements IGitProvider {
return true
}
// Why: the local getRemoteFileUrl uses hosted-git-info which requires the
// remote URL from .git/config. For SSH connections we must fetch the remote
// URL from the relay, then apply the same hosted-git-info logic locally.
// Why: SSH worktrees need the remote URL from the relay-side .git/config
// before local code can map it to a hosted source link.
async getRemoteFileUrl(
worktreePath: string,
relativePath: string,
@ -293,11 +292,6 @@ export class SshGitProvider implements IGitProvider {
return null
}
const info = hostedGitInfo.fromUrl(remoteUrl)
if (!info) {
return null
}
let defaultBranch = 'main'
try {
const refResult = await this.exec(
@ -312,13 +306,6 @@ export class SshGitProvider implements IGitProvider {
// Fall back to 'main'
}
const browseUrl = info.browseFile(relativePath, { committish: defaultBranch })
if (!browseUrl) {
return null
}
// Why: hosted-git-info lowercases the fragment, but GitHub convention
// uses uppercase L for line links (e.g. #L42). Append manually.
return `${browseUrl}#L${line}`
return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line)
}
}

View File

@ -1,13 +0,0 @@
declare module 'hosted-git-info' {
type HostedGitInfo = {
browseFile(path: string, opts?: { committish?: string; fragment?: string }): string | undefined
browse(path?: string, opts?: { committish?: string; fragment?: string }): string | undefined
}
type HostedGitInfoStatic = {
fromUrl(url: string): HostedGitInfo | undefined
}
const hostedGitInfo: HostedGitInfoStatic
export default hostedGitInfo
}

View File

@ -12,7 +12,6 @@ import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeSlug from 'rehype-slug'
import GithubSlugger from 'github-slugger'
import { extractFrontMatter } from './markdown-frontmatter'
import {
Check,
@ -230,27 +229,6 @@ const markdownPreviewSanitizeSchema = {
}
}
function getMarkdownPreviewNodeText(node: React.ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map((child) => getMarkdownPreviewNodeText(child)).join('')
}
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
return getMarkdownPreviewNodeText(node.props.children)
}
return ''
}
// Why: use the same GithubSlugger that rehype-slug uses internally so
// heading IDs match standard GitHub/VS Code anchor links. The custom
// slugger previously stripped punctuation differently, breaking links
// like `#a--b` for headings containing `A & B`.
function createMarkdownPreviewHeadingId(headingText: string, slugger: GithubSlugger): string {
return slugger.slug(headingText)
}
function parseLineTarget(hash: string): { line: number; column?: number } | null {
if (!hash) {
return null
@ -471,7 +449,6 @@ export default function MarkdownPreview({
.replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '')
.trim()
}, [frontMatter])
const sluggerRef = useRef(new GithubSlugger())
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
const [reviewPanelOpen, setReviewPanelOpen] = useState(false)
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
@ -884,8 +861,6 @@ export default function MarkdownPreview({
)
const components: Components = useMemo(() => {
sluggerRef.current.reset()
const slugger = sluggerRef.current
return {
a: ({ href, children, className, ...props }) => {
const docLinkTarget = parseMarkdownDocLinkHref(href)
@ -1239,61 +1214,55 @@ export default function MarkdownPreview({
)
},
h1: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h1',
node as MarkdownPreviewPositionNode,
<h1 {...props} id={id} tabIndex={-1}>
<h1 {...props} tabIndex={-1}>
{children}
</h1>
)
},
h2: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h2',
node as MarkdownPreviewPositionNode,
<h2 {...props} id={id} tabIndex={-1}>
<h2 {...props} tabIndex={-1}>
{children}
</h2>
)
},
h3: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h3',
node as MarkdownPreviewPositionNode,
<h3 {...props} id={id} tabIndex={-1}>
<h3 {...props} tabIndex={-1}>
{children}
</h3>
)
},
h4: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h4',
node as MarkdownPreviewPositionNode,
<h4 {...props} id={id} tabIndex={-1}>
<h4 {...props} tabIndex={-1}>
{children}
</h4>
)
},
h5: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h5',
node as MarkdownPreviewPositionNode,
<h5 {...props} id={id} tabIndex={-1}>
<h5 {...props} tabIndex={-1}>
{children}
</h5>
)
},
h6: ({ node, children, ...props }) => {
const id = createMarkdownPreviewHeadingId(getMarkdownPreviewNodeText(children), slugger)
return wrapAnnotatedBlock(
'h6',
node as MarkdownPreviewPositionNode,
<h6 {...props} id={id} tabIndex={-1}>
<h6 {...props} tabIndex={-1}>
{children}
</h6>
)

View File

@ -1,10 +1,7 @@
import GithubSlugger from 'github-slugger'
import { MarkdownHeadingSlugger } from './markdown-heading-slug'
// Why: rehype-slug generates heading ids using a stateful GithubSlugger that
// appends numeric suffixes to duplicate headings (foo, foo-1, foo-2). To keep
// the editor's anchor matching in parity with the preview, we must use the
// same stateful slugger — the stateless `slug()` helper would miss suffixes
// and silently land on the wrong heading.
// Why: duplicate headings need the same stateful suffixes as the preview
// renderer (foo, foo-1, foo-2) or anchor links can jump to the wrong heading.
export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string): void {
if (!root || !anchor) {
return
@ -16,7 +13,7 @@ export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string)
// Malformed %-escapes: fall back to the raw fragment.
}
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6')
const slugger = new GithubSlugger()
const slugger = new MarkdownHeadingSlugger()
for (const heading of headings) {
if (slugger.slug(heading.textContent ?? '') === decoded) {
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { MarkdownHeadingSlugger, slugMarkdownHeading } from './markdown-heading-slug'
describe('markdown heading slugger', () => {
it('matches GitHub-style punctuation and space handling used by markdown anchors', () => {
expect(slugMarkdownHeading('A & B')).toBe('a--b')
expect(slugMarkdownHeading('https://example.com')).toBe('httpsexamplecom')
expect(slugMarkdownHeading('Keep_under-score')).toBe('keep_under-score')
})
it('adds stable duplicate suffixes', () => {
const slugger = new MarkdownHeadingSlugger()
expect([slugger.slug('Repeat'), slugger.slug('Repeat'), slugger.slug('Repeat')]).toEqual([
'repeat',
'repeat-1',
'repeat-2'
])
})
it('can reset duplicate state between render passes', () => {
const slugger = new MarkdownHeadingSlugger()
expect(slugger.slug('Repeat')).toBe('repeat')
expect(slugger.slug('Repeat')).toBe('repeat-1')
slugger.reset()
expect(slugger.slug('Repeat')).toBe('repeat')
})
})

View File

@ -0,0 +1,30 @@
// Scoped local fork of github-slugger@2.0.0 behavior.
// Why: TOC/editor anchors need to match rehype-slug@6.0.0 preview IDs without
// adding a direct github-slugger runtime dependency.
const markdownSlugPunctuationPattern = /[^\p{L}\p{M}\p{N} _-]/gu
export class MarkdownHeadingSlugger {
private readonly occurrences = new Map<string, number>()
reset(): void {
this.occurrences.clear()
}
slug(value: string): string {
const baseSlug = slugMarkdownHeading(value)
let nextSlug = baseSlug
while (this.occurrences.has(nextSlug)) {
const nextCount = (this.occurrences.get(baseSlug) ?? 0) + 1
this.occurrences.set(baseSlug, nextCount)
nextSlug = `${baseSlug}-${nextCount}`
}
this.occurrences.set(nextSlug, 0)
return nextSlug
}
}
export function slugMarkdownHeading(value: string): string {
return value.toLowerCase().replace(markdownSlugPunctuationPattern, '').replace(/ /g, '-')
}

View File

@ -1,10 +1,8 @@
import GithubSlugger from 'github-slugger'
import { decodeHTML } from 'entities'
import { toString } from 'mdast-util-to-string'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { unified } from 'unified'
import { MarkdownHeadingSlugger } from './markdown-heading-slug'
export type MarkdownTocLevel = 1 | 2 | 3
@ -15,12 +13,43 @@ export type MarkdownTocItem = {
title: string
}
const htmlEntitiesForToc = new Map([
['amp', '&'],
['apos', "'"],
['gt', '>'],
['lt', '<'],
['nbsp', ' '],
['quot', '"']
])
function isMarkdownTocLevel(value: number): value is MarkdownTocLevel {
return value === 1 || value === 2 || value === 3
}
// Scoped local fork of the tiny entities@6.0.1 surface Orca used here.
// Why: TOC labels only need common/numeric entity decoding before inline
// Markdown stripping, not the full entity database.
function decodeTocHtmlEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]+);/gi, (match, entity: string) => {
const normalized = entity.toLowerCase()
if (normalized.startsWith('#x')) {
const codePoint = Number.parseInt(normalized.slice(2), 16)
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
? String.fromCodePoint(codePoint)
: match
}
if (normalized.startsWith('#')) {
const codePoint = Number.parseInt(normalized.slice(1), 10)
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
? String.fromCodePoint(codePoint)
: match
}
return htmlEntitiesForToc.get(normalized) ?? match
})
}
export function stripInlineMarkdownForToc(text: string): string {
return decodeHTML(text)
return decodeTocHtmlEntities(text)
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
@ -48,18 +77,33 @@ function appendTocItem(stack: MarkdownTocItem[], item: MarkdownTocItem): void {
}
type MarkdownAstNode = {
alt?: string
children?: MarkdownAstNode[]
depth?: number
type?: string
value?: string
}
// Scoped local fork of mdast-util-to-string@4.0.0 for heading nodes.
// Why: TOC generation only needs text/alt/child concatenation from parsed
// Markdown headings, so a local walker keeps the dependency boundary smaller.
function markdownAstNodeToText(node: MarkdownAstNode): string {
if (typeof node.value === 'string') {
return node.value
}
if (typeof node.alt === 'string') {
return node.alt
}
return (node.children ?? []).map(markdownAstNodeToText).join('')
}
export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[] {
const slugger = new GithubSlugger()
const slugger = new MarkdownHeadingSlugger()
const root: MarkdownTocItem = { id: 'toc-root', level: 1, title: '', children: [] }
const stack: MarkdownTocItem[] = [root]
// Why: the TOC must produce the same heading text/ids as react-markdown plus
// rehype-slug; parsing Markdown avoids drift on setext, GFM, and entities.
// Why: parsing Markdown keeps the TOC aligned with rendered setext/GFM/entity
// headings without carrying separate mdast stringifier/entity packages.
const tree = unified()
.use(remarkParse)
.use(remarkGfm)
@ -72,7 +116,7 @@ export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[
typeof node.depth === 'number' &&
isMarkdownTocLevel(node.depth)
) {
const title = toString(node).replace(/\s+/g, ' ').trim()
const title = markdownAstNodeToText(node).replace(/\s+/g, ' ').trim()
if (title) {
appendTocItem(stack, {
children: [],

View File

@ -5,9 +5,9 @@ import { createRoot } from 'react-dom/client'
import App from './App'
import { applyDocumentTheme } from './lib/document-theme'
if (import.meta.env.DEV) {
import('react-grab').then(({ init }) => init())
import('react-grab/styles.css')
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_REACT_GRAB === 'true') {
void import('react-grab').then(({ init }) => init())
void import('react-grab/styles.css')
}
applyDocumentTheme('system', { disableTransitions: false })