把 Obsidian 筆記變成公開網頁:Cloudflare Pages 完整教學

2026-02-19
obsidiancloudflarepublishtutorial

把 Obsidian 筆記變成公開網頁:Cloudflare Pages 完整教學

這份教學會帶你從零開始,把一個 Obsidian Markdown 檔案,變成一個可以直接分享的公開網址。全程免費,部署在 Cloudflare Pages 上。


你會得到什麼


前置準備

1. 安裝 Node.js

前往 nodejs.org 下載安裝 Node.js(建議 v18 以上)。

安裝完成後,在終端機確認:

node --version
# 應顯示 v18.x.x 或以上

2. 安裝 Wrangler CLI(Cloudflare 工具)

npm install -g wrangler

3. 申請 Cloudflare 帳號

前往 cloudflare.com 免費註冊帳號。

4. 登入 Cloudflare

wrangler login

執行後會跳出瀏覽器視窗,點擊授權即可。


第一次設定(只需做一次)

建立 Cloudflare Pages 專案

wrangler pages project create vault-pub

這會在你的 Cloudflare 帳號建立一個名為 vault-pub 的 Pages 專案。名字可以自訂,但後續指令需要一致。


目錄結構

設定完成後,你的工作目錄大致如下:

my-publish/
├── build/          ← 轉換後的 HTML 會放在這裡
├── notes/          ← 你的 Obsidian MD 檔案放這裡
├── convert.mjs     ← 轉換腳本(MD → HTML)
├── deploy.sh       ← 部署腳本
└── template.html   ← HTML 模板(決定樣式)

核心流程:三個步驟

MD 檔案  →  [轉換]  →  HTML  →  [部署]  →  Cloudflare Pages  →  公開網址

步驟一:準備你的 Markdown 檔案

把你要分享的 Obsidian 筆記放到 notes/ 資料夾。

範例檔案 notes/my-first-note.md

---
title: 我的第一篇筆記
tags: [tutorial, obsidian]
---

# 我的第一篇筆記

這是一段普通文字。

<div class="callout callout-tip"><div class="callout-title">小提示</div><div class="callout-content">

這是 Obsidian callout 語法,會被轉換成漂亮的提示框。

</div></div>

## 程式碼範例

```python
def hello():
    print("Hello, World!")

### 步驟二:執行轉換

```bash
node convert.mjs notes/my-first-note.md my-first-note build/ notes/ template.html

參數說明:

參數說明範例
第1個來源 MD 檔案路徑notes/my-first-note.md
第2個網址用的 slug(英文小寫,用 - 連接)my-first-note
第3個輸出目錄build/
第4個Vault 根目錄(用於解析圖片等資源)notes/
第5個HTML 模板路徑template.html

執行成功後,build/my-first-note/ 裡會出現 index.html

步驟三:部署到 Cloudflare

CF_PROJECT="vault-pub" bash deploy.sh publish my-first-note notes/my-first-note.md "我的第一篇筆記"

等待約 30 秒,部署完成後你會看到:

✅ 部署成功!
🌐 網址:https://vault-pub.pages.dev/my-first-note

把這個網址丟給任何人,他們不需要安裝任何東西就能閱讀。


更新已發布的筆記

如果你修改了 MD 內容,重新執行轉換和更新指令:

# 重新轉換
node convert.mjs notes/my-first-note.md my-first-note build/ notes/ template.html

# 更新部署
CF_PROJECT="vault-pub" bash deploy.sh update my-first-note notes/my-first-note.md "我的第一篇筆記"

查看所有已發布的筆記

bash deploy.sh list

輸出範例:

已發布的筆記(3 篇):
1. my-first-note → https://vault-pub.pages.dev/my-first-note
2. team-guide    → https://vault-pub.pages.dev/team-guide
3. roadmap-2026  → https://vault-pub.pages.dev/roadmap-2026

下架筆記

CF_PROJECT="vault-pub" bash deploy.sh unpublish my-first-note

支援的 Obsidian 語法

