Browse Source

Merge branch 'master' of ssh://onedev.gitcitadel.eu:6611/Alexandria/gc-alexandria into feature/text-entry

master
silberengel 8 months ago
parent
commit
eb9ccc08c3
  1. 31
      .cursor/rules/alexandria.mdc
  2. 1001
      package-lock.json
  3. 6
      src/lib/components/publications/PublicationFeed.svelte
  4. 16
      src/lib/components/publications/PublicationHeader.svelte
  5. 4
      src/lib/components/util/ContainingIndexes.svelte
  6. 2
      src/lib/components/util/ViewPublicationLink.svelte
  7. 133
      src/lib/navigator/EventNetwork/Legend.svelte
  8. 2
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  9. 39
      src/lib/navigator/EventNetwork/Settings.svelte
  10. 8
      src/lib/navigator/EventNetwork/TagTable.svelte
  11. 6
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  12. 7
      src/lib/ndk.ts
  13. 2
      src/lib/stores/index.ts
  14. 56
      src/lib/utils.ts
  15. 6
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  16. 10
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  17. 5
      src/lib/utils/markup/basicMarkupParser.ts
  18. 4
      src/lib/utils/network_detection.ts
  19. 11
      src/lib/utils/nostrUtils.ts
  20. 2
      src/lib/utils/nostr_identifiers.ts
  21. 143
      src/lib/utils/websocket_utils.ts
  22. 22
      src/routes/+layout.ts
  23. 4
      src/routes/about/+page.svelte
  24. 6
      src/routes/events/+page.svelte
  25. 5
      src/routes/proxy+layout.ts
  26. 41
      src/routes/publication/+page.server.ts
  27. 134
      src/routes/publication/+page.svelte
  28. 115
      src/routes/publication/+page.ts
  29. 28
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  30. 29
      src/routes/publication/[type]/[identifier]/+layout.svelte
  31. 65
      src/routes/publication/[type]/[identifier]/+layout.ts
  32. 18
      src/routes/publication/[type]/[identifier]/+page.server.ts
  33. 114
      src/routes/publication/[type]/[identifier]/+page.svelte
  34. 54
      src/routes/publication/[type]/[identifier]/+page.ts
  35. 10
      src/routes/start/+page.svelte

31
.cursor/rules/alexandria.mdc

@ -9,11 +9,7 @@ You are senior full-stack software engineer with 20 years of experience writing @@ -9,11 +9,7 @@ You are senior full-stack software engineer with 20 years of experience writing
## Project Overview
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree.
### Reader Features
In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view.
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays.
### Tech Stack
@ -36,14 +32,33 @@ When responding to prompts, adhere to the following rules: @@ -36,14 +32,33 @@ When responding to prompts, adhere to the following rules:
- Avoid proposing code edits unless I specifically tell you to do so.
- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily.
## Code Style
## Coding Guidelines
### Prime Directive
NEVER assume developer intent. If you are unsure about something, ALWAYS stop and ask the developer for clarification before proceeding.
### AI Anchor Comments
Observe the following style guidelines when writing code:
- Use anchor comments prefixed with `AI-NOTE:`, `AI-TODO:`, or `AI-QUESTION:` to share context between AI agents and developers across time.
- Use all-caps prefixes.
- Also _read_ (but do not write) variants of this format that begin with `AI-<date>:` where `<date>` is some date in `MM/DD/YYYY` format. Anchor comments with this format are used by developers to record context.
- **Important:** Before scanning files, ALWAYS search first for `AI-` anchor comments in relevant subdirectories.
- ALWAYS update relevant anchor comments when modifying associated code.
- NEVER remove `AI-` comments unless the developer explicitly instructs it.
- Add new anchor comments as relevant when:
- Code is unusually complex.
- Code is critical to security, performance, or functionality.
- Code is confusing.
- Code could have a bug.
### General Guidance
- Before writing any code, ALWAYS search the codebase for relevant anchor comments.
- Whenever updating code, ALWAYS update relevant anchor comments.
- Prefer to use Deno to manage dependencies, build the project, and run tests.
- Use snake_case names for plain TypeScript files.
- Use comments sparingly; code should be self-documenting.
- Use comments sparingly; aim to make code readable and self-documenting.
### JavaScript/TypeScript

1001
package-lock.json generated

File diff suppressed because it is too large Load Diff

6
src/lib/components/publications/PublicationFeed.svelte

@ -292,7 +292,7 @@ @@ -292,7 +292,7 @@
// Debounced search function
const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) {
if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents);
eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30;
@ -303,10 +303,6 @@ @@ -303,10 +303,6 @@
}, 300);
$effect(() => {
console.debug(
"[PublicationFeed] Search query effect triggered:",
props.searchQuery,
);
debouncedSearch(props.searchQuery);
});

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { naddrEncode } from "$lib/utils";
import { naddrEncode, neventEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays } from "$lib/ndk";
import { Card } from "flowbite-svelte";
@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { indexKind } from "$lib/consts";
const { event } = $props<{ event: NDKEvent }>();
@ -19,11 +20,16 @@ @@ -19,11 +20,16 @@
});
const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
const dTag = event.getMatchingTags("d")[0]?.[1];
const isIndexEvent = event.kind === indexKind;
if (dTag != null && isIndexEvent) {
// For index events with d tag, use naddr encoding
const naddr = naddrEncode(event, relays);
return `publication/naddr/${naddr}`;
} else {
return `publication?id=${naddrEncode(event, relays)}`;
// Fallback to d tag if available
return dTag ? `publication/d/${dTag}` : null;
}
});

