feat: support text-fragments (#5140)

---------

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
pull/4522/merge
Sergio 2 months ago committed by GitHub
parent d019acd3ca
commit 44e2675889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,8 +5,9 @@
"baseUrl": ".",
"types": ["node", "vitest/globals"],
"paths": {
"client/*": ["../src/client/*"],
"node/*": ["../src/node/*"],
"client/*": ["../src/client/*"]
"shared/*": ["../src/shared/*"]
}
}
}

@ -0,0 +1,51 @@
import { slugify } from '@mdit-vue/shared'
import { MarkdownItAsync } from 'markdown-it-async'
import { linkPlugin } from 'node/markdown/plugins/link'
describe('node/markdown/plugins/link', () => {
const md = new MarkdownItAsync()
linkPlugin(md, {}, '/', slugify)
test('preserves text-fragment hashes on markdown links', async () => {
const html = await md.renderAsync(
'[58-61](/resources/server/user#:~:text=58*,time%20authentication%20token)',
{ cleanUrls: false }
)
expect(html).toContain(
'href="/resources/server/user.html#:~:text=58*,time%20authentication%20token"'
)
})
// https://web.dev/articles/text-fragments#mixing_element_and_text_fragments
test('preserves mixed element and text-fragment hashes', async () => {
const html = await md.renderAsync(
'[Section](/guide/getting-started#Hello%20World:~:text=Hello%20World)',
{ cleanUrls: false }
)
expect(html).toContain(
'href="/guide/getting-started.html#hello-world:~:text=Hello%20World"'
)
})
test('continues to normalize regular heading hashes', async () => {
const html = await md.renderAsync(
'[Section](/guide/getting-started#Hello%20World)',
{ cleanUrls: false }
)
expect(html).toContain('href="/guide/getting-started.html#hello-world"')
})
test('does not break encoding for text-fragments', async () => {
const html = await md.renderAsync(
'[Section](/foo?title=Cat&oldid=916388819#:~:text=Claws-,Like%20almost,the%20Felidae%2C,-cats)',
{ cleanUrls: false }
)
expect(html).toContain(
'href="/foo.html?title=Cat&amp;oldid=916388819#:~:text=Claws-,Like%20almost,the%20Felidae%2C,-cats"'
)
})
})

