Browse Source

rudimentary LaTeX implementation for Markup

master
Silberengel 8 months ago
parent
commit
c37a3f3a73
  1. 16
      README.md
  2. 2
      docker-compose.yaml
  3. 28
      playwright.config.ts
  4. 5
      postcss.config.js
  5. 14
      src/app.css
  6. 23
      src/app.html
  7. 166
      src/lib/components/CommentBox.svelte
  8. 159
      src/lib/components/EventDetails.svelte
  9. 8
      src/lib/components/EventRenderLevelLimit.svelte
  10. 173
      src/lib/components/EventSearch.svelte
  11. 42
      src/lib/components/Login.svelte
  12. 54
      src/lib/components/LoginModal.svelte
  13. 180
      src/lib/components/Preview.svelte
  14. 2
      src/lib/components/Publication.svelte
  15. 146
      src/lib/components/PublicationFeed.svelte
  16. 65
      src/lib/components/PublicationHeader.svelte
  17. 101
      src/lib/components/PublicationSection.svelte
  18. 97
      src/lib/components/RelayActions.svelte
  19. 48
      src/lib/components/RelayDisplay.svelte
  20. 89
      src/lib/components/RelayStatus.svelte
  21. 12
      src/lib/components/Toc.svelte
  22. 61
      src/lib/components/cards/BlogHeader.svelte
  23. 89
      src/lib/components/cards/ProfileHeader.svelte
  24. 120
      src/lib/components/util/ArticleNav.svelte
  25. 145
      src/lib/components/util/CardActions.svelte
  26. 30
      src/lib/components/util/CopyToClipboard.svelte
  27. 84
      src/lib/components/util/Details.svelte
  28. 70
      src/lib/components/util/Interactions.svelte
  29. 58
      src/lib/components/util/Profile.svelte
  30. 4
      src/lib/components/util/QrCode.svelte
  31. 23
      src/lib/components/util/TocToggle.svelte
  32. 2
      src/lib/components/util/ZapOutline.svelte
  33. 48
      src/lib/consts.ts
  34. 77
      src/lib/data_structures/publication_tree.ts
  35. 26
      src/lib/navigator/EventNetwork/Legend.svelte
  36. 44
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  37. 18
      src/lib/navigator/EventNetwork/Settings.svelte
  38. 154
      src/lib/navigator/EventNetwork/index.svelte
  39. 49
      src/lib/navigator/EventNetwork/utils/forceSimulation.ts
  40. 26
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  41. 243
      src/lib/ndk.ts
  42. 396
      src/lib/parser.ts
  43. 14
      src/lib/snippets/PublicationSnippets.svelte
  44. 10
      src/lib/snippets/UserSnippets.svelte
  45. 5
      src/lib/stores.ts
  46. 2
      src/lib/stores/relayStore.ts
  47. 8
      src/lib/types.ts
  48. 26
      src/lib/utils.ts
  49. 1
      src/lib/utils/markup/MarkupInfo.md
  50. 95
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  51. 317
      src/lib/utils/markup/advancedMarkupParser.ts
  52. 77
      src/lib/utils/markup/asciidoctorExtensions.ts
  53. 57
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  54. 205
      src/lib/utils/markup/basicMarkupParser.ts
  55. 4
      src/lib/utils/markup/tikzRenderer.ts
  56. 15
      src/lib/utils/mime.ts
  57. 221
      src/lib/utils/nostrUtils.ts
  58. 2
      src/lib/utils/npubCache.ts
  59. 27
      src/routes/+layout.svelte
  60. 26
      src/routes/+layout.ts
  61. 64
      src/routes/+page.svelte
  62. 22
      src/routes/[...catchall]/+page.svelte
  63. 5
      src/routes/about/+page.svelte
  64. 291
      src/routes/contact/+page.svelte
  65. 78
      src/routes/events/+page.svelte
  66. 15
      src/routes/new/compose/+page.svelte
  67. 62
      src/routes/new/edit/+page.svelte
  68. 40
      src/routes/publication/+error.svelte
  69. 46
      src/routes/publication/+page.ts
  70. 7
      src/routes/start/+page.svelte
  71. 7
      src/routes/visualize/+page.svelte
  72. 2
      src/styles/scrollbar.css
  73. 2
      src/styles/visualize.css
  74. 8
      src/types/d3.d.ts
  75. 2
      src/types/plantuml-encoder.d.ts
  76. 140
      tailwind.config.cjs
  77. 50
      test_data/latex_markdown.md
  78. 16
      tests/e2e/example.pw.spec.ts
  79. 112
      tests/integration/markupIntegration.test.ts
  80. 101
      tests/integration/markupTestfile.md
  81. 145
      tests/unit/advancedMarkupParser.test.ts
  82. 100
      tests/unit/basicMarkupParser.test.ts
  83. 101
      tests/unit/latexRendering.test.ts
  84. 26
      vite.config.ts

