Browse Source

Merge branch 'master' of ssh://onedev.gitcitadel.eu:6611/Alexandria/gc-alexandria into issue#199#202

master
Silberengel 10 months ago
parent
commit
a81eea503e
  1. 62
      .cursor/rules/alexandria.mdc
  2. 3
      .onedev-buildspec.yml
  3. 5
      .vscode/settings.json
  4. 6
      README.md
  5. 3
      deno.lock
  6. 1906
      package-lock.json
  7. 9
      package.json
  8. 303
      src/app.css
  9. 8
      src/app.d.ts
  10. 6
      src/lib/components/EventLimitControl.svelte
  11. 6
      src/lib/components/EventRenderLevelLimit.svelte
  12. 13
      src/lib/components/Login.svelte
  13. 77
      src/lib/components/LoginModal.svelte
  14. 33
      src/lib/components/Navigation.svelte
  15. 175
      src/lib/components/Publication.svelte
  16. 120
      src/lib/components/PublicationSection.svelte
  17. 2
      src/lib/consts.ts
  18. 47
      src/lib/data_structures/lazy.ts
  19. 168
      src/lib/data_structures/publication_tree.ts
  20. 65
      src/lib/navigator/EventNetwork/Legend.svelte
  21. 114
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  22. 52
      src/lib/navigator/EventNetwork/Settings.svelte
  23. 401
      src/lib/navigator/EventNetwork/index.svelte
  24. 92
      src/lib/navigator/EventNetwork/types.ts
  25. 193
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  26. 178
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  27. 39
      src/lib/snippets/PublicationSnippets.svelte
  28. 55
      src/lib/utils/markup/MarkupInfo.md
  29. 389
      src/lib/utils/markup/advancedMarkupParser.ts
  30. 388
      src/lib/utils/markup/basicMarkupParser.ts
  31. 96
      src/lib/utils/mime.ts
  32. 182
      src/lib/utils/nostrUtils.ts
  33. 49
      src/lib/utils/npubCache.ts
  34. 9
      src/routes/+layout.svelte
  35. 134
      src/routes/about/+page.svelte
  36. 518
      src/routes/contact/+page.svelte
  37. 70
      src/routes/publication/+page.svelte
  38. 3
      src/routes/publication/+page.ts
  39. 174
      src/routes/start/+page.svelte
  40. 126
      src/routes/visualize/+page.svelte
  41. 90
      src/styles/publications.css
  42. 90
      src/styles/visualize.css
  43. 19
      src/types/d3.d.ts
  44. 22
      tailwind.config.cjs
  45. 99
      tests/integration/markupIntegration.test.ts
  46. 244
      tests/integration/markupTestfile.md
  47. 118
      tests/unit/advancedMarkupParser.test.ts
  48. 88
      tests/unit/basicMarkupParser.test.ts
  49. 3
      tests/unit/example.js
  50. 6
      tests/unit/example.unit-test.js
  51. 2
      vite.config.ts

62
.cursor/rules/alexandria.mdc

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
---
description:
globs:
alwaysApply: true
---
# Project Alexandria
You are senior full-stack software engineer with 20 years of experience writing web apps. You have been working with the Svelte web development framework for 8 years, since it was first released, and you currently are a leading expert on Svelte 5 and SvelteKit 2. Additionally, you are a pioneer developer on the Nostr protocol, and have developing production-quality Nostr apps for 4 years.
## 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.
### Tech Stack
Svelte components in Alexandria use TypeScript exclusively over plain JavaScript. Styles are defined via Tailwind 4 utility classes, and some custom utility classes are defined in [app.css](mdc:src/app.css). The app runs on Deno, but maintains compatibility with Node.js.
## General Guidelines
When responding to prompts, adhere to the following rules:
- Avoid making apologetic or conciliatory statements.
- Avoid verbose responses; be direct and to the point.
- Provide links to relevant documentation so that I can do further reading on the tools or techniques discussed and used in your responses.
- When I tell you a response is incorrect, avoid simply agreeing with me; think about the points raised and provide well-reasoned explanations for your subsequent responses.
- 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
Observe the following style guidelines when writing code:
### General Guidance
- Use PascalCase names for Svelte 5 components and their files.
- Use snake_case names for plain TypeScript files.
- Use comments sparingly; code should be self-documenting.
### JavaScript/TypeScript
- Use an indentation size of 2 spaces.
- Use camelCase names for variables, classes, and functions.
- Give variables, classes, and functions descriptive names that reflect their content and purpose.
- Use Svelte 5 features, such as runes. Avoid using legacy Svelte 4 features.
- Write JSDoc comments for all functions.
- Use blocks enclosed by curly brackets when writing control flow expressions such as `for` and `while` loops, and `if` and `switch` statements.
- Begin `case` expressions in a `switch` statement at the same indentation level as the `switch` itself. Indent code within a `case` block.
- Limit line length to 100 characters; break statements across lines if necessary.
- Default to single quotes.
### HTML
- Use an indentation size of 2 spaces.
- Break long tags across multiple lines.
- Use Tailwind 4 utility classes for styling.
- Default to single quotes.

3
.onedev-buildspec.yml

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
version: 38
version: 39
jobs:
- name: Github Push
steps:
@ -10,6 +10,7 @@ jobs: @@ -10,6 +10,7 @@ jobs:
condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL
triggers:
- !BranchUpdateTrigger {}
- !TagCreateTrigger {}
retryCondition: never
maxRetries: 3
retryDelay: 30

5
.vscode/settings.json vendored

@ -1,3 +1,6 @@ @@ -1,3 +1,6 @@
{
"editor.tabSize": 2
"editor.tabSize": 2,
"files.associations": {
"*.css": "postcss"
}
}

6
README.md

@ -7,7 +7,7 @@ For a thorough introduction, please refer to our [project documention](https://n @@ -7,7 +7,7 @@ For a thorough introduction, please refer to our [project documention](https://n
## Issues and Patches
If you would like to suggest a feature or report a bug, or submit a patch for review, please use the [Nostr git interface](https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyt8wumn8ghj7ur4wfcxcetjv4kxz7fwvdhk6tcqpfqkcetcv9hxgunfvyamcf5z) on our homepage.
If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact).
You can also contact us [on Nostr](https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly.
@ -115,3 +115,7 @@ For the Playwright end-to-end (e2e) tests: @@ -115,3 +115,7 @@ For the Playwright end-to-end (e2e) tests:
```bash
npx playwright test
```
## Markup Support
Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](src/lib/utils/markup/MarkupInfo.md).

3
deno.lock

@ -2887,10 +2887,11 @@ @@ -2887,10 +2887,11 @@
"npm:@sveltejs/adapter-auto@3",
"npm:@sveltejs/adapter-node@^5.2.12",
"npm:@sveltejs/adapter-static@3",
"npm:@sveltejs/kit@2",
"npm:@sveltejs/kit@^2.16.0",
"npm:@sveltejs/vite-plugin-svelte@4",
"npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5",
"npm:@types/d3@^7.4.3",
"npm:@types/he@1.2",
"npm:@types/node@22",
"npm:asciidoctor@3.0",

1906
package-lock.json generated

File diff suppressed because it is too large Load Diff

9
package.json

@ -22,6 +22,8 @@ @@ -22,6 +22,8 @@
"asciidoctor": "3.0.x",
"d3": "^7.9.0",
"he": "1.2.x",
"highlight.js": "^11.11.1",
"node-emoji": "^2.2.0",
"nostr-tools": "2.10.x"
},
"devDependencies": {
@ -29,8 +31,9 @@ @@ -29,8 +31,9 @@
"@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "2.x",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/node": "22.x",
"autoprefixer": "10.x",
@ -45,11 +48,11 @@ @@ -45,11 +48,11 @@
"prettier-plugin-svelte": "3.x",
"svelte": "5.x",
"svelte-check": "4.x",
"tailwind-merge": "^2.5.5",
"tailwind-merge": "^3.3.0",
"tailwindcss": "3.x",
"tslib": "2.8.x",
"typescript": "5.7.x",
"vite": "5.x",
"vitest": "^3.0.5"
"vitest": "^3.1.3"
}
}

303
src/app.css

@ -3,38 +3,36 @@ @@ -3,38 +3,36 @@
@import './styles/publications.css';
@import './styles/visualize.css';
@layer components {
/* General */
/* Custom styles */
@layer base {
.leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200;
}
.btn-leather.text-xs {
@apply w-7 h-7;
@apply px-2 py-1;
}
.btn-leather.text-xs svg {
@apply w-3 h-3;
@apply h-3 w-3;
}
.btn-leather.text-sm {
@apply w-8 h-8;
@apply px-3 py-2;
}
.btn-leather.text-sm svg {
@apply w-4 h-4;
@apply h-4 w-4;
}
div[role='tooltip'] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700;
}
/* Images */
.image-border {
@apply border border-primary-700;
}
/* Card */
div.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
@ -53,7 +51,6 @@ @@ -53,7 +51,6 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
}
/* Content */
main {
@apply max-w-full flex;
}
@ -79,7 +76,6 @@ @@ -79,7 +76,6 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Section headers */
h1.h-leather,
h2.h-leather,
h3.h-leather,
@ -113,7 +109,6 @@ @@ -113,7 +109,6 @@
@apply text-base font-semibold;
}
/* Modal */
div.modal-leather>div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
}
@ -153,23 +148,20 @@ @@ -153,23 +148,20 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
/* Sidebar */
aside.sidebar-leather>div {
@apply bg-gray-100 dark:bg-gray-900;
@apply bg-primary-0 dark:bg-primary-1000;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Skeleton */
div.skeleton-leather div {
@apply bg-gray-400 dark:bg-gray-600;
@apply bg-primary-100 dark:bg-primary-800;
}
/* Textarea */
div.textarea-leather {
@apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600;
@apply bg-primary-0 dark:bg-primary-1000;
}
div.textarea-leather>div:nth-child(1),
@ -178,7 +170,7 @@ @@ -178,7 +170,7 @@
}
div.textarea-leather>div:nth-child(2) {
@apply bg-gray-100 dark:bg-gray-900;
@apply bg-primary-0 dark:bg-primary-1000;
}
div.textarea-leather,
@ -186,36 +178,285 @@ @@ -186,36 +178,285 @@
@apply text-gray-800 dark:text-gray-300;
}
/* Tooltip */
div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300;
}
div[role='tooltip'] button.btn-leather .tooltip-leather {
@apply bg-gray-200 dark:bg-gray-700;
@apply bg-primary-100 dark:bg-primary-800;
}
/* Unordered list */
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
/* Network visualization */
.network-link-leather {
@apply stroke-gray-400 fill-gray-400;
@apply stroke-primary-200 fill-primary-200;
}
.network-node-leather {
@apply stroke-gray-800;
@apply stroke-primary-600;
}
.network-node-content {
@apply fill-[#d6c1a8];
@apply fill-primary-100;
}
}
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
.h3-leather {
@apply text-2xl font-bold;
}
.h4-leather {
@apply text-xl font-bold;
}
.h5-leather {
@apply text-lg font-semibold;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
}
@layer components {
/* Legend */
.leather-legend {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow
border border-gray-200 dark:border-gray-800;
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded;
@apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
/* Tooltip */
.tooltip-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 transition-colors duration-200;
max-width: 400px;
z-index: 1000;
}
.leather-legend button {
@apply dark:text-white;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
h1,
h2,
h3,
h4,
h5,
h6 {
@apply h-leather;
}
h1 {
@apply h1-leather;
}
h2 {
@apply h2-leather;
}
h3 {
@apply h3-leather;
}
h4 {
@apply h4-leather;
}
h5 {
@apply h5-leather;
}
h6 {
@apply h6-leather;
}
div {
@apply flex flex-col space-y-4;
}
.olist {
@apply flex flex-col space-y-4;
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
.ulist {
@apply flex flex-col space-y-4;
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
a {
@apply link;
}
.imageblock {
@apply flex flex-col items-center;
.title {
@apply text-sm text-center;
}
}
.stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
}
.literalblock {
pre {
@apply text-wrap;
}
}
table {
@apply w-full overflow-x-auto;
caption {
@apply text-sm;
}
thead,
tbody {
th,
td {
@apply border border-gray-200 dark:border-gray-700;
}
}
}
}
/* Footnotes */
.footnote-ref {
text-decoration: none;
color: var(--color-primary);
}
.footnotes {
margin-top: 2rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.footnotes hr {
margin: 1rem 0;
border-top: 1px solid var(--color-border);
}
.footnotes ol {
padding-left: 1rem;
}
.footnotes li {
margin-bottom: 0.5rem;
}
.footnote-backref {
text-decoration: none;
margin-left: 0.5rem;
color: var(--color-primary);
}
.note-leather .footnote-ref,
.note-leather .footnote-backref {
color: var(--color-leather-primary);
}
/* Scrollable content */
.description-textarea,
.prose-content {
overflow-y: scroll !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
}
.description-textarea {
min-height: 100% !important;
}
.description-textarea::-webkit-scrollbar,
.prose-content::-webkit-scrollbar {
width: 8px !important;
display: block !important;
}
.description-textarea::-webkit-scrollbar-track,
.prose-content::-webkit-scrollbar-track {
background: transparent !important;
}
.description-textarea::-webkit-scrollbar-thumb,
.prose-content::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5) !important;
border-radius: 4px !important;
}
.description-textarea::-webkit-scrollbar-thumb:hover,
.prose-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7) !important;
}
/* Tab content */
.tab-content {
position: relative;
display: flex;
flex-direction: column;
}
/* Input styles */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="number"],
input[type="tel"],
input[type="url"],
textarea {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-400 dark:focus:border-primary-500;
}
}

8
src/app.d.ts vendored

@ -1,14 +1,18 @@ @@ -1,14 +1,18 @@
// See https://kit.svelte.dev/docs/types#app
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import Pharos from "./lib/parser.ts";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface PageData {
ndk?: NDK;
parser?: Pharos;
waitable?: Promise<any>;
publicationType?: string;
indexEvent?: NDKEvent;
url?: URL;
}
// interface Platform {}
}

