diff --git a/deno.lock b/deno.lock index e3b827f..f113237 100644 --- a/deno.lock +++ b/deno.lock @@ -2862,6 +2862,22 @@ } }, "workspace": { + "dependencies": [ + "npm:@nostr-dev-kit/ndk-cache-dexie@2.5", + "npm:@nostr-dev-kit/ndk@2.11", + "npm:@popperjs/core@2.11", + "npm:@tailwindcss/forms@0.5", + "npm:@tailwindcss/typography@0.5", + "npm:asciidoctor@3.0", + "npm:d3@7.9", + "npm:flowbite-svelte-icons@2.0", + "npm:flowbite-svelte@0.44", + "npm:flowbite@2.2", + "npm:he@1.2", + "npm:nostr-tools@2.10", + "npm:svelte@5.0", + "npm:tailwind-merge@2.5" + ], "packageJson": { "dependencies": [ "npm:@nostr-dev-kit/ndk-cache-dexie@2.5", diff --git a/import_map.json b/import_map.json index 7398dc6..daca291 100644 --- a/import_map.json +++ b/import_map.json @@ -13,6 +13,7 @@ "svelte": "npm:svelte@5.0.x", "flowbite": "npm:flowbite@2.2.x", "flowbite-svelte": "npm:flowbite-svelte@0.44.x", - "flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.0.x" + "flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.0.x", + "child_process": "node:child_process" } } \ No newline at end of file diff --git a/src/app.html b/src/app.html index da5c914..d025d7c 100644 --- a/src/app.html +++ b/src/app.html @@ -2,7 +2,7 @@ - + %sveltekit.head% diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte index 2d77763..1456149 100644 --- a/src/lib/components/Login.svelte +++ b/src/lib/components/Login.svelte @@ -50,7 +50,7 @@
+ Show Table of Contents {/if} {#if currentBlog !== null && $publicationColumnVisibility.inner } diff --git a/src/lib/components/PublicationHeader.svelte b/src/lib/components/PublicationHeader.svelte index f9ded78..c7f9e15 100644 --- a/src/lib/components/PublicationHeader.svelte +++ b/src/lib/components/PublicationHeader.svelte @@ -1,6 +1,6 @@ - + diff --git a/src/lib/components/util/Profile.svelte b/src/lib/components/util/Profile.svelte index fd23c9f..a517a60 100644 --- a/src/lib/components/util/Profile.svelte +++ b/src/lib/components/util/Profile.svelte @@ -46,10 +46,12 @@ function shortenNpub(long: string|undefined) { class='h-6 w-6 cursor-pointer' src={pfp} alt={username} + id="profile-avatar" /> {#key username || tag} @@ -70,25 +72,21 @@ function shortenNpub(long: string|undefined) { {#if isNav}
  • - Sign out - +
  • {:else} {/if} diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 0df0ebf..17b8b87 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -1,6 +1,6 @@ export const wikiKind = 30818; export const indexKind = 30040; -export const zettelKinds = [ 30041 ]; +export const zettelKinds = [ 30041, 30818 ]; export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ]; export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts new file mode 100644 index 0000000..6be32fb --- /dev/null +++ b/src/lib/data_structures/lazy.ts @@ -0,0 +1,16 @@ +export class Lazy { + #value?: T; + #resolver: () => Promise; + + constructor(resolver: () => Promise) { + this.#resolver = resolver; + } + + async value(): Promise { + if (!this.#value) { + this.#value = await this.#resolver(); + } + + return this.#value; + } +} \ No newline at end of file diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts new file mode 100644 index 0000000..7f6c968 --- /dev/null +++ b/src/lib/data_structures/publication_tree.ts @@ -0,0 +1,430 @@ +import type NDK from "@nostr-dev-kit/ndk"; +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import { Lazy } from "./lazy.ts"; +import { findIndexAsync as _findIndexAsync } from '../utils.ts'; + +enum PublicationTreeNodeType { + Root, + Branch, + Leaf, +} + +interface PublicationTreeNode { + type: PublicationTreeNodeType; + address: string; + parent?: PublicationTreeNode; + children?: Array>; +} + +export class PublicationTree implements AsyncIterable { + /** + * The root node of the tree. + */ + #root: PublicationTreeNode; + + /** + * A map of addresses in the tree to their corresponding nodes. + */ + #nodes: Map>; + + /** + * A map of addresses in the tree to their corresponding events. + */ + #events: Map; + + /** + * An ordered list of the addresses of the leaves of the tree. + */ + #leaves: string[] = []; + + /** + * The address of the last-visited node. Used for iteration and progressive retrieval. + */ + #bookmark?: string; + + /** + * The NDK instance used to fetch events. + */ + #ndk: NDK; + + constructor(rootEvent: NDKEvent, ndk: NDK) { + const rootAddress = rootEvent.tagAddress(); + this.#root = { + type: PublicationTreeNodeType.Root, + address: rootAddress, + children: [], + }; + + this.#nodes = new Map>(); + this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))); + + this.#events = new Map(); + this.#events.set(rootAddress, rootEvent); + + this.#ndk = ndk; + } + + /** + * Adds an event to the publication tree. + * @param event The event to be added. + * @param parentEvent The parent event of the event to be added. + * @throws An error if the parent event is not in the tree. + * @description The parent event must already be in the tree. Use + * {@link PublicationTree.getEvent} to retrieve an event already in the tree. + */ + async addEvent(event: NDKEvent, parentEvent: NDKEvent) { + const address = event.tagAddress(); + const parentAddress = parentEvent.tagAddress(); + const parentNode = await this.#nodes.get(parentAddress)?.value(); + + if (!parentNode) { + throw new Error( + `PublicationTree: Parent node with address ${parentAddress} not found.` + ); + } + + const node: PublicationTreeNode = { + type: await this.#getNodeType(event), + address, + parent: parentNode, + children: [], + }; + const lazyNode = new Lazy(() => Promise.resolve(node)); + parentNode.children!.push(lazyNode); + this.#nodes.set(address, lazyNode); + this.#events.set(address, event); + } + + /** + * Lazily adds an event to the publication tree by address if the full event is not already + * loaded into memory. + * @param address The address of the event to add. + * @param parentEvent The parent event of the event to add. + * @description The parent event must already be in the tree. Use + * {@link PublicationTree.getEvent} to retrieve an event already in the tree. + */ + async addEventByAddress(address: string, parentEvent: NDKEvent) { + const parentAddress = parentEvent.tagAddress(); + const parentNode = await this.#nodes.get(parentAddress)?.value(); + + if (!parentNode) { + throw new Error( + `PublicationTree: Parent node with address ${parentAddress} not found.` + ); + } + + await this.#addNode(address, parentNode); + } + + /** + * Retrieves an event from the publication tree. + * @param address The address of the event to retrieve. + * @returns The event, or null if the event is not found. + */ + async getEvent(address: string): Promise { + let event = this.#events.get(address) ?? null; + if (!event) { + event = await this.#depthFirstRetrieve(address); + } + + return event; + } + + /** + * Retrieves the addresses of the loaded children, if any, of the node with the given address. + * @param address The address of the parent node. + * @returns An array of addresses of any loaded child nodes. + */ + async getChildAddresses(address: string): Promise { + const node = await this.#nodes.get(address)?.value(); + if (!node) { + throw new Error(`PublicationTree: Node with address ${address} not found.`); + } + + return Promise.all( + node.children?.map(async child => + (await child.value()).address + ) ?? [] + ); + } + /** + * Retrieves the events in the hierarchy of the event with the given address. + * @param address The address of the event for which to retrieve the hierarchy. + * @returns Returns an array of events in the addressed event's hierarchy, beginning with the + * root and ending with the addressed event. + */ + async getHierarchy(address: string): Promise { + let node = await this.#nodes.get(address)?.value(); + if (!node) { + throw new Error(`PublicationTree: Node with address ${address} not found.`); + } + + const hierarchy: NDKEvent[] = [this.#events.get(address)!]; + + while (node.parent) { + hierarchy.push(this.#events.get(node.parent.address)!); + node = node.parent; + } + + return hierarchy.reverse(); + } + + /** + * Sets a start point for iteration over the leaves of the tree. + * @param address The address of the event to bookmark. + */ + setBookmark(address: string) { + this.#bookmark = address; + this.#cursor.tryMoveTo(address); + } + + // #region Iteration Cursor + + #cursor = new class { + target: PublicationTreeNode | null | undefined; + + #tree: PublicationTree; + + constructor(tree: PublicationTree) { + this.#tree = tree; + } + + async tryMoveTo(address?: string) { + if (!address) { + const startEvent = await this.#tree.#depthFirstRetrieve(); + this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); + } else { + this.target = await this.#tree.#nodes.get(address)?.value(); + } + + if (!this.target) { + return false; + } + + return true; + } + + async tryMoveToFirstChild(): Promise { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); + } + + if (this.target.type === PublicationTreeNodeType.Leaf) { + return false; + } + + this.target = (await this.target.children?.at(0)?.value())!; + return true; + } + + async tryMoveToNextSibling(): Promise { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); + } + + const parent = this.target.parent; + const siblings = parent?.children; + if (!siblings) { + return false; + } + + const currentIndex = await siblings.findIndexAsync( + async (sibling: Lazy) => (await sibling.value()).address === this.target!.address + ); + + if (currentIndex === -1) { + return false; + } + + const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; + if (!nextSibling) { + return false; + } + + this.target = nextSibling; + return true; + } + + tryMoveToParent(): boolean { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); + } + + const parent = this.target.parent; + if (!parent) { + return false; + } + + this.target = parent; + return true; + } + }(this); + + // #endregion + + // #region Async Iterator Implementation + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + async next(): Promise> { + if (!this.#cursor.target) { + await this.#cursor.tryMoveTo(this.#bookmark); + } + + do { + if (await this.#cursor.tryMoveToFirstChild()) { + continue; + } + + if (await this.#cursor.tryMoveToNextSibling()) { + continue; + } + + if (this.#cursor.tryMoveToParent()) { + continue; + } + + if (this.#cursor.target?.type === PublicationTreeNodeType.Root) { + return { done: true, value: null }; + } + } while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf); + + const event = await this.getEvent(this.#cursor.target!.address); + return { done: false, value: event! }; + } + + // #endregion + + // #region Private Methods + + /** + * Traverses the publication tree in a depth-first manner to retrieve an event, filling in + * missing nodes during the traversal. + * @param address The address of the event to retrieve. If no address is provided, the function + * will return the first leaf in the tree. + * @returns The event, or null if the event is not found. + */ + async #depthFirstRetrieve(address?: string): Promise { + if (address && this.#nodes.has(address)) { + return this.#events.get(address)!; + } + + const stack: string[] = [this.#root.address]; + let currentNode: PublicationTreeNode | null | undefined = this.#root; + let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!; + while (stack.length > 0) { + const currentAddress = stack.pop(); + currentNode = await this.#nodes.get(currentAddress!)?.value(); + if (!currentNode) { + throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`); + } + + currentEvent = this.#events.get(currentAddress!); + if (!currentEvent) { + throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`); + } + + // Stop immediately if the target of the search is found. + if (address != null && currentAddress === address) { + return currentEvent; + } + + const currentChildAddresses = currentEvent.tags + .filter(tag => tag[0] === 'a') + .map(tag => tag[1]); + + // If the current event has no children, it is a leaf. + if (currentChildAddresses.length === 0) { + // Return the first leaf if no address was provided. + if (address == null) { + return currentEvent!; + } + + continue; + } + + // Augment the tree with the children of the current event. + for (const childAddress of currentChildAddresses) { + if (this.#nodes.has(childAddress)) { + continue; + } + + await this.#addNode(childAddress, currentNode!); + } + + // Push the popped address's children onto the stack for the next iteration. + while (currentChildAddresses.length > 0) { + const nextAddress = currentChildAddresses.pop()!; + stack.push(nextAddress); + } + } + + return null; + } + + #addNode(address: string, parentNode: PublicationTreeNode) { + const lazyNode = new Lazy(() => this.#resolveNode(address, parentNode)); + parentNode.children!.push(lazyNode); + this.#nodes.set(address, lazyNode); + } + + /** + * Resolves a node address into an event, and creates new nodes for its children. + * + * This method is intended for use as a {@link Lazy} resolver. + * + * @param address The address of the node to resolve. + * @param parentNode The parent node of the node to resolve. + * @returns The resolved node. + */ + async #resolveNode( + address: string, + parentNode: PublicationTreeNode + ): Promise { + const [kind, pubkey, dTag] = address.split(':'); + const event = await this.#ndk.fetchEvent({ + kinds: [parseInt(kind)], + authors: [pubkey], + '#d': [dTag], + }); + + if (!event) { + throw new Error( + `PublicationTree: Event with address ${address} not found.` + ); + } + + this.#events.set(address, event); + + const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); + + const node: PublicationTreeNode = { + type: await this.#getNodeType(event), + address, + parent: parentNode, + children: [], + }; + + for (const address of childAddresses) { + this.addEventByAddress(address, event); + } + + return node; + } + + async #getNodeType(event: NDKEvent): Promise { + if (event.tagAddress() === this.#root.address) { + return PublicationTreeNodeType.Root; + } + + if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { + return PublicationTreeNodeType.Branch; + } + + return PublicationTreeNodeType.Leaf; + } + + // #endregion +} \ No newline at end of file diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 7d27ffa..fe88aac 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -18,7 +18,7 @@ C
    - Content events (kind 30041) - Publication sections + Content events (kinds 30041, 30818) - Publication sections
  • diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index 9ebe042..cb6f779 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,38 +1,128 @@
    -
    -
    {node.title}
    + +
    +
    - {node.type} ({node.isContainer ? "30040" : "30041"}) + {node.type} ({node.kind})
    -
    - ID: {node.id} - {#if node.naddr} -
    {node.naddr}
    - {/if} - {#if node.nevent} -
    {node.nevent}
    - {/if} +
    + Author: {getAuthorTag(node)}
    + + {#if node.isContainer && getSummaryTag(node)} +
    + Summary: {truncateContent(getSummaryTag(node) || "", 200)} +
    + {/if} + {#if node.content}
    - {node.content} + {truncateContent(node.content)}
    {/if} {#if selected} @@ -41,4 +131,4 @@
    {/if}
    -
    \ No newline at end of file +
    diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 9246df3..6a7fa61 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -8,6 +8,7 @@ import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation"; import Legend from "./Legend.svelte"; import NodeTooltip from "./NodeTooltip.svelte"; + import type { NetworkNode, NetworkLink } from "./types"; let { events = [] } = $props<{ events?: NDKEvent[] }>(); @@ -90,14 +91,14 @@ function updateGraph() { if (!svg || !events?.length || !svgGroup) return; - const { nodes, links } = generateGraph(events, currentLevels); + const { nodes, links } = generateGraph(events, Number(currentLevels)); if (!nodes.length) return; // Stop any existing simulation if (simulation) simulation.stop(); // Create new simulation - simulation = createSimulation(nodes, links, nodeRadius, linkDistance); + simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance)); const dragHandler = setupDragHandlers(simulation); // Update links @@ -303,6 +304,11 @@ updateGraph(); } }); + + function handleTooltipClose() { + tooltipVisible = false; + selectedNodeId = null; + }
    {/if}
    - - \ No newline at end of file diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 676b6af..72bb2d4 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -12,7 +12,7 @@ import type { } from 'asciidoctor'; import he from 'he'; import { writable, type Writable } from 'svelte/store'; -import { zettelKinds } from './consts'; +import { zettelKinds } from './consts.ts'; interface IndexMetadata { authors?: string[]; @@ -66,6 +66,8 @@ export default class Pharos { private asciidoctor: Asciidoctor; + private pharosExtensions: Extensions.Registry; + private ndk: NDK; private contextCounters: Map = new Map(); @@ -135,25 +137,26 @@ export default class Pharos { constructor(ndk: NDK) { this.asciidoctor = asciidoctor(); + this.pharosExtensions = this.asciidoctor.Extensions.create(); this.ndk = ndk; const pharos = this; - this.asciidoctor.Extensions.register(function () { - const registry = this; - registry.treeProcessor(function () { - const dsl = this; - dsl.process(function (document) { - const treeProcessor = this; - pharos.treeProcessor(treeProcessor, document); - }); - }) + this.pharosExtensions.treeProcessor(function () { + const dsl = this; + dsl.process(function (document) { + const treeProcessor = this; + pharos.treeProcessor(treeProcessor, document); + }); }); } parse(content: string, options?: ProcessorOptions | undefined): void { try { - this.html = this.asciidoctor.convert(content, options) as string | Document | undefined; + this.html = this.asciidoctor.convert(content, { + 'extension_registry': this.pharosExtensions, + ...options, + }) as string | Document | undefined; } catch (error) { console.error(error); throw new Error('Failed to parse AsciiDoc document.'); diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte new file mode 100644 index 0000000..26645fa --- /dev/null +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -0,0 +1,45 @@ + + +{#snippet sectionHeading(title: string, depth: number)} + {#if depth === 0} +

    + {title} +

    + {:else if depth === 1} +

    + {title} +

    + {:else if depth === 2} +

    + {title} +

    + {:else if depth === 3} +

    + {title} +

    + {:else if depth === 4} +
    + {title} +
    + {:else} +
    + {title} +
    + {/if} +{/snippet} + +{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} + {#if publicationType === 'novel'} +

    + {@html content} +

    + {:else} +

    + {@html content} +

    + {/if} +{/snippet} diff --git a/src/lib/stores.ts b/src/lib/stores.ts index 3f65fe5..a78e275 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -3,7 +3,7 @@ import { FeedType } from "./consts"; export let idList = writable([]); -export let alexandriaKinds = readable([30040, 30041]); +export let alexandriaKinds = readable([30040, 30041, 30818]); export let feedType = writable(FeedType.StandardRelays); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3a54d64..35d6e03 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -10,6 +10,20 @@ export function neventEncode(event: NDKEvent, relays: string[]) { }); } +export function naddrEncode(event: NDKEvent, relays: string[]) { + const dTag = event.getMatchingTags('d')[0]?.[1]; + if (!dTag) { + throw new Error('Event does not have a d tag'); + } + + return nip19.naddrEncode({ + identifier: dTag, + pubkey: event.pubkey, + kind: event.kind || 0, + relays, + }); +} + export function formatDate(unixtimestamp: number) { const months = [ "Jan", @@ -109,3 +123,38 @@ export function filterValidIndexEvents(events: Set): Set { console.debug(`Filtered index events: ${events.size} events remaining.`); return events; } + +/** + * Async version of Array.findIndex() that runs sequentially. + * Returns the index of the first element that satisfies the provided testing function. + * @param array The array to search + * @param predicate The async testing function + * @returns A promise that resolves to the index of the first matching element, or -1 if none found + */ +export async function findIndexAsync( + array: T[], + predicate: (element: T, index: number, array: T[]) => Promise +): Promise { + for (let i = 0; i < array.length; i++) { + if (await predicate(array[i], i, array)) { + return i; + } + } + return -1; +} + +// Extend Array prototype with findIndexAsync +declare global { + interface Array { + findIndexAsync( + predicate: (element: T, index: number, array: T[]) => Promise + ): Promise; + } +} + +Array.prototype.findIndexAsync = function( + this: T[], + predicate: (element: T, index: number, array: T[]) => Promise +): Promise { + return findIndexAsync(this, predicate); +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0045d4c..4cac70e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,15 +2,44 @@ import "../app.css"; import Navigation from "$lib/components/Navigation.svelte"; import { onMount } from "svelte"; + import { page } from "$app/stores"; // Compute viewport height. $: displayHeight = window.innerHeight; + // Get standard metadata for OpenGraph tags + let title = 'Library of Alexandria'; + let currentUrl = $page.url.href; + + // Get default image and summary for the Alexandria website + let image = '/screenshots/old_books.jpg'; + let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'; + onMount(() => { document.body.style.height = `${displayHeight}px`; }); + + + {title} + + + + + + + + + + + + + + + + +
    diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 9e4ee80..ace2103 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,56 +1,118 @@
    -
    - About -

    Alexandria is a reader and writer for curated publications (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the GitCitadel project team.

    - -

    Please submit support issues on the project repo page and follow us on GitHub and Geyserfund.

    - -

    We are easiest to contact over our Nostr address npub1s3h…75wz.

    - - Overview - -

    Alexandria opens up to the landing page, where the user can: login (top-right), select whether to only view the publications hosted on the thecitadel document relay or add in their own relays, and scroll/search the publications.

    - -

    Landing page

    -

    Relay selection

    - -

    There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).

    - -

    If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s), in the order in which they are indexed, and displays them as a single document.

    +
    +
    + About the Library of Alexandria + {#if isVersionKnown} + Version: {appVersion} + {/if} +
    + Alexandria icon + +

    + Alexandria is a reader and writer for curated publications (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the GitCitadel project team. +

    + +

    + Please submit support issues on the Alexandria repo page and follow us on GitHub and Geyserfund. +

    + +

    + We are easiest to contact over our Nostr address npub1s3h…75wz. +

    -

    Each 30041 section is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)

    - -

    ToC icon

    -

    Table of contents example

    + Overview + +

    + Alexandria opens up to the landing page, where the user can: login (top-right), select whether to only view the publications hosted on the thecitadel document relay or add in their own relays, and scroll/search the publications. +

    + +
    + Landing page + Relay selection +
    + +

    + There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon). +

    + +

    + If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document. +

    + +

    + Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.) +

    + +
    + ToC icon + Table of contents example +
    + + Typical use cases - Typical use cases + For e-books + +

    + The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications. +

    - For e-books -

    The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.

    +

    + An example of a book is Jane Eyre +

    -

    An example of a book is Jane Eyre

    +
    + Jane Eyre, by Charlotte Brontë +
    + + For scientific papers -

    Jane Eyre, by Charlotte Brontë

    +

    + Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes. +

    - For scientific papers -

    Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.

    +

    + Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler. +

    -

    Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.

    +

    + Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app. +

    -

    Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.

    +

    + An example of a research paper is Less Partnering, Less Children, or Both? +

    -

    An example of a research paper is Less Partnering, Less Children, or Both?

    +
    + Research paper +
    + + For documentation -

    Research paper

    +

    + Our own team uses Alexandria to document the app, to display our blog entries, as well as to store copies of our most interesting technical specifications. +

    - For documentation -

    Our own team uses Alexandria to document the app, to display our blog entries, as well as to store copies of our most interesting technical specifications.

    +
    + Documentation +
    + + For wiki pages -

    Documentation

    +

    + Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content. +

    -
    -
    \ No newline at end of file +

    + Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored. +

    + + +
    diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index 75aa31d..697ca4e 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -4,12 +4,52 @@ import type { PageData } from "./$types"; import { onDestroy } from "svelte"; import ArticleNav from "$components/util/ArticleNav.svelte"; + import type { NDKEvent } from "@nostr-dev-kit/ndk"; + import { pharosInstance } from "$lib/parser"; + import { page } from "$app/stores"; - let { data }: { data: PageData } = $props(); + // Extend the PageData type with the properties we need + interface ExtendedPageData extends PageData { + waitable: Promise; + publicationType: string; + indexEvent: NDKEvent; + parser: any; + } + + let { data } = $props<{ data: ExtendedPageData }>(); + + // Get publication metadata for OpenGraph tags + let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication'); + let currentUrl = $page.url.href; + + // Get image and summary from the event tags if available + // If image unavailable, use the Alexandria default pic. + let image = $derived(data.indexEvent?.getMatchingTags('image')[0]?.[1] || '/screenshots/old_books.jpg'); + let summary = $derived(data.indexEvent?.getMatchingTags('summary')[0]?.[1] || 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'); onDestroy(() => data.parser.reset()); + + + {title} + + + + + + + + + + + + + + + + + {#key data} {/key} @@ -18,6 +58,10 @@ {#await data.waitable} {:then} -
    +
    {/await} diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index 3d58b39..56b7fd1 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -1,48 +1,107 @@ import { error } from '@sveltejs/kit'; -import { NDKRelay, NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'; -import type { PageLoad } from './$types'; -import { get } from 'svelte/store'; -import { getActiveRelays, inboxRelays, ndkInstance } from '$lib/ndk'; -import { standardRelays } from '$lib/consts'; +import type { Load } from '@sveltejs/kit'; +import type { NDKEvent } from '@nostr-dev-kit/ndk'; +import { nip19 } from 'nostr-tools'; +import { getActiveRelays } from '$lib/ndk.ts'; -export const load: PageLoad = async ({ url, parent }) => { - const id = url.searchParams.get('id'); - const dTag = url.searchParams.get('d'); - - const { ndk, parser } = await parent(); +/** + * Decodes an naddr identifier and returns a filter object + */ +function decodeNaddr(id: string) { + try { + if (!id.startsWith('naddr')) return {}; + + const decoded = nip19.decode(id); + if (decoded.type !== 'naddr') return {}; + + const data = decoded.data; + return { + kinds: [data.kind], + authors: [data.pubkey], + '#d': [data.identifier] + }; + } catch (e) { + console.error('Failed to decode naddr:', e); + return null; + } +} - let eventPromise: Promise; - let indexEvent: NDKEvent | null; +/** + * Fetches an event by ID or filter + */ +async function fetchEventById(ndk: any, id: string): Promise { + const filter = decodeNaddr(id); + + // Handle the case where filter is null (decoding error) + if (filter === null) { + // If we can't decode the naddr, try using the raw ID + try { + const event = await ndk.fetchEvent(id); + if (!event) { + throw new Error(`Event not found for ID: ${id}`); + } + return event; + } catch (err) { + throw error(404, `Failed to fetch publication root event.\n${err}`); + } + } + + const hasFilter = Object.keys(filter).length > 0; + + try { + const event = await (hasFilter ? + ndk.fetchEvent(filter) : + ndk.fetchEvent(id)); + + if (!event) { + throw new Error(`Event not found for ID: ${id}`); + } + return event; + } catch (err) { + throw error(404, `Failed to fetch publication root event.\n${err}`); + } +} - if (id) { - eventPromise = ndk.fetchEvent(id) - .then((ev: NDKEvent | null) => { - return ev; - }) - .catch((err: any) => { - error(404, `Failed to fetch publication root event for ID: ${id}\n${err}`); - }); - } else if (dTag) { - eventPromise = new Promise(resolve => { - ndk - .fetchEvent({ '#d': [dTag] }, { closeOnEose: false }, getActiveRelays(ndk)) - .then((event: NDKEvent | null) => { - resolve(event); - }) - .catch((err: any) => { - error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`); - }); - }); - } else { - error(400, 'No publication root event ID or d tag provided.'); +/** + * Fetches an event by d tag + */ +async function fetchEventByDTag(ndk: any, dTag: string): Promise { + try { + const event = await ndk.fetchEvent( + { '#d': [dTag] }, + { closeOnEose: false }, + getActiveRelays(ndk) + ); + + if (!event) { + throw new Error(`Event not found for d tag: ${dTag}`); + } + return event; + } catch (err) { + throw error(404, `Failed to fetch publication root event.\n${err}`); } +} - indexEvent = await eventPromise as NDKEvent; +export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise }) => { + const id = url.searchParams.get('id'); + const dTag = url.searchParams.get('d'); + const { ndk, parser } = await parent(); + + if (!id && !dTag) { + throw error(400, 'No publication root event ID or d tag provided.'); + } + + // Fetch the event based on available parameters + const indexEvent = id + ? await fetchEventById(ndk, id) + : await fetchEventByDTag(ndk, dTag!); + const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1]; const fetchPromise = parser.fetch(indexEvent); return { waitable: fetchPromise, publicationType, + indexEvent, }; }; diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 35b837d..f1f7d33 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -49,7 +49,7 @@ // Fetch the referenced content events const contentEvents = await $ndkInstance.fetchEvents( { - kinds: [30041], + kinds: [30041, 30818], ids: Array.from(contentEventIds), }, { @@ -79,44 +79,45 @@
    -

    Publication Network

    - - - - {#if !loading && !error} - +
    +

    Publication Network

    + + + {#if !loading && !error} + + {/if} +
    + {#if !loading && !error && showSettings} - {#if showSettings} -
    -
    -

    - Visualization Settings -

    +
    +
    +

    + Visualization Settings +

    -
    - - Showing {events.length} events from {$networkFetchLimit} headers - - - -
    +
    + + Showing {events.length} events from {$networkFetchLimit} headers + + +
    - {/if} +
    {/if} + {#if loading}
    @@ -155,6 +156,6 @@
    {:else} -
    +
    {/if} -
    \ No newline at end of file +
    diff --git a/static/favicon.png b/static/favicon.png index 825b9e6..7418972 100644 Binary files a/static/favicon.png and b/static/favicon.png differ diff --git a/static/screenshots/old_books.jpg b/static/screenshots/old_books.jpg new file mode 100644 index 0000000..933a0be Binary files /dev/null and b/static/screenshots/old_books.jpg differ diff --git a/tsconfig.json b/tsconfig.json index 794b95b..ec41776 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true + "strict": true, + "allowImportingTsExtensions": true } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // diff --git a/vite.config.ts b/vite.config.ts index b9ba52c..4dc4254 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,36 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; +import { execSync } from "child_process"; + +// Function to get the latest git tag +function getAppVersionString() { + // if running in ci context, we can assume the package has been properly versioned + if (process.env.ALEXANDIRA_IS_CI_BUILD && process.env.npm_package_version && process.env.npm_package_version.trim() !== '') { + return process.env.npm_package_version; + } + + try { + // Get the latest git tag, assuming git is installed and tagged branch is available + const tag = execSync('git describe --tags --abbrev=0').toString().trim(); + return tag; + } catch (error) { + return 'development'; + } +} export default defineConfig({ plugins: [sveltekit()], + resolve: { + alias: { + $lib: './src/lib', + $components: './src/components' + } + }, test: { include: ['./tests/unit/**/*.unit-test.js'] + }, + define: { + // Expose the app version as a global variable + 'import.meta.env.APP_VERSION': JSON.stringify(getAppVersionString()) } });