16
README.md

@ -18,21 +18,25 @@ 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
``` ```
@ -42,21 +46,25 @@ deno task dev
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
``` ```
@ -95,25 +105,29 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
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
``` ```

2
docker-compose.yaml

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

28
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(),
]
}; };

14
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;
} }
@ -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;

23
src/app.html

@ -9,21 +9,30 @@
<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%

166
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
.filter((t: string[]) => t[0] === "p")
.forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) { if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', 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>
@ -273,7 +304,7 @@
{#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>

159
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,17 +263,23 @@
{/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>

8
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"

173
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);
@ -48,18 +58,22 @@
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) {
@ -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>

42
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'>
<Button
onclick={handleSignInClick}
> >
Extension Sign-In <div class="w-full flex flex-col space-y-2">
</Button> <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}

54
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"
>
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">
Login Required
</h3>
<button <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}

180
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.
@ -21,7 +36,7 @@
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,8 +103,16 @@
$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];
@ -102,23 +127,26 @@
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,7 +154,7 @@
}).format(date); }).format(date);
return formattedDate ?? ""; return formattedDate ?? "";
} }
return ''; return "";
} }
function readBlog(rootId: string) { function readBlog(rootId: string) {
@ -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
event={getBlogEvent(index)}
{rootId}
onBlogUpdate={readBlog}
active={true}
/>
{:else} {: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;

146
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,16 +41,21 @@
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],
}, },
@ -43,29 +64,30 @@
skipVerification: false, skipVerification: false,
skipValidation: false, skipValidation: false,
}, },
relaySet relaySet,
).withTimeout(5000); )
.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
outline
class="w-full max-w-md"
onclick={async () => {
await loadMorePublications(); 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>

65
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,17 +56,21 @@
</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
class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<Img src={image} class="rounded w-full h-full object-cover" /> <Img src={image} class="rounded w-full h-full object-cover" />
</div> </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)}
@ -65,13 +78,17 @@
{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>

101
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,15 +69,20 @@
return event; return event;
}); });
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(
async () => {
if (!previousLeafEvent) { if (!previousLeafEvent) {
return null; 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][] = [];
@ -80,13 +93,17 @@
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++;
} }
@ -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>

97
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,41 +80,40 @@
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>
@ -117,7 +125,7 @@
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}

48
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,41 +18,57 @@
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>

89
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">

12
src/lib/components/Toc.svelte

@ -1,10 +1,10 @@
<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>
@ -12,7 +12,11 @@
<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>
<a href="#{nip19.noteEncode(note.id)}"
>{note.getMatchingTags("title")[0][1]}</a
>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>