語法說明
# 標題 1###### 標題 6標題層級
**粗體** / *斜體*文字強調
`行內程式碼`行內程式碼
```語言名稱程式碼區塊(語法高亮)
> [!tip] 標題Callout(note / tip / warning / important)
![圖片.png](images/圖片.png)圖片嵌入(需放在 Vault 內)
<span class="wikilink-unresolved">其他筆記</span>Wikilink(顯示為純文字)
#tag標籤(顯示在標題下方)
---水平分隔線

常見問題

Q:我的圖片沒有顯示?

圖片必須放在 Vault 目錄內(notes/ 或其子資料夾),且使用 ![圖片名稱.png](images/圖片名稱.png) 語法引用。

Q:部署後網址沒有更新?

Cloudflare Pages 有快取機制,等待 1-2 分鐘再重新整理。或開啟無痕視窗測試。

Q:可以用自訂網域嗎?

可以。在 Cloudflare Pages 設定頁面,進入「Custom Domains」,綁定你自己的網域即可(需要你擁有該網域)。

Q:發布的內容是公開的嗎?

是的,任何知道網址的人都能看到。如果是私人內容,建議在 Cloudflare Pages 設定存取控制(Access Policy)。


完整指令速查表

# 首次設定
npm install -g wrangler
wrangler login
wrangler pages project create vault-pub

# 發布新筆記
node convert.mjs <md路徑> <slug> build/ <vault根目錄> template.html
CF_PROJECT="vault-pub" bash deploy.sh publish <slug> <md路徑> "<標題>"

# 更新已發布
node convert.mjs <md路徑> <slug> build/ <vault根目錄> template.html
CF_PROJECT="vault-pub" bash deploy.sh update <slug> <md路徑> "<標題>"

# 查看清單
bash deploy.sh list

# 下架
CF_PROJECT="vault-pub" bash deploy.sh unpublish <slug>

作者: Marcus × Rin 最後更新: 2026-02-20 如有問題歡迎留言交流。


附錄:完整 Skill 原始碼

以下是這套系統所有核心檔案的完整原始碼,方便你在自己的環境中重建。


SKILL.md(Skill 定義與工作流程)

---
name: publish-note
description: "Publish individual Obsidian notes to the web as beautiful standalone HTML pages."
---

# Publish Note

Convert Obsidian Flavored Markdown notes to responsive HTML pages and deploy to Cloudflare Pages.

## Paths

| Constant | Value |
|----------|-------|
| VAULT_ROOT | ~/clawd/vault |
| BUILD_DIR | VAULT_ROOT/99_System/Published/build |
| REGISTRY_PATH | VAULT_ROOT/99_System/Published/registry.json |
| CONVERT_SCRIPT | SKILL_DIR/scripts/convert.mjs |
| DEPLOY_SCRIPT | SKILL_DIR/scripts/deploy.sh |
| TEMPLATE_PATH | SKILL_DIR/assets/template.html |
| BASE_URL | https://vault-pub.pages.dev |

## Workflow

| Intent | Steps |
|--------|-------|
| publish | 1 → 2 → 2.5 → 3 → 4 |
| list | 5 |
| unpublish | 6 |
| update | 2.5 → 3 → 7 |

## Step 1: Resolve Source File
Resolve .md path relative to VAULT_ROOT. If ambiguous, ask to confirm.

## Step 2: Generate Slug
Translate the note title to a concise English slug (lowercase, hyphenated, max 5 words).

## Step 2.5: Render Mermaid Diagrams
For notes containing mermaid blocks, render to SVG before conversion:
```
mmdc -i /tmp/diagram.mmd -o "VAULT_ROOT/99_System/Attachments/<slug>-<name>.svg" -b transparent
```

## Step 3: Convert OFM to HTML
```
node "CONVERT_SCRIPT" "SOURCE_PATH" "SLUG" "BUILD_DIR" "VAULT_ROOT" "TEMPLATE_PATH" "REGISTRY_PATH"
```

## Step 4: Deploy
```
REGISTRY_PATH="REGISTRY_PATH" BUILD_DIR="BUILD_DIR" CF_PROJECT="vault-pub" \
  bash "DEPLOY_SCRIPT" publish "SLUG" "SOURCE_RELATIVE_PATH" "TITLE"
```

## Step 5: List Published Notes
```
REGISTRY_PATH="REGISTRY_PATH" BUILD_DIR="BUILD_DIR" \
  bash "DEPLOY_SCRIPT" list
