Browse Source

Merge remote-tracking branch 'origin/master' into Issue#162

master
Silberengel 11 months ago
parent
commit
5c4894a8bf
  1. 16
      deno.lock
  2. 2
      src/app.css
  3. 0
      src/lib/components/Modal.svelte
  4. 30
      src/lib/components/Preview.svelte
  5. 7
      src/lib/components/PublicationHeader.svelte
  6. 33
      src/lib/components/util/CardActions.svelte
  7. 2
      src/lib/components/util/CopyToClipboard.svelte
  8. 6
      src/lib/components/util/Profile.svelte
  9. 16
      src/lib/data_structures/lazy.ts
  10. 430
      src/lib/data_structures/publication_tree.ts
  11. 30
      src/lib/parser.ts
  12. 45
      src/lib/snippets/PublicationSnippets.svelte
  13. 49
      src/lib/utils.ts
  14. 14
      src/routes/[...catchall]/+page.svelte
  15. 101
      src/routes/about/+page.svelte
  16. 124
      src/routes/publication/+page.ts
  17. 24
      src/styles/publications.css
  18. 3
      tsconfig.json
  19. 6
      vite.config.ts

16
deno.lock

@ -2862,6 +2862,22 @@ @@ -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",

2
src/app.css

@ -74,7 +74,7 @@ @@ -74,7 +74,7 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Heading */
/* Section headers */
h1.h-leather,
h2.h-leather,
h3.h-leather,

0
src/lib/Modal.svelte → src/lib/components/Modal.svelte

30
src/lib/components/Preview.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte';
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
// TODO: Fix move between parents.
@ -150,31 +151,12 @@ @@ -150,31 +151,12 @@
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{@const headingLevel = Math.min(depth + 1, 6)}
{@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'}
<svelte:element this={`h${headingLevel}`} class={className}>
{title}
</h6>
{/if}
</svelte:element>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string)}