61
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,25 +49,30 @@
</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
class="ArticleBoxImage flex col justify-center"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }} 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">
@ -64,7 +83,7 @@
{/if} {/if}
</div> </div>
{#if active} {#if active}
<Interactions rootId={rootId} event={event} /> <Interactions {rootId} {event} />
{/if} {/if}
</div> </div>
</Card> </Card>

89
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,12 +26,12 @@
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");
} }
} }
}); });
@ -31,17 +39,37 @@
{#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
src={profile.banner}
class="rounded w-full max-h-72 object-cover"
alt="Profile banner"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div> </div>
{/if} {/if}
<div class='flex flex-row space-x-4 items-center'> <div class="flex flex-row space-x-4 items-center">
{#if profile.picture} {#if profile.picture}
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> <img
src={profile.picture}
alt="Profile avatar"
class="w-16 h-16 rounded-full border"
onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png";
}}
/>
{/if} {/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.display_name || profile.name || event.pubkey)} {@render userBadge(
toNpub(event.pubkey) as string,
profile.displayName ||
profile.display_name ||
profile.name ||
event.pubkey,
)}
</div> </div>
<div> <div>
<div class="mt-2 flex flex-col gap-4"> <div class="mt-2 flex flex-col gap-4">
@ -68,14 +96,25 @@
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt> <dt class="font-semibold min-w-[120px]">Website:</dt>
<dd> <dd>
<a href={profile.website} class="underline text-primary-700 dark:text-primary-200">{profile.website}</a> <a
href={profile.website}
class="underline text-primary-700 dark:text-primary-200"
>{profile.website}</a
>
</dd> </dd>
</div> </div>
{/if} {/if}
{#if profile.lud16} {#if profile.lud16}
<div class="flex items-center gap-2 mt-4"> <div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> <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> <dd>
<Button
class="btn-leather"
color="primary"
outline
onclick={() => (lnModalOpen = true)}>{profile.lud16}</Button
>
</dd>
</div> </div>
{/if} {/if}
{#if profile.nip05} {#if profile.nip05}
@ -87,7 +126,13 @@
{#each identifiers as id} {#each identifiers as id}
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> <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> <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> </div>
{/each} {/each}
</dl> </dl>
@ -96,18 +141,28 @@
</div> </div>
</Card> </Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'> <Modal
class="modal-leather"
title="Lightning Address"
bind:open={lnModalOpen}
outsideclose
size="sm"
>
{#if profile.lud16} {#if profile.lud16}
<div> <div>
<div class='flex flex-col items-center'> <div class="flex flex-col items-center">
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)} {@render userBadge(
toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey,
)}
<P>{profile.lud16}</P> <P>{profile.lud16}</P>
</div> </div>
<div class="flex flex-col items-center mt-3 space-y-4"> <div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P> <P>Scan the QR code or copy the address</P>
{#if lnurl} {#if lnurl}
<P style="overflow-wrap: anywhere"> <P style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard> <CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard>
</P> </P>
<QrCode value={lnurl} /> <QrCode value={lnurl} />
{:else} {:else}

120
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,51 +104,91 @@
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>

145
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);
@ -50,11 +72,11 @@
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;
} }
@ -94,7 +119,7 @@
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,46 +130,57 @@
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
type="button"
id="dots-{event.id}" id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" color="none" class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
data-popover-target="popover-actions"> color="none"
data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" /> <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
id="popover-actions"
placement="bottom" placement="bottom"
trigger="click" trigger="click"
class='popover-leather w-fit z-10' class="popover-leather w-fit z-10"
onmouseleave={closePopover} onmouseleave={closePopover}
> >
<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 text-nowrap'> <div class="flex flex-col text-nowrap">
<ul class="space-y-2"> <ul class="space-y-2">
<li> <li>
<button class='btn-leather w-full text-left' onclick={viewDetails}> <button
class="btn-leather w-full text-left"
onclick={viewDetails}
>
<EyeOutline class="inline mr-2" /> View details <EyeOutline class="inline mr-2" /> View details
</button> </button>
</li> </li>
<li> <li>
<CopyToClipboard <CopyToClipboard
displayText="Copy naddr address" displayText="Copy naddr address"
copyText={getIdentifier('naddr')} copyText={getIdentifier("naddr")}
icon={ShareNodesOutline} icon={ShareNodesOutline}
/> />
</li> </li>
<li> <li>
<CopyToClipboard <CopyToClipboard
displayText="Copy nevent address" displayText="Copy nevent address"
copyText={getIdentifier('nevent')} copyText={getIdentifier("nevent")}
icon={ClipboardCleanOutline} icon={ClipboardCleanOutline}
/> />
</li> </li>
@ -154,41 +190,68 @@
</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>

