Skip to content

Commit e782462

Browse files
essenmitsossecoderabbitai[bot]alexdlndanielroe
authored
feat: replace links in markdown instead of via javascript (#1339)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Alex Savelyev <91429106+alexdln@users.noreply.github.com> Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 90fecf8 commit e782462

File tree

3 files changed

+71
-19
lines changed

3 files changed

+71
-19
lines changed

app/components/Readme.vue

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ defineProps<{
33
html: string
44
}>()
55
6-
const router = useRouter()
76
const { copy } = useClipboard()
87
98
// Combined click handler for:
@@ -13,6 +12,10 @@ function handleClick(event: MouseEvent) {
1312
const target = event.target as HTMLElement | undefined
1413
if (!target) return
1514
15+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button) {
16+
return
17+
}
18+
1619
// Handle copy button clicks
1720
const copyTarget = target.closest('[data-copy]')
1821
if (copyTarget) {
@@ -48,20 +51,11 @@ function handleClick(event: MouseEvent) {
4851
if (!href) return
4952
5053
// Handle relative anchor links
51-
if (href.startsWith('#')) {
54+
if (href.startsWith('#') || href.startsWith('/')) {
5255
event.preventDefault()
53-
router.push(href)
56+
navigateTo(href)
5457
return
5558
}
56-
57-
const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/)
58-
if (!match || !match[1]) return
59-
60-
const route = router.resolve(match[1])
61-
if (route) {
62-
event.preventDefault()
63-
router.push(route)
64-
}
6559
}
6660
</script>
6761

@@ -141,15 +135,19 @@ function handleClick(event: MouseEvent) {
141135
}
142136
143137
.readme :deep(a) {
144-
color: var(--fg);
145-
text-decoration: underline;
146-
text-underline-offset: 4px;
147-
text-decoration-color: var(--fg-subtle);
148-
transition: text-decoration-color 0.2s ease;
138+
@apply underline-offset-[0.2rem] underline decoration-1 decoration-fg/30 font-mono text-fg transition-colors duration-200;
149139
}
150-
151140
.readme :deep(a:hover) {
152-
text-decoration-color: var(--accent);
141+
@apply decoration-accent text-accent;
142+
}
143+
.readme :deep(a:focus-visible) {
144+
@apply decoration-accent text-accent;
145+
}
146+
147+
.readme :deep(a[target='_blank']::after) {
148+
/* I don't know what kind of sorcery this is, but it ensures this icon can't wrap to a new line on its own. */
149+
content: '__';
150+
@apply inline i-carbon:launch rtl-flip ms-1 opacity-50;
153151
}
154152
155153
.readme :deep(code) {

server/utils/readme.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,33 @@ function slugify(text: string): string {
183183
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
184184
}
185185

186+
/** These path on npmjs.com don't belong to packages or search, so we shouldn't try to replace them with npmx.dev urls */
187+
const reservedPathsNpmJs = [
188+
'products',
189+
'login',
190+
'signup',
191+
'advisories',
192+
'blog',
193+
'about',
194+
'press',
195+
'policies',
196+
]
197+
198+
const isNpmJsUrlThatCanBeRedirected = (url: URL) => {
199+
if (url.host !== 'www.npmjs.com' && url.host !== 'npmjs.com') {
200+
return false
201+
}
202+
203+
if (
204+
url.pathname === '/' ||
205+
reservedPathsNpmJs.some(path => url.pathname.startsWith(`/${path}`))
206+
) {
207+
return false
208+
}
209+
210+
return true
211+
}
212+
186213
/**
187214
* Resolve a relative URL to an absolute URL.
188215
* If repository info is available, resolve to provider's raw file URLs.
@@ -199,6 +226,10 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
199226
try {
200227
const parsed = new URL(url, 'https://example.com')
201228
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
229+
// Redirect npmjs urls to ourself
230+
if (isNpmJsUrlThatCanBeRedirected(parsed)) {
231+
return parsed.pathname + parsed.search + parsed.hash
232+
}
202233
return url
203234
}
204235
} catch {

test/unit/server/utils/readme.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ describe('Markdown File URL Resolution', () => {
306306
)
307307
})
308308
})
309+
310+
describe('npm.js urls', () => {
311+
it('redirects npmjs.com urls to local', async () => {
312+
const markdown = `[Some npmjs.com link](https://www.npmjs.com/package/test-pkg)`
313+
const result = await renderReadmeHtml(markdown, 'test-pkg')
314+
315+
expect(result.html).toContain('href="/package/test-pkg"')
316+
})
317+
318+
it('redirects npmjs.com urls to local (no www and http)', async () => {
319+
const markdown = `[Some npmjs.com link](http://npmjs.com/package/test-pkg)`
320+
const result = await renderReadmeHtml(markdown, 'test-pkg')
321+
322+
expect(result.html).toContain('href="/package/test-pkg"')
323+
})
324+
325+
it('does not redirect npmjs.com to local if they are in the list of exceptions', async () => {
326+
const markdown = `[Root Contributing](https://www.npmjs.com/products)`
327+
const result = await renderReadmeHtml(markdown, 'test-pkg')
328+
329+
expect(result.html).toContain('href="https://www.npmjs.com/products"')
330+
})
331+
})
309332
})
310333

311334
describe('Markdown Content Extraction', () => {

0 commit comments

Comments
 (0)