mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 09:57:23 +02:00
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:
117
package-lock.json
generated
117
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
src/components/WikiViewer.tsx
Normal file
53
src/components/WikiViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
122
src/components/nostr/AsciidocContent.tsx
Normal file
122
src/components/nostr/AsciidocContent.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
src/components/nostr/kinds/WikiDetailRenderer.tsx
Normal file
89
src/components/nostr/kinds/WikiDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/nostr/kinds/WikiRenderer.tsx
Normal file
49
src/components/nostr/kinds/WikiRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
115
src/index.css
115
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));
|
||||
}
|
||||
|
||||
44
src/lib/wiki-parser.ts
Normal file
44
src/lib/wiki-parser.ts
Normal 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 };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "wiki"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user