6
src/lib/components/EventLimitControl.svelte

@ -30,7 +30,7 @@ @@ -30,7 +30,7 @@
</script>
<div class="flex items-center gap-2 mb-4">
<label for="event-limit" class="text-sm font-medium"
<label for="event-limit" class="leather bg-transparent text-sm font-medium"
>Number of root events:
</label>
<input
@ -38,14 +38,14 @@ @@ -38,14 +38,14 @@
id="event-limit"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
on:input={handleInput}
on:keydown={handleKeyDown}
/>
<button
on:click={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

6
src/lib/components/EventRenderLevelLimit.svelte

@ -29,16 +29,16 @@ @@ -29,16 +29,16 @@
</script>
<div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="text-sm font-medium"
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium"
>Levels to render:
</label>
<label for="event-limit" class="text-sm font-medium">Limit: </label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label>
<input
type="number"
id="levels-to-render"
min="1"
max="50"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}

13
src/lib/components/Login.svelte

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
let npub = $state<string | undefined >(undefined);
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
$effect(() => {
if ($ndkSignedIn) {
@ -26,6 +27,9 @@ @@ -26,6 +27,9 @@
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
const user = await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
@ -36,7 +40,7 @@ @@ -36,7 +40,7 @@
} catch (e) {
console.error(e);
signInFailed = true;
// TODO: Show an error message to the user.
errorMessage = e instanceof Error ? e.message : 'Failed to sign in. Please try again.';
}
}
@ -52,12 +56,17 @@ @@ -52,12 +56,17 @@
placement='bottom'
triggeredBy='#avatar'
>
<div class='w-full flex space-x-2'>
<div class='w-full flex flex-col space-y-2'>
<Button
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
{#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}
</div>
{/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}

77
src/lib/components/LoginModal.svelte

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from '$lib/ndk';
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{
show?: boolean;
onClose?: () => void;
onLoginSuccess?: () => void;
}>();
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
$effect(() => {
if ($ndkSignedIn && show) {
onLoginSuccess();
onClose();
}
});
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
const user = await loginWithExtension();
if (!user) {
throw new Error('The NIP-07 extension did not return a user.');
}
} catch (e: unknown) {
console.error(e);
signInFailed = true;
errorMessage = (e as Error)?.message ?? 'Failed to sign in. Please try again.';
}
}
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50">
<div class="relative w-auto my-6 mx-auto max-w-3xl">
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none">
<!-- Header -->
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t">
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onclick={onClose}
>
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400 mb-6">
You need to be logged in to submit an issue. Your form data will be preserved.
</p>
<div class="flex flex-col space-y-4">
<div class="flex justify-center">
<Button
color="primary"
onclick={handleSignInClick}
>
Sign in with Extension
</Button>
</div>
{#if signInFailed}
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded">
{errorMessage}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}

33
src/lib/components/Navigation.svelte

@ -1,28 +1,37 @@ @@ -1,28 +1,37 @@
<script lang="ts">
import { DarkMode, Navbar, NavLi, NavUl, NavHamburger, NavBrand } from 'flowbite-svelte';
import Login from './Login.svelte';
import {
DarkMode,
Navbar,
NavLi,
NavUl,
NavHamburger,
NavBrand,
} from "flowbite-svelte";
import Login from "./Login.svelte";
let { class: className = '' } = $props();
let { class: className = "" } = $props();
let leftMenuOpen = $state(false);
</script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
<div class='flex flex-grow justify-between'>
<NavBrand href='/'>
<div class="flex flex-grow justify-between">
<NavBrand href="/">
<h1>Alexandria</h1>
</NavBrand>
</div>
<div class='flex md:order-2'>
<div class="flex md:order-2">
<Login />
<NavHamburger class='btn-leather' />
<NavHamburger class="btn-leather" />
</div>
<NavUl class='ul-leather'>
<NavLi href='/new/edit'>Publish</NavLi>
<NavLi href='/visualize'>Visualize</NavLi>
<NavLi href='/about'>About</NavLi>
<NavUl class="ul-leather">
<NavLi href="/new/edit">Publish</NavLi>
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>
<DarkMode btnClass='btn-leather p-0'/>
<DarkMode btnClass="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

175
src/lib/components/Publication.svelte

