Browse Source

interim commit

master
Silberengel 10 months ago
parent
commit
512ea92c3a
  1. 10
      README.md
  2. 5
      src/lib/components/Navigation.svelte
  3. 78
      src/lib/components/PublicationFeed.svelte
  4. 27
      src/lib/components/WikiCard.svelte
  5. 52
      src/lib/components/util/CardActions.svelte
  6. 6
      src/lib/components/util/InlineProfile.svelte
  7. 2
      src/lib/components/util/Profile.svelte
  8. 2
      src/lib/consts.ts
  9. 2
      src/lib/utils/markup/MarkupInfo.md
  10. 2
      src/lib/utils/mime.ts
  11. 65
      src/lib/utils/nostrUtils.ts
  12. 159
      src/lib/wiki.ts
  13. 2
      src/routes/about/+page.svelte
  14. 2
      src/routes/contact/+page.svelte
  15. 367
      src/routes/events/+page.svelte
  16. 249
      src/routes/wiki/+page.svelte
  17. 4
      tests/integration/markupIntegration.test.ts
  18. 2
      tests/unit/advancedMarkupParser.test.ts
  19. 2
      tests/unit/basicMarkupParser.test.ts

10
README.md

@ -3,13 +3,13 @@
# Alexandria # Alexandria
Alexandria is a reader and writer for curated publications, including e-books. 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 ## 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 ## 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 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: 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 ## 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). 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).

5
src/lib/components/Navigation.svelte

@ -11,7 +11,6 @@
let { class: className = "" } = $props(); let { class: className = "" } = $props();
let leftMenuOpen = $state(false);
</script> </script>
<Navbar class={`Navbar navbar-leather ${className}`}> <Navbar class={`Navbar navbar-leather ${className}`}>
@ -25,7 +24,9 @@
<NavHamburger class="btn-leather" /> <NavHamburger class="btn-leather" />
</div> </div>
<NavUl class="ul-leather"> <NavUl class="ul-leather">
<NavLi href="/new/edit">Publish</NavLi> <NavLi href="/">Publications</NavLi>
<NavLi href="/wiki">Wiki</NavLi>
<NavLi href="/events">Events</NavLi>
<NavLi href="/visualize">Visualize</NavLi> <NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi> <NavLi href="/start">Getting Started</NavLi>
<NavLi href="/about">About</NavLi> <NavLi href="/about">About</NavLi>

78
src/lib/components/PublicationFeed.svelte

