Share Notes

chundev

View the Project on GitHub latteouka/share-notes

用 vite-plugin-singlefile 打造離線自包含的 Markdown 檢視器

日期:2026-04-21 技術棧:Vite 6 + React 18 + react-markdown + vite-plugin-singlefile


TL;DR

想把一份多檔 Markdown 文件包成單一 HTML(雙擊開檔、可 AirDrop、沒有伺服器也能看)時,vite-plugin-singlefile + ?raw import 是最乾淨的解。 但如果 markdown 內容本身寫了 [foo.md](foo.md) 這類相對連結,點下去會直接 404——因為瀏覽器真的以為要去讀外部檔案。 用 react-markdowncomponents.a 把 md 路徑攔截、轉成 hash 內部導航即可。


背景

需求是產出一份「雙擊 index.html 就能看全部內容」的離線文件檢視器:

這其實是很常見的離線技術手冊、內部知識包、演練腳本等情境。


核心架構:?raw import + viteSingleFile

vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [react(), viteSingleFile()],
  base: "./",
  build: {
    assetsInlineLimit: 100_000_000,   // 提高門檻,確保所有 asset 都能 inline
    cssCodeSplit: false,              // 關閉 CSS 分割,全部併成一份
    chunkSizeWarningLimit: 100_000_000,
  },
});

內容匯入(content.ts

import chapter1 from "./content/01-intro.md?raw";
import chapter2 from "./content/02-setup.md?raw";

export const SECTIONS = [
  { id: "intro", label: "簡介", body: chapter1 },
  { id: "setup", label: "安裝設定", body: chapter2 },
];

?raw 的作用:Vite 官方 import suffix,把檔案內容當作字串在 build 階段直接 inline 進 JS bundle。這跟執行期 fetch("./01-intro.md") 本質不同——?raw 是編譯期行為,build 完字串已經嵌在 JS 裡了。

渲染(React + react-markdown)

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

<ReactMarkdown remarkPlugins={[remarkGfm]}>{section.body}</ReactMarkdown>

結果

pnpm build 之後 dist/ 只剩 一份 index.html(我的專案約 1MB),JS / CSS / 所有 markdown 字串全部 inline。雙擊即開,沒有任何網路請求。


問題:markdown 內部連結會 404

開起來看似完美,但點擊內文裡的 [下一章](02-setup.md) 時,瀏覽器跳轉到 file:///.../02-setup.md——404 / 檔案不存在

看起來就像「HTML 沒有真的把內容吃進去」,但實際上字串全都在 bundle 裡,只是 markdown 原始碼寫的相對連結被 react-markdown 原封不動產生成 <a href="02-setup.md">,瀏覽器當成真的檔案連結去處理。

原因

  1. react-markdown 預設把 [text](url) 直接轉成 <a href={url}>text</a>,不做 URL resolution
  2. file:// 協議下,相對 URL 會解析成磁碟路徑
  3. 這些檔案根本不存在於磁碟(都 inline 進 HTML 了)→ 404

關鍵洞察:「HTML 自包含」只保證渲染需要的資源都在檔案裡,但內容本身的超連結指向什麼還是由 markdown 原始碼決定。


解法:攔截 <a> 把 md 路徑轉成 hash 導航

react-markdowncomponents prop 允許覆寫任何 HTML 元素的渲染。因為 vite-plugin-singlefile 的限制頁明確寫了 hash-based routing 可以用於 file:// 協議(Web History API 反而不行),最乾淨的解就是:

  1. 在 app 裡用 location.hash 驅動章節切換(不用 React Router)
  2. 攔截 markdown 生成的 <a>,把 .md 連結轉成 #<section-id>
  3. 外部 http(s) 連結開新分頁、其他連結保留原行為

實作

const MD_TO_SECTION: Record<string, string> = {
  "01-intro.md": "intro",
  "02-setup.md": "setup",
  // ...
};

function resolveMdHref(href: string): string | null {
  if (!href) return null;
  if (/^https?:\/\//i.test(href)) return null;
  const withoutHash = href.split("#")[0] ?? "";
  const filename = withoutHash.split("/").pop() ?? "";
  const id = MD_TO_SECTION[filename];
  return id ? `#${id}` : null;
}

<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  components={{
    a: ({ href, children, ...rest }) => {
      const mapped = resolveMdHref(href ?? "");
      if (mapped) {
        return (
          <a
            {...rest}
            href={mapped}
            onClick={(e) => {
              e.preventDefault();
              navigateToSection(mapped.slice(1));
            }}
          >
            {children}
          </a>
        );
      }
      const external = href && /^https?:\/\//i.test(href);
      return (
        <a
          {...rest}
          href={href}
          target={external ? "_blank" : undefined}
          rel={external ? "noreferrer noopener" : undefined}
        >
          {children}
        </a>
      );
    },
  }}
>
  {section.body}
</ReactMarkdown>

搭配 hash router

useEffect(() => {
  const onHash = () => {
    const id = window.location.hash.slice(1);
    if (id && SECTIONS.some((s) => s.id === id)) setActiveId(id);
  };
  window.addEventListener("hashchange", onHash);
  onHash();
  return () => window.removeEventListener("hashchange", onHash);
}, []);

這樣點連結會更新 location.hash,hash 變化驅動 state 切換章節——全程都在 file:// 協議下運作


除錯過程

嘗試 做法 結果 原因
❌ 1 懷疑 ?raw 沒真的 inline wc -c dist/index.html 看到 1MB 其實有 inline,字串都在 JS bundle
❌ 2 懷疑 viteSingleFile 沒生效 看 build log 有 Inlining: index-xxx.js 有生效
❌ 3 想把 md 連結從原始檔裡刪掉 不想改內容 不優雅,且會失去章節跳轉能力
✅ 4 components.a 攔截,轉 hash 導航 點擊正常跳章節 react-markdown 的官方 extension point

最有用的 debug 指令

# 驗證內容真的 inline 在 HTML
grep -c "某個只會出現在 md 檔裡的獨特字串" dist/index.html
# 如果輸出 > 0,就代表字串真的嵌進去了,問題不在 inline

替代方案比較

方案 優點 缺點
react-markdown + components.a(本文作法) 官方 API、保留 React 組件化、無需自己 sanitize HTML 需要建立 md filename → section id 對照表
在 markdown 原始檔把 [foo](foo.md) 改成 [foo](#foo) 不寫 JS 攔截邏輯 汙染原始內容、來源 md 若也用於其他 renderer 會失去語意
寫一個 remark 插件在解析階段改 URL 最「正確」的層級、不需要維護對照表 學習曲線陡、只為了一個小改動 overkill

決策理由:保留 markdown 原始碼的可攜性(同一份 md 也可以給別的 renderer 用),把呈現層的路由決策留在應用程式裡,這是職責分離最乾淨的切法。


學到的事


參考資料