Browse Source

Merge issue#137#118-table-of-contents into feature/text-entry - integrated ToC functionality

master
silberengel 8 months ago
parent
commit
e3a76c4b64
  1. 3
      .vscode/settings.json
  2. 736
      deno.lock
  3. 2
      import_map.json
  4. 2
      package.json
  5. 14
      src/app.css
  6. 172
      src/lib/components/PublicationHeader.svelte
  7. 198
      src/lib/components/PublicationSection.svelte
  8. 28
      src/lib/components/Toc.svelte
  9. 82
      src/lib/components/publications/Publication.svelte
  10. 0
      src/lib/components/publications/PublicationFeed.svelte
  11. 64
      src/lib/components/publications/PublicationHeader.svelte
  12. 122
      src/lib/components/publications/PublicationSection.svelte
  13. 77
      src/lib/components/publications/TableOfContents.svelte
  14. 111
      src/lib/components/publications/svelte_publication_tree.svelte.ts
  15. 292
      src/lib/components/publications/table_of_contents.svelte.ts
  16. 123
      src/lib/components/util/ArticleNav.svelte
  17. 150
      src/lib/components/util/TocToggle.svelte
  18. 291
      src/lib/data_structures/publication_tree.ts
  19. 22
      src/lib/stores.ts
  20. 94
      src/routes/publication/+page.svelte
  21. 76
      src/routes/publication/+page.ts

3
.vscode/settings.json vendored

@ -10,5 +10,6 @@ @@ -10,5 +10,6 @@
},
"files.associations": {
"*.svelte": "svelte"
}
},
"editor.tabSize": 2
}

736
deno.lock

File diff suppressed because it is too large Load Diff

2
import_map.json

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
"tailwind-merge": "npm:tailwind-merge@2.5.x",
"svelte": "npm:svelte@5.0.x",
"flowbite": "npm:flowbite@2.2.x",
"flowbite-svelte": "npm:flowbite-svelte@0.44.x",
"flowbite-svelte": "npm:flowbite-svelte@0.48.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x",
"child_process": "node:child_process"
}

2
package.json

@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
"autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x",
"flowbite": "2.x",
"flowbite-svelte": "0.x",
"flowbite-svelte": "0.48.x",
"flowbite-svelte-icons": "2.1.x",
"playwright": "^1.50.1",
"postcss": "8.x",

14
src/app.css

@ -155,20 +155,6 @@ @@ -155,20 +155,6 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400;
}
/* Sidebar */
aside.sidebar-leather {
@apply fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10;
@apply bg-primary-0 dark:bg-primary-1000 px-5 w-full sm:w-auto sm:max-w-xl;
}
aside.sidebar-leather > div {
@apply bg-primary-50 dark:bg-gray-800 h-full px-5 py-0;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-gray-800;
}
div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
}

172
src/lib/components/PublicationHeader.svelte

