diff --git a/README.md b/README.md index b7cffbb..f82fb12 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ # Alexandria Alexandria is a reader and writer for curated publications, including e-books. -For a thorough introduction, please refer to our [project documention](https://next-alexandria.gitcitadel.eu/publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](https://next-alexandria.gitcitadel.eu/about). +For a thorough introduction, please refer to our [project documention](./publication?d=gitcitadel-project-documentation-by-stella-v-1), viewable on Alexandria, or to the Alexandria [About page](./about). ## Issues and Patches -If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact). +If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](./contact). -You can also contact us [on Nostr](https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly. +You can also contact us [on Nostr](./events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly. ## Developing @@ -73,7 +73,7 @@ To run the container, in detached mode (-d): docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria ``` -The container is then viewable on your [local machine](http://localhost:4174). +The container is then viewable on your [local machine](./). If you want to see the container process (assuming it's the last process to start), enter: @@ -118,4 +118,4 @@ npx playwright test ## Markup Support -Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](src/lib/utils/markup/MarkupInfo.md). \ No newline at end of file +Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md). \ No newline at end of file diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index fab2c7d..986f246 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -11,7 +11,6 @@ let { class: className = "" } = $props(); - let leftMenuOpen = $state(false); @@ -25,7 +24,9 @@ - Publish + Publications + Wiki + Events Visualize Getting Started About diff --git a/src/lib/components/PublicationFeed.svelte b/src/lib/components/PublicationFeed.svelte index a6c28e8..56cc4e1 100644 --- a/src/lib/components/PublicationFeed.svelte +++ b/src/lib/components/PublicationFeed.svelte @@ -2,6 +2,7 @@ import { indexKind } from '$lib/consts'; import { ndkInstance } from '$lib/ndk'; import { filterValidIndexEvents } from '$lib/utils'; + import { fetchEventWithFallback } from '$lib/utils/nostrUtils'; import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'; import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import ArticleHeader from './PublicationHeader.svelte'; @@ -20,40 +21,57 @@ async function getEvents( before: number | undefined = undefined, ): Promise { - let eventSet = await $ndkInstance.fetchEvents( - { + try { + // First try to fetch a single event to verify we can connect to the relays + const testEvent = await fetchEventWithFallback($ndkInstance, { kinds: [indexKind], - limit: 16, - until: before, - }, - { - groupable: false, - skipVerification: false, - skipValidation: false, - }, - NDKRelaySet.fromRelayUrls(relays, $ndkInstance) - ); - eventSet = filterValidIndexEvents(eventSet); - - let eventArray = Array.from(eventSet); - eventArray?.sort((a, b) => b.created_at! - a.created_at!); - - if (!eventArray) { - return; - } + limit: 1, + until: before + }); - endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id; + if (!testEvent) { + console.warn('No events found in initial fetch'); + return; + } - if (endOfFeed) { - return; - } + // If we found an event, proceed with fetching the full set + let eventSet = await $ndkInstance.fetchEvents( + { + kinds: [indexKind], + limit: 16, + until: before, + }, + { + groupable: false, + skipVerification: false, + skipValidation: false, + }, + NDKRelaySet.fromRelayUrls(relays, $ndkInstance) + ); + eventSet = filterValidIndexEvents(eventSet); + + let eventArray = Array.from(eventSet); + eventArray?.sort((a, b) => b.created_at! - a.created_at!); + + if (!eventArray) { + return; + } - const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event])); - const allEvents = Array.from(eventMap.values()); - const uniqueIds = new Set(allEvents.map(event => event.id)); - eventsInView = Array.from(uniqueIds) - .map(id => eventMap.get(id)) - .filter(event => event != null) as NDKEvent[]; + endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id; + + if (endOfFeed) { + return; + } + + const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event])); + const allEvents = Array.from(eventMap.values()); + const uniqueIds = new Set(allEvents.map(event => event.id)); + eventsInView = Array.from(uniqueIds) + .map(id => eventMap.get(id)) + .filter(event => event != null) as NDKEvent[]; + } catch (err) { + console.error('Error fetching events:', err); + } } const getSkeletonIds = (): string[] => { diff --git a/src/lib/components/WikiCard.svelte b/src/lib/components/WikiCard.svelte index e608591..ae84c7f 100644 --- a/src/lib/components/WikiCard.svelte +++ b/src/lib/components/WikiCard.svelte @@ -2,14 +2,19 @@ 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[]; - }>(); + export let title: string; + export let pubhex: string; + export let eventId: string; + export let summary: string; + export let urlPath: string; + export let hashtags: string[] = []; + export let html: string = ''; + + let expanded = false; + $: preview = html.slice(0, 250); + + // Logging for debug + console.log('WikiCard props:', { title, pubhex, eventId, summary, urlPath, hashtags }); @@ -32,6 +37,12 @@ {/if} +
+ {@html expanded ? html : preview} + {#if !expanded && html.length > 250} + + {/if} +
diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte index aa15174..a1e3a3a 100644 --- a/src/lib/components/util/CardActions.svelte +++ b/src/lib/components/util/CardActions.svelte @@ -11,6 +11,7 @@ import { standardRelays } from "$lib/consts"; import { neventEncode, naddrEncode } from "$lib/utils"; import InlineProfile from "$components/util/InlineProfile.svelte"; + import { goto } from "$app/navigation"; let { event } = $props(); @@ -78,10 +79,41 @@ } function viewDetails() { - console.log('Details'); detailsModalOpen = true; } + // --- Custom JSON pretty-printer with NIP-33 address hyperlinking --- + /** + * Returns HTML for pretty-printed JSON, with NIP-33 addresses as links to /events?id=naddr1... + */ + function jsonWithNaddrLinks(obj: any): string { + const NIP33_REGEX = /\b(\d{5}:[a-f0-9]{64}:[a-zA-Z0-9._-]+)\b/g; + function replacer(_key: string, value: any) { + return value; + } + // Stringify with 2-space indent + let json = JSON.stringify(obj, replacer, 2); + // Replace NIP-33 addresses with links + json = json.replace(NIP33_REGEX, (match) => { + try { + const [kind, pubkey, dtag] = match.split(":"); + // Compose a fake event for naddrEncode + const fakeEvent = { + kind: parseInt(kind), + pubkey, + tags: [["d", dtag]], + }; + const naddr = naddrEncode(fakeEvent as any, standardRelays); + return `${match}`; + } catch { + return match; + } + }); + // Escape < and > for HTML safety, but allow our tags + json = json.replace(/&/g, '&').replace(//g, '>'); + json = json.replace(/<a /g, ''); + return json; + }
@@ -127,22 +159,11 @@ {/if} -
  • - -
  • {/if} - - -
    -
    {JSON.stringify(event.rawEvent(), null, 2)}
    -
    -
    diff --git a/src/lib/components/util/InlineProfile.svelte b/src/lib/components/util/InlineProfile.svelte index 4b9efe3..9ed9d5b 100644 --- a/src/lib/components/util/InlineProfile.svelte +++ b/src/lib/components/util/InlineProfile.svelte @@ -5,7 +5,7 @@ let { pubkey, title = null } = $props(); - const externalProfileDestination = 'https://njump.me/' + const externalProfileDestination = './events?id=' let loading = $state(true); let anon = $state(false); let npub = $state(''); @@ -45,9 +45,9 @@ {#if loading} {title ?? '…'} {:else if anon } - {shortenNpub(npub)} + {shortenNpub(npub)} {:else if npub } - + = 30000 && kind < 40000) { return 'addressable'; diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts index c59e84b..123a19a 100644 --- a/src/lib/utils/nostrUtils.ts +++ b/src/lib/utils/nostrUtils.ts @@ -2,6 +2,9 @@ import { get } from 'svelte/store'; import { nip19 } from 'nostr-tools'; import { ndkInstance } from '$lib/ndk'; import { npubCache } from './npubCache'; +import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; +import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { standardRelays, bootstrapRelays } from "$lib/consts"; // Regular expressions for Nostr identifiers - match the entire identifier including any prefix export const NOSTR_PROFILE_REGEX = /(?@${escapedText}`; + return `@${escapedText}`; } /** @@ -110,7 +113,7 @@ function createNoteLink(identifier: string): string { const escapedId = escapeHtml(cleanId); const escapedText = escapeHtml(shortId); - return `${escapedText}`; + return `${escapedText}`; } /** @@ -180,4 +183,62 @@ export async function getNpubFromNip05(nip05: string): Promise { console.error('Error getting npub from nip05:', error); return null; } +} + +/** + * Fetches an event using a two-step relay strategy: + * 1. First tries standard relays with timeout + * 2. Falls back to all relays if not found + * Always wraps result as NDKEvent + */ +export async function fetchEventWithFallback( + ndk: NDK, + filterOrId: string | NDKFilter, + timeoutMs: number = 3000 +): Promise { + const allRelays = Array.from(new Set([...standardRelays, ...bootstrapRelays])); + const relaySets = [ + NDKRelaySet.fromRelayUrls(standardRelays, ndk), + NDKRelaySet.fromRelayUrls(allRelays, ndk) + ]; + + async function withTimeout(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs)) + ]); + } + + try { + let found: NDKEvent | null = null; + + // Try standard relays first + if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { + found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[0])); + if (!found) { + // Fallback to all relays + found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[1])); + } + } else { + const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; + const results = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[0])); + found = results instanceof Set ? Array.from(results)[0] as NDKEvent : null; + if (!found) { + // Fallback to all relays + const fallbackResults = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[1])); + found = fallbackResults instanceof Set ? Array.from(fallbackResults)[0] as NDKEvent : null; + } + } + + if (!found) { + console.warn('Event not found after timeout. Some relays may be offline or slow.'); + return null; + } + + // Always wrap as NDKEvent + return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); + } catch (err) { + console.error('Error in fetchEventWithFallback:', err); + return null; + } } \ No newline at end of file diff --git a/src/lib/wiki.ts b/src/lib/wiki.ts index d545ffa..42a3142 100644 --- a/src/lib/wiki.ts +++ b/src/lib/wiki.ts @@ -1,17 +1,24 @@ -import Asciidoctor from 'asciidoctor'; import { parseBasicmarkup } from './utils/markup/basicMarkupParser'; -import { getUserMetadata } from './utils/nostrUtils'; +import { getUserMetadata, fetchEventWithFallback } 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'; +import type NDK from '@nostr-dev-kit/ndk'; +import Pharos from '$lib/parser.ts'; +import { wikiKind } from './consts'; -async function fetchWikiEventById(id: string): Promise { +/** + * Fetch a single wiki event by id (hex or bech32). + */ +export async function fetchWikiEventById(id: string): Promise { const ndk = get(ndkInstance); - if (!ndk) return null; + if (!ndk) { + console.warn('NDK instance not found in fetchWikiEventById'); + 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); @@ -20,75 +27,139 @@ async function fetchWikiEventById(id: string): Promise { } else if (decoded.type === 'note') { eventId = decoded.data; } - } catch { + } catch (e) { + console.error('Failed to decode id in fetchWikiEventById:', e); 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) { + const event = await fetchEventWithFallback(ndk, eventId); + if (event && event.kind === wikiKind) { + console.log('Fetched wiki event:', event); return event; } + console.warn('No wiki event found for id:', eventId); return null; } -async function fetchWikiEventsByDTag(dtag: string): Promise { +/** + * Fetch all wiki events by d-tag. + */ +export async function fetchWikiEventsByDTag(dtag: string): Promise { const ndk = get(ndkInstance); - if (!ndk) return []; + if (!ndk) { + console.warn('NDK instance not found in fetchWikiEventsByDTag'); + return []; + } - // Query for kind 30818 events with the given d-tag - const events = await ndk.fetchEvents({ - kinds: [30818], + const event = await fetchEventWithFallback(ndk, { + kinds: [wikiKind], '#d': [dtag] }); + + if (!event) { + console.warn(`No wiki events found for dtag: ${dtag}`); + return []; + } - // Convert Set to Array and return - return Array.from(events); + // For d-tag queries, we want to get all matching events, not just the first one + const events = await ndk.fetchEvents({ + kinds: [wikiKind], + '#d': [dtag] + }); + + const arr = Array.from(events); + console.log(`Fetched ${arr.length} wiki events for dtag:`, dtag); + return arr; } -// Placeholder: Fetch profile name for a pubkey (kind 0 event) -async function getProfileName(pubkey: string): Promise { +/** + * Get a display name for a pubkey. + */ +export async function getProfileName(pubkey: string): Promise { 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; +/** + * Fetch and parse a wiki page by event id or nevent. + */ +export async function getWikiPageById(id: string, ndk: NDK) { + console.log('getWikiPageById: fetching wiki page for id', id); + if (!id) { + console.error('getWikiPageById: id is undefined'); + return null; + } + + let event; + try { + event = await fetchEventWithFallback(ndk, id); + if (!event) { + console.error('getWikiPageById: No event found for id:', id); + return null; + } + if (event.kind !== wikiKind) { + console.error('getWikiPageById: Event found but kind !== wikiKind:', event); + return null; + } + if (!event.content) { + console.error('getWikiPageById: Event has no content:', event); + return null; + } + if (!event.tags) { + console.error('getWikiPageById: Event has no tags:', event); + return null; + } + } catch (err) { + console.error('getWikiPageById: Exception fetching event:', err, 'id:', id); + return null; + } + const pubhex = event.pubkey || ''; - const author = await getProfileName(pubhex); - const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); + 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 }; + 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]) || []; + + let asciidoc = event.content; + if (!/^=\s/m.test(asciidoc)) { + console.warn('getWikiPageById: No document header found, prepending fake header for title:', title); + asciidoc = `= ${title}\n\n` + asciidoc; + } + + let html = ''; + try { + const pharos = new Pharos(ndk); + pharos.parse(asciidoc); + const pharosHtml = pharos.getHtml(); + html = await parseBasicmarkup(pharosHtml); + if (!html) { + console.error('getWikiPageById: Parsed HTML is empty for id:', id, 'event:', event); + } + } catch (err) { + console.error('getWikiPageById: Error parsing content:', err, 'event:', event); + return null; + } + + return { title, pubhex, eventId: event.id, summary, hashtags, html }; } +/** + * Search wiki pages by d-tag. + */ 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) => { + return Promise.all(events.map(async (event: NDKEvent) => { 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, @@ -97,4 +168,14 @@ export async function searchWikiPagesByDTag(dtag: string) { urlPath }; })); +} + +/** + * Parse wiki content using Pharos and basic markup parser. + */ +export async function parseWikiContent(content: string, ndk: NDK): Promise { + const pharos = new Pharos(ndk); + pharos.parse(content); + const pharosHtml = pharos.getHtml(); + return await parseBasicmarkup(pharosHtml); } \ No newline at end of file diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 8f6a911..f449b74 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -45,7 +45,7 @@

    We are easiest to contact over our Nostr address GitCitadel. Or, you can visit us on our

    - You can contact us on Nostr GitCitadel or you can view submitted issues on the Alexandria repo page. + You can contact us on Nostr GitCitadel or you can view submitted issues on the Alexandria repo page.

    Submit an issue diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte new file mode 100644 index 0000000..bb1584d --- /dev/null +++ b/src/routes/events/+page.svelte @@ -0,0 +1,367 @@ + + +
    +
    +
    + Events +
    + +

    + Use this page to view any event (npub, nprofile, nevent, naddr, or hexID). +

    + +
    + e.key === 'Enter' && searchEvent()} + /> + +
    + + {#if error} + + {/if} + + {#if event} +
    + +
    + {neventEncode(event, standardRelays)} +
    + + +
    +

    {getEventTitle(event)}

    +
    + Author: + +
    +
    + Kind: + {event.kind} + ({getEventTypeDisplay(event)}) +
    + {#if getEventSummary(event)} +
    + Summary: +

    {getEventSummary(event)}

    +
    + {/if} + {#if getEventHashtags(event).length} +
    + Tags: +
    + {#each getEventHashtags(event) as tag} + #{tag} + {/each} +
    +
    + {/if} + + +
    + Content: + {#if event.kind === 0} + {#if profile} +
    + {#if profile.name} +
    Name: {profile.name}
    + {/if} + {#if profile.display_name} +
    Display Name: {profile.display_name}
    + {/if} + {#if profile.about} +
    About: {profile.about}
    + {/if} + {#if profile.picture} +
    + Picture: + Profile +
    + {/if} + {#if profile.banner} +
    + Banner: + Banner +
    + {/if} + {#if profile.website} +
    + Website: + {profile.website} +
    + {/if} + {#if profile.lud16} +
    + Lightning Address: {profile.lud16} +
    + {/if} + {#if profile.nip05} +
    + NIP-05: {profile.nip05} +
    + {/if} + +
    + {:else} +
    {event.content}
    + {/if} + {:else} +
    + {@html showFullContent ? parsedContent : contentPreview} + {#if !showFullContent && parsedContent.length > 250} + + {/if} +
    + {/if} +
    + + + {#if event.tags && event.tags.length} +
    + Event Tags: +
    + {#each event.tags as tag} + {@html renderTag(tag)} + {/each} +
    +
    + {/if} + + +
    + + Show Raw Event JSON + +
    +{JSON.stringify(event.rawEvent(), null, 2)}
    +            
    +
    +
    +
    + {/if} +
    +
    diff --git a/src/routes/wiki/+page.svelte b/src/routes/wiki/+page.svelte index 3cc7587..58abf1b 100644 --- a/src/routes/wiki/+page.svelte +++ b/src/routes/wiki/+page.svelte @@ -4,9 +4,12 @@ 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'; + import { getWikiPageById, getProfileName } from '$lib/wiki'; + import { type NDKEvent } from '@nostr-dev-kit/ndk'; + import { neventEncode } from '$lib/utils'; + import { processNostrIdentifiers } from '$lib/utils/nostrUtils'; + import { standardRelays, wikiKind } from '$lib/consts'; // @ts-ignore Svelte linter false positive: hashtags is used in the template let { } = $props<{ @@ -18,21 +21,26 @@ hashtags?: string[]; }>(); - type WikiCardResult = { + type WikiPage = { title: string; pubhex: string; eventId: string; summary: string; - urlPath: string; hashtags: string[]; + html: string; }; - let search = $state(''); - let results: WikiCardResult[] = $state([]); + let searchInput = $state(''); + let results: WikiPage[] = $state([]); let loading = $state(false); - let wikiPage: WikiCardResult | null = $state(null); + let wikiPage: WikiPage | null = $state(null); let wikiContent: { title: string; author: string; pubhex: string; html: string } | null = $state(null); let error = $state(null); + let expandedContent = $state(false); + let contentPreview = $derived(() => { + if (!wikiPage) return ''; + return wikiPage.html.slice(0, 250); + }); function normalize(str: string) { return str.toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim(); @@ -45,42 +53,48 @@ 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); - }); + error = null; + try { + const ndk = $ndkInstance; + if (!ndk) { + results = []; + error = 'NDK instance not available'; + loading = false; + return; + } + const events = await ndk.fetchEvents({ kinds: [wikiKind] }); + const normQuery = normalize(query); - // 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)); + // Filter by title or hashtags + let filtered = Array.from(events).filter((event: NDKEvent) => { + const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); + const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; + const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || []; + + return normalize(title).includes(normQuery) || + 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; + const pages = await Promise.all(filtered.map(async (event: NDKEvent) => { + const pageData = await getWikiPageById(event.id, ndk); + if (pageData) { + // Process Nostr identifiers in the HTML content + pageData.html = await processNostrIdentifiers(pageData.html); + } + if (event && typeof event.getMatchingTags !== 'function') { + console.error('Fetched event is not an NDKEvent:', event); + } + return pageData as WikiPage | null; + })); + + results = pages.filter((page): page is WikiPage => page !== null); + } catch (e) { + error = 'Error searching wiki pages'; + results = []; + console.error('fetchResults: Exception:', e); + } finally { + loading = false; + } } async function fetchWikiPageById(id: string) { @@ -91,69 +105,65 @@ if (!ndk) { wikiPage = null; wikiContent = null; + console.error('fetchWikiPageById: NDK instance not available'); 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; - } + if (!id) { + console.error('fetchWikiPageById: id is undefined'); + return; } - 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'; + console.log('fetchWikiPageById: fetching wiki page for id', id); + const pageData = await getWikiPageById(id, ndk); + if (pageData) { + // Process Nostr identifiers in the HTML content + const processedHtml = await processNostrIdentifiers(pageData.html); + wikiPage = { + title: pageData.title, + pubhex: pageData.pubhex, + eventId: pageData.eventId, + summary: pageData.summary, + hashtags: pageData.hashtags, + html: processedHtml, + }; + wikiContent = { + title: pageData.title, + author: await getProfileName(pageData.pubhex), + pubhex: pageData.pubhex, + html: processedHtml + }; + if (!wikiPage.html) { + console.error('fetchWikiPageById: wikiPage.html is empty for id', id, wikiPage); } + console.log('wikiPage.html:', wikiPage?.html); } else { wikiPage = null; wikiContent = null; error = 'Wiki page not found'; + console.error('fetchWikiPageById: Wiki page not found for id', id); } } catch (e) { - console.error('Error fetching wiki page:', e); error = 'Error loading wiki page'; wikiPage = null; wikiContent = null; + console.error('fetchWikiPageById: Exception:', e); } finally { loading = false; } } - // Debounced effect for search + // Clear wikiPage if searching $effect(() => { - if (search && wikiPage) { + if (searchInput && wikiPage) { wikiPage = null; } }); + // Watch for ?id= in the URL and load the wiki page if present $effect(() => { const id = $page.url.searchParams.get('id'); if (id) { fetchWikiPageById(id); - search = ''; + searchInput = ''; results = []; } }); @@ -162,6 +172,20 @@ goto(`/wiki?id=${encodeURIComponent(urlPath)}`); } + function getNevent(eventId: string): string { + try { + const event = { id: eventId, kind: wikiKind } as NDKEvent; + return neventEncode(event, standardRelays); + } catch (e) { + console.error('Error encoding nevent:', e); + return eventId; + } + } + + function handleProfileClick(pubkey: string) { + goto(`/profile?pubkey=${pubkey}`); + } + onMount(() => { const params = new URLSearchParams(window.location.search); const d = params.get('d'); @@ -169,14 +193,14 @@ if (id) { wikiPage = null; fetchWikiPageById(id); - search = ''; + searchInput = ''; results = []; } else if (d) { - search = d; + searchInput = d; wikiPage = null; - fetchResults(search); + fetchResults(searchInput); } else { - search = ''; + searchInput = ''; results = []; wikiPage = null; } @@ -188,13 +212,13 @@ { if (wikiPage) { wikiPage = null; wikiContent = null; } - fetchResults(search); + fetchResults(searchInput); }} 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" @@ -212,9 +236,15 @@ {:else if wikiPage && wikiContent}
    -

    {wikiContent.title}

    +
    {getNevent(wikiPage.eventId)}
    +

    {wikiPage.title}

    - by + by
    {#if wikiPage.hashtags.length}
    @@ -227,10 +257,23 @@
    {wikiPage.summary}
    {/if}
    - {@html wikiContent.html} + {#if wikiPage.html && wikiPage.html.trim().length > 0} + {#if event && typeof event.getMatchingTags === 'function'} + {@html wikiPage.html} + {:else if event} +
    Fetched event is not a valid NDKEvent. See console for details.
    + {/if} + {:else} +
    + No content found for this wiki page. +
    +              {JSON.stringify(wikiPage, null, 2)}
    +            
    +
    + {/if}
    - {:else if !search} + {:else if !searchInput}

    Welcome to the Alexandria Wiki! @@ -244,31 +287,17 @@ {:else if results.length === 0}

    No entries found for this topic.

    {:else} - - -
    {@html '

    Hello

    This is a test.

    '}
    \ No newline at end of file +
    \ No newline at end of file diff --git a/tests/integration/markupIntegration.test.ts b/tests/integration/markupIntegration.test.ts index 0bc7443..091f648 100644 --- a/tests/integration/markupIntegration.test.ts +++ b/tests/integration/markupIntegration.test.ts @@ -34,7 +34,7 @@ describe('Markup Integration Test', () => { // Hashtags expect(output).toContain('text-primary-600'); // Nostr identifiers (should be njump.me links) - expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); + expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); // Wikilinks expect(output).toContain('wikilink'); // YouTube iframe @@ -77,7 +77,7 @@ describe('Markup Integration Test', () => { // Hashtags expect(output).toContain('text-primary-600'); // Nostr identifiers (should be njump.me links) - expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); + expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); // Wikilinks expect(output).toContain('wikilink'); // YouTube iframe diff --git a/tests/unit/advancedMarkupParser.test.ts b/tests/unit/advancedMarkupParser.test.ts index 6e64327..0d868d1 100644 --- a/tests/unit/advancedMarkupParser.test.ts +++ b/tests/unit/advancedMarkupParser.test.ts @@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => { it('parses nostr identifiers', async () => { const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const output = await parseAdvancedmarkup(input); - expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); + expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); }); it('parses emoji shortcodes', async () => { diff --git a/tests/unit/basicMarkupParser.test.ts b/tests/unit/basicMarkupParser.test.ts index 45e0d46..4025b65 100644 --- a/tests/unit/basicMarkupParser.test.ts +++ b/tests/unit/basicMarkupParser.test.ts @@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => { it('parses nostr identifiers', async () => { const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const output = await parseBasicmarkup(input); - expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); + expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); }); it('parses emoji shortcodes', async () => {