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: "collect-secrets.sh"
layout: code-only
---

<<< @/skills/security/collect-secrets.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.

The same applies to any separate hand-maintained index or listing page: a code-only wrapper (or co-located artifact) filtered out of the sidebar should not be linked from the index either, or the two surfaces drift. See Auto Nav for deriving one canonical page list and testing every surface against it.

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.