@ -1,172 +0,0 @@ @@ -1,172 +0,0 @@
<script lang="ts">
import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from "../consts";
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { goto } from '$app/navigation';
import { getUserMetadata, toNpub, getMatchingTags } from "$lib/utils/nostrUtils";
const { event } = $props<{ event: NDKEvent }>();
const relays = $derived.by(() => {
return $ndkInstance.activeUser?.relayUrls ?? standardRelays;
});
const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
});
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let authorTag: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? '');
let pTag: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? '');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(event.getMatchingTags('t').map((tag: string[]) => tag[1]));
// New: fetch profile display name for authorPubkey
let authorDisplayName = $state<string | undefined>(undefined);
let imageLoaded = $state(false);
let imageError = $state(false);
function isValidNostrPubkey(str: string): boolean {
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63);
}
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
function generatePastelColor(eventId: string): string {
// Use the first 6 characters of the event ID to generate a pastel color
const hash = eventId.substring(0, 6);
const r = parseInt(hash.substring(0, 2), 16);
const g = parseInt(hash.substring(2, 4), 16);
const b = parseInt(hash.substring(4, 6), 16);
// Convert to pastel by mixing with white (lightening the color)
const pastelR = Math.round((r + 255) / 2);
const pastelG = Math.round((g + 255) / 2);
const pastelB = Math.round((b + 255) / 2);
return `rgb(${pastelR}, ${pastelG}, ${pastelB})`;
}
function handleImageLoad() {
imageLoaded = true;
}
function handleImageError() {
imageError = true;
}
$effect(() => {
if (authorPubkey) {
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
authorTag ||
authorPubkey;
});
} else {
authorDisplayName = undefined;
}
});
</script>
{#if title != null && href != null}
<Card
class="ArticleBox card-leather max-w-md h-64 flex flex-row overflow-hidden"
>
<div class="w-24 h-full overflow-hidden flex-shrink-0">
{#if image && !imageError}
<div class="w-full h-full relative">
<!-- Pastel placeholder -->
<div
class="w-full h-full transition-opacity duration-300"
style="background-color: {generatePastelColor(event.id)}; opacity: {imageLoaded ? '0' : '1'}"
></div>
<!-- Image -->
<img
src={image}
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
style="opacity: {imageLoaded ? '1' : '0'}"
onload={handleImageLoad}
onerror={handleImageError}
loading="lazy"
alt="Publication cover"
/>
</div>
{:else}
<!-- Pastel placeholder when no image or image failed to load -->
<div
class="w-full h-full"
style="background-color: {generatePastelColor(event.id)}"
></div>
{/if}
</div>
<div class="flex flex-col flex-grow p-4 relative">
<div class="absolute top-2 right-2 z-10">
<CardActions {event} />
</div>
<button
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline pr-8"
onclick={() => goto(`/${href}`)}
>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
<h3 class='text-base font-normal'>
by
{#if authorTag && pTag && isValidNostrPubkey(pTag)}
{authorTag} {@render userBadge(pTag, '')}
{:else if authorTag}
{authorTag}
{:else if pTag && isValidNostrPubkey(pTag)}
{@render userBadge(pTag, '')}
{:else if authorPubkey != null}
{@render userBadge(authorPubkey, authorDisplayName)}
{:else}
unknown
{/if}
</h3>
{#if version != "1"}
<h3
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
version: {version}
</h3>
{/if}
</button>
{#if hashtags.length > 0}
<div class="tags mt-auto pt-2 flex flex-wrap gap-1">
{#each hashtags as tag (tag)}
<button
class="text-xs text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div>
{/if}
</div>
</Card>
{/if}

198
src/lib/components/PublicationSection.svelte

@ -1,198 +0,0 @@ @@ -1,198 +0,0 @@
<script lang="ts">
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { goto } from '$app/navigation';
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string;
rootAddress: string;
leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void;
} = $props();
console.debug(`[PublicationSection] Received address: ${address}`);
console.debug(`[PublicationSection] Root address: ${rootAddress}`);
console.debug(`[PublicationSection] Leaves count: ${leaves.length}`);
const publicationTree: PublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => {
console.debug(`[PublicationSection] Getting event for address: ${address}`);
const event = await publicationTree.getEvent(address);
console.debug(`[PublicationSection] Retrieved event: ${event?.id}`);
return event;
},
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(
async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(
async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
let leafContent: Promise<string | Document> = $derived.by(async () => {
const rawContent = (await leafEvent)?.content ?? "";
const asciidoctorHtml = asciidoctor.convert(rawContent);
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString());
});
let leafHashtags: Promise<string[]> = $derived.by(
async () => (await leafEvent)?.getMatchingTags("t").map((tag: string[]) => tag[1]) ?? [],
);
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number;
let event: NDKEvent | null = null;
let decrement = 1;
do {
index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) {
return null;
}
event = leaves[index - decrement++];
} while (event == null && index - decrement >= 0);
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
},
);
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([
leafHierarchy,
previousLeafHierarchy,
]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<section
id={address}
bind:this={sectionRef}
class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, leafEvent, leafHashtags], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, resolvedLeafEvent, hashtags]}
{@const contentString = leafContent.toString()}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(
contentString,
publicationType ?? "article",
false,
)}
{#if hashtags.length > 0}
<div class="tags my-2 flex flex-wrap gap-1">
{#each hashtags as tag (tag)}
<button
class="text-sm text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div>
{/if}
{/await}
</section>

28
src/lib/components/Toc.svelte

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export let notes: NDKEvent[] = [];
// check if notes is empty
if (notes.length === 0) {
console.debug("notes is empty");
}
</script>
<div class="toc">
<h2>Table of contents</h2>
<ul>
{#each notes as note}
<li>
<a href="#{nip19.noteEncode(note.id)}"
>{note.getMatchingTags("title")[0][1]}</a
>
</li>
{/each}
</ul>
</div>
<style>
.toc h2 {
text-align: center;
}
</style>

82
src/lib/components/Publication.svelte → src/lib/components/publications/Publication.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton,
} from "flowbite-svelte";
import { getContext, onDestroy, onMount } from "svelte";
import {
@ -15,13 +16,13 @@ @@ -15,13 +16,13 @@
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from "$lib/parser";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string;
@ -29,16 +30,16 @@ @@ -29,16 +30,16 @@
indexEvent: NDKEvent;
}>();
const publicationTree = getContext("publicationTree") as PublicationTree;
const publicationTree = getContext("publicationTree") as SveltePublicationTree;
const toc = getContext("toc") as TocType;
// #region Loading
// TODO: Test load handling.
let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state<boolean>(false);
let isDone = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null);
let activeAddress = $state<string | null>(null);
let observer: IntersectionObserver;
@ -82,7 +83,8 @@ @@ -82,7 +83,8 @@
// #endregion
// region Columns visibility
// #region Columns visibility
let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
@ -91,6 +93,10 @@ @@ -91,6 +93,10 @@
return currentBlog !== null && $publicationColumnVisibility.inner;
}
function closeToc() {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
}
@ -119,6 +125,33 @@ @@ -119,6 +125,33 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
// #endregion
/**
* Performs actions on the DOM element for a publication tree leaf when it is mounted.
*
* @param el The DOM element that was mounted.
* @param address The address of the event that was mounted.
*/
function onPublicationSectionMounted(el: HTMLElement, address: string) {
// Update last element ref for the intersection observer.
setLastElementRef(el, leaves.length);
// Michael J - 08 July 2025 - NOTE: Updating the ToC from here somewhat breaks separation of
// concerns, since the TableOfContents component is primarily responsible for working with the
// ToC data structure. However, the Publication component has direct access to the needed DOM
// element already, and I want to avoid complicated callbacks between the two components.
// Update the ToC from the contents of the leaf section.
const entry = toc.getEntry(address);
if (!entry) {
console.warn(`[Publication] No parent found for ${address}`);
return;
}
toc.buildTocFromDocument(el, entry);
}
// #region Lifecycle hooks
onDestroy(() => {
// reset visibility
publicationColumnVisibility.reset();
@ -154,13 +187,27 @@ @@ -154,13 +187,27 @@
};
});
// Whenever the publication changes, update rootId
let rootId = $derived($pharosInstance.getRootIndexId());
// #endregion
</script>
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} />
{#if publicationType !== 'blog' || !isLeaf}
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ''}`}
asideClass='fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4'
activeClass='flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg'
nonActiveClass='flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg'
>
<CloseButton onclick={closeToc} class='btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800' />
<TableOfContents
displayMode='sidebar'
rootAddress={rootAddress}
depth={2}
onSectionFocused={(address: string) => publicationTree.setBookmark(address)}
/>
</Sidebar>
{/if}
{/if}
<!-- Default publications -->
@ -179,11 +226,12 @@ @@ -179,11 +226,12 @@
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
{@const address = leaf.tagAddress()}
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
{address}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
{/each}
@ -193,7 +241,7 @@ @@ -193,7 +241,7 @@
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-700 dark:text-gray-300">
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
{/if}
@ -204,9 +252,7 @@ @@ -204,9 +252,7 @@
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1
{isInnerActive() ? 'discreet' : ''}
"
class={`flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1 ${isInnerActive() ? 'discreet' : ''}`}
>
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
@ -287,7 +333,7 @@ @@ -287,7 +333,7 @@
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-700 dark:text-gray-300">1.1.1970</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to

0
src/lib/components/PublicationFeed.svelte → src/lib/components/publications/PublicationFeed.svelte

64
src/lib/components/publications/PublicationHeader.svelte

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
<script lang="ts">
import { ndkInstance } from '$lib/ndk';
import { naddrEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { standardRelays } from '../../consts';
import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>();
const relays = $derived.by(() => {
return $ndkInstance.activeUser?.relayUrls ?? standardRelays;
});
const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
}
}
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
console.log("PublicationHeader event:", event);
</script>
{#if title != null && href != null}
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'>
{#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/>
</div>
{/if}
<div class='col flex flex-row flex-grow space-x-4'>
<div class="flex flex-col flex-grow">
<a href="/{href}" class='flex flex-col space-y-2'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
<h3 class='text-base font-normal'>
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
{:else}
{author}
{/if}
</h3>
{#if version != '1'}
<h3 class='text-base font-thin'>version: {version}</h3>
{/if}
</a>
</div>
<div class="flex flex-col justify-start items-center">
<CardActions event={event} />
</div>
</div>
</Card>
{/if}

122
src/lib/components/publications/PublicationSection.svelte

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
<script lang='ts'>
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: Array<NDKEvent | null>,
ref: (ref: HTMLElement) => void,
} = $props();
const publicationTree: SveltePublicationTree = getContext('publicationTree');
const asciidoctor: Asciidoctor = getContext('asciidoctor');
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address));
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address));
let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number;
let event: NDKEvent | null = null;
let decrement = 1;
do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address);
if (index === 0) {
return null;
}
event = leaves[index - decrement++];
} while (event == null && index - decrement >= 0);
return event;
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
});
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)}
{/await}
</section>

77
src/lib/components/publications/TableOfContents.svelte

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
<script lang='ts'>
import {
TableOfContents,
type TocEntry
} from '$lib/components/publications/table_of_contents.svelte';
import { getContext } from 'svelte';
import { SidebarDropdownWrapper, SidebarGroup, SidebarItem } from 'flowbite-svelte';
import Self from './TableOfContents.svelte';
let {
depth,
onSectionFocused,
} = $props<{
rootAddress: string;
depth: number;
onSectionFocused?: (address: string) => void;
}>();
let toc = getContext('toc') as TableOfContents;
let entries = $derived.by<TocEntry[]>(() => {
const newEntries = [];
for (const [_, entry] of toc.addressMap) {
if (entry.depth !== depth) {
continue;
}
newEntries.push(entry);
}
return newEntries;
});
function setEntryExpanded(address: string, expanded: boolean = false) {
const entry = toc.getEntry(address);
if (!entry) {
return;
}
toc.expandedMap.set(address, expanded);
entry.resolveChildren();
}
</script>
<!-- TODO: Figure out how to style indentations. -->
<!-- TODO: Make group title fonts the same as entry title fonts. -->
<SidebarGroup>
{#each entries as entry}
{@const address = entry.address}
{@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)}
{#if isLeaf}
<SidebarItem
label={entry.title}
href={`#${address}`}
spanClass='px-2 text-ellipsis'
onclick={() => onSectionFocused?.(address)}
/>
{:else}
{@const childDepth = depth + 1}
<SidebarDropdownWrapper
label={entry.title}
btnClass='flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800'
bind:isOpen={
() => expanded,
(open) => setEntryExpanded(address, open)
}
>
<Self
rootAddress={address}
depth={childDepth}
onSectionFocused={onSectionFocused}
/>
</SidebarDropdownWrapper>
{/if}
{/each}
</SidebarGroup>

