diff --git a/package-lock.json b/package-lock.json
index abd2db3..ec7d1c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 494749e..aec792a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/WikiViewer.tsx b/src/components/WikiViewer.tsx
new file mode 100644
index 0000000..43a0435
--- /dev/null
+++ b/src/components/WikiViewer.tsx
@@ -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 (
+
+
+
+ Loading wiki article...
+
+
+
+ );
+ }
+
+ // Render the wiki article using the detail renderer
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index e43055e..86e65e2 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -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 = ;
+ break;
default:
content = (
diff --git a/src/components/nostr/AsciidocContent.tsx b/src/components/nostr/AsciidocContent.tsx
new file mode 100644
index 0000000..67b1b9a
--- /dev/null
+++ b/src/components/nostr/AsciidocContent.tsx
@@ -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 `
${displayText}`;
+ },
+ );
+
+ // Process nostr: links (already in
tags from asciidoctor)
+ // For now, render them as plain links (TODO: parse and render as mentions)
+ html = html.replace(
+ /]*href="nostr:([^"]+)"[^>]*>([^<]+)<\/a>/g,
+ (_match, nostrId, linkText) => {
+ // Render as clickable link with nostr-mention class
+ return `${linkText}`;
+ },
+ );
+
+ 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 `Failed to render wiki content: ${error instanceof Error ? error.message : "Unknown error"}
`;
+ }
+ }, [content, asciidoctor]);
+
+ // Handle clicks on wiki links and nostr mentions
+ const handleClick = (e: React.MouseEvent) => {
+ 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 (
+
+ );
+}
diff --git a/src/components/nostr/kinds/WikiDetailRenderer.tsx b/src/components/nostr/kinds/WikiDetailRenderer.tsx
new file mode 100644
index 0000000..389213a
--- /dev/null
+++ b/src/components/nostr/kinds/WikiDetailRenderer.tsx
@@ -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 (
+
+ {/* Wiki Article Header */}
+
+ {/* Title */}
+ {title && {title}
}
+ {!title && (
+
+ (Untitled wiki article)
+
+ )}
+
+ {/* Featured Image */}
+ {resolvedImageUrl && (
+
+ )}
+
+ {/* Summary */}
+ {summary && {summary}
}
+
+ {/* Metadata */}
+
+
+ By
+
+
+
•
+
+
+
+
+ {/* Wiki Article Content - Asciidoc */}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/WikiRenderer.tsx b/src/components/nostr/kinds/WikiRenderer.tsx
new file mode 100644
index 0000000..692d35b
--- /dev/null
+++ b/src/components/nostr/kinds/WikiRenderer.tsx
@@ -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 (
+
+
+ {/* Title with wiki icon */}
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {!title && (
+
+ (Untitled wiki article)
+
+ )}
+
+
+ {/* Summary */}
+ {summary && (
+
+ {summary}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index fcec3b3..7d75ffe 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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)
diff --git a/src/index.css b/src/index.css
index 1741435..d8e4573 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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));
+}
diff --git a/src/lib/wiki-parser.ts b/src/lib/wiki-parser.ts
new file mode 100644
index 0000000..5657f93
--- /dev/null
+++ b/src/lib/wiki-parser.ts
@@ -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
+ * 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 ");
+ }
+
+ // 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 };
+}
diff --git a/src/types/app.ts b/src/types/app.ts
index 09a1148..5cafc6f 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
+ | "wiki"
| "win";
export interface WindowInstance {
diff --git a/src/types/man.ts b/src/types/man.ts
index c566eed..24182c8 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -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 = {
category: "Nostr",
defaultProps: {},
},
+ wiki: {
+ name: "wiki",
+ section: "1",
+ synopsis: "wiki ",
+ 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: "",
+ 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",