Skip to content

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 生成都放在共享模块中:

short-url.ts
ts
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),主要是考虑到大小写敏感的问题。静态入口的本质是 HTML 文件,部分平台上文件名是大小写不敏感的。

静态入口的好处 ​

也可以在 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。

生成的入口大致如下:

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 不得与真实的顶层路由或已有构建产物冲突。

完整实现见:

map-short-url.ts
ts
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 插件与构建结束钩子:

.vitepress/config.mts
ts
export default {
  transformPageData: collectShortUrlPageData,
  buildEnd: async (siteConfig) => {
    await generateShortUrlRedirects(siteConfig)
  },
  vite: {
    plugins: [mapShortUrl()],
  },
}

在开发环境中尚未存在构建产物,所以插件的开发服务器中间件会将有效短码临时 302 到目标路径。

链接分享 ​

网页上生成分享链接时,createShareUrl 会比较完整直链与根路径短链的实际长度,只在短链确实更短时返回短链。

ts
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。

vue
<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>
md
---
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>

限制 ​

页面路径发生变化(移动、重命名或删除)后,对应的静态短链入口也会在下次构建中消失。本站不额外维护历史路径别名。

相关文档 ​