跳转到内容
关系图谱

remote-file:远程源码文件嵌入完全指南

2,257 字 9 分钟

属性 1
series
VaultKit 包指南

@vaultkit/vitepress-plugin-remote-file 提供 ::: remote-file 容器:构建期抓取 GitHub / CNB 仓库中的文件内容直接嵌入文章,免复制粘贴,随每次部署自动同步。嵌入产物带出处 chrome(平台徽标 + 可点路径链接 + ref / 行号徽标),代码本体走站点自己的 shiki 管线——高亮主题、行号、行高亮 meta 全部天然可用。

它是系列中架构最讲究的一个:两段协作——remoteFile() Vite 插件负责抓取与原文改写,remoteFileFrame() markdown-it 容器负责出处 chrome,缺一不可。本文覆盖三种 URL 形态、全部 7 个容器属性、全部 5 个插件 options,并讲透两段架构的必然性。

为什么需要它

技术文章引用上游仓库代码,复制粘贴是万恶之源:上游改了你不知道,文章里的代码悄悄过时;而「贴个链接让读者自己去看」又打断阅读流。remote-file 取中道——构建期抓取真实文件内容嵌进页面,出处链接与行号徽标保留可溯源性,每次部署自动与上游同步。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-remote-file
ts
// .vitepress/config.ts **两段都要注册**,位置不同
import { remoteFile, remoteFileFrame } from '@vaultkit/vitepress-plugin-remote-file'
import { defineConfig } from 'vitepress'

export default defineConfig({
  vite: { plugins: [remoteFile()] }, // 第一段:Vite 插件
  markdown: {
    config(md) {
      md.use(remoteFileFrame) // 第二段:markdown-it 容器
    },
  },
})
ts
// .vitepress/theme/index.ts
import '@vaultkit/vitepress-plugin-remote-file/styles/index.css'

使用 @vaultkit/vitepress-theme-vault 主题则开箱即带,容器直接可用。

快速上手

主参数走 info-string 位,单行即闭合、::: 收尾可省:

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/package.json {lines="1-15"}

实活效果(本页构建期真实抓取 VitePress 仓库的 package.json 前 15 行):