30
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(() => { )
.then(() => {
copied = false; copied = false;
}).catch(() => { })
.catch(() => {
// If timeout occurs, still reset the state // If timeout occurs, still reset the state
copied = false; 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}

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

@ -3,43 +3,64 @@
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">
@ -52,8 +73,12 @@
{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}

70
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);
} }
}); });
@ -59,11 +64,11 @@
}); });
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
class="modal-leather"
title="Interaction"
bind:open={interactionOpen}
autoclose
outsideclose
size="sm"
>
<P>Can't like, zap or highlight yet.</P> <P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P> <P>You should totally check out the discussion though.</P>
</Modal> </Modal>

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

@ -1,11 +1,15 @@
<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 {
ArrowRightToBracketOutline,
UserOutline,
FileSearchOutline,
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; 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();
@ -16,13 +20,11 @@ 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;
}); });
}); });
@ -33,8 +35,8 @@ async function handleSignOutClick() {
} }
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>
@ -43,7 +45,7 @@ function shortenNpub(long: string|undefined) {
<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"
@ -52,32 +54,42 @@ function shortenNpub(long: string|undefined) {
<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> <li>
<CopyToClipboard displayText={shortenNpub(npub)} copyText={npub} /> <CopyToClipboard
displayText={shortenNpub(npub)}
copyText={npub}
/>
</li> </li>
<li> <li>
<a class='hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0' href='{externalProfileDestination}{npub}'> <a
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span> 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> </a>
</li> </li>
{#if isNav} {#if isNav}
<li> <li>
<button <button
id='sign-out-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' class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500"
onclick={handleSignOutClick} onclick={handleSignOutClick}
> >
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out <ArrowRightToBracketOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/> Sign out
</button> </button>
</li> </li>
{:else} {:else}

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;

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

@ -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}
/> />

2
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

48
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";

77
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,7 +168,9 @@ 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)!];
@ -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();
} }
@ -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) {
@ -288,7 +298,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) {
@ -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
@ -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,11 +482,15 @@ 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);
} }
@ -485,18 +506,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/ */
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,7 +531,9 @@ 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),
@ -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;
} }

26
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}
@ -45,7 +51,9 @@
<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 -->
@ -55,7 +63,9 @@
<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 -->

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

@ -7,10 +7,16 @@
<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,
selected = false,
x,
y,
onclose,
} = $props<{
node: NetworkNode; // The node to display information for node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked) selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip x: number; // X position for the tooltip
@ -68,7 +74,10 @@
/** /**
* 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) + "...";
@ -117,13 +126,18 @@
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"
fill="currentColor"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> <path
<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" /> 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>
@ -131,10 +145,7 @@
<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>
@ -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}
@ -165,9 +177,7 @@
<!-- 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>

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

@ -2,7 +2,7 @@
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";
@ -10,10 +10,10 @@
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);
@ -31,7 +31,13 @@
<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}

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

@ -16,13 +16,13 @@
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;
@ -45,7 +45,10 @@
} }
// 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);
@ -69,7 +72,9 @@
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;
@ -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();
@ -172,7 +176,7 @@
// 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));
@ -181,7 +185,7 @@
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) {
@ -212,13 +216,14 @@
.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) =>
enter
.append("path") .append("path")
.attr("class", "link network-link-leather") .attr("class", "link network-link-leather")
.attr("stroke-width", 2) .attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"), .attr("marker-end", "url(#arrowhead)"),
(update: any) => update, (update: any) => update,
(exit: any) => exit.remove() (exit: any) => exit.remove(),
); );
// Update nodes // Update nodes
@ -260,23 +265,27 @@
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")
.attr("class", (d: NetworkNode) =>
!d.isContainer
? "visual-circle network-node-leather network-node-content" ? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather" : "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");
@ -322,9 +331,14 @@
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) {
@ -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),
); );
} }
} }
@ -440,7 +461,7 @@
debug("Effect triggered", { debug("Effect triggered", {
hasSvg: !!svg, hasSvg: !!svg,
eventCount: events?.length, eventCount: events?.length,
currentLevels currentLevels,
}); });
try { try {
@ -472,9 +493,12 @@
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)
.transition()
.duration(750)
.call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8) d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8),
); );
} }
} }
@ -484,9 +508,7 @@
*/ */
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
);
} }
} }
@ -495,9 +517,7 @@
*/ */
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
);
} }
} }
@ -520,7 +540,10 @@
<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
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" 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
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" 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
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2" 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>

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