@ -3,7 +3,6 @@ import {
findRegion,
rawPathToToken
} from 'node/markdown/plugins/snippet'
import { expect } from 'vitest'
const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
return Object.fromEntries(

@ -12,6 +12,7 @@ export default defineConfig({
{ find: '@siteData', replacement: resolve(dir, './shims.ts') },
{ find: 'client', replacement: resolve(dir, '../../src/client') },
{ find: 'node', replacement: resolve(dir, '../../src/node') },
{ find: 'shared', replacement: resolve(dir, '../../src/shared') },
{
find: /^vitepress$/,
replacement: resolve(dir, '../../src/client/index.js')

@ -79,9 +79,21 @@ export function createRouter(
const router: Router = {
route,
async go(href, options) {
const { hash } = new URL(href, fakeHost)
const hasTextFragment =
inBrowser && document.fragmentDirective && hash.includes(':~:')
href = normalizeHref(href)
if ((await router.onBeforeRouteChange?.(href)) === false) return
if (!inBrowser || (await changeRoute(href, options))) await loadPage(href)
if (
!inBrowser ||
(await changeRoute(href, { ...options, hasTextFragment }))
) {
await loadPage(href, { initialLoad: !!options?.initialLoad })
}
if (hasTextFragment) {
// this will create a new history entry, but that's almost unavoidable
location.hash = hash
}
syncRouteQueryAndHash()
await router.onAfterRouteChange?.(href)
}
@ -89,7 +101,10 @@ export function createRouter(
let latestPendingPath: string | null = null
async function loadPage(href: string, scrollPosition = 0, isRetry = false) {
async function loadPage(
href: string,
{ scrollPosition = 0, isRetry = false, initialLoad = false } = {}
) {
if ((await router.onBeforePageLoad?.(href)) === false) return
const targetLoc = new URL(href, fakeHost)
@ -130,7 +145,7 @@ export function createRouter(
history.replaceState({}, '', href)
}
return scrollTo(targetLoc.hash, false, scrollPosition)
if (!initialLoad) scrollTo(targetLoc.hash, false, scrollPosition)
})
}
}
@ -149,7 +164,7 @@ export function createRouter(
try {
const res = await fetch(siteDataRef.value.base + 'hashmap.json')
;(window as any).__VP_HASH_MAP__ = await res.json()
await loadPage(href, scrollPosition, true)
await loadPage(href, { scrollPosition, isRetry: true, initialLoad })
return
} catch (e) {}
}
@ -229,7 +244,7 @@ export function createRouter(
window.addEventListener('popstate', async (e) => {
if (e.state === null) return
const href = normalizeHref(location.href)
await loadPage(href, (e.state && e.state.scrollPosition) || 0)
await loadPage(href, { scrollPosition: e.state.scrollPosition || 0 })
syncRouteQueryAndHash()
await router.onAfterRouteChange?.(href)
})
@ -341,12 +356,17 @@ function normalizeHref(href: string): string {
} else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html')) {
url.pathname += '.html'
}
return url.pathname + url.search + url.hash
return url.pathname + url.search + url.hash.split(':~:')[0]
}
async function changeRoute(
href: string,
{ smoothScroll = false, initialLoad = false, replace = false } = {}
{
smoothScroll = false,
initialLoad = false,
replace = false,
hasTextFragment = false
} = {}
): Promise<boolean> {
const loc = normalizeHref(location.href)
const nextUrl = new URL(href, location.origin)
@ -354,7 +374,7 @@ async function changeRoute(
if (href === loc) {
if (!initialLoad) {
scrollTo(nextUrl.hash, smoothScroll)
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
return false
}
} else {
@ -375,7 +395,7 @@ async function changeRoute(
newURL: nextUrl.href
})
)
scrollTo(nextUrl.hash, smoothScroll)
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
}
return false

@ -35,7 +35,8 @@ export const linkPlugin = (
token.attrGet('class') !== 'header-anchor' // header anchors are already normalized
) {
const hrefAttr = token.attrs![hrefIndex]
const url = hrefAttr[1]
let [url, frag] = hrefAttr[1].split(':~:', 2)
hrefAttr[1] = url
if (isExternal(url)) {
Object.entries(externalAttrs).forEach(([key, val]) => {
token.attrSet(key, val)
@ -66,6 +67,9 @@ export const linkPlugin = (
hrefAttr[1] = `${base}${hrefAttr[1]}`.replace(/\/+/g, '/')
}
}
if (frag) {
hrefAttr[1] += (hrefAttr[1].includes('#') ? '' : '#') + ':~:' + frag
}
}
return self.renderToken(tokens, idx, options)
}

@ -63,8 +63,7 @@ const isPageChunk = (
chunk.facadeModuleId.endsWith('.md')
)
const cleanUrl = (url: string): string =>
url.replace(/#.*$/s, '').replace(/\?.*$/s, '')
const cleanUrl = (url: string): string => url.replace(/[?#].*$/s, '')
export async function createVitePressPlugin(
siteConfig: SiteConfig,

@ -28,7 +28,7 @@ export const APPEARANCE_KEY = 'vitepress-theme-appearance'
export const VP_SOURCE_KEY = '[VP_SOURCE]'
const UnpackStackView = Symbol('stack-view:unpack')
const HASH_RE = /#.*$/
const HASH_WITHOUT_FRAGMENT_RE = /#.*?(?=:~:|$)/
const HASH_OR_QUERY_RE = /[?#].*$/
const INDEX_OR_EXT_RE = /(?:(^|\/)index)?\.(?:md|html)$/
@ -64,7 +64,7 @@ export function isActive(
return false
}
const hashMatch = matchPath.match(HASH_RE)
const hashMatch = matchPath.match(HASH_WITHOUT_FRAGMENT_RE)
if (hashMatch) {
return (inBrowser ? location.hash : '') === hashMatch[0]

Loading…
Cancel
Save