6 changed files with 441 additions and 3 deletions
@ -0,0 +1,38 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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