Skip to content

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>.raw for 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_HANDLES set + 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.sh

The <<< 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.raw on the docs site vs GitHub raw URL. This pattern publishes raw at /path/to/file.X.raw on the docs site. An alternative is to point machines at https://raw.githubusercontent.com/owner/repo/main/path/to/file.X directly. 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 defineConfig body so wrappers exist before VitePress scans the source tree. Running discovery in a Vite plugin's buildStart is 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.