package.json
json
{
  "name": "vitepress",
  "version": "2.0.0-alpha.18",
  "description": "Vite & Vue powered static site generator",
  "keywords": [
    "vite",
    "vue",
    "vitepress"
  ],
  "homepage": "https://vitepress.dev/",
  "bugs": {
    "url": "https://github.com/vuejs/vitepress/issues"
  },
  "repository": "github:vuejs/vitepress",
  "license": "MIT",

三种 URL 形态

平台从 URL 自动识别,全部支持 #L10-L40 行区间片段:

GitHub blob 页

日常从浏览器地址栏复制的形态,分支名或 commit SHA(permalink,文件页按 y 获取)都行,refs/heads/ 前缀自动归一:

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/src/shared/shared.ts#L1-L16
shared.ts
ts
import type {
  AdditionalConfig,
  HeadConfig,
  PageData,
  SiteData
} from '../../types/shared'

export type {
  AdditionalConfig,
  AdditionalConfigDict,
  AdditionalConfigLoader,
  Awaitable,
  DefaultTheme,
  HeadConfig,
  Header,
  LocaleConfig,

GitHub raw 地址

raw.githubusercontent.com 形态同样直接可用:

md
::: remote-file https://raw.githubusercontent.com/vuejs/vitepress/main/package.json
package.json
json
{
  "name": "vitepress",
  "version": "2.0.0-alpha.18",
  "description": "Vite & Vue powered static site generator",
  "keywords": [
    "vite",
    "vue",
    "vitepress"
  ],
  "homepage": "https://vitepress.dev/",
  "bugs": {
    "url": "https://github.com/vuejs/vitepress/issues"
  },
  "repository": "github:vuejs/vitepress",
  "license": "MIT",
  "author": "Evan You",
  "type": "module",
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "default": "./dist/node/index.js"
    },
    "./dist/*": "./dist/*",
    "./package.json": "./package.json",
    "./client": {
      "types": "./client.d.ts",
      "default": "./dist/client/index.js"
    },
    "./theme": {
      "types": "./theme.d.ts",
      "default": "./dist/client/theme-default/index.js"
    },
    "./theme-without-fonts": {
      "types": "./theme-without-fonts.d.ts",
      "default": "./dist/client/theme-default/without-fonts.js"
    }
  },
  "main": "dist/node/index.js",
  "types": "types/index.d.ts",
  "bin": {
    "vitepress": "bin/vitepress.js"
  },
  "files": [
    "bin",
    "dist",
    "types",
    "template",
    "client.d.ts",
    "theme.d.ts",
    "theme-without-fonts.d.ts",
    "lib"
  ],
  "scripts": {
    "clean": "node -e \"require('node:fs').rmSync('./dist',{recursive:!0,force:!0,maxRetries:10})\"",
    "dev": "pnpm clean && pnpm dev:shared && pnpm dev:start",
    "dev:start": "pnpm --stream '/^dev:(client|node|watch)$/'",
    "dev:client": "tsc --sourcemap -w --preserveWatchOutput -p src/client",
    "dev:node": "DEV=true pnpm build:node -w",
    "dev:shared": "node scripts/copyShared.ts",
    "dev:watch": "node scripts/watchAndCopy.ts",
    "build": "pnpm build:prepare && pnpm build:client && pnpm build:node",
    "build:prepare": "pnpm clean && node scripts/copyShared.ts",
    "build:client": "vue-tsc --noEmit -p src/client && tsc -p src/client && node scripts/copyClient.ts",
    "build:node": "tsc -p src/node --noEmit && rollup --config rollup.config.ts --configPlugin esbuild",
    "test": "pnpm --aggregate-output --reporter=append-only '/^test:(unit|e2e|init)$/'",
    "test:unit": "vitest run -r __tests__/unit",
    "test:unit:watch": "vitest -r __tests__/unit",
    "test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",
    "test:e2e:site:dev": "pnpm -F=tests-e2e site:dev",
    "test:e2e:site:build": "pnpm -F=tests-e2e site:build",
    "test:e2e:site:preview": "pnpm -F=tests-e2e site:preview",
    "test:e2e-dev": "pnpm -F=tests-e2e test",
    "test:e2e-dev:watch": "pnpm -F=tests-e2e watch",
    "test:e2e-build": "VITE_TEST_BUILD=1 pnpm test:e2e-dev",
    "test:e2e-build:watch": "VITE_TEST_BUILD=1 pnpm test:e2e-dev:watch",
    "test:init": "pnpm -F=tests-init test",
    "test:init:watch": "pnpm -F=tests-init watch",
    "docs": "pnpm --stream '/^(docs:)?dev$/'",
    "docs:dev": "wait-on -d 100 dist/node/cli.js && pnpm -F=docs dev",
    "docs:debug": "NODE_OPTIONS='--inspect-brk' pnpm docs:dev",
    "docs:build": "pnpm build && pnpm docs:build:only",
    "docs:build:only": "pnpm -F=docs build",
    "docs:preview": "pnpm -F=docs preview",
    "docs:lunaria:build": "pnpm -F=docs lunaria:build",
    "docs:lunaria:open": "pnpm -F=docs lunaria:open",
    "format": "prettier --experimental-cli --write .",
    "format:fail": "prettier --experimental-cli --check .",
    "check": "pnpm format:fail && pnpm build && pnpm test",
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
    "release": "node scripts/release.ts"
  },
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged"
  },
  "lint-staged": {
    "*": "prettier --experimental-cli --ignore-unknown --write"
  },
  "dependencies": {
    "@docsearch/css": "^4.6.3",
    "@docsearch/js": "^4.6.3",
    "@docsearch/sidepanel-js": "^4.6.3",
    "@iconify-json/simple-icons": "^1.2.87",
    "@shikijs/core": "^4.3.0",
    "@shikijs/transformers": "^4.3.0",
    "@shikijs/types": "^4.3.0",
    "@types/markdown-it": "^14.1.2",
    "@vitejs/plugin-vue": "^6.0.7",
    "@vue/devtools-api": "^8.1.4",
    "@vue/shared": "^3.5.39",
    "@vueuse/core": "^14.3.0",
    "@vueuse/integrations": "^14.3.0",
    "focus-trap": "^8.2.2",
    "mark.js": "8.11.1",
    "minisearch": "^7.2.0",
    "shiki": "^4.3.0",
    "vite": "^8.1.3",
    "vue": "^3.5.39"
  },
  "devDependencies": {
    "@clack/prompts": "^1.6.0",
    "@emnapi/core": "^1.11.1",
    "@emnapi/runtime": "^1.11.1",
    "@iconify/utils": "^3.1.3",
    "@mdit-vue/plugin-component": "^3.0.2",
    "@mdit-vue/plugin-frontmatter": "^3.0.2",
    "@mdit-vue/plugin-headers": "^3.0.2",
    "@mdit-vue/plugin-sfc": "^3.0.2",
    "@mdit-vue/plugin-title": "^3.0.2",
    "@mdit-vue/plugin-toc": "^3.0.2",
    "@mdit-vue/shared": "^3.0.2",
    "@polka/compression": "^1.0.0-next.28",
    "@rollup/plugin-alias": "^6.0.0",
    "@rollup/plugin-commonjs": "^29.0.3",
    "@rollup/plugin-json": "^6.1.0",
    "@rollup/plugin-node-resolve": "^16.0.3",
    "@rollup/plugin-replace": "^6.0.3",
    "@types/cross-spawn": "^6.0.6",
    "@types/lodash.template": "^4.5.3",
    "@types/mark.js": "^8.11.12",
    "@types/markdown-it-attrs": "^4.1.3",
    "@types/markdown-it-container": "^4.0.0",
    "@types/markdown-it-emoji": "^3.0.1",
    "@types/minimist": "^1.2.5",
    "@types/node": "^25.9.4",
    "@types/picomatch": "^4.0.3",
    "@types/semver": "^7.7.1",
    "chokidar": "^5.0.0",
    "conventional-changelog": "^7.2.1",
    "conventional-changelog-angular": "^8.3.1",
    "cross-spawn": "^7.0.6",
    "esbuild": "^0.27.7",
    "get-port": "^7.2.0",
    "gray-matter": "^4.0.3",
    "image-size": "^2.0.2",
    "lint-staged": "^16.4.0",
    "lodash.template": "^4.18.1",
    "lru-cache": "^11.5.1",
    "markdown-it": "^14.2.0",
    "markdown-it-anchor": "^9.2.0",
    "markdown-it-async": "^2.2.0",
    "markdown-it-attrs": "4.3.1",
    "markdown-it-cjk-friendly": "^2.0.2",
    "markdown-it-container": "^4.0.0",
    "markdown-it-emoji": "^3.0.0",
    "markdown-it-mathjax3": "^4.3.2",
    "minimist": "^1.2.8",
    "nanoid": "^5.1.16",
    "obug": "^2.1.3",
    "ora": "^9.4.1",
    "p-map": "^7.0.4",
    "package-directory": "^8.2.0",
    "path-to-regexp": "^6.3.0",
    "picocolors": "^1.1.1",
    "picomatch": "^4.0.4",
    "playwright-chromium": "^1.61.1",
    "polka": "^1.0.0-next.28",
    "postcss": "^8.5.6",
    "postcss-selector-parser": "^7.1.4",
    "prettier": "^3.9.0",
    "punycode": "^2.3.1",
    "rollup": "^4.62.2",
    "rollup-plugin-dts": "6.1.1",
    "rollup-plugin-esbuild": "^6.2.1",
    "semver": "^7.8.5",
    "simple-git-hooks": "^2.13.1",
    "sirv": "^3.0.2",
    "sitemap": "^9.0.1",
    "tinyglobby": "^0.2.17",
    "typescript": "^5.9.3",
    "vitest": "^4.1.9",
    "vue-tsc": "^3.3.5",
    "wait-on": "^9.0.10"
  },
  "peerDependencies": {
    "markdown-it-mathjax3": "^4",
    "postcss": "^8"
  },
  "peerDependenciesMeta": {
    "markdown-it-mathjax3": {
      "optional": true
    },
    "postcss": {
      "optional": true
    }
  },
  "packageManager": "pnpm@10.33.0"
}