@ -1,22 +1,85 @@ @@ -1,22 +1,85 @@
<script lang="ts">
import Preview from "./Preview.svelte";
import { pharosInstance } from "$lib/parser";
import {
Alert,
Button,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
Skeleton,
TextPlaceholder,
Tooltip,
} from "flowbite-svelte";
import { getContext, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
let { rootId, publicationType, indexEvent } = $props<{
rootId: string,
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string,
publicationType: string,
indexEvent: NDKEvent
}>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
const publicationTree = getContext('publicationTree') as PublicationTree;
// #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 observer: IntersectionObserver;
async function loadMore(count: number) {
isLoading = true;
for (let i = 0; i < count; i++) {
const iterResult = await publicationTree.next();
const { done, value } = iterResult;
if (done) {
isDone = true;
break;
}
leaves.push(value);
}
isLoading = false;
}
function setLastElementRef(el: HTMLElement, i: number) {
if (i === leaves.length - 1) {
lastElementRef = el;
}
}
$effect(() => {
if (!lastElementRef) {
return;
}
if (isDone) {
observer?.unobserve(lastElementRef!);
return;
}
observer?.observe(lastElementRef!);
return () => observer?.unobserve(lastElementRef!);
});
// #endregion
// #region ToC
const tocBreakpoint = 1140;
let activeHash = $state(page.url.hash);
let currentBlog: null|string = $state(null);
@ -68,6 +131,100 @@ @@ -68,6 +131,100 @@
</div>
{/if}
// #endregion
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
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);
// Set up the intersection observer.
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(1);
}
});
}, { threshold: 0.5 });
loadMore(8);
return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect();
};
});
</script>
<!-- TODO: Keep track of already-loaded leaves. -->
<!-- TODO: Handle entering mid-document and scrolling up. -->
{#if showTocButton && !showToc}
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip> -->
{/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl pb-10 px-4 sm:px-6 md:px-8">
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class='flex space-x-2'>
<ExclamationCircleOutline class='w-5 h-5' />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">
Loading...
</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>
Show More
</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">You've reached the end of the publication.</p>
{/if}
</div>
</div>
<style>
:global(.sidebar-group-leather) {
max-height: calc(100vh - 8rem);

120
src/lib/components/PublicationSection.svelte

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
<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";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: Array<NDKEvent | null>,
ref: (ref: HTMLElement) => void,
} = $props();
const publicationTree: PublicationTree = 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 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(branch.getMatchingTags('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>

2
src/lib/consts.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ];
export enum FeedType {

47
src/lib/data_structures/lazy.ts

@ -1,16 +1,55 @@ @@ -1,16 +1,55 @@
export enum LazyStatus {
Pending,
Resolved,
Error,
}
export class Lazy<T> {
#value?: T;
#value: T | null = null;
#resolver: () => Promise<T>;
#pendingPromise: Promise<T | null> | null = null;
status: LazyStatus;
constructor(resolver: () => Promise<T>) {
this.#resolver = resolver;
this.status = LazyStatus.Pending;
}
async value(): Promise<T> {
if (!this.#value) {
this.#value = await this.#resolver();
/**
* Resolves the lazy object and returns the value.
*
* @returns The resolved value.
*
* @remarks Lazy object resolution is performed as an atomic operation. If a resolution has
* already been requested when this function is invoked, the pending promise from the earlier
* invocation is returned. Thus, all calls to this function before it is resolved will depend on
* a single resolution.
*/
value(): Promise<T | null> {
if (this.status === LazyStatus.Resolved) {
return Promise.resolve(this.#value);
}
if (this.#pendingPromise) {
return this.#pendingPromise;
}
this.#pendingPromise = this.#resolve();
return this.#pendingPromise;
}
async #resolve(): Promise<T | null> {
try {
this.#value = await this.#resolver();
this.status = LazyStatus.Resolved;
return this.#value;
} catch (error) {
this.status = LazyStatus.Error;
console.error(error);
return null;
} finally {
this.#pendingPromise = null;
}
}
}

168
src/lib/data_structures/publication_tree.ts

@ -4,19 +4,24 @@ import { Lazy } from "./lazy.ts"; @@ -4,19 +4,24 @@ import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts';
enum PublicationTreeNodeType {
Root,
Branch,
Leaf,
}
enum PublicationTreeNodeStatus {
Resolved,
Error,
}
interface PublicationTreeNode {
type: PublicationTreeNodeType;
status: PublicationTreeNodeStatus;
address: string;
parent?: PublicationTreeNode;
children?: Array<Lazy<PublicationTreeNode>>;
}
export class PublicationTree implements AsyncIterable<NDKEvent> {
export class PublicationTree implements AsyncIterable<NDKEvent | null> {
/**
* The root node of the tree.
*/
@ -50,7 +55,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -50,7 +55,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress();
this.#root = {
type: PublicationTreeNodeType.Root,
type: this.#getNodeType(rootEvent),
status: PublicationTreeNodeStatus.Resolved,
address: rootAddress,
children: [],
};
@ -85,6 +91,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -85,6 +91,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
const node: PublicationTreeNode = {
type: await this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
address,
parent: parentNode,
children: [],
@ -113,7 +120,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -113,7 +120,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
);
}
await this.#addNode(address, parentNode);
this.#addNode(address, parentNode);
}
/**
@ -135,7 +142,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -135,7 +142,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
* @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes.
*/
async getChildAddresses(address: string): Promise<string[]> {
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.`);
@ -143,7 +150,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -143,7 +150,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
return Promise.all(
node.children?.map(async child =>
(await child.value()).address
(await child.value())?.address ?? null
) ?? []
);
}
@ -206,20 +213,72 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -206,20 +213,72 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) {
throw new Error("Cursor: Target node is null or undefined.");
console.debug("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(0)?.value();
return true;
}
async tryMoveToLastChild(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
return false;
}
if (this.target.type === PublicationTreeNodeType.Leaf) {
return false;
}
this.target = (await this.target.children?.at(0)?.value())!;
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) {
throw new Error("Cursor: Target node is null or undefined.");
console.debug("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
);
if (currentIndex === -1) {
return false;
}
if (currentIndex + 1 >= siblings.length) {
return false;
}
this.target = await siblings.at(currentIndex + 1)?.value();
return true;
}
async tryMoveToPreviousSibling(): Promise<boolean> {
if (!this.target) {
console.debug("Cursor: Target node is null or undefined.");
return false;
}
const parent = this.target.parent;
@ -229,25 +288,25 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -229,25 +288,25 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
}
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;
}
const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null;
if (!nextSibling) {
if (currentIndex <= 0) {
return false;
}
this.target = nextSibling;
this.target = await siblings.at(currentIndex - 1)?.value();
return true;
}
tryMoveToParent(): boolean {
if (!this.target) {
throw new Error("Cursor: Target node is null or undefined.");
console.debug("Cursor: Target node is null or undefined.");
return false;
}
const parent = this.target.parent;
@ -264,35 +323,75 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -264,35 +323,75 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
// #region Async Iterator Implementation
[Symbol.asyncIterator](): AsyncIterator<NDKEvent> {
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> {
return this;
}
async next(): Promise<IteratorResult<NDKEvent>> {
// TODO: Add `previous()` method.
async next(): Promise<IteratorResult<NDKEvent | null>> {
if (!this.#cursor.target) {
await this.#cursor.tryMoveTo(this.#bookmark);
if (await this.#cursor.tryMoveTo(this.#bookmark)) {
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
}
}
// Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do {
if (await this.#cursor.tryMoveToFirstChild()) {
if (await this.#cursor.tryMoveToNextSibling()) {
while (await this.#cursor.tryMoveToFirstChild()) {
continue;
}
if (await this.#cursor.tryMoveToNextSibling()) {
continue;
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
if (this.#cursor.tryMoveToParent()) {
continue;
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event };
}
} while (this.#cursor.tryMoveToParent());
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
if (this.#cursor.target?.type === PublicationTreeNodeType.Root) {
// If we get to this point, we're at the root node (can't move up any more).
return { done: true, value: null };
}
} while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf);
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 };
}
}
// 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! };
return { done: false, value: event };
}
} while (this.#cursor.tryMoveToParent());
if (this.#cursor.target!.status === PublicationTreeNodeStatus.Error) {
return { done: false, value: null };
}
return { done: true, value: null };
}
// #endregion
@ -391,9 +490,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -391,9 +490,17 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
});
if (!event) {
throw new Error(
console.debug(
`PublicationTree: Event with address ${address} not found.`
);
return {
type: PublicationTreeNodeType.Leaf,
status: PublicationTreeNodeStatus.Error,
address,
parent: parentNode,
children: [],
};
}
this.#events.set(address, event);
@ -401,7 +508,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -401,7 +508,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const node: PublicationTreeNode = {
type: await this.#getNodeType(event),
type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
address,
parent: parentNode,
children: [],
@ -414,11 +522,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> { @@ -414,11 +522,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent> {
return node;
}
async #getNodeType(event: NDKEvent): Promise<PublicationTreeNodeType> {
if (event.tagAddress() === this.#root.address) {
return PublicationTreeNodeType.Root;
}
#getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) {
return PublicationTreeNodeType.Branch;
}

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

@ -1,30 +1,75 @@ @@ -1,30 +1,75 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts">
export let className: string = "";
import {Button} from 'flowbite-svelte';
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let {
collapsedOnInteraction = false,
className = ""
} = $props<{collapsedOnInteraction: boolean, className: string}>();
let expanded = $state(true);
$effect(() => {
if (collapsedOnInteraction) {
expanded = false;
}
});
function toggle() {
expanded = !expanded;
}
</script>
<div class="leather-legend {className}">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
{#if expanded}
<ul class="legend-list">
<!-- Index event node -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)">
</span>
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
>
<span class="legend-letter">I</span>
</span>
</div>
<span>Index events (kind 30040) - Each with a unique pastel color</span>
<span class="legend-text">Index events (kind 30040) - Each with a unique pastel color</span>
</li>
<!-- Content event node -->
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-circle content">
<span class="legend-letter">C</span>
</span>
</div>
<span>Content events (kinds 30041, 30818) - Publication sections</span>
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span>
</li>
<!-- Link arrow -->
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" />
<path
d="M4 12h16M16 6l6 6-6 6"
class="network-link-leather"
stroke-width="2"
fill="none"
/>
</svg>
<span>Arrows indicate reading/sequence order</span>
<span class="legend-text">Arrows indicate reading/sequence order</span>
</li>
</ul>
{/if}
</div>

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

@ -1,20 +1,33 @@ @@ -1,20 +1,33 @@
<!--
NodeTooltip Component
Displays detailed information about a node when hovering or clicking on it
in the event network visualization.
-->
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount, createEventDispatcher } from "svelte";
let { node, selected = false, x, y } = $props<{
node: NetworkNode;
selected?: boolean;
x: number;
y: number;
import { onMount } from "svelte";
// Component props
let { node, selected = false, x, y, onclose } = $props<{
node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip
y: number; // Y position for the tooltip
onclose: () => void; // Function to call when closing the tooltip
}>();
const dispatch = createEventDispatcher();
// DOM reference and positioning
let tooltipElement: HTMLDivElement;
let tooltipX = $state(x + 10);
let tooltipX = $state(x + 10); // Add offset to avoid cursor overlap
let tooltipY = $state(y - 10);
// Maximum content length to display
const MAX_CONTENT_LENGTH = 200;
/**
* Gets the author name from the event tags
*/
function getAuthorTag(node: NetworkNode): string {
if (node.event) {
const authorTags = node.event.getMatchingTags("author");
@ -25,6 +38,9 @@ @@ -25,6 +38,9 @@
return "Unknown";
}
/**
* Gets the summary from the event tags
*/
function getSummaryTag(node: NetworkNode): string | null {
if (node.event) {
const summaryTags = node.event.getMatchingTags("summary");
@ -35,6 +51,9 @@ @@ -35,6 +51,9 @@
return null;
}
/**
* Gets the d-tag from the event
*/
function getDTag(node: NetworkNode): string {
if (node.event) {
const dTags = node.event.getMatchingTags("d");
@ -45,40 +64,47 @@ @@ -45,40 +64,47 @@
return "View Publication";
}
function truncateContent(content: string, maxLength: number = 200): string {
/**
* Truncates content to a maximum length
*/
function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string {
if (!content) return "";
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
/**
* Closes the tooltip
*/
function closeTooltip() {
dispatch('close');
onclose();
}
// Ensure tooltip is fully visible on screen
/**
* Ensures tooltip is fully visible on screen
*/
onMount(() => {
if (tooltipElement) {
const rect = tooltipElement.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const padding = 10; // Padding from window edges
// Check if tooltip goes off the right edge
// Adjust position if tooltip goes off screen
if (rect.right > windowWidth) {
tooltipX = windowWidth - rect.width - 10;
tooltipX = windowWidth - rect.width - padding;
}
// Check if tooltip goes off the bottom edge
if (rect.bottom > windowHeight) {
tooltipY = windowHeight - rect.height - 10;
tooltipY = windowHeight - rect.height - padding;
}
// Check if tooltip goes off the left edge
if (rect.left < 0) {
tooltipX = 10;
tooltipX = padding;
}
// Check if tooltip goes off the top edge
if (rect.top < 0) {
tooltipY = 10;
tooltipY = padding;
}
}
});
@ -86,12 +112,12 @@ @@ -86,12 +112,12 @@
<div
bind:this={tooltipElement}
class="tooltip-leather fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-800
border border-gray-200 dark:border-gray-800 transition-colors duration-200"
style="left: {tooltipX}px; top: {tooltipY}px; z-index: 1000; max-width: 400px;"
class="tooltip-leather"
style="left: {tooltipX}px; top: {tooltipY}px;"
>
<!-- Close button -->
<button
class="absolute top-2 left-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
class="tooltip-close-btn"
onclick={closeTooltip}
aria-label="Close"
>
@ -99,34 +125,46 @@ @@ -99,34 +125,46 @@
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div class="space-y-2 pl-6">
<div class="font-bold text-base">
<a href="/publication?id={node.id}" class="text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500">
{node.title}
<!-- Tooltip content -->
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a
href="/publication?id={node.id}"
class="tooltip-title-link"
>
{node.title || "Untitled"}
</a>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
{node.type} ({node.kind})
<!-- Node type and kind -->
<div class="tooltip-metadata">
{node.type} (kind: {node.kind})
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
<!-- Author -->
<div class="tooltip-metadata">
Author: {getAuthorTag(node)}
</div>
<!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "", 200)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")}
</div>
{/if}
<!-- Content preview -->
{#if node.content}
<div
class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"
>
<div class="tooltip-content-preview">
{truncateContent(node.content)}
</div>
{/if}
<!-- Help text for selected nodes -->
{#if selected}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<div class="tooltip-help-text">
Click node again to dismiss
</div>
{/if}

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

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<!--
Settings Component
-->
<script lang="ts">
import {Button} from 'flowbite-svelte';
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
let {
count = 0,
onupdate
} = $props<{count: number, onupdate: () => void}>();
let expanded = $state(false);
function toggle() {
expanded = !expanded;
}
/**
* Handles updates to visualization settings
*/
function handleLimitUpdate() {
onupdate();
}
</script>
<div class="leather-legend sm:!right-1 sm:!left-auto" >
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Settings</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
{#if expanded}
<CaretUpOutline />
{:else}
<CaretDownOutline />
{/if}
</Button>
</div>
{#if expanded}
<div class="space-y-4">
<span class="leather bg-transparent legend-text">
Showing {count} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
{/if}
</div>

401
src/lib/navigator/EventNetwork/index.svelte

@ -1,40 +1,84 @@ @@ -1,40 +1,84 @@
<!-- EventNetwork.svelte -->
<!--
EventNetwork Component
A force-directed graph visualization of Nostr events, showing the relationships
between index events and their content. This component handles the D3 force
simulation, SVG rendering, and user interactions.
-->
<script lang="ts">
import { onMount } from "svelte";
import * as d3 from "d3";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder";
import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation";
import {
createSimulation,
setupDragHandlers,
applyGlobalLogGravity,
applyConnectedGravity,
type Simulation
} from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types";
import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte';
// Type alias for D3 selections
type Selection = any;
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const NODE_RADIUS = 20;
const LINK_DISTANCE = 10;
const ARROW_DISTANCE = 10;
const CONTENT_COLOR_LIGHT = "#d6c1a8";
const CONTENT_COLOR_DARK = "#FFFFFF";
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[EventNetwork]", ...args);
}
}
let { events = [] } = $props<{ events?: NDKEvent[] }>();
// Component props
let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>();
// Error state
let errorMessage = $state<string | null>(null);
let hasError = $derived(!!errorMessage);
// DOM references
let svg: SVGSVGElement;
let isDarkMode = $state(false);
let container: HTMLDivElement;
// Use a string ID for comparisons instead of the node object
// Theme state
let isDarkMode = $state(false);
// Tooltip state
let selectedNodeId = $state<string | null>(null);
let tooltipVisible = $state(false);
let tooltipX = $state(0);
let tooltipY = $state(0);
let tooltipNode = $state<NetworkNode | null>(null);
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
// Dimensions
let width = $state(1000);
let height = $state(600);
let windowHeight = $state<number | undefined>(undefined);
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
let simulation: d3.Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
// D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: Selection;
let zoomBehavior: any;
let svgElement: Selection;
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
// Track current render level
let currentLevels = $derived(levelsToRender);
// Update dimensions when container changes
$effect(() => {
@ -44,32 +88,40 @@ @@ -44,32 +88,40 @@
}
});
// Track levelsToRender changes
let currentLevels = $derived(levelsToRender);
/**
* Initializes the SVG graph structure
* Sets up the SVG element, zoom behavior, and arrow marker
*/
function initializeGraph() {
if (!svg) return;
debug("Initializing graph");
if (!svg) {
debug("SVG element not found");
return;
}
debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content
svgElement.selectAll("*").remove();
debug("Cleared SVG content");
// Create main group for zoom
svgGroup = svgElement.append("g");
debug("Created SVG group");
// Set up zoom behavior
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
zoomBehavior = d3
.zoom()
.scaleExtent([0.1, 9]) // Min/max zoom levels
.on("zoom", (event: any) => {
svgGroup.attr("transform", event.transform);
});
svgElement.call(zoom);
svgElement.call(zoomBehavior);
// Set up arrow marker
// Set up arrow marker for links
const defs = svgElement.append("defs");
defs
.append("marker")
@ -88,58 +140,116 @@ @@ -88,58 +140,116 @@
.attr("stroke-width", 1);
}
/**
* Updates the graph with new data
* Generates the graph from events, creates the simulation, and renders nodes and links
*/
function updateGraph() {
if (!svg || !events?.length || !svgGroup) return;
debug("Updating graph");
errorMessage = null;
// Create variables to hold our selections
let link: any;
let node: any;
let dragHandler: any;
let nodes: NetworkNode[] = [];
let links: NetworkLink[] = [];
try {
// Validate required elements
if (!svg) {
throw new Error("SVG element not found");
}
if (!events?.length) {
throw new Error("No events to render");
}
const { nodes, links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return;
if (!svgGroup) {
throw new Error("SVG group not found");
}
// Generate graph data from events
debug("Generating graph with events", {
eventCount: events.length,
currentLevels
});
const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes;
links = graphData.links;
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
});
if (!nodes.length) {
throw new Error("No nodes to render");
}
// Stop any existing simulation
if (simulation) simulation.stop();
if (simulation) {
debug("Stopping existing simulation");
simulation.stop();
}
// Create new simulation
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation);
debug("Creating new simulation");
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
// Center the nodes when the simulation is done
simulation.on("end", () => {
centerGraph();
});
// Create drag handler
dragHandler = setupDragHandlers(simulation);
// Update links
const link = svgGroup
.selectAll<SVGPathElement, NetworkLink>("path.link")
.data(links, d => `${d.source.id}-${d.target.id}`)
debug("Updating links");
link = svgGroup
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join(
enter => enter
(enter: any) => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
update => update,
exit => exit.remove()
(update: any) => update,
(exit: any) => exit.remove()
);
// Update nodes
const node = svgGroup
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, d => d.id)
debug("Updating nodes");
node = svgGroup
.selectAll("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
enter => {
(enter: any) => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
// Larger transparent circle for better drag handling
nodeEnter
.append("circle")
.attr("class", "drag-circle")
.attr("r", nodeRadius * 2.5)
.attr("r", NODE_RADIUS * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
// Visible circle
nodeEnter
.append("circle")
.attr("class", "visual-circle")
.attr("r", nodeRadius)
.attr("r", NODE_RADIUS)
.attr("stroke-width", 2);
// Node label
nodeEnter
.append("text")
.attr("dy", "0.35em")
@ -149,27 +259,29 @@ @@ -149,27 +259,29 @@
return nodeEnter;
},
update => update,
exit => exit.remove()
(update: any) => update,
(exit: any) => exit.remove()
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", d => !d.isContainer
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
)
.attr("fill", d => !d.isContainer
? isDarkMode ? "#FFFFFF" : "network-link-leather"
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
node.select("text")
.text(d => d.isContainer ? "I" : "C");
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Update node interactions
// Set up node interactions
debug("Setting up node interactions");
node
.on("mouseover", (event, d) => {
.on("mouseover", (event: any, d: NetworkNode) => {
if (!selectedNodeId) {
tooltipVisible = true;
tooltipNode = d;
@ -177,7 +289,7 @@ @@ -177,7 +289,7 @@
tooltipY = event.pageY;
}
})
.on("mousemove", (event, d) => {
.on("mousemove", (event: any) => {
if (!selectedNodeId) {
tooltipX = event.pageX;
tooltipY = event.pageY;
@ -189,15 +301,14 @@ @@ -189,15 +301,14 @@
tooltipNode = null;
}
})
.on("click", (event, d) => {
.on("click", (event: any, d: NetworkNode) => {
event.stopPropagation();
if (selectedNodeId === d.id) {
// Clicking the selected node again deselects it
selectedNodeId = null;
tooltipVisible = false;
tooltipNode = d;
tooltipX = event.pageX;
tooltipY = event.pageY;
} else {
// Select the node and show its tooltip
selectedNodeId = d.id;
tooltipVisible = true;
tooltipNode = d;
@ -206,21 +317,28 @@ @@ -206,21 +317,28 @@
}
});
// Handle simulation ticks
// Set up simulation tick handler
debug("Setting up simulation tick handler");
if (simulation) {
simulation.on("tick", () => {
// Apply custom forces to each node
nodes.forEach(node => {
// Pull nodes toward the center
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha());
// Pull connected nodes toward each other
applyConnectedGravity(node, links, simulation!.alpha());
});
// Update positions
link.attr("d", d => {
// Update link positions
link.attr("d", (d: NetworkLink) => {
// Calculate angle between source and target
const dx = d.target.x! - d.source.x!;
const dy = d.target.y! - d.source.y!;
const angle = Math.atan2(dy, dx);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance;
// Calculate start and end points with offsets for node radius
const sourceGap = NODE_RADIUS;
const targetGap = NODE_RADIUS + ARROW_DISTANCE;
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
@ -230,24 +348,40 @@ @@ -230,24 +348,40 @@
return `M${startX},${startY}L${endX},${endY}`;
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
// Update node positions
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`);
});
}
} catch (error) {
console.error("Error in updateGraph:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
}
}
/**
* Component lifecycle setup
*/
onMount(() => {
debug("Component mounted");
try {
// Detect initial theme
isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure
initializeGraph();
} catch (error) {
console.error("Error in onMount:", error);
errorMessage = `Error initializing graph: ${error instanceof Error ? error.message : String(error)}`;
}
// Handle window resizing
// Set up window resize handler
const handleResize = () => {
windowHeight = window.innerHeight;
};
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
// Set up theme change observer
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
@ -256,10 +390,10 @@ @@ -256,10 +390,10 @@
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll<SVGGElement, NetworkNode>("g.node")
svgGroup.selectAll("g.node")
.select("circle.visual-circle")
.attr("fill", d => !d.isContainer
? newIsDarkMode ? "#FFFFFF" : "network-link-leather"
.attr("fill", (d: NetworkNode) => !d.isContainer
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
);
}
@ -268,6 +402,7 @@ @@ -268,6 +402,7 @@
});
});
// Set up container resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
@ -275,19 +410,21 @@ @@ -275,19 +410,21 @@
}
if (svg) {
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Trigger simulation to adjust to new dimensions
// Restart simulation with new dimensions
if (simulation) {
simulation.alpha(0.3).restart();
}
}
});
// Start observers
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
resizeObserver.observe(container);
// Clean up on component destruction
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
@ -296,29 +433,150 @@ @@ -296,29 +433,150 @@
};
});
// Watch for changes that should trigger a graph update
/**
* Watch for changes that should trigger a graph update
*/
$effect(() => {
debug("Effect triggered", {
hasSvg: !!svg,
eventCount: events?.length,
currentLevels
});
try {
if (svg && events?.length) {
// Include currentLevels in the effect dependencies
const _ = currentLevels;
updateGraph();
}
} catch (error) {
console.error("Error in effect:", error);
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
}
});
/**
* Handles tooltip close event
*/
function handleTooltipClose() {
tooltipVisible = false;
selectedNodeId = null;
}
/**
* Centers the graph in the viewport
*/
function centerGraph() {
if (svg && svgGroup && zoomBehavior) {
const svgWidth = svg.clientWidth || width;
const svgHeight = svg.clientHeight || height;
// Reset zoom and center
d3.select(svg).transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8)
);
}
}
/**
* Zooms in the graph
*/
function zoomIn() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 1.3
);
}
}
/**
* Zooms out the graph
*/
function zoomOut() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 0.7
);
}
}
/**
* Legend interactions
*/
let graphInteracted = $state(false);
function handleGraphClick() {
if (!graphInteracted) {
graphInteracted = true;
}
}
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
<div class="network-container">
{#if hasError}
<div class="network-error">
<h3 class="network-error-title">Error</h3>
<p>{errorMessage}</p>
<button
class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }}
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
Retry
</button>
</div>
{/if}
<div class="network-svg-container" bind:this={container} role="figure">
<Legend collapsedOnInteraction={graphInteracted} className='' />
<!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} onupdate={onupdate} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
class="network-svg"
onclick={handleGraphClick}
/>
<!-- Zoom controls -->
<div class="network-controls">
<Button outline size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn}
aria-label="Zoom in"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomOut}
aria-label="Zoom out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph}
aria-label="Center graph"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</Button>
</div>
</div>
{#if tooltipVisible && tooltipNode}
@ -327,9 +585,8 @@ @@ -327,9 +585,8 @@
selected={tooltipNode.id === selectedNodeId}
x={tooltipX}
y={tooltipY}
on:close={handleTooltipClose}
onclose={handleTooltipClose}
/>
{/if}
<Legend />
</div>

92
src/lib/navigator/EventNetwork/types.ts

@ -1,35 +1,79 @@ @@ -1,35 +1,79 @@
/**
* Type definitions for the Event Network visualization
*
* This module defines the core data structures used in the D3 force-directed
* graph visualization of Nostr events.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export interface NetworkNode extends d3.SimulationNodeDatum {
id: string;
event?: NDKEvent;
level: number;
kind: number;
title: string;
content: string;
author: string;
type: "Index" | "Content";
naddr?: string;
nevent?: string;
x?: number;
y?: number;
isContainer?: boolean;
/**
* Base interface for nodes in a D3 force simulation
* Represents the physical properties of a node in the simulation
*/
export interface SimulationNodeDatum {
index?: number; // Node index in the simulation
x?: number; // X position
y?: number; // Y position
vx?: number; // X velocity
vy?: number; // Y velocity
fx?: number | null; // Fixed X position (when node is pinned)
fy?: number | null; // Fixed Y position (when node is pinned)
}
/**
* Base interface for links in a D3 force simulation
* Represents connections between nodes
*/
export interface SimulationLinkDatum<NodeType> {
source: NodeType | string | number; // Source node or identifier
target: NodeType | string | number; // Target node or identifier
index?: number; // Link index in the simulation
}
/**
* Represents a node in the event network visualization
* Extends the base simulation node with Nostr event-specific properties
*/
export interface NetworkNode extends SimulationNodeDatum {
id: string; // Unique identifier (event ID)
event?: NDKEvent; // Reference to the original NDK event
level: number; // Hierarchy level in the network
kind: number; // Nostr event kind (30040 for index, 30041/30818 for content)
title: string; // Event title
content: string; // Event content
author: string; // Author's public key
type: "Index" | "Content"; // Node type classification
naddr?: string; // NIP-19 naddr identifier
nevent?: string; // NIP-19 nevent identifier
isContainer?: boolean; // Whether this node is a container (index)
}
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
source: NetworkNode;
target: NetworkNode;
isSequential: boolean;
/**
* Represents a link between nodes in the event network
* Extends the base simulation link with event-specific properties
*/
export interface NetworkLink extends SimulationLinkDatum<NetworkNode> {
source: NetworkNode; // Source node (overridden to be more specific)
target: NetworkNode; // Target node (overridden to be more specific)
isSequential: boolean; // Whether this link represents a sequential relationship
}
/**
* Represents the complete graph data for visualization
*/
export interface GraphData {
nodes: NetworkNode[];
links: NetworkLink[];
nodes: NetworkNode[]; // All nodes in the graph
links: NetworkLink[]; // All links in the graph
}
/**
* Represents the internal state of the graph during construction
* Used to track relationships and build the final graph
*/
export interface GraphState {
nodeMap: Map<string, NetworkNode>;
links: NetworkLink[];
eventMap: Map<string, NDKEvent>;
referencedIds: Set<string>;
nodeMap: Map<string, NetworkNode>; // Maps event IDs to nodes
links: NetworkLink[]; // All links in the graph
eventMap: Map<string, NDKEvent>; // Maps event IDs to original events
referencedIds: Set<string>; // Set of event IDs referenced by other events
}

193
src/lib/navigator/EventNetwork/utils/forceSimulation.ts

@ -1,27 +1,100 @@ @@ -1,27 +1,100 @@
/**
* D3 force simulation utilities for the event network
* D3 Force Simulation Utilities
*
* This module provides utilities for creating and managing D3 force-directed
* graph simulations for the event network visualization.
*/
import type { NetworkNode, NetworkLink } from "../types";
import type { Simulation } from "d3";
import * as d3 from "d3";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const GRAVITY_STRENGTH = 0.05; // Strength of global gravity
const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes
/**
* Updates a node's velocity
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[ForceSimulation]", ...args);
}
}
/**
* Type definition for D3 force simulation
* Provides type safety for simulation operations
*/
export interface Simulation<NodeType, LinkType> {
nodes(): NodeType[];
nodes(nodes: NodeType[]): this;
alpha(): number;
alpha(alpha: number): this;
alphaTarget(): number;
alphaTarget(target: number): this;
restart(): this;
stop(): this;
tick(): this;
on(type: string, listener: (this: this) => void): this;
force(name: string): any;
force(name: string, force: any): this;
}
/**
* Type definition for D3 drag events
* Provides type safety for drag operations
*/
export interface D3DragEvent<GElement extends Element, Datum, Subject> {
active: number;
sourceEvent: any;
subject: Subject;
x: number;
y: number;
dx: number;
dy: number;
identifier: string | number;
}
/**
* Updates a node's velocity by applying a force
*
* @param node - The node to update
* @param deltaVx - Change in x velocity
* @param deltaVy - Change in y velocity
*/
export function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number
) {
debug("Updating node velocity", {
nodeId: node.id,
currentVx: node.vx,
currentVy: node.vy,
deltaVx,
deltaVy
});
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy });
} else {
debug("Node velocity not defined", { nodeId: node.id });
}
}
/**
* Applies a logarithmic gravity force to a node
* Applies a logarithmic gravity force pulling the node toward the center
*
* The logarithmic scale ensures that nodes far from the center experience
* stronger gravity, preventing them from drifting too far away.
*
* @param node - The node to apply gravity to
* @param centerX - X coordinate of the center
* @param centerY - Y coordinate of the center
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyGlobalLogGravity(
node: NetworkNode,
@ -35,102 +108,128 @@ export function applyGlobalLogGravity( @@ -35,102 +108,128 @@ export function applyGlobalLogGravity(
if (distance === 0) return;
const force = Math.log(distance + 1) * 0.05 * alpha;
const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Applies gravity between connected nodes
*
* This creates a cohesive force that pulls connected nodes toward their
* collective center of gravity, creating more meaningful clusters.
*
* @param node - The node to apply connected gravity to
* @param links - All links in the network
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
// Find all nodes connected to this node
const connectedNodes = links
.filter(
(link) => link.source.id === node.id || link.target.id === node.id,
)
.map((link) => (link.source.id === node.id ? link.target : link.source));
.filter(link => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source);
if (connectedNodes.length === 0) return;
const cogX = d3.mean(connectedNodes, (n) => n.x);
const cogY = d3.mean(connectedNodes, (n) => n.y);
// Calculate center of gravity of connected nodes
const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x);
const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y);
if (cogX === undefined || cogY === undefined) return;
// Calculate force direction and magnitude
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = distance * 0.3 * alpha;
// Apply force proportional to distance
const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
* Sets up drag behavior for nodes
*
* This enables interactive dragging of nodes in the visualization.
*
* @param simulation - The D3 force simulation
* @param warmupClickEnergy - Alpha target when dragging starts (0-1)
* @returns D3 drag behavior configured for the simulation
*/
export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9
) {
return d3
.drag<SVGGElement, NetworkNode>()
.on(
"start",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active)
.drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
}
// Fix node position at current location
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
if (!event.active) simulation.alphaTarget(0);
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);
}
// Release fixed position
d.fx = null;
d.fy = null;
},
);
});
}
/**
* Creates a D3 force simulation for the network
*
* @param nodes - Array of network nodes
* @param links - Array of network links
* @param nodeRadius - Radius of node circles
* @param linkDistance - Desired distance between linked nodes
* @returns Configured D3 force simulation
*/
export function createSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number
) {
return d3
.forceSimulation<NetworkNode>(nodes)
): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", {
nodeCount: nodes.length,
linkCount: links.length,
nodeRadius,
linkDistance
});
try {
// Create the simulation with nodes
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1)
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
.force("collide", d3.forceCollide().radius(nodeRadius * 4));
debug("Simulation created successfully");
return simulation;
} catch (error) {
console.error("Error creating simulation:", error);
throw error;
}
}

