NIP-54: Wiki
在 Nostr 上建立去中心化的維基百科式知識庫
概述
NIP-54 定義了在 Nostr 上建立維基(Wiki)風格內容的標準。使用者可以建立和編輯 主題條目,透過社群協作建立去中心化的知識庫。每個條目都是可定址事件, 支援版本歷史和多作者貢獻。
事件類型
| Kind | 說明 | 類型 |
|---|---|---|
30818 | Wiki 條目 | 可定址(可替換) |
事件結構
Content
條目內容使用 Markdown 格式,支援標題、列表、連結、程式碼區塊等。 可使用 nostr: URI 連結到其他
Nostr 內容。
必要標籤
| 標籤 | 說明 | 範例 |
|---|---|---|
d | 條目識別符(主題名稱,小寫) | ["d", "bitcoin"] |
可選標籤
| 標籤 | 說明 | 範例 |
|---|---|---|
title | 顯示標題 | ["title", "Bitcoin"] |
summary | 簡短摘要 | ["summary", "去中心化數位貨幣"] |
a | 引用其他條目 | ["a", "30818:pubkey:lightning-network"] |
t | 主題標籤 | ["t", "cryptocurrency"] |
published_at | 發布時間戳 | ["published_at", "1704067200"] |
主題命名規則
d 標籤的值應遵循以下規則:
- 使用小寫字母
- 空格替換為連字符
- - 移除特殊字符
- 保持簡潔明確
範例
| 主題 | d 標籤值 |
|---|---|
| Bitcoin | bitcoin |
| Lightning Network | lightning-network |
| Nostr Protocol | nostr-protocol |
| 中本聰 | satoshi-nakamoto |
範例
基本 Wiki 條目
{
"kind": 30818,
"content": "# Bitcoin\n\nBitcoin 是一種去中心化的數位貨幣,由中本聰於 2008 年提出,2009 年正式上線。\n\n## 特點\n\n- **去中心化**:沒有中央機構控制\n- **有限供應**:總量限制為 2100 萬枚\n- **開源**:任何人都可以審查程式碼\n\n## 相關主題\n\n- [[lightning-network|閃電網路]]\n- [[satoshi-nakamoto|中本聰]]\n\n## 參考資料\n\n- [Bitcoin 白皮書](https://bitcoin.org/bitcoin.pdf)",
"tags": [
["d", "bitcoin"],
["title", "Bitcoin"],
["summary", "去中心化的點對點電子現金系統"],
["t", "cryptocurrency"],
["t", "區塊鏈"],
["published_at", "1704067200"]
]
} 引用其他條目
{
"kind": 30818,
"content": "# 閃電網路\n\n閃電網路是建立在 [[bitcoin|Bitcoin]] 之上的二層擴展方案。\n\n## 工作原理\n\n透過支付通道實現快速、低成本的交易...\n\n## 相關技術\n\n- [[htlc|HTLC]]\n- [[payment-channel|支付通道]]",
"tags": [
["d", "lightning-network"],
["title", "閃電網路"],
["summary", "Bitcoin 的二層擴展解決方案"],
["a", "30818:author-pubkey:bitcoin", "wss://relay.example.com"],
["a", "30818:author-pubkey:htlc", "wss://relay.example.com"],
["t", "lightning"],
["t", "layer2"]
]
} Wiki 連結語法
在內容中可以使用 Wiki 風格的連結語法:
[[topic-name]] → 連結到同一作者的條目
[[topic-name|顯示文字]] → 自訂顯示文字 TypeScript 實作
建立 Wiki 條目
import { finalizeEvent } from 'nostr-tools';
interface WikiArticle {
topic: string;
title: string;
content: string;
summary?: string;
tags?: string[];
references?: string[]; // 其他條目的座標
}
function createWikiEvent(
article: WikiArticle,
secretKey: Uint8Array
) {
const tags: string[][] = [
['d', article.topic],
['title', article.title],
];
if (article.summary) {
tags.push(['summary', article.summary]);
}
article.references?.forEach((ref) => {
tags.push(['a', ref]);
});
article.tags?.forEach((tag) => {
tags.push(['t', tag]);
});
tags.push(['published_at', Math.floor(Date.now() / 1000).toString()]);
const event = {
kind: 30818,
content: article.content,
tags,
created_at: Math.floor(Date.now() / 1000),
};
return finalizeEvent(event, secretKey);
}
// 使用範例
const wikiEvent = createWikiEvent(
{
topic: 'nostr-protocol',
title: 'Nostr 協議',
content: '# Nostr\n\nNostr 是一個簡單、開放的去中心化社交協議...',
summary: '去中心化社交協議',
tags: ['protocol', 'decentralized', 'social'],
references: ['30818:pubkey:bitcoin', '30818:pubkey:lightning-network'],
},
secretKey
); 解析 Wiki 條目
interface ParsedWikiArticle {
topic: string;
title: string;
content: string;
summary?: string;
tags: string[];
references: string[];
publishedAt?: number;
author: string;
}
function parseWikiEvent(event: any): ParsedWikiArticle | null {
if (event.kind !== 30818) return null;
const getTag = (name: string) => {
const tag = event.tags.find((t: string[]) => t[0] === name);
return tag?.[1];
};
const getAllTags = (name: string) => {
return event.tags
.filter((t: string[]) => t[0] === name)
.map((t: string[]) => t[1]);
};
const topic = getTag('d');
if (!topic) return null;
const publishedAtStr = getTag('published_at');
return {
topic,
title: getTag('title') || topic,
content: event.content,
summary: getTag('summary'),
tags: getAllTags('t'),
references: getAllTags('a'),
publishedAt: publishedAtStr ? parseInt(publishedAtStr) : undefined,
author: event.pubkey,
};
} 解析 Wiki 連結
interface WikiLink {
topic: string;
displayText: string;
start: number;
end: number;
}
function parseWikiLinks(content: string): WikiLink[] {
const regex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
const links: WikiLink[] = [];
let match;
while ((match = regex.exec(content)) !== null) {
links.push({
topic: match[1].trim(),
displayText: match[2]?.trim() || match[1].trim(),
start: match.index,
end: match.index + match[0].length,
});
}
return links;
}
function renderWikiContent(
content: string,
authorPubkey: string,
onLinkClick?: (topic: string) => void
): string {
const links = parseWikiLinks(content);
// 從後往前替換,避免位置偏移
let result = content;
for (let i = links.length - 1; i >= 0; i--) {
const link = links[i];
const href = `/wiki/${authorPubkey}/${link.topic}`;
const replacement = `${link.displayText}`;
result = result.slice(0, link.start) + replacement + result.slice(link.end);
}
return result;
} 搜尋 Wiki 條目
import { SimplePool } from 'nostr-tools';
interface WikiSearchOptions {
topic?: string;
author?: string;
tags?: string[];
limit?: number;
}
async function searchWikiArticles(
pool: SimplePool,
relays: string[],
options: WikiSearchOptions = {}
): Promise<ParsedWikiArticle[]> {
const filter: any = {
kinds: [30818],
limit: options.limit || 50,
};
if (options.author) {
filter.authors = [options.author];
}
if (options.topic) {
filter['#d'] = [options.topic];
}
if (options.tags && options.tags.length > 0) {
filter['#t'] = options.tags;
}
const events = await pool.querySync(relays, filter);
return events
.map(parseWikiEvent)
.filter((a): a is ParsedWikiArticle => a !== null)
.sort((a, b) => (b.publishedAt || 0) - (a.publishedAt || 0));
}
// 取得特定條目的所有版本
async function getArticleVersions(
pool: SimplePool,
relays: string[],
topic: string,
author?: string
): Promise<ParsedWikiArticle[]> {
const filter: any = {
kinds: [30818],
'#d': [topic],
};
if (author) {
filter.authors = [author];
}
const events = await pool.querySync(relays, filter);
return events
.map(parseWikiEvent)
.filter((a): a is ParsedWikiArticle => a !== null)
.sort((a, b) => (b.publishedAt || 0) - (a.publishedAt || 0));
} React 元件
import ReactMarkdown from 'react-markdown';
interface WikiArticleViewProps {
article: ParsedWikiArticle;
onNavigate?: (topic: string) => void;
}
function WikiArticleView({ article, onNavigate }: WikiArticleViewProps) {
// 處理 Wiki 連結
const processedContent = article.content.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_, topic, text) => `[${text || topic}](/wiki/${article.author}/${topic})`
);
return (
<article className="wiki-article">
<header>
<h1>{article.title}</h1>
{article.summary && (
<p className="summary">{article.summary}</p>
)}
<div className="meta">
<span className="author">作者: {article.author.slice(0, 8)}...</span>
{article.publishedAt && (
<span className="date">
{new Date(article.publishedAt * 1000).toLocaleDateString()}
</span>
)}
</div>
<div className="tags">
{article.tags.map((tag) => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
</header>
<div className="content">
<ReactMarkdown>{processedContent}</ReactMarkdown>
</div>
</article>
);
}
function WikiSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<ParsedWikiArticle[]>([]);
const handleSearch = async () => {
const articles = await searchWikiArticles(pool, relays, {
tags: [query.toLowerCase()],
limit: 20,
});
setResults(articles);
};
return (
<div className="wiki-search">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜尋 Wiki 條目..."
/>
<button onClick={handleSearch}>搜尋</button>
<div className="results">
{results.map((article) => (
<div key={`${article.author}:${article.topic}`} className="result">
<h3>{article.title}</h3>
<p>{article.summary}</p>
</div>
))}
</div>
</div>
);
} 使用場景
社群知識庫
- Bitcoin/加密貨幣百科
- 技術文檔和教程
- 專業領域知識庫
協作編輯
- 多人貢獻同一主題
- 版本歷史追蹤
- 分散式知識管理
個人筆記
- 個人知識管理系統
- 學習筆記整理
- 研究資料彙整
最佳實踐
- 規範命名:使用一致的主題命名規則
- 充分引用:使用 Wiki 連結連接相關條目
- 提供摘要:方便搜尋和預覽
- 版本管理:重大修改時更新 published_at
- 標籤分類:使用標籤幫助發現
相關 NIPs
參考資源
- NIP-54 規範
- Wikifreedia - Nostr Wiki 客戶端
- nostr-tools
已複製連結