Appearance
Linked Assets
Make relative links from markdown to non-markdown files (scripts, configs, demo pages) resolve to real URLs on the docs site, and let machines curl raw text from a stable address.
When to use
VitePress's dead-link checker treats unknown extensions like .sh as missing pages. Even for known extensions like .yaml or .js, VitePress only validates the link — it never copies the linked file into dist/. So a relative link [script](./foo.sh) from a skill page either fails the build or 404s silently when clicked. Use this pattern any time a skill links to a non-markdown file.
The rule: if a markdown file links to a non-md file, just make the link work. No extension lists to maintain, no manual wrappers to write.
Two flavors based on what the browser knows how to do with the file:
- Code — the default for anything not browser-handled. Render as a syntax-highlighted, full-bleed page that mirrors GitHub's blob view; also expose
<file>.rawfor plain-text curl. - Browser-handled —
.html, images, fonts, video, archives. The file is served as-is at the native URL; the browser does the rest.
The pattern
Three pieces work together: a discovery pass that scans markdown for linked non-md files, an auto-generated wrapper per code file that triggers the rendered viewer build, and a code-only layout that strips chrome from those viewer pages.
Discover linked assets
Run the discovery at config-evaluation time, before VitePress scans the source tree — so any auto-generated wrappers exist when VitePress reads .md sources:
js
import { globSync } from "glob";
import {
readFileSync, writeFileSync, existsSync,
} from "node:fs";
import { dirname, basename, posix } from "node:path";
const BROWSER_HANDLES = new Set([
"html", "htm",
"png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico",
"pdf", "zip", "gz", "tar",
"mp3", "mp4", "wav", "ogg", "webm", "mov",
"ttf", "woff", "woff2", "otf",
]);
function discoverLinkedAssets() {
const mdFiles = globSync("**/*.md", {
ignore: ["**/node_modules/**", ".vitepress/**", "dist/**"],
});
const code = new Set();
const page = new Set();
const inlineRe = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
const refDefRe = /^[ \t]*\[([^\]]+)\]:[ \t]+(\S+)/gm;
const refUseRe = /\[(?:[^\]\n]|\\\])*\]\[([^\]\n]+)\]/g;
function classify(url, mdDir) {
const clean = url.split("#")[0].split("?")[0];
if (!clean || /^[a-z]+:\/\//i.test(clean) || clean.startsWith("/")) return;
if (clean.endsWith(".md") || clean.endsWith("/")) return;
const target = posix.normalize(posix.join(mdDir, clean));
if (target.startsWith("..") || !existsSync(target)) return;
const base = basename(target);
const dot = base.lastIndexOf(".");
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
// VitePress ignores dotfile-prefixed paths (`.github/`, `.vitepress/`)
// when scanning sources, so a `.X.md` wrapper there won't be built.
const inHiddenDir = target.split("/").some(s => s.startsWith("."));
// Extensionless files (`LICENSE`, `COPYING`) and browser-handled
// files ship as raw at the native URL.
if (BROWSER_HANDLES.has(ext) || inHiddenDir || !ext) {
page.add(target);
} else {
code.add(target);
}
}
for (const mdFile of mdFiles) {
const stripped = readFileSync(mdFile, "utf-8")
.replace(/```[\s\S]*?```/g, "") // skip fenced code blocks
.replace(/`[^`]*`/g, ""); // skip inline code
const mdDir = dirname(mdFile);
// Inline links: [text](url)
for (const match of stripped.matchAll(inlineRe)) {
classify(match[1], mdDir);
}
// Reference-style: [text][ref] paired with `[ref]: url`.
const refDefs = new Map();
for (const match of stripped.matchAll(refDefRe)) {
refDefs.set(match[1].toLowerCase(), match[2]);
}
for (const match of stripped.matchAll(refUseRe)) {
const url = refDefs.get(match[1].toLowerCase());
if (url) classify(url, mdDir);
}
}
return { code: [...code], page: [...page] };
}
function ensureCodeWrappers(codeAssets) {
for (const target of codeAssets) {
const wrapperPath = target + ".md";
if (existsSync(wrapperPath)) continue;
writeFileSync(
wrapperPath,
`---\ntitle: "${basename(target)}"\nlayout: code-only\n---\n\n` +
`<<< @/${target}\n`,
);
}
}
const linkedAssets = discoverLinkedAssets();
ensureCodeWrappers(linkedAssets.code);Notes on the discovery:
- Handles both inline
[text](url)and reference-style[text][ref]+[ref]: url. Reference labels match case-insensitively per CommonMark. - Strips fenced and inline code blocks before scanning, so a
[fake](./missing.sh)example inside a code block doesn't pollute the result set. - Drops anything that doesn't exist on disk — this isn't a link checker. VitePress's own dead-link check still runs after.
- The
BROWSER_HANDLESset + the dotfile-dir check together decide classification: anything not browser-handled and not under a.foo/path becomes code. New text-based file types (e.g..toml,.rs) just work without changes; assets under.github/get raw at the native URL because VitePress won't build a wrapper inside a hidden directory.
Vite plugin: emit assets and serve in dev
The plugin emits the discovered assets into dist/ for production builds and serves the same URLs via middleware in dev mode (so links work without restarting in preview):
js
function linkedAssetsPlugin({ code, page }) {
let base = "/";
const codeSet = new Set(code);
const pageSet = new Set(page);
return {
name: "linked-assets",
configResolved(config) {
base = config.base;
},
generateBundle() {
for (const target of codeSet) {
this.emitFile({
type: "asset",
fileName: target + ".raw",
source: readFileSync(target),
});
}
for (const target of pageSet) {
this.emitFile({
type: "asset",
fileName: target,
source: readFileSync(target),
});
}
},
configureServer(server) {
const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const stripBase = (url) => {
const match = url.match(
new RegExp(`^${escapedBase}(.+?)(?:\\?.*)?$`),
);
return match ? match[1] : null;
};
server.middlewares.use((req, res, next) => {
const path = stripBase(req.url ?? "");
if (!path) return next();
if (path.endsWith(".raw")) {
const target = path.slice(0, -".raw".length);
if (codeSet.has(target)) {
res.setHeader("content-type", "text/plain; charset=utf-8");
res.end(readFileSync(target));
return;
}
}
if (pageSet.has(path)) {
// Set Content-Type explicitly so fonts/SVG/video render
// correctly; the browser's MIME sniffing isn't reliable for
// non-HTML types served with no header.
res.setHeader("content-type", mimeFor(path));
res.end(readFileSync(path));
return;
}
// Code native URL: rewrite to SPA shell so the rendered viewer
// page wins over vite's static-file serving of the raw source.
if (codeSet.has(path)) req.url = base;
next();
});
},
};
}configResolved captures config.base so the dev middleware doesn't hardcode the base path — change the site's base and the dev server keeps matching.
Auto-generated wrapper
ensureCodeWrappers writes one of these next to each linked code file:
markdown
---
title: "sync-claude-action.sh"
layout: code-only
---
<<< @/skills/github/sync-claude-action.shThe <<< is a built-in VitePress snippet import — embeds the file as a fenced code block, language inferred from the extension. If a wrapper already exists, it's left alone — authors can override title or layout per file.
Code-only layout
The wrapper's layout: code-only triggers a custom layout that strips all chrome — no nav, no sidebar, no header. Just the syntax-highlighted code with a discreet "raw" link in the corner.
vue
<template>
<div class="code-only-layout">
<a class="raw-link" :href="rawUrl" target="_blank">raw</a>
<Content />
</div>
</template>
<script setup>
import { Content, useData } from "vitepress";
import { computed } from "vue";
const { page, site } = useData();
const rawUrl = computed(() => {
const sourcePath = page.value.relativePath.replace(/\.md$/, "");
return site.value.base + sourcePath + ".raw";
});
</script>
<style>
.code-only-layout div[class*="language-"] .lang {
display: none;
}
/* hide the "sh" / "yaml" language label that floats top-right */
</style>The site's main custom layout dispatches based on frontmatter:
vue
<template>
<CodeOnlyLayout v-if="frontmatter.layout === 'code-only'" />
<Layout v-else>
<!-- normal doc layout -->
</Layout>
</template>Dev-mode URL collision: why the SPA-shell rewrite
In dev mode, Vite's static-file middleware runs before VitePress's page router. If a request comes in for /skills/foo/bar.sh, Vite finds the source bar.sh file on disk and serves it as plain text — beating VitePress's rendered page for the same URL. The middleware short-circuits this by rewriting code-native URLs to the SPA shell (req.url = base); the client-side router then resolves the rendered page.
Production builds don't have this collision: dist/ only contains the emitted .X.html rendered output and the .X.raw asset, no source .X file at the colliding path.
Keep wrappers out of the sidebar
Auto-generated wrappers are plumbing for inline links, not navigation entries. A sidebar that auto-builds from every markdown file in a directory will pick them up by default — filter them out by their layout: code-only frontmatter.
A wrapper is <<< @/path/to/file with no surrounding prose. Reaching it via direct sidebar nav (rather than via a contextual inline link from a topic) lands the user on an unexplained code dump. The skill's narrative should mention any file worth highlighting; the inline link from there is the navigation path.
If you ever genuinely want a code wrapper to appear in the nav, write a real topic .md next to it that explains the file and [link](./foo.sh) to the wrapper — the topic gets the sidebar slot, the wrapper stays plumbing.
Trade-offs
.X.rawon the docs site vs GitHub raw URL. This pattern publishes raw at/path/to/file.X.rawon the docs site. An alternative is to point machines athttps://raw.githubusercontent.com/owner/repo/main/path/to/file.Xdirectly. GitHub raw is free and stable for public repos but returns 404 to anonymous requests for private repos. The on-docs-site approach works for both visibility states because GitHub Pages can be public regardless of repo visibility.- Auto-generated wrappers committed vs gitignored. This pattern commits the auto-generated wrapper files to git, so reviewers can see what pages a PR adds. The alternative is gitignoring them and letting the build regenerate on every checkout. Committed is more visible; gitignored saves PR noise.
- Discovery at config time vs at build time. This pattern runs discovery in the
defineConfigbody so wrappers exist before VitePress scans the source tree. Running discovery in a Vite plugin'sbuildStartis too late — VitePress has already cached its source list. If you want hot-reload of newly added links during dev, hook into Vite's file watcher.
See also
- Auto Nav: The same config-time globbing pattern that auto-builds the sidebar from the file system.
- Copy Buttons: Another
transformPageData+ custom layout integration that adds per-page UI without forking the theme.