Browse Source

interim commit

master
Silberengel 10 months ago
parent
commit
512ea92c3a
  1. 10
      README.md
  2. 5
      src/lib/components/Navigation.svelte
  3. 18
      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. 157
      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. 205
      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 @@ @@ -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): @@ -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 @@ -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).
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 @@ @@ -11,7 +11,6 @@
let { class: className = "" } = $props();
let leftMenuOpen = $state(false);
</script>
<Navbar class={`Navbar navbar-leather ${className}`}>
@ -25,7 +24,9 @@ @@ -25,7 +24,9 @@
<NavHamburger class="btn-leather" />
</div>
<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="/start">Getting Started</NavLi>
<NavLi href="/about">About</NavLi>

18
src/lib/components/PublicationFeed.svelte

@ -2,6 +2,7 @@ @@ -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,6 +21,20 @@ @@ -20,6 +21,20 @@
async function getEvents(
before: number | undefined = undefined,
): Promise<void> {
try {
// First try to fetch a single event to verify we can connect to the relays
const testEvent = await fetchEventWithFallback($ndkInstance, {
kinds: [indexKind],
limit: 1,
until: before
});
if (!testEvent) {
console.warn('No events found in initial fetch');
return;
}
// If we found an event, proceed with fetching the full set
let eventSet = await $ndkInstance.fetchEvents(
{
kinds: [indexKind],
@ -54,6 +69,9 @@ @@ -54,6 +69,9 @@
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[] => {

27
src/lib/components/WikiCard.svelte

@ -2,14 +2,19 @@ @@ -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 });
</script>
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'>
@ -32,6 +37,12 @@ @@ -32,6 +37,12 @@
</div>
{/if}
</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>
</div>
</div>

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

@ -11,6 +11,7 @@ @@ -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 @@ @@ -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 `<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>
<div class="group" role="group" onmouseenter={openPopover}>
@ -127,22 +159,11 @@ @@ -127,22 +159,11 @@
{/if}
</button>
</li>
<li>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</button>
</li>
</ul>
</div>
</div>
</Popover>
{/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 -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<div class="flex flex-row space-x-4">
@ -190,7 +211,12 @@ @@ -190,7 +211,12 @@
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{/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>
</Modal>

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

@ -5,7 +5,7 @@ @@ -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 @@ @@ -45,9 +45,9 @@
{#if loading}
{title ?? '…'}
{: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 }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'>
<a href={'/events?id=' + npub} title={title ?? username}>
<Avatar rounded
class='h-6 w-6 mx-1 cursor-pointer inline'
src={pfp}

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

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

2
src/lib/consts.ts

@ -2,7 +2,7 @@ export const wikiKind = 30818; @@ -2,7 +2,7 @@ export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ];
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 {
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: @@ -30,7 +30,7 @@ The **advanced markup parser** includes all features of the basic parser, plus:
- **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
- **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

2
src/lib/utils/mime.ts

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
* - Addressable: 30000-39999 (latest per d-tag stored)
* - 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
if (kind >= 30000 && kind < 40000) {
return 'addressable';

65
src/lib/utils/nostrUtils.ts

@ -2,6 +2,9 @@ import { get } from 'svelte/store'; @@ -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 = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
@ -98,7 +101,7 @@ function createProfileLink(identifier: string, displayText: string | undefined): @@ -98,7 +101,7 @@ function createProfileLink(identifier: string, displayText: string | undefined):
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
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 { @@ -110,7 +113,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId);
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>`;
}
/**
@ -181,3 +184,61 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -181,3 +184,61 @@ export async function getNpubFromNip05(nip05: string): Promise<string | 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;
}
}

157
src/lib/wiki.ts

@ -1,17 +1,24 @@ @@ -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<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);
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<NDKEvent | null> { @@ -20,75 +27,139 @@ async function fetchWikiEventById(id: string): Promise<NDKEvent | null> {
} 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<NDKEvent[]> {
/**
* Fetch all wiki events by d-tag.
*/
export async function fetchWikiEventsByDTag(dtag: string): Promise<NDKEvent[]> {
const ndk = get(ndkInstance);
if (!ndk) return [];
if (!ndk) {
console.warn('NDK instance not found in fetchWikiEventsByDTag');
return [];
}
const event = await fetchEventWithFallback(ndk, {
kinds: [wikiKind],
'#d': [dtag]
});
if (!event) {
console.warn(`No wiki events found for dtag: ${dtag}`);
return [];
}
// Query for kind 30818 events with the given d-tag
// For d-tag queries, we want to get all matching events, not just the first one
const events = await ndk.fetchEvents({
kinds: [30818],
kinds: [wikiKind],
'#d': [dtag]
});
// Convert Set to Array and return
return Array.from(events);
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';
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,
@ -98,3 +169,13 @@ export async function searchWikiPagesByDTag(dtag: string) { @@ -98,3 +169,13 @@ export async function searchWikiPagesByDTag(dtag: string) {
};
}));
}
/**
* 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 @@ @@ -45,7 +45,7 @@
<P>
We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
href="./events?id=nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank">GitCitadel</A
>. Or, you can visit us on our <A

2
src/routes/contact/+page.svelte

@ -280,7 +280,7 @@ @@ -280,7 +280,7 @@
</P>
<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>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>

367
src/routes/events/+page.svelte

@ -0,0 +1,367 @@ @@ -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>

205
src/routes/wiki/+page.svelte

@ -4,9 +4,12 @@ @@ -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 @@ @@ -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<string | null>(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,43 +53,49 @@ @@ -45,43 +53,49 @@
return;
}
loading = true;
error = null;
try {
const ndk = $ndkInstance;
if (!ndk) {
results = [];
error = 'NDK instance not available';
loading = false;
return;
}
const events = await ndk.fetchEvents({ kinds: [30818] });
const events = await ndk.fetchEvents({ kinds: [wikiKind] });
const normQuery = normalize(query);
// 1. Filter by title
let filtered = Array.from(events).filter((event: any) => {
// 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';
return normalize(title).includes(normQuery);
});
const hashtags = event.tags?.filter((tag: string[]) => tag[0] === 't').map((tag: string[]) => tag[1]) || [];
// 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));
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 };
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) {
loading = true;
@ -91,69 +105,65 @@ @@ -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);
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: content.title,
author: content.author,
pubhex: content.pubhex,
html: html
title: pageData.title,
author: await getProfileName(pageData.pubhex),
pubhex: pageData.pubhex,
html: processedHtml
};
} else {
error = 'Failed to load wiki content';
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 @@ @@ -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 @@ @@ -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 @@ @@ -188,13 +212,13 @@
<input
type="text"
placeholder="Search for a wiki topic..."
bind:value={search}
bind:value={searchInput}
oninput={() => {
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 @@ @@ -212,9 +236,15 @@
</div>
{:else if wikiPage && wikiContent}
<div class="flex flex-col items-center mt-8 max-w-4xl w-full px-4">
<h1 class="text-3xl font-bold mb-2">{wikiContent.title}</h1>
<div class="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">
by <InlineProfile pubkey={wikiContent.pubhex} />
by <button
class="text-primary-600 hover:underline"
onclick={() => wikiPage && handleProfileClick(wikiPage.pubhex)}
>
<InlineProfile pubkey={wikiPage.pubhex} />
</button>
</div>
{#if wikiPage.hashtags.length}
<div class="flex flex-wrap gap-2 mb-6">
@ -227,10 +257,23 @@ @@ -227,10 +257,23 @@
<div class="mb-6 text-lg text-gray-700 max-w-2xl text-center">{wikiPage.summary}</div>
{/if}
<div class="w-full prose prose-lg dark:prose-invert max-w-none">
{@html wikiContent.html}
{#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>
{:else if !search}
{:else if !searchInput}
<div class="max-w-xl mx-auto mt-12 text-center text-lg space-y-4">
<p>
<strong>Welcome to the Alexandria Wiki!</strong>
@ -244,31 +287,17 @@ @@ -244,31 +287,17 @@
{:else if results.length === 0}
<p class="text-center mt-8">No entries found for this topic.</p>
{:else}
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 justify-center mt-8"
style="max-width: 100vw;"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8 max-w-6xl w-full px-4">
{#each results as result}
<a
href="/wiki?id={result.urlPath}"
onclick={(e) => { e.preventDefault(); handleCardClick(result.urlPath); }}
class="mx-auto w-full max-w-xl block text-left focus:outline-none"
tabindex="0"
aria-label={`Open wiki page: ${result.title}`}
style="cursor:pointer;"
>
<WikiCard
title={result.title}
pubhex={result.pubhex}
eventId={result.eventId}
summary={result.summary}
urlPath={result.urlPath}
hashtags={result.hashtags}
urlPath={result.eventId}
/>
</a>
{/each}
</div>
{/if}
</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', () => { @@ -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', () => { @@ -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

2
tests/unit/advancedMarkupParser.test.ts

@ -69,7 +69,7 @@ describe('Advanced Markup Parser', () => { @@ -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 () => {

2
tests/unit/basicMarkupParser.test.ts

@ -70,7 +70,7 @@ describe('Basic Markup Parser', () => { @@ -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 () => {

Loading…
Cancel
Save