@ -2,6 +2,7 @@
import { indexKind } from '$lib/consts'; import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils'; import { filterValidIndexEvents } from '$lib/utils';
import { fetchEventWithFallback } from '$lib/utils/nostrUtils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte'; import ArticleHeader from './PublicationHeader.svelte';
@ -20,40 +21,57 @@
async function getEvents( async function getEvents(
before: number | undefined = undefined, before: number | undefined = undefined,
): Promise<void> { ): Promise<void> {
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], kinds: [indexKind],
limit: 16, limit: 1,
until: before, 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;
}
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) { // If we found an event, proceed with fetching the full set
return; 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])); endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id;
const allEvents = Array.from(eventMap.values());
const uniqueIds = new Set(allEvents.map(event => event.id)); if (endOfFeed) {
eventsInView = Array.from(uniqueIds) return;
.map(id => eventMap.get(id)) }
.filter(event => event != null) as NDKEvent[];
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[] => { const getSkeletonIds = (): string[] => {

27
src/lib/components/WikiCard.svelte

@ -2,14 +2,19 @@
import { Card } from "flowbite-svelte"; import { Card } from "flowbite-svelte";
import InlineProfile from "$components/util/InlineProfile.svelte"; import InlineProfile from "$components/util/InlineProfile.svelte";
let { title, pubhex, eventId, summary, urlPath, hashtags = [] } = $props<{ export let title: string;
title: string; export let pubhex: string;
pubhex: string; export let eventId: string;
eventId: string; export let summary: string;
summary: string; export let urlPath: string;
urlPath: string; export let hashtags: string[] = [];
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 });
</script> </script>
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'> <Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'>
@ -32,6 +37,12 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="prose dark:prose-invert max-w-none mt-2">
{@html expanded ? html : preview}
{#if !expanded && html.length > 250}
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => expanded = true}>Read more...</button>
{/if}
</div>
</a> </a>
</div> </div>
</div> </div>

52
src/lib/components/util/CardActions.svelte

@ -11,6 +11,7 @@
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte"; import InlineProfile from "$components/util/InlineProfile.svelte";
import { goto } from "$app/navigation";
let { event } = $props(); let { event } = $props();
@ -78,10 +79,41 @@
} }
function viewDetails() { function viewDetails() {
console.log('Details');
detailsModalOpen = true; 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 `<a href='./events?id=${naddr}' class='text-primary-600 underline' target='_blank'>${match}</a>`;
} catch {
return match;
}
});
// Escape < and > for HTML safety, but allow our <a> tags
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
json = json.replace(/&lt;a /g, '<a ').replace(/&lt;\/a&gt;/g, '</a>');
return json;
}
</script> </script>
<div class="group" role="group" onmouseenter={openPopover}> <div class="group" role="group" onmouseenter={openPopover}>
@ -127,22 +159,11 @@
{/if} {/if}
</button> </button>
</li> </li>
<li>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</button>
</li>
</ul> </ul>
</div> </div>
</div> </div>
</Popover> </Popover>
{/if} {/if}
<!-- Event JSON -->
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='lg'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1" style="max-height: 70vh;">
<pre><code>{JSON.stringify(event.rawEvent(), null, 2)}</code></pre>
</div>
</Modal>
<!-- Event details --> <!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'> <Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<div class="flex flex-row space-x-4"> <div class="flex flex-row space-x-4">
@ -190,7 +211,12 @@
{#if identifier !== null} {#if identifier !== null}
<h5 class="text-sm">{identifier}</h5> <h5 class="text-sm">{identifier}</h5>
{/if} {/if}
<a
href="/events?id={neventEncode(event, standardRelays)}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
>
View Event Details
</a>
</div> </div>
</Modal> </Modal>

6
src/lib/components/util/InlineProfile.svelte

@ -5,7 +5,7 @@
let { pubkey, title = null } = $props(); let { pubkey, title = null } = $props();
const externalProfileDestination = 'https://njump.me/' const externalProfileDestination = './events?id='
let loading = $state(true); let loading = $state(true);
let anon = $state(false); let anon = $state(false);
let npub = $state(''); let npub = $state('');
@ -45,9 +45,9 @@
{#if loading} {#if loading}
{title ?? '…'} {title ?? '…'}
{:else if anon } {:else if anon }
<a class='underline' href='{externalProfileDestination}{npub}' title={title ?? npub} target='_blank'>{shortenNpub(npub)}</a> <a class='underline' href={'/events?id=' + npub} title={title ?? npub}>{shortenNpub(npub)}</a>
{:else if npub } {:else if npub }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'> <a href={'/events?id=' + npub} title={title ?? username}>
<Avatar rounded <Avatar rounded
class='h-6 w-6 mx-1 cursor-pointer inline' class='h-6 w-6 mx-1 cursor-pointer inline'
src={pfp} src={pfp}

2
src/lib/components/util/Profile.svelte

@ -5,7 +5,7 @@ import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flow
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = 'https://njump.me/' const externalProfileDestination = './events?id='
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();

2
src/lib/consts.ts

@ -2,7 +2,7 @@ export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ]; export const zettelKinds = [ 30041, 30818 ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' , 'wss://relay.damus.io', 'wss://relay.nostr.band'];
export enum FeedType { export enum FeedType {
StandardRelays = 'standard', StandardRelays = 'standard',

2
src/lib/utils/markup/MarkupInfo.md

@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **Tables:** Pipe-delimited tables with or without headers - **Tables:** Pipe-delimited tables with or without headers
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers - **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended - **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.) - **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](./wiki?d=nip-54)
## Publications and Wikis ## Publications and Wikis

2
src/lib/utils/mime.ts

@ -6,7 +6,7 @@
* - Addressable: 30000-39999 (latest per d-tag stored) * - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays) * - Regular: all other kinds (stored by relays)
*/ */
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
// Check special ranges first // Check special ranges first
if (kind >= 30000 && kind < 40000) { if (kind >= 30000 && kind < 40000) {
return 'addressable'; return 'addressable';

65
src/lib/utils/nostrUtils.ts

@ -2,6 +2,9 @@ import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache'; 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 // Regular expressions for Nostr identifiers - match the entire identifier including any prefix
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
@ -98,7 +101,7 @@ function createProfileLink(identifier: string, displayText: string | undefined):
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`;
} }
/** /**
@ -110,7 +113,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId); const escapedText = escapeHtml(shortId);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
} }
/** /**
@ -180,4 +183,62 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
console.error('Error getting npub from nip05:', error); console.error('Error getting npub from nip05:', error);
return null; 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<NDKKind>,
timeoutMs: number = 3000
): Promise<NDKEvent | null> {
const allRelays = Array.from(new Set([...standardRelays, ...bootstrapRelays]));
const relaySets = [
NDKRelaySet.fromRelayUrls(standardRelays, ndk),
NDKRelaySet.fromRelayUrls(allRelays, ndk)
];
async function withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, 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;
}
} }

159
src/lib/wiki.ts

@ -1,17 +1,24 @@
import Asciidoctor from 'asciidoctor';
import { parseBasicmarkup } from './utils/markup/basicMarkupParser'; import { parseBasicmarkup } from './utils/markup/basicMarkupParser';
import { getUserMetadata } from './utils/nostrUtils'; import { getUserMetadata, fetchEventWithFallback } from './utils/nostrUtils';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NDKEvent } from '@nostr-dev-kit/ndk'; 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<NDKEvent | null> { /**
* Fetch a single wiki event by id (hex or bech32).
*/
export async function fetchWikiEventById(id: string): Promise<NDKEvent | null> {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) return null; if (!ndk) {
console.warn('NDK instance not found in fetchWikiEventById');
return null;
}
let eventId = id; let eventId = id;
// If bech32, decode to hex
if (id.startsWith('nevent') || id.startsWith('note') || id.startsWith('naddr')) { if (id.startsWith('nevent') || id.startsWith('note') || id.startsWith('naddr')) {
try { try {
const decoded = nip19.decode(id); const decoded = nip19.decode(id);
@ -20,75 +27,139 @@ async function fetchWikiEventById(id: string): Promise<NDKEvent | null> {
} else if (decoded.type === 'note') { } else if (decoded.type === 'note') {
eventId = decoded.data; eventId = decoded.data;
} }
} catch { } catch (e) {
console.error('Failed to decode id in fetchWikiEventById:', e);
return null; return null;
} }
} }
// Fetch the event by id (hex) const event = await fetchEventWithFallback(ndk, eventId);
const event = await ndk.fetchEvent({ ids: [eventId] }); if (event && event.kind === wikiKind) {
// Only return if it's a wiki event (kind 30818) console.log('Fetched wiki event:', event);
if (event && event.kind === 30818) {
return event; return event;
} }
console.warn('No wiki event found for id:', eventId);
return null; return null;
} }
async function fetchWikiEventsByDTag(dtag: string): Promise<NDKEvent[]> { /**
* Fetch all wiki events by d-tag.
*/
export async function fetchWikiEventsByDTag(dtag: string): Promise<NDKEvent[]> {
const ndk = get(ndkInstance); 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 event = await fetchEventWithFallback(ndk, {
const events = await ndk.fetchEvents({ kinds: [wikiKind],
kinds: [30818],
'#d': [dtag] '#d': [dtag]
}); });
if (!event) {
console.warn(`No wiki events found for dtag: ${dtag}`);
return [];
}
// Convert Set to Array and return // For d-tag queries, we want to get all matching events, not just the first one
return Array.from(events); 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<string> { * Get a display name for a pubkey.
*/
export async function getProfileName(pubkey: string): Promise<string> {
if (!pubkey) return 'unknown'; if (!pubkey) return 'unknown';
const metadata = await getUserMetadata(pubkey); const metadata = await getUserMetadata(pubkey);
return metadata.displayName || metadata.name || pubkey.slice(0, 10); return metadata.displayName || metadata.name || pubkey.slice(0, 10);
} }
export async function getWikiPageById(id: string) { /**
const event = await fetchWikiEventById(id); * Fetch and parse a wiki page by event id or nevent.
if (!event) return null; */
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 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 title = titleTag ? titleTag[1] : 'Untitled';
const asciidoctor = Asciidoctor(); const summaryTag = event.tags.find((tag: string[]) => tag[0] === 'summary');
const asciidocHtml = asciidoctor.convert(event.content).toString(); const summary = summaryTag ? summaryTag[1] : '';
// Optionally log for debugging: const hashtags = event.tags.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || [];
// console.log('AsciiDoc HTML:', asciidocHtml);
const html = await parseBasicmarkup(asciidocHtml); let asciidoc = event.content;
return { title, author, pubhex, html }; 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) { export async function searchWikiPagesByDTag(dtag: string) {
const events = await fetchWikiEventsByDTag(dtag); const events = await fetchWikiEventsByDTag(dtag);
// Return array of { title, pubhex, eventId, summary, nip05 } return Promise.all(events.map(async (event: NDKEvent) => {
return Promise.all(events.map(async (event: any) => {
const pubhex = event.pubkey || ''; const pubhex = event.pubkey || '';
// Get title from 't' tag
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 't'); const titleTag = event.tags?.find((tag: string[]) => tag[0] === 't');
const title = titleTag ? titleTag[1] : 'Untitled'; const title = titleTag ? titleTag[1] : 'Untitled';
// Get summary from 'summary' tag
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary');
const summary = summaryTag ? summaryTag[1] : ''; const summary = summaryTag ? summaryTag[1] : '';
// Get user metadata including NIP-05
const metadata = await getUserMetadata(pubhex); const metadata = await getUserMetadata(pubhex);
const nip05 = metadata.nip05 || ''; const nip05 = metadata.nip05 || '';
// Construct human-readable URL
const urlPath = nip05 ? `${dtag}/${nip05}` : `${dtag}*${pubhex}`; const urlPath = nip05 ? `${dtag}/${nip05}` : `${dtag}*${pubhex}`;
return { return {
title, title,
pubhex, pubhex,
@ -97,4 +168,14 @@ export async function searchWikiPagesByDTag(dtag: string) {
urlPath urlPath
}; };
})); }));
}
/**
* Parse wiki content using Pharos and basic markup parser.
*/
export async function parseWikiContent(content: string, ndk: NDK): Promise<string> {
const pharos = new Pharos(ndk);
pharos.parse(content);
const pharosHtml = pharos.getHtml();
return await parseBasicmarkup(pharosHtml);
} }

2
src/routes/about/+page.svelte

@ -45,7 +45,7 @@
<P> <P>
We are easiest to contact over our Nostr address <A We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" href="./events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank">GitCitadel</A target="_blank">GitCitadel</A
>. Or, you can visit us on our <A >. Or, you can visit us on our <A

2
src/routes/contact/+page.svelte

@ -280,7 +280,7 @@
</P> </P>
<P class="mb-3"> <P class="mb-3">
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A> You can contact us on Nostr <A href="./events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
</P> </P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading> <Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>

367
src/routes/events/+page.svelte

@ -0,0 +1,367 @@
<script lang="ts">
import { Heading, P, Button } from "flowbite-svelte";
import { Input } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import { onMount } from "svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { getMimeTags, getEventType } from "$lib/utils/mime";
import { page } from "$app/stores";
import { nip19 } from 'nostr-tools';
import InlineProfile from '$lib/components/util/InlineProfile.svelte';
let searchQuery = "";
let event: NDKEvent | null = null;
let loading = false;
let error: string | null = null;
let showFullContent = false;
let contentPreview = '';
let parsedContent = '';
async function searchEvent() {
if (!searchQuery.trim()) return;
loading = true;
error = null;
event = null;
console.log('[Events] searchEvent called with query:', searchQuery);
try {
let filterOrId: any = searchQuery.trim();
// Try to decode bech32 (nevent, naddr, note, npub, nprofile)
if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(searchQuery.trim())) {
try {
const decoded = nip19.decode(searchQuery.trim());
console.log('[Events] Decoded NIP-19:', decoded);
if (decoded.type === 'nevent') {
filterOrId = decoded.data.id;
} else if (decoded.type === 'note') {
filterOrId = decoded.data;
} else if (decoded.type === 'naddr') {
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier],
};
} else if (decoded.type === 'nprofile') {
// Fetch kind 0 (profile) event for pubkey
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
} else if (decoded.type === 'npub') {
// Fetch kind 0 (profile) event for pubkey
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
}
console.log('[Events] Using filterOrId:', filterOrId);
} catch (e) {
console.error('[Events] Invalid Nostr identifier:', searchQuery, e);
error = 'Invalid Nostr identifier.';
loading = false;
return;
}
}
// Use our new utility function to fetch the event
console.log('[Events] Fetching event with filterOrId:', filterOrId);
event = await fetchEventWithFallback($ndkInstance, filterOrId);
if (!event) {
console.warn('[Events] Event not found for filterOrId:', filterOrId);
error = 'Event not found';
} else {
console.log('[Events] Event found:', event);
}
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', searchQuery);
error = 'Error fetching event. Please check the ID and try again.';
} finally {
loading = false;
}
}
function getEventLink(event: NDKEvent): string {
const eventType = getEventType(event.kind || 0);
if (eventType === 'addressable') {
const dTag = event.getMatchingTags('d')[0]?.[1];
if (dTag) {
return `/publication?id=${event.id}`;
}
}
if (event.kind === 30818) {
return `/wiki?id=${event.id}`;
}
const nevent = neventEncode(event, standardRelays);
return `https://njump.me/${nevent}`;
}
function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`;
}
function getEventTitle(event: NDKEvent): string {
return event.getMatchingTags('title')[0]?.[1] || 'Untitled';
}
function getEventSummary(event: NDKEvent): string {
return event.getMatchingTags('summary')[0]?.[1] || '';
}
function getEventAuthor(event: NDKEvent): string {
return event.pubkey;
}
function getEventHashtags(event: NDKEvent): string[] {
return event.tags.filter(tag => tag[0] === 't').map(tag => tag[1]);
}
/**
* Returns HTML for pretty-printed JSON, with NIP-33 addresses and event IDs as links
*/
function jsonWithLinks(obj: any): string {
const NIP33_REGEX = /\b(\d{5}:[a-f0-9]{64}:[a-zA-Z0-9._-]+)\b/g;
const EVENT_ID_REGEX = /\b([0-9a-f]{64})\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 `<a href='./events?id=${naddr}' class='text-primary-600 underline' target='_blank'>${match}</a>`;
} catch {
return match;
}
});
// Replace event IDs with links
json = json.replace(EVENT_ID_REGEX, (match) => {
try {
const nevent = neventEncode({ id: match, kind: 1 } as NDKEvent, standardRelays);
return `<a href='./events?id=${nevent}' class='text-primary-600 underline' target='_blank'>${match}</a>`;
} catch {
return match;
}
});
// Escape < and > for HTML safety, but allow our <a> tags
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
json = json.replace(/&lt;a /g, '<a ').replace(/&lt;\/a&gt;/g, '</a>');
return json;
}
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
// Use type assertion as any to satisfy NDKEvent signature for naddrEncode
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [["d", d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} else if (tag[0] === 'e' && tag.length > 1) {
// Use type assertion as any to satisfy NDKEvent signature for neventEncode
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
}
}
onMount(async () => {
const id = $page.url.searchParams.get('id');
if (id) {
searchQuery = id;
await searchEvent();
}
});
$: if (event && event.kind !== 0) {
// Only parse for non-profile events
parseBasicmarkup(event.content).then(html => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
}
$: profile = event && event.kind === 0
? (() => { try { return JSON.parse(event.content); } catch { return null; } })()
: null;
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
</div>
<P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, or hexID).
</P>
<div class="flex gap-2">
<Input
bind:value={searchQuery}
placeholder="Enter event ID, nevent, or naddr..."
class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent()}
/>
<Button on:click={searchEvent} disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
{#if error}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{error}
{#if searchQuery.trim()}
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
>njump</a>.
</div>
{/if}
</div>
{/if}
{#if event}
<div class="flex flex-col space-y-6">
<!-- Event Identifier (plain text, not a link) -->
<div class="text-sm font-mono text-gray-600 dark:text-gray-400 break-all">
{neventEncode(event, standardRelays)}
</div>
<!-- Event Details -->
<div class="flex flex-col space-y-4">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2>
<div class="flex items-center space-x-2">
<span class="text-gray-600 dark:text-gray-400">Author:</span>
<InlineProfile pubkey={event.pubkey} />
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-600 dark:text-gray-400">Kind:</span>
<span class="font-mono">{event.kind}</span>
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span>
</div>
{#if getEventSummary(event)}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Summary:</span>
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p>
</div>
{/if}
{#if getEventHashtags(event).length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Tags:</span>
<div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag}
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span>
{/each}
</div>
</div>
{/if}
<!-- Content -->
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Content:</span>
{#if event.kind === 0}
{#if profile}
<div class="flex flex-col gap-2 mt-2">
{#if profile.name}
<div><span class="font-semibold">Name:</span> {profile.name}</div>
{/if}
{#if profile.display_name}
<div><span class="font-semibold">Display Name:</span> {profile.display_name}</div>
{/if}
{#if profile.about}
<div><span class="font-semibold">About:</span> {profile.about}</div>
{/if}
{#if profile.picture}
<div class="flex items-center gap-2">
<span class="font-semibold">Picture:</span>
<img src={profile.picture} alt="Profile" class="w-16 h-16 rounded-full border" />
</div>
{/if}
{#if profile.banner}
<div class="flex items-center gap-2">
<span class="font-semibold">Banner:</span>
<img src={profile.banner} alt="Banner" class="w-full max-w-xs rounded border" />
</div>
{/if}
{#if profile.website}
<div>
<span class="font-semibold">Website:</span>
<a href={profile.website} target="_blank" class="underline text-primary-700">{profile.website}</a>
</div>
{/if}
{#if profile.lud16}
<div>
<span class="font-semibold">Lightning Address:</span> {profile.lud16}
</div>
{/if}
{#if profile.nip05}
<div>
<span class="font-semibold">NIP-05:</span> {profile.nip05}
</div>
{/if}
<!-- Add more fields as needed -->
</div>
{:else}
<pre class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-2 mt-2">{event.content}</pre>
{/if}
{:else}
<div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250}
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button>
{/if}
</div>
{/if}
</div>
<!-- Tags Array: Only a-tags and e-tags as hyperlinks -->
{#if event.tags && event.tags.length}
<div class="flex flex-col space-y-1">
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span>
<div class="flex flex-wrap gap-2">
{#each event.tags as tag}
{@html renderTag(tag)}
{/each}
</div>
</div>
{/if}
<!-- Raw Event JSON -->
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4">
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2">
Show Raw Event JSON
</summary>
<pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono"
style="line-height: 1.7; font-size: 1rem;"
>
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>
</details>
</div>
</div>
{/if}
</main>
</div>

249
src/routes/wiki/+page.svelte

@ -4,9 +4,12 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { nip19 } from 'nostr-tools';
import { getWikiPageById } from '$lib/wiki';
import { page } from '$app/stores'; 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 // @ts-ignore Svelte linter false positive: hashtags is used in the template
let { } = $props<{ let { } = $props<{
@ -18,21 +21,26 @@
hashtags?: string[]; hashtags?: string[];
}>(); }>();
type WikiCardResult = { type WikiPage = {
title: string; title: string;
pubhex: string; pubhex: string;
eventId: string; eventId: string;
summary: string; summary: string;
urlPath: string;
hashtags: string[]; hashtags: string[];
html: string;
}; };
let search = $state(''); let searchInput = $state('');
let results: WikiCardResult[] = $state([]); let results: WikiPage[] = $state([]);
let loading = $state(false); 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 wikiContent: { title: string; author: string; pubhex: string; html: string } | null = $state(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let expandedContent = $state(false);
let contentPreview = $derived(() => {
if (!wikiPage) return '';
return wikiPage.html.slice(0, 250);
});
function normalize(str: string) { function normalize(str: string) {
return str.toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim(); return str.toLowerCase().replace(/[-_]/g, ' ').replace(/\s+/g, ' ').trim();
@ -45,42 +53,48 @@
return; return;
} }
loading = true; loading = true;
const ndk = $ndkInstance; error = null;
if (!ndk) { try {
results = []; const ndk = $ndkInstance;
loading = false; if (!ndk) {
return; results = [];
} error = 'NDK instance not available';
const events = await ndk.fetchEvents({ kinds: [30818] }); loading = false;
const normQuery = normalize(query); return;
}
// 1. Filter by title const events = await ndk.fetchEvents({ kinds: [wikiKind] });
let filtered = Array.from(events).filter((event: any) => { const normQuery = normalize(query);
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 // Filter by title or hashtags
if (filtered.length === 0) { let filtered = Array.from(events).filter((event: NDKEvent) => {
filtered = Array.from(events).filter((event: any) => { const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title');
// Find all tags that are hashtags (tag[0] === '#') const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled';
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === '#').map((tag: string[]) => tag[1]) || []; const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || [];
return hashtags.some((hashtag: string) => normalize(hashtag).includes(normQuery));
return normalize(title).includes(normQuery) ||
hashtags.some((hashtag: string) => normalize(hashtag).includes(normQuery));
}); });
}
results = await Promise.all(filtered.map(async (event: any) => { const pages = await Promise.all(filtered.map(async (event: NDKEvent) => {
const pubhex = event.pubkey || ''; const pageData = await getWikiPageById(event.id, ndk);
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); if (pageData) {
const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; // Process Nostr identifiers in the HTML content
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); pageData.html = await processNostrIdentifiers(pageData.html);
const summary = summaryTag ? summaryTag[1] : ''; }
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || []; if (event && typeof event.getMatchingTags !== 'function') {
const nevent = nip19.neventEncode({ id: event.id, relays: [] }); console.error('Fetched event is not an NDKEvent:', event);
return { title, pubhex, eventId: event.id, summary, urlPath: nevent, hashtags }; }
})); return pageData as WikiPage | null;
loading = false; }));
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) { async function fetchWikiPageById(id: string) {
@ -91,69 +105,65 @@
if (!ndk) { if (!ndk) {
wikiPage = null; wikiPage = null;
wikiContent = null; wikiContent = null;
console.error('fetchWikiPageById: NDK instance not available');
return; return;
} }
let eventId = id; if (!id) {
if (id.startsWith('nevent')) { console.error('fetchWikiPageById: id is undefined');
const decoded = nip19.decode(id); return;
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] }); console.log('fetchWikiPageById: fetching wiki page for id', id);
if (event) { const pageData = await getWikiPageById(id, ndk);
const pubhex = event.pubkey || ''; if (pageData) {
const titleTag = event.tags?.find((tag: string[]) => tag[0] === 'title'); // Process Nostr identifiers in the HTML content
const title = titleTag && titleTag[1]?.trim() ? titleTag[1] : 'Untitled'; const processedHtml = await processNostrIdentifiers(pageData.html);
const summaryTag = event.tags?.find((tag: string[]) => tag[0] === 'summary'); wikiPage = {
const summary = summaryTag ? summaryTag[1] : ''; title: pageData.title,
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || []; pubhex: pageData.pubhex,
wikiPage = { title, pubhex, eventId: event.id, summary, urlPath: id, hashtags }; eventId: pageData.eventId,
summary: pageData.summary,
// Fetch the full wiki content hashtags: pageData.hashtags,
const content = await getWikiPageById(id); html: processedHtml,
if (content) { };
// const html = await parseBasicmarkup(asciidocHtml); wikiContent = {
const html = content.html; title: pageData.title,
console.log('Final HTML:', html); author: await getProfileName(pageData.pubhex),
wikiContent = { pubhex: pageData.pubhex,
title: content.title, html: processedHtml
author: content.author, };
pubhex: content.pubhex, if (!wikiPage.html) {
html: html console.error('fetchWikiPageById: wikiPage.html is empty for id', id, wikiPage);
};
} else {
error = 'Failed to load wiki content';
} }
console.log('wikiPage.html:', wikiPage?.html);
} else { } else {
wikiPage = null; wikiPage = null;
wikiContent = null; wikiContent = null;
error = 'Wiki page not found'; error = 'Wiki page not found';
console.error('fetchWikiPageById: Wiki page not found for id', id);
} }
} catch (e) { } catch (e) {
console.error('Error fetching wiki page:', e);
error = 'Error loading wiki page'; error = 'Error loading wiki page';
wikiPage = null; wikiPage = null;
wikiContent = null; wikiContent = null;
console.error('fetchWikiPageById: Exception:', e);
} finally { } finally {
loading = false; loading = false;
} }
} }
// Debounced effect for search // Clear wikiPage if searching
$effect(() => { $effect(() => {
if (search && wikiPage) { if (searchInput && wikiPage) {
wikiPage = null; wikiPage = null;
} }
}); });
// Watch for ?id= in the URL and load the wiki page if present
$effect(() => { $effect(() => {
const id = $page.url.searchParams.get('id'); const id = $page.url.searchParams.get('id');
if (id) { if (id) {
fetchWikiPageById(id); fetchWikiPageById(id);
search = ''; searchInput = '';
results = []; results = [];
} }
}); });
@ -162,6 +172,20 @@
goto(`/wiki?id=${encodeURIComponent(urlPath)}`); 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(() => { onMount(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const d = params.get('d'); const d = params.get('d');
@ -169,14 +193,14 @@
if (id) { if (id) {
wikiPage = null; wikiPage = null;
fetchWikiPageById(id); fetchWikiPageById(id);
search = ''; searchInput = '';
results = []; results = [];
} else if (d) { } else if (d) {
search = d; searchInput = d;
wikiPage = null; wikiPage = null;
fetchResults(search); fetchResults(searchInput);
} else { } else {
search = ''; searchInput = '';
results = []; results = [];
wikiPage = null; wikiPage = null;
} }
@ -188,13 +212,13 @@
<input <input
type="text" type="text"
placeholder="Search for a wiki topic..." placeholder="Search for a wiki topic..."
bind:value={search} bind:value={searchInput}
oninput={() => { oninput={() => {
if (wikiPage) { if (wikiPage) {
wikiPage = null; wikiPage = null;
wikiContent = null; wikiContent = null;
} }
fetchResults(search); fetchResults(searchInput);
}} }}
autocomplete="off" 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" 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 @@
</div> </div>
{:else if wikiPage && wikiContent} {:else if wikiPage && wikiContent}
<div class="flex flex-col items-center mt-8 max-w-4xl w-full px-4"> <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="text-sm font-mono text-gray-600 dark:text-gray-400 mb-2 break-all whitespace-pre-wrap">{getNevent(wikiPage.eventId)}</div>
<h1 class="text-3xl font-bold mb-2">{wikiPage.title}</h1>
<div class="mb-2"> <div class="mb-2">
by <InlineProfile pubkey={wikiContent.pubhex} /> by <button
class="text-primary-600 hover:underline"
onclick={() => wikiPage && handleProfileClick(wikiPage.pubhex)}
>
<InlineProfile pubkey={wikiPage.pubhex} />
</button>
</div> </div>
{#if wikiPage.hashtags.length} {#if wikiPage.hashtags.length}
<div class="flex flex-wrap gap-2 mb-6"> <div class="flex flex-wrap gap-2 mb-6">
@ -227,10 +257,23 @@
<div class="mb-6 text-lg text-gray-700 max-w-2xl text-center">{wikiPage.summary}</div> <div class="mb-6 text-lg text-gray-700 max-w-2xl text-center">{wikiPage.summary}</div>
{/if} {/if}
<div class="w-full prose prose-lg dark:prose-invert max-w-none"> <div class="w-full prose prose-lg dark:prose-invert max-w-none">
{@html wikiContent.html} {#if wikiPage.html && wikiPage.html.trim().length > 0}
{#if event && typeof event.getMatchingTags === 'function'}
{@html wikiPage.html}
{:else if event}
<div class="text-red-600">Fetched event is not a valid NDKEvent. See console for details.</div>
{/if}
{:else}
<div class="text-red-600">
No content found for this wiki page.
<pre class="text-xs mt-2 bg-gray-100 dark:bg-gray-800 p-2 rounded">
{JSON.stringify(wikiPage, null, 2)}
</pre>
</div>
{/if}
</div> </div>
</div> </div>
{:else if !search} {:else if !searchInput}
<div class="max-w-xl mx-auto mt-12 text-center text-lg space-y-4"> <div class="max-w-xl mx-auto mt-12 text-center text-lg space-y-4">
<p> <p>
<strong>Welcome to the Alexandria Wiki!</strong> <strong>Welcome to the Alexandria Wiki!</strong>
@ -244,31 +287,17 @@
{:else if results.length === 0} {:else if results.length === 0}
<p class="text-center mt-8">No entries found for this topic.</p> <p class="text-center mt-8">No entries found for this topic.</p>
{:else} {:else}
<div <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8 max-w-6xl w-full px-4">
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} {#each results as result}
<a <WikiCard
href="/wiki?id={result.urlPath}" title={result.title}
onclick={(e) => { e.preventDefault(); handleCardClick(result.urlPath); }} pubhex={result.pubhex}
class="mx-auto w-full max-w-xl block text-left focus:outline-none" eventId={result.eventId}
tabindex="0" summary={result.summary}
aria-label={`Open wiki page: ${result.title}`} hashtags={result.hashtags}
style="cursor:pointer;" urlPath={result.eventId}
> />
<WikiCard
title={result.title}
pubhex={result.pubhex}
eventId={result.eventId}
summary={result.summary}
urlPath={result.urlPath}
hashtags={result.hashtags}
/>
</a>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
<div>{@html '<h1>Hello</h1><p>This is a test.</p>'}</div>

4
tests/integration/markupIntegration.test.ts

@ -34,7 +34,7 @@ describe('Markup Integration Test', () => {
// Hashtags // Hashtags
expect(output).toContain('text-primary-600'); expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links) // Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks // Wikilinks
expect(output).toContain('wikilink'); expect(output).toContain('wikilink');
// YouTube iframe // YouTube iframe
@ -77,7 +77,7 @@ describe('Markup Integration Test', () => {
// Hashtags // Hashtags
expect(output).toContain('text-primary-600'); expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links) // Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks // Wikilinks
expect(output).toContain('wikilink'); expect(output).toContain('wikilink');
// YouTube iframe // YouTube iframe

2
tests/unit/advancedMarkupParser.test.ts

@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => {
it('parses nostr identifiers', async () => { it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseAdvancedmarkup(input); const output = await parseAdvancedmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
}); });
it('parses emoji shortcodes', async () => { it('parses emoji shortcodes', async () => {

2
tests/unit/basicMarkupParser.test.ts

@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => {
it('parses nostr identifiers', async () => { it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseBasicmarkup(input); const output = await parseBasicmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
}); });
it('parses emoji shortcodes', async () => { it('parses emoji shortcodes', async () => {

Loading…
Cancel
Save