把 Obsidian 筆記變成公開網頁:Cloudflare Pages 完整教學
這份教學會帶你從零開始,把一個 Obsidian Markdown 檔案,變成一個可以直接分享的公開網址。全程免費,部署在 Cloudflare Pages 上。
你會得到什麼
- 一個可直接分享的網址(例如:
https://vault-pub.pages.dev/my-note) - 支援 Obsidian 語法:callout、wikilinks、tags、程式碼區塊
- 美觀的排版,支援中英文、手機友善
- 每次更新只需重新執行一個指令
前置準備
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) |
 | 圖片嵌入(需放在 Vault 內) |
<span class="wikilink-unresolved">其他筆記</span> | Wikilink(顯示為純文字) |
#tag | 標籤(顯示在標題下方) |
--- | 水平分隔線 |
常見問題
Q:我的圖片沒有顯示?
圖片必須放在 Vault 目錄內(notes/ 或其子資料夾),且使用  語法引用。
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: 
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 ``;
}
}
return ``;
}
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 模板是所有發布頁面的骨架,包含:
- 亮色 / 暗色主題切換
- 中英文字體(Noto Sans TC + JetBrains Mono)
- Callout 樣式
- 程式碼區塊樣式
- 表格、圖片、標籤樣式
- 手機響應式佈局
佔位符(由 convert.mjs 替換):
{{TITLE}}— 筆記標題(出現兩次:<title>和<h1>){{DATE}}— 發布日期{{TAGS}}— 標籤 HTML{{CONTENT}}— 正文 HTML
完整 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 文字 |
 | <img src="images/image.png"> |
> [!note] 標題 | <div class="callout callout-note"> |
> [!tip]- 折疊 | <details class="callout callout-tip"> (預設收合) |
> [!tip]+ 展開 | <details class="callout callout-tip" open> |
 | <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