```

## Step 6: Unpublish
```
REGISTRY_PATH="REGISTRY_PATH" BUILD_DIR="BUILD_DIR" CF_PROJECT="vault-pub" \
  bash "DEPLOY_SCRIPT" unpublish "SLUG"
```

## Step 7: Update
```
REGISTRY_PATH="REGISTRY_PATH" BUILD_DIR="BUILD_DIR" CF_PROJECT="vault-pub" \
  bash "DEPLOY_SCRIPT" update "SLUG" "SOURCE_RELATIVE_PATH" "TITLE"
```

## First-Time Setup
```
wrangler pages project create vault-pub
wrangler login
```

scripts/convert.mjs(OFM → HTML 轉換腳本)

#!/usr/bin/env node
/**
 * convert.mjs — OFM (Obsidian Flavored Markdown) → HTML converter
 *
 * Usage: node convert.mjs <input.md> <slug> <build-dir> <vault-root> <template-path> [registry-path]
 */

import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'fs';
import { resolve, dirname, basename, join, extname } from 'path';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkFrontmatter from 'remark-frontmatter';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeRaw from 'rehype-raw';
import yaml from 'yaml';

const [,, inputPath, slug, buildDir, vaultRoot, templatePath, registryPath] = process.argv;

if (!inputPath || !slug || !buildDir || !vaultRoot || !templatePath) {
  console.error('Usage: node convert.mjs <input.md> <slug> <build-dir> <vault-root> <template-path> [registry-path]');
  process.exit(1);
}

const absInput = resolve(inputPath);
const absBuild = resolve(buildDir);
const absVault = resolve(vaultRoot);
const absTemplate = resolve(templatePath);

const mdSource = readFileSync(absInput, 'utf-8');

let frontmatter = {};
let mdBody = mdSource;

const fmMatch = mdSource.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (fmMatch) {
  try { frontmatter = yaml.parse(fmMatch[1]) || {}; } catch (e) {}
  mdBody = fmMatch[2];
}

const h1Match = mdBody.match(/^#\s+(.+)$/m);
const title = frontmatter.title || (h1Match ? h1Match[1].trim() : basename(absInput, extname(absInput)));
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];

let publishedSlugs = {};
if (registryPath) {
  try {
    const reg = JSON.parse(readFileSync(resolve(registryPath), 'utf-8'));
    for (const note of reg.notes || []) {
      if (note.status === 'active') {
        const srcName = basename(note.source, extname(note.source));
        publishedSlugs[srcName] = note.slug;
      }
    }
  } catch (e) {}
}

const collectedImages = [];

// YouTube embeds
mdBody = mdBody.replace(
  /!\[\]\((https?:\/\/(?:www\.youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)(?:[&?]t=(\d+)s?)?)(\))/g,
  (match, fullUrl, videoId, t) => {
    const tParam = t ? `?start=${t}` : '';
    return `<iframe src="https://www.youtube.com/embed/${videoId}${tParam}" allowfullscreen loading="lazy" style="width:100%;aspect-ratio:16/9;border:none;border-radius:8px;margin-top:8px;"></iframe>`;
  }
);

// Image embeds: ![image.png](images/image.png)
mdBody = mdBody.replace(/!\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]/g, (match, file, altText) => {
  const alt = altText || file;
  const ext = extname(file).toLowerCase();
  const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp'];
  if (imageExts.includes(ext)) {
    const possiblePaths = [
      join(absVault, '99_System', 'Attachments', file),
      join(absVault, file),
      join(dirname(absInput), file),
    ];
    for (const p of possiblePaths) {
      if (existsSync(p)) {
        collectedImages.push({ src: p, filename: file });
        if (ext === '.svg') {
          try {
            const svgContent = readFileSync(p, 'utf8');
            const vbMatch = svgContent.match(/viewBox=["'][\d.]+ [\d.]+ ([\d.]+) ([\d.]+)["']/);
            if (vbMatch) {
              const svgW = parseFloat(vbMatch[1]);
              const svgH = parseFloat(vbMatch[2]);
              if (svgH > svgW * 1.5) {
                const maxPx = Math.min(Math.round(svgW * 2), 320);
                return `<img src="images/${file}" alt="${alt}" style="max-width:${maxPx}px;display:block;margin:1em auto;">`;
              }
            }
          } catch (_) {}
        }
        return `![${alt}](images/${file})`;
      }
    }
    return `![${alt}](images/${file})`;
  }
  return `> _Embedded: ${file}_`;
});

// Wikilinks: <span class="wikilink-unresolved">Page</span> or <span class="wikilink-unresolved">Display</span>
mdBody = mdBody.replace(/(?<!!)\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]/g, (match, target, display) => {
  const label = display || target;
  const targetName = target.replace(/^.*\//, '');
  if (publishedSlugs[targetName]) return `[${label}](/${publishedSlugs[targetName]})`;
  return `<span class="wikilink-unresolved">${label}</span>`;
});

// Callouts
mdBody = mdBody.replace(
  /^(> \[!(note|tip|warning|important|caution|info|abstract|summary|todo|success|question|failure|danger|bug|example|quote)\]([+-]?)[ ]*(.*)\n)((?:>.*\n?)*)/gim,
  (match, firstLine, type, foldable, calloutTitle, body) => {
    const normalizedType = type.toLowerCase();
    const displayTitle = calloutTitle.trim() || normalizedType.charAt(0).toUpperCase() + normalizedType.slice(1);
    const bodyContent = body.split('\n').map(line => line.replace(/^>\s?/, '')).join('\n').trim();
    const typeMap = { abstract: 'note', summary: 'note', todo: 'tip', success: 'tip', question: 'warning', failure: 'important', danger: 'important', bug: 'important', example: 'note', quote: 'note' };
    const cssType = typeMap[normalizedType] || normalizedType;
    if (foldable === '-') return `<details class="callout callout-${cssType}"><summary class="callout-title">${displayTitle}</summary><div class="callout-content">\n\n${bodyContent}\n\n</div></details>\n`;
    if (foldable === '+') return `<details class="callout callout-${cssType}" open><summary class="callout-title">${displayTitle}</summary><div class="callout-content">\n\n${bodyContent}\n\n</div></details>\n`;
    return `<div class="callout callout-${cssType}"><div class="callout-title">${displayTitle}</div><div class="callout-content">\n\n${bodyContent}\n\n</div></div>\n`;
  }
);

// Inline tags
mdBody = mdBody.replace(/(?<=\s|^)#([a-zA-Z\u4e00-\u9fff][\w\u4e00-\u9fff/-]*)/g, '<span class="tag">$1</span>');

// Highlights: <mark>text</mark>
mdBody = mdBody.replace(/<mark>(.*?)</mark>/g, '<mark>$1</mark>');

const result = await unified()
  .use(remarkParse)
  .use(remarkGfm)
  .use(remarkFrontmatter, ['yaml'])
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeRaw)
  .use(rehypeStringify)
  .process(mdBody);

const htmlBody = String(result);
let template = readFileSync(absTemplate, 'utf-8');
template = template.replace('{{TITLE}}', title);
template = template.replace('{{TITLE}}', title);
template = template.replace('{{DATE}}', new Date().toISOString().split('T')[0]);
const tagsHtml = tags.length > 0
  ? `<div class="tags">${tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>`
  : '';
template = template.replace('{{TAGS}}', tagsHtml);
template = template.replace('{{CONTENT}}', htmlBody);

mkdirSync(absBuild, { recursive: true });
mkdirSync(join(absBuild, 'images'), { recursive: true });

for (const img of collectedImages) {
  try { copyFileSync(img.src, join(absBuild, 'images', img.filename)); }
  catch (e) { console.error(`Warning: Could not copy image ${img.filename}: ${e.message}`); }
}

writeFileSync(join(absBuild, `${slug}.html`), template, 'utf-8');

console.log(JSON.stringify({ slug, title, tags, images: collectedImages.map(i => i.filename) }));

scripts/deploy.sh(部署腳本)

#!/usr/bin/env bash
# deploy.sh — Deploy, unpublish, list, and update published notes
#
# Environment:
#   REGISTRY_PATH — path to registry.json
#   BUILD_DIR     — path to build/ directory
#   CF_PROJECT    — Cloudflare Pages project name (default: vault-pub)

set -euo pipefail

REGISTRY_PATH="${REGISTRY_PATH:?REGISTRY_PATH is required}"
BUILD_DIR="${BUILD_DIR:?BUILD_DIR is required}"
CF_PROJECT="${CF_PROJECT:-vault-pub}"
BASE_URL="https://${CF_PROJECT}.pages.dev"

command -v jq &>/dev/null || { echo "Error: jq is required." >&2; exit 1; }
command -v wrangler &>/dev/null || { echo "Error: wrangler CLI is required. Install: npm install -g wrangler" >&2; exit 1; }

cmd_publish() {
  local slug="$1" source="$2" title="$3"
  local now; now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  local url="${BASE_URL}/${slug}"
  local tmp; tmp=$(mktemp)
  jq --arg slug "$slug" --arg source "$source" --arg title "$title" \
     --arg url "$url" --arg now "$now" \
     '.notes = [.notes[] | select(.slug != $slug)] +
      [{ slug: $slug, source: $source, title: $title, url: $url,
         published_at: $now, updated_at: $now, status: "active" }]' \
     "$REGISTRY_PATH" > "$tmp"
  mv "$tmp" "$REGISTRY_PATH"
  generate_index
  wrangler pages deploy "$BUILD_DIR" --project-name "$CF_PROJECT" \
    --commit-dirty=true --commit-message "publish: ${slug}" 2>&1
  echo ""; echo "URL: ${url}"
}

cmd_unpublish() {
  local slug="$1"
  rm -f "${BUILD_DIR}/${slug}.html"
  local tmp; tmp=$(mktemp)
  jq --arg slug "$slug" --arg now "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
     '.notes = [.notes[] | if .slug == $slug then .status = "removed" | .updated_at = $now else . end]' \
     "$REGISTRY_PATH" > "$tmp"
  mv "$tmp" "$REGISTRY_PATH"
  generate_index
  wrangler pages deploy "$BUILD_DIR" --project-name "$CF_PROJECT" \
    --commit-dirty=true --commit-message "deploy: vault-pub" 2>&1
  echo ""; echo "Unpublished: ${slug}"
}

cmd_list() {
  echo ""
  jq -r '.notes[] | select(.status == "active") |
    "| \(.title) | \(.url) | \(.published_at[:10]) | \(.status) |"
  ' "$REGISTRY_PATH" | { echo "| Title | URL | Date | Status |"; echo "|-------|-----|------|--------|"; cat; }
  echo ""
}

cmd_update() {
  local slug="$1" source="$2" title="$3"
  local now; now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  local url="${BASE_URL}/${slug}"
  local tmp; tmp=$(mktemp)
  jq --arg slug "$slug" --arg source "$source" --arg title "$title" \
     --arg url "$url" --arg now "$now" \
     '.notes = [.notes[] | if .slug == $slug then
       .source = $source | .title = $title | .url = $url | .updated_at = $now | .status = "active"
     else . end]' "$REGISTRY_PATH" > "$tmp"
  mv "$tmp" "$REGISTRY_PATH"
  generate_index
  wrangler pages deploy "$BUILD_DIR" --project-name "$CF_PROJECT" \
    --commit-dirty=true --commit-message "deploy: vault-pub" 2>&1
  echo ""; echo "Updated: ${url}"
}

generate_index() {
  local notes_html
  notes_html=$(jq -r '.notes[] | select(.status == "active") |
    "<li><a href=\"/\(.slug)\">\(.title)</a> <span class=\"date\">\(.published_at[:10])</span></li>"
  ' "$REGISTRY_PATH")
  cat > "${BUILD_DIR}/index.html" <<INDEXEOF
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Published Notes</title>
<style>
  body { font-family: -apple-system, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
  ul { list-style: none; padding: 0; }
  li { padding: 12px 0; border-bottom: 1px solid <span class="tag">e2e8f0</span>; }
  a { color: #3b82f6; text-decoration: none; font-weight: 500; }
  .date { color: #8892a4; font-size: 0.85rem; margin-left: 12px; }
</style>
</head>
<body>
  <h1>Published Notes</h1>
  <ul>${notes_html}</ul>
</body>
</html>
INDEXEOF
}

case "${1:-}" in
  publish)   <span class="wikilink-unresolved"> $# -ge 4 </span> || { echo "Usage: deploy.sh publish <slug> <source> <title>" >&2; exit 1; }; cmd_publish "$2" "$3" "$4" ;;
  unpublish) <span class="wikilink-unresolved"> $# -ge 2 </span> || { echo "Usage: deploy.sh unpublish <slug>" >&2; exit 1; }; cmd_unpublish "$2" ;;
  list)      cmd_list ;;
  update)    <span class="wikilink-unresolved"> $# -ge 4 </span> || { echo "Usage: deploy.sh update <slug> <source> <title>" >&2; exit 1; }; cmd_update "$2" "$3" "$4" ;;
  *)         echo "Usage: deploy.sh {publish|unpublish|list|update} [args...]" >&2; exit 1 ;;
esac

assets/template.html(HTML 版型模板)

這個 HTML 模板是所有發布頁面的骨架,包含:

佔位符(由 convert.mjs 替換):

完整 HTML 模板約 400 行,以下為結構摘要:

<!DOCTYPE html>
<html lang="zh-Hant" data-theme="light">
<head>
  <!-- Google Fonts: Noto Sans TC + JetBrains Mono -->
  <style>
    /* CSS variables for light/dark theme */
    :root { --bg-primary: <span class="tag">ffffff</span>; --text-primary: #1a1a2e; ... }
    [data-theme="dark"] { --bg-primary: #0f172a; ... }

    /* Layout: sticky header, max-width 760px container */
    /* Typography: h1-h6, paragraph, list, blockquote */
    /* Callouts: .callout-note, .callout-tip, .callout-warning, etc. */
    /* Code: inline code, pre blocks with dark background */
    /* Tables, images, tags, footnotes */
    /* Responsive: mobile breakpoint at 640px */
    /* Print: hide header/footer */
  </style>
</head>
<body>
  <div class="header"><!-- Dark/Light toggle button --></div>
  <main class="container">
    <div class="title-area">
      <h1>{{TITLE}}</h1>
      <div class="meta">
        <span class="date">{{DATE}}</span>
        {{TAGS}}
      </div>
    </div>
    <article class="content">{{CONTENT}}</article>
  </main>
  <footer class="footer">Published from Obsidian</footer>
  <script>/* Theme toggle + localStorage persistence */</script>
</body>
</html>

references/registry-spec.md(Registry 規格)

Registry 是一個 JSON 檔案,記錄所有已發布筆記的元數據:

{
  "project": "vault-pub",
  "base_url": "https://vault-pub.pages.dev",
  "notes": [
    {
      "slug": "my-first-note",
      "source": "00_Inbox/my-first-note.md",
      "title": "我的第一篇筆記",
      "url": "https://vault-pub.pages.dev/my-first-note",
      "published_at": "2026-02-20T10:00:00Z",
      "updated_at": "2026-02-20T10:00:00Z",
      "status": "active"
    }
  ]
}

狀態流程:

publish    → status: "active"
update     → status: "active",updated_at 更新
unpublish  → status: "removed",HTML 檔案刪除
re-publish → status: "active"(新的記錄覆蓋舊的)

references/ofm-conversion.md(OFM 語法轉換對照表)

OFM 語法輸出 HTML
---frontmatter---提取 title/tags,不渲染為正文
<span class="wikilink-unresolved">Page Name</span>若已發布 → <a href="/slug">;否則 <span class="wikilink-unresolved">
<span class="wikilink-unresolved">Display</span>同上,使用 Display 文字
![image.png](images/image.png)<img src="images/image.png">
> [!note] 標題<div class="callout callout-note">
> [!tip]- 折疊<details class="callout callout-tip"> (預設收合)
> [!tip]+ 展開<details class="callout callout-tip" open>
![](youtube_url)<iframe> YouTube 播放器
<mark>highlight</mark><mark>highlight</mark>
#tag<span class="tag">tag</span>
```python語法高亮程式碼區塊
| 表格 |<table>
- [ ] 待辦<input type="checkbox"> (唯讀)

scripts/package.json(Node.js 依賴)

{
  "type": "module",
  "dependencies": {
    "rehype-raw": "^7.0.0",
    "rehype-stringify": "^10.0.0",
    "remark-frontmatter": "^5.0.0",
    "remark-gfm": "^4.0.0",
    "remark-parse": "^11.0.0",
    "remark-rehype": "^11.0.0",
    "unified": "^11.0.0",
    "yaml": "^2.0.0"
  }
}

安裝依賴:

cd scripts/
npm install