7
src/lib/components/PublicationHeader.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import { ndkInstance } from '$lib/ndk';
import { neventEncode } from '$lib/utils';
import { naddrEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { standardRelays } from '../consts';
import { Card, Img } from "flowbite-svelte";
@ -18,9 +18,10 @@ @@ -18,9 +18,10 @@
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${neventEncode(event, relays)}`;
return `publication?id=${naddrEncode(event, relays)}`;
}
});
}
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');

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

@ -9,8 +9,7 @@ @@ -9,8 +9,7 @@
} from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts";
import { neventEncode } from "$lib/utils";
import { type AddressPointer, naddrEncode } from "nostr-tools/nip19";
import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte";
let { event } = $props();
@ -45,16 +44,9 @@ @@ -45,16 +44,9 @@
function shareNjump() {
const relays: string[] = standardRelays;
const dTag : string | undefined = event.dTag;
if (typeof dTag === 'string') {
const opts: AddressPointer = {
identifier: dTag,
pubkey: event.pubkey,
kind: 30040,
relays
};
const naddr = naddrEncode(opts);
try {
const naddr = naddrEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
@ -62,9 +54,8 @@ @@ -62,9 +54,8 @@
shareLinkCopied = false;
}, 4000);
}
else {
console.log('dTag is undefined');
catch (e) {
console.error('Failed to encode naddr:', e);
}
}
@ -114,12 +105,12 @@ @@ -114,12 +105,12 @@
<div class='flex flex-col text-nowrap'>
<ul class="space-y-2">
<li>
<button type="button" class='btn-leather' onclick={viewDetails}>
<button class='btn-leather w-full text-left' onclick={viewDetails}>
<EyeOutline class="inline mr-2" /> View details
</button>
</li>
<li>
<button type="button" class='btn-leather' onclick={shareNjump}>
<button class='btn-leather w-full text-left' onclick={shareNjump}>
{#if shareLinkCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
@ -128,7 +119,7 @@ @@ -128,7 +119,7 @@
</button>
</li>
<li>
<button type="button" class='btn-leather' onclick={copyEventId}>
<button class='btn-leather w-full text-left' onclick={copyEventId}>
{#if eventIdCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
@ -137,7 +128,7 @@ @@ -137,7 +128,7 @@
</button>
</li>
<li>
<button type="button" class='btn-leather' onclick={viewJson}>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</button>
</li>
@ -149,7 +140,7 @@ @@ -149,7 +140,7 @@
<!-- Event JSON -->
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='sm'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1">
<code>{JSON.stringify(event.rawEvent())}</code>
<pre><code>{JSON.stringify(event.rawEvent(), null, 2)}</code></pre>
</div>
</Modal>
<!-- Event details -->
@ -157,7 +148,7 @@ @@ -157,7 +148,7 @@
<div class="flex flex-row space-x-4">
{#if image}
<div class="flex col">
<img class="max-w-48" src={image} alt="Publication cover image for {title}" />
<img class="max-w-48" src={image} alt="Publication cover" />
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">

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

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
}
</script>
<button type="button" class='btn-leather text-nowrap' onclick={copyToClipboard}>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}>
{#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied!
{:else}

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

@ -82,13 +82,11 @@ function shortenNpub(long: string|undefined) { @@ -82,13 +82,11 @@ function shortenNpub(long: string|undefined) {
</li>
{:else}
<!-- li>
<a
href=""
<button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
role="button"
>
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</a>
</button>
</li -->
{/if}
</ul>

16
src/lib/data_structures/lazy.ts

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
export class Lazy<T> {
#value?: T;
#resolver: () => Promise<T>;
constructor(resolver: () => Promise<T>) {
this.#resolver = resolver;
}
async value(): Promise<T> {
if (!this.#value) {
this.#value = await this.#resolver();
}
return this.#value;
}
}

430
src/lib/data_structures/publication_tree.ts

@ -0,0 +1,430 @@ @@ -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<Lazy<PublicationTreeNode>>;
}
export class PublicationTree implements AsyncIterable<NDKEvent> {
/**
* The root node of the tree.
*/
#root: PublicationTreeNode;
/**
* A map of addresses in the tree to their corresponding nodes.
*/
#nodes: Map<string, Lazy<PublicationTreeNode>>;
/**
* A map of addresses in the tree to their corresponding events.
*/
#events: Map<string, NDKEvent>;
/**
* 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<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#events = new Map<string, NDKEvent>();
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<PublicationTreeNode>(() => 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<NDKEvent | null> {
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<string[]> {
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<NDKEvent[]> {
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<boolean> {
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<boolean> {
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<PublicationTreeNode>) => (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<NDKEvent> {
return this;
}
async next(): Promise<IteratorResult<NDKEvent>> {
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<NDKEvent | null> {
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<PublicationTreeNode>(() => 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<PublicationTreeNode> {
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<PublicationTreeNodeType> {
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
}

30
src/lib/parser.ts

@ -12,7 +12,7 @@ import type { @@ -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 { @@ -66,6 +66,8 @@ export default class Pharos {
private asciidoctor: Asciidoctor;
private pharosExtensions: Extensions.Registry;
private ndk: NDK;
private contextCounters: Map<string, number> = new Map<string, number>();
@ -130,25 +132,26 @@ export default class Pharos { @@ -130,25 +132,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 () {
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.');
@ -270,6 +273,21 @@ export default class Pharos { @@ -270,6 +273,21 @@ export default class Pharos {
return block.convert();
}
/**
* Checks if the node with the given ID is a floating title (discrete header).
* @param id The ID of the node to check.
* @returns True if the node is a floating title, false otherwise.
*/
isFloatingTitle(id: string): boolean {
const normalizedId = this.normalizeId(id);
if (!normalizedId || !this.nodes.has(normalizedId)) {
return false;
}
const context = this.eventToContextMap.get(normalizedId);
return context === 'floating_title';
}
/**
* Updates the `content` field of a Nostr event in-place.
* @param dTag The d tag of the event to update.

45
src/lib/snippets/PublicationSnippets.svelte

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<script module lang='ts'>
import { P } from 'flowbite-svelte';
export { contentParagraph, sectionHeading };
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{title}
</h6>
{/if}
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
{/snippet}

49
src/lib/utils.ts

@ -10,6 +10,20 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -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<NDKEvent>): Set<NDKEvent> { @@ -109,3 +123,38 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
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<T>(
array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
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<T> {
findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number>;
}
}
Array.prototype.findIndexAsync = function<T>(
this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
return findIndexAsync(this, predicate);
};

14
src/routes/[...catchall]/+page.svelte

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, P } from 'flowbite-svelte';
</script>
<div class="leather flex flex-col items-center justify-center min-h-screen text-center px-4">
<h1 class="h-leather mb-4">404 - Page Not Found</h1>
<P class="note-leather mb-6">The page you are looking for does not exist or has been moved.</P>
<div class="flex space-x-4">
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button>
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button>
</div>
</div>

101
src/routes/about/+page.svelte

@ -1,62 +1,105 @@ @@ -1,62 +1,105 @@
<script lang='ts'>
import { Heading } from "flowbite-svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
const gitTagVersion = import.meta.env.GIT_TAG || 'development';
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'>
<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'>About</Heading>
<Heading tag='h1' class='h-leather text-left mb-4'>About</Heading>
<span class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">Version: {gitTagVersion}</span>
</div>
<p>Alexandria is a reader and writer for <a href="https://github.com/nostr-protocol/nips/pull/1600" class='underline' target="_blank">curated publications</a> (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the <a href="https://wikistr.com/gitcitadel-project" class='underline' target="_blank">GitCitadel project team</a>.</p>
<p>Please submit support issues on the <a href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5emfw33kjarpv3jkcs83wav" class='underline' target="_blank">project repo page</a> and follow us on <a href="https://github.com/ShadowySupercode/gitcitadel" class='underline' target="_blank">GitHub</a> and <a href="https://geyser.fund/project/gitcitadel" class='underline' target="_blank">Geyserfund</a>.</p>
<P class="mb-3">
Alexandria is a reader and writer for <A href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1">curated publications</A> (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the <A href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1">GitCitadel project team</A>.
</P>
<p>We are easiest to contact over our Nostr address <a href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" class='underline' title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</a>.</p>
<P class="mb-3">
Please submit support issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page</A> and follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
</P>
<Heading tag='h2' class='h-leather mb-2'>Overview</Heading>
<P>
We are easiest to contact over our Nostr address <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A>.
</P>
<p>Alexandria opens up to the <a href="https://next-alexandria.gitcitadel.eu/" class='underline'>landing page</a>, where the user can: login (top-right), select whether to only view the publications hosted on the <a href="https://thecitadel.nostr1.com/" class='underline' target="_blank">thecitadel document relay</a> or add in their own relays, and scroll/search the publications.</p>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Overview</Heading>
<p><img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border'></p>
<p><img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border'></p>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user can: login (top-right), select whether to only view the publications hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank">thecitadel document relay</A> or add in their own relays, and scroll/search the publications.
</P>
<p>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).</p>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border rounded-lg' width="400" />
</div>
<P class="mb-3">
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).
</P>
<P class="mb-3">
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.
</P>
<P class="mb-3">
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.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border rounded-lg' width="400" />
</div>
<p>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.</p>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Typical use cases</Heading>
<p>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.)</p>
<Heading tag='h3' class='h-leather mb-3'>For e-books</Heading>
<p><img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border'></p>
<p><img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border'></p>
<P class="mb-3">
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.
</P>
<Heading tag='h2' class='h-leather mb-2'>Typical use cases</Heading>
<P class="mb-3">
An example of a book is <A href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition">Jane Eyre</A>
</P>
<Heading tag='h3' class='h-leather mb-2'>For e-books</Heading>
<p>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.</p>
<div class="flex justify-center my-4">
<Img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border rounded-lg' width="400" />
</div>
<p>An example of a book is <a href="https://next-alexandria.gitcitadel.eu/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%83-v-third-edition" class='underline'>Jane Eyre</a></p>
<Heading tag='h3' class='h-leather mb-3'>For scientific papers</Heading>
<p><img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border'></p>
<P class="mb-3">
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.
</P>
<Heading tag='h3' class='h-leather mb-2'>For scientific papers</Heading>
<p>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.</p>
<P class="mb-3">
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.
</P>
<p>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.</p>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.
</P>
<p>Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.</p>
<P class="mb-3">
An example of a research paper is <A href="/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1">Less Partnering, Less Children, or Both?</A>
</P>
<p>An example of a research paper is <a href="https://next-alexandria.gitcitadel.eu/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1" class='underline'>Less Partnering, Less Children, or Both?</a></p>
<div class="flex justify-center my-4">
<Img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border rounded-lg' width="400" />
</div>
<p><img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border'></p>
<Heading tag='h3' class='h-leather mb-3'>For documentation</Heading>
<Heading tag='h3' class='h-leather mb-2'>For documentation</Heading>
<p>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.</p>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A>, as well as to store copies of our most interesting <A href="/publication?d=gitcitadel-project-documentation-by-stella-v-1">technical specifications</A>.
</P>
<p><img src="/screenshots/Documentation.png" alt="Documentation" class='image-border'></p>
<div class="flex justify-center my-4">
<Img src="/screenshots/Documentation.png" alt="Documentation" class='image-border rounded-lg' width="400" />
</div>
</main>
</div>

124
src/routes/publication/+page.ts

@ -1,43 +1,101 @@ @@ -1,43 +1,101 @@
import { error } from '@sveltejs/kit';
import { NDKRelay, NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import 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 { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk.ts';
export const load: PageLoad = async ({ url, 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;
}
}
/**
* Fetches an event by ID or filter
*/
async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
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}`);
}
}
/**
* Fetches an event by d tag
*/
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
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}`);
}
}
export const load: PageLoad = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
const { ndk, parser } = await parent();
let eventPromise: Promise<NDKEvent | null>;
let indexEvent: NDKEvent | null;
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<NDKEvent | null>(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.');
}
indexEvent = await eventPromise as NDKEvent;
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);

24
src/styles/publications.css

@ -229,4 +229,28 @@ @@ -229,4 +229,28 @@
.audioblock .content audio {
@apply w-full;
}
/* Discrete headers */
h3.discrete,
h4.discrete,
h5.discrete,
h6.discrete {
@apply text-gray-800 dark:text-gray-300;
}
h3.discrete {
@apply text-2xl font-bold;
}
h4.discrete {
@apply text-xl font-bold;
}
h5.discrete {
@apply text-lg font-semibold;
}
h6.discrete {
@apply text-base font-semibold;
}
}

3
tsconfig.json

@ -8,7 +8,8 @@ @@ -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
//

6
vite.config.ts

@ -16,6 +16,12 @@ function getLatestGitTag() { @@ -16,6 +16,12 @@ function getLatestGitTag() {
export default defineConfig({
plugins: [sveltekit()],
resolve: {
alias: {
$lib: './src/lib',
$components: './src/components'
}
},
test: {
include: ['./tests/unit/**/*.unit-test.js']
},

Loading…
Cancel
Save