Browse Source

wiki disambiguation page alpha

master
Silberengel 10 months ago
parent
commit
1635633efd
  1. 38
      src/lib/components/WikiCard.svelte
  2. 2
      src/lib/utils/markup/basicMarkupParser.ts
  3. 5
      src/lib/utils/nostrUtils.ts
  4. 100
      src/lib/wiki.ts
  5. 274
      src/routes/wiki/+page.svelte
  6. 25
      src/routes/wiki/+page.ts

38
src/lib/components/WikiCard.svelte

@ -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>

2
src/lib/utils/markup/basicMarkupParser.ts

@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string { @@ -142,7 +142,7 @@ function replaceWikilinks(text: string): string {
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./publication?d=${normalized}`;
const url = `./wiki?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
});

5
src/lib/utils/nostrUtils.ts

@ -24,7 +24,7 @@ function escapeHtml(text: string): string { @@ -24,7 +24,7 @@ function escapeHtml(text: string): string {
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string, nip05?: string}> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
@ -73,7 +73,8 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -73,7 +73,8 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
displayName: profile.displayName,
nip05: profile.nip05
};
npubCache.set(cleanId, metadata);

100
src/lib/wiki.ts

@ -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
};
}));
}

274
src/routes/wiki/+page.svelte

@ -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>

25
src/routes/wiki/+page.ts

@ -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…
Cancel
Save