111
src/lib/components/publications/svelte_publication_tree.svelte.ts

@ -0,0 +1,111 @@ @@ -0,0 +1,111 @@
import { SvelteSet } from "svelte/reactivity";
import { PublicationTree } from "../../data_structures/publication_tree.ts";
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
export class SveltePublicationTree {
resolvedAddresses: SvelteSet<string> = new SvelteSet();
#publicationTree: PublicationTree;
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) {
this.#publicationTree = new PublicationTree(rootEvent, ndk);
this.#publicationTree.onNodeResolved(this.#handleNodeResolved);
this.#publicationTree.onBookmarkMoved(this.#handleBookmarkMoved);
}
// #region Proxied Public Methods
getChildAddresses(address: string): Promise<Array<string | null>> {
return this.#publicationTree.getChildAddresses(address);
}
getEvent(address: string): Promise<NDKEvent | null> {
return this.#publicationTree.getEvent(address);
}
getHierarchy(address: string): Promise<NDKEvent[]> {
return this.#publicationTree.getHierarchy(address);
}
async getParent(address: string): Promise<NDKEvent | null> {
const hierarchy = await this.getHierarchy(address);
// The last element in the hierarchy is the event with the given address, so the parent is the
// second to last element.
return hierarchy.at(-2) ?? null;
}
setBookmark(address: string) {
this.#publicationTree.setBookmark(address);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved.
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever the bookmark is moved.
* @param observer The observer function.
*/
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
// #endregion
// #region Proxied Async Iterator Methods
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> {
return this;
}
next(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.next();
}
previous(): Promise<IteratorResult<NDKEvent | null>> {
return this.#publicationTree.previous();
}
// #endregion
// #region Private Methods
/**
* Observer function that is invoked whenever a new node is resolved on the publication tree.
*
* @param address The address of the resolved node.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleNodeResolved = (address: string) => {
this.resolvedAddresses.add(address);
for (const observer of this.#nodeResolvedObservers) {
observer(address);
}
}
/**
* Observer function that is invoked whenever the bookmark is moved on the publication tree.
*
* @param address The address of the new bookmark.
*
* This member is declared as an arrow function to ensure that the correct `this` context is
* used when the function is invoked in this class's constructor.
*/
#handleBookmarkMoved = (address: string) => {
for (const observer of this.#bookmarkMovedObservers) {
observer(address);
}
}
// #endregion
}