178
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -1,17 +1,49 @@ @@ -1,17 +1,49 @@
/**
* Network Builder Utilities
*
* This module provides utilities for building a network graph from Nostr events.
* It handles the creation of nodes and links, and the processing of event relationships.
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KIND = 30041;
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[NetworkBuilder]", ...args);
}
}
/**
* Creates a NetworkNode from an NDKEvent
*
* Extracts relevant information from the event and creates a node representation
* for the visualization.
*
* @param event - The Nostr event to convert to a node
* @param level - The hierarchy level of the node (default: 0)
* @returns A NetworkNode object representing the event
*/
export function createNetworkNode(
event: NDKEvent,
level: number = 0
): NetworkNode {
const isContainer = event.kind === 30040;
debug("Creating network node", { eventId: event.id, kind: event.kind, level });
const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content";
// Create the base node with essential properties
const node: NetworkNode = {
id: event.id,
event,
@ -20,13 +52,16 @@ export function createNetworkNode( @@ -20,13 +52,16 @@ export function createNetworkNode(
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "",
author: event.pubkey || "",
kind: event.kind,
type: event?.kind === 30040 ? "Index" : "Content",
kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined
type: nodeType,
};
// Add NIP-19 identifiers if possible
if (event.kind && event.pubkey) {
try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
// Create naddr (NIP-19 address) for the event
node.naddr = nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
@ -34,6 +69,7 @@ export function createNetworkNode( @@ -34,6 +69,7 @@ export function createNetworkNode(
relays: standardRelays,
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
relays: standardRelays,
@ -47,50 +83,93 @@ export function createNetworkNode( @@ -47,50 +83,93 @@ export function createNetworkNode(
return node;
}
/**
* Creates a map of event IDs to events for quick lookup
*
* @param events - Array of Nostr events
* @returns Map of event IDs to events
*/
export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
debug("Creating event map", { eventCount: events.length });
const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => {
if (event.id) {
eventMap.set(event.id, event);
}
});
debug("Event map created", { mapSize: eventMap.size });
return eventMap;
}
/**
* Extracts an event ID from an 'a' tag
*
* @param tag - The tag array from a Nostr event
* @returns The event ID or null if not found
*/
export function extractEventIdFromATag(tag: string[]): string | null {
return tag[3] || null;
}
/**
* Generates a color for an event based on its ID
* Generates a deterministic color for an event based on its ID
*
* This creates visually distinct colors for different index events
* while ensuring the same event always gets the same color.
*
* @param eventId - The event ID to generate a color for
* @returns An HSL color string
*/
export function getEventColor(eventId: string): string {
// Use first 4 characters of event ID as a hex number
const num = parseInt(eventId.slice(0, 4), 16);
// Convert to a hue value (0-359)
const hue = num % 360;
// Use fixed saturation and lightness for pastel colors
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
/**
* Initializes the graph state from a set of events
*
* Creates nodes for all events and identifies referenced events.
*
* @param events - Array of Nostr events
* @returns Initial graph state
*/
export function initializeGraphState(events: NDKEvent[]): GraphState {
debug("Initializing graph state", { eventCount: events.length });
const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create initial nodes
// Create initial nodes for all events
events.forEach((event) => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
debug("Node map created", { nodeCount: nodeMap.size });
// Build referenced IDs set
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
event.getMatchingTags("a").forEach((tag) => {
const aTags = event.getMatchingTags("a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length
});
aTags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
});
debug("Referenced IDs set created", { referencedCount: referencedIds.size });
return {
nodeMap,
@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
};
}
/**
* Processes a sequence of nodes referenced by an index event
*
* Creates links between the index and its content, and between sequential content nodes.
* Also processes nested indices recursively up to the maximum level.
*
* @param sequence - Array of nodes in the sequence
* @param indexEvent - The index event referencing the sequence
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processSequence(
sequence: NetworkNode[],
indexEvent: NDKEvent,
@ -107,14 +198,15 @@ export function processSequence( @@ -107,14 +198,15 @@ export function processSequence(
state: GraphState,
maxLevel: number,
): void {
// Stop if we've reached max level or have no nodes
if (level >= maxLevel || sequence.length === 0) return;
// Set levels for sequence nodes
// Set levels for all nodes in the sequence
sequence.forEach((node) => {
node.level = level + 1;
});
// Create initial link from index to first content
// Create link from index to first content node
const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) {
state.links.push({
@ -124,7 +216,7 @@ export function processSequence( @@ -124,7 +216,7 @@ export function processSequence(
});
}
// Create sequential links
// Create sequential links between content nodes
for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i];
const nextNode = sequence[i + 1];
@ -135,16 +227,27 @@ export function processSequence( @@ -135,16 +227,27 @@ export function processSequence(
isSequential: true,
});
// Process nested indices recursively
if (currentNode.isContainer) {
processNestedIndex(currentNode, level + 1, state, maxLevel);
}
}
// Process final node if it's an index
// Process the last node if it's an index
const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel);
}
}
/**
* Processes a nested index node
*
* @param node - The index node to process
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processNestedIndex(
node: NetworkNode,
level: number,
@ -159,6 +262,14 @@ export function processNestedIndex( @@ -159,6 +262,14 @@ export function processNestedIndex(
}
}
/**
* Processes an index event and its referenced content
*
* @param indexEvent - The index event to process
* @param level - Current hierarchy level
* @param state - Current graph state
* @param maxLevel - Maximum hierarchy level to process
*/
export function processIndexEvent(
indexEvent: NDKEvent,
level: number,
@ -167,6 +278,7 @@ export function processIndexEvent( @@ -167,6 +278,7 @@ export function processIndexEvent(
): void {
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = indexEvent
.getMatchingTags("a")
.map((tag) => extractEventIdFromATag(tag))
@ -177,19 +289,53 @@ export function processIndexEvent( @@ -177,19 +289,53 @@ export function processIndexEvent(
processSequence(sequence, indexEvent, level, state, maxLevel);
}
/**
* Generates a complete graph from a set of events
*
* This is the main entry point for building the network visualization.
*
* @param events - Array of Nostr events
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data for visualization
*/
export function generateGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state
const state = initializeGraphState(events);
// Process root indices
events
.filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id))
.forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel));
// Find root index events (those not referenced by other events)
const rootIndices = events.filter(
(e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id)
);
return {
debug("Found root indices", {
rootCount: rootIndices.length,
rootIds: rootIndices.map(e => e.id)
});
// Process each root index
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: rootIndex.getMatchingTags("a").length
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});
// Create the final graph data
const result = {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length
});
return result;
}

39
src/lib/snippets/PublicationSnippets.svelte

@ -5,41 +5,16 @@ @@ -5,41 +5,16 @@
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'>
{title}
</h6>
{/if}
</svelte:element>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
<section class='whitespace-normal publication-leather'>
{@html content}
</P>
{/if}
</section>
{/snippet}

55
src/lib/utils/markup/MarkupInfo.md

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
# Markup Support in Alexandria
Alexandria supports multiple markup formats for different use cases. Below is a summary of the supported tags and features for each parser, as well as the formats used for publications and wikis.
## Basic Markup Parser
The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports:
- **Headers:**
- ATX-style: `# H1` through `###### H6`
- Setext-style: `H1\n=====`
- **Bold:** `*bold*` or `**bold**`
- **Italic:** `_italic_` or `__italic__`
- **Strikethrough:** `~strikethrough~` or `~~strikethrough~~`
- **Blockquotes:** `> quoted text`
- **Unordered lists:** `* item`
- **Ordered lists:** `1. item`
- **Links:** `[text](url)`
- **Images:** `![alt](url)`
- **Hashtags:** `#hashtag`
- **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without `nostr:` prefix (note is deprecated)
- **Emoji shortcodes:** `:smile:` will render as 😄
## Advanced Markup Parser
The **advanced markup parser** includes all features of the basic parser, plus:
- **Inline code:** `` `code` ``
- **Syntax highlighting:** for code blocks in many programming languages (from [highlight.js](https://highlightjs.org/))
- **Tables:** Pipe-delimited tables with or without headers
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.)
## Publications and Wikis
**Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary markup language, not Markdown.
AsciiDoc supports a much broader set of formatting, semantic, and structural features, including:
- Section and document structure
- Advanced tables, callouts, admonitions
- Cross-references, footnotes, and bibliography
- Custom attributes and macros
- And much more
For more information on AsciiDoc, see the [AsciiDoc documentation](https://asciidoc.org/).
---
**Note:**
- The markdown parsers are primarily used for comments, issues, and other user-generated content.
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility.
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format.
- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted.

389
src/lib/utils/markup/advancedMarkupParser.ts

@ -0,0 +1,389 @@ @@ -0,0 +1,389 @@
import { parseBasicmarkup } from './basicMarkupParser';
import hljs from 'highlight.js';
import 'highlight.js/lib/common'; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only
// Register common languages
hljs.configure({
ignoreUnescapedHTML: true
});
// Regular expressions for advanced markup elements
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm;
const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
const CODE_BLOCK_REGEX = /^```(\w*)$/;
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Tailwind classes for each heading level
const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
];
// Process ATX-style headings (# Heading)
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
const headingLevel = Math.min(level.length, 6);
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
return processedContent;
}
/**
* Process tables
*/
function processTables(content: string): string {
try {
if (!content) return '';
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
.split('|')
.slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim());
};
// Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows
let headerCells: string[] = [];
let bodyRows: string[] = [];
if (hasHeader) {
// If we have a header, first row is header, skip delimiter, rest is body
headerCells = processCells(rows[0]);
bodyRows = rows.slice(2);
} else {
// No header, all rows are body
bodyRows = rows;
}
// Build table HTML
let html = '<div class="overflow-x-auto my-4">\n';
html += '<table class="min-w-full border-collapse">\n';
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
});
html += '</tr>\n</thead>\n';
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
});
html += '</tr>\n';
});
html += '</tbody>\n</table>\n</div>';
return html;
} catch (e: unknown) {
console.error('Error processing table row:', e);
return match;
}
});
} catch (e: unknown) {
console.error('Error in processTables:', e);
return content;
}
}
/**
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
);
}
/**
* Process footnotes
*/
function processFootnotes(content: string): string {
try {
if (!content) return '';
// Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map<string, string>();
content.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => {
footnotes.set(id, text.trim());
return match;
});
// Remove all footnote definition lines from the main content
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, '');
// Track all references to each footnote
const referenceOrder: { id: string, refNum: number, label: string }[] = [];
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
return match;
}
const refNum = globalRefNum++;
if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
});
// Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference
const seen = new Set<string>();
for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue;
seen.add(id);
const text = footnotes.get(id) || '';
// List of backrefs for this footnote
const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`
).join(' ');
// If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : '';
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
}
processedContent += '</ol>';
}
return processedContent;
} catch (error) {
console.error('Error processing footnotes:', error);
return content;
}
}
/**
* Process blockquotes
*/
function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split('\n')
.map(line => line.replace(/^>[ \t]?/, ''))
.join('\n')
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
});
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let blockCount = 0;
let lastWasCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(CODE_BLOCK_REGEX);
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
inCodeBlock = true;
currentLanguage = codeBlockStart[1];
currentCode = [];
lastWasCodeBlock = true;
} else {
// Ending current code block
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push(''); // Add spacing before code block
processedLines.push(id);
processedLines.push(''); // Add spacing after code block
inCodeBlock = false;
currentCode = [];
currentLanguage = '';
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
if (lastWasCodeBlock && line.trim()) {
processedLines.push('');
lastWasCodeBlock = false;
}
processedLines.push(line);
}
}
// Handle unclosed code block
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push('');
processedLines.push(id);
processedLines.push('');
}
return {
text: processedLines.join('\n'),
blocks
};
}
/**
* Restore code blocks with proper formatting
*/
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
let result = text;
for (const [id, blockData] of blocks) {
try {
const { code, language } = JSON.parse(blockData);
let html;
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, {
language,
ignoreIllegals: true
}).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
}
result = result.replace(id, html);
} catch (e: unknown) {
console.error('Error restoring code block:', e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
}
}
return result;
}
/**
* Parse markup text with advanced formatting
*/
export async function parseAdvancedmarkup(text: string): Promise<string> {
if (!text) return '';
try {
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode;
// Step 2: Process block-level elements
processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText);
// Process inline elements
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
// Process footnotes (only references, not definitions)
processedText = processFootnotes(processedText);
// Process basic markup (which will also handle Nostr identifiers)
processedText = await parseBasicmarkup(processedText);
// Step 3: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (e: unknown) {
console.error('Error in parseAdvancedmarkup:', e);
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
}
}

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

