From 1ddbcca8101e7ebaba5bfaa8d231bbb9014f0982 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 22:20:24 +0000 Subject: [PATCH] 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 is automatically normalized per NIP-54 (lowercase, hyphens, etc.) --- package-lock.json | 117 ++++++++++++++++- package.json | 1 + src/components/WikiViewer.tsx | 53 ++++++++ src/components/WindowRenderer.tsx | 6 + src/components/nostr/AsciidocContent.tsx | 122 ++++++++++++++++++ .../nostr/kinds/WikiDetailRenderer.tsx | 89 +++++++++++++ src/components/nostr/kinds/WikiRenderer.tsx | 49 +++++++ src/components/nostr/kinds/index.tsx | 4 + src/index.css | 115 +++++++++++++++++ src/lib/wiki-parser.ts | 44 +++++++ src/types/app.ts | 1 + src/types/man.ts | 26 ++++ 12 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 src/components/WikiViewer.tsx create mode 100644 src/components/nostr/AsciidocContent.tsx create mode 100644 src/components/nostr/kinds/WikiDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/WikiRenderer.tsx create mode 100644 src/lib/wiki-parser.ts 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",