6 changed files with 441 additions and 3 deletions
@ -0,0 +1,38 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Card } from "flowbite-svelte"; |
||||||
|
import InlineProfile from "$components/util/InlineProfile.svelte"; |
||||||
|
|
||||||
|
let { title, pubhex, eventId, summary, urlPath, hashtags = [] } = $props<{ |
||||||
|
title: string; |
||||||
|
pubhex: string; |
||||||
|
eventId: string; |
||||||
|
summary: string; |
||||||
|
urlPath: string; |
||||||
|
hashtags?: string[]; |
||||||
|
}>(); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'> |
||||||
|
<div class='col flex flex-row flex-grow space-x-4'> |
||||||
|
<div class="flex flex-col flex-grow"> |
||||||
|
<a href="/wiki?id={urlPath}" class='flex flex-col space-y-2'> |
||||||
|
<h2 class='text-lg font-bold line-clamp-2' title={title}>{title}</h2> |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<h3 class='text-base font-normal'> |
||||||
|
by <InlineProfile pubkey={pubhex} /> |
||||||
|
</h3> |
||||||
|
{#if summary} |
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{summary}</p> |
||||||
|
{/if} |
||||||
|
{#if hashtags.length} |
||||||
|
<div class="flex flex-wrap gap-2 mt-2"> |
||||||
|
{#each hashtags as tag} |
||||||
|
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-xs font-semibold">#{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
import Asciidoctor from 'asciidoctor'; |
||||||
|
import { parseBasicmarkup } from './utils/markup/basicMarkupParser'; |
||||||
|
import { getUserMetadata } from './utils/nostrUtils'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
async function fetchWikiEventById(id: string): Promise<NDKEvent | null> { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) return null; |
||||||
|
|
||||||
|
let eventId = id; |
||||||
|
// If bech32, decode to hex
|
||||||
|
if (id.startsWith('nevent') || id.startsWith('note') || id.startsWith('naddr')) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(id); |
||||||
|
if (decoded.type === 'nevent') { |
||||||
|
eventId = decoded.data.id; |
||||||
|
} else if (decoded.type === 'note') { |
||||||
|
eventId = decoded.data; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch the event by id (hex)
|
||||||
|
const event = await ndk.fetchEvent({ ids: [eventId] }); |
||||||
|
// Only return if it's a wiki event (kind 30818)
|
||||||
|
if (event && event.kind === 30818) { |
||||||
|
return event; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchWikiEventsByDTag(dtag: string): Promise<NDKEvent[]> { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) return []; |
||||||
|
|
||||||
|
// Query for kind 30818 events with the given d-tag
|
||||||
|
const events = await ndk.fetchEvents({ |
||||||
|
kinds: [30818], |
||||||
|
'#d': [dtag] |
||||||
|
}); |
||||||
|
|
||||||
|
// Convert Set to Array and return
|
||||||
|
return Array.from(events); |
||||||
|
} |
||||||
|
|
||||||
|
// Placeholder: Fetch profile name for a pubkey (kind 0 event)
|
||||||
|
async function getProfileName(pubkey: string): Promise<string> { |
||||||
|
if (!pubkey) return 'unknown'; |
||||||
|
const metadata = await getUserMetadata(pubkey); |
||||||
|
return metadata.displayName || metadata.name || pubkey.slice(0, 10); |
||||||
|
} |
||||||
|
|
||||||
|
export async function getWikiPageById(id: string) { |
||||||
|
const event = await fetchWikiEventById(id); |
||||||
|
if (!event) return null; |
||||||
|
const pubhex = event.pubkey || ''; |
||||||
|
const author = await getProfileName(pubhex); |
||||||
|
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); |
||||||
|
const title = titleTag ? titleTag[1] : 'Untitled'; |
||||||
|
const asciidoctor = Asciidoctor(); |
||||||
|
const asciidocHtml = asciidoctor.convert(event.content).toString(); |
||||||
|
// Optionally log for debugging:
|
||||||
|
// console.log('AsciiDoc HTML:', asciidocHtml);
|
||||||
|
const html = await parseBasicmarkup(asciidocHtml); |
||||||
|
return { title, author, pubhex, html }; |
||||||
|
} |
||||||
|
|
||||||
|
export async function searchWikiPagesByDTag(dtag: string) { |
||||||
|
const events = await fetchWikiEventsByDTag(dtag); |
||||||
|
// Return array of { title, pubhex, eventId, summary, nip05 }
|
||||||
|
return Promise.all(events.map(async (event: any) => { |
||||||
|
const pubhex = event.pubkey || ''; |
||||||
|
// Get title from 't' tag
|
||||||
|
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 't'); |
||||||
|
const title = titleTag ? titleTag[1] : 'Untitled'; |
||||||
|
// Get summary from 'summary' tag
|
||||||
|
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); |
||||||
|
const summary = summaryTag ? summaryTag[1] : ''; |
||||||
|
|
||||||
|
// Get user metadata including NIP-05
|
||||||
|
const metadata = await getUserMetadata(pubhex); |
||||||
|
const nip05 = metadata.nip05 || ''; |
||||||
|
|
||||||
|
// Construct human-readable URL
|
||||||
|
const urlPath = nip05 ? `${dtag}/${nip05}` : `${dtag}*${pubhex}`; |
||||||
|
|
||||||
|
return { |
||||||
|
title, |
||||||
|
pubhex, |
||||||
|
eventId: event.id, |
||||||
|
summary, |
||||||
|
urlPath |
||||||
|
}; |
||||||
|
})); |
||||||
|
} |
||||||
@ -0,0 +1,274 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import WikiCard from "$lib/components/WikiCard.svelte"; |
||||||
|
import InlineProfile from "$lib/components/util/InlineProfile.svelte"; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { getWikiPageById } from '$lib/wiki'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
|
||||||
|
// @ts-ignore Svelte linter false positive: hashtags is used in the template |
||||||
|
let { } = $props<{ |
||||||
|
title: string; |
||||||
|
pubhex: string; |
||||||
|
eventId: string; |
||||||
|
summary: string; |
||||||
|
urlPath: string; |
||||||
|
hashtags?: string[]; |
||||||
|
}>(); |
||||||
|
|
||||||
|
type WikiCardResult = { |
||||||
|
title: string; |
||||||
|
pubhex: string; |
||||||
|
eventId: string; |
||||||
|
summary: string; |
||||||
|
urlPath: string; |
||||||
|
hashtags: string[]; |
||||||
|
}; |
||||||
|
|
||||||
|
let search = $state(''); |
||||||
|
let results: WikiCardResult[] = $state([]); |
||||||
|
let loading = $state(false); |
||||||
|
let wikiPage: WikiCardResult | null = $state(null); |
||||||
|
let wikiContent: { title: string; author: string; pubhex: string; html: string } | null = $state(null); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
|
||||||
|
function normalize(str: string) { |
||||||
|
return str.toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim(); |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchResults(query: string) { |
||||||
|
if (!query.trim()) { |
||||||
|
results = []; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
loading = true; |
||||||
|
const ndk = $ndkInstance; |
||||||
|
if (!ndk) { |
||||||
|
results = []; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
const events = await ndk.fetchEvents({ kinds: [30818] }); |
||||||
|
const normQuery = normalize(query); |
||||||
|
|
||||||
|
// 1. Filter by title |
||||||
|
let filtered = Array.from(events).filter((event: any) => { |
||||||
|
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); |
||||||
|
const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; |
||||||
|
return normalize(title).includes(normQuery); |
||||||
|
}); |
||||||
|
|
||||||
|
// 2. If no title matches, filter by hashtags |
||||||
|
if (filtered.length === 0) { |
||||||
|
filtered = Array.from(events).filter((event: any) => { |
||||||
|
// Find all tags that are hashtags (tag[0] === '#') |
||||||
|
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === '#').map((tag: string[]) => tag[1]) || []; |
||||||
|
return hashtags.some((hashtag: string) => normalize(hashtag).includes(normQuery)); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
results = await Promise.all(filtered.map(async (event: any) => { |
||||||
|
const pubhex = event.pubkey || ''; |
||||||
|
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); |
||||||
|
const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; |
||||||
|
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); |
||||||
|
const summary = summaryTag ? summaryTag[1] : ''; |
||||||
|
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || []; |
||||||
|
const nevent = nip19.neventEncode({ id: event.id, relays: [] }); |
||||||
|
return { title, pubhex, eventId: event.id, summary, urlPath: nevent, hashtags }; |
||||||
|
})); |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchWikiPageById(id: string) { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
try { |
||||||
|
const ndk = $ndkInstance; |
||||||
|
if (!ndk) { |
||||||
|
wikiPage = null; |
||||||
|
wikiContent = null; |
||||||
|
return; |
||||||
|
} |
||||||
|
let eventId = id; |
||||||
|
if (id.startsWith('nevent')) { |
||||||
|
const decoded = nip19.decode(id); |
||||||
|
if (typeof decoded === 'string') { |
||||||
|
eventId = decoded; |
||||||
|
} else if (typeof decoded === 'object' && 'data' in decoded && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||||
|
eventId = decoded.data.id; |
||||||
|
} |
||||||
|
} |
||||||
|
const event = await ndk.fetchEvent({ ids: [eventId] }); |
||||||
|
if (event) { |
||||||
|
const pubhex = event.pubkey || ''; |
||||||
|
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); |
||||||
|
const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; |
||||||
|
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); |
||||||
|
const summary = summaryTag ? summaryTag[1] : ''; |
||||||
|
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || []; |
||||||
|
wikiPage = { title, pubhex, eventId: event.id, summary, urlPath: id, hashtags }; |
||||||
|
|
||||||
|
// Fetch the full wiki content |
||||||
|
const content = await getWikiPageById(id); |
||||||
|
if (content) { |
||||||
|
// const html = await parseBasicmarkup(asciidocHtml); |
||||||
|
const html = content.html; |
||||||
|
console.log('Final HTML:', html); |
||||||
|
wikiContent = { |
||||||
|
title: content.title, |
||||||
|
author: content.author, |
||||||
|
pubhex: content.pubhex, |
||||||
|
html: html |
||||||
|
}; |
||||||
|
} else { |
||||||
|
error = 'Failed to load wiki content'; |
||||||
|
} |
||||||
|
} else { |
||||||
|
wikiPage = null; |
||||||
|
wikiContent = null; |
||||||
|
error = 'Wiki page not found'; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('Error fetching wiki page:', e); |
||||||
|
error = 'Error loading wiki page'; |
||||||
|
wikiPage = null; |
||||||
|
wikiContent = null; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Debounced effect for search |
||||||
|
$effect(() => { |
||||||
|
if (search && wikiPage) { |
||||||
|
wikiPage = null; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
const id = $page.url.searchParams.get('id'); |
||||||
|
if (id) { |
||||||
|
fetchWikiPageById(id); |
||||||
|
search = ''; |
||||||
|
results = []; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function handleCardClick(urlPath: string) { |
||||||
|
goto(`/wiki?id=${encodeURIComponent(urlPath)}`); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
const params = new URLSearchParams(window.location.search); |
||||||
|
const d = params.get('d'); |
||||||
|
const id = params.get('id'); |
||||||
|
if (id) { |
||||||
|
wikiPage = null; |
||||||
|
fetchWikiPageById(id); |
||||||
|
search = ''; |
||||||
|
results = []; |
||||||
|
} else if (d) { |
||||||
|
search = d; |
||||||
|
wikiPage = null; |
||||||
|
fetchResults(search); |
||||||
|
} else { |
||||||
|
search = ''; |
||||||
|
results = []; |
||||||
|
wikiPage = null; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col items-center min-h-[60vh] pt-8"> |
||||||
|
<div class="w-full max-w-xl flex flex-col items-center"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
placeholder="Search for a wiki topic..." |
||||||
|
bind:value={search} |
||||||
|
oninput={() => { |
||||||
|
if (wikiPage) { |
||||||
|
wikiPage = null; |
||||||
|
wikiContent = null; |
||||||
|
} |
||||||
|
fetchResults(search); |
||||||
|
}} |
||||||
|
autocomplete="off" |
||||||
|
class="w-full px-6 py-4 rounded-2xl border border-primary-200 shadow bg-primary-50 focus:outline-none focus:ring-2 focus:ring-primary-400 text-lg transition" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="flex flex-col items-center mt-8"> |
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> |
||||||
|
<p class="mt-4 text-gray-600">Loading wiki content...</p> |
||||||
|
</div> |
||||||
|
{:else if error} |
||||||
|
<div class="flex flex-col items-center mt-8 text-red-600"> |
||||||
|
<p>{error}</p> |
||||||
|
</div> |
||||||
|
{:else if wikiPage && wikiContent} |
||||||
|
<div class="flex flex-col items-center mt-8 max-w-4xl w-full px-4"> |
||||||
|
<h1 class="text-3xl font-bold mb-2">{wikiContent.title}</h1> |
||||||
|
<div class="mb-2"> |
||||||
|
by <InlineProfile pubkey={wikiContent.pubhex} /> |
||||||
|
</div> |
||||||
|
{#if wikiPage.hashtags.length} |
||||||
|
<div class="flex flex-wrap gap-2 mb-6"> |
||||||
|
{#each wikiPage.hashtags as tag} |
||||||
|
<span class="px-3 py-1 rounded-full bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if wikiPage.summary} |
||||||
|
<div class="mb-6 text-lg text-gray-700 max-w-2xl text-center">{wikiPage.summary}</div> |
||||||
|
{/if} |
||||||
|
<div class="w-full prose prose-lg dark:prose-invert max-w-none"> |
||||||
|
{@html wikiContent.html} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{:else if !search} |
||||||
|
<div class="max-w-xl mx-auto mt-12 text-center text-lg space-y-4"> |
||||||
|
<p> |
||||||
|
<strong>Welcome to the Alexandria Wiki!</strong> |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
Use the search bar above to find wiki pages on any topic. |
||||||
|
Alexandria wiki pages are stored on Nostr relays and can be collaboratively added to by anyone with a Nostr key. |
||||||
|
Search by topic, and you'll see all relevant wiki pages, each signed by its author. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
{:else if results.length === 0} |
||||||
|
<p class="text-center mt-8">No entries found for this topic.</p> |
||||||
|
{:else} |
||||||
|
<div |
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 justify-center mt-8" |
||||||
|
style="max-width: 100vw;" |
||||||
|
> |
||||||
|
{#each results as result} |
||||||
|
<a |
||||||
|
href="/wiki?id={result.urlPath}" |
||||||
|
onclick={(e) => { e.preventDefault(); handleCardClick(result.urlPath); }} |
||||||
|
class="mx-auto w-full max-w-xl block text-left focus:outline-none" |
||||||
|
tabindex="0" |
||||||
|
aria-label={`Open wiki page: ${result.title}`} |
||||||
|
style="cursor:pointer;" |
||||||
|
> |
||||||
|
<WikiCard |
||||||
|
title={result.title} |
||||||
|
pubhex={result.pubhex} |
||||||
|
eventId={result.eventId} |
||||||
|
summary={result.summary} |
||||||
|
urlPath={result.urlPath} |
||||||
|
hashtags={result.hashtags} |
||||||
|
/> |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div>{@html '<h1>Hello</h1><p>This is a test.</p>'}</div> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import type { Load } from '@sveltejs/kit'; |
||||||
|
import { getWikiPageById, searchWikiPagesByDTag } from '../../lib/wiki'; |
||||||
|
|
||||||
|
export const load: Load = async ({ url }) => { |
||||||
|
const id = url.searchParams.get('id'); |
||||||
|
const d = url.searchParams.get('d'); |
||||||
|
|
||||||
|
if (d) { |
||||||
|
// Disambiguation/search page for d-tag
|
||||||
|
const results = await searchWikiPagesByDTag(d); |
||||||
|
return { disambiguation: true, results, dtag: d }; |
||||||
|
} |
||||||
|
|
||||||
|
if (id) { |
||||||
|
// Single wiki page by event id (bech32 or hex)
|
||||||
|
const page = await getWikiPageById(id); |
||||||
|
if (!page) { |
||||||
|
return { status: 404, error: 'Wiki page not found.' }; |
||||||
|
} |
||||||
|
return { disambiguation: false, page }; |
||||||
|
} |
||||||
|
|
||||||
|
// No query: show only the search bar
|
||||||
|
return { disambiguation: true, results: [], dtag: '' }; |
||||||
|
};
|
||||||
Loading…
Reference in new issue