@ -0,0 +1,388 @@ @@ -0,0 +1,388 @@
import { processNostrIdentifiers } from '../nostrUtils';
import * as emoji from 'node-emoji';
import { nip19 } from 'nostr-tools';
/* Regex constants for basic markup parsing */
// Text formatting
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
// Block elements
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
});
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, append (View here: nostr:<id>) if a Nostr identifier is present
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `${url} (View here: nostr:${nevent})`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `${url} (View here: nostr:${bech32Match[0]})`;
}
return url;
});
return text;
}
// Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string {
// List of tracking params to remove
const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i];
try {
// Absolute URL
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
const parsed = new URL(url);
trackingParams.forEach(pattern => {
for (const key of Array.from(parsed.searchParams.keys())) {
if (pattern.test(key)) {
parsed.searchParams.delete(key);
}
}
});
const queryString = parsed.searchParams.toString();
return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || '');
} else {
// Relative URL: parse query string manually
const [path, queryAndHash = ''] = url.split('?');
const [query = '', hash = ''] = queryAndHash.split('#');
if (!query) return url;
const params = query.split('&').filter(Boolean);
const filtered = params.filter(param => {
const [key] = param.split('=');
return !trackingParams.some(pattern => pattern.test(key));
});
const queryString = filtered.length ? '?' + filtered.join('&') : '';
const hashString = hash ? '#' + hash : '';
return path + queryString + hashString;
}
} catch {
return url;
}
}
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function replaceWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./publication?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>`;
});
}
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string {
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] {
let html = '';
let i = start;
html += `<${type} class="${type === 'ol' ? 'list-decimal' : 'list-disc'} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, ' ').length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? 'ol' : 'ul';
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, '') + nestedHtml + '</li>';
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, ' ').length;
const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul';
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += '</li>';
i++;
}
html += `</${type}>`;
return [html, i];
}
if (!lines.length) return '';
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, ' ').length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul');
const [html] = parseList(0, indent, type);
return html;
}
function processBasicFormatting(content: string): string {
if (!content) return '';
let processedText = content;
try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => {
url = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = extractYouTubeVideoId(url);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(url)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || 'Video'}</video>`;
}
if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) {
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`;
});
// Process markup links
processedText = processedText.replace(MARKUP_LINK, (match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`
);
// Process WebSocket URLs
processedText = processedText.replace(WSS_URL, match => {
// Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, '');
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, match => {
const clean = stripTrackingParams(match);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) {
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
});
// Process text formatting
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>');
processedText = processedText.replace(ITALIC_REGEX, match => {
const text = match.replace(/^_+|_+$/g, '');
return `<em>${text}</em>`;
});
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
});
// Process hashtags
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-primary-600 dark:text-primary-500">#$1</span>');
// --- Improved List Grouping and Parsing ---
const lines = processedText.split('\n');
let output = '';
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul');
buffer = [];
inList = false;
}
output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n';
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul');
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error('Error in processBasicFormatting:', e);
}
return processedText;
}
// Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
function processBlockquotes(content: string): string {
try {
if (!content) return '';
return content.replace(BLOCKQUOTE_REGEX, match => {
const lines = match.split('\n').map(line => {
return line.replace(/^[ \t]*>[ \t]?/, '').trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join('\n')
}</blockquote>`;
});
} catch (e: unknown) {
console.error('Error in processBlockquotes:', e);
return content;
}
}
function processEmojiShortcuts(content: string): string {
try {
return emoji.emojify(content, { fallback: (name: string) => {
const emojiChar = emoji.get(name);
return emojiChar || `:${name}:`;
}});
} catch (e: unknown) {
console.error('Error in processEmojiShortcuts:', e);
return content;
}
}
export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return '';
try {
// Process basic text formatting first
let processedText = processBasicFormatting(text);
// Process emoji shortcuts
processedText = processEmojiShortcuts(processedText);
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags
processedText = processedText
.split(/\n\n+/)
.map(para => para.trim())
.filter(para => para.length > 0)
.map(para => `<p class="my-4">${para}</p>`)
.join('\n');
// Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText);
// Replace wikilinks
processedText = replaceWikilinks(processedText);
return processedText;
} catch (e: unknown) {
console.error('Error in parseBasicmarkup:', e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
}
}