CNB 文件页

cnb.cool 的 /-/blob/(及 /-/raw//-/git/raw/)形态,仓库路径可含多级组织;构建需要 CNB_TOKEN 环境变量:

md
::: remote-file https://cnb.cool/examples/showcase/-/blob/main/README.md {lines="1-10"}
README.md
md
# CNB完整示例

![badge](/-/badge/git/latest/ci/pipeline-as-code)
![badge](/-/badge/git/latest/ci/status/auto_merge)

## NPC

1. [Claude Code](https://cnb.cool/npc/claude-code)
2. [Open Code](https://cnb.cool/npc/open-code)
3. [HOK - 王者荣耀英雄氛围编程助手](https://cnb.cool/npc/hok)

容器属性逐项详解

全部 7 个属性(repo / ref / path 为一组显式坐标):

属性类型缺省说明
urlstring文件 URL(等价于 info-string 主参数位)
linesstringURL 的 #L… 片段行区间,"10-40" 或单行 "5"
langstring按扩展名推断覆盖 shiki 语言
titlestring文件名代码块标题栏文案
metastring透传围栏 meta
repo / pathstringCNB 旧语法的显式坐标
refstringmain显式坐标的分支 / SHA

url

文件地址的属性写法,与 info-string 主参数完全等价(主参数优先):

md
::: remote-file {url="https://github.com/vuejs/vitepress/blob/main/package.json"}

lines

行区间截取,优先级高于 URL 的 #L… 片段"10-40" 取区间,"5" 取单行。截取结果同步显示为出处栏的行号徽标(L10-L40):

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/package.json {lines="1-15"}

lang

覆盖代码高亮语言。缺省按文件扩展名推断,内置一批别名(mjs/cjsjsmts/ctstsymlyamldockerfiledockermakefilemake),无扩展名文件落 txt。推断不准时手工指定:

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/.prettierrc {lang="json"}

title

代码块标题栏文案,缺省取文件名:

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/package.json {title="VitePress 的包清单"}

meta

原样透传给生成的代码围栏 meta 位——站点 shiki 管线认识什么它就支持什么,最常用的是行高亮:

md
::: remote-file https://github.com/vuejs/vitepress/blob/main/package.json {lines="1-15" meta="{2-3}"}

repo / ref / path

CNB 旧语法的显式坐标形态(不给 URL 时生效),ref 缺省 main

md
::: remote-file {repo="examples/showcase" ref="main" path="README.md"}

另有两个兼容别名容器 ::: github-file / ::: cnb-file,构建期打弃用告警,下个大版本移除——新文章一律用 ::: remote-file <url>

插件 Options 全览

remoteFile(options) 共 5 项(remoteFileFrame(md) 无选项):

名称类型默认值说明
githubTokenstring环境变量 GITHUB_TOKENGitHub 令牌,可选(仅提额)
cnbTokenstring环境变量 CNB_TOKENCNB 令牌,CNB 文件必需
cnbApiBasestring'https://api.cnb.cool'CNB API 基址
ttlnumber3_600_000缓存有效期(ms,缺省 1 小时)
cacheDirstringnode_modules/.cache/vitepress-plugin-remote-file缓存目录(CI 建议持久化)
ts
remoteFile({
  ttl: 24 * 3_600_000,
  cacheDir: '.cache/remote-file',
})

设计机制

两段架构:为什么缺一不可

第一段 remoteFile()(Vite 插件,enforce: 'pre'):markdown-it 的解析规则是同步的,没法 await fetch()——md 编译之前的 Vite transform 是唯一干净的异步时机。它抓取文件内容,把容器改写为「remote-file-frame 容器 + 普通代码围栏」的组合(示意,行首空格为围栏保护):

md
::: remote-file-frame {platform="github" path="vuejs/vitepress/package.json" ref="main" href="…" lines="L1-L15"}
 ```json [package.json]
 …抓取到的文件内容…
 ```
 :::

关键抉择在这里:改写为普通代码围栏而不是自渲染 HTML。这样代码本体走站点自己的 shiki 管线,高亮主题、行号、文件名标题栏、meta 行高亮全部天然可用——插件不重复造高亮的轮子。

第二段 remoteFileFrame()(markdown-it 容器):改写产物里的 frame 容器需要有人渲染成出处 chrome(平台徽标 + 路径链接 + ref / 行号徽标,data-platform 切换 GitHub / CNB 视觉)。这一步必须发生在 markdown-it 管线内——因为它要包住已被 shiki 处理过的代码块。

少注册第一段,容器原样留在页面上;少注册第二段,改写出的 frame 容器没人认识。所以二者缺一不可。

围栏安全:自动加长反引号

嵌入的文件内容本身可能含三反引号围栏串(比如嵌一个 Markdown 文件)。插件扫描内容里最长的行首反引号串,把包裹围栏加长到比它多一根——CommonMark 规定围栏必须长于内容中的任意围栏标记,任意文件内容都不破版。

TTL 缓存 + 陈缓存回退

与 repo-card 同款(复用 @vaultkit/repo-platform-kit):TTL 内直接命中磁盘缓存;过期重抓失败则回退陈缓存并告警;既无网又无缓存才 fail-fast。上游删文件、断网、限额都不会让旧文章突然构建失败。

行区间锚定的忠告

分支形态的 URL(blob/main/...)里,上游一旦改动文件,行号立即漂移。需要长期稳定的行区间时,用 commit SHA 固定链接(GitHub 文件页按 y 得 permalink)——代价是不再跟随上游更新,按引用意图二选一。

常见问题

嵌入内容什么时候更新? 构建时缓存过期即重抓。想强制刷新,删缓存目录再构建。

能嵌私有仓库吗? 能,GITHUB_TOKEN / CNB_TOKEN 有权限即可——但注意内容会被固化进公开页面。

围栏里能顶格演示 ::: remote-file 语法吗? 能。第一段的原文改写会先按 CommonMark 围栏规则切段、只处理围栏外的文本,代码块里的示例原样保留。

相关链接