292
src/lib/components/publications/table_of_contents.svelte.ts

@ -0,0 +1,292 @@ @@ -0,0 +1,292 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts';
import type { NDKEvent } from '../../utils/nostrUtils.ts';
import { indexKind } from '../../consts.ts';
export interface TocEntry {
address: string;
title: string;
href?: string;
children: TocEntry[];
parent?: TocEntry;
depth: number;
childrenResolved: boolean;
resolveChildren: () => Promise<void>;
}
/**
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree.
*
* @see SveltePublicationTree
*/
export class TableOfContents {
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap();
public expandedMap: SvelteMap<string, boolean> = new SvelteMap();
public leaves: SvelteSet<string> = new SvelteSet();
#root: TocEntry | null = null;
#publicationTree: SveltePublicationTree;
#pagePathname: string;
/**
* Constructs a `TableOfContents` from a `SveltePublicationTree`.
*
* @param rootAddress The address of the root event.
* @param publicationTree The SveltePublicationTree instance.
* @param pagePathname The current page pathname for href generation.
*/
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) {
this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname;
this.#init(rootAddress);
}
// #region Public Methods
/**
* Returns the root entry of the ToC.
*
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized.
*/
getRootEntry(): TocEntry | null {
return this.#root;
}
getEntry(address: string): TocEntry | undefined {
return this.addressMap.get(address);
}
/**
* Builds a table of contents from the DOM subtree rooted at `parentElement`.
*
* @param parentElement The root of the DOM subtree containing the content to be added to the
* ToC.
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated
* by `parentElement`.
*
* This function is intended for use on segments of HTML markup that are not directly derived
* from a structure publication of the kind supported by `PublicationTree`. It may be used to
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or
* from a kind `30023` event with Markdown content.
*/
buildTocFromDocument(
parentElement: HTMLElement,
parentEntry: TocEntry,
) {
parentElement
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`)
.forEach((header) => {
// TODO: Correctly update ToC state from DOM.
const title = header.textContent?.trim();
const id = header.id;
// Only create an entry if the header has an ID and a title.
if (id && title) {
const href = `${this.#pagePathname}#${id}`;
// TODO: Check this logic.
const tocEntry: TocEntry = {
address: parentEntry.address,
title,
href,
depth: parentEntry.depth + 1,
children: [],
childrenResolved: true,
resolveChildren: () => Promise.resolve(),
};
parentEntry.children.push(tocEntry);
this.expandedMap.set(tocEntry.address, false);
this.buildTocFromDocument(header, tocEntry);
}
});
}
// #endregion
// #region Iterator Methods
/**
* Iterates over all ToC entries in depth-first order.
*/
*[Symbol.iterator](): IterableIterator<TocEntry> {
function* traverse(entry: TocEntry | null): IterableIterator<TocEntry> {
if (!entry) {
return;
}
yield entry;
if (entry.children) {
for (const child of entry.children) {
yield* traverse(child);
}
}
}
yield* traverse(this.#root);
}
// #endregion
// #region Private Methods
/**
* Initializes the ToC from the associated publication tree.
*
* @param rootAddress The address of the publication's root event.
*
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the
* publication has been fully resolved.
*
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to
* initialize the ToC with all of its first-level children.
*/
async #init(rootAddress: string) {
const rootEvent = await this.#publicationTree.getEvent(rootAddress);
if (!rootEvent) {
throw new Error(`[ToC] Root event ${rootAddress} not found.`);
}
this.#root = await this.#buildTocEntry(rootAddress);
this.addressMap.set(rootAddress, this.#root);
// Handle any other nodes that have already been resolved in parallel.
await Promise.all(
Array.from(this.#publicationTree.resolvedAddresses).map((address) =>
this.#buildTocEntryFromResolvedNode(address)
)
);
// Set up an observer to handle progressive resolution of the publication tree.
this.#publicationTree.onNodeResolved((address: string) => {
this.#buildTocEntryFromResolvedNode(address);
});
}
#getTitle(event: NDKEvent | null): string {
if (!event) {
// TODO: What do we want to return in this case?
return '[untitled]';
}
const titleTag = event.getMatchingTags?.('title')?.[0]?.[1];
return titleTag || event.tagAddress() || '[untitled]';
}
async #buildTocEntry(address: string): Promise<TocEntry> {
// Michael J - 07 July 2025 - NOTE: This arrow function is nested so as to use its containing
// scope in its operation. Do not move it to the top level without ensuring it still has access
// to the necessary variables.
const resolver = async () => {
if (entry.childrenResolved) {
return;
}
const event = await this.#publicationTree.getEvent(entry.address);
if (event?.kind !== indexKind) {
// TODO: Build ToC entries from HTML markup in this case.
return;
}
const childAddresses = await this.#publicationTree.getChildAddresses(entry.address);
for (const childAddress of childAddresses) {
if (!childAddress) {
continue;
}
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
// here so that we can determine whether to render an entry as a leaf before it is fully
// resolved.
if (childAddress.split(':')[0] !== indexKind.toString()) {
this.leaves.add(childAddress);
}
// Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the
// publication tree. This is acceptable here, because the tree is always resolved
// top-down. Therefore, by the time we handle a node's resolution, its parent and
// siblings have already been resolved.
const childEntry = await this.#buildTocEntry(childAddress);
childEntry.parent = entry;
childEntry.depth = entry.depth + 1;
entry.children.push(childEntry);
this.addressMap.set(childAddress, childEntry);
}
await this.#matchChildrenToTagOrder(entry);
entry.childrenResolved = true;
}
const event = await this.#publicationTree.getEvent(address);
if (!event) {
throw new Error(`[ToC] Event ${address} not found.`);
}
const depth = (await this.#publicationTree.getHierarchy(address)).length;
const entry: TocEntry = {
address,
title: this.#getTitle(event),
href: `${this.#pagePathname}#${address}`,
children: [],
depth,
childrenResolved: false,
resolveChildren: resolver,
};
this.expandedMap.set(address, false);
// Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner
// resolver function. The resolver function is called when entries are resolved by expanding
// a ToC entry, and we'll reach the block below when entries are resolved by the publication
// tree.
if (event.kind !== indexKind) {
this.leaves.add(address);
}
return entry;
}
/**
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding
* Nostr index event.
*
* @param entry The ToC entry to reorder.
*
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the
* parent event has. Average size of `n` is small enough to be negligible.
*/
async #matchChildrenToTagOrder(entry: TocEntry) {
const parentEvent = await this.#publicationTree.getEvent(entry.address);
if (parentEvent?.kind === indexKind) {
const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]);
const addressToOrdinal = new Map<string, number>();
// Build map of addresses to their ordinals from tag order
tagOrder.forEach((address, index) => {
addressToOrdinal.set(address, index);
});
entry.children.sort((a, b) => {
const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER;
const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER;
return aOrdinal - bOrdinal;
});
}
}
#buildTocEntryFromResolvedNode(address: string) {
if (this.addressMap.has(address)) {
return;
}
this.#buildTocEntry(address).then((entry) => {
this.addressMap.set(address, entry);
});
}
// #endregion
}