96
src/lib/utils/mime.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/**
* Determine the type of Nostr event based on its kind number
* Following NIP specification for kind ranges:
* - Replaceable: 0, 3, 10000-19999 (only latest stored)
* - Ephemeral: 20000-29999 (not stored)
* - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays)
*/
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
// Check special ranges first
if (kind >= 30000 && kind < 40000) {
return 'addressable';
}
if (kind >= 20000 && kind < 30000) {
return 'ephemeral';
}
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) {
return 'replaceable';
}
// Everything else is regular
return 'regular';
}
/**
* Get MIME tags for a Nostr event based on its kind number
* Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]]
* Following NKBIP-06 and NIP-94 specifications
*/
export function getMimeTags(kind: number): [string, string][] {
// Default tags for unknown kinds
let mTag: [string, string] = ["m", "text/plain"];
let MTag: [string, string] = ["M", "note/generic/nonreplaceable"];
// Determine replaceability based on event type
const eventType = getEventType(kind);
const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
? "replaceable"
: "nonreplaceable";
switch (kind) {
// Short text note
case 1:
mTag = ["m", "text/plain"];
MTag = ["M", `note/microblog/${replaceability}`];
break;
// Generic reply
case 1111:
mTag = ["m", "text/plain"];
MTag = ["M", `note/comment/${replaceability}`];
break;
// Issue
case 1621:
mTag = ["m", "text/markup"];
MTag = ["M", `git/issue/${replaceability}`];
break;
// Issue comment
case 1622:
mTag = ["m", "text/markup"];
MTag = ["M", `git/comment/${replaceability}`];
break;
// Book metadata
case 30040:
mTag = ["m", "application/json"];
MTag = ["M", `meta-data/index/${replaceability}`];
break;
// Book content
case 30041:
mTag = ["m", "text/asciidoc"];
MTag = ["M", `article/publication-content/${replaceability}`];
break;
// Wiki page
case 30818:
mTag = ["m", "text/asciidoc"];
MTag = ["M", `article/wiki/${replaceability}`];
break;
// Long-form note
case 30023:
mTag = ["m", "text/markup"];
MTag = ["M", `article/long-form/${replaceability}`];
break;
// Add more cases as needed...
}
return [mTag, MTag];
}

182
src/lib/utils/nostrUtils.ts

