VitePress 实现短链接分享
WARNING
本文记录当前使用的静态重定向方案。最初的 shortmap.json 方案见 短链接分享(旧版)。
基本逻辑
- 新版短链接使用根路径,格式为
https://notes.linho.cc/xxxxxxxx,其中短 ID 是 8 位 Base36 小写字符(0-9a-z) 。 - VitePress 完成构建后,为每个短 ID 生成一个对应的根目录 HTML 文件,例如
abcdefgh.html。GitHub Pages 会以 HTTP 200 将/abcdefgh映射到该文件。 - 浏览器打开静态入口后使用
location.replace跳转,同时提供 meta refresh 和手动链接作为降级方案。 - 旧版
/s?q=xxxxxxxxxx链接作为兼容途径保留,但映射表改为 Vite 虚拟模块,不再请求shortmap.json。
短 ID 的生成
旧版 ID 使用页面路径 MD5 的前 10 位十六进制字符,编码空间为 。新版把完整 MD5 视为整数,对 取模,再固定编码为 8 位 Base36(0-9a-z
路径归一化、新旧 ID 计算、URL 路径编码和分享 URL 生成都放在共享模块中:
import md5 from 'blueimp-md5'
export const LEGACY_SHORT_ID_LENGTH = 10
export const SHORT_ID_LENGTH = 8
const BASE36_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
const BASE36_RADIX = BigInt(BASE36_ALPHABET.length)
const SHORT_ID_SPACE = BASE36_RADIX ** BigInt(SHORT_ID_LENGTH)
export function normalizePagePath(path: string) {
return path.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '')
}
export function createLegacyShortId(path: string) {
return md5(path).slice(0, LEGACY_SHORT_ID_LENGTH)
}
export function createShortId(path: string) {
let value = BigInt(`0x${md5(path)}`) % SHORT_ID_SPACE
let id = ''
for (let i = 0; i < SHORT_ID_LENGTH; i++) {
id = BASE36_ALPHABET[Number(value % BASE36_RADIX)] + id
value /= BASE36_RADIX
}
return id
}
export function encodePagePath(path: string) {
return path.split('/').map(encodeURIComponent).join('/')
}
export function createShareUrl(baseUrl: string, filePath: string) {
const path = normalizePagePath(filePath)
const base = baseUrl.replace(/\/$/, '')
const directUrl = `${base}/${encodePagePath(path)}`
const shortUrl = `${base}/${createShortId(path)}`
return directUrl.length <= shortUrl.length ? directUrl : shortUrl
}MD5 在这里只用于映射,和密码学安全没有关系。
这里没有使用 Base62(0-9a-zA-Z
静态入口的好处
也可以在 VitePress 404 页面中识别 8 位路径并查表跳转,但 GitHub Pages 对未知路径返回的 HTTP 状态仍然是 404。这会让部分链接检查器和社交平台直接判定链接失效,而且普通浏览器必须先启动完整的 VitePress 客户端才能跳转。
根目录的 <ID>.html 则能同时得到:
- 稳定的 HTTP 200 响应;
- 比完整 VitePress 应用更小的首次响应;
- 可被静态抓取的页面标题与站点简介;
- 禁止索引短链接、将搜索权重归并到原页面的
noindex和 canonical。
静态入口文件普遍不超过 1 KB。目前仓库里笔记规模大概在 500 这个量级,全站增加 500 KB 的大小微不足道。
构建阶段
短链接插件从 VitePress 已解析的 pages 与 rewrites 获取实际路由。transformPageData 在构建页面时记录 VitePress 已解析的页面标题;构建结束后,插件直接用这些标题和全局 description 在输出根目录生成短码入口,无需重新读取目标 HTML。
生成的入口大致如下:
<!doctype html>
<html lang="zh-CN">
<head>
<title>目标页面标题 | LinhoNotes</title>
<meta name="description" content="一个笔记仓库" />
<meta name="robots" content="noindex" />
<link rel="canonical" href="https://notes.linho.cc/目标路径" />
<meta
http-equiv="refresh"
content="0;url=https://notes.linho.cc/目标路径"
/>
<script>
location.replace(document.querySelector("link").href);
</script>
</head>
</html>插件还会在写入任何文件前完成以下检查:
- 新旧 ID 映射均不得碰撞;
- 新 ID 不得与真实的顶层路由或已有构建产物冲突。
完整实现见:
import {
createLegacyShortId,
createShortId,
encodePagePath,
normalizePagePath,
SHORT_ID_LENGTH,
} from '#shared/short-url'
import site from '#shared/site.json'
import fs from 'node:fs'
import path from 'node:path'
import { escape } from 'lodash-es'
import type { Plugin, ResolvedConfig } from 'vite'
import type { PageData, SiteConfig } from 'vitepress'
type ShortUrlEntry = {
id: string
legacyId: string
path: string
}
const moduleId = 'virtual:legacy-short-url-map'
const resolvedModuleId = `\0${moduleId}`
const shortIdPattern = new RegExp(`^[0-9a-z]{${SHORT_ID_LENGTH}}$`)
const pageTitles = new Map<string, string>()
export function collectShortUrlPageData(pageData: PageData) {
if (pageData.isNotFound) return
pageTitles.set(
normalizePagePath(pageData.relativePath),
pageData.title ? `${pageData.title} | ${site.title}` : site.title,
)
}
function assertUniqueIds(
entries: ShortUrlEntry[],
getId: (entry: ShortUrlEntry) => string,
version: string,
) {
const pathsById = new Map<string, string>()
for (const entry of entries) {
const id = getId(entry)
const existingPath = pathsById.get(id)
if (existingPath !== undefined && existingPath !== entry.path)
throw new Error(
`Short URL ${version} collision: "${existingPath}" and "${entry.path}" both map to "${id}"`,
)
pathsById.set(id, entry.path)
}
}
function validateEntries(entries: ShortUrlEntry[]) {
assertUniqueIds(entries, (entry) => entry.legacyId, 'v1')
assertUniqueIds(entries, (entry) => entry.id, 'v2')
const rootRoutes = new Set(
entries
.map((entry) => entry.path.replace(/\/$/, ''))
.filter((route) => route && !route.includes('/')),
)
for (const entry of entries)
if (rootRoutes.has(entry.id))
throw new Error(
`Short URL "${entry.id}" for "${entry.path}" conflicts with a root route of the same name`,
)
}
function createShortUrlData(siteConfig: SiteConfig) {
const entries = siteConfig.pages
.map((sourcePage) => siteConfig.rewrites.map[sourcePage] ?? sourcePage)
.map(normalizePagePath)
.filter((targetPath) => targetPath !== 's')
.map((targetPath) => ({
id: createShortId(targetPath),
legacyId: createLegacyShortId(targetPath),
path: targetPath,
}))
validateEntries(entries)
return {
entries,
legacyMap: Object.fromEntries(entries.map((entry) => [entry.legacyId, entry.path])),
}
}
function createRedirectHtml(title: string, targetUrl: string, lang: string) {
const canonical = escape(targetUrl)
return /* html */ `<!doctype html>
<html lang="${escape(lang)}">
<head>
<meta charset="utf-8">
<title>${escape(title)}</title>
<meta name="description" content="${escape(site.description)}">
<meta name="robots" content="noindex">
<link rel="canonical" href="${canonical}">
<meta http-equiv="refresh" content="0;url=${canonical}">
<script>location.replace(document.querySelector('link').href)</script>
</head>
</html>
`.replaceAll(/\n\s+/g, ' ')
}
function validateOutputTargets(entries: ShortUrlEntry[], names: string[]) {
const existingNames = new Set(names.map((name) => name.toLowerCase()))
for (const entry of entries) {
const outputName = `${entry.id}.html`
if (existingNames.has(outputName) || existingNames.has(entry.id))
throw new Error(
`Short URL output "${outputName}" for "${entry.path}" conflicts with an existing build output`,
)
if (!pageTitles.has(entry.path))
throw new Error(`Missing VitePress page title for short URL target "${entry.path}"`)
}
}
export async function generateShortUrlRedirects(siteConfig: SiteConfig) {
const { entries } = createShortUrlData(siteConfig)
validateOutputTargets(entries, await fs.promises.readdir(siteConfig.outDir))
await Promise.all(
entries.map(async (entry) => {
const title = pageTitles.get(entry.path)!
const targetUrl = new URL(`/${encodePagePath(entry.path)}`, site.baseUrl).href
const html = createRedirectHtml(title, targetUrl, siteConfig.site.lang)
await fs.promises.writeFile(path.join(siteConfig.outDir, `${entry.id}.html`), html)
}),
)
}
export default function mapShortUrl(): Plugin {
let shortUrlData: ReturnType<typeof createShortUrlData> | undefined
return {
name: 'linho-notes:short-url',
configResolved(config: ResolvedConfig) {
if (!config.vitepress) throw new Error('VitePress site config is unavailable')
shortUrlData = createShortUrlData(config.vitepress)
},
resolveId(id) {
if (id === moduleId) return resolvedModuleId
},
load(id) {
if (id !== resolvedModuleId) return
if (!shortUrlData) throw new Error('VitePress site config is unavailable')
return `export default ${JSON.stringify(shortUrlData.legacyMap)}`
},
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (!shortUrlData || !request.url) return next()
const pathname = new URL(request.url, 'http://localhost').pathname
const id = pathname.match(/^\/([0-9a-z]+)\/?$/)?.[1]
if (!id || !shortIdPattern.test(id)) return next()
const entry = shortUrlData.entries.find((candidate) => candidate.id === id)
if (!entry) return next()
response.statusCode = 302
response.setHeader('Location', `/${encodePagePath(entry.path)}`)
response.end()
})
},
}
}在 config.mts 中同时注册 Vite 插件与构建结束钩子:
export default {
transformPageData: collectShortUrlPageData,
buildEnd: async (siteConfig) => {
await generateShortUrlRedirects(siteConfig)
},
vite: {
plugins: [mapShortUrl()],
},
}在开发环境中尚未存在构建产物,所以插件的开发服务器中间件会将有效短码临时 302 到目标路径。
链接分享
网页上生成分享链接时,createShareUrl 会比较完整直链与根路径短链的实际长度,只在短链确实更短时返回短链。
watchEffect(() => {
link.value = createShareUrl(
import.meta.env.DEV ? location.origin : site.baseUrl,
page.value.filePath,
);
});根路径格式省去了旧版短链接的 /s?q=。当前线上域名与 8 位 ID 组成的短链接共 31 字节,在 L 级纠错下仍可使用 QR Version 2(
旧链接兼容
/s?q=xxxxxxxxxx 仍通过旧跳转页处理。legacy 映射表由 Vite 虚拟模块生成并直接打包到 /s 的页面 chunk,因此不再存在同名 shortmap.json 的缓存错配,也不需要 axios 或额外的 fetch。
<template></template>
<script setup lang="ts">
import { encodePagePath } from '#shared/short-url'
import legacyShortUrlMap from 'virtual:legacy-short-url-map'
import { useRouter } from 'vitepress'
import { onMounted } from 'vue'
onMounted(() => {
const params = new URLSearchParams(window.location.search)
const legacyId = params.get('q')
const path = legacyId && /^[0-9a-f]{10}$/.test(legacyId) ? legacyShortUrlMap[legacyId] : undefined
useRouter().go(path === undefined ? '/404' : `/${encodePagePath(path)}`, { replace: true })
})
</script>---
layout: false
head:
- - meta
- name: robots
content: noindex
---
<Loading />
<ClientOnly><Jumper /></ClientOnly>
<script lang="ts" setup>
import Jumper from '@features/short-url/jumper.vue'
import Loading from '@features/short-url/loading.vue'
</script>限制
页面路径发生变化(移动、重命名或删除)后,对应的静态短链入口也会在下次构建中消失。本站不额外维护历史路径别名。