123
src/lib/components/util/ArticleNav.svelte

@ -1,41 +1,35 @@ @@ -1,41 +1,35 @@
<script lang="ts">
import {
BookOutline,
CaretLeftOutline,
CloseOutline,
GlobeOutline,
} from "flowbite-svelte-icons";
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte";
let { publicationType, indexEvent } = $props<{
rootId: any;
publicationType: string;
indexEvent: NDKEvent;
let {
publicationType,
indexEvent
} = $props<{
rootId: any,
publicationType: string,
indexEvent: NDKEvent
}>();
let title: string = $derived(indexEvent.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
indexEvent.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let pubkey: string = $derived(
indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
);
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let lastScrollY = $state(0);
let isVisible = $state(true);
// Function to toggle column visibility
function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") {
publicationColumnVisibility.update((current) => {
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') {
publicationColumnVisibility.update(current => {
const newValue = !current[column];
const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === "blog" && newValue) {
if (window.innerWidth < 1400 && column === 'blog' && newValue) {
updated.discussion = false;
}
@ -45,13 +39,11 @@ @@ -45,13 +39,11 @@
function shouldShowBack() {
const vis = $publicationColumnVisibility;
return ["discussion", "toc", "inner"].some(
(key) => vis[key as keyof typeof vis],
);
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]);
}
function backToMain() {
publicationColumnVisibility.update((current) => {
publicationColumnVisibility.update(current => {
const updated = { ...current };
// if current is 'inner', just go back to blog
@ -64,7 +56,7 @@ @@ -64,7 +56,7 @@
updated.discussion = false;
updated.toc = false;
if (publicationType === "blog") {
if (publicationType === 'blog') {
updated.inner = true;
updated.blog = false;
} else {
@ -76,13 +68,13 @@ @@ -76,13 +68,13 @@
}
function backToBlog() {
publicationColumnVisibility.update((current) => {
publicationColumnVisibility.update(current => {
const updated = { ...current };
updated.inner = false;
updated.discussion = false;
updated.blog = true;
return updated;
});
})
}
function handleScroll() {
@ -104,93 +96,70 @@ @@ -104,93 +96,70 @@
let unsubscribe: () => void;
onMount(() => {
window.addEventListener("scroll", handleScroll);
window.addEventListener('scroll', handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes
});
});
onDestroy(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener('scroll', handleScroll);
unsubscribe();
});
</script>
<nav
class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
? 'translate-y-0'
: '-translate-y-full'}"
>
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}">
<div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()}
<Button
class="btn-leather !w-auto sm:hidden"
outline={true}
onclick={backToMain}
>
<CaretLeftOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Back</span
>
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}>
<CaretLeftOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if !isLeaf}
{#if publicationType === "blog"}
{#if publicationType === 'blog'}
<Button
class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog
? 'active'
: ''}"
class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? 'active' : ''}`}
outline={true}
onclick={() => toggleColumn("blog")}
onclick={() => toggleColumn('blog')}
>
<BookOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Table of Contents</span
>
<BookOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Table of Contents</span>
</Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button
class="btn-leather !w-auto"
class={`btn-leather !w-auto ${$publicationColumnVisibility.toc ? 'active' : ''}`}
outline={true}
onclick={() => toggleColumn("toc")}
onclick={() => toggleColumn('toc')}
>
<BookOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Table of Contents</span
>
<BookOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Table of Contents</span>
</Button>
{/if}
{/if}
</div>
<div class="flex flex-grow text justify-center items-center">
<div class="flex flex-col flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis">
<b class="text-nowrap">{title}</b>
<span class="whitespace-nowrap"
>by {@render userBadge(pubkey, author)}</span
>
</p>
<p>
<span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span>
</p>
</div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner}
<Button
class="btn-leather !w-auto hidden sm:flex"
outline={true}
onclick={backToBlog}
>
<CloseOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Close</span
>
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}>
<CloseOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
<Button
class="btn-leather !hidden sm:flex !w-auto"
outline={true}
onclick={() => toggleColumn("discussion")}
>
<GlobeOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Discussion</span
>
{#if publicationType !== 'blog' && !$publicationColumnVisibility.discussion}
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} >
<GlobeOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Discussion</span>
</Button>
{/if}
</div>
</div>
</nav>
</nav>

150
src/lib/components/util/TocToggle.svelte

@ -1,150 +0,0 @@ @@ -1,150 +0,0 @@
<script lang="ts">
import {
Heading,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(window.location.hash);
interface TocItem {
label: string;
hash: string;
}
// Get TOC items from parser
let tocItems = $state<TocItem[]>([]);
$effect(() => {
// This will re-run whenever tocUpdate changes
tocUpdate;
const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log("TOC rootId:", rootId, "childIds:", childIds);
const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId);
if (title) {
items.push({
label: title,
hash: `#${nodeId}`,
});
}
const children = $pharosInstance.getChildIndexIds(nodeId);
children.forEach(processNode);
};
childIds.forEach(processNode);
tocItems = items;
});
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
function updateActiveHash() {
activeHash = window.location.hash;
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update((v) => ({
...v,
toc: window.innerWidth >= tocBreakpoint,
}));
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
// Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", updateActiveHash);
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
return () => {
window.removeEventListener("hashchange", updateActiveHash);
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
};
});
</script>
<!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc}
<Sidebar class="sidebar-leather left-0">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>
(This ToC is only for demo purposes, and is not fully-functional.)
</p>
{#each tocItems as item}
<SidebarItem
class="sidebar-item-leather {activeHash === item.hash
? 'bg-primary-200 font-bold'
: ''}"
label={item.label}
href={item.hash}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}