@ -0,0 +1,182 @@ @@ -0,0 +1,182 @@
import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
/**
* HTML escape a string
*/
function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
}
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
if (npubCache.has(cleanId)) {
return npubCache.get(cleanId)!;
}
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
npubCache.set(cleanId, fallback);
return fallback;
}
const decoded = nip19.decode(cleanId);
if (!decoded) {
npubCache.set(cleanId, fallback);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(cleanId, fallback);
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(cleanId, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(cleanId, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
}
/**
* Create a profile link element
*/
function createProfileLink(identifier: string, displayText: string | undefined): string {
const cleanId = identifier.replace(/^nostr:/, '');
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`;
}
/**
* Create a note link element
*/
function createNoteLink(identifier: string): string {
const cleanId = identifier.replace(/^nostr:/, '');
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
}
/**
* Process Nostr identifiers in text
*/
export async function processNostrIdentifiers(content: string): Promise<string> {
let processedContent = content;
// Helper to check if a match is part of a URL
function isPartOfUrl(text: string, index: number): boolean {
// Look for http(s):// or www. before the match
const before = text.slice(Math.max(0, index - 12), index);
return /https?:\/\/$|www\.$/i.test(before);
}
// Process profiles (npub and nprofile)
const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX));
for (const match of profileMatches) {
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
if (isPartOfUrl(content, matchIndex)) {
continue; // skip if part of a URL
}
let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) {
identifier = 'nostr:' + identifier;
}
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
processedContent = processedContent.replace(fullMatch, link);
}
// Process notes (nevent, note, naddr)
const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX));
for (const match of noteMatches) {
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
if (isPartOfUrl(processedContent, matchIndex)) {
continue; // skip if part of a URL
}
let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) {
identifier = 'nostr:' + identifier;
}
const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link);
}
return processedContent;
}
export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.error('NDK not initialized');
return null;
}
const user = await ndk.getUser({ nip05 });
if (!user || !user.npub) {
return null;
}
return user.npub;
} catch (error) {
console.error('Error getting npub from nip05:', error);
return null;
}
}

49
src/lib/utils/npubCache.ts

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
export type NpubMetadata = { name?: string; displayName?: string };
class NpubCache {
private cache: Record<string, NpubMetadata> = {};
get(key: string): NpubMetadata | undefined {
return this.cache[key];
}
set(key: string, value: NpubMetadata): void {
this.cache[key] = value;
}
has(key: string): boolean {
return key in this.cache;
}
delete(key: string): boolean {
if (key in this.cache) {
delete this.cache[key];
return true;
}
return false;
}
deleteMany(keys: string[]): number {
let deleted = 0;
for (const key of keys) {
if (this.delete(key)) {
deleted++;
}
}
return deleted;
}
clear(): void {
this.cache = {};
}
size(): number {
return Object.keys(this.cache).length;
}
getAll(): Record<string, NpubMetadata> {
return { ...this.cache };
}
}
export const npubCache = new NpubCache();

9
src/routes/+layout.svelte

@ -3,6 +3,8 @@ @@ -3,6 +3,8 @@
import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
@ -42,5 +44,12 @@ @@ -42,5 +44,12 @@
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' />
<Alert rounded={false} class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-4'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
<p>Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.</p>
<p>New to Alexandria? Check out our <a href="/start" class='text-primary-600 dark:text-primary-400 hover:underline'>Getting Started guide</a> to learn more about using the library.</p>
</span>
</Alert>
<slot />
</div>

134
src/routes/about/+page.svelte

@ -1,118 +1,58 @@ @@ -1,118 +1,58 @@
<script lang='ts'>
<script lang="ts">
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || 'development';
const isVersionKnown = appVersion !== 'development';
const appVersion = import.meta.env.APP_VERSION || "development";
const isVersionKnown = appVersion !== "development";
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4'>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<Heading tag='h1' class='h-leather mb-2'>About the Library of Alexandria</Heading>
<Heading tag="h1" class="h-leather mb-2"
>About the Library of Alexandria</Heading
>
{#if isVersionKnown}
<span class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap">Version: {appVersion}</span>
<span
class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap"
>Version: {appVersion}</span
>
{/if}
</div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="mb-3">
Alexandria is a reader and writer for <A href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1">curated publications</A> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the <A href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1">GitCitadel project team</A>.
Alexandria is a reader and writer for <A
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (markup). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1"
>GitCitadel project team</A
>.
</P>
<P class="mb-3">
Please submit support issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page</A> and follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
Please submit support issues on the <A href="/contact"
>Alexandria contact page</A
> and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"
>Geyserfund</A
>.
</P>
<P>
We are easiest to contact over our Nostr address <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A>.
We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank">GitCitadel</A
>. Or, you can visit us on our <A
href="https://gitcitadel.com"
title="GitCitadel Homepage"
target="_blank">homepage</A
> and find out more about us, and the many projects we are working on.
</P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user can: login (top-right), select whether to only view the publications hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank">thecitadel document relay</A> or add in their own relays, and scroll/search the publications.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border rounded-lg' width="400" />
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document.
</P>
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Typical use cases</Heading>
<Heading tag='h3' class='h-leather mb-3'>For e-books</Heading>
<P class="mb-3">
The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.
</P>
<P class="mb-3">
An example of a book is <A href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition">Jane Eyre</A>
</P>
<div class="flex justify-center my-4">
<Img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For scientific papers</Heading>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.
</P>
<P class="mb-3">
An example of a research paper is <A href="/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1">Less Partnering, Less Children, or Both?</A>
</P>
<div class="flex justify-center my-4">
<Img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For documentation</Heading>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A>, as well as to store copies of our most interesting <A href="/publication?d=gitcitadel-project-documentation-by-stella-v-1">technical specifications</A>.
</P>
<div class="flex justify-center my-4">
<Img src="/screenshots/Documentation.png" alt="Documentation" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For wiki pages</Heading>
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content.
</P>
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored.
</P>
</main>
</div>

518
src/routes/contact/+page.svelte

@ -0,0 +1,518 @@ @@ -0,0 +1,518 @@
<script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte';
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser';
import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
// Function to close the success message
function closeSuccessMessage() {
submissionSuccess = false;
submittedEvent = null;
}
function clearForm() {
subject = '';
content = '';
submissionError = '';
isExpanded = false;
activeTab = 'write';
}
let subject = $state('');
let content = $state('');
let isSubmitting = $state(false);
let showLoginModal = $state(false);
let submissionSuccess = $state(false);
let submissionError = $state('');
let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state('');
let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false);
let activeTab = $state('write');
let showConfirmDialog = $state(false);
// Store form data when user needs to login
let savedFormData = {
subject: '',
content: ''
};
// Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
// Hard-coded relays to ensure we have working relays
const allRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
...standardRelays
];
// Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
const repoId = 'Alexandria';
// Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, '');
}
function toggleSize() {
isExpanded = !isExpanded;
}
async function handleSubmit(e: Event) {
// Prevent form submission
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Check if user is logged in
if (!$ndkSignedIn) {
// Save form data
savedFormData = {
subject,
content
};
// Show login modal
showLoginModal = true;
return;
}
// Show confirmation dialog
showConfirmDialog = true;
}
async function confirmSubmit() {
showConfirmDialog = false;
await submitIssue();
}
function cancelSubmit() {
showConfirmDialog = false;
}
/**
* Publish event to relays with retry logic
*/
async function publishToRelays(
event: NDKEvent,
ndk: NDK,
relays: Set<string>,
maxRetries: number = 3,
timeout: number = 10000
): Promise<string[]> {
const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => {
return new Promise<void>(resolve => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl);
resolve();
}
});
} else {
resolve(); // Resolve if relay not available
}
});
});
// Try publishing with retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout);
});
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
if (successfulRelays.length > 0) {
break; // Exit retry loop if we have successful publishes
}
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
} catch (error) {
if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts');
}
}
}
return successfulRelays;
}
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionSuccess = false;
try {
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
}
if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.');
}
// Create and prepare the event
const event = await createIssueEvent(ndk);
// Collect all unique relays
const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
]);
try {
// Publish to relays with retry logic
successfulRelays = await publishToRelays(event, ndk, uniqueRelays);
// Store the submitted event and create issue link
submittedEvent = event;
// Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Clear form and show success message
clearForm();
submissionSuccess = true;
} catch (error) {
throw new Error('Failed to publish event');
}
} catch (error: any) {
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
} finally {
isSubmitting = false;
}
}
/**
* Create and sign a new issue event
*/
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]);
// Add MIME tags
const mimeTags = getMimeTags(1621);
event.tags.push(...mimeTags);
// Set content
event.content = content;
// Sign the event
try {
await event.sign();
} catch (error) {
throw new Error('Failed to sign event');
}
return event;
}
// Handle login completion
$effect(() => {
if ($ndkSignedIn && showLoginModal) {
showLoginModal = false;
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content;
// Submit the issue
submitIssue();
}
});
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'>
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading>
<P class="mb-3">
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
</P>
<P class="mb-3">
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
</P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>
<P class="mb-3">
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P>
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full">
<div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist">
<li class="mr-2" role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'write'}
role="tab"
>
Write
</button>
</li>
<li role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'preview'}
role="tab"
>
Preview
</button>
</li>
</ul>
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'}
<div class="absolute inset-0 overflow-hidden">
<Textarea
id="content"
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-400 dark:focus:border-primary-500"
bind:value={content}
required
placeholder="Describe your issue in detail...
The following markup is supported:
# Headers (1-6 levels)
Header 1
======
*Bold* or **bold**
_Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes
Lists, including nested:
* Bullets/unordered lists
1. Numbered/ordered lists
[Links](url)
![Images](url)
`Inline code`
```language
Code blocks with syntax highlighting for over 100 languages
```
| Tables | With or without headers |
|--------|------|
| Multiple | Rows |
Footnotes[^1] and [^1]: footnote content
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix."
/>
</div>
{:else}
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content">
{#key content}
{#await parseAdvancedmarkup(content)}
<p>Loading preview...</p>
{:then html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div>
{/if}
</div>
</div>
<Button
type="button"
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
on:click={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
</Button>
</div>
</div>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" on:click={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
{#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
</div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600">
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedmarkup(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">Error rendering markup: {error.message}</p>
{/await}
</div>
</div>
</div>
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all">
{issueLink}
</A>
</div>
</div>
<!-- Display successful relays -->
<div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span>
<ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if submissionError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{submissionError}
</div>
{/if}
</form>
</main>
</div>
<!-- Confirmation Dialog -->
<Modal
bind:open={showConfirmDialog}
size="sm"
autoclose={false}
class="w-full"
>
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Would you like to submit the issue?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}>
Cancel
</Button>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
</div>
</div>
</Modal>
<!-- Login Modal -->
<LoginModal
show={showLoginModal}
onClose={() => showLoginModal = false}
onLoginSuccess={() => {
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content;
// Submit the issue
submitIssue();
}}
/>

70
src/routes/publication/+page.svelte

@ -1,31 +1,36 @@ @@ -1,31 +1,36 @@
<script lang="ts">
import Article from "$lib/components/Publication.svelte";
import Publication from "$lib/components/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageData } from "./$types";
import { onDestroy } from "svelte";
import ArticleNav from "$components/util/ArticleNav.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/stores";
import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor";
// Extend the PageData type with the properties we need
interface ExtendedPageData extends PageData {
waitable: Promise<any>;
publicationType: string;
indexEvent: NDKEvent;
parser: any;
}
let { data }: PageProps = $props();
let { data } = $props<{ data: ExtendedPageData }>();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext("publicationTree", publicationTree);
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 = $page.url.href;
let title = $derived(
data.indexEvent?.getMatchingTags("title")[0]?.[1] ||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
);
let currentUrl = data.url?.href ?? "";
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
let image = $derived(data.indexEvent?.getMatchingTags('image')[0]?.[1] || '/screenshots/old_books.jpg');
let summary = $derived(data.indexEvent?.getMatchingTags('summary')[0]?.[1] || 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.');
let image = $derived(
data.indexEvent?.getMatchingTags("image")[0]?.[1] ||
"/screenshots/old_books.jpg",
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
onDestroy(() => data.parser.reset());
</script>
@ -33,33 +38,36 @@ @@ -33,33 +38,36 @@
<svelte:head>
<!-- Basic meta tags -->
<title>{title}</title>
<meta name="description" content="{summary}" />
<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: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}" />
<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}" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={summary} />
<meta name="twitter:image" content={image} />
</svelte:head>
{#key data}
<ArticleNav publicationType={data.publicationType} rootId={data.parser.getRootIndexId()} />
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
/>
{/key}
<main class={data.publicationType}>
{#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
<Article
rootId={data.parser.getRootIndexId()}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>

3
src/routes/publication/+page.ts

@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit'; @@ -2,7 +2,7 @@ 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.ts';
import { getActiveRelays } from '$lib/ndk';
/**
* Decodes an naddr identifier and returns a filter object
@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
waitable: fetchPromise,
publicationType,
indexEvent,
url,
};
};

174
src/routes/start/+page.svelte

@ -0,0 +1,174 @@ @@ -0,0 +1,174 @@
<script lang="ts">
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || "development";
const isVersionKnown = appVersion !== "development";
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<Heading tag="h1" class="h-leather mb-2"
>Getting Started with Alexandria</Heading
>
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user
can: login (top-right), select whether to only view the publications
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A
> or add in their own relays, and scroll/search the publications.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/LandingPage.png"
alt="Landing page"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="image-border rounded-lg"
width="400"
/>
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all
of the content events (30041s and 30818s for wiki pages), in the order in
which they are indexed, and displays them as a single document.
</P>
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. (This
functionality has been temporarily disabled.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ToC_icon.png"
alt="ToC icon"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/TableOfContents.png"
alt="Table of contents example"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
<P class="mb-3">
The most common use for Alexandria is for e-books: both those the users
have written themselves and those uploaded to Nostr from other sources.
The first minor version of the app, Gutenberg, is focused on displaying
and producing these publications.
</P>
<P class="mb-3">
An example of a book is <A
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="mb-3">
An example of a research paper is <A
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</A
>
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/ResearchPaper.png"
alt="Research paper"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A
>, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</A
>.
</P>
<div class="flex justify-center my-4">
<Img
src="/screenshots/Documentation.png"
alt="Documentation"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading>
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected,
evolving content.
</P>
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links
to other wiki pages, creating a web of knowledge that can be navigated and
explored.
</P>
</main>
</div>

126
src/routes/visualize/+page.svelte

@ -1,55 +1,91 @@ @@ -1,55 +1,91 @@
<!--
Visualization Page
This page displays a network visualization of Nostr publications,
showing the relationships between index events and their content.
-->
<script lang="ts">
import { onMount } from "svelte";
import EventNetwork from "$lib/navigator/EventNetwork/index.svelte";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils";
import EventLimitControl from "$lib/components/EventLimitControl.svelte";
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte";
import { networkFetchLimit } from "$lib/state";
import { fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { CogSolid } from "flowbite-svelte-icons";
import { Button, Tooltip } from "flowbite-svelte";
import { Button } from "flowbite-svelte";
import Settings from "$lib/navigator/EventNetwork/Settings.svelte";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818];
/**
* Debug logging function that only logs when DEBUG is true
*/
function debug(...args: any[]) {
if (DEBUG) {
console.log("[VisualizePage]", ...args);
}
}
// State
let events: NDKEvent[] = [];
let loading = true;
let error: string | null = null;
// panel visibility
let showSettings = false;
/**
* Fetches events from the Nostr network
*
* This function fetches index events and their referenced content events,
* filters them according to NIP-62, and combines them for visualization.
*/
async function fetchEvents() {
debug("Fetching events with limit:", $networkFetchLimit);
try {
loading = true;
error = null;
// Fetch both index and content events
// Step 1: Fetch index events
debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`);
const indexEvents = await $ndkInstance.fetchEvents(
{ kinds: [30040], limit: $networkFetchLimit },
{
kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
debug("Fetched index events:", indexEvents.size);
// Filter valid index events according to NIP-62
// Step 2: Filter valid index events according to NIP-62
const validIndexEvents = filterValidIndexEvents(indexEvents);
debug("Valid index events after filtering:", validIndexEvents.size);
// Get all the content event IDs referenced by the index events
// Step 3: Extract content event IDs from index events
const contentEventIds = new Set<string>();
validIndexEvents.forEach((event) => {
event.getMatchingTags("a").forEach((tag) => {
let eventId = tag[3];
const aTags = event.getMatchingTags("a");
debug(`Event ${event.id} has ${aTags.length} a-tags`);
aTags.forEach((tag) => {
const eventId = tag[3];
if (eventId) {
contentEventIds.add(eventId);
}
});
});
debug("Content event IDs to fetch:", contentEventIds.size);
// Fetch the referenced content events
// Step 4: Fetch the referenced content events
debug(`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(', ')})`);
const contentEvents = await $ndkInstance.fetchEvents(
{
kinds: [30041, 30818],
kinds: CONTENT_EVENT_KINDS,
ids: Array.from(contentEventIds),
},
{
@ -58,9 +94,11 @@ @@ -58,9 +94,11 @@
skipValidation: false,
},
);
debug("Fetched content events:", contentEvents.size);
// Combine both sets of events
// Step 5: Combine both sets of events
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)];
debug("Total events for visualization:", events.length);
} catch (e) {
console.error("Error fetching events:", e);
error = e instanceof Error ? e.message : String(e);
@ -69,55 +107,20 @@ @@ -69,55 +107,20 @@
}
}
function handleLimitUpdate() {
fetchEvents();
}
// Fetch events when component mounts
onMount(() => {
debug("Component mounted");
fetchEvents();
});
</script>
<div class="leather w-full p-4 relative">
<div class="flex items-center gap-4 mb-4">
<h1 class="h-leather text-2xl font-bold">Publication Network</h1>
<!-- Settings Button - Using Flowbite Components -->
{#if !loading && !error}
<Button
class="btn-leather z-10 rounded-lg min-w-[120px]"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
Settings
</Button>
{/if}
</div>
{#if !loading && !error && showSettings}
<!-- Settings Panel -->
<div
class="absolute left-[220px] top-14 h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-10
overflow-y-auto max-h-[calc(100vh-96px)] rounded-lg border
border-gray-200 dark:border-gray-700"
transition:fly={{ duration: 300, y: -10, opacity: 1, easing: quintOut }}
>
<div class="card space-y-4">
<h2 class="text-xl font-bold mb-4 h-leather">
Visualization Settings
</h2>
<div class="space-y-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing {events.length} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
<!-- Header with title and settings button -->
<div class="flex items-center mb-4">
<h1 class="h-leather">Publication Network</h1>
</div>
</div>
{/if}
<!-- Loading spinner -->
{#if loading}
<div class="flex justify-center items-center h-64">
<div role="status">
@ -140,12 +143,14 @@ @@ -140,12 +143,14 @@
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Error message -->
{:else if error}
<div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
role="alert"
>
<p>Error loading network: {error}</p>
<p class="font-bold mb-2">Error loading network:</p>
<p class="mb-3">{error}</p>
<button
type="button"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
@ -154,8 +159,9 @@ @@ -154,8 +159,9 @@
Retry
</button>
</div>
<!-- Network visualization -->
{:else}
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
<!-- Event network visualization -->
<EventNetwork {events} onupdate={fetchEvents} />
{/if}
</div>

90
src/styles/publications.css

@ -53,7 +53,7 @@ @@ -53,7 +53,7 @@
@apply p-3 text-wrap break-words;
}
.note-leather .listingblock pre {
.publication-leather .listingblock pre {
@apply overflow-x-auto;
}
@ -62,24 +62,24 @@ @@ -62,24 +62,24 @@
@apply space-y-1 list-disc list-inside;
}
.note-leather .olist ol {
.publication-leather .olist ol {
@apply space-y-1 list-inside;
}
.note-leather ol.arabic {
.publication-leather ol.arabic {
@apply list-decimal;
}
.note-leather ol.loweralpha {
.publication-leather ol.loweralpha {
@apply list-lower-alpha;
}
.note-leather ol.upperalpha {
.publication-leather ol.upperalpha {
@apply list-upper-alpha;
}
.note-leather li ol,
.note-leather li ul {
.publication-leather li ol,
.publication-leather li ul {
@apply ps-5 my-2;
}
@ -93,25 +93,25 @@ @@ -93,25 +93,25 @@
@apply my-2 font-thin text-lg;
}
.note-leather li p {
.publication-leather li p {
@apply inline;
}
/* blockquote; prose and poetry quotes */
.note-leather .quoteblock,
.note-leather .verseblock {
.publication-leather .quoteblock,
.publication-leather .verseblock {
@apply p-4 my-4 border-s-4 rounded border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-700;
}
.note-leather .verseblock pre.content {
.publication-leather .verseblock pre.content {
@apply text-base font-sans;
}
.note-leather .attribution {
.publication-leather .attribution {
@apply mt-3 italic clear-both;
}
.note-leather cite {
.publication-leather cite {
@apply text-sm;
}
@ -120,94 +120,98 @@ @@ -120,94 +120,98 @@
}
/* admonition */
.note-leather .admonitionblock .title {
.publication-leather .admonitionblock .title {
@apply font-semibold;
}
.note-leather .admonitionblock table {
.publication-leather .admonitionblock table {
@apply w-full border-collapse;
}
.note-leather .admonitionblock tr {
@apply flex flex-col;
.publication-leather .admonitionblock tr {
@apply flex flex-col border-none;
}
.note-leather .admonitionblock p:has(code) {
.publication-leather .admonitionblock td {
@apply border-none;
}
.publication-leather .admonitionblock p:has(code) {
@apply my-3;
}
.note-leather .admonitionblock {
.publication-leather .admonitionblock {
@apply rounded overflow-hidden border;
}
.note-leather .admonitionblock .icon,
.note-leather .admonitionblock .content {
.publication-leather .admonitionblock .icon,
.publication-leather .admonitionblock .content {
@apply p-4;
}
.note-leather .admonitionblock .content {
.publication-leather .admonitionblock .content {
@apply pt-0;
}
.note-leather .admonitionblock.tip {
.publication-leather .admonitionblock.tip {
@apply rounded overflow-hidden border border-success-100 dark:border-success-800;
}
.note-leather .admonitionblock.tip .icon,
.note-leather .admonitionblock.tip .content {
.publication-leather .admonitionblock.tip .icon,
.publication-leather .admonitionblock.tip .content {
@apply bg-success-100 dark:bg-success-800;
}
.note-leather .admonitionblock.note {
.publication-leather .admonitionblock.note {
@apply rounded overflow-hidden border border-info-100 dark:border-info-700;
}
.note-leather .admonitionblock.note .icon,
.note-leather .admonitionblock.note .content {
.publication-leather .admonitionblock.note .icon,
.publication-leather .admonitionblock.note .content {
@apply bg-info-100 dark:bg-info-800;
}
.note-leather .admonitionblock.important {
.publication-leather .admonitionblock.important {
@apply rounded overflow-hidden border border-primary-200 dark:border-primary-700;
}
.note-leather .admonitionblock.important .icon,
.note-leather .admonitionblock.important .content {
.publication-leather .admonitionblock.important .icon,
.publication-leather .admonitionblock.important .content {
@apply bg-primary-200 dark:bg-primary-700;
}
.note-leather .admonitionblock.caution {
.publication-leather .admonitionblock.caution {
@apply rounded overflow-hidden border border-warning-200 dark:border-warning-700;
}
.note-leather .admonitionblock.caution .icon,
.note-leather .admonitionblock.caution .content {
.publication-leather .admonitionblock.caution .icon,
.publication-leather .admonitionblock.caution .content {
@apply bg-warning-200 dark:bg-warning-700;
}
.note-leather .admonitionblock.warning {
.publication-leather .admonitionblock.warning {
@apply rounded overflow-hidden border border-danger-200 dark:border-danger-800;
}
.note-leather .admonitionblock.warning .icon,
.note-leather .admonitionblock.warning .content {
.publication-leather .admonitionblock.warning .icon,
.publication-leather .admonitionblock.warning .content {
@apply bg-danger-200 dark:bg-danger-800;
}
/* listingblock, literalblock */
.note-leather .listingblock,
.note-leather .literalblock {
.publication-leather .listingblock,
.publication-leather .literalblock {
@apply p-4 rounded bg-highlight dark:bg-primary-700;
}
.note-leather .sidebarblock .title,
.note-leather .listingblock .title,
.note-leather .literalblock .title {
.publication-leather .sidebarblock .title,
.publication-leather .listingblock .title,
.publication-leather .literalblock .title {
@apply font-semibold mb-1;
}
/* sidebar */
.note-leather .sidebarblock {
.publication-leather .sidebarblock {
@apply p-4 rounded bg-info-100 dark:bg-info-800;
}

90
src/styles/visualize.css

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
@layer components {
/* Legend styles - specific to visualization */
.legend-list {
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300;
@apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300;
}
.legend-item {
@ -20,7 +21,92 @@ @@ -20,7 +21,92 @@
background-color: #d6c1a8;
}
.legend-circle.content {
background-color: var(--content-color, #d6c1a8);
}
:global(.dark) .legend-circle.content {
background-color: var(--content-color-dark, #FFFFFF);
}
.legend-letter {
@apply absolute inset-0 flex items-center justify-center text-black text-xs;
@apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold;
}
.legend-text {
@apply text-sm;
}
/* Network visualization styles - specific to visualization */
.network-container {
@apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px] max-h-[900px];
}
.network-svg-container {
@apply relative sm:h-[100%];
}
.network-svg {
@apply w-full sm:h-[100%] border;
@apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 rounded;
}
.network-error {
@apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4;
}
.network-error-title {
@apply font-bold text-lg;
}
.network-error-retry {
@apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700;
}
.network-debug {
@apply mt-4 text-sm text-gray-500;
}
/* Zoom controls */
.network-controls {
@apply absolute bottom-4 right-4 flex flex-col gap-2 z-10;
}
.network-control-button {
@apply bg-white;
}
/* Tooltip styles - specific to visualization tooltips */
.tooltip-close-btn {
@apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600
rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200;
}
.tooltip-content {
@apply space-y-2 pr-6;
}
.tooltip-title {
@apply font-bold text-base;
}
.tooltip-title-link {
@apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400;
}
.tooltip-metadata {
@apply text-gray-600 dark:text-gray-400 text-sm;
}
.tooltip-summary {
@apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40;
}
.tooltip-content-preview {
@apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40;
}
.tooltip-help-text {
@apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic;
}
}

19
src/types/d3.d.ts vendored

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
/**
* Type declarations for D3.js and related modules
*
* These declarations allow TypeScript to recognize D3 imports without requiring
* detailed type definitions. For a project requiring more type safety, consider
* using the @types/d3 package and its related sub-packages.
*/
// Core D3 library
declare module 'd3';
// Force simulation module for graph layouts
declare module 'd3-force';
// DOM selection and manipulation module
declare module 'd3-selection';
// Drag behavior module
declare module 'd3-drag';

22
tailwind.config.cjs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import flowbite from "flowbite/plugin";
import plugin from "tailwindcss/plugin";
/** @type {import('tailwindcss').Config}*/
const config = {
@ -19,7 +20,7 @@ const config = { @@ -19,7 +20,7 @@ const config = {
200: '#c6a885',
300: '#b58f62',
400: '#ad8351',
500: '#9c7649',
500: '#c6a885',
600: '#795c39',
700: '#574229',
800: '#342718',
@ -93,6 +94,25 @@ const config = { @@ -93,6 +94,25 @@ const config = {
plugins: [
flowbite(),
plugin(function({ addUtilities, matchUtilities }) {
addUtilities({
'.content-visibility-auto': {
'content-visibility': 'auto',
},
'.contain-size': {
contain: 'size',
},
});
matchUtilities({
'contain-intrinsic-w-*': value => ({
width: value,
}),
'contain-intrinsic-h-*': value => ({
height: value,
})
});
})
],
darkMode: 'class',

99
tests/integration/markupIntegration.test.ts

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser';
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser';
import { readFileSync } from 'fs';
import { join } from 'path';
const testFilePath = join(__dirname, './markupTestfile.md');
const md = readFileSync(testFilePath, 'utf-8');
describe('Markup Integration Test', () => {
it('parses markupTestfile.md with the basic parser', async () => {
const output = await parseBasicmarkup(md);
// Headers (should be present as text, not <h1> tags)
expect(output).toContain('This is a test');
expect(output).toContain('============');
expect(output).toContain('### Disclaimer');
// Unordered list
expect(output).toContain('<ul');
expect(output).toContain('but');
// Ordered list
expect(output).toContain('<ol');
expect(output).toContain('first');
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain('<blockquote');
expect(output).toContain('This is important information');
// Inline code
expect(output).toContain('<div class="leather min-h-full w-full flex flex-col items-center">');
// Images
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/);
// Links
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags
expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks
expect(output).toContain('wikilink');
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
expect(output).not.toMatch(/utm_/);
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule (should be present as --- in basic)
expect(output).toContain('---');
// Footnote references (should be present as [^1] in basic)
expect(output).toContain('[^1]');
// Table (should be present as | Syntax | Description | in basic)
expect(output).toContain('| Syntax | Description |');
});
it('parses markupTestfile.md with the advanced parser', async () => {
const output = await parseAdvancedmarkup(md);
// Headers
expect(output).toContain('<h1');
expect(output).toContain('<h2');
expect(output).toContain('Disclaimer');
// Unordered list
expect(output).toContain('<ul');
expect(output).toContain('but');
// Ordered list
expect(output).toContain('<ol');
expect(output).toContain('first');
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain('<blockquote');
expect(output).toContain('This is important information');
// Inline code
expect(output).toMatch(/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s);
// Images
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/);
// Links
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
// Hashtags
expect(output).toContain('text-primary-600');
// Nostr identifiers (should be njump.me links)
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
// Wikilinks
expect(output).toContain('wikilink');
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
expect(output).not.toMatch(/utm_/);
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule
expect(output).toContain('<hr');
// Footnote references and section
expect(output).toContain('Footnotes');
expect(output).toMatch(/<li id=\"fn-1\">/);
// Table
expect(output).toContain('<table');
// Code blocks
expect(output).toContain('<pre');
});
});

244
tests/integration/markupTestfile.md

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
This is a test
============
### Disclaimer
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
# H1
## H2
### H3
#### H4
##### H5
###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser.
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]]
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
> This is important information
> This is multiple
> lines of
> important information
> with a second[^2] footnote.
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984.
This is a youtube link
https://www.youtube.com/watch?v=9aqVxNCpx9s
And here is a link with tracking tokens:
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU
This is an unordered list:
* but
* not
* really
This is an unordered list with nesting:
* but
* not
* really
* but
* yes,
* really
## More testing
An ordered list:
1. first
2. second
3. third
Let's nest that:
1. first
2. second indented
3. third
4. fourth indented
5. fifth indented even more
6. sixth under the fourth
7. seventh under the sixth
8. eighth under the third
This is ordered and unordered mixed:
1. first
2. second indented
3. third
* make this a bullet point
4. fourth indented even more
* second bullet point
Here is a horizontal rule:
---
Try embedded a nostr note with nevent:
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske
Here a note with no prefix
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz
Here with a naddr:
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz
Here's a nonsense one:
nevent123
And a nonsense one with a prefix:
nostr:naddrwhatever
And some Nostr addresses that should be preserved and have a internal link appended:
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk
URL with a tracking parameter, no markup:
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale
Image without markup:
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags:
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or
```
in a code block
```
You can even use a multi-line code block, with a json tag.
```json
{
"created_at":1745038670,"content":"# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n![Nostr logo](https://user-images.githubusercontent.com/99301796/219900773-d6d02038-e2a0-4334-9f28-c14d40ab6fe7.png)\n\n[^1]: this is a footnote\n[^2]: so is this","tags":[["subject","test"],["alt","git repository issue: test"],["a","30617:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:Alexandria","","root"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["t","gitstuff"]],"kind":1621,"pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","id":"e78a689369511fdb3c36b990380c2d8db2b5e62f13f6b836e93ef5a09611afe8","sig":"7a2b3a6f6f61b6ea04de1fe873e46d40f2a220f02cdae004342430aa1df67647a9589459382f22576c651b3d09811546bbd79564cf472deaff032f137e94a865"
}
```
C or C++:
```cpp
bool getBit(int num, int i) {
return ((num & (1<<i)) != 0);
}
```
Asciidoc:
```adoc
= Header 1
preamble goes here
== Header 2
some more text
```
Gherkin:
```gherkin
Feature: Account Holder withdraws cash
Scenario: Account has sufficient funds
Given The account balance is $100
And the card is valid
And the machine contains enough money
When the Account Holder requests $20
Then the ATM should dispense $20
And the account balance should be $80
And the card should be returned
```
Go:
```go
package main
import (
"fmt"
"bufio"
"os"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Enter text: ")
scanner.Scan()
input := scanner.Text()
fmt.Println("You entered:", input)
}
```
or even markup:
```md
A H1 Header
============
Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists
look like:
* this one[^some reference text]
* that one
* the other one
Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.
> Block quotes are
> written like so.
>
> They can span multiple paragraphs,
> if you like.
```
Test out some emojis :heart: and :trophy:
#### Here is an image![^some reference text]
![Nostr logo](https://user-images.githubusercontent.com/99301796/219900773-d6d02038-e2a0-4334-9f28-c14d40ab6fe7.png)
### I went ahead and implemented tables, too.
A neat table[^some reference text]:
| Syntax | Description |
| ----------- | ----------- |
| Header | Title |
| Paragraph | Text |
A messy table (should render the same as above):
| Syntax | Description |
| --- | ----------- |
| Header | Title |
| Paragraph | Text |
Here is a table without a header row:
| Sometimes | you don't |
| need a | header |
| just | pipes |
[^1]: this is a footnote
[^some reference text]: this is a footnote that isn't a number

118
tests/unit/advancedMarkupParser.test.ts

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser';
function stripWS(str: string) {
return str.replace(/\s+/g, ' ').trim();
}
describe('Advanced Markup Parser', () => {
it('parses headers (ATX and Setext)', async () => {
const input = '# H1\nText\n\nH2\n====\n';
const output = await parseAdvancedmarkup(input);
expect(stripWS(output)).toContain('H1');
expect(stripWS(output)).toContain('H2');
});
it('parses bold, italic, and strikethrough', async () => {
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<strong>bold</strong>');
expect(output).toContain('<em>italic</em>');
expect(output).toContain('<del class="line-through">strikethrough</del>');
});
it('parses blockquotes', async () => {
const input = '> quote';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
});
it('parses multi-line blockquotes', async () => {
const input = '> quote\n> quote';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
expect(output).toContain('quote');
});
it('parses unordered lists', async () => {
const input = '* a\n* b';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<ul');
expect(output).toContain('a');
expect(output).toContain('b');
});
it('parses ordered lists', async () => {
const input = '1. one\n2. two';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<ol');
expect(output).toContain('one');
expect(output).toContain('two');
});
it('parses links and images', async () => {
const input = '[link](https://example.com) ![alt](https://img.com/x.png)';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<a');
expect(output).toContain('<img');
});
it('parses hashtags', async () => {
const input = '#hashtag';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('text-primary-600');
expect(output).toContain('#hashtag');
});
it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
});
it('parses emoji shortcodes', async () => {
const input = 'hello :smile:';
const output = await parseAdvancedmarkup(input);
expect(output).toMatch(/😄|:smile:/);
});
it('parses wikilinks', async () => {
const input = '[[Test Page|display]]';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('wikilink');
expect(output).toContain('display');
});
it('parses tables (with and without headers)', async () => {
const input = `| Syntax | Description |\n|--------|-------------|\n| Header | Title |\n| Paragraph | Text |\n\n| a | b |\n| c | d |`;
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<table');
expect(output).toContain('Header');
expect(output).toContain('a');
});
it('parses code blocks (with and without language)', async () => {
const input = '```js\nconsole.log(1);\n```\n```\nno lang\n```';
const output = await parseAdvancedmarkup(input);
const textOnly = output.replace(/<[^>]+>/g, '');
expect(output).toContain('<pre');
expect(textOnly).toContain('console.log(1);');
expect(textOnly).toContain('no lang');
});
it('parses horizontal rules', async () => {
const input = '---';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('<hr');
});
it('parses footnotes (references and section)', async () => {
const input = 'Here is a footnote[^1].\n\n[^1]: This is the footnote.';
const output = await parseAdvancedmarkup(input);
expect(output).toContain('Footnotes');
expect(output).toContain('This is the footnote');
expect(output).toContain('fn-1');
});
});

88
tests/unit/basicMarkupParser.test.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser';
// Helper to strip whitespace for easier comparison
function stripWS(str: string) {
return str.replace(/\s+/g, ' ').trim();
}
describe('Basic Markup Parser', () => {
it('parses ATX and Setext headers', async () => {
const input = '# H1\nText\n\nH2\n====\n';
const output = await parseBasicmarkup(input);
expect(stripWS(output)).toContain('H1');
expect(stripWS(output)).toContain('H2');
});
it('parses bold, italic, and strikethrough', async () => {
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~';
const output = await parseBasicmarkup(input);
expect(output).toContain('<strong>bold</strong>');
expect(output).toContain('<em>italic</em>');
expect(output).toContain('<del class="line-through">strikethrough</del>');
});
it('parses blockquotes', async () => {
const input = '> quote';
const output = await parseBasicmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
});
it('parses multi-line blockquotes', async () => {
const input = '> quote\n> quote';
const output = await parseBasicmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
expect(output).toContain('quote');
});
it('parses unordered lists', async () => {
const input = '* a\n* b';
const output = await parseBasicmarkup(input);
expect(output).toContain('<ul');
expect(output).toContain('a');
expect(output).toContain('b');
});
it('parses ordered lists', async () => {
const input = '1. one\n2. two';
const output = await parseBasicmarkup(input);
expect(output).toContain('<ol');
expect(output).toContain('one');
expect(output).toContain('two');
});
it('parses links and images', async () => {
const input = '[link](https://example.com) ![alt](https://img.com/x.png)';
const output = await parseBasicmarkup(input);
expect(output).toContain('<a');
expect(output).toContain('<img');
});
it('parses hashtags', async () => {
const input = '#hashtag';
const output = await parseBasicmarkup(input);
expect(output).toContain('text-primary-600');
expect(output).toContain('#hashtag');
});
it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseBasicmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
});
it('parses emoji shortcodes', async () => {
const input = 'hello :smile:';
const output = await parseBasicmarkup(input);
expect(output).toMatch(/😄|:smile:/);
});
it('parses wikilinks', async () => {
const input = '[[Test Page|display]]';
const output = await parseBasicmarkup(input);
expect(output).toContain('wikilink');
expect(output).toContain('display');
});
});

3
tests/unit/example.js

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
export function sum(a, b) {
return a + b
}

6
tests/unit/example.unit-test.js

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
import { expect, test } from 'vitest'
import { sum } from './example.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})

2
vite.config.ts

@ -27,7 +27,7 @@ export default defineConfig({ @@ -27,7 +27,7 @@ export default defineConfig({
}
},
test: {
include: ['./tests/unit/**/*.unit-test.js']
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts']
},
define: {
// Expose the app version as a global variable

Loading…
Cancel
Save