@ -66,14 +66,14 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> {
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") {
@ -129,8 +129,8 @@ export function applyConnectedGravity(
) { ) {
// 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;
@ -163,11 +163,16 @@ export function applyConnectedGravity(
*/ */
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(
"start",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Warm up simulation if it's cooled down // Warm up simulation if it's cooled down
if (!event.active) { if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart(); simulation.alphaTarget(warmupClickEnergy).restart();
@ -175,13 +180,25 @@ export function setupDragHandlers(
// Fix node position at current location // Fix node position at current location
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
}) },
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"drag",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update fixed position to mouse position // Update fixed position to mouse position
d.fx = event.x; d.fx = event.x;
d.fy = event.y; d.fy = event.y;
}) },
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => { )
.on(
"end",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends // Cool down simulation when drag ends
if (!event.active) { if (!event.active) {
simulation.alphaTarget(0); simulation.alphaTarget(0);
@ -189,7 +206,8 @@ export function setupDragHandlers(
// Release fixed position // Release fixed position
d.fx = null; d.fx = null;
d.fy = null; d.fy = null;
}); },
);
} }
/** /**
@ -205,13 +223,13 @@ 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 {
@ -220,9 +238,10 @@ export function createSimulation(
.forceSimulation(nodes) .forceSimulation(nodes)
.force( .force(
"link", "link",
d3.forceLink(links) d3
.forceLink(links)
.id((d: NetworkNode) => d.id) .id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1) .distance(linkDistance * 0.1),
) )
.force("collide", d3.forceCollide().radius(nodeRadius * 4)); .force("collide", d3.forceCollide().radius(nodeRadius * 4));

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

@ -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
@ -37,9 +37,13 @@ function debug(...args: any[]) {
*/ */
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,
kind: event.kind,
level,
});
const isContainer = event.kind === INDEX_EVENT_KIND; const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content"; const nodeType = isContainer ? "Index" : "Content";
@ -162,7 +166,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
const aTags = getMatchingTags(event, "a"); const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", { debug("Processing a-tags for event", {
eventId: event.id, eventId: event.id,
aTagCount: aTags.length aTagCount: aTags.length,
}); });
aTags.forEach((tag) => { aTags.forEach((tag) => {
@ -298,10 +302,7 @@ export function processIndexEvent(
* @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[],
maxLevel: number
): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel }); debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state // Initialize the graph state
@ -309,19 +310,20 @@ export function generateGraph(
// Find root index events (those not referenced by other events) // Find root index events (those not referenced by other events)
const rootIndices = events.filter( const rootIndices = events.filter(
(e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) (e) =>
e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id),
); );
debug("Found root indices", { debug("Found root indices", {
rootCount: rootIndices.length, rootCount: rootIndices.length,
rootIds: rootIndices.map(e => e.id) rootIds: rootIndices.map((e) => e.id),
}); });
// Process each root index // Process each root index
rootIndices.forEach((rootIndex) => { rootIndices.forEach((rootIndex) => {
debug("Processing root index", { debug("Processing root index", {
rootId: rootIndex.id, rootId: rootIndex.id,
aTags: getMatchingTags(rootIndex, "a").length aTags: getMatchingTags(rootIndex, "a").length,
}); });
processIndexEvent(rootIndex, 0, state, maxLevel); processIndexEvent(rootIndex, 0, state, maxLevel);
}); });
@ -334,7 +336,7 @@ export function generateGraph(
debug("Graph generation complete", { debug("Graph generation complete", {
nodeCount: result.nodes.length, nodeCount: result.nodes.length,
linkCount: result.links.length linkCount: result.links.length,
}); });
return result; return result;

243
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,7 +44,9 @@ 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;
} }
@ -39,42 +54,53 @@ class CustomRelayAuthPolicy {
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,11 +109,11 @@ 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
@ -97,21 +123,28 @@ class CustomRelayAuthPolicy {
// 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] - Is localhost:", isLocalhost);
console.debug('[NDK.ts] - Protocol:', window.location.protocol); console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug('[NDK.ts] - Is HTTP:', isHttp); console.debug("[NDK.ts] - Is HTTP:", isHttp);
console.debug('[NDK.ts] - Is HTTPS:', isHttps); 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;
@ -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,10 +377,12 @@ 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;
@ -346,12 +397,16 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
// 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);
}); });
@ -367,12 +422,14 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
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,16 +440,19 @@ 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, _] =
startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) ? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null]; : [null, null];
// Ensure all relay URLs use secure WebSocket protocol // Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (startingInboxes != null const secureRelayUrls = (
startingInboxes != null
? Array.from(startingInboxes.values()) ? Array.from(startingInboxes.values())
: anonymousRelays).map(ensureSecureWebSocket); : anonymousRelays
).map(ensureSecureWebSocket);
console.debug('[NDK.ts] Initializing NDK with relay URLs:', secureRelayUrls); console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls);
const ndk = new NDK({ const ndk = new NDK({
autoConnectUserRelays: true, autoConnectUserRelays: 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,7 +538,7 @@ 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(
{ {
@ -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:

396
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,12 +28,12 @@ interface IndexMetadata {
export enum SiblingSearchDirection { export enum SiblingSearchDirection {
Previous, Previous,
Next Next,
} }
export enum InsertLocation { export enum InsertLocation {
Before, Before,
After After,
} }
/** /**
@ -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);
@ -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();
@ -326,7 +334,7 @@ export default class Pharos {
} }
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];
@ -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 {
@ -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
@ -705,11 +747,13 @@ export default class Pharos {
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;
} }
@ -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,7 +869,10 @@ 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!,
]);
} }
} }
@ -858,8 +902,8 @@ export default class Pharos {
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();
@ -902,172 +946,172 @@ 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;
} }
@ -1082,18 +1126,19 @@ 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;
@ -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 }),
}; };
} }

2
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";

26
src/lib/utils.ts

@ -12,9 +12,9 @@ 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({
@ -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;

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

@ -123,6 +123,7 @@ 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.

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

@ -1,5 +1,5 @@
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:
@ -8,7 +8,9 @@ import plantumlEncoder from 'plantuml-encoder';
* - 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;
} }

317
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,
(_, text, level) => {
const headingLevel = level[0] === "=" ? 1 : 2;
const classes = headingClasses[headingLevel - 1]; const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
}); },
);
return processedContent; return processedContent;
} }
@ -53,24 +66,25 @@ 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[] = [];
@ -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,15 +161,19 @@ 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(
FOOTNOTE_REFERENCE_REGEX,
(match, id) => {
if (!footnotes.has(id)) { if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`); console.warn(
`Footnote reference [^${id}] found but no definition exists`,
);
return match; return match;
} }
const refNum = globalRefNum++; const refNum = globalRefNum++;
@ -162,32 +181,37 @@ function processFootnotes(content: string): string {
referenceMap.get(id)!.push(refNum); referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id }); 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>`; 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;
} }
} }
@ -202,9 +226,9 @@ function processBlockquotes(content: string): string {
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,13 +238,16 @@ 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;
@ -239,11 +266,11 @@ 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) {
@ -251,24 +278,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
id,
JSON.stringify({
code: formattedCode, code: formattedCode,
language: currentLanguage, language: currentLanguage,
raw: true 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,11 +309,11 @@ 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) {
@ -291,19 +321,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
} }
} }
blocks.set(id, JSON.stringify({ blocks.set(
id,
JSON.stringify({
code: formattedCode, code: formattedCode,
language: currentLanguage, language: currentLanguage,
raw: true raw: true,
})); }),
processedLines.push(''); );
processedLines.push("");
processedLines.push(id); processedLines.push(id);
processedLines.push(''); processedLines.push("");
} }
return { return {
text: processedLines.join('\n'), text: processedLines.join("\n"),
blocks blocks,
}; };
} }
@ -322,12 +355,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
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,8 +368,119 @@ 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;
}
/**
* 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>`;
}
} }
} }
@ -347,14 +491,17 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
* 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>`;
} }
} }

77
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 {
@ -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);
} }
} }
} }
@ -132,7 +135,7 @@ 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 {
@ -142,7 +145,7 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void {
// 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
} }
} }
@ -157,7 +160,7 @@ 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 {
@ -167,7 +170,7 @@ function processTikZBlocks(treeProcessor: any, document: any): void {
// 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"),
); );
} }

57
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(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./events?d=${normalized}`; const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // 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>`; 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,15 +54,16 @@ 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
@ -66,7 +84,8 @@ async function processNostrAddresses(html: string): Promise<string> {
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 =
processedHtml.slice(0, matchIndex) +
processedMatch + processedMatch +
processedHtml.slice(matchIndex + fullMatch.length); processedHtml.slice(matchIndex + fullMatch.length);
} }
@ -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
} }
} }

205
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,19 +23,23 @@ 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(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) { if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match; if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern); const hexMatch = url.match(hexPattern);
@ -53,7 +57,8 @@ function replaceAlexandriaNostrLinks(text: string): string {
} }
} }
return match; 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(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./events?d=${normalized}`; const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // 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>`; 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,32 +198,36 @@ 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;
@ -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,7 +288,7 @@ 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
@ -267,22 +296,28 @@ function processBasicFormatting(content: string): string {
}); });
// 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(
STRIKETHROUGH_REGEX,
(match, doubleText, singleText) => {
const text = doubleText || singleText; const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`; 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,43 +352,47 @@ 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, {
fallback: (name: string) => {
const emojiChar = emoji.get(name); const emojiChar = emoji.get(name);
return emojiChar || `:${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
@ -367,12 +405,19 @@ export async function parseBasicmarkup(text: string): Promise<string> {
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>`;
} }
} }

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

@ -16,7 +16,7 @@ export function renderTikZ(tikzCode: string): string {
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>
@ -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;
} }

15
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,7 +38,8 @@ 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 =
eventType === "replaceable" || eventType === "addressable"
? "replaceable" ? "replaceable"
: "nonreplaceable"; : "nonreplaceable";

221
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,21 +38,23 @@ 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,7 +103,7 @@ 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);
@ -105,8 +117,11 @@ 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);
@ -117,15 +132,18 @@ export function createProfileLink(identifier: string, displayText: string | unde
/** /**
* 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,7 +177,11 @@ 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);
@ -164,11 +190,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp
} }
// 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,7 +202,7 @@ 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);
@ -187,7 +213,9 @@ function createNoteLink(identifier: string): string {
/** /**
* 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,7 +266,7 @@ 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;
} }
@ -248,7 +276,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | 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;
} }
} }
@ -266,17 +294,17 @@ 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),
) ),
]); ]);
} }
@ -286,8 +314,8 @@ export function withTimeout<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),
) ),
]); ]);
} }
@ -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,14 +342,14 @@ 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;
@ -328,7 +359,7 @@ export async function fetchEventWithFallback(
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,24 +367,42 @@ 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;
@ -361,22 +410,32 @@ export async function fetchEventWithFallback(
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));
} }

2
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;

27
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);

64
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);
@ -23,26 +28,33 @@
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"
>
<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> </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={fallbackRelays} searchQuery={searchQuery} /> <PublicationFeed relays={$inboxRelays} {fallbackRelays} {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

291
src/routes/contact/+page.svelte

@ -1,15 +1,24 @@
<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() {
@ -18,51 +27,53 @@
} }
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() {
@ -74,7 +85,7 @@
e.preventDefault(); e.preventDefault();
if (!subject || !content) { if (!subject || !content) {
submissionError = 'Please fill in all fields'; submissionError = "Please fill in all fields";
return; return;
} }
@ -83,7 +94,7 @@
// Save form data // Save form data
savedFormData = { savedFormData = {
subject, subject,
content content,
}; };
// Show login modal // Show login modal
@ -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,13 +151,13 @@
// 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) {
@ -155,11 +166,15 @@
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,18 +184,18 @@
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
@ -189,9 +204,13 @@
// 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 {
@ -209,10 +228,10 @@
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;
} }
@ -224,20 +243,15 @@
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);
@ -250,7 +264,7 @@
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;
@ -269,44 +283,76 @@
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
@ -315,8 +361,11 @@
<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
@ -326,7 +375,7 @@
</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"
@ -373,14 +422,19 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
/> />
</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>
@ -413,29 +467,59 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</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,7 +529,9 @@ 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>
@ -454,7 +540,11 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<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>
@ -473,33 +563,26 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{/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>
@ -507,7 +590,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- 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;

78
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,17 +50,17 @@
} }
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;
@ -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,8 +88,9 @@
</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
@ -108,7 +109,7 @@
{#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,7 +121,8 @@
{#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}
@ -130,27 +132,43 @@
> >
<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
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read 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>

46
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;
} }
} }
@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
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,9 +69,9 @@ 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) {
@ -83,13 +83,19 @@ 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
@ -97,7 +103,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? 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">

7
src/routes/visualize/+page.svelte

@ -49,7 +49,7 @@
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,
@ -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");

2
src/styles/scrollbar.css

@ -14,7 +14,7 @@
} }
*::-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 */
} }
} }

2
src/styles/visualize.css

@ -26,7 +26,7 @@
} }
: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 {

8
src/types/d3.d.ts vendored

@ -7,13 +7,13 @@
*/ */
// 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/plantuml-encoder.d.ts vendored

@ -1,4 +1,4 @@
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;

140
tailwind.config.cjs

@ -12,83 +12,83 @@ 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",
} },
}, },
}, },
@ -96,26 +96,26 @@ const config = {
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();
}); });

112
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");
}); });
}); });

101
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,7 +28,7 @@ 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
@ -33,42 +37,40 @@ 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,6 +164,7 @@ bool getBit(int num, int i) {
``` ```
Asciidoc: Asciidoc:
```adoc ```adoc
= Header 1 = Header 1
@ -155,6 +176,7 @@ some more text
``` ```
Gherkin: Gherkin:
```gherkin ```gherkin
Feature: Account Holder withdraws cash Feature: Account Holder withdraws cash
@ -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.
@ -223,14 +245,14 @@ 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 |
@ -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]:
this is a footnote
[^some reference text]: this is a footnote that isn't a number [^some reference text]: this is a footnote that isn't a number

145
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");
}); });
}); });

100
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>/);
});
});

26
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