291
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
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";
import type NDK from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { Lazy } from './lazy.ts';
enum PublicationTreeNodeType {
Branch,
@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus { @@ -13,6 +12,16 @@ enum PublicationTreeNodeStatus {
Error,
}
export enum TreeTraversalMode {
Leaves,
All,
}
enum TreeTraversalDirection {
Forward,
Backward,
}
interface PublicationTreeNode {
type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus;
@ -52,20 +61,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -52,20 +61,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
#ndk: NDK;
#nodeAddedObservers: Array<(address: string) => void> = [];
#nodeResolvedObservers: Array<(address: string) => void> = [];
#bookmarkMovedObservers: Array<(address: string) => void> = [];
constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress();
this.#root = {
type: this.#getNodeType(rootEvent),
type: PublicationTreeNodeType.Branch,
status: PublicationTreeNodeStatus.Resolved,
address: rootAddress,
children: [],
};
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent);
@ -88,7 +100,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -88,7 +100,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`,
`PublicationTree: Parent node with address ${parentAddress} not found.`
);
}
@ -119,7 +131,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -119,7 +131,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`,
`PublicationTree: Parent node with address ${parentAddress} not found.`
);
}
@ -142,21 +154,22 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -142,21 +154,22 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* 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.
*
* Note that this method resolves all children of the node.
*/
async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
throw new Error(`[PublicationTree] Node with address ${address} not found.`);
}
return Promise.all(
node.children?.map(
async (child) => (await child.value())?.address ?? null,
) ?? [],
node.children?.map(async child =>
(await child.value())?.address ?? null
) ?? []
);
}
/**
@ -168,13 +181,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -168,13 +181,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
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.`,
);
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;
@ -189,12 +200,34 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -189,12 +200,34 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
setBookmark(address: string) {
this.#bookmark = address;
this.#cursor.tryMoveTo(address);
this.#cursor.tryMoveTo(address).then(success => {
if (success) {
this.#bookmarkMovedObservers.forEach(observer => observer(address));
}
});
}
onBookmarkMoved(observer: (address: string) => void) {
this.#bookmarkMovedObservers.push(observer);
}
onNodeAdded(observer: (address: string) => void) {
this.#nodeAddedObservers.push(observer);
}
/**
* Registers an observer function that is invoked whenever a new node is resolved. Nodes are
* added lazily.
*
* @param observer The observer function.
*/
onNodeResolved(observer: (address: string) => void) {
this.#nodeResolvedObservers.push(observer);
}
// #region Iteration Cursor
#cursor = new (class {
#cursor = new class {
target: PublicationTreeNode | null | undefined;
#tree: PublicationTree;
@ -206,9 +239,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -206,9 +239,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) {
if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
?.value();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value();
} else {
this.target = await this.#tree.#nodes.get(address)?.value();
}
@ -222,7 +253,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -222,7 +253,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug("[Publication Tree Cursor] Target node is null or undefined.");
return false;
}
@ -233,32 +264,32 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -233,32 +264,32 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (this.target.children == null || this.target.children.length === 0) {
return false;
}
this.target = await this.target.children?.at(0)?.value();
return true;
}
async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug("[Publication Tree Cursor] Target node is null or undefined.");
return false;
}
if (this.target.type === PublicationTreeNodeType.Leaf) {
return false;
}
if (this.target.children == null || this.target.children.length === 0) {
return false;
}
this.target = await this.target.children?.at(-1)?.value();
return true;
}
async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug("[Publication Tree Cursor] Target node is null or undefined.");
return false;
}
@ -269,8 +300,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -269,8 +300,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
);
if (currentIndex === -1) {
@ -287,36 +317,35 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -287,36 +317,35 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug("[Publication Tree Cursor] Target node is null or undefined.");
return false;
}
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,
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
);
if (currentIndex === -1) {
return false;
}
if (currentIndex <= 0) {
return false;
}
this.target = await siblings.at(currentIndex - 1)?.value();
return true;
}
tryMoveToParent(): boolean {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
console.debug("[Publication Tree Cursor] Target node is null or undefined.");
return false;
}
@ -328,7 +357,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -328,7 +357,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent;
return true;
}
})(this);
}(this);
// #endregion
@ -338,9 +367,40 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -338,9 +367,40 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return this;
}
// TODO: Add `previous()` method.
/**
* Return the next event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The next event in the tree, or null if the tree is empty.
*/
async next(
mode: TreeTraversalMode = TreeTraversalMode.Leaves
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
return this.#yieldEventAtCursor(false);
}
}
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Forward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Forward);
}
}
async next(): Promise<IteratorResult<NDKEvent | null>> {
/**
* Return the previous event in the tree for the given traversal mode.
*
* @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or
* {@link TreeTraversalMode.All}.
* @returns The previous event in the tree, or null if the tree is empty.
*/
async previous(
mode: TreeTraversalMode = TreeTraversalMode.Leaves
): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
@ -348,11 +408,41 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -348,11 +408,41 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
switch (mode) {
case TreeTraversalMode.Leaves:
return this.#walkLeaves(TreeTraversalDirection.Backward);
case TreeTraversalMode.All:
return this.#preorderWalkAll(TreeTraversalDirection.Backward);
}
}
async #yieldEventAtCursor(done: boolean): Promise<IteratorResult<NDKEvent | null>> {
const value = (await this.getEvent(this.#cursor.target!.address)) ?? null;
return { done, value };
}
/**
* Walks the tree in the given direction, yielding the event at each leaf.
*
* @param direction The direction to walk the tree.
* @returns The event at the leaf, or null if the tree is empty.
*
* Based on Raymond Chen's tree traversal algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
*/
async #walkLeaves(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
do {
if (await this.#cursor.tryMoveToNextSibling()) {
while (await this.#cursor.tryMoveToFirstChild()) {
if (await tryMoveToSibling()) {
while (await tryMoveToChild()) {
continue;
}
@ -360,8 +450,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -360,8 +450,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
return this.#yieldEventAtCursor(false);
}
} while (this.#cursor.tryMoveToParent());
@ -373,28 +462,32 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -373,28 +462,32 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: true, value: null };
}
async previous(): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
}
/**
* Walks the tree in the given direction, yielding the event at each node.
*
* @param direction The direction to walk the tree.
* @returns The event at the node, or null if the tree is empty.
*
* Based on Raymond Chen's preorder walk algorithm example.
* https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304
*/
async #preorderWalkAll(
direction: TreeTraversalDirection = TreeTraversalDirection.Forward
): Promise<IteratorResult<NDKEvent | null>> {
const tryMoveToSibling: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToNextSibling.bind(this.#cursor)
: this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor);
const tryMoveToChild: () => Promise<boolean> = direction === TreeTraversalDirection.Forward
? this.#cursor.tryMoveToFirstChild.bind(this.#cursor)
: this.#cursor.tryMoveToLastChild.bind(this.#cursor);
if (await tryMoveToChild()) {
return this.#yieldEventAtCursor(false);
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do {
if (await this.#cursor.tryMoveToPreviousSibling()) {
while (await this.#cursor.tryMoveToLastChild()) {
continue;
}
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
if (await tryMoveToSibling()) {
return this.#yieldEventAtCursor(false);
}
} while (this.#cursor.tryMoveToParent());
@ -402,7 +495,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -402,7 +495,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: false, value: null };
}
return { done: true, value: null };
// If we get to this point, we're at the root node (can't move up any more).
return this.#yieldEventAtCursor(true);
}
// #endregion
@ -423,23 +517,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -423,23 +517,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
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.`,
);
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.`,
);
throw new Error(`[PublicationTree] Event with address ${currentAddress} not found.`);
}
// Stop immediately if the target of the search is found.
@ -448,8 +536,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -448,8 +536,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentChildAddresses = currentEvent.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
.filter(tag => tag[0] === 'a')
.map(tag => tag[1]);
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
@ -481,43 +569,36 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -481,43 +569,36 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#addNode(address: string, parentNode: PublicationTreeNode) {
if (this.#nodes.has(address)) {
console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return;
}
const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode));
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
this.#nodeAddedObservers.forEach(observer => observer(address));
}
/**
* 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,
parentNode: PublicationTreeNode
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(":");
const [kind, pubkey, dTag] = address.split(':');
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
"#d": [dTag],
'#d': [dTag],
});
if (!event) {
console.debug(
`PublicationTree: Event with address ${address} not found.`,
`[PublicationTree] Event with address ${address} not found.`
);
return {
@ -531,10 +612,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -531,10 +612,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event);
const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const node: PublicationTreeNode = {
type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
@ -547,11 +626,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -547,11 +626,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.addEventByAddress(address, event);
}
this.#nodeResolvedObservers.forEach(observer => observer(address));
return node;
}
#getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) {
return PublicationTreeNodeType.Branch;
}
@ -559,4 +640,4 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -559,4 +640,4 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
// #endregion
}
}

22
src/lib/stores.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { readable, writable } from "svelte/store";
import { FeedType } from "./consts";
import { readable, writable } from 'svelte/store';
import { FeedType } from './consts.ts';
export let idList = writable<string[]>([]);
@ -7,23 +7,33 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]); @@ -7,23 +7,33 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = {
export interface PublicationLayoutVisibility {
toc: boolean;
blog: boolean;
main: boolean;
inner: boolean;
discussion: boolean;
editing: boolean;
}
const defaultVisibility: PublicationLayoutVisibility = {
toc: false,
blog: true,
main: true,
inner: false,
discussion: false,
editing: false,
editing: false
};
function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility });
const { subscribe, set, update }
= writable<PublicationLayoutVisibility>({ ...defaultVisibility });
return {
subscribe,
set,
update,
reset: () => set({ ...defaultVisibility }),
reset: () => set({ ...defaultVisibility })
};
}

94
src/routes/publication/+page.svelte

@ -1,26 +1,31 @@ @@ -1,26 +1,31 @@
<script lang="ts">
import Publication from "$lib/components/Publication.svelte";
import Publication from "$lib/components/publications/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import { onDestroy, onMount, setContext } from "svelte";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
import { SveltePublicationTree } from "$lib/components/publications/svelte_publication_tree.svelte";
import { TableOfContents } from "$lib/components/publications/table_of_contents.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
const publicationTree = new SveltePublicationTree(data.indexEvent, data.ndk);
const toc = new TableOfContents(data.indexEvent.tagAddress(), publicationTree, page.url.pathname ?? "");
setContext("publicationTree", publicationTree);
setContext("toc", toc);
setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags
let title = $derived(
data.indexEvent?.getMatchingTags("title")[0]?.[1] ||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
);
let currentUrl = data.url?.href ?? "";
let currentUrl = $derived(`${page.url.origin}${page.url.pathname}${page.url.search}`);
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
@ -30,9 +35,57 @@ @@ -30,9 +35,57 @@
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
publicationTree.onBookmarkMoved(address => {
goto(`#${address}`, {
replaceState: true,
});
// TODO: Extract IndexedDB interaction to a service layer.
// Store bookmark in IndexedDB
const db = indexedDB.open('alexandria', 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' });
};
db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readwrite');
const store = transaction.objectStore('bookmarks');
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
store.put({ key: bookmarkKey, address });
};
});
onMount(() => {
// TODO: Extract IndexedDB interaction to a service layer.
// Read bookmark from IndexedDB
const db = indexedDB.open('alexandria', 1);
db.onupgradeneeded = () => {
const objectStore = db.result.createObjectStore('bookmarks', { keyPath: 'key' });
};
db.onsuccess = () => {
const transaction = db.result.transaction(['bookmarks'], 'readonly');
const store = transaction.objectStore('bookmarks');
const bookmarkKey = `${data.indexEvent.tagAddress()}`;
const request = store.get(bookmarkKey);
request.onsuccess = () => {
if (request.result?.address) {
// Set the bookmark in the publication tree
publicationTree.setBookmark(request.result.address);
// Jump to the bookmarked element
goto(`#${request.result.address}`, {
replaceState: true,
});
}
};
};
});
onDestroy(() => data.parser.reset());
</script>
@ -56,23 +109,16 @@ @@ -56,23 +109,16 @@
<meta name="twitter:image" content={image} />
</svelte:head>
{#key data}
<ArticleNav
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
<main class="publication {data.publicationType}">
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
{/key}
<main class="publication {data.publicationType}">
{#await data.waitable}
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
{@const debugInfo = console.debug(`[Publication Page] Data loaded, rendering Publication component with publicationType: ${data.publicationType}, rootAddress: ${data.indexEvent.tagAddress()}`)}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>
{/await}
</main>

76
src/routes/publication/+page.ts

@ -1,28 +1,28 @@ @@ -1,28 +1,28 @@
import { error } from "@sveltejs/kit";
import type { Load } from "@sveltejs/kit";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { error } from '@sveltejs/kit';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
/**
* Decodes an naddr identifier and returns a filter object
*/
function decodeNaddr(id: string) {
try {
if (!id.startsWith("naddr")) return {};
if (!id.startsWith('naddr')) return {};
const decoded = nip19.decode(id);
if (decoded.type !== "naddr") return {};
if (decoded.type !== 'naddr') return {};
const data = decoded.data;
return {
kinds: [data.kind],
authors: [data.pubkey],
"#d": [data.identifier],
'#d': [data.identifier]
};
} catch (e) {
console.error("Failed to decode naddr:", e);
console.error('Failed to decode naddr:', e);
return null;
}
}
@ -32,7 +32,7 @@ function decodeNaddr(id: string) { @@ -32,7 +32,7 @@ function decodeNaddr(id: string) {
*/
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
@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
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));
const event = await (hasFilter ?
ndk.fetchEvent(filter) :
ndk.fetchEvent(id));
if (!event) {
throw new Error(`Event not found for ID: ${id}`);
}
@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try {
const event = await ndk.fetchEvent(
{ "#d": [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk),
{ '#d': [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk)
);
if (!event) {
throw new Error(`Event not found for d tag: ${dTag}`);
}
@ -83,33 +83,25 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { @@ -83,33 +83,25 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
}
}
export const load: Load = 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();
// TODO: Use path params instead of query params.
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
const { ndk } = await parent();
if (!id && !dTag) {
throw error(400, "No publication root event ID or d tag provided.");
throw error(400, 'No publication root event ID or d tag provided.');
}
// Fetch the event based on available parameters
const indexEvent = id
const indexEvent = id
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
return {
waitable: fetchPromise,
publicationType,
indexEvent,
url,
};
};

Loading…
Cancel
Save