feat: implement NIP-54 wiki article rendering with Asciidoc

Adds full support for NIP-54 wiki articles (kind 30818) with Asciidoc rendering:

- Install @asciidoctor/core for Asciidoc to HTML conversion
- Create AsciidocContent component for rendering wiki content with:
  - Wikilink support ([[article-name]] or [[target|display text]])
  - Basic nostr: link rendering (TODO: full parsing)
  - Prose styling matching markdown articles
- Create WikiRenderer (feed view) showing title and summary
- Create WikiDetailRenderer (detail view) with full article content
- Create WikiViewer component to query and display wiki articles by subject
- Add wiki command to man pages with subject normalization
- Register wiki appId and renderers in the rendering system
- Add CSS styles for Asciidoc content elements and wikilinks

Wiki articles can be opened with: wiki <subject>
Subject is automatically normalized per NIP-54 (lowercase, hyphens, etc.)
This commit is contained in:
Claude
2026-01-16 22:20:24 +00:00
parent 97f18de358
commit 1ddbcca810
12 changed files with 626 additions and 1 deletions

117
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -181,6 +182,74 @@
"dev": true,
"license": "MIT"
},
"node_modules/@asciidoctor/core": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-3.0.4.tgz",
"integrity": "sha512-41SDMi7iRRBViPe0L6VWFTe55bv6HEOJeRqMj5+E5wB1YPdUPuTucL4UAESPZM6OWmn4t/5qM5LusXomFUVwVQ==",
"license": "MIT",
"dependencies": {
"@asciidoctor/opal-runtime": "3.0.1",
"unxhr": "1.2.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
}
},
"node_modules/@asciidoctor/opal-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@asciidoctor/opal-runtime/-/opal-runtime-3.0.1.tgz",
"integrity": "sha512-iW7ACahOG0zZft4A/4CqDcc7JX+fWRNjV5tFAVkNCzwZD+EnFolPaUOPYt8jzadc0+Bgd80cQTtRMQnaaV1kkg==",
"license": "MIT",
"dependencies": {
"glob": "8.1.0",
"unxhr": "1.2.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@asciidoctor/opal-runtime/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@asciidoctor/opal-runtime/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@asciidoctor/opal-runtime/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -5816,7 +5885,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
@@ -7226,6 +7294,12 @@
}
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -7603,6 +7677,23 @@
"node": ">=0.8.19"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -9379,6 +9470,15 @@
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -11686,6 +11786,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unxhr": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.2.0.tgz",
"integrity": "sha512-6cGpm8NFXPD9QbSNx0cD2giy7teZ6xOkCUH3U89WKVkL9N9rBrWjlCwhR94Re18ZlAop4MOc3WU1M3Hv/bgpIw==",
"license": "MIT",
"engines": {
"node": ">=8.11"
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@@ -12294,6 +12403,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -17,6 +17,7 @@
"test:run": "vitest run"
},
"dependencies": {
"@asciidoctor/core": "^3.0.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",

View File

@@ -0,0 +1,53 @@
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import { DetailKindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
import { EventDetailSkeleton } from "@/components/ui/skeleton";
import { BookOpen } from "lucide-react";
export interface WikiViewerProps {
subject: string; // Normalized subject (d-tag)
}
/**
* WikiViewer - Displays a wiki article by subject (NIP-54)
* Fetches and displays the latest kind 30818 event with the given d-tag
*/
export function WikiViewer({ subject }: WikiViewerProps) {
// Query for kind 30818 wiki articles with this subject
// Kind 30818 is replaceable, so eventStore will automatically return the latest version
const events = use$(
() =>
eventStore.timeline({
kinds: [30818],
"#d": [subject],
}),
[subject],
);
// Get the first (and should be only) event from the array
// For replaceable events, eventStore returns only the latest version
const event = events && events.length > 0 ? events[0] : null;
// Loading state
if (!event) {
return (
<div className="flex flex-col h-full p-8">
<div className="flex items-center gap-2 text-muted-foreground mb-4">
<BookOpen className="size-5" />
<span className="text-sm">Loading wiki article...</span>
</div>
<EventDetailSkeleton />
</div>
);
}
// Render the wiki article using the detail renderer
return (
<div className="flex flex-col h-full overflow-y-auto">
<EventErrorBoundary event={event}>
<DetailKindRenderer event={event} />
</EventErrorBoundary>
</div>
);
}

View File

@@ -43,6 +43,9 @@ const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const CountViewer = lazy(() => import("./CountViewer"));
const WikiViewer = lazy(() =>
import("./WikiViewer").then((m) => ({ default: m.WikiViewer })),
);
// Loading fallback component
function ViewerLoading() {
@@ -220,6 +223,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "wiki":
content = <WikiViewer subject={window.props.subject} />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -0,0 +1,122 @@
import { useMemo } from "react";
import Asciidoctor from "@asciidoctor/core";
import { useGrimoire } from "@/core/state";
export interface AsciidocContentProps {
content: string;
canonicalUrl?: string | null; // Reserved for future use (image URL resolution)
}
/**
* Normalize wiki subject according to NIP-54 rules:
* - Convert to lowercase
* - Replace whitespace with hyphens
* - Remove punctuation/symbols
* - Collapse multiple hyphens
* - Strip leading/trailing hyphens
*/
function normalizeWikiSubject(subject: string): string {
return subject
.toLowerCase()
.replace(/\s+/g, "-") // spaces to hyphens
.replace(/[^\w\u0080-\uFFFF-]/g, "") // remove non-word chars except UTF-8 and hyphens
.replace(/-+/g, "-") // collapse multiple hyphens
.replace(/^-+|-+$/g, ""); // strip leading/trailing hyphens
}
/**
* Process wikilinks [[...]] and nostr: links in HTML
* Replaces [[target|display]] wikilinks with clickable links
* Replaces nostr: links with embedded components or clickable links
*/
function processLinks(html: string): string {
// Process wikilinks [[target|display]] or [[target]]
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, display) => {
const normalized = normalizeWikiSubject(target);
const displayText = display || target;
return `<a href="#wiki-${normalized}" class="wiki-link" data-wiki="${normalized}">${displayText}</a>`;
},
);
// Process nostr: links (already in <a> tags from asciidoctor)
// For now, render them as plain links (TODO: parse and render as mentions)
html = html.replace(
/<a[^>]*href="nostr:([^"]+)"[^>]*>([^<]+)<\/a>/g,
(_match, nostrId, linkText) => {
// Render as clickable link with nostr-mention class
return `<span class="nostr-mention" data-nostr-id="${nostrId}">${linkText}</span>`;
},
);
return html;
}
/**
* Shared Asciidoc renderer for Nostr wiki content (kind 30818)
* Handles wikilinks [[...]], nostr: mentions, and media embeds
*/
export function AsciidocContent({
content,
canonicalUrl: _canonicalUrl = null, // Reserved for future use
}: AsciidocContentProps) {
const { addWindow } = useGrimoire();
// Initialize asciidoctor processor
const asciidoctor = useMemo(() => Asciidoctor(), []);
// Convert Asciidoc to HTML
const html = useMemo(() => {
try {
const rawHtml = asciidoctor.convert(content, {
safe: "safe",
attributes: {
showtitle: true,
sectanchors: true,
icons: "font",
},
}) as string;
// Process wikilinks and nostr links
return processLinks(rawHtml);
} catch (error) {
console.error("Failed to convert Asciidoc:", error);
return `<div class="text-destructive text-sm">Failed to render wiki content: ${error instanceof Error ? error.message : "Unknown error"}</div>`;
}
}, [content, asciidoctor]);
// Handle clicks on wiki links and nostr mentions
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Handle wiki links
if (target.classList.contains("wiki-link")) {
e.preventDefault();
const subject = target.getAttribute("data-wiki");
if (subject) {
addWindow("wiki", { subject }, `Wiki: ${subject}`);
}
return;
}
// Handle nostr mentions
// TODO: Parse nostr: links and open appropriate windows
if (target.classList.contains("nostr-mention")) {
e.preventDefault();
const nostrId = target.getAttribute("data-nostr-id");
if (nostrId) {
console.log("Nostr mention clicked:", nostrId);
// For now, just log - we can implement parsing later
}
}
};
return (
<article
className="prose prose-invert prose-sm max-w-none asciidoc-content"
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -0,0 +1,89 @@
import { useMemo } from "react";
import { getTagValue } from "applesauce-core/helpers";
import { UserName } from "../UserName";
import { MediaEmbed } from "../MediaEmbed";
import { AsciidocContent } from "../AsciidocContent";
import type { NostrEvent } from "@/types/nostr";
/**
* Detail renderer for Kind 30818 - Wiki Article (NIP-54)
* Displays full Asciidoc content with metadata
* Note: getTagValue caches internally, no useMemo needed
*/
export function WikiDetailRenderer({ event }: { event: NostrEvent }) {
// Get title from "title" tag, fallback to "d" tag (subject identifier)
const title = getTagValue(event, "title") || getTagValue(event, "d");
const summary = getTagValue(event, "summary");
const imageUrl = getTagValue(event, "image");
// Get canonical URL from "r" tag to resolve relative URLs
const canonicalUrl = useMemo(() => {
const rTag = event.tags.find((t) => t[0] === "r");
return rTag?.[1] || null;
}, [event]);
// Format created date (wiki articles use created_at timestamp)
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
);
// Resolve article image URL
const resolvedImageUrl = useMemo(() => {
if (!imageUrl) return null;
if (imageUrl.match(/^https?:\/\//)) return imageUrl;
if (canonicalUrl) {
try {
return new URL(imageUrl, canonicalUrl).toString();
} catch {
return null;
}
}
return null;
}, [imageUrl, canonicalUrl]);
return (
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Wiki Article Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Title */}
{title && <h1 className="text-3xl font-bold">{title}</h1>}
{!title && (
<h1 className="text-3xl font-bold text-muted-foreground italic">
(Untitled wiki article)
</h1>
)}
{/* Featured Image */}
{resolvedImageUrl && (
<MediaEmbed
url={resolvedImageUrl}
preset="preview"
enableZoom
className="w-full rounded-lg overflow-hidden"
/>
)}
{/* Summary */}
{summary && <p className="text-lg text-muted-foreground">{summary}</p>}
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
<span></span>
<time>{createdDate}</time>
</div>
</header>
{/* Wiki Article Content - Asciidoc */}
<AsciidocContent content={event.content} canonicalUrl={canonicalUrl} />
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { BookOpen } from "lucide-react";
import { getTagValue } from "applesauce-core/helpers";
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
/**
* Renderer for Kind 30818 - Wiki Article (NIP-54)
* Displays wiki article title and summary in feed
* Note: getTagValue caches internally, no useMemo needed
*/
export function WikiRenderer({ event }: BaseEventProps) {
// Get title from "title" tag, fallback to "d" tag (subject identifier)
const title = getTagValue(event, "title") || getTagValue(event, "d");
const summary = getTagValue(event, "summary");
return (
<BaseEventContainer event={event}>
<div dir="auto" className="flex flex-col gap-2">
{/* Title with wiki icon */}
<div className="flex items-center gap-2">
<BookOpen className="size-4 text-muted-foreground flex-shrink-0" />
{title && (
<ClickableEventTitle
event={event}
className="text-lg font-bold text-foreground"
>
{title}
</ClickableEventTitle>
)}
{!title && (
<span className="text-sm text-muted-foreground italic">
(Untitled wiki article)
</span>
)}
</div>
{/* Summary */}
{summary && (
<p className="text-sm text-muted-foreground line-clamp-3 pl-6">
{summary}
</p>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -112,6 +112,8 @@ import {
WikiRelaysRenderer,
WikiRelaysDetailRenderer,
} from "./WikiListRenderer";
import { WikiRenderer } from "./WikiRenderer";
import { WikiDetailRenderer } from "./WikiDetailRenderer";
import {
FollowSetRenderer,
FollowSetDetailRenderer,
@@ -210,6 +212,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
30817: CommunityNIPRenderer, // Community NIP
30818: WikiRenderer, // Wiki Article (NIP-54)
31922: CalendarDateEventRenderer, // Date-Based Calendar Event (NIP-52)
31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52)
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
@@ -300,6 +303,7 @@ const detailRenderers: Record<
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
30817: CommunityNIPDetailRenderer, // Community NIP Detail
30818: WikiDetailRenderer, // Wiki Article Detail (NIP-54)
31922: CalendarDateEventDetailRenderer, // Date-Based Calendar Event Detail (NIP-52)
31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52)
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)

View File

@@ -423,3 +423,118 @@ body.animating-layout
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
/* Asciidoc Wiki Content Styles */
.asciidoc-content {
/* Inherit prose styles from prose-invert */
}
/* Wiki link styles - match markdown link style */
.asciidoc-content .wiki-link {
color: hsl(var(--accent));
text-decoration: underline;
text-decoration-style: dotted;
cursor: crosshair;
word-break: break-all;
}
.asciidoc-content .wiki-link:hover {
color: hsl(var(--accent) / 0.8);
}
/* Nostr mention styles - match markdown mention style */
.asciidoc-content .nostr-mention {
color: hsl(var(--accent));
font-weight: 600;
cursor: pointer;
}
.asciidoc-content .nostr-mention:hover {
color: hsl(var(--accent) / 0.8);
}
/* Asciidoc-specific element styles to match markdown prose */
.asciidoc-content h1 {
font-size: 1.5rem;
font-weight: bold;
margin-top: 2rem;
margin-bottom: 1rem;
}
.asciidoc-content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.asciidoc-content h3 {
font-size: 1.125rem;
font-weight: bold;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.asciidoc-content p {
font-size: 0.875rem;
line-height: 1.625;
margin-bottom: 1rem;
}
.asciidoc-content pre {
background-color: hsl(var(--muted));
padding: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
overflow-x: auto;
max-width: 100%;
margin: 1rem 0;
}
.asciidoc-content code {
background-color: hsl(var(--muted));
padding: 0.125rem 0.125rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: monospace;
}
.asciidoc-content pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
.asciidoc-content blockquote {
border-left: 4px solid hsl(var(--muted));
padding-left: 1rem;
font-style: italic;
color: hsl(var(--muted-foreground));
margin: 1rem 0;
}
.asciidoc-content ul,
.asciidoc-content ol {
font-size: 0.875rem;
list-style-position: inside;
margin: 1rem 0;
padding-left: 0;
}
.asciidoc-content ul {
list-style-type: disc;
}
.asciidoc-content ol {
list-style-type: decimal;
}
.asciidoc-content ul li,
.asciidoc-content ol li {
margin-top: 0.5rem;
}
.asciidoc-content hr {
margin: 1rem 0;
border-color: hsl(var(--border));
}

44
src/lib/wiki-parser.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* Normalize wiki subject according to NIP-54 rules:
* - Convert to lowercase
* - Replace whitespace with hyphens
* - Remove punctuation/symbols (except UTF-8 chars and hyphens)
* - Collapse multiple hyphens
* - Strip leading/trailing hyphens
*/
export function normalizeWikiSubject(subject: string): string {
return subject
.toLowerCase()
.replace(/\s+/g, "-") // spaces to hyphens
.replace(/[^\w\u0080-\uFFFF-]/g, "") // remove non-word chars except UTF-8 and hyphens
.replace(/-+/g, "-") // collapse multiple hyphens
.replace(/^-+|-+$/g, ""); // strip leading/trailing hyphens
}
/**
* Parse wiki command arguments
* Usage: wiki <subject>
* Examples:
* wiki bitcoin -> { subject: "bitcoin" }
* wiki "Bitcoin Core" -> { subject: "bitcoin-core" }
* wiki Москва -> { subject: "москва" }
*/
export function parseWikiCommand(args: string[]): { subject: string } {
if (args.length === 0) {
throw new Error("Wiki subject is required. Usage: wiki <subject>");
}
// Join all args (in case subject was split by spaces without quotes)
const rawSubject = args.join(" ");
// Normalize according to NIP-54 rules
const subject = normalizeWikiSubject(rawSubject);
if (!subject) {
throw new Error(
"Invalid wiki subject. Subject cannot be empty after normalization.",
);
}
return { subject };
}

View File

@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
| "wiki"
| "win";
export interface WindowInstance {

View File

@@ -8,6 +8,7 @@ import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
import { parseWikiCommand } from "@/lib/wiki-parser";
export interface ManPageEntry {
name: string;
@@ -641,6 +642,31 @@ export const manPages: Record<string, ManPageEntry> = {
category: "Nostr",
defaultProps: {},
},
wiki: {
name: "wiki",
section: "1",
synopsis: "wiki <subject>",
description:
"Open a Nostr wiki article by subject (NIP-54). Wiki articles are kind 30818 events identified by their normalized subject (d-tag). Multiple authors can write about the same subject, and clients prioritize articles using web-of-trust, reactions, and trusted author lists (kinds 10101, 10102).",
options: [
{
flag: "<subject>",
description:
"Wiki article subject (will be normalized: lowercase, spaces→hyphens)",
},
],
examples: [
"wiki bitcoin Open the 'bitcoin' wiki article",
"wiki \"Bitcoin Core\" Open the 'bitcoin-core' article (normalized)",
"wiki Москва Open the 'москва' article (UTF-8 preserved)",
],
seeAlso: ["open", "req"],
appId: "wiki",
category: "Nostr",
argParser: (args: string[]) => {
return parseWikiCommand(args);
},
},
blossom: {
name: "blossom",
section: "1",