Browse Source

rudimentary LaTeX implementation for Markup

master
Silberengel 8 months ago
parent
commit
c37a3f3a73
  1. 30
      .onedev-buildspec.yml
  2. 2
      .prettierrc
  3. 2
      .vscode/settings.json
  4. 26
      README.md
  5. 2
      deno.json
  6. 2
      docker-compose.yaml
  7. 2
      import_map.json
  8. 10
      maintainers.yaml
  9. 30
      playwright.config.ts
  10. 5
      postcss.config.js
  11. 48
      src/app.css
  12. 27
      src/app.html
  13. 182
      src/lib/components/CommentBox.svelte
  14. 161
      src/lib/components/EventDetails.svelte
  15. 2
      src/lib/components/EventLimitControl.svelte
  16. 10
      src/lib/components/EventRenderLevelLimit.svelte
  17. 185
      src/lib/components/EventSearch.svelte
  18. 46
      src/lib/components/Login.svelte
  19. 64
      src/lib/components/LoginModal.svelte
  20. 196
      src/lib/components/Preview.svelte
  21. 2
      src/lib/components/Publication.svelte
  22. 164
      src/lib/components/PublicationFeed.svelte
  23. 75
      src/lib/components/PublicationHeader.svelte
  24. 119
      src/lib/components/PublicationSection.svelte
  25. 103
      src/lib/components/RelayActions.svelte
  26. 50
      src/lib/components/RelayDisplay.svelte
  27. 91
      src/lib/components/RelayStatus.svelte
  28. 36
      src/lib/components/Toc.svelte
  29. 77
      src/lib/components/cards/BlogHeader.svelte
  30. 233
      src/lib/components/cards/ProfileHeader.svelte
  31. 122
      src/lib/components/util/ArticleNav.svelte
  32. 211
      src/lib/components/util/CardActions.svelte
  33. 38
      src/lib/components/util/CopyToClipboard.svelte
  34. 90
      src/lib/components/util/Details.svelte
  35. 84
      src/lib/components/util/Interactions.svelte
  36. 158
      src/lib/components/util/Profile.svelte
  37. 4
      src/lib/components/util/QrCode.svelte
  38. 27
      src/lib/components/util/TocToggle.svelte
  39. 4
      src/lib/components/util/ZapOutline.svelte
  40. 54
      src/lib/consts.ts
  41. 6
      src/lib/data_structures/lazy.ts
  42. 105
      src/lib/data_structures/publication_tree.ts
  43. 40
      src/lib/navigator/EventNetwork/Legend.svelte
  44. 90
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  45. 52
      src/lib/navigator/EventNetwork/Settings.svelte
  46. 270
      src/lib/navigator/EventNetwork/index.svelte
  47. 62
      src/lib/navigator/EventNetwork/types.ts
  48. 285
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  49. 418
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  50. 285
      src/lib/ndk.ts
  51. 576
      src/lib/parser.ts
  52. 14
      src/lib/snippets/PublicationSnippets.svelte
  53. 10
      src/lib/snippets/UserSnippets.svelte
  54. 5
      src/lib/stores.ts
  55. 4
      src/lib/stores/relayStore.ts
  56. 8
      src/lib/types.ts
  57. 30
      src/lib/utils.ts
  58. 7
      src/lib/utils/markup/MarkupInfo.md
  59. 99
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  60. 375
      src/lib/utils/markup/advancedMarkupParser.ts
  61. 93
      src/lib/utils/markup/asciidoctorExtensions.ts
  62. 91
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  63. 271
      src/lib/utils/markup/basicMarkupParser.ts
  64. 12
      src/lib/utils/markup/tikzRenderer.ts
  65. 27
      src/lib/utils/mime.ts
  66. 257
      src/lib/utils/nostrUtils.ts
  67. 4
      src/lib/utils/npubCache.ts
  68. 33
      src/routes/+layout.svelte
  69. 26
      src/routes/+layout.ts
  70. 78
      src/routes/+page.svelte
  71. 22
      src/routes/[...catchall]/+page.svelte
  72. 5
      src/routes/about/+page.svelte
  73. 397
      src/routes/contact/+page.svelte
  74. 98
      src/routes/events/+page.svelte
  75. 15
      src/routes/new/compose/+page.svelte
  76. 62
      src/routes/new/edit/+page.svelte
  77. 40
      src/routes/publication/+error.svelte
  78. 70
      src/routes/publication/+page.ts
  79. 7
      src/routes/start/+page.svelte
  80. 23
      src/routes/visualize/+page.svelte
  81. 8
      src/styles/base.css
  82. 8
      src/styles/events.css
  83. 574
      src/styles/publications.css
  84. 32
      src/styles/scrollbar.css
  85. 204
      src/styles/visualize.css
  86. 10
      src/types/d3.d.ts
  87. 2
      src/types/global.d.ts
  88. 4
      src/types/plantuml-encoder.d.ts
  89. 142
      tailwind.config.cjs
  90. 50
      test_data/latex_markdown.md
  91. 16
      tests/e2e/example.pw.spec.ts
  92. 114
      tests/integration/markupIntegration.test.ts
  93. 123
      tests/integration/markupTestfile.md
  94. 147
      tests/unit/advancedMarkupParser.test.ts
  95. 102
      tests/unit/basicMarkupParser.test.ts
  96. 101
      tests/unit/latexRendering.test.ts
  97. 28
      vite.config.ts

30
.onedev-buildspec.yml

@ -1,17 +1,17 @@
version: 39 version: 39
jobs: jobs:
- name: Github Push - name: Github Push
steps: steps:
- !PushRepository - !PushRepository
name: gc-alexandria name: gc-alexandria
remoteUrl: https://github.com/ShadowySupercode/gc-alexandria remoteUrl: https://github.com/ShadowySupercode/gc-alexandria
passwordSecret: github_access_token passwordSecret: github_access_token
force: false force: false
condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL
triggers: triggers:
- !BranchUpdateTrigger {} - !BranchUpdateTrigger {}
- !TagCreateTrigger {} - !TagCreateTrigger {}
retryCondition: never retryCondition: never
maxRetries: 3 maxRetries: 3
retryDelay: 30 retryDelay: 30
timeout: 14400 timeout: 14400

2
.prettierrc

@ -1,3 +1,3 @@
{ {
"plugins":["prettier-plugin-svelte"] "plugins": ["prettier-plugin-svelte"]
} }

2
.vscode/settings.json vendored

@ -11,4 +11,4 @@
"files.associations": { "files.associations": {
"*.svelte": "svelte" "*.svelte": "svelte"
} }
} }

26
README.md

@ -1,4 +1,4 @@
![Roman scrolls](https://i.nostr.build/M5qXa.jpg) ![Roman scrolls](https://i.nostr.build/M5qXa.jpg)
# Alexandria # Alexandria
@ -18,45 +18,53 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?
Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed. Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed.
Once you've cloned this repo, install dependencies with NPM: Once you've cloned this repo, install dependencies with NPM:
```bash ```bash
npm install npm install
``` ```
or with Deno: or with Deno:
```bash ```bash
deno install deno install
``` ```
then start a development server with Node: then start a development server with Node:
```bash ```bash
npm run dev npm run dev
``` ```
or with Deno: or with Deno:
```bash ```bash
deno task dev deno task dev
``` ```
## Building ## Building
Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well. Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well.
To build a production version of your app with Node, use: To build a production version of your app with Node, use:
```bash ```bash
npm run build npm run build
``` ```
or with Deno: or with Deno:
```bash ```bash
deno task build deno task build
``` ```
You can preview the (non-static) production build with: You can preview the (non-static) production build with:
```bash ```bash
npm run preview npm run preview
``` ```
or with Deno: or with Deno:
```bash ```bash
deno task preview deno task preview
``` ```
@ -66,11 +74,13 @@ deno task preview
This docker container performs the build. This docker container performs the build.
To build the container: To build the container:
```bash ```bash
docker build . -t gc-alexandria docker build . -t gc-alexandria
``` ```
To run the container, in detached mode (-d): To run the container, in detached mode (-d):
```bash ```bash
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
``` ```
@ -83,7 +93,7 @@ If you want to see the container process (assuming it's the last process to star
docker ps -l docker ps -l
``` ```
which should return something like: which should return something like:
```bash ```bash
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
@ -92,32 +102,36 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
## Docker + Deno ## Docker + Deno
This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments. This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments.
To build the app for local development: To build the app for local development:
```bash ```bash
docker build -t local-alexandria -f Dockerfile.local . docker build -t local-alexandria -f Dockerfile.local .
``` ```
To run the local development build: To run the local development build:
```bash ```bash
docker run -d -p 3000:3000 local-alexandria docker run -d -p 3000:3000 local-alexandria
``` ```
## Testing ## Testing
*These tests are under development, but will run. They will later be added to the container.* _These tests are under development, but will run. They will later be added to the container._
To run the Vitest suite we've built, install the program locally and run the tests. To run the Vitest suite we've built, install the program locally and run the tests.
```bash ```bash
npm run test npm run test
``` ```
For the Playwright end-to-end (e2e) tests: For the Playwright end-to-end (e2e) tests:
```bash ```bash
npx playwright test npx playwright test
``` ```
## Markup Support ## 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). 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).

2
deno.json

@ -4,4 +4,4 @@
"allowJs": true, "allowJs": true,
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
} }
} }

2
docker-compose.yaml

@ -1,4 +1,4 @@
version: '3' version: "3"
services: services:
wikinostr: wikinostr:

2
import_map.json

@ -16,4 +16,4 @@
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x", "flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x",
"child_process": "node:child_process" "child_process": "node:child_process"
} }
} }

10
maintainers.yaml

@ -1,8 +1,8 @@
identifier: Alexandria identifier: Alexandria
maintainers: maintainers:
- npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf - npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf
- npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z - npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z
- npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn - npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn
relays: relays:
- wss://theforest.nostr1.com - wss://theforest.nostr1.com
- wss://thecitadel.nostr1.com - wss://thecitadel.nostr1.com

30
playwright.config.ts

@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from "@playwright/test";
/** /**
* Read environment variables from file. * Read environment variables from file.
@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e/', testDir: "./tests/e2e/",
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
@ -22,34 +22,31 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [ reporter: [["list"], ["html", { outputFolder: "./tests/e2e/html-report" }]],
['list'],
['html', { outputFolder: './tests/e2e/html-report' }]
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000', // baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: "on-first-retry",
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ {
name: 'chromium', name: "chromium",
use: { ...devices['Desktop Chrome'] }, use: { ...devices["Desktop Chrome"] },
}, },
{ {
name: 'firefox', name: "firefox",
use: { ...devices['Desktop Firefox'] }, use: { ...devices["Desktop Firefox"] },
}, },
{ {
name: 'webkit', name: "webkit",
use: { ...devices['Desktop Safari'] }, use: { ...devices["Desktop Safari"] },
}, },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
@ -84,10 +81,10 @@ export default defineConfig({
// testIgnore: '*test-assets', // testIgnore: '*test-assets',
// Glob patterns or regular expressions that match test files. // Glob patterns or regular expressions that match test files.
testMatch: '*.pw.spec.ts', testMatch: "*.pw.spec.ts",
// Folder for test artifacts such as screenshots, videos, traces, etc. // Folder for test artifacts such as screenshots, videos, traces, etc.
outputDir: './tests/e2e/test-results', outputDir: "./tests/e2e/test-results",
// path to the global setup files. // path to the global setup files.
// globalSetup: require.resolve('./global-setup'), // globalSetup: require.resolve('./global-setup'),
@ -102,5 +99,4 @@ export default defineConfig({
// Maximum time expect() should wait for the condition to be met. // Maximum time expect() should wait for the condition to be met.
timeout: 5000, timeout: 5000,
}, },
});
});

5
postcss.config.js

@ -2,8 +2,5 @@ import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer"; import autoprefixer from "autoprefixer";
export default { export default {
plugins: [ plugins: [tailwindcss(), autoprefixer()],
tailwindcss(),
autoprefixer(),
]
}; };

48
src/app.css

@ -1,7 +1,7 @@
@import './styles/base.css'; @import "./styles/base.css";
@import './styles/scrollbar.css'; @import "./styles/scrollbar.css";
@import './styles/publications.css'; @import "./styles/publications.css";
@import './styles/visualize.css'; @import "./styles/visualize.css";
@import "./styles/events.css"; @import "./styles/events.css";
/* Custom styles */ /* Custom styles */
@ -26,7 +26,7 @@
@apply h-4 w-4; @apply h-4 w-4;
} }
div[role='tooltip'] button.btn-leather { div[role="tooltip"] button.btn-leather {
@apply hover:text-primary-600 dark:hover:text-primary-400 hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 dark:hover:bg-gray-700; @apply hover:text-primary-600 dark:hover:text-primary-400 hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 dark:hover:bg-gray-700;
} }
@ -61,9 +61,9 @@
} }
/* To scroll columns independently */ /* To scroll columns independently */
main.publication.blog { main.publication.blog {
@apply w-full sm:w-auto min-h-full; @apply w-full sm:w-auto min-h-full;
} }
main.main-leather, main.main-leather,
article.article-leather { article.article-leather {
@ -115,16 +115,16 @@
@apply text-base font-semibold; @apply text-base font-semibold;
} }
div.modal-leather>div { div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; @apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
} }
div.modal-leather>div>h1, div.modal-leather > div > h1,
div.modal-leather>div>h2, div.modal-leather > div > h2,
div.modal-leather>div>h3, div.modal-leather > div > h3,
div.modal-leather>div>h4, div.modal-leather > div > h4,
div.modal-leather>div>h5, div.modal-leather > div > h5,
div.modal-leather>div>h6 { div.modal-leather > div > h6 {
@apply text-gray-900 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-100; @apply text-gray-900 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-100;
} }
@ -176,12 +176,12 @@
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
div.textarea-leather>div:nth-child(1), div.textarea-leather > div:nth-child(1),
div.toolbar-leather { div.toolbar-leather {
@apply border-none; @apply border-none;
} }
div.textarea-leather>div:nth-child(2) { div.textarea-leather > div:nth-child(2) {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
@ -194,7 +194,7 @@
@apply text-gray-900 dark:text-gray-100; @apply text-gray-900 dark:text-gray-100;
} }
div[role='tooltip'] button.btn-leather .tooltip-leather { div[role="tooltip"] button.btn-leather .tooltip-leather {
@apply bg-primary-100 dark:bg-primary-800; @apply bg-primary-100 dark:bg-primary-800;
} }
@ -276,7 +276,6 @@
} }
@layer components { @layer components {
/* Legend */ /* Legend */
.leather-legend { .leather-legend {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded; @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded;
@ -395,7 +394,6 @@
thead, thead,
tbody { tbody {
th, th,
td { td {
@apply border border-gray-200 dark:border-gray-700; @apply border border-gray-200 dark:border-gray-700;
@ -425,10 +423,10 @@
padding-left: 1rem; padding-left: 1rem;
} }
.line-ellipsis { .line-ellipsis {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.footnotes li { .footnotes li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -497,4 +495,4 @@
@apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none px-4 py-2; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none px-4 py-2;
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }
} }

27
src/app.html

@ -4,28 +4,37 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" /> <link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<!-- MathJax for math rendering --> <!-- MathJax for math rendering -->
<script> <script>
window.MathJax = { window.MathJax = {
tex: { tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']], inlineMath: [
displayMath: [['$$', '$$'], ['\\[', '\\]']], ["$", "$"],
["\\(", "\\)"],
],
displayMath: [
["$$", "$$"],
["\\[", "\\]"],
],
processEscapes: true, processEscapes: true,
processEnvironments: true processEnvironments: true,
}, },
options: { options: {
ignoreHtmlClass: 'tex2jax_ignore', ignoreHtmlClass: "tex2jax_ignore",
processHtmlClass: 'tex2jax_process' processHtmlClass: "tex2jax_process",
} },
}; };
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<!-- highlight.js for code highlighting --> <!-- highlight.js for code highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
%sveltekit.head% %sveltekit.head%
</head> </head>

182
src/lib/components/CommentBox.svelte

@ -1,14 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte'; import { Button, Textarea, Alert } from "flowbite-svelte";
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser'; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; import {
import { standardRelays, fallbackRelays } from '$lib/consts'; getEventHash,
import { userRelays } from '$lib/stores/relayStore'; signEvent,
import { get } from 'svelte/store'; getUserMetadata,
import { goto } from '$app/navigation'; type NostrProfile,
import type { NDKEvent } from '$lib/utils/nostrUtils'; } from "$lib/utils/nostrUtils";
import { onMount } from 'svelte'; import { standardRelays, fallbackRelays } from "$lib/consts";
import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { onMount } from "svelte";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
@ -16,8 +21,8 @@
userRelayPreference: boolean; userRelayPreference: boolean;
}>(); }>();
let content = $state(''); let content = $state("");
let preview = $state(''); let preview = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null); let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -35,32 +40,38 @@
// Markup buttons // Markup buttons
const markupButtons = [ const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') }, { label: "Bold", action: () => insertMarkup("**", "**") },
{ label: 'Italic', action: () => insertMarkup('_', '_') }, { label: "Italic", action: () => insertMarkup("_", "_") },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') }, { label: "Strike", action: () => insertMarkup("~~", "~~") },
{ label: 'Link', action: () => insertMarkup('[', '](url)') }, { label: "Link", action: () => insertMarkup("[", "](url)") },
{ label: 'Image', action: () => insertMarkup('![', '](url)') }, { label: "Image", action: () => insertMarkup("![", "](url)") },
{ label: 'Quote', action: () => insertMarkup('> ', '') }, { label: "Quote", action: () => insertMarkup("> ", "") },
{ label: 'List', action: () => insertMarkup('- ', '') }, { label: "List", action: () => insertMarkup("- ", "") },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') }, { label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: 'Hashtag', action: () => insertMarkup('#', '') } { label: "Hashtag", action: () => insertMarkup("#", "") },
]; ];
function insertMarkup(prefix: string, suffix: string) { function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea'); const textarea = document.querySelector("textarea");
if (!textarea) return; if (!textarea) return;
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const selectedText = content.substring(start, end); const selectedText = content.substring(start, end);
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end); content =
content.substring(0, start) +
prefix +
selectedText +
suffix +
content.substring(end);
updatePreview(); updatePreview();
// Set cursor position after the inserted markup // Set cursor position after the inserted markup
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length; textarea.selectionStart = textarea.selectionEnd =
start + prefix.length + selectedText.length + suffix.length;
}, 0); }, 0);
} }
@ -69,8 +80,8 @@
} }
function clearForm() { function clearForm() {
content = ''; content = "";
preview = ''; preview = "";
error = null; error = null;
success = null; success = null;
showOtherRelays = false; showOtherRelays = false;
@ -79,26 +90,29 @@
function removeFormatting() { function removeFormatting() {
content = content content = content
.replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, '$1') .replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, '$1') .replace(/~~(.*?)~~/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, '$1') .replace(/\[(.*?)\]\(.*?\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, '$1') .replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, '') .replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, '') .replace(/^[-*]\s*/gm, "")
.replace(/^\d+\.\s*/gm, '') .replace(/^\d+\.\s*/gm, "")
.replace(/#(\w+)/g, '$1'); .replace(/#(\w+)/g, "$1");
updatePreview(); updatePreview();
} }
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) { async function handleSubmit(
useOtherRelays = false,
useFallbackRelays = false,
) {
isSubmitting = true; isSubmitting = true;
error = null; error = null;
success = null; success = null;
try { try {
if (!props.event.kind) { if (!props.event.kind) {
throw new Error('Invalid event: missing kind'); throw new Error("Invalid event: missing kind");
} }
const kind = props.event.kind === 1 ? 1 : 1111; const kind = props.event.kind === 1 ? 1 : 1111;
@ -106,28 +120,32 @@
if (kind === 1) { if (kind === 1) {
// NIP-10 reply // NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']); tags.push(["e", props.event.id, "", "reply"]);
tags.push(['p', props.event.pubkey]); tags.push(["p", props.event.pubkey]);
if (props.event.tags) { if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root'); const rootTag = props.event.tags.find(
(t: string[]) => t[0] === "e" && t[3] === "root",
);
if (rootTag) { if (rootTag) {
tags.push(['e', rootTag[1], '', 'root']); tags.push(["e", rootTag[1], "", "root"]);
} }
// Add all p tags from the parent event // Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => { props.event.tags
if (!tags.some((pt: string[]) => pt[1] === t[1])) { .filter((t: string[]) => t[0] === "p")
tags.push(['p', t[1]]); .forEach((t: string[]) => {
} if (!tags.some((pt: string[]) => pt[1] === t[1])) {
}); tags.push(["p", t[1]]);
}
});
} }
} else { } else {
// NIP-22 comment // NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]); tags.push(["E", props.event.id, "", props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]); tags.push(["K", props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]); tags.push(["P", props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]); tags.push(["e", props.event.id, "", props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]); tags.push(["k", props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]); tags.push(["p", props.event.pubkey]);
} }
const eventToSign = { const eventToSign = {
@ -135,7 +153,7 @@
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
content, content,
pubkey: props.userPubkey pubkey: props.userPubkey,
}; };
const id = getEventHash(eventToSign); const id = getEventHash(eventToSign);
@ -144,7 +162,7 @@
const signedEvent = { const signedEvent = {
...eventToSign, ...eventToSign,
id, id,
sig sig,
}; };
// Determine which relays to use // Determine which relays to use
@ -164,16 +182,16 @@
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
ws.close(); ws.close();
reject(new Error('Timeout')); reject(new Error("Timeout"));
}, 5000); }, 5000);
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent])); ws.send(JSON.stringify(["EVENT", signedEvent]));
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data); const [type, id, ok, message] = JSON.parse(e.data);
if (type === 'OK' && id === signedEvent.id) { if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout); clearTimeout(timeout);
if (ok) { if (ok) {
published = true; published = true;
@ -190,7 +208,7 @@
ws.onerror = () => { ws.onerror = () => {
clearTimeout(timeout); clearTimeout(timeout);
ws.close(); ws.close();
reject(new Error('WebSocket error')); reject(new Error("WebSocket error"));
}; };
}); });
if (published) break; if (published) break;
@ -202,12 +220,14 @@
if (!published) { if (!published) {
if (!useOtherRelays && !useFallbackRelays) { if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true; showOtherRelays = true;
error = 'Failed to publish to primary relays. Would you like to try the other relays?'; error =
"Failed to publish to primary relays. Would you like to try the other relays?";
} else if (useOtherRelays && !useFallbackRelays) { } else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true; showFallbackRelays = true;
error = 'Failed to publish to other relays. Would you like to try the fallback relays?'; error =
"Failed to publish to other relays. Would you like to try the fallback relays?";
} else { } else {
error = 'Failed to publish to any relays. Please try again later.'; error = "Failed to publish to any relays. Please try again later.";
} }
} else { } else {
// Navigate to the event page // Navigate to the event page
@ -215,7 +235,7 @@
goto(`/events?id=${nevent}`); goto(`/events?id=${nevent}`);
} }
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'An error occurred'; error = e instanceof Error ? e.message : "An error occurred";
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
@ -227,7 +247,9 @@
{#each markupButtons as button} {#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button> <Button size="xs" on:click={button.action}>{button.label}</Button>
{/each} {/each}
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button> <Button size="xs" color="alternative" on:click={removeFormatting}
>Remove Formatting</Button
>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button> <Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div> </div>
@ -250,10 +272,16 @@
<Alert color="red" dismissable> <Alert color="red" dismissable>
{error} {error}
{#if showOtherRelays} {#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button> <Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}
>Try Other Relays</Button
>
{/if} {/if}
{#if showFallbackRelays} {#if showFallbackRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button> <Button
size="xs"
class="mt-2"
on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button
>
{/if} {/if}
</Alert> </Alert>
{/if} {/if}
@ -261,7 +289,10 @@
{#if success} {#if success}
<Alert color="green" dismissable> <Alert color="green" dismissable>
Comment published successfully to {success.relay}! Comment published successfully to {success.relay}!
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline"> <a
href="/events?id={nip19.neventEncode({ id: success.eventId })}"
class="text-primary-600 dark:text-primary-500 hover:underline"
>
View your comment View your comment
</a> </a>
</Alert> </Alert>
@ -271,9 +302,9 @@
{#if userProfile} {#if userProfile}
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
{#if userProfile.picture} {#if userProfile.picture}
<img <img
src={userProfile.picture} src={userProfile.picture}
alt={userProfile.name || 'Profile'} alt={userProfile.name || "Profile"}
class="w-8 h-8 rounded-full" class="w-8 h-8 rounded-full"
onerror={(e) => { onerror={(e) => {
const img = e.target as HTMLImageElement; const img = e.target as HTMLImageElement;
@ -282,7 +313,9 @@
/> />
{/if} {/if}
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'} {userProfile.displayName ||
userProfile.name ||
nip19.npubEncode(props.userPubkey).slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}
@ -303,7 +336,8 @@
{#if !props.userPubkey} {#if !props.userPubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your current account. Please sign in to post comments. Your comments will be signed with your
current account.
</Alert> </Alert>
{/if} {/if}
</div> </div>
@ -314,4 +348,4 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
</style> </style>

161
src/lib/components/EventDetails.svelte

@ -5,14 +5,18 @@
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte"; import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte'; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
const { event, profile = null, searchValue = null } = $props<{ const {
event,
profile = null,
searchValue = null,
} = $props<{
event: NDKEvent; event: NDKEvent;
profile?: { profile?: {
name?: string; name?: string;
@ -28,43 +32,66 @@
}>(); }>();
let showFullContent = $state(false); let showFullContent = $state(false);
let parsedContent = $state(''); let parsedContent = $state("");
let contentPreview = $state(''); let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled'; return getMatchingTags(event, "title")[0]?.[1] || "Untitled";
} }
function getEventSummary(event: NDKEvent): string { function getEventSummary(event: NDKEvent): string {
return getMatchingTags(event, 'summary')[0]?.[1] || ''; return getMatchingTags(event, "summary")[0]?.[1] || "";
} }
function getEventHashtags(event: NDKEvent): string[] { function getEventHashtags(event: NDKEvent): string[] {
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]); return getMatchingTags(event, "t").map((tag: string[]) => tag[1]);
} }
function getEventTypeDisplay(event: NDKEvent): string { function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0); const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`; return MTag[1].split("/")[1] || `Event Kind ${event.kind}`;
} }
function getTagButtonInfo(tag: string[]): { text: string, gotoValue?: string } { function getTagButtonInfo(tag: string[]): {
if (tag[0] === 'a' && tag.length > 1) { text: string;
const [kind, pubkey, d] = tag[1].split(':'); gotoValue?: string;
const naddr = naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays); } {
if (tag[0] === "a" && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(":");
const naddr = naddrEncode(
{
kind: +kind,
pubkey,
tags: [["d", d]],
content: "",
id: "",
sig: "",
} as any,
standardRelays,
);
return { text: `a:${tag[1]}`, gotoValue: naddr }; return { text: `a:${tag[1]}`, gotoValue: naddr };
} }
if (tag[0] === 'e' && tag.length > 1) { if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays); const nevent = neventEncode(
{
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any,
standardRelays,
);
return { text: `e:${tag[1]}`, gotoValue: nevent }; return { text: `e:${tag[1]}`, gotoValue: nevent };
} }
return { text: '' }; return { text: "" };
} }
$effect(() => { $effect(() => {
if (event && event.kind !== 0 && event.content) { if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => { parseBasicmarkup(event.content).then((html) => {
parsedContent = html; parsedContent = html;
contentPreview = html.slice(0, 250); contentPreview = html.slice(0, 250);
}); });
@ -73,8 +100,12 @@
$effect(() => { $effect(() => {
if (event?.pubkey) { if (event?.pubkey) {
getUserMetadata(toNpub(event.pubkey) as string).then(profile => { getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName = profile.displayName || (profile as any).display_name || profile.name || event.pubkey; authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
}); });
} else { } else {
authorDisplayName = undefined; authorDisplayName = undefined;
@ -82,30 +113,46 @@
}); });
// --- Identifier helpers --- // --- Identifier helpers ---
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] { function getIdentifiers(
const ids: { label: string, value: string, link?: string }[] = []; event: NDKEvent,
profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) { if (event.kind === 0) {
// NIP-05 // NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1]; const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub // npub
const npub = toNpub(event.pubkey); const npub = toNpub(event.pubkey);
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` }); if (npub)
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` });
// nprofile // nprofile
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, standardRelays), link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}` }); ids.push({
label: "nprofile",
value: nprofileEncode(event.pubkey, standardRelays),
link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}`,
});
// nevent // nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); ids.push({
label: "nevent",
value: neventEncode(event, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
});
// hex pubkey // hex pubkey
ids.push({ label: 'pubkey', value: event.pubkey }); ids.push({ label: "pubkey", value: event.pubkey });
} else { } else {
// nevent // nevent
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); ids.push({
label: "nevent",
value: neventEncode(event, standardRelays),
link: `/events?id=${neventEncode(event, standardRelays)}`,
});
// naddr (if addressable) // naddr (if addressable)
try { try {
const naddr = naddrEncode(event, standardRelays); const naddr = naddrEncode(event, standardRelays);
ids.push({ label: 'naddr', value: naddr, link: `/events?id=${naddr}` }); ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` });
} catch {} } catch {}
// hex id // hex id
ids.push({ label: 'id', value: event.id }); ids.push({ label: "id", value: event.id });
} }
return ids; return ids;
} }
@ -113,20 +160,25 @@
function isCurrentSearch(value: string): boolean { function isCurrentSearch(value: string): boolean {
if (!searchValue) return false; if (!searchValue) return false;
// Compare ignoring case and possible nostr: prefix // Compare ignoring case and possible nostr: prefix
const norm = (s: string) => s.replace(/^nostr:/, '').toLowerCase(); const norm = (s: string) => s.replace(/^nostr:/, "").toLowerCase();
return norm(value) === norm(searchValue); return norm(value) === norm(searchValue);
} }
</script> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
{#if event.kind !== 0 && getEventTitle(event)} {#if event.kind !== 0 && getEventTitle(event)}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{getEventTitle(event)}
</h2>
{/if} {/if}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)} {#if toNpub(event.pubkey)}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-700 dark:text-gray-300">
Author: {@render userBadge(toNpub(event.pubkey) as string, authorDisplayName)} Author: {@render userBadge(
toNpub(event.pubkey) as string,
authorDisplayName,
)}
</span> </span>
{:else} {:else}
<span class="text-gray-700 dark:text-gray-300"> <span class="text-gray-700 dark:text-gray-300">
@ -138,7 +190,9 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">Kind:</span> <span class="text-gray-700 dark:text-gray-300">Kind:</span>
<span class="font-mono">{event.kind}</span> <span class="font-mono">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300">({getEventTypeDisplay(event)})</span> <span class="text-gray-700 dark:text-gray-300"
>({getEventTypeDisplay(event)})</span
>
</div> </div>
{#if getEventSummary(event)} {#if getEventSummary(event)}
@ -153,7 +207,10 @@
<span class="text-gray-700 dark:text-gray-300">Tags:</span> <span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag} {#each getEventHashtags(event) as tag}
<span class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium">#{tag}</span> <span
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium"
>#{tag}</span
>
{/each} {/each}
</div> </div>
</div> </div>
@ -166,7 +223,10 @@
<div class="prose dark:prose-invert max-w-none"> <div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview} {@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250} {#if !showFullContent && parsedContent.length > 250}
<button class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200" onclick={() => showFullContent = true}>Show more</button> <button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button
>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -174,7 +234,11 @@
<!-- If event is profile --> <!-- If event is profile -->
{#if event.kind === 0} {#if event.kind === 0}
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} /> <ProfileHeader
{event}
{profile}
identifiers={getIdentifiers(event, profile)}
/>
{/if} {/if}
<!-- Tags Array --> <!-- Tags Array -->
@ -186,7 +250,8 @@
{@const tagInfo = getTagButtonInfo(tag)} {@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue} {#if tagInfo.text && tagInfo.gotoValue}
<button <button
onclick={() => goto(`/events?id=${encodeURIComponent(tagInfo.gotoValue!)}`)} onclick={() =>
goto(`/events?id=${encodeURIComponent(tagInfo.gotoValue!)}`)}
class="underline text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100" class="underline text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
> >
{tagInfo.text} {tagInfo.text}
@ -198,18 +263,24 @@
{/if} {/if}
<!-- Raw Event JSON --> <!-- Raw Event JSON -->
<details class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4"> <details
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"> class="relative w-full max-w-2xl md:max-w-full bg-primary-50 dark:bg-primary-900 rounded p-4"
>
<summary
class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"
>
Show Raw Event JSON Show Raw Event JSON
</summary> </summary>
<div class="absolute top-4 right-4"> <div class="absolute top-4 right-4">
<CopyToClipboard displayText="" copyText={JSON.stringify(event.rawEvent(), null, 2)} /> <CopyToClipboard
displayText=""
copyText={JSON.stringify(event.rawEvent(), null, 2)}
/>
</div> </div>
<pre <pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono" class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono"
style="line-height: 1.7; font-size: 1rem;" style="line-height: 1.7; font-size: 1rem;">
>
{JSON.stringify(event.rawEvent(), null, 2)} {JSON.stringify(event.rawEvent(), null, 2)}
</pre> </pre>
</details> </details>
</div> </div>

2
src/lib/components/EventLimitControl.svelte

@ -45,7 +45,7 @@
/> />
<button <button
on:click={handleUpdate} on:click={handleUpdate}
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 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-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
> >
Update Update
</button> </button>

10
src/lib/components/EventRenderLevelLimit.svelte

@ -29,10 +29,14 @@
</script> </script>
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium" <label
for="levels-to-render"
class="leather bg-transparent text-sm font-medium"
>Levels to render: >Levels to render:
</label> </label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label> <label for="event-limit" class="leather bg-transparent text-sm font-medium"
>Limit:
</label>
<input <input
type="number" type="number"
id="levels-to-render" id="levels-to-render"
@ -45,7 +49,7 @@
/> />
<button <button
onclick={handleUpdate} onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
> >
Update Update
</button> </button>

185
src/lib/components/EventSearch.svelte

@ -2,13 +2,21 @@
import { Input, Button } from "flowbite-svelte"; import { Input, Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { nip19 } from '$lib/utils/nostrUtils'; import { nip19 } from "$lib/utils/nostrUtils";
import { goto } from '$app/navigation'; import { goto } from "$app/navigation";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import RelayDisplay from './RelayDisplay.svelte'; import RelayDisplay from "./RelayDisplay.svelte";
import { getActiveRelays } from '$lib/ndk'; import { getActiveRelays } from "$lib/ndk";
const { loading, error, searchValue, dTagValue, onEventFound, onSearchResults, event } = $props<{ const {
loading,
error,
searchValue,
dTagValue,
onEventFound,
onSearchResults,
event,
} = $props<{
loading: boolean; loading: boolean;
error: string | null; error: string | null;
searchValue: string | null; searchValue: string | null;
@ -20,7 +28,9 @@
let searchQuery = $state(""); let searchQuery = $state("");
let localError = $state<string | null>(null); let localError = $state<string | null>(null);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>(
{},
);
let foundEvent = $state<NDKEvent | null>(null); let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false); let searching = $state(false);
@ -43,25 +53,29 @@
async function searchByDTag(dTag: string) { async function searchByDTag(dTag: string) {
localError = null; localError = null;
searching = true; searching = true;
// Convert d-tag to lowercase for consistent searching // Convert d-tag to lowercase for consistent searching
const normalizedDTag = dTag.toLowerCase(); const normalizedDTag = dTag.toLowerCase();
try { try {
console.log('[Events] Searching for events with d-tag:', normalizedDTag); console.log("[Events] Searching for events with d-tag:", normalizedDTag);
const ndk = $ndkInstance; const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
localError = 'NDK not initialized'; localError = "NDK not initialized";
return; return;
} }
const filter = { '#d': [normalizedDTag] }; const filter = { "#d": [normalizedDTag] };
const relaySet = getActiveRelays(ndk); const relaySet = getActiveRelays(ndk);
// Fetch multiple events with the same d-tag // Fetch multiple events with the same d-tag
const events = await ndk.fetchEvents(filter, { closeOnEose: true }, relaySet); const events = await ndk.fetchEvents(
filter,
{ closeOnEose: true },
relaySet,
);
const eventArray = Array.from(events); const eventArray = Array.from(events);
if (eventArray.length === 0) { if (eventArray.length === 0) {
localError = `No events found with d-tag: ${normalizedDTag}`; localError = `No events found with d-tag: ${normalizedDTag}`;
onSearchResults([]); onSearchResults([]);
@ -70,29 +84,40 @@
handleFoundEvent(eventArray[0]); handleFoundEvent(eventArray[0]);
} else { } else {
// Multiple events found, show as search results // Multiple events found, show as search results
console.log(`[Events] Found ${eventArray.length} events with d-tag: ${normalizedDTag}`); console.log(
`[Events] Found ${eventArray.length} events with d-tag: ${normalizedDTag}`,
);
onSearchResults(eventArray); onSearchResults(eventArray);
} }
} catch (err) { } catch (err) {
console.error('[Events] Error searching by d-tag:', err); console.error("[Events] Error searching by d-tag:", err);
localError = 'Error searching for events with this d-tag.'; localError = "Error searching for events with this d-tag.";
onSearchResults([]); onSearchResults([]);
} finally { } finally {
searching = false; searching = false;
} }
} }
async function searchEvent(clearInput: boolean = true, queryOverride?: string) { async function searchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
localError = null; localError = null;
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim(); const query = (
queryOverride !== undefined ? queryOverride : searchQuery
).trim();
if (!query) return; if (!query) return;
// Check if this is a d-tag search // Check if this is a d-tag search
if (query.toLowerCase().startsWith('d:')) { if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase(); const dTag = query.slice(2).trim().toLowerCase();
if (dTag) { if (dTag) {
const encoded = encodeURIComponent(dTag); const encoded = encodeURIComponent(dTag);
goto(`?d=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true }); goto(`?d=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
return; return;
} }
} }
@ -100,41 +125,51 @@
// Only update the URL if this is a manual search // Only update the URL if this is a manual search
if (clearInput) { if (clearInput) {
const encoded = encodeURIComponent(query); const encoded = encodeURIComponent(query);
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true }); goto(`?id=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
} }
if (clearInput) { if (clearInput) {
searchQuery = ''; searchQuery = "";
} }
// Clean the query and normalize to lowercase // Clean the query and normalize to lowercase
let cleanedQuery = query.replace(/^nostr:/, '').toLowerCase(); let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: any = cleanedQuery; let filterOrId: any = cleanedQuery;
console.log('[Events] Cleaned query:', cleanedQuery); console.log("[Events] Cleaned query:", cleanedQuery);
// NIP-05 address pattern: user@domain // NIP-05 address pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) { if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) {
try { try {
const [name, domain] = cleanedQuery.split('@'); const [name, domain] = cleanedQuery.split("@");
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`); const res = await fetch(
`https://${domain}/.well-known/nostr.json?name=${name}`,
);
const data = await res.json(); const data = await res.json();
const pubkey = data.names?.[name]; const pubkey = data.names?.[name];
if (pubkey) { if (pubkey) {
filterOrId = { kinds: [0], authors: [pubkey] }; filterOrId = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); const profileEvent = await fetchEventWithFallback(
$ndkInstance,
filterOrId,
10000,
);
if (profileEvent) { if (profileEvent) {
handleFoundEvent(profileEvent); handleFoundEvent(profileEvent);
return; return;
} else { } else {
localError = 'No profile found for this NIP-05 address.'; localError = "No profile found for this NIP-05 address.";
return; return;
} }
} else { } else {
localError = 'NIP-05 address not found.'; localError = "NIP-05 address not found.";
return; return;
} }
} catch (e) { } catch (e) {
localError = 'Error resolving NIP-05 address.'; localError = "Error resolving NIP-05 address.";
return; return;
} }
} }
@ -143,43 +178,56 @@
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) { if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) {
// Try as event id // Try as event id
filterOrId = cleanedQuery; filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); const eventResult = await fetchEventWithFallback(
$ndkInstance,
filterOrId,
10000,
);
// Always try as pubkey (profile event) as well // Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback($ndkInstance, profileFilter, 10000); const profileEvent = await fetchEventWithFallback(
$ndkInstance,
profileFilter,
10000,
);
// Prefer profile if found and pubkey matches query // Prefer profile if found and pubkey matches query
if (profileEvent && profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()) { if (
profileEvent &&
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()
) {
handleFoundEvent(profileEvent); handleFoundEvent(profileEvent);
} else if (eventResult) { } else if (eventResult) {
handleFoundEvent(eventResult); handleFoundEvent(eventResult);
} }
return; return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) { } else if (
/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)
) {
try { try {
const decoded = nip19.decode(cleanedQuery); const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error('Invalid identifier'); if (!decoded) throw new Error("Invalid identifier");
console.log('[Events] Decoded NIP-19:', decoded); console.log("[Events] Decoded NIP-19:", decoded);
switch (decoded.type) { switch (decoded.type) {
case 'nevent': case "nevent":
filterOrId = decoded.data.id; filterOrId = decoded.data.id;
break; break;
case 'note': case "note":
filterOrId = decoded.data; filterOrId = decoded.data;
break; break;
case 'naddr': case "naddr":
filterOrId = { filterOrId = {
kinds: [decoded.data.kind], kinds: [decoded.data.kind],
authors: [decoded.data.pubkey], authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier], "#d": [decoded.data.identifier],
}; };
break; break;
case 'nprofile': case "nprofile":
filterOrId = { filterOrId = {
kinds: [0], kinds: [0],
authors: [decoded.data.pubkey], authors: [decoded.data.pubkey],
}; };
break; break;
case 'npub': case "npub":
filterOrId = { filterOrId = {
kinds: [0], kinds: [0],
authors: [decoded.data], authors: [decoded.data],
@ -188,28 +236,32 @@
default: default:
filterOrId = cleanedQuery; filterOrId = cleanedQuery;
} }
console.log('[Events] Using filterOrId:', filterOrId); console.log("[Events] Using filterOrId:", filterOrId);
} catch (e) { } catch (e) {
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e); console.error("[Events] Invalid Nostr identifier:", cleanedQuery, e);
localError = 'Invalid Nostr identifier.'; localError = "Invalid Nostr identifier.";
return; return;
} }
} }
try { try {
console.log('Searching for event:', filterOrId); console.log("Searching for event:", filterOrId);
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); const event = await fetchEventWithFallback(
$ndkInstance,
filterOrId,
10000,
);
if (!event) { if (!event) {
console.warn('[Events] Event not found for filterOrId:', filterOrId); console.warn("[Events] Event not found for filterOrId:", filterOrId);
localError = 'Event not found'; localError = "Event not found";
} else { } else {
console.log('[Events] Event found:', event); console.log("[Events] Event found:", event);
handleFoundEvent(event); handleFoundEvent(event);
} }
} catch (err) { } catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', query); console.error("[Events] Error fetching event:", err, "Query:", query);
localError = 'Error fetching event. Please check the ID and try again.'; localError = "Error fetching event. Please check the ID and try again.";
} }
} }
@ -225,15 +277,18 @@
bind:value={searchQuery} bind:value={searchQuery}
placeholder="Enter event ID, nevent, naddr, or d:tag-name..." placeholder="Enter event ID, nevent, naddr, or d:tag-name..."
class="flex-grow" class="flex-grow"
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)} on:keydown={(e: KeyboardEvent) => e.key === "Enter" && searchEvent(true)}
/> />
<Button on:click={() => searchEvent(true)} disabled={loading}> <Button on:click={() => searchEvent(true)} disabled={loading}>
{loading ? 'Searching...' : 'Search'} {loading ? "Searching..." : "Search"}
</Button> </Button>
</div> </div>
{#if localError || error} {#if localError || error}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{localError || error} {localError || error}
{#if searchQuery.trim()} {#if searchQuery.trim()}
<div class="mt-2"> <div class="mt-2">
@ -242,8 +297,8 @@
class="underline text-primary-700" class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())} href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank" target="_blank"
rel="noopener" rel="noopener">Njump</a
>Njump</a>. >.
</div> </div>
{/if} {/if}
</div> </div>
@ -252,11 +307,13 @@
<div class="mt-4"> <div class="mt-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]} {#each Object.entries(relayStatuses) as [relay, status]}
<RelayDisplay {relay} showStatus={true} status={status} /> <RelayDisplay {relay} showStatus={true} {status} />
{/each} {/each}
</div> </div>
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')} {#if !foundEvent && Object.values(relayStatuses).some((s) => s === "pending")}
<div class="text-gray-700 dark:text-gray-300 mt-2">Searching relays...</div> <div class="text-gray-700 dark:text-gray-300 mt-2">
Searching relays...
</div>
{/if} {/if}
</div> </div>
</div> </div>

46
src/lib/components/Login.svelte

@ -1,21 +1,27 @@
<script lang='ts'> <script lang="ts">
import { type NDKUserProfile } from '@nostr-dev-kit/ndk'; import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk'; import {
import { Avatar, Button, Popover } from 'flowbite-svelte'; activePubkey,
loginWithExtension,
ndkInstance,
ndkSignedIn,
persistLogin,
} from "$lib/ndk";
import { Avatar, Button, Popover } from "flowbite-svelte";
import Profile from "$components/util/Profile.svelte"; import Profile from "$components/util/Profile.svelte";
let profile = $state<NDKUserProfile | null>(null); let profile = $state<NDKUserProfile | null>(null);
let npub = $state<string | undefined >(undefined); let npub = $state<string | undefined>(undefined);
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(''); let errorMessage = $state<string>("");
$effect(() => { $effect(() => {
if ($ndkSignedIn) { if ($ndkSignedIn) {
$ndkInstance $ndkInstance
.getUser({ pubkey: $activePubkey ?? undefined }) .getUser({ pubkey: $activePubkey ?? undefined })
?.fetchProfile() ?.fetchProfile()
.then(userProfile => { .then((userProfile) => {
profile = userProfile; profile = userProfile;
}); });
npub = $ndkInstance.activeUser?.npub; npub = $ndkInstance.activeUser?.npub;
@ -25,11 +31,11 @@
async function handleSignInClick() { async function handleSignInClick() {
try { try {
signInFailed = false; signInFailed = false;
errorMessage = ''; errorMessage = "";
const user = await loginWithExtension(); const user = await loginWithExtension();
if (!user) { if (!user) {
throw new Error('The NIP-07 extension did not return a user.'); throw new Error("The NIP-07 extension did not return a user.");
} }
profile = await user.fetchProfile(); profile = await user.fetchProfile();
@ -37,28 +43,24 @@
} catch (e) { } catch (e) {
console.error(e); console.error(e);
signInFailed = true; signInFailed = true;
errorMessage = e instanceof Error ? e.message : 'Failed to sign in. Please try again.'; errorMessage =
e instanceof Error ? e.message : "Failed to sign in. Please try again.";
} }
} }
</script> </script>
<div class="m-4"> <div class="m-4">
{#if $ndkSignedIn} {#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} /> <Profile pubkey={$activePubkey} isNav={true} />
{:else} {:else}
<Avatar rounded class='h-6 w-6 cursor-pointer bg-transparent' id='avatar' /> <Avatar rounded class="h-6 w-6 cursor-pointer bg-transparent" id="avatar" />
<Popover <Popover
class='popover-leather w-fit' class="popover-leather w-fit"
placement='bottom' placement="bottom"
triggeredBy='#avatar' triggeredBy="#avatar"
> >
<div class='w-full flex flex-col space-y-2'> <div class="w-full flex flex-col space-y-2">
<Button <Button onclick={handleSignInClick}>Extension Sign-In</Button>
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
{#if signInFailed} {#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded"> <div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage} {errorMessage}

64
src/lib/components/LoginModal.svelte

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

196
src/lib/components/Preview.svelte

@ -1,11 +1,26 @@
<script lang='ts'> <script lang="ts">
import { pharosInstance, SiblingSearchDirection } from '$lib/parser'; import { pharosInstance, SiblingSearchDirection } from "$lib/parser";
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte'; import {
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons'; Button,
import Self from './Preview.svelte'; ButtonGroup,
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte'; CloseButton,
Input,
P,
Textarea,
Tooltip,
} from "flowbite-svelte";
import {
CaretDownSolid,
CaretUpSolid,
EditOutline,
} from "flowbite-svelte-icons";
import Self from "./Preview.svelte";
import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -14,14 +29,14 @@
depth = 0, depth = 0,
isSectionStart, isSectionStart,
needsUpdate = $bindable<boolean>(), needsUpdate = $bindable<boolean>(),
oncursorcapture, oncursorcapture,
oncursorrelease, oncursorrelease,
parentId, parentId,
rootId, rootId,
index, index,
sectionClass, sectionClass,
publicationType, publicationType,
onBlogUpdate onBlogUpdate,
} = $props<{ } = $props<{
allowEditing?: boolean; allowEditing?: boolean;
depth?: number; depth?: number;
@ -39,7 +54,9 @@
let currentContent: string = $state($pharosInstance.getContent(rootId)); let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId)); let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId));
let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId)); let orderedChildren: string[] = $state(
$pharosInstance.getOrderedChildIds(rootId),
);
let blogEntries = $state(Array.from($pharosInstance.getBlogEntries())); let blogEntries = $state(Array.from($pharosInstance.getBlogEntries()));
let metadata = $state($pharosInstance.getIndexMetadata()); let metadata = $state($pharosInstance.getIndexMetadata());
@ -86,9 +103,17 @@
$effect(() => { $effect(() => {
if (parentId && allowEditing) { if (parentId && allowEditing) {
// Check for previous/next siblings on load // Check for previous/next siblings on load
const previousSibling = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous); const previousSibling = $pharosInstance.getNearestSibling(
const nextSibling = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Next); rootId,
depth - 1,
SiblingSearchDirection.Previous,
);
const nextSibling = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Next,
);
// Hide arrows if no siblings exist // Hide arrows if no siblings exist
hasPreviousSibling = !!previousSibling[0]; hasPreviousSibling = !!previousSibling[0];
hasNextSibling = !!nextSibling[0]; hasNextSibling = !!nextSibling[0];
@ -96,29 +121,32 @@
}); });
function getBlogEvent(index: number) { function getBlogEvent(index: number) {
return blogEntries[index][1]; return blogEntries[index][1];
} }
function byline(rootId: string, index: number) { function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries); console.log(rootId, index, blogEntries);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const author = event ? getMatchingTags(event, 'author')[0][1] : ''; const author = event ? getMatchingTags(event, "author")[0][1] : "";
return author ?? ""; return author ?? "";
} }
function hasCoverImage(rootId: string, index: number) { function hasCoverImage(rootId: string, index: number) {
console.log(rootId); console.log(rootId);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const image = event && getMatchingTags(event, 'image')[0] ? getMatchingTags(event, 'image')[0][1] : ''; const image =
return image ?? ''; event && getMatchingTags(event, "image")[0]
? getMatchingTags(event, "image")[0][1]
: "";
return image ?? "";
} }
function publishedAt(rootId: string, index: number) { function publishedAt(rootId: string, index: number) {
console.log(rootId, index); console.log(rootId, index);
console.log(blogEntries[index]); console.log(blogEntries[index]);
const event = blogEntries[index][1]; const event = blogEntries[index][1];
const date = event.created_at ? new Date(event.created_at * 1000) : ''; const date = event.created_at ? new Date(event.created_at * 1000) : "";
if (date !== '') { if (date !== "") {
const formattedDate = new Intl.DateTimeFormat("en-US", { const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -126,14 +154,14 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function readBlog(rootId:string) { function readBlog(rootId: string) {
onBlogUpdate?.(rootId); onBlogUpdate?.(rootId);
} }
function propagateBlogUpdate(rootId:string) { function propagateBlogUpdate(rootId: string) {
onBlogUpdate?.(rootId); onBlogUpdate?.(rootId);
} }
@ -167,7 +195,6 @@
if (editing && shouldSave) { if (editing && shouldSave) {
if (orderedChildren.length > 0) { if (orderedChildren.length > 0) {
} }
$pharosInstance.updateEventContent(id, currentContent); $pharosInstance.updateEventContent(id, currentContent);
@ -178,7 +205,11 @@
function moveUp(rootId: string, parentId: string) { function moveUp(rootId: string, parentId: string) {
// Get the previous sibling and its index // Get the previous sibling and its index
const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Previous); const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Previous,
);
if (!prevSiblingId || prevIndex == null) { if (!prevSiblingId || prevIndex == null) {
return; return;
} }
@ -186,11 +217,15 @@
// Move the current event before the previous sibling. // Move the current event before the previous sibling.
$pharosInstance.moveEvent(rootId, prevSiblingId, false); $pharosInstance.moveEvent(rootId, prevSiblingId, false);
needsUpdate = true; needsUpdate = true;
}; }
function moveDown(rootId: string, parentId: string) { function moveDown(rootId: string, parentId: string) {
// Get the next sibling and its index // Get the next sibling and its index
const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling(rootId, depth - 1, SiblingSearchDirection.Next); const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling(
rootId,
depth - 1,
SiblingSearchDirection.Next,
);
if (!nextSiblingId || nextIndex == null) { if (!nextSiblingId || nextIndex == null) {
return; return;
} }
@ -203,7 +238,9 @@
{#snippet sectionHeading(title: string, depth: number)} {#snippet sectionHeading(title: string, depth: number)}
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
{@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'} {@const className = $pharosInstance.isFloatingTitle(rootId)
? "discrete"
: "h-leather"}
<svelte:element this={`h${headingLevel}`} class={className}> <svelte:element this={`h${headingLevel}`} class={className}>
{title} {title}
@ -219,25 +256,25 @@
{/snippet} {/snippet}
{#snippet blogMetadata(rootId: string, index: number)} {#snippet blogMetadata(rootId: string, index: number)}
<p class='h-leather'> <p class="h-leather">
by {byline(rootId, index)} by {byline(rootId, index)}
</p> </p>
<p class='h-leather italic text-sm'> <p class="h-leather italic text-sm">
{publishedAt(rootId, index)} {publishedAt(rootId, index)}
</p> </p>
{/snippet} {/snippet}
{#snippet contentParagraph(content: string, publicationType: string)} {#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === 'novel'} {#if publicationType === "novel"}
<P class='whitespace-normal' firstupper={isSectionStart}> <P class="whitespace-normal" firstupper={isSectionStart}>
{@html content} {@html content}
</P> </P>
{:else if publicationType === 'blog'} {:else if publicationType === "blog"}
<P class='whitespace-normal' firstupper={false}> <P class="whitespace-normal" firstupper={false}>
{@html content} {@html content}
</P> </P>
{:else} {:else}
<P class='whitespace-normal' firstupper={false}> <P class="whitespace-normal" firstupper={false}>
{@html content} {@html content}
</P> </P>
{/if} {/if}
@ -249,28 +286,31 @@
class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`} class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`}
onmouseenter={handleMouseEnter} onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave} onmouseleave={handleMouseLeave}
aria-label='Publication section' aria-label="Publication section"
> >
<!-- Zettel base case --> <!-- Zettel base case -->
{#if orderedChildren.length === 0 || depth >= 4} {#if orderedChildren.length === 0 || depth >= 4}
{#key updateCount} {#key updateCount}
{#if isEditing} {#if isEditing}
<form class='w-full'> <form class="w-full">
<Textarea class='textarea-leather w-full whitespace-normal' bind:value={currentContent}> <Textarea
<div slot='footer' class='flex space-x-2 justify-end'> class="textarea-leather w-full whitespace-normal"
bind:value={currentContent}
>
<div slot="footer" class="flex space-x-2 justify-end">
<Button <Button
type='reset' type="reset"
class='btn-leather min-w-fit' class="btn-leather min-w-fit"
size='sm' size="sm"
outline outline
onclick={() => toggleEditing(rootId, false)} onclick={() => toggleEditing(rootId, false)}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type='submit' type="submit"
class='btn-leather min-w-fit' class="btn-leather min-w-fit"
size='sm' size="sm"
onclick={() => toggleEditing(rootId, true)} onclick={() => toggleEditing(rootId, true)}
> >
Save Save
@ -283,32 +323,43 @@
{/if} {/if}
{/key} {/key}
{:else} {:else}
<div class='flex flex-col space-y-2 w-full'> <div class="flex flex-col space-y-2 w-full">
{#if isEditing} {#if isEditing}
<ButtonGroup class='w-full'> <ButtonGroup class="w-full">
<Input type='text' class='input-leather' size='lg' bind:value={title}> <Input type="text" class="input-leather" size="lg" bind:value={title}>
<CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} /> <CloseButton
slot="right"
onclick={() => toggleEditing(rootId, false)}
/>
</Input> </Input>
<Button class='btn-leather' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}> <Button
class="btn-leather"
color="primary"
size="lg"
onclick={() => toggleEditing(rootId, true)}
>
Save Save
</Button> </Button>
</ButtonGroup> </ButtonGroup>
{:else} {:else if !(publicationType === "blog" && depth === 1)}
{#if !(publicationType === 'blog' && depth === 1)} {@render sectionHeading(title!, depth)}
{@render sectionHeading(title!, depth)}
{/if}
{/if} {/if}
<!-- Recurse on child indices and zettels --> <!-- Recurse on child indices and zettels -->
{#if publicationType === 'blog' && depth === 1} {#if publicationType === "blog" && depth === 1}
<BlogHeader event={getBlogEvent(index)} rootId={rootId} onBlogUpdate={readBlog} active={true} /> <BlogHeader
{:else } event={getBlogEvent(index)}
{rootId}
onBlogUpdate={readBlog}
active={true}
/>
{:else}
{#key subtreeUpdateCount} {#key subtreeUpdateCount}
{#each orderedChildren as id, index} {#each orderedChildren as id, index}
<Self <Self
rootId={id} rootId={id}
parentId={rootId} parentId={rootId}
index={index} {index}
publicationType={publicationType} {publicationType}
depth={depth + 1} depth={depth + 1}
{allowEditing} {allowEditing}
{sectionClass} {sectionClass}
@ -324,21 +375,38 @@
</div> </div>
{/if} {/if}
{#if allowEditing && depth > 0} {#if allowEditing && depth > 0}
<div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}> <div
class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? "visible" : "invisible"}`}
>
{#if hasPreviousSibling && parentId} {#if hasPreviousSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveUp(rootId, parentId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => moveUp(rootId, parentId)}
>
<CaretUpSolid /> <CaretUpSolid />
</Button> </Button>
{/if} {/if}
{#if hasNextSibling && parentId} {#if hasNextSibling && parentId}
<Button class='btn-leather' size='sm' outline onclick={() => moveDown(rootId, parentId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => moveDown(rootId, parentId)}
>
<CaretDownSolid /> <CaretDownSolid />
</Button> </Button>
{/if} {/if}
<Button class='btn-leather' size='sm' outline onclick={() => toggleEditing(rootId)}> <Button
class="btn-leather"
size="sm"
outline
onclick={() => toggleEditing(rootId)}
>
<EditOutline /> <EditOutline />
</Button> </Button>
<Tooltip class='tooltip-leather' type='auto' placement='top'> <Tooltip class="tooltip-leather" type="auto" placement="top">
Edit Edit
</Tooltip> </Tooltip>
</div> </div>

2
src/lib/components/Publication.svelte

@ -21,7 +21,7 @@
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte"; import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser'; import { pharosInstance } from "$lib/parser";
let { rootAddress, publicationType, indexEvent } = $props<{ let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string; rootAddress: string;

164
src/lib/components/PublicationFeed.svelte

@ -1,22 +1,38 @@
<script lang='ts'> <script lang="ts">
import { indexKind } from '$lib/consts'; import { indexKind } from "$lib/consts";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from '$lib/utils'; import { filterValidIndexEvents, debounce } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from './PublicationHeader.svelte'; import ArticleHeader from "./PublicationHeader.svelte";
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils'; import {
getMatchingTags,
let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>(); NDKRelaySetFromNDK,
type NDKEvent,
type NDKRelaySet,
} from "$lib/utils/nostrUtils";
let {
relays,
fallbackRelays,
searchQuery = "",
} = $props<{
relays: string[];
fallbackRelays: string[];
searchQuery?: string;
}>();
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false); let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); let relayStatuses = $state<Record<string, "pending" | "found" | "notfound">>(
{},
);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived( let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime() eventsInView?.at(eventsInView.length - 1)?.created_at ??
new Date().getTime(),
); );
let allIndexEvents: NDKEvent[] = $state([]); let allIndexEvents: NDKEvent[] = $state([]);
@ -25,47 +41,53 @@
loading = true; loading = true;
const ndk = $ndkInstance; const ndk = $ndkInstance;
const primaryRelays: string[] = relays; const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r)); const fallback: string[] = fallbackRelays.filter(
(r: string) => !primaryRelays.includes(r),
);
const allRelays = [...primaryRelays, ...fallback]; const allRelays = [...primaryRelays, ...fallback];
relayStatuses = Object.fromEntries(allRelays.map((r: string) => [r, 'pending'])); relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
let allEvents: NDKEvent[] = []; let allEvents: NDKEvent[] = [];
// Helper to fetch from a single relay with timeout // Helper to fetch from a single relay with timeout
async function fetchFromRelay(relay: string): Promise<NDKEvent[]> { async function fetchFromRelay(relay: string): Promise<NDKEvent[]> {
try { try {
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents( let eventSet = await ndk
{ .fetchEvents(
kinds: [indexKind], {
}, kinds: [indexKind],
{ },
groupable: false, {
skipVerification: false, groupable: false,
skipValidation: false, skipVerification: false,
}, skipValidation: false,
relaySet },
).withTimeout(5000); relaySet,
)
.withTimeout(5000);
eventSet = filterValidIndexEvents(eventSet); eventSet = filterValidIndexEvents(eventSet);
relayStatuses = { ...relayStatuses, [relay]: 'found' }; relayStatuses = { ...relayStatuses, [relay]: "found" };
return Array.from(eventSet); return Array.from(eventSet);
} catch (err) { } catch (err) {
console.error(`Error fetching from relay ${relay}:`, err); console.error(`Error fetching from relay ${relay}:`, err);
relayStatuses = { ...relayStatuses, [relay]: 'notfound' }; relayStatuses = { ...relayStatuses, [relay]: "notfound" };
return []; return [];
} }
} }
// Fetch from all relays in parallel, do not block on any single relay // Fetch from all relays in parallel, do not block on any single relay
const results = await Promise.allSettled( const results = await Promise.allSettled(allRelays.map(fetchFromRelay));
allRelays.map(fetchFromRelay)
);
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled') { if (result.status === "fulfilled") {
allEvents = allEvents.concat(result.value); allEvents = allEvents.concat(result.value);
} }
} }
// Deduplicate by tagAddress // Deduplicate by tagAddress
const eventMap = new Map(allEvents.map(event => [event.tagAddress(), event])); const eventMap = new Map(
allEvents.map((event) => [event.tagAddress(), event]),
);
allIndexEvents = Array.from(eventMap.values()); allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending // Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
@ -79,56 +101,63 @@
const filterEventsBySearch = (events: NDKEvent[]) => { const filterEventsBySearch = (events: NDKEvent[]) => {
if (!searchQuery) return events; if (!searchQuery) return events;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
console.debug('[PublicationFeed] Filtering events with query:', query, 'Total events before filter:', events.length); console.debug(
"[PublicationFeed] Filtering events with query:",
query,
"Total events before filter:",
events.length,
);
// Check if the query is a NIP-05 address // Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query); const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
console.debug('[PublicationFeed] Is NIP-05 query:', isNip05Query); console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter(event => { const filtered = events.filter((event) => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? ''; const title =
const authorName = getMatchingTags(event, 'author')[0]?.[1]?.toLowerCase() ?? ''; getMatchingTags(event, "title")[0]?.[1]?.toLowerCase() ?? "";
const authorName =
getMatchingTags(event, "author")[0]?.[1]?.toLowerCase() ?? "";
const authorPubkey = event.pubkey.toLowerCase(); const authorPubkey = event.pubkey.toLowerCase();
const nip05 = getMatchingTags(event, 'nip05')[0]?.[1]?.toLowerCase() ?? ''; const nip05 =
getMatchingTags(event, "nip05")[0]?.[1]?.toLowerCase() ?? "";
// For NIP-05 queries, only match against NIP-05 tags // For NIP-05 queries, only match against NIP-05 tags
if (isNip05Query) { if (isNip05Query) {
const matches = nip05 === query; const matches = nip05 === query;
if (matches) { if (matches) {
console.debug('[PublicationFeed] Event matches NIP-05 search:', { console.debug("[PublicationFeed] Event matches NIP-05 search:", {
id: event.id, id: event.id,
nip05, nip05,
authorPubkey authorPubkey,
}); });
} }
return matches; return matches;
} }
// For regular queries, match against all fields // For regular queries, match against all fields
const matches = ( const matches =
title.includes(query) || title.includes(query) ||
authorName.includes(query) || authorName.includes(query) ||
authorPubkey.includes(query) || authorPubkey.includes(query) ||
nip05.includes(query) nip05.includes(query);
);
if (matches) { if (matches) {
console.debug('[PublicationFeed] Event matches search:', { console.debug("[PublicationFeed] Event matches search:", {
id: event.id, id: event.id,
title, title,
authorName, authorName,
authorPubkey, authorPubkey,
nip05 nip05,
}); });
} }
return matches; return matches;
}); });
console.debug('[PublicationFeed] Events after filtering:', filtered.length); console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered; return filtered;
}; };
// Debounced search function // Debounced search function
const debouncedSearch = debounce(async (query: string) => { const debouncedSearch = debounce(async (query: string) => {
console.debug('[PublicationFeed] Search query changed:', query); console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) { if (query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents); const filtered = filterEventsBySearch(allIndexEvents);
eventsInView = filtered.slice(0, 30); eventsInView = filtered.slice(0, 30);
@ -140,14 +169,19 @@
}, 300); }, 300);
$effect(() => { $effect(() => {
console.debug('[PublicationFeed] Search query effect triggered:', searchQuery); console.debug(
"[PublicationFeed] Search query effect triggered:",
searchQuery,
);
debouncedSearch(searchQuery); debouncedSearch(searchQuery);
}); });
async function loadMorePublications() { async function loadMorePublications() {
loadingMore = true; loadingMore = true;
const current = eventsInView.length; const current = eventsInView.length;
let source = searchQuery.trim() ? filterEventsBySearch(allIndexEvents) : allIndexEvents; let source = searchQuery.trim()
? filterEventsBySearch(allIndexEvents)
: allIndexEvents;
eventsInView = source.slice(0, current + 30); eventsInView = source.slice(0, current + 30);
endOfFeed = eventsInView.length >= source.length; endOfFeed = eventsInView.length >= source.length;
loadingMore = false; loadingMore = false;
@ -168,40 +202,46 @@
}); });
</script> </script>
<div class='leather'> <div class="leather">
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#if loading && eventsInView.length === 0} {#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id} {#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' /> <Skeleton divClass="skeleton-leather w-full" size="lg" />
{/each} {/each}
{:else if eventsInView.length > 0} {:else if eventsInView.length > 0}
{#each eventsInView as event} {#each eventsInView as event}
<ArticleHeader {event} /> <ArticleHeader {event} />
{/each} {/each}
{:else} {:else}
<div class='col-span-full'> <div class="col-span-full">
<p class='text-center'>No publications found.</p> <p class="text-center">No publications found.</p>
</div> </div>
{/if} {/if}
</div> </div>
{#if !loadingMore && !endOfFeed} {#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'> <div class="flex justify-center mt-4 mb-8">
<Button outline class="w-full max-w-md" onclick={async () => { <Button
await loadMorePublications(); outline
}}> class="w-full max-w-md"
onclick={async () => {
await loadMorePublications();
}}
>
Show more publications Show more publications
</Button> </Button>
</div> </div>
{:else if loadingMore} {:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'> <div class="flex justify-center mt-4 mb-8">
<Button outline disabled class="w-full max-w-md"> <Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-600 dark:text-gray-300' size='4' /> <Spinner class="mr-3 text-gray-600 dark:text-gray-300" size="4" />
Loading... Loading...
</Button> </Button>
</div> </div>
{:else} {:else}
<div class='flex justify-center mt-4 mb-8'> <div class="flex justify-center mt-4 mb-8">
<P class='text-sm text-gray-700 dark:text-gray-300'>You've reached the end of the feed.</P> <P class="text-sm text-gray-700 dark:text-gray-300"
>You've reached the end of the feed.</P
>
</div> </div>
{/if} {/if}
</div> </div>

75
src/lib/components/PublicationHeader.svelte

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { naddrEncode } from '$lib/utils'; import { naddrEncode } from "$lib/utils";
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { standardRelays } from '../consts'; import { standardRelays } from "../consts";
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getUserMetadata, toNpub } from '$lib/utils/nostrUtils'; import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -15,28 +15,37 @@
}); });
const href = $derived.by(() => { const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1]; const d = event.getMatchingTags("d")[0]?.[1];
if (d != null) { if (d != null) {
return `publication?d=${d}`; return `publication?d=${d}`;
} else { } else {
return `publication?id=${naddrEncode(event, relays)}`; return `publication?id=${naddrEncode(event, relays)}`;
} }
} });
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); event.getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); );
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let version: string = $derived(
event.getMatchingTags("version")[0]?.[1] ?? "1",
);
let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
// New: fetch profile display name for authorPubkey // New: fetch profile display name for authorPubkey
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
$effect(() => { $effect(() => {
if (authorPubkey) { if (authorPubkey) {
getUserMetadata(toNpub(authorPubkey) as string).then(profile => { getUserMetadata(toNpub(authorPubkey) as string).then((profile) => {
authorDisplayName = profile.displayName || (profile as any).display_name || author || authorPubkey; authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
author ||
authorPubkey;
}); });
} else { } else {
authorDisplayName = undefined; authorDisplayName = undefined;
@ -47,31 +56,39 @@
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
<Card class='ArticleBox card-leather max-w-md h-48 flex flex-row items-center space-x-2'> <Card
class="ArticleBox card-leather max-w-md h-48 flex flex-row items-center space-x-2"
>
{#if image} {#if image}
<div class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"> <div
<Img src={image} class="rounded w-full h-full object-cover"/> class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
</div> >
<Img src={image} class="rounded w-full h-full object-cover" />
</div>
{/if} {/if}
<div class='col flex flex-row flex-grow space-x-4'> <div class="col flex flex-row flex-grow space-x-4">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<a href="/{href}" class='flex flex-col space-y-2'> <a href="/{href}" class="flex flex-col space-y-2">
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class='text-base font-normal'> <h3 class="text-base font-normal">
by by
{#if authorPubkey != null} {#if authorPubkey != null}
{@render userBadge(authorPubkey, authorDisplayName)} {@render userBadge(authorPubkey, authorDisplayName)}
{:else} {:else}
{author} {author}
{/if} {/if}
</h3> </h3>
{#if version != '1'} {#if version != "1"}
<h3 class='text-base font-medium text-primary-700 dark:text-primary-300'>version: {version}</h3> <h3
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
version: {version}
</h3>
{/if} {/if}
</a> </a>
</div> </div>
<div class="flex flex-col justify-start items-center"> <div class="flex flex-col justify-start items-center">
<CardActions event={event} /> <CardActions {event} />
</div> </div>
</div> </div>
</Card> </Card>

119
src/lib/components/PublicationSection.svelte

@ -1,13 +1,16 @@
<script lang='ts'> <script lang="ts">
console.log('PublicationSection loaded'); console.log("PublicationSection loaded");
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; import {
contentParagraph,
sectionHeading,
} from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte"; import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor"; import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { postProcessAdvancedAsciidoctorHtml } from '$lib/utils/markup/advancedAsciidoctorPostProcessor'; import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
let { let {
address, address,
@ -15,32 +18,37 @@
leaves, leaves,
ref, ref,
}: { }: {
address: string, address: string;
rootAddress: string, rootAddress: string;
leaves: Array<NDKEvent | null>, leaves: Array<NDKEvent | null>;
ref: (ref: HTMLElement) => void, ref: (ref: HTMLElement) => void;
} = $props(); } = $props();
const publicationTree: PublicationTree = getContext('publicationTree'); const publicationTree: PublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext('asciidoctor'); const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => let leafEvent: Promise<NDKEvent | null> = $derived.by(
await publicationTree.getEvent(address)); async () => await publicationTree.getEvent(address),
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => let rootEvent: Promise<NDKEvent | null> = $derived.by(
await publicationTree.getEvent(rootAddress)); async () => await publicationTree.getEvent(rootAddress),
);
let publicationType: Promise<string | undefined> = $derived.by(async () => let publicationType: Promise<string | undefined> = $derived.by(
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1],
);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => let leafHierarchy: Promise<NDKEvent[]> = $derived.by(
await publicationTree.getHierarchy(address)); async () => await publicationTree.getHierarchy(address),
);
let leafTitle: Promise<string | undefined> = $derived.by(async () => let leafTitle: Promise<string | undefined> = $derived.by(
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1],
);
let leafContent: Promise<string | Document> = $derived.by(async () => { let leafContent: Promise<string | Document> = $derived.by(async () => {
const rawContent = (await leafEvent)?.content ?? ''; const rawContent = (await leafEvent)?.content ?? "";
const asciidoctorHtml = asciidoctor.convert(rawContent); const asciidoctorHtml = asciidoctor.convert(rawContent);
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString()); return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString());
}); });
@ -51,7 +59,7 @@
let decrement = 1; let decrement = 1;
do { do {
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); index = leaves.findIndex((leaf) => leaf?.tagAddress() === address);
if (index === 0) { if (index === 0) {
return null; return null;
} }
@ -61,16 +69,21 @@
return event; return event;
}); });
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
if (!previousLeafEvent) { async () => {
return null; if (!previousLeafEvent) {
} return null;
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); }
}); return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
},
);
let divergingBranches = $derived.by(async () => { let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([
leafHierarchy,
previousLeafHierarchy,
]);
const branches: [NDKEvent, number][] = []; const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) { if (!previousLeafHierarchyValue) {
@ -80,22 +93,26 @@
return branches; return branches;
} }
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); const minLength = Math.min(
leafHierarchyValue.length,
previousLeafHierarchyValue.length,
);
// Find the first diverging node. // Find the first diverging node.
let divergingIndex = 0; let divergingIndex = 0;
while ( while (
divergingIndex < minLength && divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() leafHierarchyValue[divergingIndex].tagAddress() ===
previousLeafHierarchyValue[divergingIndex].tagAddress()
) { ) {
divergingIndex++; divergingIndex++;
} }
// Add all branches from the first diverging node to the current leaf. // Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]); branches.push([leafHierarchyValue[i], i]);
} }
return branches; return branches;
}); });
@ -111,24 +128,38 @@
$effect(() => { $effect(() => {
if (leafContent) { if (leafContent) {
console.log('leafContent HTML:', leafContent.toString()); console.log("leafContent HTML:", leafContent.toString());
} }
}); });
</script> </script>
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'> <section
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} id={address}
<TextPlaceholder size='xxl' /> 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]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{@const contentString = leafContent.toString()} {@const contentString = leafContent.toString()}
{@const _ = (() => { console.log('leafContent HTML:', contentString); return null; })()} {@const _ = (() => {
console.log("leafContent HTML:", contentString);
return null;
})()}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)} {@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
depth,
)}
{/each} {/each}
{#if leafTitle} {#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1} {@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)} {@render sectionHeading(leafTitle, leafDepth)}
{/if} {/if}
{@render contentParagraph(contentString, publicationType ?? 'article', false)} {@render contentParagraph(
contentString,
publicationType ?? "article",
false,
)}
{/await} {/await}
</section> </section>

103
src/lib/components/RelayActions.svelte

@ -1,10 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { get } from 'svelte/store'; import { get } from "svelte/store";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils'; import {
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte'; createRelaySetFromUrls,
createNDKEvent,
} from "$lib/utils/nostrUtils";
import RelayDisplay, {
getConnectedRelays,
getEventRelays,
} from "./RelayDisplay.svelte";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
const { event } = $props<{ const { event } = $props<{
@ -17,7 +23,9 @@
let broadcastSuccess = $state(false); let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null); let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false); let showRelayModal = $state(false);
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound">
>({});
let allRelays = $state<string[]>([]); let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG // Magnifying glass icon SVG
@ -39,7 +47,7 @@
try { try {
const connectedRelays = getConnectedRelays(); const connectedRelays = getConnectedRelays();
if (connectedRelays.length === 0) { if (connectedRelays.length === 0) {
throw new Error('No connected relays available'); throw new Error("No connected relays available");
} }
// Create a new event with the same content // Create a new event with the same content
@ -47,15 +55,16 @@
...event.rawEvent(), ...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey, pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
sig: '' sig: "",
}); });
// Publish to all relays // Publish to all relays
await newEvent.publish(); await newEvent.publish();
broadcastSuccess = true; broadcastSuccess = true;
} catch (err) { } catch (err) {
console.error('Error broadcasting event:', err); console.error("Error broadcasting event:", err);
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event'; broadcastError =
err instanceof Error ? err.message : "Failed to broadcast event";
} finally { } finally {
broadcasting = false; broadcasting = false;
} }
@ -71,53 +80,52 @@
if (!event) return; if (!event) return;
relaySearchResults = {}; relaySearchResults = {};
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url); const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(
allRelays = [ (r) => r.url,
...standardRelays, );
...userRelays, allRelays = [...standardRelays, ...userRelays, ...fallbackRelays].filter(
...fallbackRelays (url, idx, arr) => arr.indexOf(url) === idx,
].filter((url, idx, arr) => arr.indexOf(url) === idx); );
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending'])); relaySearchResults = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
await Promise.all( await Promise.all(
allRelays.map(async (relay: string) => { allRelays.map(async (relay: string) => {
try { try {
const relaySet = createRelaySetFromUrls([relay], ndk); const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent( const found = await ndk
{ ids: [event?.id || ''] }, .fetchEvent({ ids: [event?.id || ""] }, undefined, relaySet)
undefined, .withTimeout(3000);
relaySet relaySearchResults = {
).withTimeout(3000); ...relaySearchResults,
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' }; [relay]: found ? "found" : "notfound",
};
} catch { } catch {
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' }; relaySearchResults = { ...relaySearchResults, [relay]: "notfound" };
} }
}) }),
); );
} }
function closeRelayModal() { function closeRelayModal() {
showRelayModal = false; showRelayModal = false;
} }
</script> </script>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<Button <Button on:click={openRelayModal} class="flex items-center">
on:click={openRelayModal}
class="flex items-center"
>
{@html searchIcon} {@html searchIcon}
Where can I find this event? Where can I find this event?
</Button> </Button>
{#if $ndkInstance?.activeUser} {#if $ndkInstance?.activeUser}
<Button <Button
on:click={broadcastEvent} on:click={broadcastEvent}
disabled={broadcasting} disabled={broadcasting}
class="flex items-center" class="flex items-center"
> >
{@html broadcastIcon} {@html broadcastIcon}
{broadcasting ? 'Broadcasting...' : 'Broadcast'} {broadcasting ? "Broadcasting..." : "Broadcast"}
</Button> </Button>
{/if} {/if}
</div> </div>
@ -160,23 +168,32 @@
</div> </div>
{#if showRelayModal} {#if showRelayModal}
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"> <div
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"> class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"
<button class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100" onclick={closeRelayModal}>&times;</button> >
<div
class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"
>
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeRelayModal}>&times;</button
>
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2> <h2 class="text-lg font-semibold mb-4">Relay Search Results</h2>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto"> <div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries({ {#each Object.entries( { "Standard Relays": standardRelays, "User Relays": Array.from($ndkInstance?.pool?.relays.values() || []).map((r) => r.url), "Fallback Relays": fallbackRelays }, ) as [groupName, groupRelays]}
'Standard Relays': standardRelays,
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url),
'Fallback Relays': fallbackRelays
}) as [groupName, groupRelays]}
{#if groupRelays.length > 0} {#if groupRelays.length > 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h3 class="font-medium text-gray-900 dark:text-gray-100 sticky top-0 bg-white dark:bg-gray-900 py-2"> <h3
class="font-medium text-gray-900 dark:text-gray-100 sticky top-0 bg-white dark:bg-gray-900 py-2"
>
{groupName} {groupName}
</h3> </h3>
{#each groupRelays as relay} {#each groupRelays as relay}
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} /> <RelayDisplay
{relay}
showStatus={true}
status={relaySearchResults[relay] || null}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -187,4 +204,4 @@
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}

50
src/lib/components/RelayDisplay.svelte

@ -1,14 +1,16 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays) // Get relays from event (prefer event.relay or event.relays, fallback to standardRelays)
export function getEventRelays(event: NDKEvent): string[] { export function getEventRelays(event: NDKEvent): string[] {
if (event && (event as any).relay) { if (event && (event as any).relay) {
const relay = (event as any).relay; const relay = (event as any).relay;
return [typeof relay === 'string' ? relay : relay.url]; return [typeof relay === "string" ? relay : relay.url];
} }
if (event && (event as any).relays && (event as any).relays.length) { if (event && (event as any).relays && (event as any).relays.length) {
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url); return (event as any).relays.map((r: any) =>
typeof r === "string" ? r : r.url,
);
} }
return standardRelays; return standardRelays;
} }
@ -16,44 +18,60 @@
export function getConnectedRelays(): string[] { export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || []) return Array.from(ndk?.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays .filter((r) => r.status === 1) // Only use connected relays
.map(r => r.url); .map((r) => r.url);
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
export let relay: string; export let relay: string;
export let showStatus = false; export let showStatus = false;
export let status: 'pending' | 'found' | 'notfound' | null = null; export let status: "pending" | "found" | "notfound" | null = null;
// Use a static fallback icon for all relays // Use a static fallback icon for all relays
function relayFavicon(relay: string): string { function relayFavicon(relay: string): string {
return '/favicon.png'; return "/favicon.png";
} }
</script> </script>
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"> <div
class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"
>
<img <img
src={relayFavicon(relay)} src={relayFavicon(relay)}
alt="relay icon" alt="relay icon"
class="w-5 h-5 object-contain" class="w-5 h-5 object-contain"
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png";
}}
/> />
<span class="font-mono text-xs flex-1">{relay}</span> <span class="font-mono text-xs flex-1">{relay}</span>
{#if showStatus && status} {#if showStatus && status}
{#if status === 'pending'} {#if status === "pending"}
<svg class="w-4 h-4 animate-spin text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24"> <svg
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> class="w-4 h-4 animate-spin text-gray-600 dark:text-gray-400"
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path> fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"
></path>
</svg> </svg>
{:else if status === 'found'} {:else if status === "found"}
<span class="text-green-600">&#10003;</span> <span class="text-green-600">&#10003;</span>
{:else} {:else}
<span class="text-red-500">&#10007;</span> <span class="text-red-500">&#10007;</span>
{/if} {/if}
{/if} {/if}
</div> </div>

91
src/lib/components/RelayStatus.svelte

@ -1,11 +1,17 @@
<script lang="ts"> <script lang="ts">
import { Button, Alert } from 'flowbite-svelte'; import { Button, Alert } from "flowbite-svelte";
import { ndkInstance, ndkSignedIn, testRelayConnection, checkWebSocketSupport, checkEnvironmentForWebSocketDowngrade } from '$lib/ndk'; import {
import { standardRelays, anonymousRelays } from '$lib/consts'; ndkInstance,
import { onMount } from 'svelte'; ndkSignedIn,
import { feedType } from '$lib/stores'; testRelayConnection,
import { inboxRelays, outboxRelays } from '$lib/ndk'; checkWebSocketSupport,
import { FeedType } from '$lib/consts'; checkEnvironmentForWebSocketDowngrade,
} from "$lib/ndk";
import { standardRelays, anonymousRelays } from "$lib/consts";
import { onMount } from "svelte";
import { feedType } from "$lib/stores";
import { inboxRelays, outboxRelays } from "$lib/ndk";
import { FeedType } from "$lib/consts";
interface RelayStatus { interface RelayStatus {
url: string; url: string;
@ -30,58 +36,54 @@
if ($feedType === FeedType.UserRelays && $ndkSignedIn) { if ($feedType === FeedType.UserRelays && $ndkSignedIn) {
// Use user's relays (inbox + outbox), deduplicated // Use user's relays (inbox + outbox), deduplicated
const userRelays = new Set([ const userRelays = new Set([...$inboxRelays, ...$outboxRelays]);
...$inboxRelays,
...$outboxRelays
]);
relaysToTest = Array.from(userRelays); relaysToTest = Array.from(userRelays);
} else { } else {
// Use default relays (standard + anonymous), deduplicated // Use default relays (standard + anonymous), deduplicated
relaysToTest = Array.from(new Set([ relaysToTest = Array.from(
...standardRelays, new Set([...standardRelays, ...anonymousRelays]),
...anonymousRelays );
]));
} }
console.log('[RelayStatus] Relays to test:', relaysToTest); console.log("[RelayStatus] Relays to test:", relaysToTest);
relayStatuses = relaysToTest.map(url => ({ relayStatuses = relaysToTest.map((url) => ({
url, url,
connected: false, connected: false,
requiresAuth: false, requiresAuth: false,
testing: true testing: true,
})); }));
const results = await Promise.allSettled( const results = await Promise.allSettled(
relaysToTest.map(async (url) => { relaysToTest.map(async (url) => {
console.log('[RelayStatus] Testing relay:', url); console.log("[RelayStatus] Testing relay:", url);
try { try {
return await testRelayConnection(url, ndk); return await testRelayConnection(url, ndk);
} catch (error) { } catch (error) {
return { return {
connected: false, connected: false,
requiresAuth: false, requiresAuth: false,
error: error instanceof Error ? error.message : 'Unknown error' error: error instanceof Error ? error.message : "Unknown error",
}; };
} }
}) }),
); );
relayStatuses = relayStatuses.map((status, index) => { relayStatuses = relayStatuses.map((status, index) => {
const result = results[index]; const result = results[index];
if (result.status === 'fulfilled') { if (result.status === "fulfilled") {
return { return {
...status, ...status,
...result.value, ...result.value,
testing: false testing: false,
}; };
} else { } else {
return { return {
...status, ...status,
connected: false, connected: false,
requiresAuth: false, requiresAuth: false,
error: 'Test failed', error: "Test failed",
testing: false testing: false,
}; };
} }
}); });
@ -100,30 +102,26 @@
}); });
function getStatusColor(status: RelayStatus): string { function getStatusColor(status: RelayStatus): string {
if (status.testing) return 'text-yellow-600'; if (status.testing) return "text-yellow-600";
if (status.connected) return 'text-green-600'; if (status.connected) return "text-green-600";
if (status.requiresAuth && !$ndkSignedIn) return 'text-orange-600'; if (status.requiresAuth && !$ndkSignedIn) return "text-orange-600";
return 'text-red-600'; return "text-red-600";
} }
function getStatusText(status: RelayStatus): string { function getStatusText(status: RelayStatus): string {
if (status.testing) return 'Testing...'; if (status.testing) return "Testing...";
if (status.connected) return 'Connected'; if (status.connected) return "Connected";
if (status.requiresAuth && !$ndkSignedIn) return 'Requires Authentication'; if (status.requiresAuth && !$ndkSignedIn) return "Requires Authentication";
if (status.error) return `Error: ${status.error}`; if (status.error) return `Error: ${status.error}`;
return 'Failed to Connect'; return "Failed to Connect";
} }
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Relay Connection Status</h3> <h3 class="text-lg font-medium">Relay Connection Status</h3>
<Button <Button size="sm" onclick={runRelayTests} disabled={testing}>
size="sm" {testing ? "Testing..." : "Refresh"}
onclick={runRelayTests}
disabled={testing}
>
{testing ? 'Testing...' : 'Refresh'}
</Button> </Button>
</div> </div>
@ -131,8 +129,8 @@
<Alert color="yellow"> <Alert color="yellow">
<span class="font-medium">Anonymous Mode</span> <span class="font-medium">Anonymous Mode</span>
<p class="mt-1 text-sm"> <p class="mt-1 text-sm">
You are not signed in. Some relays require authentication and may not be accessible. You are not signed in. Some relays require authentication and may not be
Sign in to access all relays. accessible. Sign in to access all relays.
</p> </p>
</Alert> </Alert>
{/if} {/if}
@ -146,12 +144,17 @@
{getStatusText(status)} {getStatusText(status)}
</div> </div>
</div> </div>
<div class="w-3 h-3 rounded-full {getStatusColor(status).replace('text-', 'bg-')}"></div> <div
class="w-3 h-3 rounded-full {getStatusColor(status).replace(
'text-',
'bg-',
)}"
></div>
</div> </div>
{/each} {/each}
</div> </div>
{#if relayStatuses.some(s => s.requiresAuth && !$ndkSignedIn)} {#if relayStatuses.some((s) => s.requiresAuth && !$ndkSignedIn)}
<Alert color="orange"> <Alert color="orange">
<span class="font-medium">Authentication Required</span> <span class="font-medium">Authentication Required</span>
<p class="mt-1 text-sm"> <p class="mt-1 text-sm">
@ -159,4 +162,4 @@
</p> </p>
</Alert> </Alert>
{/if} {/if}
</div> </div>

36
src/lib/components/Toc.svelte

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

77
src/lib/components/cards/BlogHeader.svelte

@ -1,24 +1,38 @@
<script lang="ts"> <script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { scale } from 'svelte/transition'; import { scale } from "svelte/transition";
import { Card, Img } from "flowbite-svelte"; import { Card, Img } from "flowbite-svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing"; import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>(); const {
rootId,
event,
onBlogUpdate,
active = true,
} = $props<{
rootId: string;
event: NDKEvent;
onBlogUpdate?: any;
active: boolean;
}>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); );
let hashtags: string = $derived(event.getMatchingTags('t') ?? null); let image: string = $derived(event.getMatchingTags("image")[0]?.[1] ?? null);
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
let hashtags: string = $derived(event.getMatchingTags("t") ?? null);
function publishedAt() { function publishedAt() {
const date = event.created_at ? new Date(event.created_at * 1000) : ''; const date = event.created_at ? new Date(event.created_at * 1000) : "";
if (date !== '') { if (date !== "") {
const formattedDate = new Intl.DateTimeFormat("en-US", { const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -26,7 +40,7 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function showBlog() { function showBlog() {
@ -35,36 +49,41 @@
</script> </script>
{#if title != null} {#if title != null}
<Card class="ArticleBox card-leather w-full grid max-w-xl {active ? 'active' : ''}"> <Card
<div class='space-y-4'> class="ArticleBox card-leather w-full grid max-w-xl {active
? 'active'
: ''}"
>
<div class="space-y-4">
<div class="flex flex-row justify-between my-2"> <div class="flex flex-row justify-between my-2">
<div class="flex flex-col"> <div class="flex flex-col">
{@render userBadge(authorPubkey, author)} {@render userBadge(authorPubkey, author)}
<span class='text-gray-700 dark:text-gray-300'>{publishedAt()}</span> <span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div> </div>
<CardActions event={event} /> <CardActions {event} />
</div> </div>
{#if image && active} {#if image && active}
<div class="ArticleBoxImage flex col justify-center" <div
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }} class="ArticleBoxImage flex col justify-center"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
> >
<Img src={image} class="rounded w-full max-h-72 object-cover"/> <Img src={image} class="rounded w-full max-h-72 object-cover" />
</div> </div>
{/if} {/if}
<div class='flex flex-col flex-grow space-y-4'> <div class="flex flex-col flex-grow space-y-4">
<button onclick={() => showBlog()} class='text-left'> <button onclick={() => showBlog()} class="text-left">
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> <h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
</button> </button>
{#if hashtags} {#if hashtags}
<div class="tags"> <div class="tags">
{#each hashtags as tag} {#each hashtags as tag}
<span>{tag}</span> <span>{tag}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
{#if active} {#if active}
<Interactions rootId={rootId} event={event} /> <Interactions {rootId} {event} />
{/if} {/if}
</div> </div>
</Card> </Card>

233
src/lib/components/cards/ProfileHeader.svelte

@ -6,10 +6,18 @@
import QrCode from "$components/util/QrCode.svelte"; import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// @ts-ignore // @ts-ignore
import { bech32 } from 'https://esm.sh/bech32'; import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
const { event, profile, identifiers = [] } = $props<{ event: NDKEvent, profile: NostrProfile, identifiers?: { label: string, value: string, link?: string }[] }>(); const {
event,
profile,
identifiers = [],
} = $props<{
event: NDKEvent;
profile: NostrProfile;
identifiers?: { label: string; value: string; link?: string }[];
}>();
let lnModalOpen = $state(false); let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null); let lnurl = $state<string | null>(null);
@ -18,103 +26,150 @@
if (profile?.lud16) { if (profile?.lud16) {
try { try {
// Convert LN address to LNURL // Convert LN address to LNURL
const [name, domain] = profile?.lud16.split('@'); const [name, domain] = profile?.lud16.split("@");
const url = `https://${domain}/.well-known/lnurlp/${name}`; const url = `https://${domain}/.well-known/lnurlp/${name}`;
const words = bech32.toWords(new TextEncoder().encode(url)); const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode('lnurl', words); lnurl = bech32.encode("lnurl", words);
} catch { } catch {
console.log('Error converting LN address to LNURL'); console.log("Error converting LN address to LNURL");
} }
} }
}); });
</script> </script>
{#if profile} {#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl"> <Card class="ArticleBox card-leather w-full max-w-2xl">
<div class='space-y-4'> <div class="space-y-4">
{#if profile.banner} {#if profile.banner}
<div class="ArticleBoxImage flex col justify-center"> <div class="ArticleBoxImage flex col justify-center">
<Img src={profile.banner} class="rounded w-full max-h-72 object-cover" alt="Profile banner" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none';}} /> <Img
</div> src={profile.banner}
{/if} class="rounded w-full max-h-72 object-cover"
<div class='flex flex-row space-x-4 items-center'> alt="Profile banner"
{#if profile.picture} onerror={(e) => {
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> (e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
{/if} {/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.display_name || profile.name || event.pubkey)} <div class="flex flex-row space-x-4 items-center">
</div> {#if profile.picture}
<div> <img
<div class="mt-2 flex flex-col gap-4"> src={profile.picture}
<dl class="grid grid-cols-1 gap-y-2"> alt="Profile avatar"
{#if profile.name} class="w-16 h-16 rounded-full border"
<div class="flex gap-2"> onerror={(e) => {
<dt class="font-semibold min-w-[120px]">Name:</dt> (e.target as HTMLImageElement).src = "/favicon.png";
<dd>{profile.name}</dd> }}
</div> />
{/if} {/if}
{#if profile.displayName} {@render userBadge(
<div class="flex gap-2"> toNpub(event.pubkey) as string,
<dt class="font-semibold min-w-[120px]">Display Name:</dt> profile.displayName ||
<dd>{profile.displayName}</dd> profile.display_name ||
</div> profile.name ||
{/if} event.pubkey,
{#if profile.about} )}
<div class="flex gap-2"> </div>
<dt class="font-semibold min-w-[120px]">About:</dt> <div>
<dd class="whitespace-pre-line">{profile.about}</dd> <div class="mt-2 flex flex-col gap-4">
</div> <dl class="grid grid-cols-1 gap-y-2">
{/if} {#if profile.name}
{#if profile.website} <div class="flex gap-2">
<div class="flex gap-2"> <dt class="font-semibold min-w-[120px]">Name:</dt>
<dt class="font-semibold min-w-[120px]">Website:</dt> <dd>{profile.name}</dd>
<dd> </div>
<a href={profile.website} class="underline text-primary-700 dark:text-primary-200">{profile.website}</a> {/if}
</dd> {#if profile.displayName}
</div> <div class="flex gap-2">
{/if} <dt class="font-semibold min-w-[120px]">Display Name:</dt>
{#if profile.lud16} <dd>{profile.displayName}</dd>
<div class="flex items-center gap-2 mt-4"> </div>
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> {/if}
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd> {#if profile.about}
</div> <div class="flex gap-2">
{/if} <dt class="font-semibold min-w-[120px]">About:</dt>
{#if profile.nip05} <dd class="whitespace-pre-line">{profile.about}</dd>
<div class="flex gap-2"> </div>
<dt class="font-semibold min-w-[120px]">NIP-05:</dt> {/if}
<dd>{profile.nip05}</dd> {#if profile.website}
</div> <div class="flex gap-2">
{/if} <dt class="font-semibold min-w-[120px]">Website:</dt>
{#each identifiers as id} <dd>
<div class="flex gap-2"> <a
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> href={profile.website}
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd> class="underline text-primary-700 dark:text-primary-200"
</div> >{profile.website}</a
{/each} >
</dl> </dd>
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd>
<Button
class="btn-leather"
color="primary"
outline
onclick={() => (lnModalOpen = true)}>{profile.lud16}</Button
>
</dd>
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
{#if id.link}<a
href={id.link}
class="underline text-primary-700 dark:text-primary-200 break-all"
>{id.value}</a
>{:else}{id.value}{/if}
</dd>
</div>
{/each}
</dl>
</div>
</div> </div>
</div> </div>
</div> </Card>
</Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'> <Modal
{#if profile.lud16} class="modal-leather"
<div> title="Lightning Address"
<div class='flex flex-col items-center'> bind:open={lnModalOpen}
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)} outsideclose
<P>{profile.lud16}</P> size="sm"
</div> >
<div class="flex flex-col items-center mt-3 space-y-4"> {#if profile.lud16}
<P>Scan the QR code or copy the address</P> <div>
{#if lnurl} <div class="flex flex-col items-center">
<P style="overflow-wrap: anywhere"> {@render userBadge(
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard> toNpub(event.pubkey) as string,
</P> profile?.displayName || profile.name || event.pubkey,
<QrCode value={lnurl} /> )}
{:else} <P>{profile.lud16}</P>
<P>Couldn't generate address.</P> </div>
{/if} <div class="flex flex-col items-center mt-3 space-y-4">
</div> <P>Scan the QR code or copy the address</P>
</div> {#if lnurl}
{/if} <P style="overflow-wrap: anywhere">
</Modal> <CopyToClipboard icon={false} displayText={lnurl}
{/if} ></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
{/if}

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

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

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

@ -3,7 +3,7 @@
ClipboardCleanOutline, ClipboardCleanOutline,
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ShareNodesOutline ShareNodesOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte"; import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays, FeedType } from "$lib/consts"; import { standardRelays, FeedType } from "$lib/consts";
@ -18,17 +18,39 @@
let { event } = $props<{ event: NDKEvent }>(); let { event } = $props<{ event: NDKEvent }>();
// Derive metadata from event // Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? ''); let title = $derived(
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[1] ?? ''); event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
let image = $derived(event.tags.find((t: string[]) => t[0] === 'image')?.[1] ?? null); );
let author = $derived(event.tags.find((t: string[]) => t[0] === 'author')?.[1] ?? ''); let summary = $derived(
let originalAuthor = $derived(event.tags.find((t: string[]) => t[0] === 'original_author')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "",
let version = $derived(event.tags.find((t: string[]) => t[0] === 'version')?.[1] ?? ''); );
let source = $derived(event.tags.find((t: string[]) => t[0] === 'source')?.[1] ?? null); let image = $derived(
let type = $derived(event.tags.find((t: string[]) => t[0] === 'type')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "image")?.[1] ?? null,
let language = $derived(event.tags.find((t: string[]) => t[0] === 'language')?.[1] ?? null); );
let publisher = $derived(event.tags.find((t: string[]) => t[0] === 'publisher')?.[1] ?? null); let author = $derived(
let identifier = $derived(event.tags.find((t: string[]) => t[0] === 'identifier')?.[1] ?? null); event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "",
);
let originalAuthor = $derived(
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null,
);
let version = $derived(
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "",
);
let source = $derived(
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null,
);
let type = $derived(
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null,
);
let language = $derived(
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null,
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state // UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
@ -43,18 +65,18 @@
(() => { (() => {
const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays; const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays;
const relays = isUserFeed ? $inboxRelays : standardRelays; const relays = isUserFeed ? $inboxRelays : standardRelays;
console.debug("[CardActions] Selected relays:", { console.debug("[CardActions] Selected relays:", {
eventId: event.id, eventId: event.id,
isSignedIn: $ndkSignedIn, isSignedIn: $ndkSignedIn,
feedType: $feedType, feedType: $feedType,
isUserFeed, isUserFeed,
relayCount: relays.length, relayCount: relays.length,
relayUrls: relays relayUrls: relays,
}); });
return relays; return relays;
})() })(),
); );
/** /**
@ -71,7 +93,7 @@
function closePopover() { function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id }); console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false; isOpen = false;
const menu = document.getElementById('dots-' + event.id); const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur(); if (menu) menu.blur();
} }
@ -80,10 +102,13 @@
* @param type - The type of identifier to get ('nevent' or 'naddr') * @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string * @returns The encoded identifier string
*/ */
function getIdentifier(type: 'nevent' | 'naddr'): string { function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode; const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays); const identifier = encodeFn(event, activeRelays);
console.debug("[CardActions] ${type} identifier for event ${event.id}:", identifier); console.debug(
"[CardActions] ${type} identifier for event ${event.id}:",
identifier,
);
return identifier; return identifier;
} }
@ -91,10 +116,10 @@
* Opens the event details modal * Opens the event details modal
*/ */
function viewDetails() { function viewDetails() {
console.debug("[CardActions] Opening details modal", { console.debug("[CardActions] Opening details modal", {
eventId: event.id, eventId: event.id,
title: event.title, title: event.title,
author: event.author author: event.author,
}); });
detailsModalOpen = true; detailsModalOpen = true;
} }
@ -105,90 +130,128 @@
kind: event.kind, kind: event.kind,
pubkey: event.pubkey, pubkey: event.pubkey,
title: event.title, title: event.title,
author: event.author author: event.author,
}); });
</script> </script>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}> <div
class="group bg-highlight dark:bg-primary-1000 rounded"
role="group"
onmouseenter={openPopover}
>
<!-- Main button --> <!-- Main button -->
<Button type="button" <Button
id="dots-{event.id}" type="button"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" color="none" id="dots-{event.id}"
data-popover-target="popover-actions"> class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
<DotsVerticalOutline class="h-6 w-6"/> color="none"
data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" />
<span class="sr-only">Open actions menu</span> <span class="sr-only">Open actions menu</span>
</Button> </Button>
{#if isOpen} {#if isOpen}
<Popover id="popover-actions" <Popover
placement="bottom" id="popover-actions"
trigger="click" placement="bottom"
class='popover-leather w-fit z-10' trigger="click"
onmouseleave={closePopover} class="popover-leather w-fit z-10"
> onmouseleave={closePopover}
<div class='flex flex-row justify-between space-x-4'> >
<div class='flex flex-col text-nowrap'> <div class="flex flex-row justify-between space-x-4">
<ul class="space-y-2"> <div class="flex flex-col text-nowrap">
<li> <ul class="space-y-2">
<button class='btn-leather w-full text-left' onclick={viewDetails}> <li>
<EyeOutline class="inline mr-2" /> View details <button
</button> class="btn-leather w-full text-left"
</li> onclick={viewDetails}
<li> >
<CopyToClipboard <EyeOutline class="inline mr-2" /> View details
displayText="Copy naddr address" </button>
copyText={getIdentifier('naddr')} </li>
icon={ShareNodesOutline} <li>
/> <CopyToClipboard
</li> displayText="Copy naddr address"
<li> copyText={getIdentifier("naddr")}
<CopyToClipboard icon={ShareNodesOutline}
displayText="Copy nevent address" />
copyText={getIdentifier('nevent')} </li>
icon={ClipboardCleanOutline} <li>
/> <CopyToClipboard
</li> displayText="Copy nevent address"
</ul> copyText={getIdentifier("nevent")}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
</div>
</div> </div>
</div> </Popover>
</Popover>
{/if} {/if}
<!-- Event details --> <!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'> <Modal
class="modal-leather"
title="Publication details"
bind:open={detailsModalOpen}
autoclose
outsideclose
size="sm"
>
<div class="flex flex-row space-x-4"> <div class="flex flex-row space-x-4">
{#if image} {#if image}
<div class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"> <div
<img src={image} alt="Publication cover" class="rounded w-full h-full object-cover" /> class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<img
src={image}
alt="Publication cover"
class="rounded w-full h-full object-cover"
/>
</div> </div>
{/if} {/if}
<div class="flex flex-col col space-y-5 justify-center align-middle"> <div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || 'Untitled'}</h1> <h1 class="text-3xl font-bold mt-5">{title || "Untitled"}</h1>
<h2 class="text-base font-bold">by <h2 class="text-base font-bold">
by
{#if originalAuthor} {#if originalAuthor}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author)}
{:else} {:else}
{author || 'Unknown'} {author || "Unknown"}
{/if} {/if}
</h2> </h2>
{#if version} {#if version}
<h4 class='text-base font-medium text-primary-700 dark:text-primary-300 mt-2'>Version: {version}</h4> <h4
class="text-base font-medium text-primary-700 dark:text-primary-300 mt-2"
>
Version: {version}
</h4>
{/if} {/if}
</div> </div>
</div> </div>
{#if summary} {#if summary}
<div class="flex flex-row"> <div class="flex flex-row">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p> <p class="text-base text-primary-900 dark:text-highlight">{summary}</p>
</div> </div>
{/if} {/if}
<div class="flex flex-row"> <div class="flex flex-row">
<h4 class='text-base font-normal mt-2'>Index author: {@render userBadge(event.pubkey, author)}</h4> <h4 class="text-base font-normal mt-2">
Index author: {@render userBadge(event.pubkey, author)}
</h4>
</div> </div>
<div class="flex flex-col pb-4 space-y-1"> <div class="flex flex-col pb-4 space-y-1">
{#if source} {#if source}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank" rel="noopener noreferrer">{source}</a></h5> <h5 class="text-sm">
Source: <a
class="underline"
href={source}
target="_blank"
rel="noopener noreferrer">{source}</a
>
</h5>
{/if} {/if}
{#if type} {#if type}
<h5 class="text-sm">Publication type: {type}</h5> <h5 class="text-sm">Publication type: {type}</h5>
@ -202,12 +265,12 @@
{#if identifier} {#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5> <h5 class="text-sm">Identifier: {identifier}</h5>
{/if} {/if}
<a <a
href="/events?id={getIdentifier('nevent')}" href="/events?id={getIdentifier('nevent')}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold" class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
> >
View Event Details View Event Details
</a> </a>
</div> </div>
</Modal> </Modal>
</div> </div>

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

@ -1,9 +1,16 @@
<script lang='ts'> <script lang="ts">
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; import {
ClipboardCheckOutline,
ClipboardCleanOutline,
} from "flowbite-svelte-icons";
import { withTimeout } from "$lib/utils/nostrUtils"; import { withTimeout } from "$lib/utils/nostrUtils";
import type { Component } from "svelte"; import type { Component } from "svelte";
let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{ let {
displayText,
copyText = displayText,
icon = ClipboardCleanOutline,
} = $props<{
displayText: string; displayText: string;
copyText?: string; copyText?: string;
icon?: Component | false; icon?: Component | false;
@ -16,21 +23,26 @@
await withTimeout(navigator.clipboard.writeText(copyText), 2000); await withTimeout(navigator.clipboard.writeText(copyText), 2000);
copied = true; copied = true;
await withTimeout( await withTimeout(
new Promise(resolve => setTimeout(resolve, 4000)), new Promise((resolve) => setTimeout(resolve, 4000)),
4000 4000,
).then(() => { )
copied = false; .then(() => {
}).catch(() => { copied = false;
// If timeout occurs, still reset the state })
copied = false; .catch(() => {
}); // If timeout occurs, still reset the state
copied = false;
});
} catch (err) { } catch (err) {
console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err); console.error(
"[CopyToClipboard] Failed to copy:",
err instanceof Error ? err.message : err,
);
} }
} }
</script> </script>
<button class='btn-leather w-full text-left' onclick={copyToClipboard}> <button class="btn-leather w-full text-left" onclick={copyToClipboard}>
{#if copied} {#if copied}
<ClipboardCheckOutline class="inline mr-2" /> Copied! <ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else} {:else}

90
src/lib/components/util/Details.svelte

@ -3,57 +3,82 @@
import CardActions from "$components/util/CardActions.svelte"; import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte"; import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte"; import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props(); let { event, isModal = false } = $props();
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1'); getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null); );
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null); let version: string = $derived(
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null); getMatchingTags(event, "version")[0]?.[1] ?? "1",
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null); );
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null); let image: string = $derived(getMatchingTags(event, "image")[0]?.[1] ?? null);
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null); let originalAuthor: string = $derived(
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null); getMatchingTags(event, "p")[0]?.[1] ?? null,
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null); );
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1])); let summary: string = $derived(
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null); getMatchingTags(event, "summary")[0]?.[1] ?? null,
);
let type: string = $derived(getMatchingTags(event, "type")[0]?.[1] ?? null);
let language: string = $derived(getMatchingTags(event, "l")[0]?.[1] ?? null);
let source: string = $derived(
getMatchingTags(event, "source")[0]?.[1] ?? null,
);
let publisher: string = $derived(
getMatchingTags(event, "published_by")[0]?.[1] ?? null,
);
let identifier: string = $derived(
getMatchingTags(event, "i")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(
getMatchingTags(event, "t").map((tag) => tag[1]),
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind); let kind = $derived(event.kind);
</script> </script>
<div class="flex flex-col relative mb-2"> <div class="flex flex-col relative mb-2">
{#if !isModal} {#if !isModal}
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P> <P class="text-base font-normal"
<CardActions event={event}></CardActions> >{@render userBadge(event.pubkey, author)}</P
>
<CardActions {event}></CardActions>
</div> </div>
{/if} {/if}
<div class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"> <div
class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"
>
{#if image} {#if image}
<div class="my-2"> <div class="my-2">
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} /> <img
class="w-full md:max-w-48 object-contain rounded"
alt={title}
src={image}
/>
</div> </div>
{/if} {/if}
<div class="space-y-4 my-4"> <div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1> <h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold"> <h2 class="text-base font-bold">
by by
{#if originalAuthor !== null} {#if originalAuthor !== null}
{@render userBadge(originalAuthor, author)} {@render userBadge(originalAuthor, author)}
{:else} {:else}
{author} {author}
{/if} {/if}
</h2> </h2>
{#if version !== '1' } {#if version !== "1"}
<h4 class="text-base font-medium text-primary-700 dark:text-primary-300">Version: {version}</h4> <h4
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
Version: {version}
</h4>
{/if} {/if}
</div> </div>
</div> </div>
@ -61,7 +86,7 @@
{#if summary} {#if summary}
<div class="flex flex-row my-2"> <div class="flex flex-row my-2">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p> <p class="text-base text-primary-900 dark:text-highlight">{summary}</p>
</div> </div>
{/if} {/if}
@ -75,7 +100,7 @@
{#if isModal} {#if isModal}
<div class="flex flex-row my-4"> <div class="flex flex-row my-4">
<h4 class='text-base font-normal mt-2'> <h4 class="text-base font-normal mt-2">
{#if kind === 30040} {#if kind === 30040}
<span>Index author:</span> <span>Index author:</span>
{:else} {:else}
@ -85,10 +110,13 @@
</h4> </h4>
</div> </div>
<div class="flex flex-col pb-4 space-y-1"> <div class="flex flex-col pb-4 space-y-1">
{#if source !== null} {#if source !== null}
<h5 class="text-sm">Source: <a class="underline break-all" href={source} target="_blank">{source}</a></h5> <h5 class="text-sm">
Source: <a class="underline break-all" href={source} target="_blank"
>{source}</a
>
</h5>
{/if} {/if}
{#if type !== null} {#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5> <h5 class="text-sm">Publication type: {type}</h5>
@ -106,5 +134,5 @@
{/if} {/if}
{#if !isModal} {#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/> <Interactions {event} {rootId} direction="row" />
{/if} {/if}

84
src/lib/components/util/Interactions.svelte

@ -1,15 +1,21 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal, P } from "flowbite-svelte";
import { import {
Button, Modal, P HeartOutline,
} from "flowbite-svelte"; FilePenOutline,
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons'; AnnotationOutline,
} from "flowbite-svelte-icons";
import ZapOutline from "$components/util/ZapOutline.svelte"; import ZapOutline from "$components/util/ZapOutline.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
const { rootId, event, direction = 'row' } = $props<{ rootId: string, event?: NDKEvent, direction?: string }>(); const {
rootId,
event,
direction = "row",
} = $props<{ rootId: string; event?: NDKEvent; direction?: string }>();
// Reactive arrays to hold incoming events // Reactive arrays to hold incoming events
let likes: NDKEvent[] = []; let likes: NDKEvent[] = [];
@ -34,13 +40,12 @@
function subscribeCount(kind: number, targetArray: NDKEvent[]) { function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({ const sub = $ndkInstance.subscribe({
kinds: [kind], kinds: [kind],
'#a': [rootId] // Will this work? "#a": [rootId], // Will this work?
}); });
sub.on("event", (evt: NDKEvent) => {
sub.on('event', (evt: NDKEvent) => {
// Only add if we haven't seen this event ID yet // Only add if we haven't seen this event ID yet
if (!targetArray.find(e => e.id === evt.id)) { if (!targetArray.find((e) => e.id === evt.id)) {
targetArray.push(evt); targetArray.push(evt);
} }
}); });
@ -52,18 +57,18 @@
onMount(() => { onMount(() => {
// Subscribe to each kind; store subs for cleanup // Subscribe to each kind; store subs for cleanup
subs.push(subscribeCount(7, likes)); // likes (Reaction) subs.push(subscribeCount(7, likes)); // likes (Reaction)
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts) subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts)
subs.push(subscribeCount(30023, highlights)); // highlights (custom kind) subs.push(subscribeCount(30023, highlights)); // highlights (custom kind)
subs.push(subscribeCount(1, comments)); // comments (Text Notes) subs.push(subscribeCount(1, comments)); // comments (Text Notes)
}); });
function showDiscussion() { function showDiscussion() {
publicationColumnVisibility.update(v => { publicationColumnVisibility.update((v) => {
const updated = { ...v, discussion: true}; const updated = { ...v, discussion: true };
// hide blog, unless the only column // hide blog, unless the only column
if (v.inner) { if (v.inner) {
updated.blog = (v.blog && window.innerWidth >= 1400 ); updated.blog = v.blog && window.innerWidth >= 1400;
} }
return updated; return updated;
}); });
@ -80,14 +85,45 @@
} }
</script> </script>
<div class='InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300'> <div
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doLike}><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button> class="InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-300"
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doZap}><ZapOutline className="mx-2" /><span>{zapCount}</span></Button> >
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doHighlight}><FilePenOutline class="mx-2" size="lg"/><span>{highlightCount}</span></Button> <Button
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={showDiscussion}><AnnotationOutline class="mx-2" size="lg"/><span>{commentCount}</span></Button> color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doLike}
><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doZap}
><ZapOutline className="mx-2" /><span>{zapCount}</span></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={doHighlight}
><FilePenOutline class="mx-2" size="lg" /><span>{highlightCount}</span
></Button
>
<Button
color="none"
class="flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0"
onclick={showDiscussion}
><AnnotationOutline class="mx-2" size="lg" /><span>{commentCount}</span
></Button
>
</div> </div>
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'> <Modal
<P>Can't like, zap or highlight yet.</P> class="modal-leather"
<P>You should totally check out the discussion though.</P> title="Interaction"
</Modal> bind:open={interactionOpen}
autoclose
outsideclose
size="sm"
>
<P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P>
</Modal>

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

@ -1,99 +1,111 @@
<script lang='ts'> <script lang="ts">
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { logout, ndkInstance } from '$lib/ndk'; import { logout, ndkInstance } from "$lib/ndk";
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; import {
import { Avatar, Popover } from "flowbite-svelte"; ArrowRightToBracketOutline,
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = './events?id=' const externalProfileDestination = "./events?id=";
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();
let profile = $state<NDKUserProfile | null>(null); let profile = $state<NDKUserProfile | null>(null);
let pfp = $derived(profile?.image); let pfp = $derived(profile?.image);
let username = $derived(profile?.name); let username = $derived(profile?.name);
let tag = $derived(profile?.name); let tag = $derived(profile?.name);
let npub = $state<string | undefined >(undefined); let npub = $state<string | undefined>(undefined);
$effect(() => { $effect(() => {
const user = $ndkInstance const user = $ndkInstance.getUser({ pubkey: pubkey ?? undefined });
.getUser({ pubkey: pubkey ?? undefined });
npub = user.npub; npub = user.npub;
user.fetchProfile() user.fetchProfile().then((userProfile) => {
.then(userProfile => {
profile = userProfile; profile = userProfile;
}); });
}); });
async function handleSignOutClick() { async function handleSignOutClick() {
logout($ndkInstance.activeUser!); logout($ndkInstance.activeUser!);
profile = null; profile = null;
} }
function shortenNpub(long: string|undefined) { function shortenNpub(long: string | undefined) {
if (!long) return ''; if (!long) return "";
return long.slice(0, 8) + '…' + long.slice(-4); return long.slice(0, 8) + "…" + long.slice(-4);
} }
</script> </script>
<div class="relative"> <div class="relative">
{#if profile} {#if profile}
<div class="group"> <div class="group">
<Avatar <Avatar
rounded rounded
class='h-6 w-6 cursor-pointer' class="h-6 w-6 cursor-pointer"
src={pfp} src={pfp}
alt={username} alt={username}
id="profile-avatar" id="profile-avatar"
/> />
{#key username || tag} {#key username || tag}
<Popover <Popover
placement="bottom" placement="bottom"
triggeredBy="#profile-avatar" triggeredBy="#profile-avatar"
class='popover-leather w-[180px]' class="popover-leather w-[180px]"
trigger='hover' trigger="hover"
> >
<div class='flex flex-row justify-between space-x-4'> <div class="flex flex-row justify-between space-x-4">
<div class='flex flex-col'> <div class="flex flex-col">
{#if username} {#if username}
<h3 class='text-lg font-bold'>{username}</h3> <h3 class="text-lg font-bold">{username}</h3>
{#if isNav}<h4 class='text-base'>@{tag}</h4>{/if} {#if isNav}<h4 class="text-base">@{tag}</h4>{/if}
{/if} {/if}
<ul class="space-y-2 mt-2"> <ul class="space-y-2 mt-2">
<li>
<CopyToClipboard displayText={shortenNpub(npub)} copyText={npub} />
</li>
<li>
<a class='hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0' href='{externalProfileDestination}{npub}'>
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span>
</a>
</li>
{#if isNav}
<li> <li>
<button <CopyToClipboard
id='sign-out-button' displayText={shortenNpub(npub)}
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' copyText={npub}
onclick={handleSignOutClick} />
</li>
<li>
<a
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0"
href="{externalProfileDestination}{npub}"
> >
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out <UserOutline
</button> class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
</a>
</li> </li>
{:else} {#if isNav}
<!-- li> <li>
<button
id="sign-out-button"
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick}
>
<ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button>
</li>
{:else}
<!-- li>
<button <button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
> >
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content <FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</button> </button>
</li --> </li -->
{/if} {/if}
</ul> </ul>
</div>
</div> </div>
</div> </Popover>
</Popover> {/key}
{/key} </div>
</div>
{/if} {/if}
</div> </div>

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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import QRCode from 'qrcode'; import QRCode from "qrcode";
export let value: string; export let value: string;
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;

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

@ -9,7 +9,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser"; import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores"; import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>(); let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) { if (rootId !== $pharosInstance.getRootIndexId()) {
@ -33,13 +33,13 @@
tocUpdate; tocUpdate;
const items: TocItem[] = []; const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId); const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log('TOC rootId:', rootId, 'childIds:', childIds); console.log("TOC rootId:", rootId, "childIds:", childIds);
const processNode = (nodeId: string) => { const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId); const title = $pharosInstance.getIndexTitle(nodeId);
if (title) { if (title) {
items.push({ items.push({
label: title, label: title,
hash: `#${nodeId}` hash: `#${nodeId}`,
}); });
} }
const children = $pharosInstance.getChildIndexIds(nodeId); const children = $pharosInstance.getChildIndexIds(nodeId);
@ -83,7 +83,10 @@
*/ */
function setTocVisibilityOnResize() { function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium // Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint })); publicationColumnVisibility.update((v) => ({
...v,
toc: window.innerWidth >= tocBreakpoint,
}));
} }
/** /**
@ -98,7 +101,7 @@
// Only allow hiding TOC on screens smaller than tocBreakpoint // Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) { if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update(v => ({ ...v, toc: false})); publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
} }
} }
@ -125,14 +128,18 @@
<!-- TODO: Get TOC from parser. --> <!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc} {#if $publicationColumnVisibility.toc}
<Sidebar class='sidebar-leather left-0'> <Sidebar class="sidebar-leather left-0">
<SidebarWrapper> <SidebarWrapper>
<SidebarGroup class='sidebar-group-leather'> <SidebarGroup class="sidebar-group-leather">
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading> <Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>(This ToC is only for demo purposes, and is not fully-functional.)</p> <p>
(This ToC is only for demo purposes, and is not fully-functional.)
</p>
{#each tocItems as item} {#each tocItems as item}
<SidebarItem <SidebarItem
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}" class="sidebar-item-leather {activeHash === item.hash
? 'bg-primary-200 font-bold'
: ''}"
label={item.label} label={item.label}
href={item.hash} href={item.hash}
/> />
@ -140,4 +147,4 @@
</SidebarGroup> </SidebarGroup>
</SidebarWrapper> </SidebarWrapper>
</Sidebar> </Sidebar>
{/if} {/if}

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

@ -1,6 +1,6 @@
<script> <script>
export let size = 24; // default size export let size = 24; // default size
export let className = ''; export let className = "";
</script> </script>
<svg <svg
@ -15,5 +15,5 @@
class={className} class={className}
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg> </svg>

54
src/lib/consts.ts

@ -1,40 +1,42 @@
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [ 30041, 30818 ]; export const zettelKinds = [30041, 30818];
export const communityRelay = [ 'wss://theforest.nostr1.com' ]; export const communityRelay = ["wss://theforest.nostr1.com"];
export const standardRelays = [ export const standardRelays = [
'wss://thecitadel.nostr1.com', "wss://thecitadel.nostr1.com",
'wss://theforest.nostr1.com', "wss://theforest.nostr1.com",
'wss://profiles.nostr1.com', "wss://profiles.nostr1.com",
'wss://gitcitadel.nostr1.com', "wss://gitcitadel.nostr1.com",
//'wss://thecitadel.gitcitadel.eu', //'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu', //'wss://theforest.gitcitadel.eu',
]; ];
// Non-auth relays for anonymous users // Non-auth relays for anonymous users
export const anonymousRelays = [ export const anonymousRelays = [
'wss://thecitadel.nostr1.com', "wss://thecitadel.nostr1.com",
'wss://theforest.nostr1.com', "wss://theforest.nostr1.com",
'wss://profiles.nostr1.com', "wss://profiles.nostr1.com",
'wss://freelay.sovbit.host', "wss://freelay.sovbit.host",
]; ];
export const fallbackRelays = [ export const fallbackRelays = [
'wss://purplepag.es', "wss://purplepag.es",
'wss://indexer.coracle.social', "wss://indexer.coracle.social",
'wss://relay.noswhere.com', "wss://relay.noswhere.com",
'wss://aggr.nostr.land', "wss://aggr.nostr.land",
'wss://nostr.wine', "wss://nostr.wine",
'wss://nostr.land', "wss://nostr.land",
'wss://nostr.sovbit.host', "wss://nostr.sovbit.host",
'wss://freelay.sovbit.host', "wss://freelay.sovbit.host",
'wss://nostr21.com', "wss://nostr21.com",
'wss://greensoul.space', "wss://greensoul.space",
"wss://relay.damus.io",
"wss://relay.nostr.band",
]; ];
export enum FeedType { export enum FeedType {
StandardRelays = 'standard', StandardRelays = "standard",
UserRelays = 'user', UserRelays = "user",
} }
export const loginStorageKey = 'alexandria/login/pubkey'; export const loginStorageKey = "alexandria/login/pubkey";
export const feedTypeStorageKey = 'alexandria/feed/type'; export const feedTypeStorageKey = "alexandria/feed/type";

6
src/lib/data_structures/lazy.ts

@ -18,9 +18,9 @@ export class Lazy<T> {
/** /**
* Resolves the lazy object and returns the value. * Resolves the lazy object and returns the value.
* *
* @returns The resolved value. * @returns The resolved value.
* *
* @remarks Lazy object resolution is performed as an atomic operation. If a resolution has * @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 * 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 * invocation is returned. Thus, all calls to this function before it is resolved will depend on
@ -52,4 +52,4 @@ export class Lazy<T> {
this.#pendingPromise = null; this.#pendingPromise = null;
} }
} }
} }

105
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,7 @@
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts"; import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts'; import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType { enum PublicationTreeNodeType {
Branch, Branch,
@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}; };
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>(); this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root))); this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#events = new Map<string, NDKEvent>(); this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent); this.#events.set(rootAddress, rootEvent);
@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -116,7 +119,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) { if (!parentNode) {
throw new Error( throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.` `PublicationTree: Parent node with address ${parentAddress} not found.`,
); );
} }
@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getChildAddresses(address: string): Promise<Array<string | null>> { async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value(); const node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`); throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
} }
return Promise.all( return Promise.all(
node.children?.map(async child => node.children?.map(
(await child.value())?.address ?? null async (child) => (await child.value())?.address ?? null,
) ?? [] ) ?? [],
); );
} }
/** /**
@ -163,11 +168,13 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> { async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value(); let node = await this.#nodes.get(address)?.value();
if (!node) { if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`); throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
} }
const hierarchy: NDKEvent[] = [this.#events.get(address)!]; const hierarchy: NDKEvent[] = [this.#events.get(address)!];
while (node.parent) { while (node.parent) {
hierarchy.push(this.#events.get(node.parent.address)!); hierarchy.push(this.#events.get(node.parent.address)!);
node = node.parent; node = node.parent;
@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #region Iteration Cursor // #region Iteration Cursor
#cursor = new class { #cursor = new (class {
target: PublicationTreeNode | null | undefined; target: PublicationTreeNode | null | undefined;
#tree: PublicationTree; #tree: PublicationTree;
@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) { async tryMoveTo(address?: string) {
if (!address) { if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve(); const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
?.value();
} else { } else {
this.target = await this.#tree.#nodes.get(address)?.value(); this.target = await this.#tree.#nodes.get(address)?.value();
} }
@ -224,7 +233,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (this.target.children == null || this.target.children.length === 0) { if (this.target.children == null || this.target.children.length === 0) {
return false; return false;
} }
this.target = await this.target.children?.at(0)?.value(); this.target = await this.target.children?.at(0)?.value();
return true; return true;
} }
@ -234,15 +243,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
console.debug("Cursor: Target node is null or undefined."); console.debug("Cursor: Target node is null or undefined.");
return false; return false;
} }
if (this.target.type === PublicationTreeNodeType.Leaf) { if (this.target.type === PublicationTreeNodeType.Leaf) {
return false; return false;
} }
if (this.target.children == null || this.target.children.length === 0) { if (this.target.children == null || this.target.children.length === 0) {
return false; return false;
} }
this.target = await this.target.children?.at(-1)?.value(); this.target = await this.target.children?.at(-1)?.value();
return true; return true;
} }
@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentIndex = await siblings.findIndexAsync( 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) { if (currentIndex === -1) {
@ -280,25 +290,26 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
console.debug("Cursor: Target node is null or undefined."); console.debug("Cursor: Target node is null or undefined.");
return false; return false;
} }
const parent = this.target.parent; const parent = this.target.parent;
const siblings = parent?.children; const siblings = parent?.children;
if (!siblings) { if (!siblings) {
return false; return false;
} }
const currentIndex = await siblings.findIndexAsync( 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) { if (currentIndex === -1) {
return false; return false;
} }
if (currentIndex <= 0) { if (currentIndex <= 0) {
return false; return false;
} }
this.target = await siblings.at(currentIndex - 1)?.value(); this.target = await siblings.at(currentIndex - 1)?.value();
return true; return true;
} }
@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent; this.target = parent;
return true; return true;
} }
}(this); })(this);
// #endregion // #endregion
@ -369,7 +380,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
return { done: false, value: event }; return { done: false, value: event };
} }
} }
// Based on Raymond Chen's tree traversal algorithm example. // Based on Raymond Chen's tree traversal algorithm example.
// https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 // https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300
do { do {
@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address]; const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root; let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!; let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
while (stack.length > 0) { while (stack.length > 0) {
const currentAddress = stack.pop(); const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value(); currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) { if (!currentNode) {
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`); throw new Error(
`PublicationTree: Node with address ${currentAddress} not found.`,
);
} }
currentEvent = this.#events.get(currentAddress!); currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) { if (!currentEvent) {
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`); throw new Error(
`PublicationTree: Event with address ${currentAddress} not found.`,
);
} }
// Stop immediately if the target of the search is found. // Stop immediately if the target of the search is found.
@ -431,8 +448,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
const currentChildAddresses = currentEvent.tags const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a') .filter((tag) => tag[0] === "a")
.map(tag => tag[1]); .map((tag) => tag[1]);
// If the current event has no children, it is a leaf. // If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) { if (currentChildAddresses.length === 0) {
@ -465,38 +482,42 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
if (this.#nodes.has(address)) { if (this.#nodes.has(address)) {
console.debug(`[PublicationTree] Node with address ${address} already exists.`); console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return; return;
} }
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode)); const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
parentNode.children!.push(lazyNode); parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode); this.#nodes.set(address, lazyNode);
} }
/** /**
* Resolves a node address into an event, and creates new nodes for its children. * Resolves a node address into an event, and creates new nodes for its children.
* *
* This method is intended for use as a {@link Lazy} resolver. * This method is intended for use as a {@link Lazy} resolver.
* *
* @param address The address of the node to resolve. * @param address The address of the node to resolve.
* @param parentNode The parent node of the node to resolve. * @param parentNode The parent node of the node to resolve.
* @returns The resolved node. * @returns The resolved node.
*/ */
async #resolveNode( async #resolveNode(
address: string, address: string,
parentNode: PublicationTreeNode parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> { ): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':'); const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({ const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)], kinds: [parseInt(kind)],
authors: [pubkey], authors: [pubkey],
'#d': [dTag], "#d": [dTag],
}); });
if (!event) { if (!event) {
console.debug( console.debug(
`PublicationTree: Event with address ${address} not found.` `PublicationTree: Event with address ${address} not found.`,
); );
return { return {
@ -510,8 +531,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event); this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
type: this.#getNodeType(event), type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved, status: PublicationTreeNodeStatus.Resolved,
@ -528,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
#getNodeType(event: NDKEvent): PublicationTreeNodeType { #getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
return PublicationTreeNodeType.Branch; return PublicationTreeNodeType.Branch;
} }
@ -536,4 +559,4 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
} }
// #endregion // #endregion
} }

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

@ -1,12 +1,12 @@
<!-- Legend Component (Svelte 5, Runes Mode) --> <!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts"> <script lang="ts">
import {Button} from 'flowbite-svelte'; import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let { let { collapsedOnInteraction = false, className = "" } = $props<{
collapsedOnInteraction = false, collapsedOnInteraction: boolean;
className = "" className: string;
} = $props<{collapsedOnInteraction: boolean, className: string}>(); }>();
let expanded = $state(true); let expanded = $state(true);
@ -24,7 +24,13 @@
<div class={`leather-legend ${className}`}> <div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3"> <div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3> <h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" > <Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded} {#if expanded}
<CaretUpOutline /> <CaretUpOutline />
{:else} {:else}
@ -38,16 +44,18 @@
<!-- Index event node --> <!-- Index event node -->
<li class="legend-item"> <li class="legend-item">
<div class="legend-icon"> <div class="legend-icon">
<span <span
class="legend-circle" class="legend-circle"
style="background-color: hsl(200, 70%, 75%)" style="background-color: hsl(200, 70%, 75%)"
> >
<span class="legend-letter">I</span> <span class="legend-letter">I</span>
</span> </span>
</div> </div>
<span class="legend-text">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> </li>
<!-- Content event node --> <!-- Content event node -->
<li class="legend-item"> <li class="legend-item">
<div class="legend-icon"> <div class="legend-icon">
@ -55,15 +63,17 @@
<span class="legend-letter">C</span> <span class="legend-letter">C</span>
</span> </span>
</div> </div>
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span> <span class="legend-text"
>Content events (kinds 30041, 30818) - Publication sections</span
>
</li> </li>
<!-- Link arrow --> <!-- Link arrow -->
<li class="legend-item"> <li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> <svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path <path
d="M4 12h16M16 6l6 6-6 6" d="M4 12h16M16 6l6 6-6 6"
class="network-link-leather" class="network-link-leather"
stroke-width="2" stroke-width="2"
fill="none" fill="none"
/> />

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

@ -7,25 +7,31 @@
<script lang="ts"> <script lang="ts">
import type { NetworkNode } from "./types"; import type { NetworkNode } from "./types";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
// Component props // Component props
let { node, selected = false, x, y, onclose } = $props<{ let {
node: NetworkNode; // The node to display information for node,
selected?: boolean; // Whether the node is selected (clicked) selected = false,
x: number; // X position for the tooltip x,
y: number; // Y position for the tooltip y,
onclose: () => void; // Function to call when closing the tooltip 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
}>(); }>();
// DOM reference and positioning // DOM reference and positioning
let tooltipElement: HTMLDivElement; let tooltipElement: HTMLDivElement;
let tooltipX = $state(x + 10); // Add offset to avoid cursor overlap let tooltipX = $state(x + 10); // Add offset to avoid cursor overlap
let tooltipY = $state(y - 10); let tooltipY = $state(y - 10);
// Maximum content length to display // Maximum content length to display
const MAX_CONTENT_LENGTH = 200; const MAX_CONTENT_LENGTH = 200;
/** /**
* Gets the author name from the event tags * Gets the author name from the event tags
*/ */
@ -38,7 +44,7 @@
} }
return "Unknown"; return "Unknown";
} }
/** /**
* Gets the summary from the event tags * Gets the summary from the event tags
*/ */
@ -51,7 +57,7 @@
} }
return null; return null;
} }
/** /**
* Gets the d-tag from the event * Gets the d-tag from the event
*/ */
@ -64,23 +70,26 @@
} }
return "View Publication"; return "View Publication";
} }
/** /**
* Truncates content to a maximum length * Truncates content to a maximum length
*/ */
function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string { function truncateContent(
content: string,
maxLength: number = MAX_CONTENT_LENGTH,
): string {
if (!content) return ""; if (!content) return "";
if (content.length <= maxLength) return content; if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "..."; return content.substring(0, maxLength) + "...";
} }
/** /**
* Closes the tooltip * Closes the tooltip
*/ */
function closeTooltip() { function closeTooltip() {
onclose(); onclose();
} }
/** /**
* Ensures tooltip is fully visible on screen * Ensures tooltip is fully visible on screen
*/ */
@ -90,20 +99,20 @@
const windowWidth = window.innerWidth; const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const padding = 10; // Padding from window edges const padding = 10; // Padding from window edges
// Adjust position if tooltip goes off screen // Adjust position if tooltip goes off screen
if (rect.right > windowWidth) { if (rect.right > windowWidth) {
tooltipX = windowWidth - rect.width - padding; tooltipX = windowWidth - rect.width - padding;
} }
if (rect.bottom > windowHeight) { if (rect.bottom > windowHeight) {
tooltipY = windowHeight - rect.height - padding; tooltipY = windowHeight - rect.height - padding;
} }
if (rect.left < 0) { if (rect.left < 0) {
tooltipX = padding; tooltipX = padding;
} }
if (rect.top < 0) { if (rect.top < 0) {
tooltipY = padding; tooltipY = padding;
} }
@ -117,33 +126,35 @@
style="left: {tooltipX}px; top: {tooltipY}px;" style="left: {tooltipX}px; top: {tooltipY}px;"
> >
<!-- Close button --> <!-- Close button -->
<button <button class="tooltip-close-btn" onclick={closeTooltip} aria-label="Close">
class="tooltip-close-btn" <svg
onclick={closeTooltip} xmlns="http://www.w3.org/2000/svg"
aria-label="Close" class="h-4 w-4"
> viewBox="0 0 20 20"
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> fill="currentColor"
<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" /> >
<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> </svg>
</button> </button>
<!-- Tooltip content --> <!-- Tooltip content -->
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a <a href="/publication?id={node.id}" class="tooltip-title-link">
href="/publication?id={node.id}"
class="tooltip-title-link"
>
{node.title || "Untitled"} {node.title || "Untitled"}
</a> </a>
</div> </div>
<!-- Node type and kind --> <!-- Node type and kind -->
<div class="tooltip-metadata"> <div class="tooltip-metadata">
{node.type} (kind: {node.kind}) {node.type} (kind: {node.kind})
</div> </div>
<!-- Author --> <!-- Author -->
<div class="tooltip-metadata"> <div class="tooltip-metadata">
Author: {getAuthorTag(node)} Author: {getAuthorTag(node)}
@ -152,7 +163,8 @@
<!-- Summary (for index nodes) --> <!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)} {#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary"> <div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")} <span class="font-semibold">Summary:</span>
{truncateContent(getSummaryTag(node) || "")}
</div> </div>
{/if} {/if}
@ -162,12 +174,10 @@
{truncateContent(node.content)} {truncateContent(node.content)}
</div> </div>
{/if} {/if}
<!-- Help text for selected nodes --> <!-- Help text for selected nodes -->
{#if selected} {#if selected}
<div class="tooltip-help-text"> <div class="tooltip-help-text">Click node again to dismiss</div>
Click node again to dismiss
</div>
{/if} {/if}
</div> </div>
</div> </div>

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

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

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

@ -11,19 +11,19 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { levelsToRender } from "$lib/state"; import { levelsToRender } from "$lib/state";
import { generateGraph, getEventColor } from "./utils/networkBuilder"; import { generateGraph, getEventColor } from "./utils/networkBuilder";
import { import {
createSimulation, createSimulation,
setupDragHandlers, setupDragHandlers,
applyGlobalLogGravity, applyGlobalLogGravity,
applyConnectedGravity, applyConnectedGravity,
type Simulation type Simulation,
} from "./utils/forceSimulation"; } from "./utils/forceSimulation";
import Legend from "./Legend.svelte"; import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte"; import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types"; import type { NetworkNode, NetworkLink } from "./types";
import Settings from "./Settings.svelte"; import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte'; import { Button } from "flowbite-svelte";
// Type alias for D3 selections // Type alias for D3 selections
type Selection = any; type Selection = any;
@ -34,7 +34,7 @@
const ARROW_DISTANCE = 10; const ARROW_DISTANCE = 10;
const CONTENT_COLOR_LIGHT = "#d6c1a8"; const CONTENT_COLOR_LIGHT = "#d6c1a8";
const CONTENT_COLOR_DARK = "#FFFFFF"; const CONTENT_COLOR_DARK = "#FFFFFF";
/** /**
* Debug logging function that only logs when DEBUG is true * Debug logging function that only logs when DEBUG is true
*/ */
@ -43,9 +43,12 @@
console.log("[EventNetwork]", ...args); console.log("[EventNetwork]", ...args);
} }
} }
// Component props // Component props
let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>(); let { events = [], onupdate } = $props<{
events?: NDKEvent[];
onupdate: () => void;
}>();
// Error state // Error state
let errorMessage = $state<string | null>(null); let errorMessage = $state<string | null>(null);
@ -54,10 +57,10 @@
// DOM references // DOM references
let svg: SVGSVGElement; let svg: SVGSVGElement;
let container: HTMLDivElement; let container: HTMLDivElement;
// Theme state // Theme state
let isDarkMode = $state(false); let isDarkMode = $state(false);
// Tooltip state // Tooltip state
let selectedNodeId = $state<string | null>(null); let selectedNodeId = $state<string | null>(null);
let tooltipVisible = $state(false); let tooltipVisible = $state(false);
@ -69,8 +72,10 @@
let width = $state(1000); let width = $state(1000);
let height = $state(600); let height = $state(600);
let windowHeight = $state<number | undefined>(undefined); let windowHeight = $state<number | undefined>(undefined);
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400); let graphHeight = $derived(
windowHeight ? Math.max(windowHeight * 0.2, 400) : 400,
);
// D3 objects // D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null; let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
let svgGroup: Selection; let svgGroup: Selection;
@ -100,8 +105,7 @@
} }
debug("SVG dimensions", { width, height }); debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg) const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
.attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content // Clear existing content
svgElement.selectAll("*").remove(); svgElement.selectAll("*").remove();
@ -147,80 +151,81 @@
function updateGraph() { function updateGraph() {
debug("Updating graph"); debug("Updating graph");
errorMessage = null; errorMessage = null;
// Create variables to hold our selections // Create variables to hold our selections
let link: any; let link: any;
let node: any; let node: any;
let dragHandler: any; let dragHandler: any;
let nodes: NetworkNode[] = []; let nodes: NetworkNode[] = [];
let links: NetworkLink[] = []; let links: NetworkLink[] = [];
try { try {
// Validate required elements // Validate required elements
if (!svg) { if (!svg) {
throw new Error("SVG element not found"); throw new Error("SVG element not found");
} }
if (!events?.length) { if (!events?.length) {
throw new Error("No events to render"); throw new Error("No events to render");
} }
if (!svgGroup) { if (!svgGroup) {
throw new Error("SVG group not found"); throw new Error("SVG group not found");
} }
// Generate graph data from events // Generate graph data from events
debug("Generating graph with events", { debug("Generating graph with events", {
eventCount: events.length, eventCount: events.length,
currentLevels currentLevels,
}); });
const graphData = generateGraph(events, Number(currentLevels)); const graphData = generateGraph(events, Number(currentLevels));
nodes = graphData.nodes; nodes = graphData.nodes;
links = graphData.links; links = graphData.links;
debug("Generated graph data", { debug("Generated graph data", {
nodeCount: nodes.length, nodeCount: nodes.length,
linkCount: links.length linkCount: links.length,
}); });
if (!nodes.length) { if (!nodes.length) {
throw new Error("No nodes to render"); throw new Error("No nodes to render");
} }
// Stop any existing simulation // Stop any existing simulation
if (simulation) { if (simulation) {
debug("Stopping existing simulation"); debug("Stopping existing simulation");
simulation.stop(); simulation.stop();
} }
// Create new simulation // Create new simulation
debug("Creating new simulation"); debug("Creating new simulation");
simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE); simulation = createSimulation(nodes, links, NODE_RADIUS, LINK_DISTANCE);
// Center the nodes when the simulation is done // Center the nodes when the simulation is done
simulation.on("end", () => { simulation.on("end", () => {
centerGraph(); centerGraph();
}); });
// Create drag handler // Create drag handler
dragHandler = setupDragHandlers(simulation); dragHandler = setupDragHandlers(simulation);
// Update links // Update links
debug("Updating links"); debug("Updating links");
link = svgGroup link = svgGroup
.selectAll("path.link") .selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`) .data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join( .join(
(enter: any) => enter (enter: any) =>
.append("path") enter
.attr("class", "link network-link-leather") .append("path")
.attr("stroke-width", 2) .attr("class", "link network-link-leather")
.attr("marker-end", "url(#arrowhead)"), .attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(update: any) => update, (update: any) => update,
(exit: any) => exit.remove() (exit: any) => exit.remove(),
); );
// Update nodes // Update nodes
debug("Updating nodes"); debug("Updating nodes");
node = svgGroup node = svgGroup
@ -260,24 +265,28 @@
return nodeEnter; return nodeEnter;
}, },
(update: any) => update, (update: any) => update,
(exit: any) => exit.remove() (exit: any) => exit.remove(),
); );
// Update node appearances // Update node appearances
debug("Updating node appearances"); debug("Updating node appearances");
node.select("circle.visual-circle") node
.attr("class", (d: NetworkNode) => !d.isContainer .select("circle.visual-circle")
? "visual-circle network-node-leather network-node-content" .attr("class", (d: NetworkNode) =>
: "visual-circle network-node-leather" !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather",
) )
.attr("fill", (d: NetworkNode) => !d.isContainer .attr("fill", (d: NetworkNode) =>
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT !d.isContainer
: getEventColor(d.id) ? isDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
); );
node.select("text") node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C"));
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
// Set up node interactions // Set up node interactions
debug("Setting up node interactions"); debug("Setting up node interactions");
node node
@ -316,15 +325,20 @@
tooltipY = event.pageY; tooltipY = event.pageY;
} }
}); });
// Set up simulation tick handler // Set up simulation tick handler
debug("Setting up simulation tick handler"); debug("Setting up simulation tick handler");
if (simulation) { if (simulation) {
simulation.on("tick", () => { simulation.on("tick", () => {
// Apply custom forces to each node // Apply custom forces to each node
nodes.forEach(node => { nodes.forEach((node) => {
// Pull nodes toward the center // Pull nodes toward the center
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha()); applyGlobalLogGravity(
node,
width / 2,
height / 2,
simulation!.alpha(),
);
// Pull connected nodes toward each other // Pull connected nodes toward each other
applyConnectedGravity(node, links, simulation!.alpha()); applyConnectedGravity(node, links, simulation!.alpha());
}); });
@ -349,7 +363,10 @@
}); });
// Update node positions // Update node positions
node.attr("transform", (d: NetworkNode) => `translate(${d.x},${d.y})`); node.attr(
"transform",
(d: NetworkNode) => `translate(${d.x},${d.y})`,
);
}); });
} }
} catch (error) { } catch (error) {
@ -366,14 +383,14 @@
try { try {
// Detect initial theme // Detect initial theme
isDarkMode = document.body.classList.contains("dark"); isDarkMode = document.body.classList.contains("dark");
// Initialize the graph structure // Initialize the graph structure
initializeGraph(); initializeGraph();
} catch (error) { } catch (error) {
console.error("Error in onMount:", error); console.error("Error in onMount:", error);
errorMessage = `Error initializing graph: ${error instanceof Error ? error.message : String(error)}`; errorMessage = `Error initializing graph: ${error instanceof Error ? error.message : String(error)}`;
} }
// Set up window resize handler // Set up window resize handler
const handleResize = () => { const handleResize = () => {
windowHeight = window.innerHeight; windowHeight = window.innerHeight;
@ -390,11 +407,15 @@
isDarkMode = newIsDarkMode; isDarkMode = newIsDarkMode;
// Update node colors when theme changes // Update node colors when theme changes
if (svgGroup) { if (svgGroup) {
svgGroup.selectAll("g.node") svgGroup
.selectAll("g.node")
.select("circle.visual-circle") .select("circle.visual-circle")
.attr("fill", (d: NetworkNode) => !d.isContainer .attr("fill", (d: NetworkNode) =>
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT !d.isContainer
: getEventColor(d.id) ? newIsDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
); );
} }
} }
@ -423,7 +444,7 @@
attributeFilter: ["class"], attributeFilter: ["class"],
}); });
resizeObserver.observe(container); resizeObserver.observe(container);
// Clean up on component destruction // Clean up on component destruction
return () => { return () => {
themeObserver.disconnect(); themeObserver.disconnect();
@ -437,12 +458,12 @@
* Watch for changes that should trigger a graph update * Watch for changes that should trigger a graph update
*/ */
$effect(() => { $effect(() => {
debug("Effect triggered", { debug("Effect triggered", {
hasSvg: !!svg, hasSvg: !!svg,
eventCount: events?.length, eventCount: events?.length,
currentLevels currentLevels,
}); });
try { try {
if (svg && events?.length) { if (svg && events?.length) {
// Include currentLevels in the effect dependencies // Include currentLevels in the effect dependencies
@ -454,7 +475,7 @@
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`;
} }
}); });
/** /**
* Handles tooltip close event * Handles tooltip close event
*/ */
@ -462,7 +483,7 @@
tooltipVisible = false; tooltipVisible = false;
selectedNodeId = null; selectedNodeId = null;
} }
/** /**
* Centers the graph in the viewport * Centers the graph in the viewport
*/ */
@ -470,40 +491,39 @@
if (svg && svgGroup && zoomBehavior) { if (svg && svgGroup && zoomBehavior) {
const svgWidth = svg.clientWidth || width; const svgWidth = svg.clientWidth || width;
const svgHeight = svg.clientHeight || height; const svgHeight = svg.clientHeight || height;
// Reset zoom and center // Reset zoom and center
d3.select(svg).transition().duration(750).call( d3.select(svg)
zoomBehavior.transform, .transition()
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8) .duration(750)
); .call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8),
);
} }
} }
/** /**
* Zooms in the graph * Zooms in the graph
*/ */
function zoomIn() { function zoomIn() {
if (svg && zoomBehavior) { if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call( d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
zoomBehavior.scaleBy, 1.3
);
} }
} }
/** /**
* Zooms out the graph * Zooms out the graph
*/ */
function zoomOut() { function zoomOut() {
if (svg && zoomBehavior) { if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call( d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
zoomBehavior.scaleBy, 0.7
);
} }
} }
/** /**
* Legend interactions * Legend interactions
*/ */
let graphInteracted = $state(false); let graphInteracted = $state(false);
function handleGraphClick() { function handleGraphClick() {
@ -518,9 +538,12 @@
<div class="network-error"> <div class="network-error">
<h3 class="network-error-title">Error</h3> <h3 class="network-error-title">Error</h3>
<p>{errorMessage}</p> <p>{errorMessage}</p>
<button <button
class="network-error-retry" class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }} onclick={() => {
errorMessage = null;
updateGraph();
}}
> >
Retry Retry
</button> </button>
@ -528,50 +551,82 @@
{/if} {/if}
<div class="network-svg-container" bind:this={container} role="figure"> <div class="network-svg-container" bind:this={container} role="figure">
<Legend collapsedOnInteraction={graphInteracted} className='' /> <Legend collapsedOnInteraction={graphInteracted} className="" />
<!-- Settings Panel (shown when settings button is clicked) --> <!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} onupdate={onupdate} /> <Settings count={events.length} {onupdate} />
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<svg <svg bind:this={svg} class="network-svg" onclick={handleGraphClick} />
bind:this={svg}
class="network-svg"
onclick={handleGraphClick}
/>
<!-- Zoom controls --> <!-- Zoom controls -->
<div class="network-controls"> <div class="network-controls">
<Button outline size="lg" <Button
class="network-control-button btn-leather rounded-lg p-2" outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn} onclick={zoomIn}
aria-label="Zoom in" 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"> <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> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line> <line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line> <line x1="8" y1="11" x2="14" y2="11"></line>
</svg> </svg>
</Button> </Button>
<Button outline size="lg" <Button
class="network-control-button btn-leather rounded-lg p-2" outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomOut} onclick={zoomOut}
aria-label="Zoom out" 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"> <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> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line> <line x1="8" y1="11" x2="14" y2="11"></line>
</svg> </svg>
</Button> </Button>
<Button outline size="lg" <Button
class="network-control-button btn-leather rounded-lg p-2" outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph} onclick={centerGraph}
aria-label="Center graph" 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"> <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="10"></circle>
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
</svg> </svg>
@ -588,5 +643,4 @@
onclose={handleTooltipClose} onclose={handleTooltipClose}
/> />
{/if} {/if}
</div> </div>

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

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

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

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

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

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

285
src/lib/ndk.ts

@ -1,7 +1,20 @@
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser, NDKEvent } from '@nostr-dev-kit/ndk'; import NDK, {
import { get, writable, type Writable } from 'svelte/store'; NDKNip07Signer,
import { fallbackRelays, FeedType, loginStorageKey, standardRelays, anonymousRelays } from './consts'; NDKRelay,
import { feedType } from './stores'; NDKRelayAuthPolicies,
NDKRelaySet,
NDKUser,
NDKEvent,
} from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store";
import {
fallbackRelays,
FeedType,
loginStorageKey,
standardRelays,
anonymousRelays,
} from "./consts";
import { feedType } from "./stores";
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();
@ -31,50 +44,63 @@ class CustomRelayAuthPolicy {
*/ */
async authenticate(relay: NDKRelay): Promise<void> { async authenticate(relay: NDKRelay): Promise<void> {
if (!this.ndk.signer || !this.ndk.activeUser) { if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn('[NDK.ts] No signer or active user available for relay authentication'); console.warn(
"[NDK.ts] No signer or active user available for relay authentication",
);
return; return;
} }
try { try {
console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`); console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`);
// Listen for AUTH challenges // Listen for AUTH challenges
relay.on('auth', (challenge: string) => { relay.on("auth", (challenge: string) => {
console.debug(`[NDK.ts] Received AUTH challenge from ${relay.url}:`, challenge); console.debug(
`[NDK.ts] Received AUTH challenge from ${relay.url}:`,
challenge,
);
this.challenges.set(relay.url, challenge); this.challenges.set(relay.url, challenge);
this.handleAuthChallenge(relay, challenge); this.handleAuthChallenge(relay, challenge);
}); });
// Listen for auth-required errors (handle via notice events) // Listen for auth-required errors (handle via notice events)
relay.on('notice', (message: string) => { relay.on("notice", (message: string) => {
if (message.includes('auth-required')) { if (message.includes("auth-required")) {
console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message); console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message);
this.handleAuthRequired(relay, message); this.handleAuthRequired(relay, message);
} }
}); });
// Listen for successful authentication // Listen for successful authentication
relay.on('authed', () => { relay.on("authed", () => {
console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`); console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`);
}); });
// Listen for authentication failures // Listen for authentication failures
relay.on('auth:failed', (error: any) => { relay.on("auth:failed", (error: any) => {
console.error(`[NDK.ts] Authentication failed for ${relay.url}:`, error); console.error(
`[NDK.ts] Authentication failed for ${relay.url}:`,
error,
);
}); });
} catch (error) { } catch (error) {
console.error(`[NDK.ts] Error setting up authentication for ${relay.url}:`, error); console.error(
`[NDK.ts] Error setting up authentication for ${relay.url}:`,
error,
);
} }
} }
/** /**
* Handles AUTH challenge from relay * Handles AUTH challenge from relay
*/ */
private async handleAuthChallenge(relay: NDKRelay, challenge: string): Promise<void> { private async handleAuthChallenge(
relay: NDKRelay,
challenge: string,
): Promise<void> {
try { try {
if (!this.ndk.signer || !this.ndk.activeUser) { if (!this.ndk.signer || !this.ndk.activeUser) {
console.warn('[NDK.ts] No signer available for AUTH challenge'); console.warn("[NDK.ts] No signer available for AUTH challenge");
return; return;
} }
@ -83,35 +109,42 @@ class CustomRelayAuthPolicy {
kind: 22242, kind: 22242,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
['relay', relay.url], ["relay", relay.url],
['challenge', challenge] ["challenge", challenge],
], ],
content: '', content: "",
pubkey: this.ndk.activeUser.pubkey pubkey: this.ndk.activeUser.pubkey,
}; };
// Create and sign the authentication event using NDKEvent // Create and sign the authentication event using NDKEvent
const authNDKEvent = new NDKEvent(this.ndk, authEvent); const authNDKEvent = new NDKEvent(this.ndk, authEvent);
await authNDKEvent.sign(); await authNDKEvent.sign();
// Send AUTH message to relay using the relay's publish method // Send AUTH message to relay using the relay's publish method
await relay.publish(authNDKEvent); await relay.publish(authNDKEvent);
console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`); console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`);
} catch (error) { } catch (error) {
console.error(`[NDK.ts] Error handling AUTH challenge for ${relay.url}:`, error); console.error(
`[NDK.ts] Error handling AUTH challenge for ${relay.url}:`,
error,
);
} }
} }
/** /**
* Handles auth-required error from relay * Handles auth-required error from relay
*/ */
private async handleAuthRequired(relay: NDKRelay, message: string): Promise<void> { private async handleAuthRequired(
relay: NDKRelay,
message: string,
): Promise<void> {
const challenge = this.challenges.get(relay.url); const challenge = this.challenges.get(relay.url);
if (challenge) { if (challenge) {
await this.handleAuthChallenge(relay, challenge); await this.handleAuthChallenge(relay, challenge);
} else { } else {
console.warn(`[NDK.ts] Auth required from ${relay.url} but no challenge available`); console.warn(
`[NDK.ts] Auth required from ${relay.url} but no challenge available`,
);
} }
} }
} }
@ -120,26 +153,33 @@ class CustomRelayAuthPolicy {
* Checks if the current environment might cause WebSocket protocol downgrade * Checks if the current environment might cause WebSocket protocol downgrade
*/ */
export function checkEnvironmentForWebSocketDowngrade(): void { export function checkEnvironmentForWebSocketDowngrade(): void {
console.debug('[NDK.ts] Environment Check for WebSocket Protocol:'); console.debug("[NDK.ts] Environment Check for WebSocket Protocol:");
const isLocalhost = window.location.hostname === 'localhost' || const isLocalhost =
window.location.hostname === '127.0.0.1'; window.location.hostname === "localhost" ||
const isHttp = window.location.protocol === 'http:'; window.location.hostname === "127.0.0.1";
const isHttps = window.location.protocol === 'https:'; const isHttp = window.location.protocol === "http:";
const isHttps = window.location.protocol === "https:";
console.debug('[NDK.ts] - Is localhost:', isLocalhost);
console.debug('[NDK.ts] - Protocol:', window.location.protocol); console.debug("[NDK.ts] - Is localhost:", isLocalhost);
console.debug('[NDK.ts] - Is HTTP:', isHttp); console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug('[NDK.ts] - Is HTTPS:', isHttps); console.debug("[NDK.ts] - Is HTTP:", isHttp);
console.debug("[NDK.ts] - Is HTTPS:", isHttps);
if (isLocalhost && isHttp) { if (isLocalhost && isHttp) {
console.warn('[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected'); console.warn(
console.warn('[NDK.ts] This is normal for development environments'); "[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected",
);
console.warn("[NDK.ts] This is normal for development environments");
} else if (isHttp) { } else if (isHttp) {
console.error('[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure'); console.error(
console.error('[NDK.ts] Consider using HTTPS in production'); "[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure",
);
console.error("[NDK.ts] Consider using HTTPS in production");
} else if (isHttps) { } else if (isHttps) {
console.debug('[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work'); console.debug(
"[NDK.ts] ✓ Running on HTTPS - Secure WebSocket connections should work",
);
} }
} }
@ -147,24 +187,24 @@ export function checkEnvironmentForWebSocketDowngrade(): void {
* Checks WebSocket protocol support and logs diagnostic information * Checks WebSocket protocol support and logs diagnostic information
*/ */
export function checkWebSocketSupport(): void { export function checkWebSocketSupport(): void {
console.debug('[NDK.ts] WebSocket Support Diagnostics:'); console.debug("[NDK.ts] WebSocket Support Diagnostics:");
console.debug('[NDK.ts] - Protocol:', window.location.protocol); console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug('[NDK.ts] - Hostname:', window.location.hostname); console.debug("[NDK.ts] - Hostname:", window.location.hostname);
console.debug('[NDK.ts] - Port:', window.location.port); console.debug("[NDK.ts] - Port:", window.location.port);
console.debug('[NDK.ts] - User Agent:', navigator.userAgent); console.debug("[NDK.ts] - User Agent:", navigator.userAgent);
// Test if secure WebSocket is supported // Test if secure WebSocket is supported
try { try {
const testWs = new WebSocket('wss://echo.websocket.org'); const testWs = new WebSocket("wss://echo.websocket.org");
testWs.onopen = () => { testWs.onopen = () => {
console.debug('[NDK.ts] ✓ Secure WebSocket (wss://) is supported'); console.debug("[NDK.ts] ✓ Secure WebSocket (wss://) is supported");
testWs.close(); testWs.close();
}; };
testWs.onerror = () => { testWs.onerror = () => {
console.warn('[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported'); console.warn("[NDK.ts] ✗ Secure WebSocket (wss://) may not be supported");
}; };
} catch (error) { } catch (error) {
console.warn('[NDK.ts] ✗ WebSocket test failed:', error); console.warn("[NDK.ts] ✗ WebSocket test failed:", error);
} }
} }
@ -174,7 +214,10 @@ export function checkWebSocketSupport(): void {
* @param ndk The NDK instance * @param ndk The NDK instance
* @returns Promise that resolves to connection status * @returns Promise that resolves to connection status
*/ */
export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{ export async function testRelayConnection(
relayUrl: string,
ndk: NDK,
): Promise<{
connected: boolean; connected: boolean;
requiresAuth: boolean; requiresAuth: boolean;
error?: string; error?: string;
@ -182,10 +225,10 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
}> { }> {
return new Promise((resolve) => { return new Promise((resolve) => {
console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`); console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`);
// Ensure the URL is using wss:// protocol // Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(relayUrl); const secureUrl = ensureSecureWebSocket(relayUrl);
const relay = new NDKRelay(secureUrl, undefined, new NDK()); const relay = new NDKRelay(secureUrl, undefined, new NDK());
let authRequired = false; let authRequired = false;
let connected = false; let connected = false;
@ -197,12 +240,12 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
resolve({ resolve({
connected: false, connected: false,
requiresAuth: authRequired, requiresAuth: authRequired,
error: 'Connection timeout', error: "Connection timeout",
actualUrl actualUrl,
}); });
}, 5000); }, 5000);
relay.on('connect', () => { relay.on("connect", () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`); console.debug(`[NDK.ts] Connected to ${secureUrl}`);
connected = true; connected = true;
actualUrl = secureUrl; actualUrl = secureUrl;
@ -212,27 +255,27 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
connected: true, connected: true,
requiresAuth: authRequired, requiresAuth: authRequired,
error, error,
actualUrl actualUrl,
}); });
}); });
relay.on('notice', (message: string) => { relay.on("notice", (message: string) => {
if (message.includes('auth-required')) { if (message.includes("auth-required")) {
authRequired = true; authRequired = true;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`); console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
} }
}); });
relay.on('disconnect', () => { relay.on("disconnect", () => {
if (!connected) { if (!connected) {
error = 'Connection failed'; error = "Connection failed";
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`); console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout); clearTimeout(timeout);
resolve({ resolve({
connected: false, connected: false,
requiresAuth: authRequired, requiresAuth: authRequired,
error, error,
actualUrl actualUrl,
}); });
} }
}); });
@ -278,7 +321,7 @@ export function clearLogin(): void {
* @param type The type of relay list to designate. * @param type The type of relay list to designate.
* @returns The constructed key. * @returns The constructed key.
*/ */
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { function getRelayStorageKey(user: NDKUser, type: "inbox" | "outbox"): string {
return `${loginStorageKey}/${user.pubkey}/${type}`; return `${loginStorageKey}/${user.pubkey}/${type}`;
} }
@ -288,14 +331,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
* @param inboxes The user's inbox relays. * @param inboxes The user's inbox relays.
* @param outboxes The user's outbox relays. * @param outboxes The user's outbox relays.
*/ */
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void { function persistRelays(
user: NDKUser,
inboxes: Set<NDKRelay>,
outboxes: Set<NDKRelay>,
): void {
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'inbox'), getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map(relay => relay.url)) JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
); );
localStorage.setItem( localStorage.setItem(
getRelayStorageKey(user, 'outbox'), getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map(relay => relay.url)) JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
); );
} }
@ -307,18 +354,20 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR
*/ */
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] {
const inboxes = new Set<string>( const inboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]') JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"),
); );
const outboxes = new Set<string>( const outboxes = new Set<string>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]') JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
); );
return [inboxes, outboxes]; return [inboxes, outboxes];
} }
export function clearPersistedRelays(user: NDKUser): void { export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox')); localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, 'outbox')); localStorage.removeItem(getRelayStorageKey(user, "outbox"));
} }
/** /**
@ -328,12 +377,14 @@ export function clearPersistedRelays(user: NDKUser): void {
*/ */
function ensureSecureWebSocket(url: string): string { function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present // Replace ws:// with wss:// if present
const secureUrl = url.replace(/^ws:\/\//, 'wss://'); const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) { if (secureUrl !== url) {
console.warn(`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`); console.warn(
`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`,
);
} }
return secureUrl; return secureUrl;
} }
@ -342,21 +393,25 @@ function ensureSecureWebSocket(url: string): string {
*/ */
function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
console.debug(`[NDK.ts] Creating relay with URL: ${url}`); console.debug(`[NDK.ts] Creating relay with URL: ${url}`);
// Ensure the URL is using wss:// protocol // Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url); const secureUrl = ensureSecureWebSocket(url);
const relay = new NDKRelay(secureUrl, NDKRelayAuthPolicies.signIn({ ndk }), ndk); const relay = new NDKRelay(
secureUrl,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
);
// Set up custom authentication handling only if user is signed in // Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) { if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk); const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on('connect', () => { relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
authPolicy.authenticate(relay); authPolicy.authenticate(relay);
}); });
} }
return relay; return relay;
} }
@ -364,15 +419,17 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
// Use anonymous relays if user is not signed in // Use anonymous relays if user is not signed in
const isSignedIn = ndk.signer && ndk.activeUser; const isSignedIn = ndk.signer && ndk.activeUser;
const relays = isSignedIn ? standardRelays : anonymousRelays; const relays = isSignedIn ? standardRelays : anonymousRelays;
return get(feedType) === FeedType.UserRelays return get(feedType) === FeedType.UserRelays
? new NDKRelaySet( ? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => createRelayWithAuth(relay, ndk))), new Set(
ndk get(inboxRelays).map((relay) => createRelayWithAuth(relay, ndk)),
),
ndk,
) )
: new NDKRelaySet( : new NDKRelaySet(
new Set(relays.map(relay => createRelayWithAuth(relay, ndk))), new Set(relays.map((relay) => createRelayWithAuth(relay, ndk))),
ndk ndk,
); );
} }
@ -383,17 +440,20 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
*/ */
export function initNdk(): NDK { export function initNdk(): NDK {
const startingPubkey = getPersistedLogin(); const startingPubkey = getPersistedLogin();
const [startingInboxes, _] = startingPubkey != null const [startingInboxes, _] =
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) startingPubkey != null
: [null, null]; ? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
// Ensure all relay URLs use secure WebSocket protocol // Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (startingInboxes != null const secureRelayUrls = (
? Array.from(startingInboxes.values()) startingInboxes != null
: anonymousRelays).map(ensureSecureWebSocket); ? Array.from(startingInboxes.values())
: anonymousRelays
console.debug('[NDK.ts] Initializing NDK with relay URLs:', secureRelayUrls); ).map(ensureSecureWebSocket);
console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls);
const ndk = new NDK({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: true,
enableOutboxModel: true, enableOutboxModel: true,
@ -413,7 +473,9 @@ export function initNdk(): NDK {
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because * @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because
* NDK is unable to fetch the user's profile or relay lists. * NDK is unable to fetch the user's profile or relay lists.
*/ */
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> { export async function loginWithExtension(
pubkey?: string,
): Promise<NDKUser | null> {
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
const signer = new NDKNip07Signer(); const signer = new NDKNip07Signer();
@ -421,12 +483,13 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
// TODO: Handle changing pubkeys. // TODO: Handle changing pubkeys.
if (pubkey && signerUser.pubkey !== pubkey) { if (pubkey && signerUser.pubkey !== pubkey) {
console.debug('[NDK.ts] Switching pubkeys from last login.'); console.debug("[NDK.ts] Switching pubkeys from last login.");
} }
activePubkey.set(signerUser.pubkey); activePubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser); const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
for (const relay of persistedInboxes) { for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay); ndk.addExplicitRelay(relay);
} }
@ -434,8 +497,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
const user = ndk.getUser({ pubkey: signerUser.pubkey }); const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url)); inboxRelays.set(
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url)); Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes); persistRelays(signerUser, inboxes, outboxes);
@ -471,14 +538,14 @@ export function logout(user: NDKUser): void {
async function getUserPreferredRelays( async function getUserPreferredRelays(
ndk: NDK, ndk: NDK,
user: NDKUser, user: NDKUser,
fallbacks: readonly string[] = fallbackRelays fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { ): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent( const relayList = await ndk.fetchEvent(
{ {
kinds: [10002], kinds: [10002],
authors: [user.pubkey], authors: [user.pubkey],
}, },
{ {
groupable: false, groupable: false,
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
@ -497,12 +564,12 @@ async function getUserPreferredRelays(
if (relayType.write) outboxRelays.add(relay); if (relayType.write) outboxRelays.add(relay);
}); });
} else { } else {
relayList.tags.forEach(tag => { relayList.tags.forEach((tag) => {
switch (tag[0]) { switch (tag[0]) {
case 'r': case "r":
inboxRelays.add(createRelayWithAuth(tag[1], ndk)); inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
case 'w': case "w":
outboxRelays.add(createRelayWithAuth(tag[1], ndk)); outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break; break;
default: default:

576
src/lib/parser.ts

@ -1,5 +1,5 @@
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'; import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import asciidoctor from 'asciidoctor'; import asciidoctor from "asciidoctor";
import type { import type {
AbstractBlock, AbstractBlock,
AbstractNode, AbstractNode,
@ -9,11 +9,11 @@ import type {
Extensions, Extensions,
Section, Section,
ProcessorOptions, ProcessorOptions,
} from 'asciidoctor'; } from "asciidoctor";
import he from 'he'; import he from "he";
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from "svelte/store";
import { zettelKinds } from './consts.ts'; import { zettelKinds } from "./consts.ts";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
interface IndexMetadata { interface IndexMetadata {
authors?: string[]; authors?: string[];
@ -28,16 +28,16 @@ interface IndexMetadata {
export enum SiblingSearchDirection { export enum SiblingSearchDirection {
Previous, Previous,
Next Next,
} }
export enum InsertLocation { export enum InsertLocation {
Before, Before,
After After,
} }
/** /**
* @classdesc Pharos is an extension of the Asciidoctor class that adds Nostr Knowledge Base (NKB) * @classdesc Pharos is an extension of the Asciidoctor class that adds Nostr Knowledge Base (NKB)
* features to core Asciidoctor functionality. Asciidoctor is used to parse an AsciiDoc document * features to core Asciidoctor functionality. Asciidoctor is used to parse an AsciiDoc document
* into an Abstract Syntax Tree (AST), and Phraos generates NKB events from the nodes in that tree. * into an Abstract Syntax Tree (AST), and Phraos generates NKB events from the nodes in that tree.
* @class * @class
@ -46,12 +46,12 @@ export enum InsertLocation {
export default class Pharos { export default class Pharos {
/** /**
* Key to terminology used in the class: * Key to terminology used in the class:
* *
* Nostr Knowledge Base (NKB) entities: * Nostr Knowledge Base (NKB) entities:
* - Zettel: Bite-sized pieces of text contained within kind 30041 events. * - Zettel: Bite-sized pieces of text contained within kind 30041 events.
* - Index: A kind 30040 event describing a collection of zettels or other Nostr events. * - Index: A kind 30040 event describing a collection of zettels or other Nostr events.
* - Event: The generic term for a Nostr event. * - Event: The generic term for a Nostr event.
* *
* Asciidoctor entities: * Asciidoctor entities:
* - Document: The entirety of an AsciiDoc document. The document title is denoted by a level 0 * - Document: The entirety of an AsciiDoc document. The document title is denoted by a level 0
* header, and the document may contain metadata, such as author and edition, immediately below * header, and the document may contain metadata, such as author and edition, immediately below
@ -112,7 +112,10 @@ export default class Pharos {
/** /**
* A map of index IDs to the IDs of the nodes they reference. * A map of index IDs to the IDs of the nodes they reference.
*/ */
private indexToChildEventsMap: Map<string, Set<string>> = new Map<string, Set<string>>(); private indexToChildEventsMap: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
/** /**
* A map of node IDs to the Nostr event IDs of the events they generate. * A map of node IDs to the Nostr event IDs of the events they generate.
@ -160,34 +163,37 @@ export default class Pharos {
*/ */
private async loadAdvancedExtensions(): Promise<void> { private async loadAdvancedExtensions(): Promise<void> {
try { try {
const { createAdvancedExtensions } = await import('./utils/markup/asciidoctorExtensions'); const { createAdvancedExtensions } = await import(
"./utils/markup/asciidoctorExtensions"
);
const advancedExtensions = createAdvancedExtensions(); const advancedExtensions = createAdvancedExtensions();
// Note: Extensions merging might not be available in this version // Note: Extensions merging might not be available in this version
// We'll handle this in the parse method instead // We'll handle this in the parse method instead
} catch (error) { } catch (error) {
console.warn('Advanced extensions not available:', error); console.warn("Advanced extensions not available:", error);
} }
} }
parse(content: string, options?: ProcessorOptions | undefined): void { parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book // Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content); content = ensureAsciiDocHeader(content);
try { try {
const mergedAttributes = Object.assign( const mergedAttributes = Object.assign(
{}, {},
options && typeof options.attributes === 'object' ? options.attributes : {}, options && typeof options.attributes === "object"
{ 'source-highlighter': 'highlightjs' } ? options.attributes
: {},
{ "source-highlighter": "highlightjs" },
); );
this.html = this.asciidoctor.convert(content, { this.html = this.asciidoctor.convert(content, {
...options, ...options,
'extension_registry': this.pharosExtensions, extension_registry: this.pharosExtensions,
attributes: mergedAttributes, attributes: mergedAttributes,
}) as string | Document | undefined; }) as string | Document | undefined;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new Error('Failed to parse AsciiDoc document.'); throw new Error("Failed to parse AsciiDoc document.");
} }
} }
@ -199,10 +205,10 @@ export default class Pharos {
async fetch(event: NDKEvent | string): Promise<void> { async fetch(event: NDKEvent | string): Promise<void> {
let content: string; let content: string;
if (typeof event === 'string') { if (typeof event === "string") {
const index = await this.ndk.fetchEvent({ ids: [event] }); const index = await this.ndk.fetchEvent({ ids: [event] });
if (!index) { if (!index) {
throw new Error('Failed to fetch publication.'); throw new Error("Failed to fetch publication.");
} }
content = await this.getPublicationContent(index); content = await this.getPublicationContent(index);
@ -224,7 +230,7 @@ export default class Pharos {
/** /**
* Generates and stores Nostr events from the parsed AsciiDoc document. The events can be * Generates and stores Nostr events from the parsed AsciiDoc document. The events can be
* modified via the parser's API and retrieved via the `getEvents()` method. * modified via the parser's API and retrieved via the `getEvents()` method.
* @param pubkey The public key (as a hex string) of the user that will sign and publish the * @param pubkey The public key (as a hex string) of the user that will sign and publish the
* events. * events.
*/ */
generate(pubkey: string): void { generate(pubkey: string): void {
@ -252,7 +258,7 @@ export default class Pharos {
* @returns The HTML content of the converted document. * @returns The HTML content of the converted document.
*/ */
getHtml(): string { getHtml(): string {
return this.html?.toString() || ''; return this.html?.toString() || "";
} }
/** /**
@ -260,7 +266,7 @@ export default class Pharos {
* @remarks The root index ID may be used to retrieve metadata or children from the root index. * @remarks The root index ID may be used to retrieve metadata or children from the root index.
*/ */
getRootIndexId(): string { getRootIndexId(): string {
return this.normalizeId(this.rootNodeId) ?? ''; return this.normalizeId(this.rootNodeId) ?? "";
} }
/** /**
@ -268,7 +274,7 @@ export default class Pharos {
*/ */
getIndexTitle(id: string): string | undefined { getIndexTitle(id: string): string | undefined {
const section = this.nodes.get(id) as Section; const section = this.nodes.get(id) as Section;
const title = section.getTitle() ?? ''; const title = section.getTitle() ?? "";
return he.decode(title); return he.decode(title);
} }
@ -276,16 +282,18 @@ export default class Pharos {
* @returns The IDs of any child indices of the index with the given ID. * @returns The IDs of any child indices of the index with the given ID.
*/ */
getChildIndexIds(id: string): string[] { getChildIndexIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? []) return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
.filter(id => this.eventToKindMap.get(id) === 30040); (id) => this.eventToKindMap.get(id) === 30040,
);
} }
/** /**
* @returns The IDs of any child zettels of the index with the given ID. * @returns The IDs of any child zettels of the index with the given ID.
*/ */
getChildZettelIds(id: string): string[] { getChildZettelIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? []) return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
.filter(id => this.eventToKindMap.get(id) !== 30040); (id) => this.eventToKindMap.get(id) !== 30040,
);
} }
/** /**
@ -307,8 +315,8 @@ export default class Pharos {
const block = this.nodes.get(normalizedId!) as AbstractBlock; const block = this.nodes.get(normalizedId!) as AbstractBlock;
switch (block.getContext()) { switch (block.getContext()) {
case 'paragraph': case "paragraph":
return block.getContent() ?? ''; return block.getContent() ?? "";
} }
return block.convert(); return block.convert();
@ -324,9 +332,9 @@ export default class Pharos {
if (!normalizedId || !this.nodes.has(normalizedId)) { if (!normalizedId || !this.nodes.has(normalizedId)) {
return false; return false;
} }
const context = this.eventToContextMap.get(normalizedId); const context = this.eventToContextMap.get(normalizedId);
return context === 'floating_title'; return context === "floating_title";
} }
/** /**
@ -361,7 +369,7 @@ export default class Pharos {
getNearestSibling( getNearestSibling(
targetDTag: string, targetDTag: string,
depth: number, depth: number,
direction: SiblingSearchDirection direction: SiblingSearchDirection,
): [string | null, string | null] { ): [string | null, string | null] {
const eventsAtLevel = this.eventsByLevelMap.get(depth); const eventsAtLevel = this.eventsByLevelMap.get(depth);
if (!eventsAtLevel) { if (!eventsAtLevel) {
@ -371,13 +379,17 @@ export default class Pharos {
const targetIndex = eventsAtLevel.indexOf(targetDTag); const targetIndex = eventsAtLevel.indexOf(targetDTag);
if (targetIndex === -1) { if (targetIndex === -1) {
throw new Error(`The event indicated by #d:${targetDTag} does not exist at level ${depth} of the event tree.`); throw new Error(
`The event indicated by #d:${targetDTag} does not exist at level ${depth} of the event tree.`,
);
} }
const parentDTag = this.getParent(targetDTag); const parentDTag = this.getParent(targetDTag);
if (!parentDTag) { if (!parentDTag) {
throw new Error(`The event indicated by #d:${targetDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${targetDTag} does not have a parent.`,
);
} }
const grandparentDTag = this.getParent(parentDTag); const grandparentDTag = this.getParent(parentDTag);
@ -395,7 +407,10 @@ export default class Pharos {
// If the target is the last node at its level and we're searching for a next sibling, // If the target is the last node at its level and we're searching for a next sibling,
// look among the siblings of the target's parent at the previous level. // look among the siblings of the target's parent at the previous level.
if (targetIndex === eventsAtLevel.length - 1 && direction === SiblingSearchDirection.Next) { if (
targetIndex === eventsAtLevel.length - 1 &&
direction === SiblingSearchDirection.Next
) {
// * Base case: The target is at the last level of the tree and has no subsequent sibling. // * Base case: The target is at the last level of the tree and has no subsequent sibling.
if (!grandparentDTag) { if (!grandparentDTag) {
return [null, null]; return [null, null];
@ -406,10 +421,10 @@ export default class Pharos {
// * Base case: There is an adjacent sibling at the same depth as the target. // * Base case: There is an adjacent sibling at the same depth as the target.
switch (direction) { switch (direction) {
case SiblingSearchDirection.Previous: case SiblingSearchDirection.Previous:
return [eventsAtLevel[targetIndex - 1], parentDTag]; return [eventsAtLevel[targetIndex - 1], parentDTag];
case SiblingSearchDirection.Next: case SiblingSearchDirection.Next:
return [eventsAtLevel[targetIndex + 1], parentDTag]; return [eventsAtLevel[targetIndex + 1], parentDTag];
} }
return [null, null]; return [null, null];
@ -424,7 +439,9 @@ export default class Pharos {
getParent(dTag: string): string | null { getParent(dTag: string): string | null {
// Check if the event exists in the parser tree. // Check if the event exists in the parser tree.
if (!this.eventIds.has(dTag)) { if (!this.eventIds.has(dTag)) {
throw new Error(`The event indicated by #d:${dTag} does not exist in the parser tree.`); throw new Error(
`The event indicated by #d:${dTag} does not exist in the parser tree.`,
);
} }
// Iterate through all the index to child mappings. // Iterate through all the index to child mappings.
@ -449,7 +466,11 @@ export default class Pharos {
* @remarks Moving the target event within the tree changes the hash of several events, so the * @remarks Moving the target event within the tree changes the hash of several events, so the
* event tree will be regenerated when the consumer next invokes `getEvents()`. * event tree will be regenerated when the consumer next invokes `getEvents()`.
*/ */
moveEvent(targetDTag: string, destinationDTag: string, insertAfter: boolean = false): void { moveEvent(
targetDTag: string,
destinationDTag: string,
insertAfter: boolean = false,
): void {
const targetEvent = this.events.get(targetDTag); const targetEvent = this.events.get(targetDTag);
const destinationEvent = this.events.get(destinationDTag); const destinationEvent = this.events.get(destinationDTag);
const targetParent = this.getParent(targetDTag); const targetParent = this.getParent(targetDTag);
@ -464,11 +485,15 @@ export default class Pharos {
} }
if (!targetParent) { if (!targetParent) {
throw new Error(`The event indicated by #d:${targetDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${targetDTag} does not have a parent.`,
);
} }
if (!destinationParent) { if (!destinationParent) {
throw new Error(`The event indicated by #d:${destinationDTag} does not have a parent.`); throw new Error(
`The event indicated by #d:${destinationDTag} does not have a parent.`,
);
} }
// Remove the target from among the children of its current parent. // Remove the target from among the children of its current parent.
@ -478,16 +503,22 @@ export default class Pharos {
this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag); this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag);
// Get the index of the destination event among the children of its parent. // Get the index of the destination event among the children of its parent.
const destinationIndex = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? []) const destinationIndex = Array.from(
.indexOf(destinationDTag); this.indexToChildEventsMap.get(destinationParent) ?? [],
).indexOf(destinationDTag);
// Insert next to the index of the destination event, either before or after as specified by // Insert next to the index of the destination event, either before or after as specified by
// the insertAfter flag. // the insertAfter flag.
const destinationChildren = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? []); const destinationChildren = Array.from(
this.indexToChildEventsMap.get(destinationParent) ?? [],
);
insertAfter insertAfter
? destinationChildren.splice(destinationIndex + 1, 0, targetDTag) ? destinationChildren.splice(destinationIndex + 1, 0, targetDTag)
: destinationChildren.splice(destinationIndex, 0, targetDTag); : destinationChildren.splice(destinationIndex, 0, targetDTag);
this.indexToChildEventsMap.set(destinationParent, new Set(destinationChildren)); this.indexToChildEventsMap.set(
destinationParent,
new Set(destinationChildren),
);
this.shouldUpdateEventTree = true; this.shouldUpdateEventTree = true;
} }
@ -517,7 +548,10 @@ export default class Pharos {
* - Each node ID is mapped to an integer event kind that will be used to represent the node. * - Each node ID is mapped to an integer event kind that will be used to represent the node.
* - Each ID of a node containing children is mapped to the set of IDs of its children. * - Each ID of a node containing children is mapped to the set of IDs of its children.
*/ */
private treeProcessor(treeProcessor: Extensions.TreeProcessor, document: Document) { private treeProcessor(
treeProcessor: Extensions.TreeProcessor,
document: Document,
) {
this.rootNodeId = this.generateNodeId(document); this.rootNodeId = this.generateNodeId(document);
document.setId(this.rootNodeId); document.setId(this.rootNodeId);
this.nodes.set(this.rootNodeId, document); this.nodes.set(this.rootNodeId, document);
@ -533,7 +567,7 @@ export default class Pharos {
continue; continue;
} }
if (block.getContext() === 'section') { if (block.getContext() === "section") {
const children = this.processSection(block as Section); const children = this.processSection(block as Section);
nodeQueue.push(...children); nodeQueue.push(...children);
} else { } else {
@ -563,7 +597,7 @@ export default class Pharos {
} }
this.nodes.set(sectionId, section); this.nodes.set(sectionId, section);
this.eventToKindMap.set(sectionId, 30040); // Sections are indexToChildEventsMap by default. this.eventToKindMap.set(sectionId, 30040); // Sections are indexToChildEventsMap by default.
this.indexToChildEventsMap.set(sectionId, new Set<string>()); this.indexToChildEventsMap.set(sectionId, new Set<string>());
const parentId = this.normalizeId(section.getParent()?.getId()); const parentId = this.normalizeId(section.getParent()?.getId());
@ -591,7 +625,7 @@ export default class Pharos {
// Obtain or generate a unique ID for the block. // Obtain or generate a unique ID for the block.
let blockId = this.normalizeId(block.getId()); let blockId = this.normalizeId(block.getId());
if (!blockId) { if (!blockId) {
blockId = this.generateNodeId(block) ; blockId = this.generateNodeId(block);
block.setId(blockId); block.setId(blockId);
} }
@ -601,7 +635,7 @@ export default class Pharos {
} }
this.nodes.set(blockId, block); this.nodes.set(blockId, block);
this.eventToKindMap.set(blockId, 30041); // Blocks are zettels by default. this.eventToKindMap.set(blockId, 30041); // Blocks are zettels by default.
const parentId = this.normalizeId(block.getParent()?.getId()); const parentId = this.normalizeId(block.getParent()?.getId());
if (!parentId) { if (!parentId) {
@ -648,21 +682,24 @@ export default class Pharos {
* @remarks This function does a depth-first crawl of the event tree using the relays specified * @remarks This function does a depth-first crawl of the event tree using the relays specified
* on the NDK instance. * on the NDK instance.
*/ */
private async getPublicationContent(event: NDKEvent, depth: number = 0): Promise<string> { private async getPublicationContent(
let content: string = ''; event: NDKEvent,
depth: number = 0,
): Promise<string> {
let content: string = "";
// Format title into AsciiDoc header. // Format title into AsciiDoc header.
const title = getMatchingTags(event, 'title')[0][1]; const title = getMatchingTags(event, "title")[0][1];
let titleLevel = ''; let titleLevel = "";
for (let i = 0; i <= depth; i++) { for (let i = 0; i <= depth; i++) {
titleLevel += '='; titleLevel += "=";
} }
content += `${titleLevel} ${title}\n\n`; content += `${titleLevel} ${title}\n\n`;
// TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62. // TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62.
let tags = getMatchingTags(event, 'a'); let tags = getMatchingTags(event, "a");
if (tags.length === 0) { if (tags.length === 0) {
tags = getMatchingTags(event, 'e'); tags = getMatchingTags(event, "e");
} }
// Base case: The event is a zettel. // Base case: The event is a zettel.
@ -673,24 +710,29 @@ export default class Pharos {
// Recursive case: The event is an index. // Recursive case: The event is an index.
const childEvents = await Promise.all( const childEvents = await Promise.all(
tags.map(tag => this.ndk.fetchEventFromTag(tag, event)) tags.map((tag) => this.ndk.fetchEventFromTag(tag, event)),
); );
// if a blog, save complete events for later // if a blog, save complete events for later
if (getMatchingTags(event, 'type').length > 0 && getMatchingTags(event, 'type')[0][1] === 'blog') { if (
childEvents.forEach(child => { getMatchingTags(event, "type").length > 0 &&
getMatchingTags(event, "type")[0][1] === "blog"
) {
childEvents.forEach((child) => {
if (child) { if (child) {
this.blogEntries.set(getMatchingTags(child, 'd')?.[0]?.[1], child); this.blogEntries.set(getMatchingTags(child, "d")?.[0]?.[1], child);
} }
}) });
} }
// populate metadata // populate metadata
if (event.created_at) { if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString(); this.rootIndexMetadata.publicationDate = new Date(
event.created_at * 1000,
).toDateString();
} }
if (getMatchingTags(event, 'image').length > 0) { if (getMatchingTags(event, "image").length > 0) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1]; this.rootIndexMetadata.coverImage = getMatchingTags(event, "image")[0][1];
} }
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching // Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
@ -699,17 +741,19 @@ export default class Pharos {
const childContentPromises: Promise<string>[] = []; const childContentPromises: Promise<string>[] = [];
for (let i = 0; i < childEvents.length; i++) { for (let i = 0; i < childEvents.length; i++) {
const childEvent = childEvents[i]; const childEvent = childEvents[i];
if (!childEvent) { if (!childEvent) {
console.warn(`NDK could not find event ${tags[i][1]}.`); console.warn(`NDK could not find event ${tags[i][1]}.`);
continue; continue;
} }
childContentPromises.push(this.getPublicationContent(childEvent, depth + 1)); childContentPromises.push(
this.getPublicationContent(childEvent, depth + 1),
);
} }
const childContents = await Promise.all(childContentPromises); const childContents = await Promise.all(childContentPromises);
content += childContents.join('\n\n'); content += childContents.join("\n\n");
return content; return content;
} }
@ -754,17 +798,17 @@ export default class Pharos {
while (nodeIdStack.length > 0) { while (nodeIdStack.length > 0) {
const nodeId = nodeIdStack.pop(); const nodeId = nodeIdStack.pop();
switch (this.eventToKindMap.get(nodeId!)) {
case 30040:
events.push(this.generateIndexEvent(nodeId!, pubkey));
break;
case 30041: switch (this.eventToKindMap.get(nodeId!)) {
default: case 30040:
// Kind 30041 (zettel) is currently the default kind for contentful events. events.push(this.generateIndexEvent(nodeId!, pubkey));
events.push(this.generateZettelEvent(nodeId!, pubkey)); break;
break;
case 30041:
default:
// Kind 30041 (zettel) is currently the default kind for contentful events.
events.push(this.generateZettelEvent(nodeId!, pubkey));
break;
} }
} }
@ -783,17 +827,14 @@ export default class Pharos {
private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent { private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent {
const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle(); const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle();
// TODO: Use a tags as per NIP-62. // TODO: Use a tags as per NIP-62.
const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!) const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!).map(
.map(id => ['#e', this.eventIds.get(id)!]); (id) => ["#e", this.eventIds.get(id)!],
);
const event = new NDKEvent(this.ndk); const event = new NDKEvent(this.ndk);
event.kind = 30040; event.kind = 30040;
event.content = ''; event.content = "";
event.tags = [ event.tags = [["title", title!], ["#d", nodeId], ...childTags];
['title', title!],
['#d', nodeId],
...childTags
];
event.created_at = Date.now(); event.created_at = Date.now();
event.pubkey = pubkey; event.pubkey = pubkey;
@ -805,7 +846,7 @@ export default class Pharos {
this.rootIndexMetadata = { this.rootIndexMetadata = {
authors: document authors: document
.getAuthors() .getAuthors()
.map(author => author.getName()) .map((author) => author.getName())
.filter((name): name is string => name != null), .filter((name): name is string => name != null),
version: document.getRevisionNumber(), version: document.getRevisionNumber(),
edition: document.getRevisionRemark(), edition: document.getRevisionRemark(),
@ -813,11 +854,11 @@ export default class Pharos {
}; };
if (this.rootIndexMetadata.authors) { if (this.rootIndexMetadata.authors) {
event.tags.push(['author', ...this.rootIndexMetadata.authors!]); event.tags.push(["author", ...this.rootIndexMetadata.authors!]);
} }
if (this.rootIndexMetadata.version || this.rootIndexMetadata.edition) { if (this.rootIndexMetadata.version || this.rootIndexMetadata.edition) {
const versionTags: string[] = ['version']; const versionTags: string[] = ["version"];
if (this.rootIndexMetadata.version) { if (this.rootIndexMetadata.version) {
versionTags.push(this.rootIndexMetadata.version); versionTags.push(this.rootIndexMetadata.version);
} }
@ -828,12 +869,15 @@ export default class Pharos {
} }
if (this.rootIndexMetadata.publicationDate) { if (this.rootIndexMetadata.publicationDate) {
event.tags.push(['published_on', this.rootIndexMetadata.publicationDate!]); event.tags.push([
"published_on",
this.rootIndexMetadata.publicationDate!,
]);
} }
} }
// Event ID generation must be the last step. // Event ID generation must be the last step.
const eventId = event.getEventHash(); const eventId = event.getEventHash();
this.eventIds.set(nodeId, eventId); this.eventIds.set(nodeId, eventId);
event.id = eventId; event.id = eventId;
@ -852,21 +896,21 @@ export default class Pharos {
*/ */
private generateZettelEvent(nodeId: string, pubkey: string): NDKEvent { private generateZettelEvent(nodeId: string, pubkey: string): NDKEvent {
const title = (this.nodes.get(nodeId)! as Block).getTitle(); const title = (this.nodes.get(nodeId)! as Block).getTitle();
const content = (this.nodes.get(nodeId)! as Block).getSource(); // AsciiDoc source content. const content = (this.nodes.get(nodeId)! as Block).getSource(); // AsciiDoc source content.
const event = new NDKEvent(this.ndk); const event = new NDKEvent(this.ndk);
event.kind = 30041; event.kind = 30041;
event.content = content!; event.content = content!;
event.tags = [ event.tags = [
['title', title!], ["title", title!],
['#d', nodeId], ["#d", nodeId],
...this.extractAndNormalizeWikilinks(content!), ...this.extractAndNormalizeWikilinks(content!),
]; ];
event.created_at = Date.now(); event.created_at = Date.now();
event.pubkey = pubkey; event.pubkey = pubkey;
// Event ID generation must be the last step. // Event ID generation must be the last step.
const eventId = event.getEventHash(); const eventId = event.getEventHash();
this.eventIds.set(nodeId, eventId); this.eventIds.set(nodeId, eventId);
event.id = eventId; event.id = eventId;
@ -902,173 +946,173 @@ export default class Pharos {
const context = block.getContext(); const context = block.getContext();
switch (context) { switch (context) {
case 'admonition': case "admonition":
blockNumber = this.contextCounters.get('admonition') ?? 0; blockNumber = this.contextCounters.get("admonition") ?? 0;
blockId = `${documentId}-admonition-${blockNumber++}`; blockId = `${documentId}-admonition-${blockNumber++}`;
this.contextCounters.set('admonition', blockNumber); this.contextCounters.set("admonition", blockNumber);
break; break;
case 'audio': case "audio":
blockNumber = this.contextCounters.get('audio') ?? 0; blockNumber = this.contextCounters.get("audio") ?? 0;
blockId = `${documentId}-audio-${blockNumber++}`; blockId = `${documentId}-audio-${blockNumber++}`;
this.contextCounters.set('audio', blockNumber); this.contextCounters.set("audio", blockNumber);
break; break;
case 'colist': case "colist":
blockNumber = this.contextCounters.get('colist') ?? 0; blockNumber = this.contextCounters.get("colist") ?? 0;
blockId = `${documentId}-colist-${blockNumber++}`; blockId = `${documentId}-colist-${blockNumber++}`;
this.contextCounters.set('colist', blockNumber); this.contextCounters.set("colist", blockNumber);
break; break;
case 'dlist': case "dlist":
blockNumber = this.contextCounters.get('dlist') ?? 0; blockNumber = this.contextCounters.get("dlist") ?? 0;
blockId = `${documentId}-dlist-${blockNumber++}`; blockId = `${documentId}-dlist-${blockNumber++}`;
this.contextCounters.set('dlist', blockNumber); this.contextCounters.set("dlist", blockNumber);
break; break;
case 'document': case "document":
blockNumber = this.contextCounters.get('document') ?? 0; blockNumber = this.contextCounters.get("document") ?? 0;
blockId = `${documentId}-document-${blockNumber++}`; blockId = `${documentId}-document-${blockNumber++}`;
this.contextCounters.set('document', blockNumber); this.contextCounters.set("document", blockNumber);
break; break;
case 'example': case "example":
blockNumber = this.contextCounters.get('example') ?? 0; blockNumber = this.contextCounters.get("example") ?? 0;
blockId = `${documentId}-example-${blockNumber++}`; blockId = `${documentId}-example-${blockNumber++}`;
this.contextCounters.set('example', blockNumber); this.contextCounters.set("example", blockNumber);
break; break;
case 'floating_title': case "floating_title":
blockNumber = this.contextCounters.get('floating_title') ?? 0; blockNumber = this.contextCounters.get("floating_title") ?? 0;
blockId = `${documentId}-floating-title-${blockNumber++}`; blockId = `${documentId}-floating-title-${blockNumber++}`;
this.contextCounters.set('floating_title', blockNumber); this.contextCounters.set("floating_title", blockNumber);
break; break;
case 'image': case "image":
blockNumber = this.contextCounters.get('image') ?? 0; blockNumber = this.contextCounters.get("image") ?? 0;
blockId = `${documentId}-image-${blockNumber++}`; blockId = `${documentId}-image-${blockNumber++}`;
this.contextCounters.set('image', blockNumber); this.contextCounters.set("image", blockNumber);
break; break;
case 'list_item': case "list_item":
blockNumber = this.contextCounters.get('list_item') ?? 0; blockNumber = this.contextCounters.get("list_item") ?? 0;
blockId = `${documentId}-list-item-${blockNumber++}`; blockId = `${documentId}-list-item-${blockNumber++}`;
this.contextCounters.set('list_item', blockNumber); this.contextCounters.set("list_item", blockNumber);
break; break;
case 'listing': case "listing":
blockNumber = this.contextCounters.get('listing') ?? 0; blockNumber = this.contextCounters.get("listing") ?? 0;
blockId = `${documentId}-listing-${blockNumber++}`; blockId = `${documentId}-listing-${blockNumber++}`;
this.contextCounters.set('listing', blockNumber); this.contextCounters.set("listing", blockNumber);
break; break;
case 'literal': case "literal":
blockNumber = this.contextCounters.get('literal') ?? 0; blockNumber = this.contextCounters.get("literal") ?? 0;
blockId = `${documentId}-literal-${blockNumber++}`; blockId = `${documentId}-literal-${blockNumber++}`;
this.contextCounters.set('literal', blockNumber); this.contextCounters.set("literal", blockNumber);
break; break;
case 'olist': case "olist":
blockNumber = this.contextCounters.get('olist') ?? 0; blockNumber = this.contextCounters.get("olist") ?? 0;
blockId = `${documentId}-olist-${blockNumber++}`; blockId = `${documentId}-olist-${blockNumber++}`;
this.contextCounters.set('olist', blockNumber); this.contextCounters.set("olist", blockNumber);
break; break;
case 'open': case "open":
blockNumber = this.contextCounters.get('open') ?? 0; blockNumber = this.contextCounters.get("open") ?? 0;
blockId = `${documentId}-open-${blockNumber++}`; blockId = `${documentId}-open-${blockNumber++}`;
this.contextCounters.set('open', blockNumber); this.contextCounters.set("open", blockNumber);
break; break;
case 'page_break': case "page_break":
blockNumber = this.contextCounters.get('page_break') ?? 0; blockNumber = this.contextCounters.get("page_break") ?? 0;
blockId = `${documentId}-page-break-${blockNumber++}`; blockId = `${documentId}-page-break-${blockNumber++}`;
this.contextCounters.set('page_break', blockNumber); this.contextCounters.set("page_break", blockNumber);
break; break;
case 'paragraph': case "paragraph":
blockNumber = this.contextCounters.get('paragraph') ?? 0; blockNumber = this.contextCounters.get("paragraph") ?? 0;
blockId = `${documentId}-paragraph-${blockNumber++}`; blockId = `${documentId}-paragraph-${blockNumber++}`;
this.contextCounters.set('paragraph', blockNumber); this.contextCounters.set("paragraph", blockNumber);
break; break;
case 'pass': case "pass":
blockNumber = this.contextCounters.get('pass') ?? 0; blockNumber = this.contextCounters.get("pass") ?? 0;
blockId = `${documentId}-pass-${blockNumber++}`; blockId = `${documentId}-pass-${blockNumber++}`;
this.contextCounters.set('pass', blockNumber); this.contextCounters.set("pass", blockNumber);
break; break;
case 'preamble': case "preamble":
blockNumber = this.contextCounters.get('preamble') ?? 0; blockNumber = this.contextCounters.get("preamble") ?? 0;
blockId = `${documentId}-preamble-${blockNumber++}`; blockId = `${documentId}-preamble-${blockNumber++}`;
this.contextCounters.set('preamble', blockNumber); this.contextCounters.set("preamble", blockNumber);
break; break;
case 'quote': case "quote":
blockNumber = this.contextCounters.get('quote') ?? 0; blockNumber = this.contextCounters.get("quote") ?? 0;
blockId = `${documentId}-quote-${blockNumber++}`; blockId = `${documentId}-quote-${blockNumber++}`;
this.contextCounters.set('quote', blockNumber); this.contextCounters.set("quote", blockNumber);
break; break;
case 'section': case "section":
blockNumber = this.contextCounters.get('section') ?? 0; blockNumber = this.contextCounters.get("section") ?? 0;
blockId = `${documentId}-section-${blockNumber++}`; blockId = `${documentId}-section-${blockNumber++}`;
this.contextCounters.set('section', blockNumber); this.contextCounters.set("section", blockNumber);
break; break;
case 'sidebar': case "sidebar":
blockNumber = this.contextCounters.get('sidebar') ?? 0; blockNumber = this.contextCounters.get("sidebar") ?? 0;
blockId = `${documentId}-sidebar-${blockNumber++}`; blockId = `${documentId}-sidebar-${blockNumber++}`;
this.contextCounters.set('sidebar', blockNumber); this.contextCounters.set("sidebar", blockNumber);
break; break;
case 'table': case "table":
blockNumber = this.contextCounters.get('table') ?? 0; blockNumber = this.contextCounters.get("table") ?? 0;
blockId = `${documentId}-table-${blockNumber++}`; blockId = `${documentId}-table-${blockNumber++}`;
this.contextCounters.set('table', blockNumber); this.contextCounters.set("table", blockNumber);
break; break;
case 'table_cell': case "table_cell":
blockNumber = this.contextCounters.get('table_cell') ?? 0; blockNumber = this.contextCounters.get("table_cell") ?? 0;
blockId = `${documentId}-table-cell-${blockNumber++}`; blockId = `${documentId}-table-cell-${blockNumber++}`;
this.contextCounters.set('table_cell', blockNumber); this.contextCounters.set("table_cell", blockNumber);
break; break;
case 'thematic_break': case "thematic_break":
blockNumber = this.contextCounters.get('thematic_break') ?? 0; blockNumber = this.contextCounters.get("thematic_break") ?? 0;
blockId = `${documentId}-thematic-break-${blockNumber++}`; blockId = `${documentId}-thematic-break-${blockNumber++}`;
this.contextCounters.set('thematic_break', blockNumber); this.contextCounters.set("thematic_break", blockNumber);
break; break;
case 'toc': case "toc":
blockNumber = this.contextCounters.get('toc') ?? 0; blockNumber = this.contextCounters.get("toc") ?? 0;
blockId = `${documentId}-toc-${blockNumber++}`; blockId = `${documentId}-toc-${blockNumber++}`;
this.contextCounters.set('toc', blockNumber); this.contextCounters.set("toc", blockNumber);
break; break;
case 'ulist': case "ulist":
blockNumber = this.contextCounters.get('ulist') ?? 0; blockNumber = this.contextCounters.get("ulist") ?? 0;
blockId = `${documentId}-ulist-${blockNumber++}`; blockId = `${documentId}-ulist-${blockNumber++}`;
this.contextCounters.set('ulist', blockNumber); this.contextCounters.set("ulist", blockNumber);
break; break;
case 'verse': case "verse":
blockNumber = this.contextCounters.get('verse') ?? 0; blockNumber = this.contextCounters.get("verse") ?? 0;
blockId = `${documentId}-verse-${blockNumber++}`; blockId = `${documentId}-verse-${blockNumber++}`;
this.contextCounters.set('verse', blockNumber); this.contextCounters.set("verse", blockNumber);
break; break;
case 'video': case "video":
blockNumber = this.contextCounters.get('video') ?? 0; blockNumber = this.contextCounters.get("video") ?? 0;
blockId = `${documentId}-video-${blockNumber++}`; blockId = `${documentId}-video-${blockNumber++}`;
this.contextCounters.set('video', blockNumber); this.contextCounters.set("video", blockNumber);
break; break;
default: default:
blockNumber = this.contextCounters.get('block') ?? 0; blockNumber = this.contextCounters.get("block") ?? 0;
blockId = `${documentId}-block-${blockNumber++}`; blockId = `${documentId}-block-${blockNumber++}`;
this.contextCounters.set('block', blockNumber); this.contextCounters.set("block", blockNumber);
break; break;
} }
block.setId(blockId); block.setId(blockId);
@ -1082,24 +1126,25 @@ export default class Pharos {
return null; return null;
} }
return he.decode(input) return he
.decode(input)
.toLowerCase() .toLowerCase()
.replace(/[_]/g, ' ') // Replace underscores with spaces. .replace(/[_]/g, " ") // Replace underscores with spaces.
.trim() .trim()
.replace(/\s+/g, '-') // Replace spaces with dashes. .replace(/\s+/g, "-") // Replace spaces with dashes.
.replace(/[^a-z0-9\-]/g, ''); // Remove non-alphanumeric characters except dashes. .replace(/[^a-z0-9\-]/g, ""); // Remove non-alphanumeric characters except dashes.
} }
private updateEventByContext(dTag: string, value: string, context: string) { private updateEventByContext(dTag: string, value: string, context: string) {
switch (context) { switch (context) {
case 'document': case "document":
case 'section': case "section":
this.updateEventTitle(dTag, value); this.updateEventTitle(dTag, value);
break; break;
default: default:
this.updateEventBody(dTag, value); this.updateEventBody(dTag, value);
break; break;
} }
} }
@ -1131,7 +1176,7 @@ export default class Pharos {
while ((match = wikilinkPattern.exec(content)) !== null) { while ((match = wikilinkPattern.exec(content)) !== null) {
const linkName = match[1]; const linkName = match[1];
const normalizedText = this.normalizeId(linkName); const normalizedText = this.normalizeId(linkName);
wikilinks.push(['wikilink', normalizedText!]); wikilinks.push(["wikilink", normalizedText!]);
} }
return wikilinks; return wikilinks;
@ -1147,7 +1192,7 @@ export const pharosInstance: Writable<Pharos> = writable();
export const tocUpdate = writable(0); export const tocUpdate = writable(0);
// Whenever you update the publication tree, call: // Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1); tocUpdate.update((n) => n + 1);
function ensureAsciiDocHeader(content: string): string { function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
@ -1156,35 +1201,36 @@ function ensureAsciiDocHeader(content: string): string {
// Find the first non-empty line as header // Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue; if (lines[i].trim() === "") continue;
if (lines[i].trim().startsWith('=')) { if (lines[i].trim().startsWith("=")) {
headerIndex = i; headerIndex = i;
break; break;
} else { } else {
throw new Error('AsciiDoc document is missing a header at the top.'); throw new Error("AsciiDoc document is missing a header at the top.");
} }
} }
if (headerIndex === -1) { if (headerIndex === -1) {
throw new Error('AsciiDoc document is missing a header.'); throw new Error("AsciiDoc document is missing a header.");
} }
// Check for doctype in the next non-empty line after header // Check for doctype in the next non-empty line after header
let nextLine = headerIndex + 1; let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') { while (nextLine < lines.length && lines[nextLine].trim() === "") {
nextLine++; nextLine++;
} }
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) { if (
nextLine < lines.length &&
lines[nextLine].trim().startsWith(":doctype:")
) {
hasDoctype = true; hasDoctype = true;
} }
// Insert doctype immediately after header if not present // Insert doctype immediately after header if not present
if (!hasDoctype) { if (!hasDoctype) {
lines.splice(headerIndex + 1, 0, ':doctype: book'); lines.splice(headerIndex + 1, 0, ":doctype: book");
} }
return lines.join("\n");
return lines.join('\n');
} }

14
src/lib/snippets/PublicationSnippets.svelte

@ -1,5 +1,5 @@
<script module lang='ts'> <script module lang="ts">
import { P } from 'flowbite-svelte'; import { P } from "flowbite-svelte";
export { contentParagraph, sectionHeading }; export { contentParagraph, sectionHeading };
</script> </script>
@ -8,13 +8,17 @@
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. --> <!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'> <svelte:element this={`h${headingLevel}`} class="h-leather">
{title} {title}
</svelte:element> </svelte:element>
{/snippet} {/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} {#snippet contentParagraph(
<section class='whitespace-normal publication-leather'> content: string,
publicationType: string,
isSectionStart: boolean,
)}
<section class="whitespace-normal publication-leather">
{@html content} {@html content}
</section> </section>
{/snippet} {/snippet}

10
src/lib/snippets/UserSnippets.svelte

@ -1,5 +1,9 @@
<script module lang='ts'> <script module lang="ts">
import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils'; import {
createProfileLink,
createProfileLinkWithVerification,
toNpub,
} from "$lib/utils/nostrUtils";
export { userBadge }; export { userBadge };
</script> </script>
@ -14,6 +18,6 @@
{@html createProfileLink(toNpub(identifier) as string, displayText)} {@html createProfileLink(toNpub(identifier) as string, displayText)}
{/await} {/await}
{:else} {:else}
{displayText ?? ''} {displayText ?? ""}
{/if} {/if}
{/snippet} {/snippet}

5
src/lib/stores.ts

@ -7,14 +7,13 @@ export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays); export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = { const defaultVisibility = {
toc: false, toc: false,
blog: true, blog: true,
main: true, main: true,
inner: false, inner: false,
discussion: false, discussion: false,
editing: false editing: false,
}; };
function createVisibilityStore() { function createVisibilityStore() {
@ -24,7 +23,7 @@ function createVisibilityStore() {
subscribe, subscribe,
set, set,
update, update,
reset: () => set({ ...defaultVisibility }) reset: () => set({ ...defaultVisibility }),
}; };
} }

4
src/lib/stores/relayStore.ts

@ -1,4 +1,4 @@
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
// Initialize with empty array, will be populated from user preferences // Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]); export const userRelays = writable<string[]>([]);

8
src/lib/types.ts

@ -6,4 +6,10 @@ export type Tab = {
data?: any; data?: any;
}; };
export type TabType = 'welcome' | 'find' | 'article' | 'user' | 'settings' | 'editor'; export type TabType =
| "welcome"
| "find"
| "article"
| "user"
| "settings"
| "editor";

30
src/lib/utils.ts

@ -12,11 +12,11 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
} }
export function naddrEncode(event: NDKEvent, relays: string[]) { export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = getMatchingTags(event, 'd')[0]?.[1]; const dTag = getMatchingTags(event, "d")[0]?.[1];
if (!dTag) { if (!dTag) {
throw new Error('Event does not have a d tag'); throw new Error("Event does not have a d tag");
} }
return nip19.naddrEncode({ return nip19.naddrEncode({
identifier: dTag, identifier: dTag,
pubkey: event.pubkey, pubkey: event.pubkey,
@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) {
export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// The filter object supports only limited parameters, so we need to filter out events that // The filter object supports only limited parameters, so we need to filter out events that
// don't respect NKBIP-01. // don't respect NKBIP-01.
events.forEach(event => { events.forEach((event) => {
// Index events have no content, and they must have `title`, `d`, and `e` tags. // Index events have no content, and they must have `title`, `d`, and `e` tags.
if ( if (
(event.content != null && event.content.length > 0) (event.content != null && event.content.length > 0) ||
|| getMatchingTags(event, 'title').length === 0 getMatchingTags(event, "title").length === 0 ||
|| getMatchingTags(event, 'd').length === 0 getMatchingTags(event, "d").length === 0 ||
|| ( (getMatchingTags(event, "a").length === 0 &&
getMatchingTags(event, 'a').length === 0 getMatchingTags(event, "e").length === 0)
&& getMatchingTags(event, 'e').length === 0
)
) { ) {
events.delete(event); events.delete(event);
} }
@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
*/ */
export async function findIndexAsync<T>( export async function findIndexAsync<T>(
array: T[], array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> { ): Promise<number> {
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
if (await predicate(array[i], i, array)) { if (await predicate(array[i], i, array)) {
@ -152,14 +150,14 @@ export async function findIndexAsync<T>(
declare global { declare global {
interface Array<T> { interface Array<T> {
findIndexAsync( findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number>; ): Promise<number>;
} }
} }
Array.prototype.findIndexAsync = function<T>( Array.prototype.findIndexAsync = function <T>(
this: T[], this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean> predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> { ): Promise<number> {
return findIndexAsync(this, predicate); return findIndexAsync(this, predicate);
}; };
@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>(
*/ */
export function debounce<T extends (...args: any[]) => any>( export function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined; let timeout: ReturnType<typeof setTimeout> | undefined;

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

@ -6,8 +6,8 @@ Alexandria supports multiple markup formats for different use cases. Below is a
The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports:
- **Headers:** - **Headers:**
- ATX-style: `# H1` through `###### H6` - ATX-style: `# H1` through `###### H6`
- Setext-style: `H1\n=====` - Setext-style: `H1\n=====`
- **Bold:** `*bold*` or `**bold**` - **Bold:** `*bold*` or `**bold**`
- **Italic:** `_italic_` or `__italic__` - **Italic:** `_italic_` or `__italic__`
@ -123,7 +123,8 @@ For more information on AsciiDoc, see the [AsciiDoc documentation](https://ascii
--- ---
**Note:** **Note:**
- The markdown parsers are primarily used for comments, issues, and other user-generated content. - 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. - 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. - 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. - [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.

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

@ -1,14 +1,16 @@
import { postProcessAsciidoctorHtml } from './asciidoctorPostProcessor'; import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor";
import plantumlEncoder from 'plantuml-encoder'; import plantumlEncoder from "plantuml-encoder";
/** /**
* Unified post-processor for Asciidoctor HTML that handles: * Unified post-processor for Asciidoctor HTML that handles:
* - Math rendering (Asciimath/Latex, stem blocks) * - Math rendering (Asciimath/Latex, stem blocks)
* - PlantUML diagrams * - PlantUML diagrams
* - BPMN diagrams * - BPMN diagrams
* - TikZ diagrams * - TikZ diagrams
*/ */
export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<string> { export async function postProcessAdvancedAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html; if (!html) return html;
try { try {
// First apply the basic post-processing (wikilinks, nostr addresses) // First apply the basic post-processing (wikilinks, nostr addresses)
@ -22,15 +24,21 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<
// Process TikZ blocks // Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml); processedHtml = processTikZBlocks(processedHtml);
// After all processing, apply highlight.js if available // After all processing, apply highlight.js if available
if (typeof window !== 'undefined' && typeof window.hljs?.highlightAll === 'function') { if (
typeof window !== "undefined" &&
typeof window.hljs?.highlightAll === "function"
) {
setTimeout(() => window.hljs!.highlightAll(), 0); setTimeout(() => window.hljs!.highlightAll(), 0);
} }
if (typeof window !== 'undefined' && typeof (window as any).MathJax?.typesetPromise === 'function') { if (
typeof window !== "undefined" &&
typeof (window as any).MathJax?.typesetPromise === "function"
) {
setTimeout(() => (window as any).MathJax.typesetPromise(), 0); setTimeout(() => (window as any).MathJax.typesetPromise(), 0);
} }
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {
console.error('Error in postProcessAdvancedAsciidoctorHtml:', error); console.error("Error in postProcessAdvancedAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails return html; // Return original HTML if processing fails
} }
} }
@ -41,44 +49,46 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<
*/ */
function fixAllMathBlocks(html: string): string { function fixAllMathBlocks(html: string): string {
// Unescape \$ to $ for math delimiters // Unescape \$ to $ for math delimiters
html = html.replace(/\\\$/g, '$'); html = html.replace(/\\\$/g, "$");
// Block math: <div class="stemblock"><div class="content">...</div></div> // Block math: <div class="stemblock"><div class="content">...</div></div>
html = html.replace( html = html.replace(
/<div class="stemblock">\s*<div class="content">([\s\S]*?)<\/div>\s*<\/div>/g, /<div class="stemblock">\s*<div class="content">([\s\S]*?)<\/div>\s*<\/div>/g,
(_match, mathContent) => { (_match, mathContent) => {
let cleanMath = mathContent let cleanMath = mathContent
.replace(/<span>\$<\/span>/g, '') .replace(/<span>\$<\/span>/g, "")
.replace(/<span>\$\$<\/span>/g, '') .replace(/<span>\$\$<\/span>/g, "")
// Remove $ or $$ on their own line, or surrounded by whitespace/newlines // Remove $ or $$ on their own line, or surrounded by whitespace/newlines
.replace(/(^|[\n\r\s])\$([\n\r\s]|$)/g, '$1$2') .replace(/(^|[\n\r\s])\$([\n\r\s]|$)/g, "$1$2")
.replace(/(^|[\n\r\s])\$\$([\n\r\s]|$)/g, '$1$2') .replace(/(^|[\n\r\s])\$\$([\n\r\s]|$)/g, "$1$2")
// Remove all leading and trailing whitespace and $ // Remove all leading and trailing whitespace and $
.replace(/^[\s$]+/, '').replace(/[\s$]+$/, '') .replace(/^[\s$]+/, "")
.replace(/[\s$]+$/, "")
.trim(); // Final trim to remove any stray whitespace or $ .trim(); // Final trim to remove any stray whitespace or $
// Always wrap in $$...$$ // Always wrap in $$...$$
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`; return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`;
} },
); );
// Inline math: <span>$</span> ... <span>$</span> (allow whitespace/newlines) // Inline math: <span>$</span> ... <span>$</span> (allow whitespace/newlines)
html = html.replace( html = html.replace(
/<span>\$<\/span>\s*([\s\S]+?)\s*<span>\$<\/span>/g, /<span>\$<\/span>\s*([\s\S]+?)\s*<span>\$<\/span>/g,
(_match, mathContent) => `<span class="math-inline">$${mathContent.trim()}$</span>` (_match, mathContent) =>
`<span class="math-inline">$${mathContent.trim()}$</span>`,
); );
// Inline math: stem:[...] or latexmath:[...] // Inline math: stem:[...] or latexmath:[...]
html = html.replace( html = html.replace(
/stem:\[([^\]]+?)\]/g, /stem:\[([^\]]+?)\]/g,
(_match, content) => `<span class="math-inline">$${content.trim()}$</span>` (_match, content) => `<span class="math-inline">$${content.trim()}$</span>`,
); );
html = html.replace( html = html.replace(
/latexmath:\[([^\]]+?)\]/g, /latexmath:\[([^\]]+?)\]/g,
(_match, content) => `<span class="math-inline">\\(${content.trim().replace(/\\\\/g, '\\')}\\)</span>` (_match, content) =>
`<span class="math-inline">\\(${content.trim().replace(/\\\\/g, "\\")}\\)</span>`,
); );
html = html.replace( html = html.replace(
/asciimath:\[([^\]]+?)\]/g, /asciimath:\[([^\]]+?)\]/g,
(_match, content) => `<span class="math-inline">\`${content.trim()}\`</span>` (_match, content) =>
`<span class="math-inline">\`${content.trim()}\`</span>`,
); );
return html; return html;
} }
@ -110,17 +120,20 @@ function processPlantUMLBlocks(html: string): string {
</details> </details>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process PlantUML block:', error); console.warn("Failed to process PlantUML block:", error);
return match; return match;
} }
} },
); );
// Fallback: match <pre> blocks whose content starts with @startuml or @start (global, robust) // Fallback: match <pre> blocks whose content starts with @startuml or @start (global, robust)
html = html.replace( html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, /<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => { (match, content) => {
const lines = content.trim().split('\n'); const lines = content.trim().split("\n");
if (lines[0].trim().startsWith('@startuml') || lines[0].trim().startsWith('@start')) { if (
lines[0].trim().startsWith("@startuml") ||
lines[0].trim().startsWith("@start")
) {
try { try {
const rawContent = decodeHTMLEntities(content); const rawContent = decodeHTMLEntities(content);
const encoded = plantumlEncoder.encode(rawContent); const encoded = plantumlEncoder.encode(rawContent);
@ -139,18 +152,18 @@ function processPlantUMLBlocks(html: string): string {
</details> </details>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process PlantUML fallback block:', error); console.warn("Failed to process PlantUML fallback block:", error);
return match; return match;
} }
} }
return match; return match;
} },
); );
return html; return html;
} }
function decodeHTMLEntities(text: string): string { function decodeHTMLEntities(text: string): string {
const textarea = document.createElement('textarea'); const textarea = document.createElement("textarea");
textarea.innerHTML = text; textarea.innerHTML = text;
return textarea.value; return textarea.value;
} }
@ -183,17 +196,20 @@ function processBPMNBlocks(html: string): string {
</div> </div>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process BPMN block:', error); console.warn("Failed to process BPMN block:", error);
return match; return match;
} }
} },
); );
// Fallback: match <pre> blocks whose content contains 'bpmn:' or '<?xml' and 'bpmn' // Fallback: match <pre> blocks whose content contains 'bpmn:' or '<?xml' and 'bpmn'
html = html.replace( html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, /<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => { (match, content) => {
const text = content.trim(); const text = content.trim();
if (text.includes('bpmn:') || (text.startsWith('<?xml') && text.includes('bpmn'))) { if (
text.includes("bpmn:") ||
(text.startsWith("<?xml") && text.includes("bpmn"))
) {
try { try {
return `<div class="bpmn-block my-4"> return `<div class="bpmn-block my-4">
<div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700"> <div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700">
@ -214,12 +230,12 @@ function processBPMNBlocks(html: string): string {
</div> </div>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process BPMN fallback block:', error); console.warn("Failed to process BPMN fallback block:", error);
return match; return match;
} }
} }
return match; return match;
} },
); );
return html; return html;
} }
@ -252,17 +268,20 @@ function processTikZBlocks(html: string): string {
</div> </div>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process TikZ block:', error); console.warn("Failed to process TikZ block:", error);
return match; return match;
} }
} },
); );
// Fallback: match <pre> blocks whose content starts with \begin{tikzpicture} or contains tikz // Fallback: match <pre> blocks whose content starts with \begin{tikzpicture} or contains tikz
html = html.replace( html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, /<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => { (match, content) => {
const lines = content.trim().split('\n'); const lines = content.trim().split("\n");
if (lines[0].trim().startsWith('\\begin{tikzpicture}') || content.includes('tikz')) { if (
lines[0].trim().startsWith("\\begin{tikzpicture}") ||
content.includes("tikz")
) {
try { try {
return `<div class="tikz-block my-4"> return `<div class="tikz-block my-4">
<div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700"> <div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700">
@ -283,12 +302,12 @@ function processTikZBlocks(html: string): string {
</div> </div>
</div>`; </div>`;
} catch (error) { } catch (error) {
console.warn('Failed to process TikZ fallback block:', error); console.warn("Failed to process TikZ fallback block:", error);
return match; return match;
} }
} }
return match; return match;
} },
); );
return html; return html;
} }
@ -297,7 +316,7 @@ function processTikZBlocks(html: string): string {
* Escapes HTML characters for safe display * Escapes HTML characters for safe display
*/ */
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }

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

@ -1,11 +1,11 @@
import { parseBasicmarkup } from './basicMarkupParser'; import { parseBasicmarkup } from "./basicMarkupParser";
import hljs from 'highlight.js'; import hljs from "highlight.js";
import 'highlight.js/lib/common'; // Import common languages import "highlight.js/lib/common"; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only import "highlight.js/styles/github-dark.css"; // Dark theme only
// Register common languages // Register common languages
hljs.configure({ hljs.configure({
ignoreUnescapedHTML: true ignoreUnescapedHTML: true,
}); });
// Regular expressions for advanced markup elements // Regular expressions for advanced markup elements
@ -17,18 +17,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
const CODE_BLOCK_REGEX = /^```(\w*)$/; const CODE_BLOCK_REGEX = /^```(\w*)$/;
// LaTeX math regex patterns
const INLINE_MATH_REGEX = /\$([^$\n]+?)\$/g;
const DISPLAY_MATH_REGEX = /\$\$([\s\S]*?)\$\$/g;
const LATEX_BLOCK_REGEX = /\\\[([\s\S]*?)\\\]/g;
const LATEX_INLINE_REGEX = /\\\(([^)]+?)\\\)/g;
// Add regex for LaTeX display math environments (e.g., \begin{pmatrix}...\end{pmatrix})
// Improved regex: match optional whitespace/linebreaks before and after, and allow for indented environments
const LATEX_ENV_BLOCK_REGEX =
/(?:^|\n)\s*\\begin\{([a-zA-Z*]+)\}([\s\S]*?)\\end\{\1\}\s*(?=\n|$)/gm;
/** /**
* Process headings (both styles) * Process headings (both styles)
*/ */
function processHeadings(content: string): string { function processHeadings(content: string): string {
// Tailwind classes for each heading level // Tailwind classes for each heading level
const headingClasses = [ const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1 "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-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-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-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-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 "text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h6
]; ];
// Process ATX-style headings (# Heading) // Process ATX-style headings (# Heading)
@ -39,11 +49,14 @@ function processHeadings(content: string): string {
}); });
// Process Setext-style headings (Heading\n====) // Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { processedContent = processedContent.replace(
const headingLevel = level[0] === '=' ? 1 : 2; ALTERNATE_HEADING_REGEX,
const classes = headingClasses[headingLevel - 1]; (_, text, level) => {
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; const headingLevel = level[0] === "=" ? 1 : 2;
}); const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
},
);
return processedContent; return processedContent;
} }
@ -53,29 +66,30 @@ function processHeadings(content: string): string {
*/ */
function processTables(content: string): string { function processTables(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => { return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try { try {
// Split into rows and clean up // Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim()); const rows = match.split("\n").filter((row) => row.trim());
if (rows.length < 1) return match; if (rows.length < 1) return match;
// Helper to process a row into cells // Helper to process a row into cells
const processCells = (row: string): string[] => { const processCells = (row: string): string[] => {
return row return row
.split('|') .split("|")
.slice(1, -1) // Remove empty cells from start/end .slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim()); .map((cell) => cell.trim());
}; };
// Check if second row is a delimiter row (only hyphens) // Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); const hasHeader =
rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows // Extract header and body rows
let headerCells: string[] = []; let headerCells: string[] = [];
let bodyRows: string[] = []; let bodyRows: string[] = [];
if (hasHeader) { if (hasHeader) {
// If we have a header, first row is header, skip delimiter, rest is body // If we have a header, first row is header, skip delimiter, rest is body
headerCells = processCells(rows[0]); headerCells = processCells(rows[0]);
@ -91,33 +105,33 @@ function processTables(content: string): string {
// Add header if exists // Add header if exists
if (hasHeader) { if (hasHeader) {
html += '<thead>\n<tr>\n'; html += "<thead>\n<tr>\n";
headerCells.forEach(cell => { 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 += `<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'; html += "</tr>\n</thead>\n";
} }
// Add body // Add body
html += '<tbody>\n'; html += "<tbody>\n";
bodyRows.forEach(row => { bodyRows.forEach((row) => {
const cells = processCells(row); const cells = processCells(row);
html += '<tr>\n'; html += "<tr>\n";
cells.forEach(cell => { 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 += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
}); });
html += '</tr>\n'; html += "</tr>\n";
}); });
html += '</tbody>\n</table>\n</div>'; html += "</tbody>\n</table>\n</div>";
return html; return html;
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error processing table row:', e); console.error("Error processing table row:", e);
return match; return match;
} }
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processTables:', e); console.error("Error in processTables:", e);
return content; return content;
} }
} }
@ -126,8 +140,9 @@ function processTables(content: string): string {
* Process horizontal rules * Process horizontal rules
*/ */
function processHorizontalRules(content: string): string { function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX, return content.replace(
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">' HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">',
); );
} }
@ -136,7 +151,7 @@ function processHorizontalRules(content: string): string {
*/ */
function processFootnotes(content: string): string { function processFootnotes(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
// Collect all footnote definitions (but do not remove them from the text yet) // Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map<string, string>(); const footnotes = new Map<string, string>();
@ -146,48 +161,57 @@ function processFootnotes(content: string): string {
}); });
// Remove all footnote definition lines from the main content // Remove all footnote definition lines from the main content
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, ''); let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, "");
// Track all references to each footnote // Track all references to each footnote
const referenceOrder: { id: string, refNum: number, label: string }[] = []; const referenceOrder: { id: string; refNum: number; label: string }[] = [];
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...] const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1; let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { processedContent = processedContent.replace(
if (!footnotes.has(id)) { FOOTNOTE_REFERENCE_REGEX,
console.warn(`Footnote reference [^${id}] found but no definition exists`); (match, id) => {
return match; if (!footnotes.has(id)) {
} console.warn(
const refNum = globalRefNum++; `Footnote reference [^${id}] found but no definition exists`,
if (!referenceMap.has(id)) referenceMap.set(id, []); );
referenceMap.get(id)!.push(refNum); return match;
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>`; 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 // Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) { 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'; 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 // Only include each unique footnote once, in order of first reference
const seen = new Set<string>(); const seen = new Set<string>();
for (const { id, label } of referenceOrder) { for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue; if (seen.has(id)) continue;
seen.add(id); seen.add(id);
const text = footnotes.get(id) || ''; const text = footnotes.get(id) || "";
// List of backrefs for this footnote // List of backrefs for this footnote
const refs = referenceMap.get(id) || []; const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) => const backrefs = refs
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>` .map(
).join(' '); (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 // If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : ''; const labelSuffix = isNaN(Number(label)) ? ` ${label}` : "";
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`; processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
} }
processedContent += '</ol>'; processedContent += "</ol>";
} }
return processedContent; return processedContent;
} catch (error) { } catch (error) {
console.error('Error processing footnotes:', error); console.error("Error processing footnotes:", error);
return content; return content;
} }
} }
@ -198,15 +222,15 @@ function processFootnotes(content: string): string {
function processBlockquotes(content: string): string { function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines // Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm; const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
return content.replace(blockquoteRegex, (match) => { return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks // Remove the '>' prefix from each line and preserve line breaks
const text = match const text = match
.split('\n') .split("\n")
.map(line => line.replace(/^>[ \t]?/, '')) .map((line) => line.replace(/^>[ \t]?/, ""))
.join('\n') .join("\n")
.trim(); .trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`; return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
}); });
} }
@ -214,20 +238,23 @@ function processBlockquotes(content: string): string {
/** /**
* Process code blocks by finding consecutive code lines and preserving their content * Process code blocks by finding consecutive code lines and preserving their content
*/ */
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } { function processCodeBlocks(text: string): {
const lines = text.split('\n'); text: string;
blocks: Map<string, string>;
} {
const lines = text.split("\n");
const processedLines: string[] = []; const processedLines: string[] = [];
const blocks = new Map<string, string>(); const blocks = new Map<string, string>();
let inCodeBlock = false; let inCodeBlock = false;
let currentCode: string[] = []; let currentCode: string[] = [];
let currentLanguage = ''; let currentLanguage = "";
let blockCount = 0; let blockCount = 0;
let lastWasCodeBlock = false; let lastWasCodeBlock = false;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
const codeBlockStart = line.match(CODE_BLOCK_REGEX); const codeBlockStart = line.match(CODE_BLOCK_REGEX);
if (codeBlockStart) { if (codeBlockStart) {
if (!inCodeBlock) { if (!inCodeBlock) {
// Starting a new code block // Starting a new code block
@ -239,36 +266,39 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
// Ending current code block // Ending current code block
blockCount++; blockCount++;
const id = `CODE_BLOCK_${blockCount}`; const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n'); const code = currentCode.join("\n");
// Try to format JSON if specified // Try to format JSON if specified
let formattedCode = code; let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') { if (currentLanguage.toLowerCase() === "json") {
try { try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2); formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) { } catch (e: unknown) {
formattedCode = code; formattedCode = code;
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
code: formattedCode, id,
language: currentLanguage, JSON.stringify({
raw: true code: formattedCode,
})); language: currentLanguage,
raw: true,
processedLines.push(''); // Add spacing before code block }),
);
processedLines.push(""); // Add spacing before code block
processedLines.push(id); processedLines.push(id);
processedLines.push(''); // Add spacing after code block processedLines.push(""); // Add spacing after code block
inCodeBlock = false; inCodeBlock = false;
currentCode = []; currentCode = [];
currentLanguage = ''; currentLanguage = "";
} }
} else if (inCodeBlock) { } else if (inCodeBlock) {
currentCode.push(line); currentCode.push(line);
} else { } else {
if (lastWasCodeBlock && line.trim()) { if (lastWasCodeBlock && line.trim()) {
processedLines.push(''); processedLines.push("");
lastWasCodeBlock = false; lastWasCodeBlock = false;
} }
processedLines.push(line); processedLines.push(line);
@ -279,31 +309,34 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
if (inCodeBlock && currentCode.length > 0) { if (inCodeBlock && currentCode.length > 0) {
blockCount++; blockCount++;
const id = `CODE_BLOCK_${blockCount}`; const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n'); const code = currentCode.join("\n");
// Try to format JSON if specified // Try to format JSON if specified
let formattedCode = code; let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') { if (currentLanguage.toLowerCase() === "json") {
try { try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2); formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) { } catch (e: unknown) {
formattedCode = code; formattedCode = code;
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
code: formattedCode, id,
language: currentLanguage, JSON.stringify({
raw: true code: formattedCode,
})); language: currentLanguage,
processedLines.push(''); raw: true,
}),
);
processedLines.push("");
processedLines.push(id); processedLines.push(id);
processedLines.push(''); processedLines.push("");
} }
return { return {
text: processedLines.join('\n'), text: processedLines.join("\n"),
blocks blocks,
}; };
} }
@ -312,22 +345,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
*/ */
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
let result = text; let result = text;
for (const [id, blockData] of blocks) { for (const [id, blockData] of blocks) {
try { try {
const { code, language } = JSON.parse(blockData); const { code, language } = JSON.parse(blockData);
let html; let html;
if (language && hljs.getLanguage(language)) { if (language && hljs.getLanguage(language)) {
try { try {
const highlighted = hljs.highlight(code, { const highlighted = hljs.highlight(code, {
language, language,
ignoreIllegals: true ignoreIllegals: true,
}).value; }).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`; html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) { } catch (e: unknown) {
console.warn('Failed to highlight code block:', e); console.warn("Failed to highlight code block:", e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`; html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ""}">${code}</code></pre>`;
} }
} else { } else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`; html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
@ -335,26 +368,140 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
result = result.replace(id, html); result = result.replace(id, html);
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error restoring code block:', e); console.error("Error restoring code block:", e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>'); result = result.replace(
id,
'<pre class="code-block"><code class="hljs">Error processing code block</code></pre>',
);
} }
} }
return result; return result;
} }
/**
* Process LaTeX math expressions using a token-based approach to avoid nested processing
*/
function processMathExpressions(content: string): string {
// Tokenize the content to avoid nested processing
const tokens: Array<{type: 'text' | 'math', content: string}> = [];
let currentText = '';
let i = 0;
while (i < content.length) {
// Check for LaTeX environments first (most specific)
const envMatch = content.slice(i).match(/^\\begin\{([^}]+)\}([\s\S]*?)\\end\{\1\}/);
if (envMatch) {
if (currentText) {
tokens.push({type: 'text', content: currentText});
currentText = '';
}
tokens.push({type: 'math', content: `\\begin{${envMatch[1]}}${envMatch[2]}\\end{${envMatch[1]}}`});
i += envMatch[0].length;
continue;
}
// Check for display math blocks ($$...$$)
const displayMatch = content.slice(i).match(/^\$\$([\s\S]*?)\$\$/);
if (displayMatch) {
if (currentText) {
tokens.push({type: 'text', content: currentText});
currentText = '';
}
tokens.push({type: 'math', content: displayMatch[1]});
i += displayMatch[0].length;
continue;
}
// Check for LaTeX display math (\[...\])
const latexDisplayMatch = content.slice(i).match(/^\\\[([^\]]+)\\\]/);
if (latexDisplayMatch) {
if (currentText) {
tokens.push({type: 'text', content: currentText});
currentText = '';
}
tokens.push({type: 'math', content: latexDisplayMatch[1]});
i += latexDisplayMatch[0].length;
continue;
}
// Check for inline math ($...$)
const inlineMatch = content.slice(i).match(/^\$([^$\n]+)\$/);
if (inlineMatch) {
if (currentText) {
tokens.push({type: 'text', content: currentText});
currentText = '';
}
tokens.push({type: 'math', content: inlineMatch[1]});
i += inlineMatch[0].length;
continue;
}
// Check for LaTeX inline math (\(...\))
const latexInlineMatch = content.slice(i).match(/^\\\(([^)]+)\\\)/);
if (latexInlineMatch) {
if (currentText) {
tokens.push({type: 'text', content: currentText});
currentText = '';
}
tokens.push({type: 'math', content: latexInlineMatch[1]});
i += latexInlineMatch[0].length;
continue;
}
// If no math pattern matches, add to current text
currentText += content[i];
i++;
}
// Add any remaining text
if (currentText) {
tokens.push({type: 'text', content: currentText});
}
// Now process the tokens to create the final HTML
let result = '';
for (const token of tokens) {
if (token.type === 'text') {
result += token.content;
} else {
// Determine if this should be display or inline math
const isDisplay = token.content.includes('\\begin{') ||
token.content.includes('\\end{') ||
token.content.includes('\\[') ||
token.content.includes('\\]') ||
token.content.length > 50 || // Heuristic for display math
token.content.includes('=') && token.content.length > 20 || // Equations with equals
token.content.includes('\\begin{') || // Any LaTeX environment
token.content.includes('\\boxed{') || // Boxed expressions
token.content.includes('\\text{') && token.content.length > 30; // Text blocks
if (isDisplay) {
result += `<div class="math-block my-4 text-center">$$${token.content}$$</div>`;
} else {
result += `<span class="math-inline">$${token.content}$</span>`;
}
}
}
return result;
}
/** /**
* Parse markup text with advanced formatting * Parse markup text with advanced formatting
*/ */
export async function parseAdvancedmarkup(text: string): Promise<string> { export async function parseAdvancedmarkup(text: string): Promise<string> {
if (!text) return ''; if (!text) return "";
try { try {
// Step 1: Extract and save code blocks first // Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text); const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode; let processedText = withoutCode;
// Step 2: Process block-level elements // Step 2: Process LaTeX math expressions FIRST to avoid wrapping in <p> or <blockquote>
processedText = processMathExpressions(processedText);
// Step 3: Process block-level elements
processedText = processTables(processedText); processedText = processTables(processedText);
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText); processedText = processHeadings(processedText);
@ -364,11 +511,11 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code const escapedCode = code
.trim() .trim()
.replace(/&/g, '&amp;') .replace(/&/g, "&amp;")
.replace(/</g, '&lt;') .replace(/</g, "&lt;")
.replace(/>/g, '&gt;') .replace(/>/g, "&gt;")
.replace(/"/g, '&quot;') .replace(/"/g, "&quot;")
.replace(/'/g, '&#039;'); .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>`; 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>`;
}); });
@ -378,12 +525,12 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
// Process basic markup (which will also handle Nostr identifiers) // Process basic markup (which will also handle Nostr identifiers)
processedText = await parseBasicmarkup(processedText); processedText = await parseBasicmarkup(processedText);
// Step 3: Restore code blocks // Step 4: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks); processedText = restoreCodeBlocks(processedText, blocks);
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in parseAdvancedmarkup:', e); console.error("Error in parseAdvancedmarkup:", e);
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`;
} }
} }

93
src/lib/utils/markup/asciidoctorExtensions.ts

@ -1,5 +1,5 @@
import { renderTikZ } from './tikzRenderer'; import { renderTikZ } from "./tikzRenderer";
import asciidoctor from 'asciidoctor'; import asciidoctor from "asciidoctor";
// Simple math rendering using MathJax CDN // Simple math rendering using MathJax CDN
function renderMath(content: string): string { function renderMath(content: string): string {
@ -18,7 +18,7 @@ function renderPlantUML(content: string): string {
// Encode content for PlantUML server // Encode content for PlantUML server
const encoded = btoa(unescape(encodeURIComponent(content))); const encoded = btoa(unescape(encodeURIComponent(content)));
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
return `<img src="${plantUMLUrl}" alt="PlantUML diagram" class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; return `<img src="${plantUMLUrl}" alt="PlantUML diagram" class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
} }
@ -66,27 +66,27 @@ export function createAdvancedExtensions(): any {
// Read the block content // Read the block content
const lines = reader.getLines(); const lines = reader.getLines();
// Create a source block with the correct language and lang attributes // Create a source block with the correct language and lang attributes
const block = self.createBlock(parent, 'source', lines, { const block = self.createBlock(parent, "source", lines, {
...attrs, ...attrs,
language: name, language: name,
lang: name, lang: name,
style: 'source', style: "source",
role: name, role: name,
}); });
block.setAttribute('language', name); block.setAttribute("language", name);
block.setAttribute('lang', name); block.setAttribute("lang", name);
block.setAttribute('style', 'source'); block.setAttribute("style", "source");
block.setAttribute('role', name); block.setAttribute("role", name);
block.setOption('source', true); block.setOption("source", true);
block.setOption('listing', true); block.setOption("listing", true);
block.setStyle('source'); block.setStyle("source");
return block; return block;
}); });
}); });
} }
registerDiagramBlock('plantuml'); registerDiagramBlock("plantuml");
registerDiagramBlock('tikz'); registerDiagramBlock("tikz");
registerDiagramBlock('bpmn'); registerDiagramBlock("bpmn");
// --- END NEW --- // --- END NEW ---
return extensions; return extensions;
@ -98,7 +98,7 @@ export function createAdvancedExtensions(): any {
function processMathBlocks(treeProcessor: any, document: any): void { function processMathBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks(); const blocks = document.getBlocks();
for (const block of blocks) { for (const block of blocks) {
if (block.getContext() === 'stem') { if (block.getContext() === "stem") {
const content = block.getContent(); const content = block.getContent();
if (content) { if (content) {
try { try {
@ -106,19 +106,22 @@ function processMathBlocks(treeProcessor: any, document: any): void {
const rendered = `<div class="math-block">$$${content}$$</div>`; const rendered = `<div class="math-block">$$${content}$$</div>`;
block.setContent(rendered); block.setContent(rendered);
} catch (error) { } catch (error) {
console.warn('Failed to render math:', error); console.warn("Failed to render math:", error);
} }
} }
} }
// Inline math: context 'inline' and style 'stem' or 'latexmath' // Inline math: context 'inline' and style 'stem' or 'latexmath'
if (block.getContext() === 'inline' && (block.getStyle() === 'stem' || block.getStyle() === 'latexmath')) { if (
block.getContext() === "inline" &&
(block.getStyle() === "stem" || block.getStyle() === "latexmath")
) {
const content = block.getContent(); const content = block.getContent();
if (content) { if (content) {
try { try {
const rendered = `<span class="math-inline">$${content}$</span>`; const rendered = `<span class="math-inline">$${content}$</span>`;
block.setContent(rendered); block.setContent(rendered);
} catch (error) { } catch (error) {
console.warn('Failed to render inline math:', error); console.warn("Failed to render inline math:", error);
} }
} }
} }
@ -130,19 +133,19 @@ function processMathBlocks(treeProcessor: any, document: any): void {
*/ */
function processPlantUMLBlocks(treeProcessor: any, document: any): void { function processPlantUMLBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks(); const blocks = document.getBlocks();
for (const block of blocks) { for (const block of blocks) {
if (block.getContext() === 'listing' && isPlantUMLBlock(block)) { if (block.getContext() === "listing" && isPlantUMLBlock(block)) {
const content = block.getContent(); const content = block.getContent();
if (content) { if (content) {
try { try {
// Use simple PlantUML rendering // Use simple PlantUML rendering
const rendered = renderPlantUML(content); const rendered = renderPlantUML(content);
// Replace the block content with the image // Replace the block content with the image
block.setContent(rendered); block.setContent(rendered);
} catch (error) { } catch (error) {
console.warn('Failed to render PlantUML:', error); console.warn("Failed to render PlantUML:", error);
// Keep original content if rendering fails // Keep original content if rendering fails
} }
} }
@ -155,19 +158,19 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void {
*/ */
function processTikZBlocks(treeProcessor: any, document: any): void { function processTikZBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks(); const blocks = document.getBlocks();
for (const block of blocks) { for (const block of blocks) {
if (block.getContext() === 'listing' && isTikZBlock(block)) { if (block.getContext() === "listing" && isTikZBlock(block)) {
const content = block.getContent(); const content = block.getContent();
if (content) { if (content) {
try { try {
// Render TikZ to SVG // Render TikZ to SVG
const svg = renderTikZ(content); const svg = renderTikZ(content);
// Replace the block content with the SVG // Replace the block content with the SVG
block.setContent(svg); block.setContent(svg);
} catch (error) { } catch (error) {
console.warn('Failed to render TikZ:', error); console.warn("Failed to render TikZ:", error);
// Keep original content if rendering fails // Keep original content if rendering fails
} }
} }
@ -179,15 +182,16 @@ function processTikZBlocks(treeProcessor: any, document: any): void {
* Checks if a block contains PlantUML content * Checks if a block contains PlantUML content
*/ */
function isPlantUMLBlock(block: any): boolean { function isPlantUMLBlock(block: any): boolean {
const content = block.getContent() || ''; const content = block.getContent() || "";
const lines = content.split('\n'); const lines = content.split("\n");
// Check for PlantUML indicators // Check for PlantUML indicators
return lines.some((line: string) => return lines.some(
line.trim().startsWith('@startuml') || (line: string) =>
line.trim().startsWith('@start') || line.trim().startsWith("@startuml") ||
line.includes('plantuml') || line.trim().startsWith("@start") ||
line.includes('uml') line.includes("plantuml") ||
line.includes("uml"),
); );
} }
@ -195,14 +199,15 @@ function isPlantUMLBlock(block: any): boolean {
* Checks if a block contains TikZ content * Checks if a block contains TikZ content
*/ */
function isTikZBlock(block: any): boolean { function isTikZBlock(block: any): boolean {
const content = block.getContent() || ''; const content = block.getContent() || "";
const lines = content.split('\n'); const lines = content.split("\n");
// Check for TikZ indicators // Check for TikZ indicators
return lines.some((line: string) => return lines.some(
line.trim().startsWith('\\begin{tikzpicture}') || (line: string) =>
line.trim().startsWith('\\tikz') || line.trim().startsWith("\\begin{tikzpicture}") ||
line.includes('tikzpicture') || line.trim().startsWith("\\tikz") ||
line.includes('tikz') line.includes("tikzpicture") ||
line.includes("tikz"),
); );
} }

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

@ -1,4 +1,4 @@
import { processNostrIdentifiers } from '../nostrUtils'; import { processNostrIdentifiers } from "../nostrUtils";
/** /**
* Normalizes a string for use as a d-tag by converting to lowercase, * Normalizes a string for use as a d-tag by converting to lowercase,
@ -8,9 +8,9 @@ import { processNostrIdentifiers } from '../nostrUtils';
function normalizeDTag(input: string): string { function normalizeDTag(input: string): string {
return input return input
.toLowerCase() .toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '-') .replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, '-') .replace(/-+/g, "-")
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, "");
} }
/** /**
@ -19,13 +19,30 @@ function normalizeDTag(input: string): string {
*/ */
function replaceWikilinks(html: string): string { function replaceWikilinks(html: string): string {
// [[target page]] or [[target page|display text]] // [[target page]] or [[target page|display text]]
return html.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { return html.replace(
const normalized = normalizeDTag(target.trim()); /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
const display = (label || target).trim(); (_match, target, label) => {
const url = `./events?d=${normalized}`; const normalized = normalizeDTag(target.trim());
// Output as a clickable <a> with the [[display]] format and matching link colors const display = (label || target).trim();
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; const url = `./events?d=${normalized}`;
}); // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(
/<a id="([^"]+)"><\/a>/g,
(_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
}
);
} }
/** /**
@ -37,40 +54,42 @@ async function processNostrAddresses(html: string): Promise<string> {
function isWithinLink(text: string, index: number): boolean { function isWithinLink(text: string, index: number): boolean {
// Look backwards from the match position to find the nearest <a> tag // Look backwards from the match position to find the nearest <a> tag
const before = text.slice(0, index); const before = text.slice(0, index);
const lastOpenTag = before.lastIndexOf('<a'); const lastOpenTag = before.lastIndexOf("<a");
const lastCloseTag = before.lastIndexOf('</a>'); const lastCloseTag = before.lastIndexOf("</a>");
// If we find an opening <a> tag after the last closing </a> tag, we're inside a link // If we find an opening <a> tag after the last closing </a> tag, we're inside a link
return lastOpenTag > lastCloseTag; return lastOpenTag > lastCloseTag;
} }
// Process nostr addresses that are not within existing links // Process nostr addresses that are not within existing links
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g; const nostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedHtml = html; let processedHtml = html;
// Find all nostr addresses // Find all nostr addresses
const matches = Array.from(processedHtml.matchAll(nostrPattern)); const matches = Array.from(processedHtml.matchAll(nostrPattern));
// Process them in reverse order to avoid index shifting issues // Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) { for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i]; const match = matches[i];
const [fullMatch] = match; const [fullMatch] = match;
const matchIndex = match.index ?? 0; const matchIndex = match.index ?? 0;
// Skip if already within a link // Skip if already within a link
if (isWithinLink(processedHtml, matchIndex)) { if (isWithinLink(processedHtml, matchIndex)) {
continue; continue;
} }
// Process the nostr identifier // Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch); const processedMatch = await processNostrIdentifiers(fullMatch);
// Replace the match in the HTML // Replace the match in the HTML
processedHtml = processedHtml.slice(0, matchIndex) + processedHtml =
processedMatch + processedHtml.slice(0, matchIndex) +
processedHtml.slice(matchIndex + fullMatch.length); processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
} }
return processedHtml; return processedHtml;
} }
@ -85,9 +104,9 @@ function fixStemBlocks(html: string): string {
/<div class="stemblock">\s*<div class="content">\s*<span>\$<\/span>([\s\S]*?)<span>\$<\/span>\s*<\/div>\s*<\/div>/g, /<div class="stemblock">\s*<div class="content">\s*<span>\$<\/span>([\s\S]*?)<span>\$<\/span>\s*<\/div>\s*<\/div>/g,
(_match, mathContent) => { (_match, mathContent) => {
// Remove any extra tags inside mathContent // Remove any extra tags inside mathContent
const cleanMath = mathContent.replace(/<\/?span[^>]*>/g, '').trim(); const cleanMath = mathContent.replace(/<\/?span[^>]*>/g, "").trim();
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`; return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`;
} },
); );
} }
@ -95,20 +114,24 @@ function fixStemBlocks(html: string): string {
* Post-processes asciidoctor HTML output to add wikilink and nostr address rendering. * Post-processes asciidoctor HTML output to add wikilink and nostr address rendering.
* This function should be called after asciidoctor.convert() to enhance the HTML output. * This function should be called after asciidoctor.convert() to enhance the HTML output.
*/ */
export async function postProcessAsciidoctorHtml(html: string): Promise<string> { export async function postProcessAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html; if (!html) return html;
try { try {
// First process wikilinks console.log('HTML before replaceWikilinks:', html);
let processedHtml = replaceWikilinks(html); // First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain)
processedHtml = replaceWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links) // Then process nostr addresses (but not those already in links)
processedHtml = await processNostrAddresses(processedHtml); processedHtml = await processNostrAddresses(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {
console.error('Error in postProcessAsciidoctorHtml:', error); console.error("Error in postProcessAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails return html; // Return original HTML if processing fails
} }
} }

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

@ -1,6 +1,6 @@
import { processNostrIdentifiers } from '../nostrUtils'; import { processNostrIdentifiers } from "../nostrUtils";
import * as emoji from 'node-emoji'; import * as emoji from "node-emoji";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
@ -23,37 +23,42 @@ const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/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 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; 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: // Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string { function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs // Regex for Alexandria/localhost URLs
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers // Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex // Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/; const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links // 1. Alexandria/localhost markup links
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => { text = text.replace(
if (alexandriaPattern.test(url)) { /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
if (/[?&]d=/.test(url)) return match; (match, _label, url) => {
const hexMatch = url.match(hexPattern); if (alexandriaPattern.test(url)) {
if (hexMatch) { if (/[?&]d=/.test(url)) return match;
try { const hexMatch = url.match(hexPattern);
const nevent = nip19.neventEncode({ id: hexMatch[0] }); if (hexMatch) {
return `nostr:${nevent}`; try {
} catch { const nevent = nip19.neventEncode({ id: hexMatch[0] });
return match; return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
} }
} }
const bech32Match = url.match(bech32Pattern); return match;
if (bech32Match) { },
return `nostr:${bech32Match[0]}`; );
}
}
return match;
});
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers // 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
@ -96,12 +101,18 @@ function replaceAlexandriaNostrLinks(text: string): string {
// Utility to strip tracking parameters from URLs // Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string { function stripTrackingParams(url: string): string {
// List of tracking params to remove // List of tracking params to remove
const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i]; const trackingParams = [
/^utm_/i,
/^fbclid$/i,
/^gclid$/i,
/^tracking$/i,
/^ref$/i,
];
try { try {
// Absolute URL // Absolute URL
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
const parsed = new URL(url); const parsed = new URL(url);
trackingParams.forEach(pattern => { trackingParams.forEach((pattern) => {
for (const key of Array.from(parsed.searchParams.keys())) { for (const key of Array.from(parsed.searchParams.keys())) {
if (pattern.test(key)) { if (pattern.test(key)) {
parsed.searchParams.delete(key); parsed.searchParams.delete(key);
@ -109,19 +120,24 @@ function stripTrackingParams(url: string): string {
} }
}); });
const queryString = parsed.searchParams.toString(); const queryString = parsed.searchParams.toString();
return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || ''); return (
parsed.origin +
parsed.pathname +
(queryString ? "?" + queryString : "") +
(parsed.hash || "")
);
} else { } else {
// Relative URL: parse query string manually // Relative URL: parse query string manually
const [path, queryAndHash = ''] = url.split('?'); const [path, queryAndHash = ""] = url.split("?");
const [query = '', hash = ''] = queryAndHash.split('#'); const [query = "", hash = ""] = queryAndHash.split("#");
if (!query) return url; if (!query) return url;
const params = query.split('&').filter(Boolean); const params = query.split("&").filter(Boolean);
const filtered = params.filter(param => { const filtered = params.filter((param) => {
const [key] = param.split('='); const [key] = param.split("=");
return !trackingParams.some(pattern => pattern.test(key)); return !trackingParams.some((pattern) => pattern.test(key));
}); });
const queryString = filtered.length ? '?' + filtered.join('&') : ''; const queryString = filtered.length ? "?" + filtered.join("&") : "";
const hashString = hash ? '#' + hash : ''; const hashString = hash ? "#" + hash : "";
return path + queryString + hashString; return path + queryString + hashString;
} }
} catch { } catch {
@ -132,38 +148,45 @@ function stripTrackingParams(url: string): string {
function normalizeDTag(input: string): string { function normalizeDTag(input: string): string {
return input return input
.toLowerCase() .toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '-') .replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, '-') .replace(/-+/g, "-")
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, "");
} }
function replaceWikilinks(text: string): string { function replaceWikilinks(text: string): string {
// [[target page]] or [[target page|display text]] // [[target page]] or [[target page|display text]]
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { return text.replace(
const normalized = normalizeDTag(target.trim()); /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
const display = (label || target).trim(); (_match, target, label) => {
const url = `./events?d=${normalized}`; const normalized = normalizeDTag(target.trim());
// Output as a clickable <a> with the [[display]] format and matching link colors const display = (label || target).trim();
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; const url = `./events?d=${normalized}`;
}); // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
} }
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string { function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] { function parseList(
let html = ''; start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start; let i = start;
html += `<${type} class="${type === 'ol' ? 'list-decimal' : 'list-disc'} ml-6 mb-2">`; html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break; if (!match) break;
const lineIndent = match[1].replace(/\t/g, ' ').length; const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]); const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? 'ol' : 'ul'; const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) { if (lineIndent > indent) {
// Nested list // Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, '') + nestedHtml + '</li>'; html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed; i = consumed;
continue; continue;
} }
@ -175,35 +198,39 @@ function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string {
if (i + 1 < lines.length) { if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) { if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, ' ').length; const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul'; const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) { if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType); const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml; html += nestedHtml;
i = consumed - 1; i = consumed - 1;
} }
} }
} }
html += '</li>'; html += "</li>";
i++; i++;
} }
html += `</${type}>`; html += `</${type}>`;
return [html, i]; return [html, i];
} }
if (!lines.length) return ''; if (!lines.length) return "";
const firstLine = lines[0]; const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, ' ').length : 0; const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul'); const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type); const [html] = parseList(0, indent, type);
return html; return html;
} }
function processBasicFormatting(content: string): string { function processBasicFormatting(content: string): string {
if (!content) return ''; if (!content) return "";
let processedText = content; let processedText = content;
try { try {
// Sanitize Alexandria Nostr links before further processing // Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText); processedText = replaceAlexandriaNostrLinks(processedText);
@ -214,17 +241,17 @@ function processBasicFormatting(content: string): string {
if (YOUTUBE_URL_REGEX.test(url)) { if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = extractYouTubeVideoId(url); const videoId = extractYouTubeVideoId(url);
if (videoId) { 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>`; 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)) { 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>`; 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)) { if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`; 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 // Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { 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">`; 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 // Otherwise, render as a clickable link
@ -232,19 +259,21 @@ function processBasicFormatting(content: string): string {
}); });
// Process markup links // Process markup links
processedText = processedText.replace(MARKUP_LINK, (match, text, url) => processedText = processedText.replace(
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>` 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 // Process WebSocket URLs
processedText = processedText.replace(WSS_URL, match => { processedText = processedText.replace(WSS_URL, (match) => {
// Remove 'wss://' from the start and any trailing slashes // Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, ''); 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>`; 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 // Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, match => { processedText = processedText.replace(DIRECT_LINK, (match) => {
const clean = stripTrackingParams(match); const clean = stripTrackingParams(match);
if (YOUTUBE_URL_REGEX.test(clean)) { if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean); const videoId = extractYouTubeVideoId(clean);
@ -259,30 +288,36 @@ function processBasicFormatting(content: string): string {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`; 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 // Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) { 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">`; 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 // 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>`; 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 // Process text formatting
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>'); processedText = processedText.replace(BOLD_REGEX, "<strong>$2</strong>");
processedText = processedText.replace(ITALIC_REGEX, match => { processedText = processedText.replace(ITALIC_REGEX, (match) => {
const text = match.replace(/^_+|_+$/g, ''); const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`; return `<em>${text}</em>`;
}); });
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { processedText = processedText.replace(
const text = doubleText || singleText; STRIKETHROUGH_REGEX,
return `<del class="line-through">${text}</del>`; (match, doubleText, singleText) => {
}); const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
// Process hashtags // Process hashtags
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-primary-600 dark:text-primary-500">#$1</span>'); processedText = processedText.replace(
HASHTAG_REGEX,
'<span class="text-primary-600 dark:text-primary-500">#$1</span>',
);
// --- Improved List Grouping and Parsing --- // --- Improved List Grouping and Parsing ---
const lines = processedText.split('\n'); const lines = processedText.split("\n");
let output = ''; let output = "";
let buffer: string[] = []; let buffer: string[] = [];
let inList = false; let inList = false;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@ -294,23 +329,22 @@ function processBasicFormatting(content: string): string {
if (inList) { if (inList) {
const firstLine = buffer[0]; const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = []; buffer = [];
inList = false; inList = false;
} }
output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n'; output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
} }
} }
if (buffer.length) { if (buffer.length) {
const firstLine = buffer[0]; const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
} }
processedText = output; processedText = output;
// --- End Improved List Grouping and Parsing --- // --- End Improved List Grouping and Parsing ---
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processBasicFormatting:', e); console.error("Error in processBasicFormatting:", e);
} }
return processedText; return processedText;
@ -318,61 +352,72 @@ function processBasicFormatting(content: string): string {
// Helper function to extract YouTube video ID // Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null { 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})/); 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; return match ? match[1] : null;
} }
function processBlockquotes(content: string): string { function processBlockquotes(content: string): string {
try { try {
if (!content) return ''; if (!content) return "";
return content.replace(BLOCKQUOTE_REGEX, match => { return content.replace(BLOCKQUOTE_REGEX, (match) => {
const lines = match.split('\n').map(line => { const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, '').trim(); return line.replace(/^[ \t]*>[ \t]?/, "").trim();
}); });
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${ return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${lines.join(
lines.join('\n') "\n",
}</blockquote>`; )}</blockquote>`;
}); });
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processBlockquotes:', e); console.error("Error in processBlockquotes:", e);
return content; return content;
} }
} }
function processEmojiShortcuts(content: string): string { function processEmojiShortcuts(content: string): string {
try { try {
return emoji.emojify(content, { fallback: (name: string) => { return emoji.emojify(content, {
const emojiChar = emoji.get(name); fallback: (name: string) => {
return emojiChar || `:${name}:`; const emojiChar = emoji.get(name);
}}); return emojiChar || `:${name}:`;
},
});
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in processEmojiShortcuts:', e); console.error("Error in processEmojiShortcuts:", e);
return content; return content;
} }
} }
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return ''; if (!text) return "";
try { try {
// Process basic text formatting first // Process basic text formatting first
let processedText = processBasicFormatting(text); let processedText = processBasicFormatting(text);
// Process emoji shortcuts // Process emoji shortcuts
processedText = processEmojiShortcuts(processedText); processedText = processEmojiShortcuts(processedText);
// Process blockquotes // Process blockquotes
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags // Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements
processedText = processedText processedText = processedText
.split(/\n\n+/) .split(/\n\n+/)
.map(para => para.trim()) .map((para) => para.trim())
.filter(para => para.length > 0) .filter((para) => para.length > 0)
.map(para => `<p class="my-4">${para}</p>`) .map((para) => {
.join('\n'); // Skip wrapping if para already contains block-level elements
if (/<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(para)) {
return para;
}
return `<p class="my-4">${para}</p>`;
})
.join("\n");
// Process Nostr identifiers last // Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText); processedText = await processNostrIdentifiers(processedText);
@ -382,7 +427,7 @@ export async function parseBasicmarkup(text: string): Promise<string> {
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {
console.error('Error in parseBasicmarkup:', e); console.error("Error in parseBasicmarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`;
} }
} }

12
src/lib/utils/markup/tikzRenderer.ts

@ -10,13 +10,13 @@ export function renderTikZ(tikzCode: string): string {
try { try {
// For now, we'll create a simple SVG placeholder // For now, we'll create a simple SVG placeholder
// In a full implementation, this would use node-tikzjax or similar library // In a full implementation, this would use node-tikzjax or similar library
// Extract TikZ content and create a basic SVG // Extract TikZ content and create a basic SVG
const svgContent = createBasicSVG(tikzCode); const svgContent = createBasicSVG(tikzCode);
return svgContent; return svgContent;
} catch (error) { } catch (error) {
console.error('Failed to render TikZ:', error); console.error("Failed to render TikZ:", error);
return `<div class="tikz-error text-red-500 p-4 border border-red-300 rounded"> return `<div class="tikz-error text-red-500 p-4 border border-red-300 rounded">
<p class="font-bold">TikZ Rendering Error</p> <p class="font-bold">TikZ Rendering Error</p>
<p class="text-sm">Failed to render TikZ diagram. Original code:</p> <p class="text-sm">Failed to render TikZ diagram. Original code:</p>
@ -33,7 +33,7 @@ function createBasicSVG(tikzCode: string): string {
// Create a simple SVG with the TikZ code as text // Create a simple SVG with the TikZ code as text
const width = 400; const width = 400;
const height = 300; const height = 300;
return `<svg width="${width}" height="${height}" class="tikz-diagram max-w-full h-auto rounded-lg shadow-lg my-4" viewBox="0 0 ${width} ${height}"> return `<svg width="${width}" height="${height}" class="tikz-diagram max-w-full h-auto rounded-lg shadow-lg my-4" viewBox="0 0 ${width} ${height}">
<rect width="${width}" height="${height}" fill="white" stroke="#ccc" stroke-width="1"/> <rect width="${width}" height="${height}" fill="white" stroke="#ccc" stroke-width="1"/>
<text x="10" y="20" font-family="monospace" font-size="12" fill="#666"> <text x="10" y="20" font-family="monospace" font-size="12" fill="#666">
@ -54,7 +54,7 @@ function createBasicSVG(tikzCode: string): string {
* Escapes HTML characters for safe display * Escapes HTML characters for safe display
*/ */
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }

27
src/lib/utils/mime.ts

@ -6,22 +6,24 @@
* - Addressable: 30000-39999 (latest per d-tag stored) * - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays) * - Regular: all other kinds (stored by relays)
*/ */
export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { export function getEventType(
kind: number,
): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first // Check special ranges first
if (kind >= 30000 && kind < 40000) { if (kind >= 30000 && kind < 40000) {
return 'addressable'; return "addressable";
} }
if (kind >= 20000 && kind < 30000) { if (kind >= 20000 && kind < 30000) {
return 'ephemeral'; return "ephemeral";
} }
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) {
return 'replaceable'; return "replaceable";
} }
// Everything else is regular // Everything else is regular
return 'regular'; return "regular";
} }
/** /**
@ -36,9 +38,10 @@ export function getMimeTags(kind: number): [string, string][] {
// Determine replaceability based on event type // Determine replaceability based on event type
const eventType = getEventType(kind); const eventType = getEventType(kind);
const replaceability = (eventType === 'replaceable' || eventType === 'addressable') const replaceability =
? "replaceable" eventType === "replaceable" || eventType === "addressable"
: "nonreplaceable"; ? "replaceable"
: "nonreplaceable";
switch (kind) { switch (kind) {
// Short text note // Short text note
@ -93,4 +96,4 @@ export function getMimeTags(kind: number): [string, string][] {
} }
return [mTag, MTag]; return [mTag, MTag];
} }

257
src/lib/utils/nostrUtils.ts

@ -1,22 +1,26 @@
import { get } from 'svelte/store'; import { get } from "svelte/store";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from "$lib/ndk";
import { npubCache } from './npubCache'; import { npubCache } from "./npubCache";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts"; import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from '@noble/hashes/sha256'; import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from '@noble/curves/secp256k1'; import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from '@noble/hashes/utils'; import { bytesToHex } from "@noble/hashes/utils";
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>' const badgeCheckSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>';
const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>'; const graduationCapSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix // 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_PROFILE_REGEX =
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; /(?<![\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;
export interface NostrProfile { export interface NostrProfile {
name?: string; name?: string;
@ -34,22 +38,24 @@ export interface NostrProfile {
*/ */
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = { const htmlEscapes: { [key: string]: string } = {
'&': '&amp;', "&": "&amp;",
'<': '&lt;', "<": "&lt;",
'>': '&gt;', ">": "&gt;",
'"': '&quot;', '"': "&quot;",
"'": '&#039;' "'": "&#039;",
}; };
return text.replace(/[&<>"']/g, char => htmlEscapes[char]); return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
} }
/** /**
* Get user metadata for a nostr identifier (npub or nprofile) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
export async function getUserMetadata(identifier: string): Promise<NostrProfile> { export async function getUserMetadata(
identifier: string,
): Promise<NostrProfile> {
// Remove nostr: prefix if present // Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
if (npubCache.has(cleanId)) { if (npubCache.has(cleanId)) {
return npubCache.get(cleanId)!; return npubCache.get(cleanId)!;
} }
@ -71,17 +77,23 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
// Handle different identifier types // Handle different identifier types
let pubkey: string; let pubkey: string;
if (decoded.type === 'npub') { if (decoded.type === "npub") {
pubkey = decoded.data; pubkey = decoded.data;
} else if (decoded.type === 'nprofile') { } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey; pubkey = decoded.data.pubkey;
} else { } else {
npubCache.set(cleanId, fallback); npubCache.set(cleanId, fallback);
return fallback; return fallback;
} }
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] }); const profileEvent = await fetchEventWithFallback(ndk, {
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null; kinds: [0],
authors: [pubkey],
});
const profile =
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content)
: null;
const metadata: NostrProfile = { const metadata: NostrProfile = {
name: profile?.name || fallback.name, name: profile?.name || fallback.name,
@ -91,9 +103,9 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
about: profile?.about, about: profile?.about,
banner: profile?.banner, banner: profile?.banner,
website: profile?.website, website: profile?.website,
lud16: profile?.lud16 lud16: profile?.lud16,
}; };
npubCache.set(cleanId, metadata); npubCache.set(cleanId, metadata);
return metadata; return metadata;
} catch (e) { } catch (e) {
@ -105,27 +117,33 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
/** /**
* Create a profile link element * Create a profile link element
*/ */
export function createProfileLink(identifier: string, displayText: string | undefined): string { export function createProfileLink(
const cleanId = identifier.replace(/^nostr:/, ''); identifier: string,
displayText: string | undefined,
): string {
const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
} }
/** /**
* Create a profile link element with a NIP-05 verification indicator. * Create a profile link element with a NIP-05 verification indicator.
*/ */
export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise<string> { export async function createProfileLinkWithVerification(
identifier: string,
displayText: string | undefined,
): Promise<string> {
const ndk = get(ndkInstance) as NDK; const ndk = get(ndkInstance) as NDK;
if (!ndk) { if (!ndk) {
return createProfileLink(identifier, displayText); return createProfileLink(identifier, displayText);
} }
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const isNpub = cleanId.startsWith('npub'); const isNpub = cleanId.startsWith("npub");
let user: NDKUser; let user: NDKUser;
if (isNpub) { if (isNpub) {
@ -134,19 +152,23 @@ export async function createProfileLinkWithVerification(identifier: string, disp
user = ndk.getUser({ pubkey: cleanId }); user = ndk.getUser({ pubkey: cleanId });
} }
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url); const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
const allRelays = [ const allRelays = [
...standardRelays, ...standardRelays,
...userRelays, ...userRelays,
...fallbackRelays ...fallbackRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx); ].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent( const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] }, { kinds: [0], authors: [user.pubkey] },
undefined, undefined,
relaySet relaySet,
); );
const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null; const profile = profileEvent?.content
? JSON.parse(profileEvent.content)
: null;
const nip05 = profile?.nip05; const nip05 = profile?.nip05;
if (!nip05) { if (!nip05) {
@ -155,20 +177,24 @@ export async function createProfileLinkWithVerification(identifier: string, disp
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
const displayIdentifier = profile?.displayName ?? profile?.display_name ?? profile?.name ?? escapedText; const displayIdentifier =
profile?.displayName ??
profile?.display_name ??
profile?.name ??
escapedText;
const isVerified = await user.validateNip05(nip05); const isVerified = await user.validateNip05(nip05);
if (!isVerified) { if (!isVerified) {
return createProfileLink(identifier, displayText); return createProfileLink(identifier, displayText);
} }
// TODO: Make this work with an enum in case we add more types. // TODO: Make this work with an enum in case we add more types.
const type = nip05.endsWith('edu') ? 'edu' : 'standard'; const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) { switch (type) {
case 'edu': case "edu":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case 'standard': case "standard":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
} }
} }
@ -176,18 +202,20 @@ export async function createProfileLinkWithVerification(identifier: string, disp
* Create a note link element * Create a note link element
*/ */
function createNoteLink(identifier: string): string { function createNoteLink(identifier: string): string {
const cleanId = identifier.replace(/^nostr:/, ''); const cleanId = identifier.replace(/^nostr:/, "");
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId); const escapedText = escapeHtml(shortId);
return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`; return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`;
} }
/** /**
* Process Nostr identifiers in text * Process Nostr identifiers in text
*/ */
export async function processNostrIdentifiers(content: string): Promise<string> { export async function processNostrIdentifiers(
content: string,
): Promise<string> {
let processedContent = content; let processedContent = content;
// Helper to check if a match is part of a URL // Helper to check if a match is part of a URL
@ -206,8 +234,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL continue; // skip if part of a URL
} }
let identifier = fullMatch; let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) { if (!identifier.startsWith("nostr:")) {
identifier = 'nostr:' + identifier; identifier = "nostr:" + identifier;
} }
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
@ -224,8 +252,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL continue; // skip if part of a URL
} }
let identifier = fullMatch; let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) { if (!identifier.startsWith("nostr:")) {
identifier = 'nostr:' + identifier; identifier = "nostr:" + identifier;
} }
const link = createNoteLink(identifier); const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link); processedContent = processedContent.replace(fullMatch, link);
@ -238,17 +266,17 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk) { if (!ndk) {
console.error('NDK not initialized'); console.error("NDK not initialized");
return null; return null;
} }
const user = await ndk.getUser({ nip05 }); const user = await ndk.getUser({ nip05 });
if (!user || !user.npub) { if (!user || !user.npub) {
return null; return null;
} }
return user.npub; return user.npub;
} catch (error) { } catch (error) {
console.error('Error getting npub from nip05:', error); console.error("Error getting npub from nip05:", error);
return null; return null;
} }
} }
@ -258,7 +286,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
* Can be used in two ways: * Can be used in two ways:
* 1. Method style: promise.withTimeout(5000) * 1. Method style: promise.withTimeout(5000)
* 2. Function style: withTimeout(promise, 5000) * 2. Function style: withTimeout(promise, 5000)
* *
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style) * @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style) * @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
* @returns The promise result if completed before timeout, otherwise throws an error * @returns The promise result if completed before timeout, otherwise throws an error
@ -266,28 +294,28 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
*/ */
export function withTimeout<T>( export function withTimeout<T>(
thisOrPromise: Promise<T> | number, thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T> timeoutMsOrPromise?: number | Promise<T>,
): Promise<T> { ): Promise<T> {
// Handle method-style call (promise.withTimeout(5000)) // Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') { if (typeof thisOrPromise === "number") {
const timeoutMs = thisOrPromise; const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>; const promise = timeoutMsOrPromise as Promise<T>;
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs) setTimeout(() => reject(new Error("Timeout")), timeoutMs),
) ),
]); ]);
} }
// Handle function-style call (withTimeout(promise, 5000)) // Handle function-style call (withTimeout(promise, 5000))
const promise = thisOrPromise; const promise = thisOrPromise;
const timeoutMs = timeoutMsOrPromise as number; const timeoutMs = timeoutMsOrPromise as number;
return Promise.race([ return Promise.race([
promise, promise,
new Promise<T>((_, reject) => new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs) setTimeout(() => reject(new Error("Timeout")), timeoutMs),
) ),
]); ]);
} }
@ -298,7 +326,10 @@ declare global {
} }
} }
Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number): Promise<T> { Promise.prototype.withTimeout = function <T>(
this: Promise<T>,
timeoutMs: number,
): Promise<T> {
return withTimeout(timeoutMs, this); return withTimeout(timeoutMs, this);
}; };
@ -311,24 +342,24 @@ Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number)
export async function fetchEventWithFallback( export async function fetchEventWithFallback(
ndk: NDK, ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>, filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000 timeoutMs: number = 3000,
): Promise<NDKEvent | null> { ): Promise<NDKEvent | null> {
// Get user relays if logged in // Get user relays if logged in
const userRelays = ndk.activeUser ? const userRelays = ndk.activeUser
Array.from(ndk.pool?.relays.values() || []) ? Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays .filter((r) => r.status === 1) // Only use connected relays
.map(r => r.url) : .map((r) => r.url)
[]; : [];
// Determine which relays to use based on user authentication status // Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser; const isSignedIn = ndk.signer && ndk.activeUser;
const primaryRelays = isSignedIn ? standardRelays : anonymousRelays; const primaryRelays = isSignedIn ? standardRelays : anonymousRelays;
// Create three relay sets in priority order // Create three relay sets in priority order
const relaySets = [ const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous) NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in) NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort) NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort)
]; ];
try { try {
@ -336,47 +367,75 @@ export async function fetchEventWithFallback(
const triedRelaySets: string[] = []; const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set // Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> { async function tryFetchFromRelaySet(
relaySet: NDKRelaySetFromNDK,
setName: string,
): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null; if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName); triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { if (
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs); typeof filterOrId === "string" &&
/^[0-9a-f]{64}$/i.test(filterOrId)
) {
return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs);
} else { } else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; const filter =
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs); typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId;
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null; const results = await ndk
.fetchEvents(filter, undefined, relaySet)
.withTimeout(timeoutMs);
return results instanceof Set
? (Array.from(results)[0] as NDKEvent)
: null;
} }
} }
// Try each relay set in order // Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) { for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') : const setName =
index === 1 ? 'user relays' : index === 0
'fallback relays'; ? isSignedIn
? "standard relays"
: "anonymous relays"
: index === 1
? "user relays"
: "fallback relays";
found = await tryFetchFromRelaySet(relaySet, setName); found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break; if (found) break;
} }
if (!found) { if (!found) {
const timeoutSeconds = timeoutMs / 1000; const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => { const relayUrls = relaySets
const setName = i === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') : .map((set, i) => {
i === 1 ? 'user relays' : const setName =
'fallback relays'; i === 0
const urls = Array.from(set.relays).map(r => r.url); ? isSignedIn
return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null; ? "standard relays"
}).filter(Boolean).join(', then '); : "anonymous relays"
: i === 1
console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`); ? "user relays"
: "fallback relays";
const urls = Array.from(set.relays).map((r) => r.url);
return urls.length > 0 ? `${setName} (${urls.join(", ")})` : null;
})
.filter(Boolean)
.join(", then ");
console.warn(
`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`,
);
return null; return null;
} }
// Always wrap as NDKEvent // Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) { } catch (err) {
console.error('Error in fetchEventWithFallback:', err); console.error("Error in fetchEventWithFallback:", err);
return null; return null;
} }
} }
@ -390,7 +449,7 @@ export function toNpub(pubkey: string | undefined): string | null {
if (/^[a-f0-9]{64}$/i.test(pubkey)) { if (/^[a-f0-9]{64}$/i.test(pubkey)) {
return nip19.npubEncode(pubkey); return nip19.npubEncode(pubkey);
} }
if (pubkey.startsWith('npub1')) return pubkey; if (pubkey.startsWith("npub1")) return pubkey;
return null; return null;
} catch { } catch {
return null; return null;
@ -432,7 +491,7 @@ export function getEventHash(event: {
event.created_at, event.created_at,
event.kind, event.kind,
event.tags, event.tags,
event.content event.content,
]); ]);
return bytesToHex(sha256(serialized)); return bytesToHex(sha256(serialized));
} }
@ -447,4 +506,4 @@ export async function signEvent(event: {
const id = getEventHash(event); const id = getEventHash(event);
const sig = await schnorr.sign(id, event.pubkey); const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig); return bytesToHex(sig);
} }

4
src/lib/utils/npubCache.ts

@ -1,4 +1,4 @@
import type { NostrProfile } from './nostrUtils'; import type { NostrProfile } from "./nostrUtils";
export type NpubMetadata = NostrProfile; export type NpubMetadata = NostrProfile;
@ -48,4 +48,4 @@ class NpubCache {
} }
} }
export const npubCache = new NpubCache(); export const npubCache = new NpubCache();

33
src/routes/+layout.svelte

@ -7,12 +7,13 @@
import { HammerSolid } from "flowbite-svelte-icons"; import { HammerSolid } from "flowbite-svelte-icons";
// Get standard metadata for OpenGraph tags // Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria'; let title = "Library of Alexandria";
let currentUrl = $page.url.href; let currentUrl = $page.url.href;
// Get default image and summary for the Alexandria website // Get default image and summary for the Alexandria website
let image = '/screenshots/old_books.jpg'; let image = "/screenshots/old_books.jpg";
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'; let summary =
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
onMount(() => { onMount(() => {
const rect = document.body.getBoundingClientRect(); const rect = document.body.getBoundingClientRect();
@ -23,24 +24,24 @@
<svelte:head> <svelte:head>
<!-- Basic meta tags --> <!-- Basic meta tags -->
<title>{title}</title> <title>{title}</title>
<meta name="description" content="{summary}" /> <meta name="description" content={summary} />
<!-- OpenGraph meta tags --> <!-- OpenGraph meta tags -->
<meta property="og:title" content="{title}" /> <meta property="og:title" content={title} />
<meta property="og:description" content="{summary}" /> <meta property="og:description" content={summary} />
<meta property="og:url" content="{currentUrl}" /> <meta property="og:url" content={currentUrl} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Alexandria" /> <meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content="{image}" /> <meta property="og:image" content={image} />
<!-- Twitter Card meta tags --> <!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content="{summary}" /> <meta name="twitter:description" content={summary} />
<meta name="twitter:image" content="{image}" /> <meta name="twitter:image" content={image} />
</svelte:head> </svelte:head>
<div class={'leather mt-[76px] h-full w-full flex flex-col items-center'}> <div class={"leather mt-[76px] h-full w-full flex flex-col items-center"}>
<Navigation class='fixed top-0' /> <Navigation class="fixed top-0" />
<slot /> <slot />
</div> </div>

26
src/routes/+layout.ts

@ -1,15 +1,21 @@
import { feedTypeStorageKey } from '$lib/consts'; import { feedTypeStorageKey } from "$lib/consts";
import { FeedType } from '$lib/consts'; import { FeedType } from "$lib/consts";
import { getPersistedLogin, initNdk, loginWithExtension, ndkInstance } from '$lib/ndk'; import {
import Pharos, { pharosInstance } from '$lib/parser'; getPersistedLogin,
import { feedType } from '$lib/stores'; initNdk,
import type { LayoutLoad } from './$types'; loginWithExtension,
ndkInstance,
} from "$lib/ndk";
import Pharos, { pharosInstance } from "$lib/parser";
import { feedType } from "$lib/stores";
import type { LayoutLoad } from "./$types";
export const ssr = false; export const ssr = false;
export const load: LayoutLoad = () => { export const load: LayoutLoad = () => {
const initialFeedType = localStorage.getItem(feedTypeStorageKey) as FeedType const initialFeedType =
?? FeedType.StandardRelays; (localStorage.getItem(feedTypeStorageKey) as FeedType) ??
FeedType.StandardRelays;
feedType.set(initialFeedType); feedType.set(initialFeedType);
const ndk = initNdk(); const ndk = initNdk();
@ -26,7 +32,9 @@ export const load: LayoutLoad = () => {
loginWithExtension(pubkey); loginWithExtension(pubkey);
} }
} catch (e) { } catch (e) {
console.warn(`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`); console.warn(
`Failed to login with extension: ${e}\n\nContinuing with anonymous session.`,
);
} }
const parser = new Pharos(ndk); const parser = new Pharos(ndk);

78
src/routes/+page.svelte

@ -1,10 +1,15 @@
<script lang='ts'> <script lang="ts">
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts'; import {
FeedType,
feedTypeStorageKey,
standardRelays,
fallbackRelays,
} from "$lib/consts";
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte"; import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from "$lib/components/PublicationFeed.svelte";
import { feedType } from '$lib/stores'; import { feedType } from "$lib/stores";
$effect(() => { $effect(() => {
localStorage.setItem(feedTypeStorageKey, $feedType); localStorage.setItem(feedTypeStorageKey, $feedType);
@ -18,31 +23,38 @@
const getFeedTypeFriendlyName = (feedType: FeedType): string => { const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) { switch (feedType) {
case FeedType.StandardRelays: case FeedType.StandardRelays:
return `Alexandria's Relays`; return `Alexandria's Relays`;
case FeedType.UserRelays: case FeedType.UserRelays:
return `Your Relays`; return `Your Relays`;
default: default:
return ''; return "";
} }
}; };
let searchQuery = $state(''); let searchQuery = $state("");
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> <Alert
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' /> rounded={false}
<span class='font-medium'> id="alert-experimental"
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable. class="border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2"
</span> >
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" />
<span class="font-medium">
Pardon our dust! The publication view is currently using an experimental
loader, and may be unstable.
</span>
</Alert> </Alert>
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class="leather flex flex-col flex-grow-0 space-y-4 p-4">
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'> <div
class="leather w-full flex flex-row items-center justify-center gap-4 mb-4"
>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm"> <Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`} {`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
{#if $ndkSignedIn} {#if $ndkSignedIn}
<ChevronDownOutline class='w-6 h-6' /> <ChevronDownOutline class="w-6 h-6" />
{/if} {/if}
</Button> </Button>
<Input <Input
@ -52,25 +64,31 @@
/> />
{#if $ndkSignedIn} {#if $ndkSignedIn}
<Dropdown <Dropdown
class='w-fit p-2 space-y-2 text-sm' class="w-fit p-2 space-y-2 text-sm"
triggeredBy="#feed-toggle-btn" triggeredBy="#feed-toggle-btn"
> >
<li> <li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> <Radio
name="relays"
bind:group={$feedType}
value={FeedType.StandardRelays}>Alexandria's Relays</Radio
>
</li> </li>
<li> <li>
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio> <Radio
name="follows"
bind:group={$feedType}
value={FeedType.UserRelays}>Your Relays</Radio
>
</li> </li>
</Dropdown> </Dropdown>
{/if} {/if}
</div> </div>
{#if !$ndkSignedIn} {#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> <PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else} {:else if $feedType === FeedType.StandardRelays}
{#if $feedType === FeedType.StandardRelays} <PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> {:else if $feedType === FeedType.UserRelays}
{:else if $feedType === FeedType.UserRelays} <PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} />
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if}
{/if} {/if}
</main> </main>

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

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

5
src/routes/about/+page.svelte

@ -46,7 +46,10 @@
</P> </P>
<P> <P>
We are easiest to contact over our Nostr address {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")}. Or, you can visit us on our <A We are easiest to contact over our Nostr address {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel",
)}. Or, you can visit us on our <A
href="https://gitcitadel.com" href="https://gitcitadel.com"
title="GitCitadel Homepage" title="GitCitadel Homepage"
target="_blank">homepage</A target="_blank">homepage</A

397
src/routes/contact/+page.svelte

@ -1,100 +1,111 @@
<script lang='ts'> <script lang="ts">
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; import {
import { ndkSignedIn, ndkInstance } from '$lib/ndk'; Heading,
import { standardRelays } from '$lib/consts'; P,
import type NDK from '@nostr-dev-kit/ndk'; A,
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; 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 // @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte'; import LoginModal from "$lib/components/LoginModal.svelte";
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser'; import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getMimeTags } from '$lib/utils/mime'; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from '$lib/snippets/UserSnippets.svelte'; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
// Function to close the success message // Function to close the success message
function closeSuccessMessage() { function closeSuccessMessage() {
submissionSuccess = false; submissionSuccess = false;
submittedEvent = null; submittedEvent = null;
} }
function clearForm() { function clearForm() {
subject = ''; subject = "";
content = ''; content = "";
submissionError = ''; submissionError = "";
isExpanded = false; isExpanded = false;
activeTab = 'write'; activeTab = "write";
} }
let subject = $state(''); let subject = $state("");
let content = $state(''); let content = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let showLoginModal = $state(false); let showLoginModal = $state(false);
let submissionSuccess = $state(false); let submissionSuccess = $state(false);
let submissionError = $state(''); let submissionError = $state("");
let submittedEvent = $state<NDKEvent | null>(null); let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state(''); let issueLink = $state("");
let successfulRelays = $state<string[]>([]); let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false); let isExpanded = $state(false);
let activeTab = $state('write'); let activeTab = $state("write");
let showConfirmDialog = $state(false); let showConfirmDialog = $state(false);
// Store form data when user needs to login // Store form data when user needs to login
let savedFormData = { let savedFormData = {
subject: '', subject: "",
content: '' content: "",
}; };
// Repository event address from the task // Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Hard-coded relays to ensure we have working relays // Hard-coded relays to ensure we have working relays
const allRelays = [ const allRelays = [
'wss://relay.damus.io', "wss://relay.damus.io",
'wss://relay.nostr.band', "wss://relay.nostr.band",
'wss://nos.lol', "wss://nos.lol",
...standardRelays ...standardRelays,
]; ];
// Hard-coded repository owner pubkey and ID from the task // Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr // These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'; const repoOwnerPubkey =
const repoId = 'Alexandria'; "fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1";
const repoId = "Alexandria";
// Function to normalize relay URLs by removing trailing slashes // Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string { function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, ''); return url.replace(/\/+$/, "");
} }
function toggleSize() { function toggleSize() {
isExpanded = !isExpanded; isExpanded = !isExpanded;
} }
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
// Prevent form submission // Prevent form submission
e.preventDefault(); e.preventDefault();
if (!subject || !content) { if (!subject || !content) {
submissionError = 'Please fill in all fields'; submissionError = "Please fill in all fields";
return; return;
} }
// Check if user is logged in // Check if user is logged in
if (!$ndkSignedIn) { if (!$ndkSignedIn) {
// Save form data // Save form data
savedFormData = { savedFormData = {
subject, subject,
content content,
}; };
// Show login modal // Show login modal
showLoginModal = true; showLoginModal = true;
return; return;
} }
// Show confirmation dialog // Show confirmation dialog
showConfirmDialog = true; showConfirmDialog = true;
} }
async function confirmSubmit() { async function confirmSubmit() {
showConfirmDialog = false; showConfirmDialog = false;
await submitIssue(); await submitIssue();
@ -103,7 +114,7 @@
function cancelSubmit() { function cancelSubmit() {
showConfirmDialog = false; showConfirmDialog = false;
} }
/** /**
* Publish event to relays with retry logic * Publish event to relays with retry logic
*/ */
@ -112,17 +123,17 @@
ndk: NDK, ndk: NDK,
relays: Set<string>, relays: Set<string>,
maxRetries: number = 3, maxRetries: number = 3,
timeout: number = 10000 timeout: number = 10000,
): Promise<string[]> { ): Promise<string[]> {
const successfulRelays: string[] = []; const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk); const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes // Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => { const publishPromises = Array.from(relays).map((relayUrl) => {
return new Promise<void>(resolve => { return new Promise<void>((resolve) => {
const relay = ndk.pool?.getRelay(relayUrl); const relay = ndk.pool?.getRelay(relayUrl);
if (relay) { if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => { relay.on("published", (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) { if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl); successfulRelays.push(relayUrl);
resolve(); resolve();
@ -140,26 +151,30 @@
// Start publishing with timeout // Start publishing with timeout
const publishPromise = event.publish(relaySet); const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout); setTimeout(() => reject(new Error("Publish timeout")), timeout);
}); });
await Promise.race([ await Promise.race([
publishPromise, publishPromise,
Promise.allSettled(publishPromises), Promise.allSettled(publishPromises),
timeoutPromise timeoutPromise,
]); ]);
if (successfulRelays.length > 0) { if (successfulRelays.length > 0) {
break; // Exit retry loop if we have successful publishes break; // Exit retry loop if we have successful publishes
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
// Wait before retrying (exponential backoff) // Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000),
);
} }
} catch (error) { } catch (error) {
if (attempt === maxRetries && successfulRelays.length === 0) { if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts'); throw new Error(
"Failed to publish to any relays after multiple attempts",
);
} }
} }
} }
@ -169,93 +184,92 @@
async function submitIssue() { async function submitIssue() {
isSubmitting = true; isSubmitting = true;
submissionError = ''; submissionError = "";
submissionSuccess = false; submissionSuccess = false;
try { try {
// Get NDK instance // Get NDK instance
const ndk = $ndkInstance; const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
throw new Error('NDK instance not available'); throw new Error("NDK instance not available");
} }
if (!ndk.signer) { if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.'); throw new Error("No signer available. Make sure you are logged in.");
} }
// Create and prepare the event // Create and prepare the event
const event = await createIssueEvent(ndk); const event = await createIssueEvent(ndk);
// Collect all unique relays // Collect all unique relays
const uniqueRelays = new Set([ const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl), ...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values()) ...(ndk.pool
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol')) ? Array.from(ndk.pool.relays.values())
.map(relay => normalizeRelayUrl(relay.url)) : []) .filter(
(relay) => relay.url && !relay.url.includes("wss://nos.lol"),
)
.map((relay) => normalizeRelayUrl(relay.url))
: []),
]); ]);
try { try {
// Publish to relays with retry logic // Publish to relays with retry logic
successfulRelays = await publishToRelays(event, ndk, uniqueRelays); successfulRelays = await publishToRelays(event, ndk, uniqueRelays);
// Store the submitted event and create issue link // Store the submitted event and create issue link
submittedEvent = event; submittedEvent = event;
// Create the issue link using the repository address // Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id); const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`; issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Clear form and show success message // Clear form and show success message
clearForm(); clearForm();
submissionSuccess = true; submissionSuccess = true;
} catch (error) { } catch (error) {
throw new Error('Failed to publish event'); throw new Error("Failed to publish event");
} }
} catch (error: any) { } catch (error: any) {
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`; submissionError = `Error submitting issue: ${error.message || "Unknown error"}`;
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
} }
/** /**
* Create and sign a new issue event * Create and sign a new issue event
*/ */
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> { async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]); event.tags.push(["subject", subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]); event.tags.push(["alt", `git repository issue: ${subject}`]);
// Add repository reference with proper format // Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`; const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([ event.tags.push(["a", aTagValue, "", "root"]);
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value // Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]); event.tags.push(["p", repoOwnerPubkey]);
// Add MIME tags // Add MIME tags
const mimeTags = getMimeTags(1621); const mimeTags = getMimeTags(1621);
event.tags.push(...mimeTags); event.tags.push(...mimeTags);
// Set content // Set content
event.content = content; event.content = content;
// Sign the event // Sign the event
try { try {
await event.sign(); await event.sign();
} catch (error) { } catch (error) {
throw new Error('Failed to sign event'); throw new Error("Failed to sign event");
} }
return event; return event;
} }
// Handle login completion // Handle login completion
$effect(() => { $effect(() => {
if ($ndkSignedIn && showLoginModal) { if ($ndkSignedIn && showLoginModal) {
@ -269,54 +283,89 @@
submitIssue(); submitIssue();
} }
}); });
</script> </script>
<div class='w-full flex justify-center'> <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'> <main
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading> 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"> <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>. 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>
<P class="mb-3"> <P class="mb-3">
You can contact us on Nostr {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")} or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A> You can contact us on Nostr {@render userBadge(
"npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz",
"GitCitadel",
)} or you can view submitted issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank">Alexandria repo page.</A
>
</P> </P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading> <Heading tag="h2" class="h-leather mt-4 mb-2">Submit an issue</Heading>
<P class="mb-3"> <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. 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> </P>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off"> <form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div> <div>
<Label for="subject" class="mb-2">Subject</Label> <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 /> <Input
id="subject"
class="w-full bg-white dark:bg-gray-800"
placeholder="Issue subject"
bind:value={subject}
required
autofocus
/>
</div> </div>
<div class="relative"> <div class="relative">
<Label for="content" class="mb-2">Description</Label> <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="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="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"> <div
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> 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"> <li class="mr-2" role="presentation">
<button <button
type="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'}" class="inline-block p-4 rounded-t-lg {activeTab === 'write'
onclick={() => activeTab = 'write'} ? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "write")}
role="tab" role="tab"
> >
Write Write
</button> </button>
</li> </li>
<li role="presentation"> <li role="presentation">
<button <button
type="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'}" class="inline-block p-4 rounded-t-lg {activeTab ===
onclick={() => activeTab = 'preview'} 'preview'
? 'border-b-2 border-primary-600 text-primary-600'
: 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => (activeTab = "preview")}
role="tab" role="tab"
> >
Preview Preview
@ -324,9 +373,9 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="flex-1 min-h-0 relative"> <div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'} {#if activeTab === "write"}
<div class="absolute inset-0 overflow-hidden"> <div class="absolute inset-0 overflow-hidden">
<Textarea <Textarea
id="content" id="content"
@ -369,18 +418,23 @@ Code blocks with syntax highlighting for over 100 languages
Footnotes[^1] and [^1]: footnote content Footnotes[^1] and [^1]: footnote content
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix." Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix."
/> />
</div> </div>
{:else} {:else}
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"> <div
class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"
>
{#key content} {#key content}
{#await parseAdvancedmarkup(content)} {#await parseAdvancedmarkup(content)}
<p>Loading preview...</p> <p>Loading preview...</p>
{:then html} {:then html}
{@html html || '<p class="text-gray-700 dark:text-gray-300">Nothing to preview</p>'} {@html html ||
'<p class="text-gray-700 dark:text-gray-300">Nothing to preview</p>'}
{:catch error} {:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p> <p class="text-red-500">
Error rendering preview: {error.message}
</p>
{/await} {/await}
{/key} {/key}
</div> </div>
@ -394,7 +448,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
color="light" color="light"
on:click={toggleSize} on:click={toggleSize}
> >
{isExpanded ? '⌃' : '⌄'} {isExpanded ? "⌃" : "⌄"}
</Button> </Button>
</div> </div>
</div> </div>
@ -411,31 +465,61 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{/if} {/if}
</Button> </Button>
</div> </div>
{#if submissionSuccess && submittedEvent} {#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"> <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 --> <!-- Close button -->
<button <button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100" class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeSuccessMessage} onclick={closeSuccessMessage}
aria-label="Close" 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"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 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> </svg>
</button> </button>
<div class="flex items-center mb-3"> <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"> <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> 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> </svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span> <span class="font-medium text-success-800 dark:text-success-200"
>Issue submitted successfully!</span
>
</div> </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-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
>
<div class="mb-2"> <div class="mb-2">
<span class="font-semibold">Subject:</span> <span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span> <span
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
"No subject"}</span
>
</div> </div>
<div> <div>
<span class="font-semibold">Description:</span> <span class="font-semibold">Description:</span>
@ -445,21 +529,27 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{:then html} {:then html}
{@html html} {@html html}
{:catch error} {:catch error}
<p class="text-red-500">Error rendering markup: {error.message}</p> <p class="text-red-500">
Error rendering markup: {error.message}
</p>
{/await} {/await}
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<span class="font-semibold">View your issue:</span> <span class="font-semibold">View your issue:</span>
<div class="mt-1"> <div class="mt-1">
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all"> <A
href={issueLink}
target="_blank"
class="hover:underline text-primary-600 dark:text-primary-500 break-all"
>
{issueLink} {issueLink}
</A> </A>
</div> </div>
</div> </div>
<!-- Display successful relays --> <!-- Display successful relays -->
<div class="text-sm"> <div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span> <span class="font-semibold">Successfully published to relays:</span>
@ -471,49 +561,42 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div> </div>
</div> </div>
{/if} {/if}
{#if submissionError} {#if submissionError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> <div
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg"
role="alert"
>
{submissionError} {submissionError}
</div> </div>
{/if} {/if}
</form> </form>
</main>
</main>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->
<Modal <Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full">
bind:open={showConfirmDialog}
size="sm"
autoclose={false}
class="w-full"
>
<div class="text-center"> <div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300"> <h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300">
Would you like to submit the issue? Would you like to submit the issue?
</h3> </h3>
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}> <Button color="alternative" on:click={cancelSubmit}>Cancel</Button>
Cancel <Button color="primary" on:click={confirmSubmit}>Submit</Button>
</Button>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>
<!-- Login Modal --> <!-- Login Modal -->
<LoginModal <LoginModal
show={showLoginModal} show={showLoginModal}
onClose={() => showLoginModal = false} onClose={() => (showLoginModal = false)}
onLoginSuccess={() => { onLoginSuccess={() => {
// Restore saved form data // Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject; if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content; if (savedFormData.content) content = savedFormData.content;
// Submit the issue // Submit the issue
submitIssue(); submitIssue();
}} }}
/> />

98
src/routes/events/+page.svelte

@ -2,13 +2,13 @@
import { Heading, P } from "flowbite-svelte"; import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from '$lib/components/EventSearch.svelte'; import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from '$lib/components/EventDetails.svelte'; import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from '$lib/components/RelayActions.svelte'; import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from '$lib/components/CommentBox.svelte'; import CommentBox from "$lib/components/CommentBox.svelte";
import { userBadge } from '$lib/snippets/UserSnippets.svelte'; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from '$lib/utils/nostrUtils'; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -50,23 +50,23 @@
} }
function getSummary(event: NDKEvent): string | undefined { function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, 'summary')[0]?.[1]; return getMatchingTags(event, "summary")[0]?.[1];
} }
function getDeferrelNaddr(event: NDKEvent): string | undefined { function getDeferrelNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferrel' tag, e.g. ['deferrel', 'naddr1...'] // Look for a 'deferrel' tag, e.g. ['deferrel', 'naddr1...']
return getMatchingTags(event, 'deferrel')[0]?.[1]; return getMatchingTags(event, "deferrel")[0]?.[1];
} }
$effect(() => { $effect(() => {
const id = $page.url.searchParams.get('id'); const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get('d'); const dTag = $page.url.searchParams.get("d");
if (id !== searchValue) { if (id !== searchValue) {
searchValue = id; searchValue = id;
dTagValue = null; dTagValue = null;
} }
if (dTag !== dTagValue) { if (dTag !== dTagValue) {
// Normalize d-tag to lowercase for consistent searching // Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null; dTagValue = dTag ? dTag.toLowerCase() : null;
@ -76,8 +76,8 @@
onMount(async () => { onMount(async () => {
// Get user's pubkey and relay preference from localStorage // Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey'); userPubkey = localStorage.getItem("userPubkey");
userRelayPreference = localStorage.getItem('useUserRelays') === 'true'; userRelayPreference = localStorage.getItem("useUserRelays") === "true";
}); });
</script> </script>
@ -88,27 +88,28 @@
</div> </div>
<P class="mb-3"> <P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID). Use this page to view any event (npub, nprofile, nevent, naddr, note,
You can also search for events by d-tag using the format "d:tag-name". pubkey, or eventID). You can also search for events by d-tag using the
format "d:tag-name".
</P> </P>
<EventSearch <EventSearch
{loading} {loading}
{error} {error}
{searchValue} {searchValue}
{dTagValue} {dTagValue}
{event} {event}
onEventFound={handleEventFound} onEventFound={handleEventFound}
onSearchResults={handleSearchResults} onSearchResults={handleSearchResults}
/> />
{#if event} {#if event}
<EventDetails {event} {profile} {searchValue} /> <EventDetails {event} {profile} {searchValue} />
<RelayActions {event} /> <RelayActions {event} />
{#if userPubkey} {#if userPubkey}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading> <Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} /> <CommentBox {event} {userPubkey} {userRelayPreference} />
</div> </div>
{:else} {:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg"> <div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
@ -120,37 +121,54 @@
{#if searchResults.length > 0} {#if searchResults.length > 0}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4">
Search Results for d-tag: "{dTagValue?.toLowerCase()}" ({searchResults.length} events) Search Results for d-tag: "{dTagValue?.toLowerCase()}" ({searchResults.length}
events)
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4">
{#each searchResults as result, index} {#each searchResults as result, index}
<button <button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden" class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)} onclick={() => handleEventFound(result)}
> >
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100">Event {index + 1}</span> <span class="font-medium text-gray-800 dark:text-gray-100"
<span class="text-xs text-gray-600 dark:text-gray-400">Kind: {result.kind}</span> >Event {index + 1}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span
>
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(toNpub(result.pubkey) as string, undefined)} {@render userBadge(
toNpub(result.pubkey) as string,
undefined,
)}
</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto"> <span
{result.created_at ? new Date(result.created_at * 1000).toLocaleDateString() : 'Unknown date'} class="text-xs text-gray-500 dark:text-gray-400 ml-auto"
>
{result.created_at
? new Date(result.created_at * 1000).toLocaleDateString()
: "Unknown date"}
</span> </span>
</div> </div>
{#if getSummary(result)} {#if getSummary(result)}
<div class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"> <div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
>
{getSummary(result)} {getSummary(result)}
</div> </div>
{/if} {/if}
{#if getDeferrelNaddr(result)} {#if getDeferrelNaddr(result)}
<div class="text-xs text-primary-800 dark:text-primary-300 mb-1"> <div
Read class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<a <a
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all"
href={'/publications?d=' + encodeURIComponent((dTagValue || '').toLowerCase())} href={"/publications?d=" +
onclick={e => e.stopPropagation()} encodeURIComponent((dTagValue || "").toLowerCase())}
onclick={(e) => e.stopPropagation()}
tabindex="0" tabindex="0"
> >
{getDeferrelNaddr(result)} {getDeferrelNaddr(result)}
@ -158,8 +176,12 @@
</div> </div>
{/if} {/if}
{#if result.content} {#if result.content}
<div class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"> <div
{result.content.slice(0, 200)}{result.content.length > 200 ? '...' : ''} class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"
>
{result.content.slice(0, 200)}{result.content.length > 200
? "..."
: ""}
</div> </div>
{/if} {/if}
</div> </div>

15
src/routes/new/compose/+page.svelte

@ -1,4 +1,4 @@
<script lang='ts'> <script lang="ts">
import Preview from "$lib/components/Preview.svelte"; import Preview from "$lib/components/Preview.svelte";
import { pharosInstance } from "$lib/parser"; import { pharosInstance } from "$lib/parser";
import { Heading } from "flowbite-svelte"; import { Heading } from "flowbite-svelte";
@ -14,11 +14,16 @@
} }
</script> </script>
<div class='w-full flex justify-center'> <div class="w-full flex justify-center">
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> <main class="main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4">
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading> <Heading tag="h1" class="h-leather mb-2">Compose</Heading>
{#key treeUpdateCount} {#key treeUpdateCount}
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} /> <Preview
rootId={$pharosInstance.getRootIndexId()}
allowEditing={true}
bind:needsUpdate={treeNeedsUpdate}
index={someIndexValue}
/>
{/key} {/key}
</main> </main>
</div> </div>

62
src/routes/new/edit/+page.svelte

@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import { Heading, Textarea, Toolbar, ToolbarButton, Tooltip } from "flowbite-svelte"; import {
import { CodeOutline, EyeSolid, PaperPlaneOutline } from "flowbite-svelte-icons"; Heading,
Textarea,
Toolbar,
ToolbarButton,
Tooltip,
} from "flowbite-svelte";
import {
CodeOutline,
EyeSolid,
PaperPlaneOutline,
} from "flowbite-svelte-icons";
import Preview from "$lib/components/Preview.svelte"; import Preview from "$lib/components/Preview.svelte";
import Pharos, { pharosInstance } from "$lib/parser"; import Pharos, { pharosInstance } from "$lib/parser";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
@ -44,44 +54,52 @@
} }
$pharosInstance.generate($ndkInstance.activeUser?.pubkey!); $pharosInstance.generate($ndkInstance.activeUser?.pubkey!);
goto('/new/compose'); goto("/new/compose");
} };
</script> </script>
<div class='w-full flex justify-center'> <div class="w-full flex justify-center">
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> <main class="main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4">
<Heading tag='h1' class='h-leather mb-2'>Edit</Heading> <Heading tag="h1" class="h-leather mb-2">Edit</Heading>
{#if isEditing} {#if isEditing}
<form> <form>
<Textarea <Textarea
id='article-content' id="article-content"
class='textarea-leather' class="textarea-leather"
rows={8} rows={8}
placeholder='Write AsciiDoc content' placeholder="Write AsciiDoc content"
bind:value={editorText} bind:value={editorText}
> >
<Toolbar slot='header' embedded> <Toolbar slot="header" embedded>
<ToolbarButton name='Preview' on:click={showPreview}> <ToolbarButton name="Preview" on:click={showPreview}>
<EyeSolid class='w-6 h-6' /> <EyeSolid class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
</Textarea> </Textarea>
</form> </form>
{:else} {:else}
<form class='border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit'> <form
<Toolbar class='toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800'> class="border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit"
<ToolbarButton name='Edit' on:click={hidePreview}> >
<CodeOutline class='w-6 h-6' /> <Toolbar
class="toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800"
>
<ToolbarButton name="Edit" on:click={hidePreview}>
<CodeOutline class="w-6 h-6" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton name='Review' slot='end' on:click={prepareReview}> <ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' /> <PaperPlaneOutline class="w=6 h-6 rotate-90" />
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview sectionClass='m-2' rootId={rootIndexId} index={someIndexValue} /> <Preview
sectionClass="m-2"
rootId={rootIndexId}
index={someIndexValue}
/>
{/if} {/if}
</form> </form>
{/if} {/if}

40
src/routes/publication/+error.svelte

@ -1,29 +1,37 @@
<script lang='ts'> <script lang="ts">
import { invalidateAll, goto } from '$app/navigation'; import { invalidateAll, goto } from "$app/navigation";
import { Alert, P, Button } from 'flowbite-svelte'; import { Alert, P, Button } from "flowbite-svelte";
import { ExclamationCircleOutline } from 'flowbite-svelte-icons'; import { ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from '$app/state'; import { page } from "$app/state";
</script> </script>
<main> <main>
<Alert> <Alert>
<div class='flex items-center space-x-2'> <div class="flex items-center space-x-2">
<ExclamationCircleOutline class='w-6 h-6' /> <ExclamationCircleOutline class="w-6 h-6" />
<span class='text-lg font-medium'> <span class="text-lg font-medium"> Failed to load publication. </span>
Failed to load publication.
</span>
</div> </div>
<P size='sm'> <P size="sm">
Alexandria failed to find one or more of the events comprising this publication. Alexandria failed to find one or more of the events comprising this
publication.
</P> </P>
<P size='xs'> <P size="xs">
{page.error?.message} {page.error?.message}
</P> </P>
<div class='flex space-x-2'> <div class="flex space-x-2">
<Button class='btn-leather !w-fit' size='sm' onclick={() => invalidateAll()}> <Button
class="btn-leather !w-fit"
size="sm"
onclick={() => invalidateAll()}
>
Try Again Try Again
</Button> </Button>
<Button class='btn-leather !w-fit' size='sm' outline onclick={() => goto('/')}> <Button
class="btn-leather !w-fit"
size="sm"
outline
onclick={() => goto("/")}
>
Return home Return home
</Button> </Button>
</div> </div>

70
src/routes/publication/+page.ts

@ -1,28 +1,28 @@
import { error } from '@sveltejs/kit'; import { error } from "@sveltejs/kit";
import type { Load } from '@sveltejs/kit'; import type { Load } from "@sveltejs/kit";
import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getActiveRelays } from '$lib/ndk'; import { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from "$lib/utils/nostrUtils";
/** /**
* Decodes an naddr identifier and returns a filter object * Decodes an naddr identifier and returns a filter object
*/ */
function decodeNaddr(id: string) { function decodeNaddr(id: string) {
try { try {
if (!id.startsWith('naddr')) return {}; if (!id.startsWith("naddr")) return {};
const decoded = nip19.decode(id); const decoded = nip19.decode(id);
if (decoded.type !== 'naddr') return {}; if (decoded.type !== "naddr") return {};
const data = decoded.data; const data = decoded.data;
return { return {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [data.pubkey],
'#d': [data.identifier] "#d": [data.identifier],
}; };
} catch (e) { } catch (e) {
console.error('Failed to decode naddr:', e); console.error("Failed to decode naddr:", e);
return null; return null;
} }
} }
@ -32,7 +32,7 @@ function decodeNaddr(id: string) {
*/ */
async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
const filter = decodeNaddr(id); const filter = decodeNaddr(id);
// Handle the case where filter is null (decoding error) // Handle the case where filter is null (decoding error)
if (filter === null) { if (filter === null) {
// If we can't decode the naddr, try using the raw ID // If we can't decode the naddr, try using the raw ID
@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
throw error(404, `Failed to fetch publication root event.\n${err}`); throw error(404, `Failed to fetch publication root event.\n${err}`);
} }
} }
const hasFilter = Object.keys(filter).length > 0; const hasFilter = Object.keys(filter).length > 0;
try { try {
const event = await (hasFilter ? const event = await (hasFilter
ndk.fetchEvent(filter) : ? ndk.fetchEvent(filter)
ndk.fetchEvent(id)); : ndk.fetchEvent(id));
if (!event) { if (!event) {
throw new Error(`Event not found for ID: ${id}`); throw new Error(`Event not found for ID: ${id}`);
} }
@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try { try {
const event = await ndk.fetchEvent( const event = await ndk.fetchEvent(
{ '#d': [dTag] }, { "#d": [dTag] },
{ closeOnEose: false }, { closeOnEose: false },
getActiveRelays(ndk) getActiveRelays(ndk),
); );
if (!event) { if (!event) {
throw new Error(`Event not found for d tag: ${dTag}`); throw new Error(`Event not found for d tag: ${dTag}`);
} }
@ -83,21 +83,27 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
} }
} }
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => { export const load: Load = async ({
const id = url.searchParams.get('id'); url,
const dTag = url.searchParams.get('d'); parent,
}: {
url: URL;
parent: () => Promise<any>;
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk, parser } = await parent(); const { ndk, parser } = await parent();
if (!id && !dTag) { if (!id && !dTag) {
throw error(400, 'No publication root event ID or d tag provided.'); throw error(400, "No publication root event ID or d tag provided.");
} }
// Fetch the event based on available parameters // Fetch the event based on available parameters
const indexEvent = id const indexEvent = id
? await fetchEventById(ndk, id) ? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!); : await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1]; const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
const fetchPromise = parser.fetch(indexEvent); const fetchPromise = parser.fetch(indexEvent);
return { return {

7
src/routes/start/+page.svelte

@ -54,10 +54,9 @@
Each content section (30041 or 30818) is also a level in the table of 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 contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. reading view. This allows for navigation within the publication.
Publications of type "blog" have a ToC which emphasizes that each entry Publications of type "blog" have a ToC which emphasizes that each entry is
is a blog post. a blog post. (This functionality has been temporarily disabled, but the
TOC is visible.)
(This functionality has been temporarily disabled, but the TOC is visible.)
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="flex flex-col items-center space-y-4 my-4">

23
src/routes/visualize/+page.svelte

@ -11,12 +11,12 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { filterValidIndexEvents } from "$lib/utils"; import { filterValidIndexEvents } from "$lib/utils";
import { networkFetchLimit } from "$lib/state"; import { networkFetchLimit } from "$lib/state";
// Configuration // Configuration
const DEBUG = false; // Set to true to enable debug logging const DEBUG = false; // Set to true to enable debug logging
const INDEX_EVENT_KIND = 30040; const INDEX_EVENT_KIND = 30040;
const CONTENT_EVENT_KINDS = [30041, 30818]; const CONTENT_EVENT_KINDS = [30041, 30818];
/** /**
* Debug logging function that only logs when DEBUG is true * Debug logging function that only logs when DEBUG is true
*/ */
@ -34,7 +34,7 @@
/** /**
* Fetches events from the Nostr network * Fetches events from the Nostr network
* *
* This function fetches index events and their referenced content events, * This function fetches index events and their referenced content events,
* filters them according to NIP-62, and combines them for visualization. * filters them according to NIP-62, and combines them for visualization.
*/ */
@ -47,9 +47,9 @@
// Step 1: Fetch index events // Step 1: Fetch index events
debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`); debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`);
const indexEvents = await $ndkInstance.fetchEvents( const indexEvents = await $ndkInstance.fetchEvents(
{ {
kinds: [INDEX_EVENT_KIND], kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit limit: $networkFetchLimit,
}, },
{ {
groupable: true, groupable: true,
@ -68,7 +68,7 @@
validIndexEvents.forEach((event) => { validIndexEvents.forEach((event) => {
const aTags = event.getMatchingTags("a"); const aTags = event.getMatchingTags("a");
debug(`Event ${event.id} has ${aTags.length} a-tags`); debug(`Event ${event.id} has ${aTags.length} a-tags`);
aTags.forEach((tag) => { aTags.forEach((tag) => {
const eventId = tag[3]; const eventId = tag[3];
if (eventId) { if (eventId) {
@ -79,7 +79,9 @@
debug("Content event IDs to fetch:", contentEventIds.size); debug("Content event IDs to fetch:", contentEventIds.size);
// Step 4: Fetch the referenced content events // Step 4: Fetch the referenced content events
debug(`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(', ')})`); debug(
`Fetching content events (kinds ${CONTENT_EVENT_KINDS.join(", ")})`,
);
const contentEvents = await $ndkInstance.fetchEvents( const contentEvents = await $ndkInstance.fetchEvents(
{ {
kinds: CONTENT_EVENT_KINDS, kinds: CONTENT_EVENT_KINDS,
@ -104,7 +106,6 @@
} }
} }
// Fetch events when component mounts // Fetch events when component mounts
onMount(() => { onMount(() => {
debug("Component mounted"); debug("Component mounted");
@ -140,7 +141,7 @@
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
<!-- Error message --> <!-- Error message -->
{:else if error} {:else if error}
<div <div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
@ -156,7 +157,7 @@
Retry Retry
</button> </button>
</div> </div>
<!-- Network visualization --> <!-- Network visualization -->
{:else} {:else}
<!-- Event network visualization --> <!-- Event network visualization -->
<EventNetwork {events} onupdate={fetchEvents} /> <EventNetwork {events} onupdate={fetchEvents} />

8
src/styles/base.css

@ -3,7 +3,7 @@
@tailwind utilities; @tailwind utilities;
@layer components { @layer components {
body { body {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000;
} }
} }

8
src/styles/events.css

@ -1,5 +1,5 @@
@layer components { @layer components {
canvas.qr-code { canvas.qr-code {
@apply block mx-auto my-4; @apply block mx-auto my-4;
} }
} }

574
src/styles/publications.css

@ -1,288 +1,288 @@
@layer components { @layer components {
/* AsciiDoc content */ /* AsciiDoc content */
.publication-leather p a { .publication-leather p a {
@apply underline hover:text-primary-600 dark:hover:text-primary-400; @apply underline hover:text-primary-600 dark:hover:text-primary-400;
} }
.publication-leather section p { .publication-leather section p {
@apply w-full; @apply w-full;
} }
.publication-leather section p table { .publication-leather section p table {
@apply w-full table-fixed space-x-2 space-y-2; @apply w-full table-fixed space-x-2 space-y-2;
} }
.publication-leather section p table td { .publication-leather section p table td {
@apply p-2; @apply p-2;
} }
.publication-leather section p table td .content:has(> .imageblock) { .publication-leather section p table td .content:has(> .imageblock) {
@apply flex flex-col items-center; @apply flex flex-col items-center;
} }
.publication-leather .imageblock { .publication-leather .imageblock {
@apply flex flex-col space-y-2; @apply flex flex-col space-y-2;
} }
.publication-leather .imageblock .content { .publication-leather .imageblock .content {
@apply flex justify-center; @apply flex justify-center;
} }
.publication-leather .imageblock .title { .publication-leather .imageblock .title {
@apply text-center; @apply text-center;
} }
.publication-leather .imageblock.left .content { .publication-leather .imageblock.left .content {
@apply justify-start; @apply justify-start;
} }
.publication-leather .imageblock.left .title { .publication-leather .imageblock.left .title {
@apply text-left; @apply text-left;
} }
.publication-leather .imageblock.right .content { .publication-leather .imageblock.right .content {
@apply justify-end; @apply justify-end;
} }
.publication-leather .imageblock.right .title { .publication-leather .imageblock.right .title {
@apply text-right; @apply text-right;
} }
.publication-leather section p table td .literalblock { .publication-leather section p table td .literalblock {
@apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600; @apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600;
} }
.publication-leather .literalblock pre { .publication-leather .literalblock pre {
@apply p-3 text-wrap break-words; @apply p-3 text-wrap break-words;
} }
.publication-leather .listingblock pre { .publication-leather .listingblock pre {
@apply overflow-x-auto; @apply overflow-x-auto;
} }
/* lists */ /* lists */
.publication-leather .ulist ul { .publication-leather .ulist ul {
@apply space-y-1 list-disc list-inside; @apply space-y-1 list-disc list-inside;
} }
.publication-leather .olist ol { .publication-leather .olist ol {
@apply space-y-1 list-inside; @apply space-y-1 list-inside;
} }
.publication-leather ol.arabic { .publication-leather ol.arabic {
@apply list-decimal; @apply list-decimal;
} }
.publication-leather ol.loweralpha { .publication-leather ol.loweralpha {
@apply list-lower-alpha; @apply list-lower-alpha;
} }
.publication-leather ol.upperalpha { .publication-leather ol.upperalpha {
@apply list-upper-alpha; @apply list-upper-alpha;
} }
.publication-leather li ol, .publication-leather li ol,
.publication-leather li ul { .publication-leather li ul {
@apply ps-5 my-2; @apply ps-5 my-2;
} }
.audioblock .title, .audioblock .title,
.imageblock .title, .imageblock .title,
.literalblock .title, .literalblock .title,
.tableblock .title, .tableblock .title,
.videoblock .title, .videoblock .title,
.olist .title, .olist .title,
.ulist .title { .ulist .title {
@apply my-2 font-thin text-lg; @apply my-2 font-thin text-lg;
} }
.publication-leather li p { .publication-leather li p {
@apply inline; @apply inline;
} }
/* blockquote; prose and poetry quotes */ /* blockquote; prose and poetry quotes */
.publication-leather .quoteblock, .publication-leather .quoteblock,
.publication-leather .verseblock { .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; @apply p-4 my-4 border-s-4 rounded border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-700;
} }
.publication-leather .verseblock pre.content { .publication-leather .verseblock pre.content {
@apply text-base font-sans overflow-x-scroll py-1; @apply text-base font-sans overflow-x-scroll py-1;
} }
.publication-leather .attribution { .publication-leather .attribution {
@apply mt-3 italic clear-both; @apply mt-3 italic clear-both;
} }
.publication-leather cite { .publication-leather cite {
@apply text-sm; @apply text-sm;
} }
.leading-normal.first-letter\:text-7xl .quoteblock { .leading-normal.first-letter\:text-7xl .quoteblock {
min-height: 108px; min-height: 108px;
} }
/* admonition */ /* admonition */
.publication-leather .admonitionblock .title { .publication-leather .admonitionblock .title {
@apply font-semibold; @apply font-semibold;
} }
.publication-leather .admonitionblock table { .publication-leather .admonitionblock table {
@apply w-full border-collapse; @apply w-full border-collapse;
} }
.publication-leather .admonitionblock tr { .publication-leather .admonitionblock tr {
@apply flex flex-col border-none; @apply flex flex-col border-none;
} }
.publication-leather .admonitionblock td { .publication-leather .admonitionblock td {
@apply border-none; @apply border-none;
} }
.publication-leather .admonitionblock p:has(code) { .publication-leather .admonitionblock p:has(code) {
@apply my-3; @apply my-3;
} }
.publication-leather .admonitionblock { .publication-leather .admonitionblock {
@apply rounded overflow-hidden border; @apply rounded overflow-hidden border;
} }
.publication-leather .admonitionblock .icon, .publication-leather .admonitionblock .icon,
.publication-leather .admonitionblock .content { .publication-leather .admonitionblock .content {
@apply p-4; @apply p-4;
} }
.publication-leather .admonitionblock .content { .publication-leather .admonitionblock .content {
@apply pt-0; @apply pt-0;
} }
.publication-leather .admonitionblock.tip { .publication-leather .admonitionblock.tip {
@apply rounded overflow-hidden border border-success-100 dark:border-success-800; @apply rounded overflow-hidden border border-success-100 dark:border-success-800;
} }
.publication-leather .admonitionblock.tip .icon, .publication-leather .admonitionblock.tip .icon,
.publication-leather .admonitionblock.tip .content { .publication-leather .admonitionblock.tip .content {
@apply bg-success-100 dark:bg-success-800; @apply bg-success-100 dark:bg-success-800;
} }
.publication-leather .admonitionblock.note { .publication-leather .admonitionblock.note {
@apply rounded overflow-hidden border border-info-100 dark:border-info-700; @apply rounded overflow-hidden border border-info-100 dark:border-info-700;
} }
.publication-leather .admonitionblock.note .icon, .publication-leather .admonitionblock.note .icon,
.publication-leather .admonitionblock.note .content { .publication-leather .admonitionblock.note .content {
@apply bg-info-100 dark:bg-info-800; @apply bg-info-100 dark:bg-info-800;
} }
.publication-leather .admonitionblock.important { .publication-leather .admonitionblock.important {
@apply rounded overflow-hidden border border-primary-200 dark:border-primary-700; @apply rounded overflow-hidden border border-primary-200 dark:border-primary-700;
} }
.publication-leather .admonitionblock.important .icon, .publication-leather .admonitionblock.important .icon,
.publication-leather .admonitionblock.important .content { .publication-leather .admonitionblock.important .content {
@apply bg-primary-200 dark:bg-primary-700; @apply bg-primary-200 dark:bg-primary-700;
} }
.publication-leather .admonitionblock.caution { .publication-leather .admonitionblock.caution {
@apply rounded overflow-hidden border border-warning-200 dark:border-warning-700; @apply rounded overflow-hidden border border-warning-200 dark:border-warning-700;
} }
.publication-leather .admonitionblock.caution .icon, .publication-leather .admonitionblock.caution .icon,
.publication-leather .admonitionblock.caution .content { .publication-leather .admonitionblock.caution .content {
@apply bg-warning-200 dark:bg-warning-700; @apply bg-warning-200 dark:bg-warning-700;
} }
.publication-leather .admonitionblock.warning { .publication-leather .admonitionblock.warning {
@apply rounded overflow-hidden border border-danger-200 dark:border-danger-800; @apply rounded overflow-hidden border border-danger-200 dark:border-danger-800;
} }
.publication-leather .admonitionblock.warning .icon, .publication-leather .admonitionblock.warning .icon,
.publication-leather .admonitionblock.warning .content { .publication-leather .admonitionblock.warning .content {
@apply bg-danger-200 dark:bg-danger-800; @apply bg-danger-200 dark:bg-danger-800;
} }
/* listingblock, literalblock */ /* listingblock, literalblock */
.publication-leather .listingblock, .publication-leather .listingblock,
.publication-leather .literalblock { .publication-leather .literalblock {
@apply p-4 rounded bg-highlight dark:bg-primary-700; @apply p-4 rounded bg-highlight dark:bg-primary-700;
} }
.publication-leather .sidebarblock .title, .publication-leather .sidebarblock .title,
.publication-leather .listingblock .title, .publication-leather .listingblock .title,
.publication-leather .literalblock .title { .publication-leather .literalblock .title {
@apply font-semibold mb-1; @apply font-semibold mb-1;
} }
/* sidebar */ /* sidebar */
.publication-leather .sidebarblock { .publication-leather .sidebarblock {
@apply p-4 rounded bg-info-100 dark:bg-info-800; @apply p-4 rounded bg-info-100 dark:bg-info-800;
} }
/* video */ /* video */
.videoblock .content { .videoblock .content {
@apply w-full aspect-video; @apply w-full aspect-video;
} }
.videoblock .content iframe, .videoblock .content iframe,
.videoblock .content video { .videoblock .content video {
@apply w-full h-full; @apply w-full h-full;
} }
/* audio */ /* audio */
.audioblock .content { .audioblock .content {
@apply my-3; @apply my-3;
} }
.audioblock .content audio { .audioblock .content audio {
@apply w-full; @apply w-full;
} }
.coverImage { .coverImage {
@apply max-h-[230px] overflow-hidden; @apply max-h-[230px] overflow-hidden;
} }
.coverImage.depth-0 { .coverImage.depth-0 {
@apply max-h-[460px] overflow-hidden; @apply max-h-[460px] overflow-hidden;
} }
.coverImage img { .coverImage img {
@apply object-contain w-full; @apply object-contain w-full;
} }
.coverImage.depth-0 img { .coverImage.depth-0 img {
@apply m-auto w-auto; @apply m-auto w-auto;
} }
/** blog */ /** blog */
@screen lg { @screen lg {
@media (hover: hover) { @media (hover: hover) {
.blog .discreet .card-leather:not(:hover) { .blog .discreet .card-leather:not(:hover) {
@apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition duration-500 ease-in-out ; @apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition duration-500 ease-in-out;
} }
.blog .discreet .group { .blog .discreet .group {
@apply bg-transparent; @apply bg-transparent;
} }
} }
} }
/* Discrete headers */ /* Discrete headers */
h3.discrete, h3.discrete,
h4.discrete, h4.discrete,
h5.discrete, h5.discrete,
h6.discrete { h6.discrete {
@apply text-gray-800 dark:text-gray-300; @apply text-gray-800 dark:text-gray-300;
} }
h3.discrete { h3.discrete {
@apply text-2xl font-bold; @apply text-2xl font-bold;
} }
h4.discrete { h4.discrete {
@apply text-xl font-bold; @apply text-xl font-bold;
} }
h5.discrete { h5.discrete {
@apply text-lg font-semibold; @apply text-lg font-semibold;
} }
h6.discrete { h6.discrete {
@apply text-base font-semibold; @apply text-base font-semibold;
} }
} }

32
src/styles/scrollbar.css

@ -1,20 +1,20 @@
@layer components { @layer components {
/* Global scrollbar styles */ /* Global scrollbar styles */
* { * {
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */ scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */
} }
/* Webkit Browsers (Chrome, Safari, Edge) */ /* Webkit Browsers (Chrome, Safari, Edge) */
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: 12px; /* Thin scrollbar */ width: 12px; /* Thin scrollbar */
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; /* Fully transparent track */ background: transparent; /* Fully transparent track */
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
@apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;; @apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;
border-radius: 6px; /* Rounded scrollbar */ border-radius: 6px; /* Rounded scrollbar */
} }
} }

204
src/styles/visualize.css

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

10
src/types/d3.d.ts vendored

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

2
src/types/global.d.ts vendored

@ -2,4 +2,4 @@ interface Window {
hljs?: { hljs?: {
highlightAll: () => void; highlightAll: () => void;
}; };
} }

4
src/types/plantuml-encoder.d.ts vendored

@ -1,5 +1,5 @@
declare module 'plantuml-encoder' { declare module "plantuml-encoder" {
export function encode(text: string): string; export function encode(text: string): string;
const _default: { encode: typeof encode }; const _default: { encode: typeof encode };
export default _default; export default _default;
} }

142
tailwind.config.cjs

@ -12,110 +12,110 @@ const config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
highlight: '#f9f6f1', highlight: "#f9f6f1",
primary: { primary: {
0: '#efe6dc', 0: "#efe6dc",
50: '#decdb9', 50: "#decdb9",
100: '#d6c1a8', 100: "#d6c1a8",
200: '#c6a885', 200: "#c6a885",
300: '#b58f62', 300: "#b58f62",
400: '#ad8351', 400: "#ad8351",
500: '#c6a885', 500: "#c6a885",
600: '#795c39', 600: "#795c39",
700: '#564a3e', 700: "#564a3e",
800: '#3c352c', 800: "#3c352c",
900: '#2a241c', 900: "#2a241c",
950: '#1d1812', 950: "#1d1812",
1000: '#15110d', 1000: "#15110d",
}, },
success: { success: {
50: '#e3f2e7', 50: "#e3f2e7",
100: '#c7e6cf', 100: "#c7e6cf",
200: '#a2d4ae', 200: "#a2d4ae",
300: '#7dbf8e', 300: "#7dbf8e",
400: '#5ea571', 400: "#5ea571",
500: '#4e8e5f', 500: "#4e8e5f",
600: '#3e744c', 600: "#3e744c",
700: '#305b3b', 700: "#305b3b",
800: '#22412a', 800: "#22412a",
900: '#15281b', 900: "#15281b",
}, },
info: { info: {
50: '#e7eff6', 50: "#e7eff6",
100: '#c5d9ea', 100: "#c5d9ea",
200: '#9fbfdb', 200: "#9fbfdb",
300: '#7aa5cc', 300: "#7aa5cc",
400: '#5e90be', 400: "#5e90be",
500: '#4779a5', 500: "#4779a5",
600: '#365d80', 600: "#365d80",
700: '#27445d', 700: "#27445d",
800: '#192b3a', 800: "#192b3a",
900: '#0d161f', 900: "#0d161f",
}, },
warning: { warning: {
50: '#fef4e6', 50: "#fef4e6",
100: '#fde4bf', 100: "#fde4bf",
200: '#fcd18e', 200: "#fcd18e",
300: '#fbbc5c', 300: "#fbbc5c",
400: '#f9aa33', 400: "#f9aa33",
500: '#f7971b', 500: "#f7971b",
600: '#c97a14', 600: "#c97a14",
700: '#9a5c0e', 700: "#9a5c0e",
800: '#6c3e08', 800: "#6c3e08",
900: '#3e2404', 900: "#3e2404",
}, },
danger: { danger: {
50: '#fbeaea', 50: "#fbeaea",
100: '#f5cccc', 100: "#f5cccc",
200: '#eba5a5', 200: "#eba5a5",
300: '#e17e7e', 300: "#e17e7e",
400: '#d96060', 400: "#d96060",
500: '#c94848', 500: "#c94848",
600: '#a53939', 600: "#a53939",
700: '#7c2b2b', 700: "#7c2b2b",
800: '#521c1c', 800: "#521c1c",
900: '#2b0e0e', 900: "#2b0e0e",
}, },
}, },
listStyleType: { listStyleType: {
'upper-alpha': 'upper-alpha', // Uppercase letters "upper-alpha": "upper-alpha", // Uppercase letters
'lower-alpha': 'lower-alpha', // Lowercase letters "lower-alpha": "lower-alpha", // Lowercase letters
}, },
flexGrow: { flexGrow: {
'1': '1', 1: "1",
'2': '2', 2: "2",
'3': '3', 3: "3",
}, },
hueRotate: { hueRotate: {
20: '20deg', 20: "20deg",
} },
}, },
}, },
plugins: [ plugins: [
flowbite(), flowbite(),
plugin(function({ addUtilities, matchUtilities }) { plugin(function ({ addUtilities, matchUtilities }) {
addUtilities({ addUtilities({
'.content-visibility-auto': { ".content-visibility-auto": {
'content-visibility': 'auto', "content-visibility": "auto",
}, },
'.contain-size': { ".contain-size": {
contain: 'size', contain: "size",
}, },
}); });
matchUtilities({ matchUtilities({
'contain-intrinsic-w-*': value => ({ "contain-intrinsic-w-*": (value) => ({
width: value, width: value,
}), }),
'contain-intrinsic-h-*': value => ({ "contain-intrinsic-h-*": (value) => ({
height: value, height: value,
}) }),
}); });
}) }),
], ],
darkMode: 'class', darkMode: "class",
}; };
module.exports = config; module.exports = config;

50
test_data/latex_markdown.md

File diff suppressed because one or more lines are too long

16
tests/e2e/example.pw.spec.ts

@ -1,18 +1,20 @@
import { test, expect } from '@playwright/test'; import { test, expect } from "@playwright/test";
test('has title', async ({ page }) => { test("has title", async ({ page }) => {
await page.goto('https://playwright.dev/'); await page.goto("https://playwright.dev/");
// Expect a title "to contain" a substring. // Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/); await expect(page).toHaveTitle(/Playwright/);
}); });
test('get started link', async ({ page }) => { test("get started link", async ({ page }) => {
await page.goto('https://playwright.dev/'); await page.goto("https://playwright.dev/");
// Click the get started link. // Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click(); await page.getByRole("link", { name: "Get started" }).click();
// Expects page to have a heading with the name of Installation. // Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); await expect(
page.getByRole("heading", { name: "Installation" }),
).toBeVisible();
}); });

114
tests/integration/markupIntegration.test.ts

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

123
tests/integration/markupTestfile.md

@ -1,15 +1,19 @@
This is a test # This is a test
============
### Disclaimer ### 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] 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 # H1
## H2 ## H2
### H3 ### H3
#### H4 #### H4
##### H5 ##### H5
###### H6 ###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser. This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser.
@ -24,51 +28,49 @@ npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That
> lines of > lines of
> important information > important information
> with a second[^2] footnote. > with a second[^2] footnote.
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984. > [^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984.
This is a youtube link This is a youtube link
https://www.youtube.com/watch?v=9aqVxNCpx9s https://www.youtube.com/watch?v=9aqVxNCpx9s
And here is a link with tracking tokens: 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 https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU
This is an unordered list: This is an unordered list:
* but
* not - but
* really - not
- really
This is an unordered list with nesting: This is an unordered list with nesting:
* but
* not - but
* really - not
* but - really
* yes, - but
* really - yes,
- really
## More testing ## More testing
An ordered list: An ordered list:
1. first 1. first
2. second 2. second
3. third 3. third
Let's nest that: Let's nest that:
1. first
2. second indented 1. first 2. second indented
3. third 2. third 4. fourth indented 5. fifth indented even more 6. sixth under the fourth 7. seventh under the sixth
4. fourth indented 3. eighth under the third
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: This is ordered and unordered mixed:
1. first
2. second indented 1. first 2. second indented
3. third 2. third
* make this a bullet point - make this a bullet point 4. fourth indented even more
4. fourth indented even more - second bullet point
* second bullet point
Here is a horizontal rule: Here is a horizontal rule:
@ -130,13 +132,31 @@ in a code block
You can even use a multi-line code block, with a json tag. You can even use a multi-line code block, with a json tag.
```json ````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" "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++: C or C++:
```cpp ```cpp
bool getBit(int num, int i) { bool getBit(int num, int i) {
return ((num & (1<<i)) != 0); return ((num & (1<<i)) != 0);
@ -144,20 +164,22 @@ bool getBit(int num, int i) {
``` ```
Asciidoc: Asciidoc:
```adoc ```adoc
= Header 1 = Header 1
preamble goes here preamble goes here
== Header 2 == Header 2
some more text some more text
``` ```
Gherkin: Gherkin:
```gherkin ```gherkin
Feature: Account Holder withdraws cash Feature: Account Holder withdraws cash
Scenario: Account has sufficient funds Scenario: Account has sufficient funds
Given The account balance is $100 Given The account balance is $100
And the card is valid And the card is valid
@ -169,6 +191,7 @@ Scenario: Account has sufficient funds
``` ```
Go: Go:
```go ```go
package main package main
@ -190,17 +213,16 @@ package main
or even markup: or even markup:
```md ```md
A H1 Header # A H1 Header
============
Paragraphs are separated by a blank line. Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists 2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists
look like: look like:
* this one[^some reference text] - this one[^some reference text]
* that one - that one
* the other one - the other one
Note that --- not considering the asterisk --- the actual text Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in. content starts at 4-columns in.
@ -222,17 +244,17 @@ Test out some emojis :heart: and :trophy:
A neat table[^some reference text]: A neat table[^some reference text]:
| Syntax | Description | | Syntax | Description |
| ----------- | ----------- | | --------- | ----------- |
| Header | Title | | Header | Title |
| Paragraph | Text | | Paragraph | Text |
A messy table (should render the same as above): A messy table (should render the same as above):
| Syntax | Description | | Syntax | Description |
| --- | ----------- | | --------- | ----------- |
| Header | Title | | Header | Title |
| Paragraph | Text | | Paragraph | Text |
Here is a table without a header row: Here is a table without a header row:
@ -240,5 +262,6 @@ Here is a table without a header row:
| need a | header | | need a | header |
| just | pipes | | just | pipes |
[^1]: this is a footnote [^1]:
[^some reference text]: this is a footnote that isn't a number this is a footnote
[^some reference text]: this is a footnote that isn't a number

147
tests/unit/advancedMarkupParser.test.ts

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

102
tests/unit/basicMarkupParser.test.ts

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

101
tests/unit/latexRendering.test.ts

@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser";
import { readFileSync } from "fs";
import { join } from "path";
describe("LaTeX Math Rendering", () => {
const mdPath = join(__dirname, "../../test_data/latex_markdown.md");
const raw = readFileSync(mdPath, "utf-8");
// Extract the markdown content field from the JSON
const content = JSON.parse(raw).content;
it('renders inline math as <span class="math-inline">', async () => {
const html = await parseAdvancedmarkup(content);
expect(html).toMatch(/<span class="math-inline">\$P \\neq NP\$<\/span>/);
expect(html).toMatch(
/<span class="math-inline">\$x_1 = \\text\{True\}\$<\/span>/,
);
});
it('renders display math as <div class="math-block', async () => {
const html = await parseAdvancedmarkup(content);
// Representative display math
expect(html).toMatch(
/<div class="math-block my-4 text-center">\$\$\s*P_j = \\bigotimes/,
);
expect(html).toMatch(
/<div class="math-block my-4 text-center">\$\$[\s\S]*?\\begin\{pmatrix\}/,
);
expect(html).toMatch(
/<div class="math-block my-4 text-center">\$\$\\boxed\{P \\neq NP\}\$\$<\/div>/,
);
});
it("does not wrap display math in <p> or <blockquote>", async () => {
const html = await parseAdvancedmarkup(content);
// No <p> or <blockquote> directly wrapping math-block
expect(html).not.toMatch(/<p[^>]*>\s*<div class="math-block/);
expect(html).not.toMatch(/<blockquote[^>]*>\s*<div class="math-block/);
});
it("renders LaTeX environments (pmatrix) within display math blocks", async () => {
const html = await parseAdvancedmarkup(content);
// Check that pmatrix is properly rendered within a display math block
expect(html).toMatch(
/<div class="math-block my-4 text-center">\$\$[\s\S]*?\\begin\{pmatrix\}[\s\S]*?\\end\{pmatrix\}[\s\S]*?\$\$<\/div>/,
);
});
it('renders all math as math (no unwrapped $...$, $$...$$, \\(...\\), \\[...\\], or environments left)', async () => {
const html = await parseAdvancedmarkup(content);
// No unwrapped $...$ outside math-inline or math-block
// Remove all math-inline and math-block tags and check for stray $...$
const htmlNoMath = html
.replace(/<span class="math-inline">\$[^$]+\$<\/span>/g, '')
.replace(/<div class="math-block[^"]*">\$\$[\s\S]*?\$\$<\/div>/g, '')
.replace(/<div class="math-block[^"]*">[\s\S]*?<\/div>/g, '');
expect(htmlNoMath).not.toMatch(/\$[^\$\n]+\$/); // inline math
expect(htmlNoMath).not.toMatch(/\$\$[\s\S]*?\$\$/); // display math
expect(htmlNoMath).not.toMatch(/\\\([^)]+\\\)/); // \(...\)
expect(htmlNoMath).not.toMatch(/\\\[[^\]]+\\\]/); // \[...\]
expect(htmlNoMath).not.toMatch(/\\begin\{[a-zA-Z*]+\}[\s\S]*?\\end\{[a-zA-Z*]+\}/); // environments
// No math inside code or pre
expect(html).not.toMatch(/<code[\s\S]*?\$[\s\S]*?\$[\s\S]*?<\/code>/);
expect(html).not.toMatch(/<pre[\s\S]*?\$[\s\S]*?\$[\s\S]*?<\/pre>/);
});
it('renders every line of the document: all math is wrapped', async () => {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const html = await parseAdvancedmarkup(line);
// If the line contains $...$, $$...$$, \(...\), \[...\], or bare LaTeX commands, it should be wrapped
const hasMath = /\$[^$]+\$|\$\$[\s\S]*?\$\$|\\\([^)]+\\\)|\\\[[^\]]+\\\]|\\[a-zA-Z]+(\{[^}]*\})*/.test(line);
if (hasMath) {
const wrapped = /math-inline|math-block/.test(html);
if (!wrapped) {
// eslint-disable-next-line no-console
console.error(`Line ${i + 1} failed:`, line);
// eslint-disable-next-line no-console
console.error('Rendered HTML:', html);
}
expect(wrapped).toBe(true);
}
// Should not have any unwrapped $...$, $$...$$, \(...\), \[...\], or bare LaTeX commands
const stray = /(^|[^>])\$[^$\n]+\$|\$\$[\s\S]*?\$\$|\\\([^)]+\\\)|\\\[[^\]]+\\\]|\\[a-zA-Z]+(\{[^}]*\})*/.test(html);
expect(stray).toBe(false);
}
});
it('renders standalone math lines as display math blocks', async () => {
const mdPath = require('path').join(__dirname, '../../test_data/latex_markdown.md');
const raw = require('fs').readFileSync(mdPath, 'utf-8');
const content = JSON.parse(raw).content || raw;
const html = await parseAdvancedmarkup(content);
// Example: Bures distance line
expect(html).toMatch(/<div class="math-block my-4 text-center">\$\$d_B\([^$]+\) = [^$]+\$\$<\/div>/);
// Example: P(\rho) = ...
expect(html).toMatch(/<div class="math-block my-4 text-center">\$\$P\([^$]+\) = [^$]+\$\$<\/div>/);
});
});

28
vite.config.ts

@ -5,16 +5,20 @@ import { execSync } from "child_process";
// Function to get the latest git tag // Function to get the latest git tag
function getAppVersionString() { function getAppVersionString() {
// if running in ci context, we can assume the package has been properly versioned // if running in ci context, we can assume the package has been properly versioned
if (process.env.ALEXANDIRA_IS_CI_BUILD && process.env.npm_package_version && process.env.npm_package_version.trim() !== '') { if (
process.env.ALEXANDIRA_IS_CI_BUILD &&
process.env.npm_package_version &&
process.env.npm_package_version.trim() !== ""
) {
return process.env.npm_package_version; return process.env.npm_package_version;
} }
try { try {
// Get the latest git tag, assuming git is installed and tagged branch is available // Get the latest git tag, assuming git is installed and tagged branch is available
const tag = execSync('git describe --tags --abbrev=0').toString().trim(); const tag = execSync("git describe --tags --abbrev=0").toString().trim();
return tag; return tag;
} catch (error) { } catch (error) {
return 'development'; return "development";
} }
} }
@ -22,20 +26,20 @@ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
resolve: { resolve: {
alias: { alias: {
$lib: './src/lib', $lib: "./src/lib",
$components: './src/components' $components: "./src/components",
} },
}, },
build: { build: {
rollupOptions: { rollupOptions: {
external: ['bech32'] external: ["bech32"],
} },
}, },
test: { test: {
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts'] include: ["./tests/unit/**/*.test.ts", "./tests/integration/**/*.test.ts"],
}, },
define: { define: {
// Expose the app version as a global variable // Expose the app version as a global variable
'import.meta.env.APP_VERSION': JSON.stringify(getAppVersionString()) "import.meta.env.APP_VERSION": JSON.stringify(getAppVersionString()),
} },
}); });

Loading…
Cancel
Save