4
src/lib/components/util/ContainingIndexes.svelte

@ -47,12 +47,12 @@ @@ -47,12 +47,12 @@
function navigateToIndex(indexEvent: NDKEvent) {
const dTag = getMatchingTags(indexEvent, "d")[0]?.[1];
if (dTag) {
goto(`/publication?d=${encodeURIComponent(dTag)}`);
goto(`/publication/d/${encodeURIComponent(dTag)}`);
} else {
// Fallback to naddr
try {
const naddr = naddrEncode(indexEvent, $activeInboxRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`);
goto(`/publication/naddr/${encodeURIComponent(naddr)}`);
} catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err);
}

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

@ -64,7 +64,7 @@ @@ -64,7 +64,7 @@
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
goto(`/publication/naddr/${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
}

133
src/lib/navigator/EventNetwork/Legend.svelte

@ -97,7 +97,13 @@ @@ -97,7 +97,13 @@
</script>
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}>
<button
class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1 w-full text-left border-none bg-none"
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggle() : null}
aria-expanded={expanded}
aria-controls="legend-content"
>
<h3 class="h-leather">Legend</h3>
<div class="pointer-events-none">
{#if expanded}
@ -106,13 +112,19 @@ @@ -106,13 +112,19 @@
<CaretDownOutline />
{/if}
</div>
</div>
</button>
{#if expanded}
<div class="space-y-4">
<div id="legend-content" class="space-y-4">
<!-- Node Types Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleNodeTypes}>
<button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={toggleNodeTypes}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleNodeTypes() : null}
aria-expanded={nodeTypesExpanded}
aria-controls="node-types-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Node Types</h4>
<div class="pointer-events-none">
{#if nodeTypesExpanded}
@ -121,9 +133,10 @@ @@ -121,9 +133,10 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if nodeTypesExpanded}
<div id="node-types-content">
<ul class="space-y-2">
<!-- Dynamic event kinds -->
{#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]}
@ -196,12 +209,19 @@ @@ -196,12 +209,19 @@
</li>
{/if}
</ul>
</div>
{/if}
</div>
<!-- Tag Anchor Controls Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => tagControlsExpanded = !tagControlsExpanded}>
<button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={() => tagControlsExpanded = !tagControlsExpanded}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (tagControlsExpanded = !tagControlsExpanded) : null}
aria-expanded={tagControlsExpanded}
aria-controls="tag-controls-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Tag Anchor Controls</h4>
<div class="pointer-events-none">
{#if tagControlsExpanded}
@ -210,10 +230,10 @@ @@ -210,10 +230,10 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if tagControlsExpanded}
<div class="space-y-3">
<div id="tag-controls-content" class="space-y-3">
<!-- Show Tag Anchors Toggle -->
<div class="flex items-center space-x-2">
<button
@ -221,7 +241,9 @@ @@ -221,7 +241,9 @@
showTagAnchors = !showTagAnchors;
onTagSettingsChange();
}}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showTagAnchors = !showTagAnchors, onTagSettingsChange()) : null}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showTagAnchors ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
aria-pressed={showTagAnchors}
>
{showTagAnchors ? 'ON' : 'OFF'}
</button>
@ -231,8 +253,9 @@ @@ -231,8 +253,9 @@
{#if showTagAnchors}
<!-- Tag Type Selection -->
<div>
<label class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label>
<label for="tag-type-select" class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label>
<select
id="tag-type-select"
bind:value={selectedTagType}
onchange={onTagSettingsChange}
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
@ -253,7 +276,13 @@ @@ -253,7 +276,13 @@
<!-- Tag Anchors section -->
{#if showTags && tagAnchors.length > 0}
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleTagAnchors}>
<button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={toggleTagAnchors}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleTagAnchors() : null}
aria-expanded={tagAnchorsExpanded}
aria-controls="tag-anchors-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Active Tag Anchors: {tagAnchors[0].type}</h4>
<div class="pointer-events-none">
{#if tagAnchorsExpanded}
@ -262,13 +291,14 @@ @@ -262,13 +291,14 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if tagAnchorsExpanded}
{@const sortedAnchors = tagSortMode === 'count'
? [...tagAnchors].sort((a, b) => b.count - a.count)
: [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label))
}
<div id="tag-anchors-content">
{#if autoDisabledTags}
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded">
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them.
@ -300,59 +330,45 @@ @@ -300,59 +330,45 @@
<span class="text-xs">Alphabetical</span>
</label>
</div>
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
onclick={invertTagSelection}
class="w-3 h-3"
/>
<span class="text-xs">Invert Selection</span>
</label>
</div>
<div
class="grid gap-1 {tagAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
>
{#each sortedAnchors as anchor}
{@const tagId = `${anchor.type}-${anchor.label}`}
{@const isDisabled = disabledTags.has(tagId)}
<div class="space-y-1 max-h-48 overflow-y-auto">
{#each sortedAnchors as tag}
{@const isDisabled = disabledTags.has(tag.value)}
<button
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => onTagToggle(tagId)}
title={isDisabled ? `Click to show ${anchor.label}` : `Click to hide ${anchor.label}`}
class="flex items-center justify-between w-full p-2 rounded text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => onTagToggle(tag.value)}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? onTagToggle(tag.value) : null}
disabled={false}
title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`}
aria-pressed={!isDisabled}
>
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
{tag.label} ({tag.count})
</span>
<div class="flex items-center">
<span
class="w-4.5 h-4.5 rounded-full border-2 border-white flex items-center justify-center"
style="background-color: {anchor.color}; opacity: {isDisabled ? 0.3 : 1};"
>
<span class="text-xs text-white font-bold">
{anchor.type === "t"
? "#"
: anchor.type === "author"
? "A"
: anchor.type.charAt(0).toUpperCase()}
</span>
</span>
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white"
style="background-color: {getEventKindColor(30040)}; opacity: {isDisabled ? 0.3 : 1};"
></span>
</div>
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" style="opacity: {isDisabled ? 0.5 : 1};" title={anchor.label}>
{anchor.label.length > 25 ? anchor.label.slice(0, 22) + '...' : anchor.label}
{#if !isDisabled}
<span class="text-gray-500 dark:text-gray-400">({anchor.count})</span>
{/if}
</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Person Visualizer Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => personVisualizerExpanded = !personVisualizerExpanded}>
<button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={() => personVisualizerExpanded = !personVisualizerExpanded}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (personVisualizerExpanded = !personVisualizerExpanded) : null}
aria-expanded={personVisualizerExpanded}
aria-controls="person-visualizer-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Person Visualizer</h4>
<div class="pointer-events-none">
{#if personVisualizerExpanded}
@ -361,10 +377,10 @@ @@ -361,10 +377,10 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if personVisualizerExpanded}
<div class="space-y-3">
<div id="person-visualizer-content" class="space-y-3">
<!-- Show Person Nodes Toggle -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
@ -373,7 +389,9 @@ @@ -373,7 +389,9 @@
showPersonNodes = !showPersonNodes;
onPersonSettingsChange();
}}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showPersonNodes = !showPersonNodes, onPersonSettingsChange()) : null}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showPersonNodes ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
aria-pressed={showPersonNodes}
>
{showPersonNodes ? 'ON' : 'OFF'}
</button>
@ -437,30 +455,23 @@ @@ -437,30 +455,23 @@
onPersonToggle(person.pubkey);
}
}}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showPersonNodes && onPersonToggle(person.pubkey)) : null}
disabled={!showPersonNodes}
title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`}
aria-pressed={!isDisabled}
>
<div class="flex items-center">
<span
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white"
style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};"
/>
></span>
</div>
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
{person.displayName || person.pubkey.slice(0, 8) + '...'}
{#if !isDisabled}
<span class="text-gray-500 dark:text-gray-400">
({person.signedByCount || 0}s/{person.referencedCount || 0}r)
</span>
{/if}
{person.displayName || person.pubkey.substring(0, 8)}
</span>
</button>
{/each}
</div>
{:else if showPersonNodes}
<p class="text-xs text-gray-500 dark:text-gray-400">
No people found in the current events.
</p>
{/if}
</div>
{/if}

2
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -188,7 +188,7 @@ @@ -188,7 +188,7 @@
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a href={getEventUrl(node)} class="tooltip-title-link">
<a href="/publication?id={node.id}" class="tooltip-title-link">
{getLinkText(node)}
</a>
</div>

39
src/lib/navigator/EventNetwork/Settings.svelte

@ -42,7 +42,13 @@ @@ -42,7 +42,13 @@
</script>
<div class="leather-legend sm:!right-1 sm:!left-auto">
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}>
<button
class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1 w-full text-left border-none bg-none"
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggle() : null}
aria-expanded={expanded}
aria-controls="settings-content"
>
<h3 class="h-leather">Settings</h3>
<div class="pointer-events-none">
{#if expanded}
@ -51,10 +57,10 @@ @@ -51,10 +57,10 @@
<CaretDownOutline />
{/if}
</div>
</div>
</button>
{#if expanded}
<div class="space-y-4">
<div id="settings-content" class="space-y-4">
<span class="leather bg-transparent legend-text">
Showing {count} of {totalCount} events
</span>
@ -63,9 +69,12 @@ @@ -63,9 +69,12 @@
<div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
>
<div
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2"
<button
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2 w-full text-left border-none bg-none"
onclick={toggleEventTypes}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleEventTypes() : null}
aria-expanded={eventTypesExpanded}
aria-controls="event-types-content"
>
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Event Configuration
@ -77,21 +86,24 @@ @@ -77,21 +86,24 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if eventTypesExpanded}
<div id="event-types-content">
<EventTypeConfig onReload={onupdate} {eventCounts} {profileStats} />
</div>
{/if}
</div>
<!-- Visual Settings Section -->
<div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
>
<div
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2"
<button
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2 w-full text-left border-none bg-none"
onclick={toggleVisualSettings}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleVisualSettings() : null}
aria-expanded={visualSettingsExpanded}
aria-controls="visual-settings-content"
>
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Visual Settings
@ -103,9 +115,9 @@ @@ -103,9 +115,9 @@
<CaretDownOutline class="w-3 h-3" />
{/if}
</div>
</div>
</button>
{#if visualSettingsExpanded}
<div id="visual-settings-content">
<div class="space-y-4">
<div class="space-y-2">
<label
@ -126,9 +138,8 @@ @@ -126,9 +138,8 @@
visualization
</p>
</div>
</div>
</div>
{/if}
</div>
</div>

8
src/lib/navigator/EventNetwork/TagTable.svelte

@ -12,12 +12,12 @@ @@ -12,12 +12,12 @@
}>();
// Computed property for unique tags
let uniqueTags = $derived(() => {
const tagMap = new Map();
let uniqueTags = $derived.by(() => {
const tagMap = new Map<string, { value: string; count: number; firstEvent: string }>();
events.forEach(event => {
events.forEach((event: NDKEvent) => {
const tags = event.tags || [];
tags.forEach(tag => {
tags.forEach((tag: string[]) => {
if (tag[0] === selectedTagType) {
const tagValue = tag[1];
const count = tagMap.get(tagValue)?.count || 0;

6
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -293,13 +293,15 @@ export function createPersonLinks( @@ -293,13 +293,15 @@ export function createPersonLinks(
connectionType = 'referenced';
}
return {
const link: PersonLink = {
source: anchor,
target: node,
isSequential: false,
connectionType,
};
}).filter(Boolean); // Remove undefineds
return link;
}).filter((link): link is PersonLink => link !== undefined); // Remove undefineds and type guard
});
debug("Created person links", { linkCount: links.length });

7
src/lib/ndk.ts

@ -6,7 +6,7 @@ import NDK, { @@ -6,7 +6,7 @@ import NDK, {
NDKUser,
NDKEvent,
} from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store";
import { writable, get, type Writable } from "svelte/store";
import {
loginStorageKey,
} from "./consts.ts";
@ -33,6 +33,11 @@ export const outboxRelays = writable<string[]>([]); @@ -33,6 +33,11 @@ export const outboxRelays = writable<string[]>([]);
export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]);
// Subscribe to userStore changes and update ndkSignedIn accordingly
userStore.subscribe((userState) => {
ndkSignedIn.set(userState.signedIn);
});
/**
* Custom authentication policy that handles NIP-42 authentication manually
* when the default NDK authentication fails

2
src/lib/stores/index.ts

@ -1,2 +0,0 @@ @@ -1,2 +0,0 @@
export * from './relayStore';
export * from './displayLimits';

56
src/lib/utils.ts

@ -1,6 +1,21 @@ @@ -1,6 +1,21 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils.ts";
import type { AddressPointer, EventPointer } from "nostr-tools/nip19";
export class DecodeError extends Error {
constructor(message: string) {
super(message);
this.name = "DecodeError";
}
}
export class InvalidKindError extends DecodeError {
constructor(message: string) {
super(message);
this.name = "InvalidKindError";
}
}
export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({
@ -29,6 +44,44 @@ export function nprofileEncode(pubkey: string, relays: string[]) { @@ -29,6 +44,44 @@ export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays });
}
/**
* Decodes a nostr identifier (naddr, nevent) and returns the decoded data.
* @param identifier The nostr identifier to decode.
* @param expectedType The expected type of the decoded data ('naddr' or 'nevent').
* @returns The decoded data.
*/
function decodeNostrIdentifier<T extends AddressPointer | EventPointer>(
identifier: string,
expectedType: "naddr" | "nevent",
): T {
try {
if (!identifier.startsWith(expectedType)) {
throw new InvalidKindError(`Invalid ${expectedType} format`);
}
const decoded = nip19.decode(identifier);
if (decoded.type !== expectedType) {
throw new InvalidKindError(`Decoded result is not an ${expectedType}`);
}
return decoded.data as T;
} catch (error) {
throw new DecodeError(`Failed to decode ${expectedType}: ${error}`);
}
}
/**
* Decodes an naddr identifier and returns the decoded data
*/
export function naddrDecode(naddr: string): AddressPointer {
return decodeNostrIdentifier<AddressPointer>(naddr, "naddr");
}
/**
* Decodes an nevent identifier and returns the decoded data
*/
export function neventDecode(nevent: string): EventPointer {
return decodeNostrIdentifier<EventPointer>(nevent, "nevent");
}
export function formatDate(unixtimestamp: number) {
const months = [
"Jan",
@ -169,7 +222,8 @@ Array.prototype.findIndexAsync = function <T>( @@ -169,7 +222,8 @@ Array.prototype.findIndexAsync = function <T>(
* @param wait The number of milliseconds to delay
* @returns A debounced version of the function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
// deno-lint-ignore no-explicit-any
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {

6
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -32,9 +32,11 @@ export async function postProcessAdvancedAsciidoctorHtml( @@ -32,9 +32,11 @@ export async function postProcessAdvancedAsciidoctorHtml(
}
if (
typeof globalThis !== "undefined" &&
typeof (globalThis.MathJax as any)?.typesetPromise === "function"
// deno-lint-ignore no-explicit-any
typeof (globalThis as any).MathJax?.typesetPromise === "function"
) {
setTimeout(() => (globalThis.MathJax as any).typesetPromise(), 0);
// deno-lint-ignore no-explicit-any
setTimeout(() => (globalThis as any).MathJax.typesetPromise(), 0);
}
return processedHtml;
} catch (error) {

10
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -24,9 +24,10 @@ function replaceWikilinks(html: string): string { @@ -24,9 +24,10 @@ function replaceWikilinks(html: string): string {
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./events?d=${normalized}`;
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
},
);
}
@ -37,8 +38,9 @@ function replaceWikilinks(html: string): string { @@ -37,8 +38,9 @@ function replaceWikilinks(html: string): string {
function replaceAsciiDocAnchors(html: string): string {
return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
const url = `/events?d=${normalized}`;
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${id}</a>`;
});
}

5
src/lib/utils/markup/basicMarkupParser.ts

@ -160,9 +160,10 @@ function replaceWikilinks(text: string): string { @@ -160,9 +160,10 @@ function replaceWikilinks(text: string): string {
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./events?d=${normalized}`;
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
// Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
},
);
}

4
src/lib/utils/network_detection.ts

@ -153,10 +153,12 @@ export function getRelaySetForNetworkCondition( @@ -153,10 +153,12 @@ export function getRelaySetForNetworkCondition(
*/
export function startNetworkMonitoring(
onNetworkChange: (condition: NetworkCondition) => void,
checkInterval: number = 60000 // Increased to 60 seconds to reduce spam
checkInterval: number = 60000, // Increased to 60 seconds to reduce spam
): () => void {
let lastCondition: NetworkCondition | null = null;
let intervalId: ReturnType<typeof setInterval> | null = null;
// deno-lint-ignore no-explicit-any
let intervalId: any = null;
const checkNetwork = async () => {
try {

11
src/lib/utils/nostrUtils.ts

@ -148,7 +148,7 @@ export function createProfileLink( @@ -148,7 +148,7 @@ export function createProfileLink(
const escapedText = escapeHtml(displayText || defaultText);
// Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
return `<a href="/events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
}
/**
@ -231,9 +231,9 @@ export async function createProfileLinkWithVerification( @@ -231,9 +231,9 @@ export async function createProfileLinkWithVerification(
const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) {
case "edu":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
return `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case "standard":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
return `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
}
}
/**
@ -245,7 +245,7 @@ function createNoteLink(identifier: string): string { @@ -245,7 +245,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`;
return `<a href="/events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`;
}
/**
@ -429,6 +429,9 @@ Promise.prototype.withTimeout = function <T>( @@ -429,6 +429,9 @@ Promise.prototype.withTimeout = function <T>(
return withTimeout(timeoutMs, this);
};
// TODO: Implement fetch for no-auth relays using the WebSocketPool and raw WebSockets.
// This fetch function will be used for server-side loading.
/**
* Fetches an event using a two-step relay strategy:
* 1. First tries standard relays with timeout

2
src/lib/utils/nostr_identifiers.ts

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
import { VALIDATION } from './search_constants';
import type { NostrEventId } from './nostr_identifiers';
/**
* Nostr identifier types
*/
export type NostrEventId = string; // 64-character hex string
export type NostrCoordinate = string; // kind:pubkey:d-tag format
export type NostrIdentifier = NostrEventId | NostrCoordinate;

143
src/lib/utils/websocket_utils.ts

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
import { WebSocketPool } from "../data_structures/websocket_pool.ts";
import { error } from "@sveltejs/kit";
import { naddrDecode, neventDecode } from "../utils.ts";
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
export interface NostrFilter {
ids?: string[];
authors?: string[];
kinds?: number[];
[tag: `#${string}`]: string[] | undefined;
since?: number;
until?: number;
limit?: number;
}
export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent> {
// TODO: Improve relay selection when relay management is implemented.
const ws = await WebSocketPool.instance.acquire("wss://thecitadel.nostr1.com");
const subId = crypto.randomUUID();
const res = new Promise<NostrEvent>((resolve, reject) => {
ws.addEventListener("message", (ev) => {
const data = JSON.parse(ev.data);
if (data[1] !== subId) {
return;
}
switch (data[0]) {
case "EVENT":
break;
case "CLOSED":
reject(new Error(`[WebSocket Utils]: Subscription ${subId} closed`));
break;
case "EOSE":
reject(new Error(`[WebSocket Utils]: Event not found`));
break;
}
const event = data[2] as NostrEvent;
if (!event) {
return;
}
resolve(event);
});
ws.addEventListener("error", (ev) => {
reject(ev);
});
}).withTimeout(2000);
ws.send(JSON.stringify(["REQ", subId, filter]));
return res;
}
/**
* Fetches an event by hex ID, throwing a SvelteKit 404 error if not found.
*/
export async function fetchEventById(id: string): Promise<NostrEvent> {
try {
const event = await fetchNostrEvent({ ids: [id], limit: 1 });
if (!event) {
throw error(404, `Event not found for ID: ${id}`);
}
return event;
} catch (err) {
if (err && typeof err === "object" && "status" in err) {
throw err;
}
throw error(404, `Failed to fetch event by ID: ${err}`);
}
}
/**
* Fetches an event by d tag, throwing a 404 if not found.
*/
export async function fetchEventByDTag(dTag: string): Promise<NostrEvent> {
try {
const event = await fetchNostrEvent({ "#d": [dTag], limit: 1 });
if (!event) {
throw error(404, `Event not found for d-tag: ${dTag}`);
}
return event;
} catch (err) {
if (err && typeof err === "object" && "status" in err) {
throw err;
}
throw error(404, `Failed to fetch event by d-tag: ${err}`);
}
}
/**
* Fetches an event by naddr identifier.
*/
export async function fetchEventByNaddr(naddr: string): Promise<NostrEvent> {
try {
const decoded = naddrDecode(naddr);
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
"#d": [decoded.identifier],
};
const event = await fetchNostrEvent(filter);
if (!event) {
throw error(404, `Event not found for naddr: ${naddr}`);
}
return event;
} catch (err) {
if (err && typeof err === "object" && "status" in err) {
throw err;
}
throw error(404, `Failed to fetch event by naddr: ${err}`);
}
}
/**
* Fetches an event by nevent identifier.
*/
export async function fetchEventByNevent(nevent: string): Promise<NostrEvent> {
try {
const decoded = neventDecode(nevent);
const event = await fetchNostrEvent({ ids: [decoded.id], limit: 1 });
if (!event) {
throw error(404, `Event not found for nevent: ${nevent}`);
}
return event;
} catch (err) {
if (err && typeof err === "object" && "status" in err) {
throw err;
}
throw error(404, `Failed to fetch event by nevent: ${err}`);
}
}

22
src/routes/+layout.ts

@ -8,14 +8,16 @@ import { loginMethodStorageKey } from "../lib/stores/userStore.ts"; @@ -8,14 +8,16 @@ import { loginMethodStorageKey } from "../lib/stores/userStore.ts";
import Pharos, { pharosInstance } from "../lib/parser.ts";
import type { LayoutLoad } from "./$types";
import { get } from "svelte/store";
import { browser } from "$app/environment";
// AI-NOTE: Leave SSR off until event fetches are implemented server-side.
export const ssr = false;
export const load: LayoutLoad = () => {
// Initialize NDK with new relay management system
const ndk = initNdk();
ndkInstance.set(ndk);
/**
* Attempts to restore the user's authentication session from localStorage.
* Handles extension, Amber (NIP-46), and npub login methods.
*/
function restoreAuthSession() {
try {
const pubkey = getPersistedLogin();
const loginMethod = localStorage.getItem(loginMethodStorageKey);
@ -113,6 +115,16 @@ export const load: LayoutLoad = () => { @@ -113,6 +115,16 @@ export const load: LayoutLoad = () => {
`Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
);
}
}
export const load: LayoutLoad = () => {
// Initialize NDK with new relay management system
const ndk = initNdk();
ndkInstance.set(ndk);
if (browser) {
restoreAuthSession();
}
const parser = new Pharos(ndk);
pharosInstance.set(parser);

4
src/routes/about/+page.svelte

@ -26,11 +26,11 @@ @@ -26,11 +26,11 @@
<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"
href="./publication/d/gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (markup). It is produced by the <A
href="./publication?d=gitcitadel-project-documentation-by-stella-v-1"
href="./publication/d/gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A
>.
</P>

6
src/routes/events/+page.svelte

@ -354,9 +354,9 @@ @@ -354,9 +354,9 @@
</script>
<div class="w-full flex justify-center">
<div class="flex w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<div class="flex flex-col lg:flex-row w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-80 min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class={showSidePanel ? "w-full lg:w-80 lg:min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
@ -775,7 +775,7 @@ @@ -775,7 +775,7 @@
<!-- Right Panel: Event Details -->
{#if showSidePanel && event}
<div class="flex-1 min-w-0 main-leather flex flex-col space-y-6">
<div class="w-full lg:flex-1 lg:min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button

5
src/routes/proxy+layout.ts

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async () => {
return {};
};

41
src/routes/publication/+page.server.ts

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
// Route pattern constants
const ROUTES = {
PUBLICATION_BASE: "/publication",
NADDR: "/publication/naddr",
NEVENT: "/publication/nevent",
ID: "/publication/id",
D_TAG: "/publication/d",
START: "/start",
} as const;
// Identifier prefixes
const IDENTIFIER_PREFIXES = {
NADDR: "naddr",
NEVENT: "nevent",
} as const;
export const load: PageServerLoad = ({ url }) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
// Handle backward compatibility for old query-based routes
if (id) {
// Check if id is an naddr or nevent
if (id.startsWith(IDENTIFIER_PREFIXES.NADDR)) {
throw redirect(301, `${ROUTES.NADDR}/${id}`);
} else if (id.startsWith(IDENTIFIER_PREFIXES.NEVENT)) {
throw redirect(301, `${ROUTES.NEVENT}/${id}`);
} else {
// Assume it's a hex ID
throw redirect(301, `${ROUTES.ID}/${id}`);
}
} else if (dTag) {
throw redirect(301, `${ROUTES.D_TAG}/${dTag}`);
}
// If no query parameters, redirect to the start page
throw redirect(301, ROUTES.START);
};

134
src/routes/publication/+page.svelte

@ -1,134 +0,0 @@ @@ -1,134 +0,0 @@
<script lang="ts">
import Publication from "$lib/components/publications/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageProps } from "./$types";
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 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",
);
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.
let image = $derived(
data.indexEvent?.getMatchingTags("image")[0]?.[1] ||
"/screenshots/old_books.jpg",
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
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>
<svelte:head>
<!-- Basic meta tags -->
<title>{title}</title>
<meta name="description" content={summary} />
<!-- OpenGraph meta tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={summary} />
<meta property="og:url" content={currentUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content={image} />
<!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={summary} />
<meta name="twitter:image" content={image} />
</svelte:head>
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
<main class="publication {data.publicationType}">
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>
</main>

115
src/routes/publication/+page.ts

@ -1,115 +0,0 @@ @@ -1,115 +0,0 @@
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 { getActiveRelaySetAsNDKRelaySet } from "../../lib/ndk.ts";
import { getMatchingTags } from "../../lib/utils/nostrUtils.ts";
import type NDK from "@nostr-dev-kit/ndk";
/**
* 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: NDK, 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: NDK, dTag: string): Promise<NDKEvent> {
try {
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); // true for inbox relays
const event = await ndk.fetchEvent(
{ "#d": [dTag] },
{ closeOnEose: false },
relaySet,
);
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}`);
}
}
// TODO: Use path params instead of query params.
export const load: Load = async ({
url,
parent,
}: {
url: URL;
parent: () => Promise<Partial<Record<string, NDK>>>;
}) => {
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.");
}
// Fetch the event based on available parameters
const indexEvent = id
? await fetchEventById(ndk!, id)
: await fetchEventByDTag(ndk!, dTag!);
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
return {
publicationType,
indexEvent,
};
};

28
src/routes/publication/[type]/[identifier]/+layout.server.ts

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
import { error } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ params, url }) => {
const { type, identifier } = params;
// Validate the identifier type for SSR
const validTypes = ['id', 'd', 'naddr', 'nevent'];
if (!validTypes.includes(type)) {
throw error(400, `Unsupported identifier type: ${type}`);
}
// Provide basic metadata for SSR - actual fetching will happen on client
const title = "Alexandria Publication";
const summary = "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
const image = "/screenshots/old_books.jpg";
const currentUrl = `${url.origin}${url.pathname}`;
return {
indexEvent: null, // Will be fetched on client side
metadata: {
title,
summary,
image,
currentUrl,
},
};
};

29
src/routes/publication/[type]/[identifier]/+layout.svelte

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
<script lang="ts">
import type { LayoutProps } from "./$types";
let { data, children }: LayoutProps = $props();
const { metadata } = data;
</script>
<!-- TODO: Provide fallback metadata values to use if the publication is on an auth-to-read relay. -->
<svelte:head>
<!-- Basic meta tags -->
<title>{metadata.title}</title>
<meta name="description" content={metadata.summary} />
<!-- OpenGraph meta tags -->
<meta property="og:title" content={metadata.title} />
<meta property="og:description" content={metadata.summary} />
<meta property="og:url" content={metadata.currentUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content={metadata.image} />
<!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metadata.title} />
<meta name="twitter:description" content={metadata.summary} />
<meta name="twitter:image" content={metadata.image} />
</svelte:head>
{@render children()}

65
src/routes/publication/[type]/[identifier]/+layout.ts

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
import { error } from "@sveltejs/kit";
import type { LayoutLoad } from "./$types";
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
import { browser } from "$app/environment";
export const load: LayoutLoad = async ({ params, url }) => {
const { type, identifier } = params;
// Only fetch on the client side where WebSocket is available
if (!browser) {
// Return basic metadata for SSR
return {
indexEvent: null,
metadata: {
title: "Alexandria Publication",
summary: "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
image: "/screenshots/old_books.jpg",
currentUrl: `${url.origin}${url.pathname}`,
},
};
}
let indexEvent: NostrEvent;
try {
// Handle different identifier types
switch (type) {
case 'id':
indexEvent = await fetchEventById(identifier);
break;
case 'd':
indexEvent = await fetchEventByDTag(identifier);
break;
case 'naddr':
indexEvent = await fetchEventByNaddr(identifier);
break;
case 'nevent':
indexEvent = await fetchEventByNevent(identifier);
break;
default:
throw error(400, `Unsupported identifier type: ${type}`);
}
// Extract metadata for meta tags
const title = indexEvent.tags.find((tag) => tag[0] === "title")?.[1] || "Alexandria Publication";
const summary = indexEvent.tags.find((tag) => tag[0] === "summary")?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
const image = indexEvent.tags.find((tag) => tag[0] === "image")?.[1] || "/screenshots/old_books.jpg";
const currentUrl = `${url.origin}${url.pathname}`;
return {
indexEvent,
metadata: {
title,
summary,
image,
currentUrl,
},
};
} catch (err) {
console.error('Failed to fetch publication:', err);
throw error(404, `Failed to load publication: ${err}`);
}
};

18
src/routes/publication/[type]/[identifier]/+page.server.ts

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
const { type, identifier } = params;
// Validate the identifier type for SSR
const validTypes = ['id', 'd', 'naddr', 'nevent'];
if (!validTypes.includes(type)) {
throw error(400, `Unsupported identifier type: ${type}`);
}
// Provide basic data for SSR - actual fetching will happen on client
return {
publicationType: "", // Will be determined on client side
indexEvent: null, // Will be fetched on client side
};
};

114
src/routes/publication/[type]/[identifier]/+page.svelte

@ -0,0 +1,114 @@ @@ -0,0 +1,114 @@
<script lang="ts">
import Publication from "$lib/components/publications/Publication.svelte";
import type { PageProps } from "./$types";
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";
import { createNDKEvent } from "$lib/utils/nostrUtils";
let { data }: PageProps = $props();
// data.indexEvent can be null from server-side rendering
// We need to handle this case properly
const indexEvent = data.indexEvent ? createNDKEvent(data.ndk, data.indexEvent) : null;
// Only create publication tree if we have a valid index event
const publicationTree = indexEvent ? new SveltePublicationTree(indexEvent, data.ndk) : null;
const toc = indexEvent ? new TableOfContents(
indexEvent.tagAddress(),
publicationTree!,
page.url.pathname ?? "",
) : null;
setContext("publicationTree", publicationTree);
setContext("toc", toc);
setContext("asciidoctor", Processor());
// Only set up bookmark handling if we have a valid publication tree
if (publicationTree && indexEvent) {
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 = `${indexEvent.tagAddress()}`;
store.put({ key: bookmarkKey, address });
};
});
}
onMount(() => {
// Only handle bookmarks if we have valid components
if (!publicationTree || !indexEvent) return;
// 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 = `${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(() => {
// TODO: Clean up resources if needed
});
</script>
{#if indexEvent && data.indexEvent}
<ArticleNav
publicationType={data.publicationType}
rootId={data.indexEvent.id}
indexEvent={indexEvent}
/>
<main class="publication {data.publicationType}">
<Publication
rootAddress={indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={indexEvent}
/>
</main>
{:else}
<main class="publication">
<div class="flex items-center justify-center min-h-screen">
<p class="text-gray-600 dark:text-gray-400">Loading publication...</p>
</div>
</main>
{/if}

54
src/routes/publication/[type]/[identifier]/+page.ts

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
import { error } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
import { browser } from "$app/environment";
export const load: PageLoad = async ({ params }) => {
const { type, identifier } = params;
// Only fetch on the client side where WebSocket is available
if (!browser) {
// Return basic data for SSR
return {
publicationType: "",
indexEvent: null,
};
}
let indexEvent: NostrEvent;
try {
// Handle different identifier types
switch (type) {
case 'id':
indexEvent = await fetchEventById(identifier);
break;
case 'd':
indexEvent = await fetchEventByDTag(identifier);
break;
case 'naddr':
indexEvent = await fetchEventByNaddr(identifier);
break;
case 'nevent':
indexEvent = await fetchEventByNevent(identifier);
break;
default:
throw error(400, `Unsupported identifier type: ${type}`);
}
if (!indexEvent) {
throw error(404, `Event not found for ${type}: ${identifier}`);
}
const publicationType = indexEvent.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
return {
publicationType,
indexEvent,
};
} catch (err) {
console.error('Failed to fetch publication:', err);
throw error(404, `Failed to load publication: ${err}`);
}
};

10
src/routes/start/+page.svelte

@ -91,7 +91,7 @@ @@ -91,7 +91,7 @@
<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"
href="/publication/d/jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</a
>
</P>
@ -127,7 +127,7 @@ @@ -127,7 +127,7 @@
<P class="mb-3">
An example of a research paper is <a
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
href="/publication/d/less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</a
>
</P>
@ -145,9 +145,9 @@ @@ -145,9 +145,9 @@
<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
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"
<a href="/publication/d/gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</a
>.
</P>
@ -168,7 +168,7 @@ @@ -168,7 +168,7 @@
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <button
class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/publication?d=sybil")}>Sybil utility</button
onclick={() => goto("/publication/d/sybil")}>Sybil utility</button
> use the same Asciidoc format as other publications but are specifically designed
for interconnected, evolving content.
</P>

Loading…
Cancel
Save