Browse Source

rudimentary LaTeX implementation for Markup

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

30
.onedev-buildspec.yml

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

2
.prettierrc

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

20
README.md

@ -18,45 +18,53 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events? @@ -18,45 +18,53 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events?
Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed.
Once you've cloned this repo, install dependencies with NPM:
```bash
npm install
```
or with Deno:
```bash
deno install
```
then start a development server with Node:
```bash
npm run dev
```
or with Deno:
```bash
deno task dev
```
## Building
Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well.
Alexandria is configured to run on a Node server. The [Node adapter](https://svelte.dev/docs/kit/adapter-node) works on Deno as well.
To build a production version of your app with Node, use:
```bash
npm run build
```
or with Deno:
```bash
deno task build
```
You can preview the (non-static) production build with:
```bash
npm run preview
```
or with Deno:
```bash
deno task preview
```
@ -66,11 +74,13 @@ deno task preview @@ -66,11 +74,13 @@ deno task preview
This docker container performs the build.
To build the container:
```bash
docker build . -t gc-alexandria
```
To run the container, in detached mode (-d):
```bash
docker run -d --rm --name=gc-alexandria -p 4174:80 gc-alexandria
```
@ -92,28 +102,32 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS @@ -92,28 +102,32 @@ CONTAINER ID IMAGE COMMAND CREATED STATUS
## Docker + Deno
This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments.
This application is configured to use the Deno runtime. A Docker container is provided to handle builds and deployments.
To build the app for local development:
```bash
docker build -t local-alexandria -f Dockerfile.local .
```
To run the local development build:
```bash
docker run -d -p 3000:3000 local-alexandria
```
## 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.
```bash
npm run test
```
For the Playwright end-to-end (e2e) tests:
```bash
npx playwright test
```

2
docker-compose.yaml

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

10
maintainers.yaml

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

28
playwright.config.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test'; @@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e/',
testDir: "./tests/e2e/",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@ -22,34 +22,31 @@ export default defineConfig({ @@ -22,34 +22,31 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],
['html', { outputFolder: './tests/e2e/html-report' }]
],
reporter: [["list"], ["html", { outputFolder: "./tests/e2e/html-report" }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* 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 */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
@ -84,10 +81,10 @@ export default defineConfig({ @@ -84,10 +81,10 @@ export default defineConfig({
// testIgnore: '*test-assets',
// 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.
outputDir: './tests/e2e/test-results',
outputDir: "./tests/e2e/test-results",
// path to the global setup files.
// globalSetup: require.resolve('./global-setup'),
@ -102,5 +99,4 @@ export default defineConfig({ @@ -102,5 +99,4 @@ export default defineConfig({
// Maximum time expect() should wait for the condition to be met.
timeout: 5000,
},
});

5
postcss.config.js

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

46
src/app.css

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

23
src/app.html

@ -9,21 +9,30 @@ @@ -9,21 +9,30 @@
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
inlineMath: [
["$", "$"],
["\\(", "\\)"],
],
displayMath: [
["$$", "$$"],
["\\[", "\\]"],
],
processEscapes: true,
processEnvironments: true
processEnvironments: true,
},
options: {
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
}
ignoreHtmlClass: "tex2jax_ignore",
processHtmlClass: "tex2jax_process",
},
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<!-- 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>
%sveltekit.head%

172
src/lib/components/CommentBox.svelte

@ -1,14 +1,19 @@ @@ -1,14 +1,19 @@
<script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte';
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser';
import { nip19 } from 'nostr-tools';
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils';
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';
import { Button, Textarea, Alert } from "flowbite-svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools";
import {
getEventHash,
signEvent,
getUserMetadata,
type NostrProfile,
} from "$lib/utils/nostrUtils";
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<{
event: NDKEvent;
@ -16,8 +21,8 @@ @@ -16,8 +21,8 @@
userRelayPreference: boolean;
}>();
let content = $state('');
let preview = $state('');
let content = $state("");
let preview = $state("");
let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null);
@ -35,32 +40,38 @@ @@ -35,32 +40,38 @@
// Markup buttons
const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') },
{ label: 'Italic', action: () => insertMarkup('_', '_') },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') },
{ label: 'Link', action: () => insertMarkup('[', '](url)') },
{ label: 'Image', action: () => insertMarkup('![', '](url)') },
{ label: 'Quote', action: () => insertMarkup('> ', '') },
{ label: 'List', action: () => insertMarkup('- ', '') },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') },
{ label: 'Hashtag', action: () => insertMarkup('#', '') }
{ label: "Bold", action: () => insertMarkup("**", "**") },
{ label: "Italic", action: () => insertMarkup("_", "_") },
{ label: "Strike", action: () => insertMarkup("~~", "~~") },
{ label: "Link", action: () => insertMarkup("[", "](url)") },
{ label: "Image", action: () => insertMarkup("![", "](url)") },
{ label: "Quote", action: () => insertMarkup("> ", "") },
{ label: "List", action: () => insertMarkup("- ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") },
];
function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea');
const textarea = document.querySelector("textarea");
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
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();
// Set cursor position after the inserted markup
setTimeout(() => {
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);
}
@ -69,8 +80,8 @@ @@ -69,8 +80,8 @@
}
function clearForm() {
content = '';
preview = '';
content = "";
preview = "";
error = null;
success = null;
showOtherRelays = false;
@ -79,26 +90,29 @@ @@ -79,26 +90,29 @@
function removeFormatting() {
content = content
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/^>\s*/gm, '')
.replace(/^[-*]\s*/gm, '')
.replace(/^\d+\.\s*/gm, '')
.replace(/#(\w+)/g, '$1');
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, "")
.replace(/^\d+\.\s*/gm, "")
.replace(/#(\w+)/g, "$1");
updatePreview();
}
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) {
async function handleSubmit(
useOtherRelays = false,
useFallbackRelays = false,
) {
isSubmitting = true;
error = null;
success = null;
try {
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;
@ -106,28 +120,32 @@ @@ -106,28 +120,32 @@
if (kind === 1) {
// NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']);
tags.push(['p', props.event.pubkey]);
tags.push(["e", props.event.id, "", "reply"]);
tags.push(["p", props.event.pubkey]);
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) {
tags.push(['e', rootTag[1], '', 'root']);
tags.push(["e", rootTag[1], "", "root"]);
}
// Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', t[1]]);
}
});
props.event.tags
.filter((t: string[]) => t[0] === "p")
.forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(["p", t[1]]);
}
});
}
} else {
// NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]);
tags.push(["E", props.event.id, "", props.event.pubkey]);
tags.push(["K", props.event.kind.toString()]);
tags.push(["P", props.event.pubkey]);
tags.push(["e", props.event.id, "", props.event.pubkey]);
tags.push(["k", props.event.kind.toString()]);
tags.push(["p", props.event.pubkey]);
}
const eventToSign = {
@ -135,7 +153,7 @@ @@ -135,7 +153,7 @@
created_at: Math.floor(Date.now() / 1000),
tags,
content,
pubkey: props.userPubkey
pubkey: props.userPubkey,
};
const id = getEventHash(eventToSign);
@ -144,7 +162,7 @@ @@ -144,7 +162,7 @@
const signedEvent = {
...eventToSign,
id,
sig
sig,
};
// Determine which relays to use
@ -164,16 +182,16 @@ @@ -164,16 +182,16 @@
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
reject(new Error("Timeout"));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent]));
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === 'OK' && id === signedEvent.id) {
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
@ -190,7 +208,7 @@ @@ -190,7 +208,7 @@
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error('WebSocket error'));
reject(new Error("WebSocket error"));
};
});
if (published) break;
@ -202,12 +220,14 @@ @@ -202,12 +220,14 @@
if (!published) {
if (!useOtherRelays && !useFallbackRelays) {
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) {
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 {
error = 'Failed to publish to any relays. Please try again later.';
error = "Failed to publish to any relays. Please try again later.";
}
} else {
// Navigate to the event page
@ -215,7 +235,7 @@ @@ -215,7 +235,7 @@
goto(`/events?id=${nevent}`);
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
error = e instanceof Error ? e.message : "An error occurred";
} finally {
isSubmitting = false;
}
@ -227,7 +247,9 @@ @@ -227,7 +247,9 @@
{#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button>
{/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>
</div>
@ -250,10 +272,16 @@ @@ -250,10 +272,16 @@
<Alert color="red" dismissable>
{error}
{#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 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}
</Alert>
{/if}
@ -261,7 +289,10 @@ @@ -261,7 +289,10 @@
{#if success}
<Alert color="green" dismissable>
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
</a>
</Alert>
@ -273,7 +304,7 @@ @@ -273,7 +304,7 @@
{#if userProfile.picture}
<img
src={userProfile.picture}
alt={userProfile.name || 'Profile'}
alt={userProfile.name || "Profile"}
class="w-8 h-8 rounded-full"
onerror={(e) => {
const img = e.target as HTMLImageElement;
@ -282,7 +313,9 @@ @@ -282,7 +313,9 @@
/>
{/if}
<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>
</div>
{/if}
@ -303,7 +336,8 @@ @@ -303,7 +336,8 @@
{#if !props.userPubkey}
<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>
{/if}
</div>

159
src/lib/components/EventDetails.svelte

@ -5,14 +5,18 @@ @@ -5,14 +5,18 @@
import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { getMatchingTags } from '$lib/utils/nostrUtils';
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import ProfileHeader from "$components/cards/ProfileHeader.svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { goto } from '$app/navigation';
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { goto } from "$app/navigation";
const { event, profile = null, searchValue = null } = $props<{
const {
event,
profile = null,
searchValue = null,
} = $props<{
event: NDKEvent;
profile?: {
name?: string;
@ -28,43 +32,66 @@ @@ -28,43 +32,66 @@
}>();
let showFullContent = $state(false);
let parsedContent = $state('');
let contentPreview = $state('');
let parsedContent = $state("");
let contentPreview = $state("");
let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled';
return getMatchingTags(event, "title")[0]?.[1] || "Untitled";
}
function getEventSummary(event: NDKEvent): string {
return getMatchingTags(event, 'summary')[0]?.[1] || '';
return getMatchingTags(event, "summary")[0]?.[1] || "";
}
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 {
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 } {
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);
function getTagButtonInfo(tag: string[]): {
text: string;
gotoValue?: string;
} {
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 };
}
if (tag[0] === 'e' && tag.length > 1) {
const nevent = neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays);
if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode(
{
id: tag[1],
kind: 1,
content: "",
tags: [],
pubkey: "",
sig: "",
} as any,
standardRelays,
);
return { text: `e:${tag[1]}`, gotoValue: nevent };
}
return { text: '' };
return { text: "" };
}
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => {
parseBasicmarkup(event.content).then((html) => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
@ -73,8 +100,12 @@ @@ -73,8 +100,12 @@
$effect(() => {
if (event?.pubkey) {
getUserMetadata(toNpub(event.pubkey) as string).then(profile => {
authorDisplayName = profile.displayName || (profile as any).display_name || profile.name || event.pubkey;
getUserMetadata(toNpub(event.pubkey) as string).then((profile) => {
authorDisplayName =
profile.displayName ||
(profile as any).display_name ||
profile.name ||
event.pubkey;
});
} else {
authorDisplayName = undefined;
@ -82,30 +113,46 @@ @@ -82,30 +113,46 @@
});
// --- Identifier helpers ---
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] {
const ids: { label: string, value: string, link?: string }[] = [];
function getIdentifiers(
event: NDKEvent,
profile: any,
): { label: string; value: string; link?: string }[] {
const ids: { label: string; value: string; link?: string }[] = [];
if (event.kind === 0) {
// NIP-05
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1];
const nip05 = profile?.nip05 || getMatchingTags(event, "nip05")[0]?.[1];
// npub
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
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
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
ids.push({ label: 'pubkey', value: event.pubkey });
ids.push({ label: "pubkey", value: event.pubkey });
} else {
// 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)
try {
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 {}
// hex id
ids.push({ label: 'id', value: event.id });
ids.push({ label: "id", value: event.id });
}
return ids;
}
@ -113,20 +160,25 @@ @@ -113,20 +160,25 @@
function isCurrentSearch(value: string): boolean {
if (!searchValue) return false;
// 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);
}
</script>
<div class="flex flex-col space-y-4">
{#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}
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<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>
{:else}
<span class="text-gray-700 dark:text-gray-300">
@ -138,7 +190,9 @@ @@ -138,7 +190,9 @@
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">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>
{#if getEventSummary(event)}
@ -153,7 +207,10 @@ @@ -153,7 +207,10 @@
<span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2">
{#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}
</div>
</div>
@ -166,7 +223,10 @@ @@ -166,7 +223,10 @@
<div class="prose dark:prose-invert max-w-none">
{@html showFullContent ? parsedContent : contentPreview}
{#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}
</div>
{/if}
@ -174,7 +234,11 @@ @@ -174,7 +234,11 @@
<!-- If event is profile -->
{#if event.kind === 0}
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} />
<ProfileHeader
{event}
{profile}
identifiers={getIdentifiers(event, profile)}
/>
{/if}
<!-- Tags Array -->
@ -186,7 +250,8 @@ @@ -186,7 +250,8 @@
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<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"
>
{tagInfo.text}
@ -198,17 +263,23 @@ @@ -198,17 +263,23 @@
{/if}
<!-- 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">
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2">
<details
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
</summary>
<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>
<pre
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)}
</pre>
</details>

2
src/lib/components/EventLimitControl.svelte

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

10
src/lib/components/EventRenderLevelLimit.svelte

@ -29,10 +29,14 @@ @@ -29,10 +29,14 @@
</script>
<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:
</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
type="number"
id="levels-to-render"
@ -45,7 +49,7 @@ @@ -45,7 +49,7 @@
/>
<button
onclick={handleUpdate}
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-400 dark:border-gray-600 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

173
src/lib/components/EventSearch.svelte

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

44
src/lib/components/Login.svelte

@ -1,21 +1,27 @@ @@ -1,21 +1,27 @@
<script lang='ts'>
import { type NDKUserProfile } from '@nostr-dev-kit/ndk';
import { activePubkey, loginWithExtension, ndkInstance, ndkSignedIn, persistLogin } from '$lib/ndk';
import { Avatar, Button, Popover } from 'flowbite-svelte';
<script lang="ts">
import { type NDKUserProfile } from "@nostr-dev-kit/ndk";
import {
activePubkey,
loginWithExtension,
ndkInstance,
ndkSignedIn,
persistLogin,
} from "$lib/ndk";
import { Avatar, Button, Popover } from "flowbite-svelte";
import Profile from "$components/util/Profile.svelte";
let profile = $state<NDKUserProfile | null>(null);
let npub = $state<string | undefined >(undefined);
let npub = $state<string | undefined>(undefined);
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
let errorMessage = $state<string>("");
$effect(() => {
if ($ndkSignedIn) {
$ndkInstance
.getUser({ pubkey: $activePubkey ?? undefined })
?.fetchProfile()
.then(userProfile => {
.then((userProfile) => {
profile = userProfile;
});
npub = $ndkInstance.activeUser?.npub;
@ -25,11 +31,11 @@ @@ -25,11 +31,11 @@
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
errorMessage = "";
const user = await loginWithExtension();
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();
@ -37,28 +43,24 @@ @@ -37,28 +43,24 @@
} catch (e) {
console.error(e);
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>
<div class="m-4">
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{: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
class='popover-leather w-fit'
placement='bottom'
triggeredBy='#avatar'
class="popover-leather w-fit"
placement="bottom"
triggeredBy="#avatar"
>
<div class='w-full flex flex-col space-y-2'>
<Button
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
<div class="w-full flex flex-col space-y-2">
<Button onclick={handleSignInClick}>Extension Sign-In</Button>
{#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}

54
src/lib/components/LoginModal.svelte

@ -1,15 +1,19 @@ @@ -1,15 +1,19 @@
<script lang="ts">
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;
onClose?: () => void;
onLoginSuccess?: () => void;
}>();
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
let errorMessage = $state<string>("");
$effect(() => {
if ($ndkSignedIn && show) {
@ -21,51 +25,65 @@ @@ -21,51 +25,65 @@
async function handleSignInClick() {
try {
signInFailed = false;
errorMessage = '';
errorMessage = "";
const user = await loginWithExtension();
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) {
console.error(e);
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>
{#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="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 -->
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t">
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3>
<div
class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"
>
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">
Login Required
</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-600 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
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>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p 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
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>
<div class="flex flex-col space-y-4">
<div class="flex justify-center">
<Button
color="primary"
onclick={handleSignInClick}
>
<Button color="primary" onclick={handleSignInClick}>
Sign in with Extension
</Button>
</div>
{#if signInFailed}
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded">
<div
class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded"
>
{errorMessage}
</div>
{/if}

190
src/lib/components/Preview.svelte

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

2
src/lib/components/Publication.svelte

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

164
src/lib/components/PublicationFeed.svelte

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

75
src/lib/components/PublicationHeader.svelte

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

109
src/lib/components/PublicationSection.svelte

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

97
src/lib/components/RelayActions.svelte

@ -1,10 +1,16 @@ @@ -1,10 +1,16 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { get } from 'svelte/store';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte';
import { get } from "svelte/store";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
createRelaySetFromUrls,
createNDKEvent,
} from "$lib/utils/nostrUtils";
import RelayDisplay, {
getConnectedRelays,
getEventRelays,
} from "./RelayDisplay.svelte";
import { standardRelays, fallbackRelays } from "$lib/consts";
const { event } = $props<{
@ -17,7 +23,9 @@ @@ -17,7 +23,9 @@
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false);
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound">
>({});
let allRelays = $state<string[]>([]);
// Magnifying glass icon SVG
@ -39,7 +47,7 @@ @@ -39,7 +47,7 @@
try {
const connectedRelays = getConnectedRelays();
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
@ -47,15 +55,16 @@ @@ -47,15 +55,16 @@
...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000),
sig: ''
sig: "",
});
// Publish to all relays
await newEvent.publish();
broadcastSuccess = true;
} catch (err) {
console.error('Error broadcasting event:', err);
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event';
console.error("Error broadcasting event:", err);
broadcastError =
err instanceof Error ? err.message : "Failed to broadcast event";
} finally {
broadcasting = false;
}
@ -71,41 +80,40 @@ @@ -71,41 +80,40 @@
if (!event) return;
relaySearchResults = {};
const ndk = get(ndkInstance);
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url);
allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending']));
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(
(r) => r.url,
);
allRelays = [...standardRelays, ...userRelays, ...fallbackRelays].filter(
(url, idx, arr) => arr.indexOf(url) === idx,
);
relaySearchResults = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
await Promise.all(
allRelays.map(async (relay: string) => {
try {
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent(
{ ids: [event?.id || ''] },
undefined,
relaySet
).withTimeout(3000);
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' };
const found = await ndk
.fetchEvent({ ids: [event?.id || ""] }, undefined, relaySet)
.withTimeout(3000);
relaySearchResults = {
...relaySearchResults,
[relay]: found ? "found" : "notfound",
};
} catch {
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' };
relaySearchResults = { ...relaySearchResults, [relay]: "notfound" };
}
})
}),
);
}
function closeRelayModal() {
showRelayModal = false;
}
</script>
<div class="mt-4 flex flex-wrap gap-2">
<Button
on:click={openRelayModal}
class="flex items-center"
>
<Button on:click={openRelayModal} class="flex items-center">
{@html searchIcon}
Where can I find this event?
</Button>
@ -117,7 +125,7 @@ @@ -117,7 +125,7 @@
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? 'Broadcasting...' : 'Broadcast'}
{broadcasting ? "Broadcasting..." : "Broadcast"}
</Button>
{/if}
</div>
@ -160,23 +168,32 @@ @@ -160,23 +168,32 @@
</div>
{#if showRelayModal}
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center">
<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>
<div
class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"
>
<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>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries({
'Standard Relays': standardRelays,
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url),
'Fallback Relays': fallbackRelays
}) as [groupName, groupRelays]}
{#each Object.entries( { "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}
<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}
</h3>
{#each groupRelays as relay}
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} />
<RelayDisplay
{relay}
showStatus={true}
status={relaySearchResults[relay] || null}
/>
{/each}
</div>
{/if}

48
src/lib/components/RelayDisplay.svelte

@ -1,14 +1,16 @@ @@ -1,14 +1,16 @@
<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)
export function getEventRelays(event: NDKEvent): string[] {
if (event && (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) {
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;
}
@ -16,41 +18,57 @@ @@ -16,41 +18,57 @@
export function getConnectedRelays(): string[] {
const ndk = get(ndkInstance);
return Array.from(ndk?.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url);
.filter((r) => r.status === 1) // Only use connected relays
.map((r) => r.url);
}
</script>
<script lang="ts">
import { get } from 'svelte/store';
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
export let relay: string;
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
function relayFavicon(relay: string): string {
return '/favicon.png';
return "/favicon.png";
}
</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
src={relayFavicon(relay)}
alt="relay icon"
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>
{#if showStatus && status}
{#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">
<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>
{#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"
>
<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>
{:else if status === 'found'}
{:else if status === "found"}
<span class="text-green-600">&#10003;</span>
{:else}
<span class="text-red-500">&#10007;</span>

89
src/lib/components/RelayStatus.svelte

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

36
src/lib/components/Toc.svelte

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

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

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

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

@ -6,10 +6,18 @@ @@ -6,10 +6,18 @@
import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// @ts-ignore
import { bech32 } from 'https://esm.sh/bech32';
import { bech32 } from "https://esm.sh/bech32";
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 lnurl = $state<string | null>(null);
@ -18,103 +26,150 @@ @@ -18,103 +26,150 @@
if (profile?.lud16) {
try {
// 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 words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode('lnurl', words);
lnurl = bech32.encode("lnurl", words);
} catch {
console.log('Error converting LN address to LNURL');
console.log("Error converting LN address to LNURL");
}
}
});
</script>
{#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl">
<div class='space-y-4'>
{#if profile.banner}
<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';}} />
</div>
{/if}
<div class='flex flex-row space-x-4 items-center'>
{#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'; }} />
<Card class="ArticleBox card-leather w-full max-w-2xl">
<div class="space-y-4">
{#if profile.banner}
<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";
}}
/>
</div>
{/if}
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.display_name || profile.name || event.pubkey)}
</div>
<div>
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
{#if profile.name}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Name:</dt>
<dd>{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Display Name:</dt>
<dd>{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd>
</div>
{/if}
{#if profile.website}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt>
<dd>
<a href={profile.website} class="underline text-primary-700 dark:text-primary-200">{profile.website}</a>
</dd>
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd>
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd>
</div>
{/each}
</dl>
<div class="flex flex-row space-x-4 items-center">
{#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";
}}
/>
{/if}
{@render userBadge(
toNpub(event.pubkey) as string,
profile.displayName ||
profile.display_name ||
profile.name ||
event.pubkey,
)}
</div>
<div>
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
{#if profile.name}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Name:</dt>
<dd>{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Display Name:</dt>
<dd>{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd>
</div>
{/if}
{#if profile.website}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt>
<dd>
<a
href={profile.website}
class="underline text-primary-700 dark:text-primary-200"
>{profile.website}</a
>
</dd>
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt>
<dd>
<Button
class="btn-leather"
color="primary"
outline
onclick={() => (lnModalOpen = true)}>{profile.lud16}</Button
>
</dd>
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
{#if id.link}<a
href={id.link}
class="underline text-primary-700 dark:text-primary-200 break-all"
>{id.value}</a
>{:else}{id.value}{/if}
</dd>
</div>
{/each}
</dl>
</div>
</div>
</div>
</div>
</Card>
</Card>
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'>
{#if profile.lud16}
<div>
<div class='flex flex-col items-center'>
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)}
<P>{profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
<Modal
class="modal-leather"
title="Lightning Address"
bind:open={lnModalOpen}
outsideclose
size="sm"
>
{#if profile.lud16}
<div>
<div class="flex flex-col items-center">
{@render userBadge(
toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey,
)}
<P>{profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P style="overflow-wrap: anywhere">
<CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard>
</P>
<QrCode value={lnurl} />
{:else}
<P>Couldn't generate address.</P>
{/if}
</div>
</div>
{/if}
</Modal>
{/if}

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

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

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

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
ClipboardCleanOutline,
DotsVerticalOutline,
EyeOutline,
ShareNodesOutline
ShareNodesOutline,
} from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays, FeedType } from "$lib/consts";
@ -18,17 +18,39 @@ @@ -18,17 +18,39 @@
let { event } = $props<{ event: NDKEvent }>();
// Derive metadata from event
let title = $derived(event.tags.find((t: string[]) => t[0] === 'title')?.[1] ?? '');
let summary = $derived(event.tags.find((t: string[]) => t[0] === 'summary')?.[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 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);
let title = $derived(
event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
);
let summary = $derived(
event.tags.find((t: string[]) => t[0] === "summary")?.[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 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
let detailsModalOpen: boolean = $state(false);
@ -50,11 +72,11 @@ @@ -50,11 +72,11 @@
feedType: $feedType,
isUserFeed,
relayCount: relays.length,
relayUrls: relays
relayUrls: relays,
});
return relays;
})()
})(),
);
/**
@ -71,7 +93,7 @@ @@ -71,7 +93,7 @@
function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false;
const menu = document.getElementById('dots-' + event.id);
const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur();
}
@ -80,10 +102,13 @@ @@ -80,10 +102,13 @@
* @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string
*/
function getIdentifier(type: 'nevent' | 'naddr'): string {
const encodeFn = type === 'nevent' ? neventEncode : naddrEncode;
function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
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;
}
@ -94,7 +119,7 @@ @@ -94,7 +119,7 @@
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author
author: event.author,
});
detailsModalOpen = true;
}
@ -105,90 +130,128 @@ @@ -105,90 +130,128 @@
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author
author: event.author,
});
</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 -->
<Button type="button"
id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" color="none"
data-popover-target="popover-actions">
<DotsVerticalOutline class="h-6 w-6"/>
<Button
type="button"
id="dots-{event.id}"
class=" hover:bg-primary-0 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="none"
data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" />
<span class="sr-only">Open actions menu</span>
</Button>
{#if isOpen}
<Popover id="popover-actions"
placement="bottom"
trigger="click"
class='popover-leather w-fit z-10'
onmouseleave={closePopover}
>
<div class='flex flex-row justify-between space-x-4'>
<div class='flex flex-col text-nowrap'>
<ul class="space-y-2">
<li>
<button class='btn-leather w-full text-left' onclick={viewDetails}>
<EyeOutline class="inline mr-2" /> View details
</button>
</li>
<li>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier('naddr')}
icon={ShareNodesOutline}
/>
</li>
<li>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier('nevent')}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
<Popover
id="popover-actions"
placement="bottom"
trigger="click"
class="popover-leather w-fit z-10"
onmouseleave={closePopover}
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
<li>
<button
class="btn-leather w-full text-left"
onclick={viewDetails}
>
<EyeOutline class="inline mr-2" /> View details
</button>
</li>
<li>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier("naddr")}
icon={ShareNodesOutline}
/>
</li>
<li>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier("nevent")}
icon={ClipboardCleanOutline}
/>
</li>
</ul>
</div>
</div>
</div>
</Popover>
</Popover>
{/if}
<!-- 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">
{#if image}
<div 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
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>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title || 'Untitled'}</h1>
<h2 class="text-base font-bold">by
<h1 class="text-3xl font-bold mt-5">{title || "Untitled"}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor}
{@render userBadge(originalAuthor, author)}
{@render userBadge(originalAuthor, author)}
{:else}
{author || 'Unknown'}
{author || "Unknown"}
{/if}
</h2>
{#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}
</div>
</div>
{#if summary}
<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>
{/if}
<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 class="flex flex-col pb-4 space-y-1">
{#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 type}
<h5 class="text-sm">Publication type: {type}</h5>

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

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

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

@ -3,57 +3,82 @@ @@ -3,57 +3,82 @@
import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { getMatchingTags } from "$lib/utils/nostrUtils";
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1');
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null);
let summary: string = $derived(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 title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
);
let version: string = $derived(
getMatchingTags(event, "version")[0]?.[1] ?? "1",
);
let image: string = $derived(getMatchingTags(event, "image")[0]?.[1] ?? null);
let originalAuthor: string = $derived(
getMatchingTags(event, "p")[0]?.[1] ?? null,
);
let summary: string = $derived(
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);
</script>
<div class="flex flex-col relative mb-2">
{#if !isModal}
<div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P>
<CardActions event={event}></CardActions>
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, author)}</P
>
<CardActions {event}></CardActions>
</div>
{/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}
<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>
{/if}
<div class="space-y-4 my-4">
<div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor !== null}
{@render userBadge(originalAuthor, author)}
{@render userBadge(originalAuthor, author)}
{:else}
{author}
{/if}
</h2>
{#if version !== '1' }
<h4 class="text-base font-medium text-primary-700 dark:text-primary-300">Version: {version}</h4>
{#if version !== "1"}
<h4
class="text-base font-medium text-primary-700 dark:text-primary-300"
>
Version: {version}
</h4>
{/if}
</div>
</div>
@ -61,7 +86,7 @@ @@ -61,7 +86,7 @@
{#if summary}
<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>
{/if}
@ -75,7 +100,7 @@ @@ -75,7 +100,7 @@
{#if isModal}
<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}
<span>Index author:</span>
{:else}
@ -85,10 +110,13 @@ @@ -85,10 +110,13 @@
</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#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 type !== null}
<h5 class="text-sm">Publication type: {type}</h5>
@ -106,5 +134,5 @@ @@ -106,5 +134,5 @@
{/if}
{#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/>
<Interactions {event} {rootId} direction="row" />
{/if}

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

@ -1,15 +1,21 @@ @@ -1,15 +1,21 @@
<script lang="ts">
import { Button, Modal, P } from "flowbite-svelte";
import {
Button, Modal, P
} from "flowbite-svelte";
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons';
HeartOutline,
FilePenOutline,
AnnotationOutline,
} from "flowbite-svelte-icons";
import ZapOutline from "$components/util/ZapOutline.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { ndkInstance } from '$lib/ndk';
import { ndkInstance } from "$lib/ndk";
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
let likes: NDKEvent[] = [];
@ -34,13 +40,12 @@ @@ -34,13 +40,12 @@
function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({
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
if (!targetArray.find(e => e.id === evt.id)) {
if (!targetArray.find((e) => e.id === evt.id)) {
targetArray.push(evt);
}
});
@ -52,18 +57,18 @@ @@ -52,18 +57,18 @@
onMount(() => {
// Subscribe to each kind; store subs for cleanup
subs.push(subscribeCount(7, likes)); // likes (Reaction)
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts)
subs.push(subscribeCount(7, likes)); // likes (Reaction)
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts)
subs.push(subscribeCount(30023, highlights)); // highlights (custom kind)
subs.push(subscribeCount(1, comments)); // comments (Text Notes)
subs.push(subscribeCount(1, comments)); // comments (Text Notes)
});
function showDiscussion() {
publicationColumnVisibility.update(v => {
const updated = { ...v, discussion: true};
publicationColumnVisibility.update((v) => {
const updated = { ...v, discussion: true };
// hide blog, unless the only column
if (v.inner) {
updated.blog = (v.blog && window.innerWidth >= 1400 );
updated.blog = v.blog && window.innerWidth >= 1400;
}
return updated;
});
@ -80,14 +85,45 @@ @@ -80,14 +85,45 @@
}
</script>
<div 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={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
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={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>
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'>
<P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P>
<Modal
class="modal-leather"
title="Interaction"
bind:open={interactionOpen}
autoclose
outsideclose
size="sm"
>
<P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P>
</Modal>

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

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

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

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

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

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

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

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

50
src/lib/consts.ts

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

77
src/lib/data_structures/publication_tree.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts';
import { findIndexAsync as _findIndexAsync } from "../utils.ts";
enum PublicationTreeNodeType {
Branch,
@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -62,7 +62,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
};
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent);
@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
`PublicationTree: Parent node with address ${parentAddress} not found.`,
);
}
@ -116,7 +119,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -116,7 +119,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
`PublicationTree: Parent node with address ${parentAddress} not found.`,
);
}
@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -145,13 +148,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getChildAddresses(address: string): Promise<Array<string | null>> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
}
return Promise.all(
node.children?.map(async child =>
(await child.value())?.address ?? null
) ?? []
node.children?.map(
async (child) => (await child.value())?.address ?? null,
) ?? [],
);
}
/**
@ -163,7 +168,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -163,7 +168,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
throw new Error(
`PublicationTree: Node with address ${address} not found.`,
);
}
const hierarchy: NDKEvent[] = [this.#events.get(address)!];
@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// #region Iteration Cursor
#cursor = new class {
#cursor = new (class {
target: PublicationTreeNode | null | undefined;
#tree: PublicationTree;
@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
async tryMoveTo(address?: string) {
if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value();
this.target = await this.#tree.#nodes
.get(startEvent!.tagAddress())
?.value();
} else {
this.target = await this.#tree.#nodes.get(address)?.value();
}
@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
);
if (currentIndex === -1) {
@ -288,7 +298,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -288,7 +298,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value())?.address === this.target!.address
async (sibling: Lazy<PublicationTreeNode>) =>
(await sibling.value())?.address === this.target!.address,
);
if (currentIndex === -1) {
@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.target = parent;
return true;
}
}(this);
})(this);
// #endregion
@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!;
let currentEvent: NDKEvent | null | undefined = this.#events.get(
this.#root.address,
)!;
while (stack.length > 0) {
const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) {
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`);
throw new Error(
`PublicationTree: Node with address ${currentAddress} not found.`,
);
}
currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) {
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`);
throw new Error(
`PublicationTree: Event with address ${currentAddress} not found.`,
);
}
// Stop immediately if the target of the search is found.
@ -431,8 +448,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -431,8 +448,8 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a')
.map(tag => tag[1]);
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
@ -465,11 +482,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -465,11 +482,15 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#addNode(address: string, parentNode: PublicationTreeNode) {
if (this.#nodes.has(address)) {
console.debug(`[PublicationTree] Node with address ${address} already exists.`);
console.debug(
`[PublicationTree] Node with address ${address} already exists.`,
);
return;
}
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode));
const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode),
);
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
}
@ -485,18 +506,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -485,18 +506,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
*/
async #resolveNode(
address: string,
parentNode: PublicationTreeNode
parentNode: PublicationTreeNode,
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':');
const [kind, pubkey, dTag] = address.split(":");
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
'#d': [dTag],
"#d": [dTag],
});
if (!event) {
console.debug(
`PublicationTree: Event with address ${address} not found.`
`PublicationTree: Event with address ${address} not found.`,
);
return {
@ -510,7 +531,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -510,7 +531,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const childAddresses = event.tags
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
const node: PublicationTreeNode = {
type: this.#getNodeType(event),
@ -528,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -528,7 +551,7 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#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;
}

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

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
<!-- Legend Component (Svelte 5, Runes Mode) -->
<script lang="ts">
import {Button} from 'flowbite-svelte';
import { Button } from "flowbite-svelte";
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons";
let {
collapsedOnInteraction = false,
className = ""
} = $props<{collapsedOnInteraction: boolean, className: string}>();
let { collapsedOnInteraction = false, className = "" } = $props<{
collapsedOnInteraction: boolean;
className: string;
}>();
let expanded = $state(true);
@ -24,7 +24,13 @@ @@ -24,7 +24,13 @@
<div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3">
<h3 class="h-leather">Legend</h3>
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" >
<Button
color="none"
outline
size="xs"
onclick={toggle}
class="rounded-full"
>
{#if expanded}
<CaretUpOutline />
{:else}
@ -45,7 +51,9 @@ @@ -45,7 +51,9 @@
<span class="legend-letter">I</span>
</span>
</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>
<!-- Content event node -->
@ -55,7 +63,9 @@ @@ -55,7 +63,9 @@
<span class="legend-letter">C</span>
</span>
</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>
<!-- Link arrow -->

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

@ -7,15 +7,21 @@ @@ -7,15 +7,21 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { getMatchingTags } from "$lib/utils/nostrUtils";
// Component props
let { node, selected = false, x, y, onclose } = $props<{
node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip
y: number; // Y position for the tooltip
onclose: () => void; // Function to call when closing the tooltip
let {
node,
selected = false,
x,
y,
onclose,
} = $props<{
node: NetworkNode; // The node to display information for
selected?: boolean; // Whether the node is selected (clicked)
x: number; // X position for the tooltip
y: number; // Y position for the tooltip
onclose: () => void; // Function to call when closing the tooltip
}>();
// DOM reference and positioning
@ -68,7 +74,10 @@ @@ -68,7 +74,10 @@
/**
* 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.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
@ -117,13 +126,18 @@ @@ -117,13 +126,18 @@
style="left: {tooltipX}px; top: {tooltipY}px;"
>
<!-- Close button -->
<button
class="tooltip-close-btn"
onclick={closeTooltip}
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
<button class="tooltip-close-btn" onclick={closeTooltip} aria-label="Close">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
@ -131,10 +145,7 @@ @@ -131,10 +145,7 @@
<div class="tooltip-content">
<!-- Title with link -->
<div class="tooltip-title">
<a
href="/publication?id={node.id}"
class="tooltip-title-link"
>
<a href="/publication?id={node.id}" class="tooltip-title-link">
{node.title || "Untitled"}
</a>
</div>
@ -152,7 +163,8 @@ @@ -152,7 +163,8 @@
<!-- Summary (for index nodes) -->
{#if node.isContainer && getSummaryTag(node)}
<div class="tooltip-summary">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "")}
<span class="font-semibold">Summary:</span>
{truncateContent(getSummaryTag(node) || "")}
</div>
{/if}
@ -165,9 +177,7 @@ @@ -165,9 +177,7 @@
<!-- Help text for selected nodes -->
{#if selected}
<div class="tooltip-help-text">
Click node again to dismiss
</div>
<div class="tooltip-help-text">Click node again to dismiss</div>
{/if}
</div>
</div>

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

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

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

@ -16,13 +16,13 @@ @@ -16,13 +16,13 @@
setupDragHandlers,
applyGlobalLogGravity,
applyConnectedGravity,
type Simulation
type Simulation,
} from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types";
import Settings from "./Settings.svelte";
import {Button} from 'flowbite-svelte';
import { Button } from "flowbite-svelte";
// Type alias for D3 selections
type Selection = any;
@ -45,7 +45,10 @@ @@ -45,7 +45,10 @@
}
// Component props
let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>();
let { events = [], onupdate } = $props<{
events?: NDKEvent[];
onupdate: () => void;
}>();
// Error state
let errorMessage = $state<string | null>(null);
@ -69,7 +72,9 @@ @@ -69,7 +72,9 @@
let width = $state(1000);
let height = $state(600);
let windowHeight = $state<number | undefined>(undefined);
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400);
let graphHeight = $derived(
windowHeight ? Math.max(windowHeight * 0.2, 400) : 400,
);
// D3 objects
let simulation: Simulation<NetworkNode, NetworkLink> | null = null;
@ -100,8 +105,7 @@ @@ -100,8 +105,7 @@
}
debug("SVG dimensions", { width, height });
const svgElement = d3.select(svg)
.attr("viewBox", `0 0 ${width} ${height}`);
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Clear existing content
svgElement.selectAll("*").remove();
@ -172,7 +176,7 @@ @@ -172,7 +176,7 @@
// Generate graph data from events
debug("Generating graph with events", {
eventCount: events.length,
currentLevels
currentLevels,
});
const graphData = generateGraph(events, Number(currentLevels));
@ -181,7 +185,7 @@ @@ -181,7 +185,7 @@
debug("Generated graph data", {
nodeCount: nodes.length,
linkCount: links.length
linkCount: links.length,
});
if (!nodes.length) {
@ -212,13 +216,14 @@ @@ -212,13 +216,14 @@
.selectAll("path.link")
.data(links, (d: NetworkLink) => `${d.source.id}-${d.target.id}`)
.join(
(enter: any) => enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(enter: any) =>
enter
.append("path")
.attr("class", "link network-link-leather")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)"),
(update: any) => update,
(exit: any) => exit.remove()
(exit: any) => exit.remove(),
);
// Update nodes
@ -260,23 +265,27 @@ @@ -260,23 +265,27 @@
return nodeEnter;
},
(update: any) => update,
(exit: any) => exit.remove()
(exit: any) => exit.remove(),
);
// Update node appearances
debug("Updating node appearances");
node.select("circle.visual-circle")
.attr("class", (d: NetworkNode) => !d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather"
node
.select("circle.visual-circle")
.attr("class", (d: NetworkNode) =>
!d.isContainer
? "visual-circle network-node-leather network-node-content"
: "visual-circle network-node-leather",
)
.attr("fill", (d: NetworkNode) => !d.isContainer
? isDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? isDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
);
node.select("text")
.text((d: NetworkNode) => d.isContainer ? "I" : "C");
node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C"));
// Set up node interactions
debug("Setting up node interactions");
@ -322,9 +331,14 @@ @@ -322,9 +331,14 @@
if (simulation) {
simulation.on("tick", () => {
// Apply custom forces to each node
nodes.forEach(node => {
nodes.forEach((node) => {
// 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
applyConnectedGravity(node, links, simulation!.alpha());
});
@ -349,7 +363,10 @@ @@ -349,7 +363,10 @@
});
// 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) {
@ -390,11 +407,15 @@ @@ -390,11 +407,15 @@
isDarkMode = newIsDarkMode;
// Update node colors when theme changes
if (svgGroup) {
svgGroup.selectAll("g.node")
svgGroup
.selectAll("g.node")
.select("circle.visual-circle")
.attr("fill", (d: NetworkNode) => !d.isContainer
? newIsDarkMode ? CONTENT_COLOR_DARK : CONTENT_COLOR_LIGHT
: getEventColor(d.id)
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? newIsDarkMode
? CONTENT_COLOR_DARK
: CONTENT_COLOR_LIGHT
: getEventColor(d.id),
);
}
}
@ -440,7 +461,7 @@ @@ -440,7 +461,7 @@
debug("Effect triggered", {
hasSvg: !!svg,
eventCount: events?.length,
currentLevels
currentLevels,
});
try {
@ -472,10 +493,13 @@ @@ -472,10 +493,13 @@
const svgHeight = svg.clientHeight || height;
// Reset zoom and center
d3.select(svg).transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8)
);
d3.select(svg)
.transition()
.duration(750)
.call(
zoomBehavior.transform,
d3.zoomIdentity.translate(svgWidth / 2, svgHeight / 2).scale(0.8),
);
}
}
@ -484,9 +508,7 @@ @@ -484,9 +508,7 @@
*/
function zoomIn() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 1.3
);
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
}
}
@ -495,15 +517,13 @@ @@ -495,15 +517,13 @@
*/
function zoomOut() {
if (svg && zoomBehavior) {
d3.select(svg).transition().duration(300).call(
zoomBehavior.scaleBy, 0.7
);
d3.select(svg).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
}
}
/**
* Legend interactions
*/
*/
let graphInteracted = $state(false);
function handleGraphClick() {
@ -520,7 +540,10 @@ @@ -520,7 +540,10 @@
<p>{errorMessage}</p>
<button
class="network-error-retry"
onclick={() => { errorMessage = null; updateGraph(); }}
onclick={() => {
errorMessage = null;
updateGraph();
}}
>
Retry
</button>
@ -528,50 +551,82 @@ @@ -528,50 +551,82 @@
{/if}
<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 count={events.length} onupdate={onupdate} />
<!-- Settings Panel (shown when settings button is clicked) -->
<Settings count={events.length} {onupdate} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svg}
class="network-svg"
onclick={handleGraphClick}
/>
<svg bind:this={svg} class="network-svg" onclick={handleGraphClick} />
<!-- Zoom controls -->
<div class="network-controls">
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomIn}
aria-label="Zoom in"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={zoomOut}
aria-label="Zoom out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
</Button>
<Button outline size="lg"
<Button
outline
size="lg"
class="network-control-button btn-leather rounded-lg p-2"
onclick={centerGraph}
aria-label="Center graph"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
@ -588,5 +643,4 @@ @@ -588,5 +643,4 @@
onclose={handleTooltipClose}
/>
{/if}
</div>

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

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

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

@ -27,18 +27,18 @@ function debug(...args: any[]) { @@ -27,18 +27,18 @@ function debug(...args: any[]) {
* Provides type safety for simulation operations
*/
export interface Simulation<NodeType, LinkType> {
nodes(): NodeType[];
nodes(nodes: NodeType[]): this;
alpha(): number;
alpha(alpha: number): this;
alphaTarget(): number;
alphaTarget(target: number): this;
restart(): this;
stop(): this;
tick(): this;
on(type: string, listener: (this: this) => void): this;
force(name: string): any;
force(name: string, force: any): this;
nodes(): NodeType[];
nodes(nodes: NodeType[]): this;
alpha(): number;
alpha(alpha: number): this;
alphaTarget(): number;
alphaTarget(target: number): this;
restart(): this;
stop(): this;
tick(): this;
on(type: string, listener: (this: this) => void): this;
force(name: string): any;
force(name: string, force: any): this;
}
/**
@ -46,14 +46,14 @@ export interface Simulation<NodeType, LinkType> { @@ -46,14 +46,14 @@ export interface Simulation<NodeType, LinkType> {
* Provides type safety for drag operations
*/
export interface D3DragEvent<GElement extends Element, Datum, Subject> {
active: number;
sourceEvent: any;
subject: Subject;
x: number;
y: number;
dx: number;
dy: number;
identifier: string | number;
active: number;
sourceEvent: any;
subject: Subject;
x: number;
y: number;
dx: number;
dy: number;
identifier: string | number;
}
/**
@ -64,25 +64,25 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> { @@ -64,25 +64,25 @@ export interface D3DragEvent<GElement extends Element, Datum, Subject> {
* @param deltaVy - Change in y velocity
*/
export function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number
node: NetworkNode,
deltaVx: number,
deltaVy: number,
) {
debug("Updating node velocity", {
nodeId: node.id,
currentVx: node.vx,
currentVy: node.vy,
deltaVx,
deltaVy
});
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy });
} else {
debug("Node velocity not defined", { nodeId: node.id });
}
debug("Updating node velocity", {
nodeId: node.id,
currentVx: node.vx,
currentVy: node.vy,
deltaVx,
deltaVy,
});
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy });
} else {
debug("Node velocity not defined", { nodeId: node.id });
}
}
/**
@ -97,19 +97,19 @@ export function updateNodeVelocity( @@ -97,19 +97,19 @@ export function updateNodeVelocity(
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyGlobalLogGravity(
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
) {
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
if (distance === 0) return;
const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
@ -123,33 +123,33 @@ export function applyGlobalLogGravity( @@ -123,33 +123,33 @@ export function applyGlobalLogGravity(
* @param alpha - Current simulation alpha (cooling factor)
*/
export function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
// Find all nodes connected to this node
const connectedNodes = links
.filter(link => link.source.id === node.id || link.target.id === node.id)
.map(link => link.source.id === node.id ? link.target : link.source);
// Find all nodes connected to this node
const connectedNodes = links
.filter((link) => link.source.id === node.id || link.target.id === node.id)
.map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return;
if (connectedNodes.length === 0) return;
// Calculate center of gravity of connected nodes
const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x);
const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y);
// Calculate center of gravity of connected nodes
const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x);
const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y);
if (cogX === undefined || cogY === undefined) return;
if (cogX === undefined || cogY === undefined) return;
// Calculate force direction and magnitude
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Calculate force direction and magnitude
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
if (distance === 0) return;
// Apply force proportional to distance
const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
// Apply force proportional to distance
const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
/**
@ -162,34 +162,52 @@ export function applyConnectedGravity( @@ -162,34 +162,52 @@ export function applyConnectedGravity(
* @returns D3 drag behavior configured for the simulation
*/
export function setupDragHandlers(
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9
simulation: Simulation<NetworkNode, NetworkLink>,
warmupClickEnergy: number = 0.9,
) {
return d3
.drag()
.on("start", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
}
// Fix node position at current location
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, d: NetworkNode) => {
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);
}
// Release fixed position
d.fx = null;
d.fy = null;
});
return d3
.drag()
.on(
"start",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Warm up simulation if it's cooled down
if (!event.active) {
simulation.alphaTarget(warmupClickEnergy).restart();
}
// Fix node position at current location
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update fixed position to mouse position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends
if (!event.active) {
simulation.alphaTarget(0);
}
// Release fixed position
d.fx = null;
d.fy = null;
},
);
}
/**
@ -202,34 +220,35 @@ export function setupDragHandlers( @@ -202,34 +220,35 @@ export function setupDragHandlers(
* @returns Configured D3 force simulation
*/
export function createSimulation(
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number
nodes: NetworkNode[],
links: NetworkLink[],
nodeRadius: number,
linkDistance: number,
): Simulation<NetworkNode, NetworkLink> {
debug("Creating simulation", {
nodeCount: nodes.length,
linkCount: links.length,
nodeRadius,
linkDistance
});
try {
// Create the simulation with nodes
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1)
)
.force("collide", d3.forceCollide().radius(nodeRadius * 4));
debug("Simulation created successfully");
return simulation;
} catch (error) {
console.error("Error creating simulation:", error);
throw error;
}
debug("Creating simulation", {
nodeCount: nodes.length,
linkCount: links.length,
nodeRadius,
linkDistance,
});
try {
// Create the simulation with nodes
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
.id((d: NetworkNode) => d.id)
.distance(linkDistance * 0.1),
)
.force("collide", d3.forceCollide().radius(nodeRadius * 4));
debug("Simulation created successfully");
return simulation;
} catch (error) {
console.error("Error creating simulation:", error);
throw error;
}
}

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

@ -9,7 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; @@ -9,7 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { getMatchingTags } from "$lib/utils/nostrUtils";
// Configuration
const DEBUG = false; // Set to true to enable debug logging
@ -36,52 +36,56 @@ function debug(...args: any[]) { @@ -36,52 +36,56 @@ function debug(...args: any[]) {
* @returns A NetworkNode object representing the event
*/
export function createNetworkNode(
event: NDKEvent,
level: number = 0
event: NDKEvent,
level: number = 0,
): NetworkNode {
debug("Creating network node", { eventId: event.id, kind: event.kind, level });
const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content";
// Create the base node with essential properties
const node: NetworkNode = {
debug("Creating network node", {
eventId: event.id,
kind: event.kind,
level,
});
const isContainer = event.kind === INDEX_EVENT_KIND;
const nodeType = isContainer ? "Index" : "Content";
// Create the base node with essential properties
const node: NetworkNode = {
id: event.id,
event,
isContainer,
level,
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "",
author: event.pubkey || "",
kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined
type: nodeType,
};
// Add NIP-19 identifiers if possible
if (event.kind && event.pubkey) {
try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
// Create naddr (NIP-19 address) for the event
node.naddr = nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: standardRelays,
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
event,
isContainer,
level,
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled",
content: event.content || "",
author: event.pubkey || "",
kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined
type: nodeType,
};
// Add NIP-19 identifiers if possible
if (event.kind && event.pubkey) {
try {
const dTag = event.getMatchingTags("d")?.[0]?.[1] || "";
// Create naddr (NIP-19 address) for the event
node.naddr = nip19.naddrEncode({
pubkey: event.pubkey,
identifier: dTag,
kind: event.kind,
relays: standardRelays,
});
// Create nevent (NIP-19 event reference) for the event
node.nevent = nip19.neventEncode({
id: event.id,
relays: standardRelays,
kind: event.kind,
});
} catch (error) {
console.warn("Failed to generate identifiers for node:", error);
}
relays: standardRelays,
kind: event.kind,
});
} catch (error) {
console.warn("Failed to generate identifiers for node:", error);
}
}
return node;
return node;
}
/**
@ -91,17 +95,17 @@ export function createNetworkNode( @@ -91,17 +95,17 @@ export function createNetworkNode(
* @returns Map of event IDs to events
*/
export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
debug("Creating event map", { eventCount: events.length });
debug("Creating event map", { eventCount: events.length });
const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => {
if (event.id) {
eventMap.set(event.id, event);
}
});
const eventMap = new Map<string, NDKEvent>();
events.forEach((event) => {
if (event.id) {
eventMap.set(event.id, event);
}
});
debug("Event map created", { mapSize: eventMap.size });
return eventMap;
debug("Event map created", { mapSize: eventMap.size });
return eventMap;
}
/**
@ -111,7 +115,7 @@ export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> { @@ -111,7 +115,7 @@ export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
* @returns The event ID or null if not found
*/
export function extractEventIdFromATag(tag: string[]): string | null {
return tag[3] || null;
return tag[3] || null;
}
/**
@ -124,14 +128,14 @@ export function extractEventIdFromATag(tag: string[]): string | null { @@ -124,14 +128,14 @@ export function extractEventIdFromATag(tag: string[]): string | null {
* @returns An HSL color string
*/
export function getEventColor(eventId: string): string {
// Use first 4 characters of event ID as a hex number
const num = parseInt(eventId.slice(0, 4), 16);
// Convert to a hue value (0-359)
const hue = num % 360;
// Use fixed saturation and lightness for pastel colors
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Use first 4 characters of event ID as a hex number
const num = parseInt(eventId.slice(0, 4), 16);
// Convert to a hue value (0-359)
const hue = num % 360;
// Use fixed saturation and lightness for pastel colors
const saturation = 70;
const lightness = 75;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
/**
@ -143,41 +147,41 @@ export function getEventColor(eventId: string): string { @@ -143,41 +147,41 @@ export function getEventColor(eventId: string): string {
* @returns Initial graph state
*/
export function initializeGraphState(events: NDKEvent[]): GraphState {
debug("Initializing graph state", { eventCount: events.length });
const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create initial nodes for all events
events.forEach((event) => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
debug("Initializing graph state", { eventCount: events.length });
const nodeMap = new Map<string, NetworkNode>();
const eventMap = createEventMap(events);
// Create initial nodes for all events
events.forEach((event) => {
if (!event.id) return;
const node = createNetworkNode(event);
nodeMap.set(event.id, node);
});
debug("Node map created", { nodeCount: nodeMap.size });
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length,
});
debug("Node map created", { nodeCount: nodeMap.size });
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length
});
aTags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
aTags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
debug("Referenced IDs set created", { referencedCount: referencedIds.size });
return {
nodeMap,
links: [],
eventMap,
referencedIds,
};
});
debug("Referenced IDs set created", { referencedCount: referencedIds.size });
return {
nodeMap,
links: [],
eventMap,
referencedIds,
};
}
/**
@ -193,52 +197,52 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -193,52 +197,52 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
* @param maxLevel - Maximum hierarchy level to process
*/
export function processSequence(
sequence: NetworkNode[],
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
sequence: NetworkNode[],
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
// Stop if we've reached max level or have no nodes
if (level >= maxLevel || sequence.length === 0) return;
// Set levels for all nodes in the sequence
sequence.forEach((node) => {
node.level = level + 1;
// Stop if we've reached max level or have no nodes
if (level >= maxLevel || sequence.length === 0) return;
// Set levels for all nodes in the sequence
sequence.forEach((node) => {
node.level = level + 1;
});
// Create link from index to first content node
const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) {
state.links.push({
source: indexNode,
target: sequence[0],
isSequential: true,
});
}
// Create link from index to first content node
const indexNode = state.nodeMap.get(indexEvent.id);
if (indexNode && sequence[0]) {
state.links.push({
source: indexNode,
target: sequence[0],
isSequential: true,
});
}
// Create sequential links between content nodes
for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i];
const nextNode = sequence[i + 1];
// Create sequential links between content nodes
for (let i = 0; i < sequence.length - 1; i++) {
const currentNode = sequence[i];
const nextNode = sequence[i + 1];
state.links.push({
source: currentNode,
target: nextNode,
isSequential: true,
});
// Process nested indices recursively
if (currentNode.isContainer) {
processNestedIndex(currentNode, level + 1, state, maxLevel);
}
}
state.links.push({
source: currentNode,
target: nextNode,
isSequential: true,
});
// Process the last node if it's an index
const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel);
// Process nested indices recursively
if (currentNode.isContainer) {
processNestedIndex(currentNode, level + 1, state, maxLevel);
}
}
// Process the last node if it's an index
const lastNode = sequence[sequence.length - 1];
if (lastNode?.isContainer) {
processNestedIndex(lastNode, level + 1, state, maxLevel);
}
}
/**
@ -250,17 +254,17 @@ export function processSequence( @@ -250,17 +254,17 @@ export function processSequence(
* @param maxLevel - Maximum hierarchy level to process
*/
export function processNestedIndex(
node: NetworkNode,
level: number,
state: GraphState,
maxLevel: number,
node: NetworkNode,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (!node.isContainer || level >= maxLevel) return;
if (!node.isContainer || level >= maxLevel) return;
const nestedEvent = state.eventMap.get(node.id);
if (nestedEvent) {
processIndexEvent(nestedEvent, level, state, maxLevel);
}
const nestedEvent = state.eventMap.get(node.id);
if (nestedEvent) {
processIndexEvent(nestedEvent, level, state, maxLevel);
}
}
/**
@ -272,21 +276,21 @@ export function processNestedIndex( @@ -272,21 +276,21 @@ export function processNestedIndex(
* @param maxLevel - Maximum hierarchy level to process
*/
export function processIndexEvent(
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
indexEvent: NDKEvent,
level: number,
state: GraphState,
maxLevel: number,
): void {
if (level >= maxLevel) return;
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
.filter((node): node is NetworkNode => node !== undefined);
// Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
.filter((node): node is NetworkNode => node !== undefined);
processSequence(sequence, indexEvent, level, state, maxLevel);
processSequence(sequence, indexEvent, level, state, maxLevel);
}
/**
@ -298,44 +302,42 @@ export function processIndexEvent( @@ -298,44 +302,42 @@ export function processIndexEvent(
* @param maxLevel - Maximum hierarchy level to process
* @returns Complete graph data for visualization
*/
export function generateGraph(
events: NDKEvent[],
maxLevel: number
): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state
const state = initializeGraphState(events);
// Find root index events (those not referenced by other events)
const rootIndices = events.filter(
(e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id)
);
debug("Found root indices", {
rootCount: rootIndices.length,
rootIds: rootIndices.map(e => e.id)
export function generateGraph(events: NDKEvent[], maxLevel: number): GraphData {
debug("Generating graph", { eventCount: events.length, maxLevel });
// Initialize the graph state
const state = initializeGraphState(events);
// Find root index events (those not referenced by other events)
const rootIndices = events.filter(
(e) =>
e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id),
);
debug("Found root indices", {
rootCount: rootIndices.length,
rootIds: rootIndices.map((e) => e.id),
});
// Process each root index
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: getMatchingTags(rootIndex, "a").length,
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});
// Process each root index
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: getMatchingTags(rootIndex, "a").length
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});
// Create the final graph data
const result = {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
// Create the final graph data
const result = {
nodes: Array.from(state.nodeMap.values()),
links: state.links,
};
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length
});
debug("Graph generation complete", {
nodeCount: result.nodes.length,
linkCount: result.links.length,
});
return result;
return result;
}

249
src/lib/ndk.ts

@ -1,7 +1,20 @@ @@ -1,7 +1,20 @@
import NDK, { NDKNip07Signer, NDKRelay, 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';
import NDK, {
NDKNip07Signer,
NDKRelay,
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();
@ -31,7 +44,9 @@ class CustomRelayAuthPolicy { @@ -31,7 +44,9 @@ class CustomRelayAuthPolicy {
*/
async authenticate(relay: NDKRelay): Promise<void> {
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;
}
@ -39,42 +54,53 @@ class CustomRelayAuthPolicy { @@ -39,42 +54,53 @@ class CustomRelayAuthPolicy {
console.debug(`[NDK.ts] Setting up authentication for ${relay.url}`);
// Listen for AUTH challenges
relay.on('auth', (challenge: string) => {
console.debug(`[NDK.ts] Received AUTH challenge from ${relay.url}:`, challenge);
relay.on("auth", (challenge: string) => {
console.debug(
`[NDK.ts] Received AUTH challenge from ${relay.url}:`,
challenge,
);
this.challenges.set(relay.url, challenge);
this.handleAuthChallenge(relay, challenge);
});
// Listen for auth-required errors (handle via notice events)
relay.on('notice', (message: string) => {
if (message.includes('auth-required')) {
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
console.debug(`[NDK.ts] Auth required from ${relay.url}:`, message);
this.handleAuthRequired(relay, message);
}
});
// Listen for successful authentication
relay.on('authed', () => {
relay.on("authed", () => {
console.debug(`[NDK.ts] Successfully authenticated to ${relay.url}`);
});
// Listen for authentication failures
relay.on('auth:failed', (error: any) => {
console.error(`[NDK.ts] Authentication failed for ${relay.url}:`, error);
relay.on("auth:failed", (error: any) => {
console.error(
`[NDK.ts] Authentication failed for ${relay.url}:`,
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
*/
private async handleAuthChallenge(relay: NDKRelay, challenge: string): Promise<void> {
private async handleAuthChallenge(
relay: NDKRelay,
challenge: string,
): Promise<void> {
try {
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;
}
@ -83,11 +109,11 @@ class CustomRelayAuthPolicy { @@ -83,11 +109,11 @@ class CustomRelayAuthPolicy {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
["relay", relay.url],
["challenge", challenge],
],
content: '',
pubkey: this.ndk.activeUser.pubkey
content: "",
pubkey: this.ndk.activeUser.pubkey,
};
// Create and sign the authentication event using NDKEvent
@ -97,21 +123,28 @@ class CustomRelayAuthPolicy { @@ -97,21 +123,28 @@ class CustomRelayAuthPolicy {
// Send AUTH message to relay using the relay's publish method
await relay.publish(authNDKEvent);
console.debug(`[NDK.ts] Sent AUTH to ${relay.url}`);
} 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
*/
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);
if (challenge) {
await this.handleAuthChallenge(relay, challenge);
} 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 { @@ -120,26 +153,33 @@ class CustomRelayAuthPolicy {
* Checks if the current environment might cause WebSocket protocol downgrade
*/
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' ||
window.location.hostname === '127.0.0.1';
const isHttp = window.location.protocol === 'http:';
const isHttps = window.location.protocol === 'https:';
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
const isHttp = window.location.protocol === "http:";
const isHttps = window.location.protocol === "https:";
console.debug('[NDK.ts] - Is localhost:', isLocalhost);
console.debug('[NDK.ts] - Protocol:', window.location.protocol);
console.debug('[NDK.ts] - Is HTTP:', isHttp);
console.debug('[NDK.ts] - Is HTTPS:', isHttps);
console.debug("[NDK.ts] - Is localhost:", isLocalhost);
console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug("[NDK.ts] - Is HTTP:", isHttp);
console.debug("[NDK.ts] - Is HTTPS:", isHttps);
if (isLocalhost && isHttp) {
console.warn('[NDK.ts] ⚠ Running on localhost with HTTP - WebSocket downgrade to ws:// is expected');
console.warn('[NDK.ts] This is normal for development environments');
console.warn(
"[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) {
console.error('[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure');
console.error('[NDK.ts] Consider using HTTPS in production');
console.error(
"[NDK.ts] ❌ Running on HTTP - WebSocket connections will be insecure",
);
console.error("[NDK.ts] Consider using HTTPS in production");
} 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 { @@ -147,24 +187,24 @@ export function checkEnvironmentForWebSocketDowngrade(): void {
* Checks WebSocket protocol support and logs diagnostic information
*/
export function checkWebSocketSupport(): void {
console.debug('[NDK.ts] WebSocket Support Diagnostics:');
console.debug('[NDK.ts] - Protocol:', window.location.protocol);
console.debug('[NDK.ts] - Hostname:', window.location.hostname);
console.debug('[NDK.ts] - Port:', window.location.port);
console.debug('[NDK.ts] - User Agent:', navigator.userAgent);
console.debug("[NDK.ts] WebSocket Support Diagnostics:");
console.debug("[NDK.ts] - Protocol:", window.location.protocol);
console.debug("[NDK.ts] - Hostname:", window.location.hostname);
console.debug("[NDK.ts] - Port:", window.location.port);
console.debug("[NDK.ts] - User Agent:", navigator.userAgent);
// Test if secure WebSocket is supported
try {
const testWs = new WebSocket('wss://echo.websocket.org');
const testWs = new WebSocket("wss://echo.websocket.org");
testWs.onopen = () => {
console.debug('[NDK.ts] ✓ Secure WebSocket (wss://) is supported');
console.debug("[NDK.ts] ✓ Secure WebSocket (wss://) is supported");
testWs.close();
};
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) {
console.warn('[NDK.ts] ✗ WebSocket test failed:', error);
console.warn("[NDK.ts] ✗ WebSocket test failed:", error);
}
}
@ -174,7 +214,10 @@ export function checkWebSocketSupport(): void { @@ -174,7 +214,10 @@ export function checkWebSocketSupport(): void {
* @param ndk The NDK instance
* @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;
requiresAuth: boolean;
error?: string;
@ -197,12 +240,12 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{ @@ -197,12 +240,12 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
resolve({
connected: false,
requiresAuth: authRequired,
error: 'Connection timeout',
actualUrl
error: "Connection timeout",
actualUrl,
});
}, 5000);
relay.on('connect', () => {
relay.on("connect", () => {
console.debug(`[NDK.ts] Connected to ${secureUrl}`);
connected = true;
actualUrl = secureUrl;
@ -212,27 +255,27 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{ @@ -212,27 +255,27 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{
connected: true,
requiresAuth: authRequired,
error,
actualUrl
actualUrl,
});
});
relay.on('notice', (message: string) => {
if (message.includes('auth-required')) {
relay.on("notice", (message: string) => {
if (message.includes("auth-required")) {
authRequired = true;
console.debug(`[NDK.ts] ${secureUrl} requires authentication`);
}
});
relay.on('disconnect', () => {
relay.on("disconnect", () => {
if (!connected) {
error = 'Connection failed';
error = "Connection failed";
console.error(`[NDK.ts] Failed to connect to ${secureUrl}`);
clearTimeout(timeout);
resolve({
connected: false,
requiresAuth: authRequired,
error,
actualUrl
actualUrl,
});
}
});
@ -278,7 +321,7 @@ export function clearLogin(): void { @@ -278,7 +321,7 @@ export function clearLogin(): void {
* @param type The type of relay list to designate.
* @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}`;
}
@ -288,14 +331,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { @@ -288,14 +331,18 @@ function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string {
* @param inboxes The user's inbox 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(
getRelayStorageKey(user, 'inbox'),
JSON.stringify(Array.from(inboxes).map(relay => relay.url))
getRelayStorageKey(user, "inbox"),
JSON.stringify(Array.from(inboxes).map((relay) => relay.url)),
);
localStorage.setItem(
getRelayStorageKey(user, 'outbox'),
JSON.stringify(Array.from(outboxes).map(relay => relay.url))
getRelayStorageKey(user, "outbox"),
JSON.stringify(Array.from(outboxes).map((relay) => relay.url)),
);
}
@ -307,18 +354,20 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR @@ -307,18 +354,20 @@ function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKR
*/
function getPersistedRelays(user: NDKUser): [Set<string>, 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>(
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]')
JSON.parse(
localStorage.getItem(getRelayStorageKey(user, "outbox")) ?? "[]",
),
);
return [inboxes, outboxes];
}
export function clearPersistedRelays(user: NDKUser): void {
localStorage.removeItem(getRelayStorageKey(user, 'inbox'));
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
localStorage.removeItem(getRelayStorageKey(user, "inbox"));
localStorage.removeItem(getRelayStorageKey(user, "outbox"));
}
/**
@ -328,10 +377,12 @@ export function clearPersistedRelays(user: NDKUser): void { @@ -328,10 +377,12 @@ export function clearPersistedRelays(user: NDKUser): void {
*/
function ensureSecureWebSocket(url: string): string {
// Replace ws:// with wss:// if present
const secureUrl = url.replace(/^ws:\/\//, 'wss://');
const secureUrl = url.replace(/^ws:\/\//, "wss://");
if (secureUrl !== url) {
console.warn(`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`);
console.warn(
`[NDK.ts] Protocol downgrade detected: ${url} -> ${secureUrl}`,
);
}
return secureUrl;
@ -346,12 +397,16 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -346,12 +397,16 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
// Ensure the URL is using wss:// protocol
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
if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on('connect', () => {
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
authPolicy.authenticate(relay);
});
@ -367,12 +422,14 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -367,12 +422,14 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
return get(feedType) === FeedType.UserRelays
? new NDKRelaySet(
new Set(get(inboxRelays).map(relay => createRelayWithAuth(relay, ndk))),
ndk
new Set(
get(inboxRelays).map((relay) => createRelayWithAuth(relay, ndk)),
),
ndk,
)
: new NDKRelaySet(
new Set(relays.map(relay => createRelayWithAuth(relay, ndk))),
ndk
new Set(relays.map((relay) => createRelayWithAuth(relay, ndk))),
ndk,
);
}
@ -383,16 +440,19 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -383,16 +440,19 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
*/
export function initNdk(): NDK {
const startingPubkey = getPersistedLogin();
const [startingInboxes, _] = startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
const [startingInboxes, _] =
startingPubkey != null
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey }))
: [null, null];
// Ensure all relay URLs use secure WebSocket protocol
const secureRelayUrls = (startingInboxes != null
? Array.from(startingInboxes.values())
: anonymousRelays).map(ensureSecureWebSocket);
const secureRelayUrls = (
startingInboxes != null
? Array.from(startingInboxes.values())
: 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({
autoConnectUserRelays: true,
@ -413,7 +473,9 @@ export function initNdk(): NDK { @@ -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
* 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 {
const ndk = get(ndkInstance);
const signer = new NDKNip07Signer();
@ -421,12 +483,13 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul @@ -421,12 +483,13 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
// TODO: Handle changing pubkeys.
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);
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser);
const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
for (const relay of persistedInboxes) {
ndk.addExplicitRelay(relay);
}
@ -434,8 +497,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul @@ -434,8 +497,12 @@ export async function loginWithExtension(pubkey?: string): Promise<NDKUser | nul
const user = ndk.getUser({ pubkey: signerUser.pubkey });
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user);
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url));
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url));
inboxRelays.set(
Array.from(inboxes ?? persistedInboxes).map((relay) => relay.url),
);
outboxRelays.set(
Array.from(outboxes ?? persistedOutboxes).map((relay) => relay.url),
);
persistRelays(signerUser, inboxes, outboxes);
@ -471,7 +538,7 @@ export function logout(user: NDKUser): void { @@ -471,7 +538,7 @@ export function logout(user: NDKUser): void {
async function getUserPreferredRelays(
ndk: NDK,
user: NDKUser,
fallbacks: readonly string[] = fallbackRelays
fallbacks: readonly string[] = fallbackRelays,
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> {
const relayList = await ndk.fetchEvent(
{
@ -497,12 +564,12 @@ async function getUserPreferredRelays( @@ -497,12 +564,12 @@ async function getUserPreferredRelays(
if (relayType.write) outboxRelays.add(relay);
});
} else {
relayList.tags.forEach(tag => {
relayList.tags.forEach((tag) => {
switch (tag[0]) {
case 'r':
case "r":
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
case 'w':
case "w":
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
default:

554
src/lib/parser.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk';
import asciidoctor from 'asciidoctor';
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import asciidoctor from "asciidoctor";
import type {
AbstractBlock,
AbstractNode,
@ -9,11 +9,11 @@ import type { @@ -9,11 +9,11 @@ import type {
Extensions,
Section,
ProcessorOptions,
} from 'asciidoctor';
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts';
import { getMatchingTags } from '$lib/utils/nostrUtils';
} from "asciidoctor";
import he from "he";
import { writable, type Writable } from "svelte/store";
import { zettelKinds } from "./consts.ts";
import { getMatchingTags } from "$lib/utils/nostrUtils";
interface IndexMetadata {
authors?: string[];
@ -28,12 +28,12 @@ interface IndexMetadata { @@ -28,12 +28,12 @@ interface IndexMetadata {
export enum SiblingSearchDirection {
Previous,
Next
Next,
}
export enum InsertLocation {
Before,
After
After,
}
/**
@ -112,7 +112,10 @@ export default class Pharos { @@ -112,7 +112,10 @@ export default class Pharos {
/**
* 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.
@ -160,34 +163,37 @@ export default class Pharos { @@ -160,34 +163,37 @@ export default class Pharos {
*/
private async loadAdvancedExtensions(): Promise<void> {
try {
const { createAdvancedExtensions } = await import('./utils/markup/asciidoctorExtensions');
const { createAdvancedExtensions } = await import(
"./utils/markup/asciidoctorExtensions"
);
const advancedExtensions = createAdvancedExtensions();
// Note: Extensions merging might not be available in this version
// We'll handle this in the parse method instead
} catch (error) {
console.warn('Advanced extensions not available:', error);
console.warn("Advanced extensions not available:", error);
}
}
parse(content: string, options?: ProcessorOptions | undefined): void {
// Ensure the content is valid AsciiDoc and has a header and the doctype book
content = ensureAsciiDocHeader(content);
try {
const mergedAttributes = Object.assign(
{},
options && typeof options.attributes === 'object' ? options.attributes : {},
{ 'source-highlighter': 'highlightjs' }
options && typeof options.attributes === "object"
? options.attributes
: {},
{ "source-highlighter": "highlightjs" },
);
this.html = this.asciidoctor.convert(content, {
...options,
'extension_registry': this.pharosExtensions,
extension_registry: this.pharosExtensions,
attributes: mergedAttributes,
}) as string | Document | undefined;
} catch (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 { @@ -199,10 +205,10 @@ export default class Pharos {
async fetch(event: NDKEvent | string): Promise<void> {
let content: string;
if (typeof event === 'string') {
if (typeof event === "string") {
const index = await this.ndk.fetchEvent({ ids: [event] });
if (!index) {
throw new Error('Failed to fetch publication.');
throw new Error("Failed to fetch publication.");
}
content = await this.getPublicationContent(index);
@ -252,7 +258,7 @@ export default class Pharos { @@ -252,7 +258,7 @@ export default class Pharos {
* @returns The HTML content of the converted document.
*/
getHtml(): string {
return this.html?.toString() || '';
return this.html?.toString() || "";
}
/**
@ -260,7 +266,7 @@ export default class Pharos { @@ -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.
*/
getRootIndexId(): string {
return this.normalizeId(this.rootNodeId) ?? '';
return this.normalizeId(this.rootNodeId) ?? "";
}
/**
@ -268,7 +274,7 @@ export default class Pharos { @@ -268,7 +274,7 @@ export default class Pharos {
*/
getIndexTitle(id: string): string | undefined {
const section = this.nodes.get(id) as Section;
const title = section.getTitle() ?? '';
const title = section.getTitle() ?? "";
return he.decode(title);
}
@ -276,16 +282,18 @@ export default class Pharos { @@ -276,16 +282,18 @@ export default class Pharos {
* @returns The IDs of any child indices of the index with the given ID.
*/
getChildIndexIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? [])
.filter(id => this.eventToKindMap.get(id) === 30040);
return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
(id) => this.eventToKindMap.get(id) === 30040,
);
}
/**
* @returns The IDs of any child zettels of the index with the given ID.
*/
getChildZettelIds(id: string): string[] {
return Array.from(this.indexToChildEventsMap.get(id) ?? [])
.filter(id => this.eventToKindMap.get(id) !== 30040);
return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter(
(id) => this.eventToKindMap.get(id) !== 30040,
);
}
/**
@ -307,8 +315,8 @@ export default class Pharos { @@ -307,8 +315,8 @@ export default class Pharos {
const block = this.nodes.get(normalizedId!) as AbstractBlock;
switch (block.getContext()) {
case 'paragraph':
return block.getContent() ?? '';
case "paragraph":
return block.getContent() ?? "";
}
return block.convert();
@ -326,7 +334,7 @@ export default class Pharos { @@ -326,7 +334,7 @@ export default class Pharos {
}
const context = this.eventToContextMap.get(normalizedId);
return context === 'floating_title';
return context === "floating_title";
}
/**
@ -361,7 +369,7 @@ export default class Pharos { @@ -361,7 +369,7 @@ export default class Pharos {
getNearestSibling(
targetDTag: string,
depth: number,
direction: SiblingSearchDirection
direction: SiblingSearchDirection,
): [string | null, string | null] {
const eventsAtLevel = this.eventsByLevelMap.get(depth);
if (!eventsAtLevel) {
@ -371,13 +379,17 @@ export default class Pharos { @@ -371,13 +379,17 @@ export default class Pharos {
const targetIndex = eventsAtLevel.indexOf(targetDTag);
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);
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);
@ -395,7 +407,10 @@ export default class Pharos { @@ -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,
// 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.
if (!grandparentDTag) {
return [null, null];
@ -406,10 +421,10 @@ export default class Pharos { @@ -406,10 +421,10 @@ export default class Pharos {
// * Base case: There is an adjacent sibling at the same depth as the target.
switch (direction) {
case SiblingSearchDirection.Previous:
return [eventsAtLevel[targetIndex - 1], parentDTag];
case SiblingSearchDirection.Next:
return [eventsAtLevel[targetIndex + 1], parentDTag];
case SiblingSearchDirection.Previous:
return [eventsAtLevel[targetIndex - 1], parentDTag];
case SiblingSearchDirection.Next:
return [eventsAtLevel[targetIndex + 1], parentDTag];
}
return [null, null];
@ -424,7 +439,9 @@ export default class Pharos { @@ -424,7 +439,9 @@ export default class Pharos {
getParent(dTag: string): string | null {
// Check if the event exists in the parser tree.
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.
@ -449,7 +466,11 @@ export default class Pharos { @@ -449,7 +466,11 @@ export default class Pharos {
* @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()`.
*/
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 destinationEvent = this.events.get(destinationDTag);
const targetParent = this.getParent(targetDTag);
@ -464,11 +485,15 @@ export default class Pharos { @@ -464,11 +485,15 @@ export default class Pharos {
}
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) {
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.
@ -478,16 +503,22 @@ export default class Pharos { @@ -478,16 +503,22 @@ export default class Pharos {
this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag);
// Get the index of the destination event among the children of its parent.
const destinationIndex = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? [])
.indexOf(destinationDTag);
const destinationIndex = Array.from(
this.indexToChildEventsMap.get(destinationParent) ?? [],
).indexOf(destinationDTag);
// Insert next to the index of the destination event, either before or after as specified by
// the insertAfter flag.
const destinationChildren = Array.from(this.indexToChildEventsMap.get(destinationParent) ?? []);
const destinationChildren = Array.from(
this.indexToChildEventsMap.get(destinationParent) ?? [],
);
insertAfter
? destinationChildren.splice(destinationIndex + 1, 0, targetDTag)
: destinationChildren.splice(destinationIndex, 0, targetDTag);
this.indexToChildEventsMap.set(destinationParent, new Set(destinationChildren));
this.indexToChildEventsMap.set(
destinationParent,
new Set(destinationChildren),
);
this.shouldUpdateEventTree = true;
}
@ -517,7 +548,10 @@ export default class Pharos { @@ -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 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);
document.setId(this.rootNodeId);
this.nodes.set(this.rootNodeId, document);
@ -533,7 +567,7 @@ export default class Pharos { @@ -533,7 +567,7 @@ export default class Pharos {
continue;
}
if (block.getContext() === 'section') {
if (block.getContext() === "section") {
const children = this.processSection(block as Section);
nodeQueue.push(...children);
} else {
@ -563,7 +597,7 @@ export default class Pharos { @@ -563,7 +597,7 @@ export default class Pharos {
}
this.nodes.set(sectionId, section);
this.eventToKindMap.set(sectionId, 30040); // Sections are indexToChildEventsMap by default.
this.eventToKindMap.set(sectionId, 30040); // Sections are indexToChildEventsMap by default.
this.indexToChildEventsMap.set(sectionId, new Set<string>());
const parentId = this.normalizeId(section.getParent()?.getId());
@ -591,7 +625,7 @@ export default class Pharos { @@ -591,7 +625,7 @@ export default class Pharos {
// Obtain or generate a unique ID for the block.
let blockId = this.normalizeId(block.getId());
if (!blockId) {
blockId = this.generateNodeId(block) ;
blockId = this.generateNodeId(block);
block.setId(blockId);
}
@ -601,7 +635,7 @@ export default class Pharos { @@ -601,7 +635,7 @@ export default class Pharos {
}
this.nodes.set(blockId, block);
this.eventToKindMap.set(blockId, 30041); // Blocks are zettels by default.
this.eventToKindMap.set(blockId, 30041); // Blocks are zettels by default.
const parentId = this.normalizeId(block.getParent()?.getId());
if (!parentId) {
@ -648,21 +682,24 @@ export default class Pharos { @@ -648,21 +682,24 @@ export default class Pharos {
* @remarks This function does a depth-first crawl of the event tree using the relays specified
* on the NDK instance.
*/
private async getPublicationContent(event: NDKEvent, depth: number = 0): Promise<string> {
let content: string = '';
private async getPublicationContent(
event: NDKEvent,
depth: number = 0,
): Promise<string> {
let content: string = "";
// Format title into AsciiDoc header.
const title = getMatchingTags(event, 'title')[0][1];
let titleLevel = '';
const title = getMatchingTags(event, "title")[0][1];
let titleLevel = "";
for (let i = 0; i <= depth; i++) {
titleLevel += '=';
titleLevel += "=";
}
content += `${titleLevel} ${title}\n\n`;
// 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) {
tags = getMatchingTags(event, 'e');
tags = getMatchingTags(event, "e");
}
// Base case: The event is a zettel.
@ -673,24 +710,29 @@ export default class Pharos { @@ -673,24 +710,29 @@ export default class Pharos {
// Recursive case: The event is an index.
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 (getMatchingTags(event, 'type').length > 0 && getMatchingTags(event, 'type')[0][1] === 'blog') {
childEvents.forEach(child => {
if (
getMatchingTags(event, "type").length > 0 &&
getMatchingTags(event, "type")[0][1] === "blog"
) {
childEvents.forEach((child) => {
if (child) {
this.blogEntries.set(getMatchingTags(child, 'd')?.[0]?.[1], child);
this.blogEntries.set(getMatchingTags(child, "d")?.[0]?.[1], child);
}
})
});
}
// populate metadata
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) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, 'image')[0][1];
if (getMatchingTags(event, "image").length > 0) {
this.rootIndexMetadata.coverImage = getMatchingTags(event, "image")[0][1];
}
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
@ -705,11 +747,13 @@ export default class Pharos { @@ -705,11 +747,13 @@ export default class Pharos {
continue;
}
childContentPromises.push(this.getPublicationContent(childEvent, depth + 1));
childContentPromises.push(
this.getPublicationContent(childEvent, depth + 1),
);
}
const childContents = await Promise.all(childContentPromises);
content += childContents.join('\n\n');
content += childContents.join("\n\n");
return content;
}
@ -756,15 +800,15 @@ export default class Pharos { @@ -756,15 +800,15 @@ export default class Pharos {
const nodeId = nodeIdStack.pop();
switch (this.eventToKindMap.get(nodeId!)) {
case 30040:
events.push(this.generateIndexEvent(nodeId!, pubkey));
break;
case 30041:
default:
// Kind 30041 (zettel) is currently the default kind for contentful events.
events.push(this.generateZettelEvent(nodeId!, pubkey));
break;
case 30040:
events.push(this.generateIndexEvent(nodeId!, pubkey));
break;
case 30041:
default:
// Kind 30041 (zettel) is currently the default kind for contentful events.
events.push(this.generateZettelEvent(nodeId!, pubkey));
break;
}
}
@ -783,17 +827,14 @@ export default class Pharos { @@ -783,17 +827,14 @@ export default class Pharos {
private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent {
const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle();
// TODO: Use a tags as per NIP-62.
const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!)
.map(id => ['#e', this.eventIds.get(id)!]);
const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!).map(
(id) => ["#e", this.eventIds.get(id)!],
);
const event = new NDKEvent(this.ndk);
event.kind = 30040;
event.content = '';
event.tags = [
['title', title!],
['#d', nodeId],
...childTags
];
event.content = "";
event.tags = [["title", title!], ["#d", nodeId], ...childTags];
event.created_at = Date.now();
event.pubkey = pubkey;
@ -805,7 +846,7 @@ export default class Pharos { @@ -805,7 +846,7 @@ export default class Pharos {
this.rootIndexMetadata = {
authors: document
.getAuthors()
.map(author => author.getName())
.map((author) => author.getName())
.filter((name): name is string => name != null),
version: document.getRevisionNumber(),
edition: document.getRevisionRemark(),
@ -813,11 +854,11 @@ export default class Pharos { @@ -813,11 +854,11 @@ export default class Pharos {
};
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) {
const versionTags: string[] = ['version'];
const versionTags: string[] = ["version"];
if (this.rootIndexMetadata.version) {
versionTags.push(this.rootIndexMetadata.version);
}
@ -828,7 +869,10 @@ export default class Pharos { @@ -828,7 +869,10 @@ export default class Pharos {
}
if (this.rootIndexMetadata.publicationDate) {
event.tags.push(['published_on', this.rootIndexMetadata.publicationDate!]);
event.tags.push([
"published_on",
this.rootIndexMetadata.publicationDate!,
]);
}
}
@ -852,14 +896,14 @@ export default class Pharos { @@ -852,14 +896,14 @@ export default class Pharos {
*/
private generateZettelEvent(nodeId: string, pubkey: string): NDKEvent {
const title = (this.nodes.get(nodeId)! as Block).getTitle();
const content = (this.nodes.get(nodeId)! as Block).getSource(); // AsciiDoc source content.
const content = (this.nodes.get(nodeId)! as Block).getSource(); // AsciiDoc source content.
const event = new NDKEvent(this.ndk);
event.kind = 30041;
event.content = content!;
event.tags = [
['title', title!],
['#d', nodeId],
["title", title!],
["#d", nodeId],
...this.extractAndNormalizeWikilinks(content!),
];
event.created_at = Date.now();
@ -902,173 +946,173 @@ export default class Pharos { @@ -902,173 +946,173 @@ export default class Pharos {
const context = block.getContext();
switch (context) {
case 'admonition':
blockNumber = this.contextCounters.get('admonition') ?? 0;
blockId = `${documentId}-admonition-${blockNumber++}`;
this.contextCounters.set('admonition', blockNumber);
break;
case "admonition":
blockNumber = this.contextCounters.get("admonition") ?? 0;
blockId = `${documentId}-admonition-${blockNumber++}`;
this.contextCounters.set("admonition", blockNumber);
break;
case 'audio':
blockNumber = this.contextCounters.get('audio') ?? 0;
blockId = `${documentId}-audio-${blockNumber++}`;
this.contextCounters.set('audio', blockNumber);
break;
case "audio":
blockNumber = this.contextCounters.get("audio") ?? 0;
blockId = `${documentId}-audio-${blockNumber++}`;
this.contextCounters.set("audio", blockNumber);
break;
case 'colist':
blockNumber = this.contextCounters.get('colist') ?? 0;
blockId = `${documentId}-colist-${blockNumber++}`;
this.contextCounters.set('colist', blockNumber);
break;
case "colist":
blockNumber = this.contextCounters.get("colist") ?? 0;
blockId = `${documentId}-colist-${blockNumber++}`;
this.contextCounters.set("colist", blockNumber);
break;
case 'dlist':
blockNumber = this.contextCounters.get('dlist') ?? 0;
blockId = `${documentId}-dlist-${blockNumber++}`;
this.contextCounters.set('dlist', blockNumber);
break;
case "dlist":
blockNumber = this.contextCounters.get("dlist") ?? 0;
blockId = `${documentId}-dlist-${blockNumber++}`;
this.contextCounters.set("dlist", blockNumber);
break;
case 'document':
blockNumber = this.contextCounters.get('document') ?? 0;
blockId = `${documentId}-document-${blockNumber++}`;
this.contextCounters.set('document', blockNumber);
break;
case "document":
blockNumber = this.contextCounters.get("document") ?? 0;
blockId = `${documentId}-document-${blockNumber++}`;
this.contextCounters.set("document", blockNumber);
break;
case 'example':
blockNumber = this.contextCounters.get('example') ?? 0;
blockId = `${documentId}-example-${blockNumber++}`;
this.contextCounters.set('example', blockNumber);
break;
case "example":
blockNumber = this.contextCounters.get("example") ?? 0;
blockId = `${documentId}-example-${blockNumber++}`;
this.contextCounters.set("example", blockNumber);
break;
case 'floating_title':
blockNumber = this.contextCounters.get('floating_title') ?? 0;
blockId = `${documentId}-floating-title-${blockNumber++}`;
this.contextCounters.set('floating_title', blockNumber);
break;
case "floating_title":
blockNumber = this.contextCounters.get("floating_title") ?? 0;
blockId = `${documentId}-floating-title-${blockNumber++}`;
this.contextCounters.set("floating_title", blockNumber);
break;
case 'image':
blockNumber = this.contextCounters.get('image') ?? 0;
blockId = `${documentId}-image-${blockNumber++}`;
this.contextCounters.set('image', blockNumber);
break;
case "image":
blockNumber = this.contextCounters.get("image") ?? 0;
blockId = `${documentId}-image-${blockNumber++}`;
this.contextCounters.set("image", blockNumber);
break;
case 'list_item':
blockNumber = this.contextCounters.get('list_item') ?? 0;
blockId = `${documentId}-list-item-${blockNumber++}`;
this.contextCounters.set('list_item', blockNumber);
break;
case "list_item":
blockNumber = this.contextCounters.get("list_item") ?? 0;
blockId = `${documentId}-list-item-${blockNumber++}`;
this.contextCounters.set("list_item", blockNumber);
break;
case 'listing':
blockNumber = this.contextCounters.get('listing') ?? 0;
blockId = `${documentId}-listing-${blockNumber++}`;
this.contextCounters.set('listing', blockNumber);
break;
case "listing":
blockNumber = this.contextCounters.get("listing") ?? 0;
blockId = `${documentId}-listing-${blockNumber++}`;
this.contextCounters.set("listing", blockNumber);
break;
case 'literal':
blockNumber = this.contextCounters.get('literal') ?? 0;
blockId = `${documentId}-literal-${blockNumber++}`;
this.contextCounters.set('literal', blockNumber);
break;
case "literal":
blockNumber = this.contextCounters.get("literal") ?? 0;
blockId = `${documentId}-literal-${blockNumber++}`;
this.contextCounters.set("literal", blockNumber);
break;
case 'olist':
blockNumber = this.contextCounters.get('olist') ?? 0;
blockId = `${documentId}-olist-${blockNumber++}`;
this.contextCounters.set('olist', blockNumber);
break;
case "olist":
blockNumber = this.contextCounters.get("olist") ?? 0;
blockId = `${documentId}-olist-${blockNumber++}`;
this.contextCounters.set("olist", blockNumber);
break;
case 'open':
blockNumber = this.contextCounters.get('open') ?? 0;
blockId = `${documentId}-open-${blockNumber++}`;
this.contextCounters.set('open', blockNumber);
break;
case "open":
blockNumber = this.contextCounters.get("open") ?? 0;
blockId = `${documentId}-open-${blockNumber++}`;
this.contextCounters.set("open", blockNumber);
break;
case 'page_break':
blockNumber = this.contextCounters.get('page_break') ?? 0;
blockId = `${documentId}-page-break-${blockNumber++}`;
this.contextCounters.set('page_break', blockNumber);
break;
case "page_break":
blockNumber = this.contextCounters.get("page_break") ?? 0;
blockId = `${documentId}-page-break-${blockNumber++}`;
this.contextCounters.set("page_break", blockNumber);
break;
case 'paragraph':
blockNumber = this.contextCounters.get('paragraph') ?? 0;
blockId = `${documentId}-paragraph-${blockNumber++}`;
this.contextCounters.set('paragraph', blockNumber);
break;
case "paragraph":
blockNumber = this.contextCounters.get("paragraph") ?? 0;
blockId = `${documentId}-paragraph-${blockNumber++}`;
this.contextCounters.set("paragraph", blockNumber);
break;
case 'pass':
blockNumber = this.contextCounters.get('pass') ?? 0;
blockId = `${documentId}-pass-${blockNumber++}`;
this.contextCounters.set('pass', blockNumber);
break;
case "pass":
blockNumber = this.contextCounters.get("pass") ?? 0;
blockId = `${documentId}-pass-${blockNumber++}`;
this.contextCounters.set("pass", blockNumber);
break;
case 'preamble':
blockNumber = this.contextCounters.get('preamble') ?? 0;
blockId = `${documentId}-preamble-${blockNumber++}`;
this.contextCounters.set('preamble', blockNumber);
break;
case "preamble":
blockNumber = this.contextCounters.get("preamble") ?? 0;
blockId = `${documentId}-preamble-${blockNumber++}`;
this.contextCounters.set("preamble", blockNumber);
break;
case 'quote':
blockNumber = this.contextCounters.get('quote') ?? 0;
blockId = `${documentId}-quote-${blockNumber++}`;
this.contextCounters.set('quote', blockNumber);
break;
case "quote":
blockNumber = this.contextCounters.get("quote") ?? 0;
blockId = `${documentId}-quote-${blockNumber++}`;
this.contextCounters.set("quote", blockNumber);
break;
case 'section':
blockNumber = this.contextCounters.get('section') ?? 0;
blockId = `${documentId}-section-${blockNumber++}`;
this.contextCounters.set('section', blockNumber);
break;
case "section":
blockNumber = this.contextCounters.get("section") ?? 0;
blockId = `${documentId}-section-${blockNumber++}`;
this.contextCounters.set("section", blockNumber);
break;
case 'sidebar':
blockNumber = this.contextCounters.get('sidebar') ?? 0;
blockId = `${documentId}-sidebar-${blockNumber++}`;
this.contextCounters.set('sidebar', blockNumber);
break;
case "sidebar":
blockNumber = this.contextCounters.get("sidebar") ?? 0;
blockId = `${documentId}-sidebar-${blockNumber++}`;
this.contextCounters.set("sidebar", blockNumber);
break;
case 'table':
blockNumber = this.contextCounters.get('table') ?? 0;
blockId = `${documentId}-table-${blockNumber++}`;
this.contextCounters.set('table', blockNumber);
break;
case "table":
blockNumber = this.contextCounters.get("table") ?? 0;
blockId = `${documentId}-table-${blockNumber++}`;
this.contextCounters.set("table", blockNumber);
break;
case 'table_cell':
blockNumber = this.contextCounters.get('table_cell') ?? 0;
blockId = `${documentId}-table-cell-${blockNumber++}`;
this.contextCounters.set('table_cell', blockNumber);
break;
case "table_cell":
blockNumber = this.contextCounters.get("table_cell") ?? 0;
blockId = `${documentId}-table-cell-${blockNumber++}`;
this.contextCounters.set("table_cell", blockNumber);
break;
case 'thematic_break':
blockNumber = this.contextCounters.get('thematic_break') ?? 0;
blockId = `${documentId}-thematic-break-${blockNumber++}`;
this.contextCounters.set('thematic_break', blockNumber);
break;
case "thematic_break":
blockNumber = this.contextCounters.get("thematic_break") ?? 0;
blockId = `${documentId}-thematic-break-${blockNumber++}`;
this.contextCounters.set("thematic_break", blockNumber);
break;
case 'toc':
blockNumber = this.contextCounters.get('toc') ?? 0;
blockId = `${documentId}-toc-${blockNumber++}`;
this.contextCounters.set('toc', blockNumber);
break;
case "toc":
blockNumber = this.contextCounters.get("toc") ?? 0;
blockId = `${documentId}-toc-${blockNumber++}`;
this.contextCounters.set("toc", blockNumber);
break;
case 'ulist':
blockNumber = this.contextCounters.get('ulist') ?? 0;
blockId = `${documentId}-ulist-${blockNumber++}`;
this.contextCounters.set('ulist', blockNumber);
break;
case "ulist":
blockNumber = this.contextCounters.get("ulist") ?? 0;
blockId = `${documentId}-ulist-${blockNumber++}`;
this.contextCounters.set("ulist", blockNumber);
break;
case 'verse':
blockNumber = this.contextCounters.get('verse') ?? 0;
blockId = `${documentId}-verse-${blockNumber++}`;
this.contextCounters.set('verse', blockNumber);
break;
case "verse":
blockNumber = this.contextCounters.get("verse") ?? 0;
blockId = `${documentId}-verse-${blockNumber++}`;
this.contextCounters.set("verse", blockNumber);
break;
case 'video':
blockNumber = this.contextCounters.get('video') ?? 0;
blockId = `${documentId}-video-${blockNumber++}`;
this.contextCounters.set('video', blockNumber);
break;
case "video":
blockNumber = this.contextCounters.get("video") ?? 0;
blockId = `${documentId}-video-${blockNumber++}`;
this.contextCounters.set("video", blockNumber);
break;
default:
blockNumber = this.contextCounters.get('block') ?? 0;
blockId = `${documentId}-block-${blockNumber++}`;
this.contextCounters.set('block', blockNumber);
break;
default:
blockNumber = this.contextCounters.get("block") ?? 0;
blockId = `${documentId}-block-${blockNumber++}`;
this.contextCounters.set("block", blockNumber);
break;
}
block.setId(blockId);
@ -1082,24 +1126,25 @@ export default class Pharos { @@ -1082,24 +1126,25 @@ export default class Pharos {
return null;
}
return he.decode(input)
return he
.decode(input)
.toLowerCase()
.replace(/[_]/g, ' ') // Replace underscores with spaces.
.replace(/[_]/g, " ") // Replace underscores with spaces.
.trim()
.replace(/\s+/g, '-') // Replace spaces with dashes.
.replace(/[^a-z0-9\-]/g, ''); // Remove non-alphanumeric characters except dashes.
.replace(/\s+/g, "-") // Replace spaces with dashes.
.replace(/[^a-z0-9\-]/g, ""); // Remove non-alphanumeric characters except dashes.
}
private updateEventByContext(dTag: string, value: string, context: string) {
switch (context) {
case 'document':
case 'section':
this.updateEventTitle(dTag, value);
break;
case "document":
case "section":
this.updateEventTitle(dTag, value);
break;
default:
this.updateEventBody(dTag, value);
break;
default:
this.updateEventBody(dTag, value);
break;
}
}
@ -1131,7 +1176,7 @@ export default class Pharos { @@ -1131,7 +1176,7 @@ export default class Pharos {
while ((match = wikilinkPattern.exec(content)) !== null) {
const linkName = match[1];
const normalizedText = this.normalizeId(linkName);
wikilinks.push(['wikilink', normalizedText!]);
wikilinks.push(["wikilink", normalizedText!]);
}
return wikilinks;
@ -1147,7 +1192,7 @@ export const pharosInstance: Writable<Pharos> = writable(); @@ -1147,7 +1192,7 @@ export const pharosInstance: Writable<Pharos> = writable();
export const tocUpdate = writable(0);
// Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1);
tocUpdate.update((n) => n + 1);
function ensureAsciiDocHeader(content: string): string {
const lines = content.split(/\r?\n/);
@ -1156,35 +1201,36 @@ function ensureAsciiDocHeader(content: string): string { @@ -1156,35 +1201,36 @@ function ensureAsciiDocHeader(content: string): string {
// Find the first non-empty line as header
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
if (lines[i].trim().startsWith('=')) {
if (lines[i].trim() === "") continue;
if (lines[i].trim().startsWith("=")) {
headerIndex = i;
break;
} 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) {
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
let nextLine = headerIndex + 1;
while (nextLine < lines.length && lines[nextLine].trim() === '') {
while (nextLine < lines.length && lines[nextLine].trim() === "") {
nextLine++;
}
if (nextLine < lines.length && lines[nextLine].trim().startsWith(':doctype:')) {
if (
nextLine < lines.length &&
lines[nextLine].trim().startsWith(":doctype:")
) {
hasDoctype = true;
}
// Insert doctype immediately after header if not present
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 @@ @@ -1,5 +1,5 @@
<script module lang='ts'>
import { P } from 'flowbite-svelte';
<script module lang="ts">
import { P } from "flowbite-svelte";
export { contentParagraph, sectionHeading };
</script>
@ -8,13 +8,17 @@ @@ -8,13 +8,17 @@
{@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'>
<svelte:element this={`h${headingLevel}`} class="h-leather">
{title}
</svelte:element>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
<section class='whitespace-normal publication-leather'>
{#snippet contentParagraph(
content: string,
publicationType: string,
isSectionStart: boolean,
)}
<section class="whitespace-normal publication-leather">
{@html content}
</section>
{/snippet}

10
src/lib/snippets/UserSnippets.svelte

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

5
src/lib/stores.ts

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

2
src/lib/stores/relayStore.ts

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

8
src/lib/types.ts

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

28
src/lib/utils.ts

@ -12,9 +12,9 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -12,9 +12,9 @@ export function neventEncode(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) {
throw new Error('Event does not have a d tag');
throw new Error("Event does not have a d tag");
}
return nip19.naddrEncode({
@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) { @@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) {
export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// The filter object supports only limited parameters, so we need to filter out events that
// 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.
if (
(event.content != null && event.content.length > 0)
|| getMatchingTags(event, 'title').length === 0
|| getMatchingTags(event, 'd').length === 0
|| (
getMatchingTags(event, 'a').length === 0
&& getMatchingTags(event, 'e').length === 0
)
(event.content != null && event.content.length > 0) ||
getMatchingTags(event, "title").length === 0 ||
getMatchingTags(event, "d").length === 0 ||
(getMatchingTags(event, "a").length === 0 &&
getMatchingTags(event, "e").length === 0)
) {
events.delete(event);
}
@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -138,7 +136,7 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
*/
export async function findIndexAsync<T>(
array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> {
for (let i = 0; i < array.length; i++) {
if (await predicate(array[i], i, array)) {
@ -152,14 +150,14 @@ export async function findIndexAsync<T>( @@ -152,14 +150,14 @@ export async function findIndexAsync<T>(
declare global {
interface Array<T> {
findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number>;
}
}
Array.prototype.findIndexAsync = function<T>(
Array.prototype.findIndexAsync = function <T>(
this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
predicate: (element: T, index: number, array: T[]) => Promise<boolean>,
): Promise<number> {
return findIndexAsync(this, predicate);
};
@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>( @@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function<T>(
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
wait: number,
): (...args: Parameters<T>) => void {
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 @@ -123,6 +123,7 @@ For more information on AsciiDoc, see the [AsciiDoc documentation](https://ascii
---
**Note:**
- The markdown parsers are primarily used for comments, issues, and other user-generated content.
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility.
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format.

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { postProcessAsciidoctorHtml } from './asciidoctorPostProcessor';
import plantumlEncoder from 'plantuml-encoder';
import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor";
import plantumlEncoder from "plantuml-encoder";
/**
* Unified post-processor for Asciidoctor HTML that handles:
@ -8,7 +8,9 @@ import plantumlEncoder from 'plantuml-encoder'; @@ -8,7 +8,9 @@ import plantumlEncoder from 'plantuml-encoder';
* - BPMN diagrams
* - TikZ diagrams
*/
export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<string> {
export async function postProcessAdvancedAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html;
try {
// First apply the basic post-processing (wikilinks, nostr addresses)
@ -22,15 +24,21 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise< @@ -22,15 +24,21 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<
// Process TikZ blocks
processedHtml = processTikZBlocks(processedHtml);
// 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);
}
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);
}
return processedHtml;
} catch (error) {
console.error('Error in postProcessAdvancedAsciidoctorHtml:', error);
console.error("Error in postProcessAdvancedAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails
}
}
@ -41,44 +49,46 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise< @@ -41,44 +49,46 @@ export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise<
*/
function fixAllMathBlocks(html: string): string {
// Unescape \$ to $ for math delimiters
html = html.replace(/\\\$/g, '$');
html = html.replace(/\\\$/g, "$");
// Block math: <div class="stemblock"><div class="content">...</div></div>
html = html.replace(
/<div class="stemblock">\s*<div class="content">([\s\S]*?)<\/div>\s*<\/div>/g,
(_match, 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
.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 $
.replace(/^[\s$]+/, '').replace(/[\s$]+$/, '')
.replace(/^[\s$]+/, "")
.replace(/[\s$]+$/, "")
.trim(); // Final trim to remove any stray whitespace or $
// Always wrap in $$...$$
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`;
}
},
);
// Inline math: <span>$</span> ... <span>$</span> (allow whitespace/newlines)
html = html.replace(
/<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:[...]
html = html.replace(
/stem:\[([^\]]+?)\]/g,
(_match, content) => `<span class="math-inline">$${content.trim()}$</span>`
(_match, content) => `<span class="math-inline">$${content.trim()}$</span>`,
);
html = html.replace(
/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(
/asciimath:\[([^\]]+?)\]/g,
(_match, content) => `<span class="math-inline">\`${content.trim()}\`</span>`
(_match, content) =>
`<span class="math-inline">\`${content.trim()}\`</span>`,
);
return html;
}
@ -110,17 +120,20 @@ function processPlantUMLBlocks(html: string): string { @@ -110,17 +120,20 @@ function processPlantUMLBlocks(html: string): string {
</details>
</div>`;
} catch (error) {
console.warn('Failed to process PlantUML block:', error);
console.warn("Failed to process PlantUML block:", error);
return match;
}
}
},
);
// Fallback: match <pre> blocks whose content starts with @startuml or @start (global, robust)
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const lines = content.trim().split('\n');
if (lines[0].trim().startsWith('@startuml') || lines[0].trim().startsWith('@start')) {
const lines = content.trim().split("\n");
if (
lines[0].trim().startsWith("@startuml") ||
lines[0].trim().startsWith("@start")
) {
try {
const rawContent = decodeHTMLEntities(content);
const encoded = plantumlEncoder.encode(rawContent);
@ -139,18 +152,18 @@ function processPlantUMLBlocks(html: string): string { @@ -139,18 +152,18 @@ function processPlantUMLBlocks(html: string): string {
</details>
</div>`;
} 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 html;
}
function decodeHTMLEntities(text: string): string {
const textarea = document.createElement('textarea');
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
}
@ -183,17 +196,20 @@ function processBPMNBlocks(html: string): string { @@ -183,17 +196,20 @@ function processBPMNBlocks(html: string): string {
</div>
</div>`;
} catch (error) {
console.warn('Failed to process BPMN block:', error);
console.warn("Failed to process BPMN block:", error);
return match;
}
}
},
);
// Fallback: match <pre> blocks whose content contains 'bpmn:' or '<?xml' and 'bpmn'
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
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 {
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">
@ -214,12 +230,12 @@ function processBPMNBlocks(html: string): string { @@ -214,12 +230,12 @@ function processBPMNBlocks(html: string): string {
</div>
</div>`;
} 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 html;
}
@ -252,17 +268,20 @@ function processTikZBlocks(html: string): string { @@ -252,17 +268,20 @@ function processTikZBlocks(html: string): string {
</div>
</div>`;
} catch (error) {
console.warn('Failed to process TikZ block:', error);
console.warn("Failed to process TikZ block:", error);
return match;
}
}
},
);
// Fallback: match <pre> blocks whose content starts with \begin{tikzpicture} or contains tikz
html = html.replace(
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
(match, content) => {
const lines = content.trim().split('\n');
if (lines[0].trim().startsWith('\\begin{tikzpicture}') || content.includes('tikz')) {
const lines = content.trim().split("\n");
if (
lines[0].trim().startsWith("\\begin{tikzpicture}") ||
content.includes("tikz")
) {
try {
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">
@ -283,12 +302,12 @@ function processTikZBlocks(html: string): string { @@ -283,12 +302,12 @@ function processTikZBlocks(html: string): string {
</div>
</div>`;
} 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 html;
}
@ -297,7 +316,7 @@ function processTikZBlocks(html: string): string { @@ -297,7 +316,7 @@ function processTikZBlocks(html: string): string {
* Escapes HTML characters for safe display
*/
function escapeHtml(text: string): string {
const div = document.createElement('div');
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}

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

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
import { parseBasicmarkup } from './basicMarkupParser';
import hljs from 'highlight.js';
import 'highlight.js/lib/common'; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only
import { parseBasicmarkup } from "./basicMarkupParser";
import hljs from "highlight.js";
import "highlight.js/lib/common"; // Import common languages
import "highlight.js/styles/github-dark.css"; // Dark theme only
// Register common languages
hljs.configure({
ignoreUnescapedHTML: true
ignoreUnescapedHTML: true,
});
// Regular expressions for advanced markup elements
@ -17,18 +17,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; @@ -17,18 +17,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
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)
*/
function processHeadings(content: string): string {
// Tailwind classes for each heading level
const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
"text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h1
"text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h2
"text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h3
"text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h4
"text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h5
"text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h6
];
// Process ATX-style headings (# Heading)
@ -39,11 +49,14 @@ function processHeadings(content: string): string { @@ -39,11 +49,14 @@ function processHeadings(content: string): string {
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
processedContent = processedContent.replace(
ALTERNATE_HEADING_REGEX,
(_, text, level) => {
const headingLevel = level[0] === "=" ? 1 : 2;
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
},
);
return processedContent;
}
@ -53,24 +66,25 @@ function processHeadings(content: string): string { @@ -53,24 +66,25 @@ function processHeadings(content: string): string {
*/
function processTables(content: string): string {
try {
if (!content) return '';
if (!content) return "";
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim());
const rows = match.split("\n").filter((row) => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
.split('|')
.split("|")
.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)
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
let headerCells: string[] = [];
@ -91,33 +105,33 @@ function processTables(content: string): string { @@ -91,33 +105,33 @@ function processTables(content: string): string {
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += "<thead>\n<tr>\n";
headerCells.forEach((cell) => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
});
html += '</tr>\n</thead>\n';
html += "</tr>\n</thead>\n";
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
html += "<tbody>\n";
bodyRows.forEach((row) => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += "<tr>\n";
cells.forEach((cell) => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
});
html += '</tr>\n';
html += "</tr>\n";
});
html += '</tbody>\n</table>\n</div>';
html += "</tbody>\n</table>\n</div>";
return html;
} catch (e: unknown) {
console.error('Error processing table row:', e);
console.error("Error processing table row:", e);
return match;
}
});
} catch (e: unknown) {
console.error('Error in processTables:', e);
console.error("Error in processTables:", e);
return content;
}
}
@ -126,8 +140,9 @@ function processTables(content: string): string { @@ -126,8 +140,9 @@ function processTables(content: string): string {
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
return content.replace(
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 { @@ -136,7 +151,7 @@ function processHorizontalRules(content: string): string {
*/
function processFootnotes(content: string): string {
try {
if (!content) return '';
if (!content) return "";
// Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map<string, string>();
@ -146,48 +161,57 @@ function processFootnotes(content: string): string { @@ -146,48 +161,57 @@ function processFootnotes(content: string): string {
});
// 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
const referenceOrder: { id: string, refNum: number, label: string }[] = [];
const referenceOrder: { id: string; refNum: number; label: string }[] = [];
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
return match;
}
const refNum = globalRefNum++;
if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
});
processedContent = processedContent.replace(
FOOTNOTE_REFERENCE_REGEX,
(match, id) => {
if (!footnotes.has(id)) {
console.warn(
`Footnote reference [^${id}] found but no definition exists`,
);
return match;
}
const refNum = globalRefNum++;
if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
},
);
// Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
processedContent +=
'\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference
const seen = new Set<string>();
for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue;
seen.add(id);
const text = footnotes.get(id) || '';
const text = footnotes.get(id) || "";
// List of backrefs for this footnote
const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`
).join(' ');
const backrefs = refs
.map(
(num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`,
)
.join(" ");
// If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : '';
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : "";
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
}
processedContent += '</ol>';
processedContent += "</ol>";
}
return processedContent;
} catch (error) {
console.error('Error processing footnotes:', error);
console.error("Error processing footnotes:", error);
return content;
}
}
@ -202,9 +226,9 @@ function processBlockquotes(content: string): string { @@ -202,9 +226,9 @@ function processBlockquotes(content: string): string {
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split('\n')
.map(line => line.replace(/^>[ \t]?/, ''))
.join('\n')
.split("\n")
.map((line) => line.replace(/^>[ \t]?/, ""))
.join("\n")
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
@ -214,13 +238,16 @@ function processBlockquotes(content: string): string { @@ -214,13 +238,16 @@ function processBlockquotes(content: string): string {
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
function processCodeBlocks(text: string): {
text: string;
blocks: Map<string, string>;
} {
const lines = text.split("\n");
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let currentLanguage = "";
let blockCount = 0;
let lastWasCodeBlock = false;
@ -239,11 +266,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -239,11 +266,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
// Ending current code block
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
const code = currentCode.join("\n");
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
if (currentLanguage.toLowerCase() === "json") {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
@ -251,24 +278,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -251,24 +278,27 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
blocks.set(
id,
JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true,
}),
);
processedLines.push(''); // Add spacing before code block
processedLines.push(""); // Add spacing before code block
processedLines.push(id);
processedLines.push(''); // Add spacing after code block
processedLines.push(""); // Add spacing after code block
inCodeBlock = false;
currentCode = [];
currentLanguage = '';
currentLanguage = "";
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
if (lastWasCodeBlock && line.trim()) {
processedLines.push('');
processedLines.push("");
lastWasCodeBlock = false;
}
processedLines.push(line);
@ -279,11 +309,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -279,11 +309,11 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
const code = currentCode.join("\n");
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
if (currentLanguage.toLowerCase() === "json") {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
@ -291,19 +321,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st @@ -291,19 +321,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map<string, st
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push('');
blocks.set(
id,
JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true,
}),
);
processedLines.push("");
processedLines.push(id);
processedLines.push('');
processedLines.push("");
}
return {
text: processedLines.join('\n'),
blocks
text: processedLines.join("\n"),
blocks,
};
}
@ -322,12 +355,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { @@ -322,12 +355,12 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
try {
const highlighted = hljs.highlight(code, {
language,
ignoreIllegals: true
ignoreIllegals: true,
}).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
console.warn("Failed to highlight code block:", e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ""}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
@ -335,8 +368,119 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { @@ -335,8 +368,119 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
result = result.replace(id, html);
} catch (e: unknown) {
console.error('Error restoring code block:', e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
console.error("Error restoring code block:", e);
result = result.replace(
id,
'<pre class="code-block"><code class="hljs">Error processing code block</code></pre>',
);
}
}
return result;
}
/**
* 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 { @@ -347,14 +491,17 @@ function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
* Parse markup text with advanced formatting
*/
export async function parseAdvancedmarkup(text: string): Promise<string> {
if (!text) return '';
if (!text) return "";
try {
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode;
// Step 2: Process block-level elements
// 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 = processBlockquotes(processedText);
processedText = processHeadings(processedText);
@ -364,11 +511,11 @@ export async function parseAdvancedmarkup(text: string): Promise<string> { @@ -364,11 +511,11 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
@ -378,12 +525,12 @@ export async function parseAdvancedmarkup(text: string): Promise<string> { @@ -378,12 +525,12 @@ export async function parseAdvancedmarkup(text: string): Promise<string> {
// Process basic markup (which will also handle Nostr identifiers)
processedText = await parseBasicmarkup(processedText);
// Step 3: Restore code blocks
// Step 4: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (e: unknown) {
console.error('Error in parseAdvancedmarkup:', e);
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
console.error("Error in parseAdvancedmarkup:", e);
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 @@ @@ -1,5 +1,5 @@
import { renderTikZ } from './tikzRenderer';
import asciidoctor from 'asciidoctor';
import { renderTikZ } from "./tikzRenderer";
import asciidoctor from "asciidoctor";
// Simple math rendering using MathJax CDN
function renderMath(content: string): string {
@ -66,27 +66,27 @@ export function createAdvancedExtensions(): any { @@ -66,27 +66,27 @@ export function createAdvancedExtensions(): any {
// Read the block content
const lines = reader.getLines();
// 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,
language: name,
lang: name,
style: 'source',
style: "source",
role: name,
});
block.setAttribute('language', name);
block.setAttribute('lang', name);
block.setAttribute('style', 'source');
block.setAttribute('role', name);
block.setOption('source', true);
block.setOption('listing', true);
block.setStyle('source');
block.setAttribute("language", name);
block.setAttribute("lang", name);
block.setAttribute("style", "source");
block.setAttribute("role", name);
block.setOption("source", true);
block.setOption("listing", true);
block.setStyle("source");
return block;
});
});
}
registerDiagramBlock('plantuml');
registerDiagramBlock('tikz');
registerDiagramBlock('bpmn');
registerDiagramBlock("plantuml");
registerDiagramBlock("tikz");
registerDiagramBlock("bpmn");
// --- END NEW ---
return extensions;
@ -98,7 +98,7 @@ export function createAdvancedExtensions(): any { @@ -98,7 +98,7 @@ export function createAdvancedExtensions(): any {
function processMathBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === 'stem') {
if (block.getContext() === "stem") {
const content = block.getContent();
if (content) {
try {
@ -106,19 +106,22 @@ function processMathBlocks(treeProcessor: any, document: any): void { @@ -106,19 +106,22 @@ function processMathBlocks(treeProcessor: any, document: any): void {
const rendered = `<div class="math-block">$$${content}$$</div>`;
block.setContent(rendered);
} 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'
if (block.getContext() === 'inline' && (block.getStyle() === 'stem' || block.getStyle() === 'latexmath')) {
if (
block.getContext() === "inline" &&
(block.getStyle() === "stem" || block.getStyle() === "latexmath")
) {
const content = block.getContent();
if (content) {
try {
const rendered = `<span class="math-inline">$${content}$</span>`;
block.setContent(rendered);
} 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 { @@ -132,7 +135,7 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === 'listing' && isPlantUMLBlock(block)) {
if (block.getContext() === "listing" && isPlantUMLBlock(block)) {
const content = block.getContent();
if (content) {
try {
@ -142,7 +145,7 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void { @@ -142,7 +145,7 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void {
// Replace the block content with the image
block.setContent(rendered);
} catch (error) {
console.warn('Failed to render PlantUML:', error);
console.warn("Failed to render PlantUML:", error);
// Keep original content if rendering fails
}
}
@ -157,7 +160,7 @@ function processTikZBlocks(treeProcessor: any, document: any): void { @@ -157,7 +160,7 @@ function processTikZBlocks(treeProcessor: any, document: any): void {
const blocks = document.getBlocks();
for (const block of blocks) {
if (block.getContext() === 'listing' && isTikZBlock(block)) {
if (block.getContext() === "listing" && isTikZBlock(block)) {
const content = block.getContent();
if (content) {
try {
@ -167,7 +170,7 @@ function processTikZBlocks(treeProcessor: any, document: any): void { @@ -167,7 +170,7 @@ function processTikZBlocks(treeProcessor: any, document: any): void {
// Replace the block content with the SVG
block.setContent(svg);
} catch (error) {
console.warn('Failed to render TikZ:', error);
console.warn("Failed to render TikZ:", error);
// Keep original content if rendering fails
}
}
@ -179,15 +182,16 @@ function processTikZBlocks(treeProcessor: any, document: any): void { @@ -179,15 +182,16 @@ function processTikZBlocks(treeProcessor: any, document: any): void {
* Checks if a block contains PlantUML content
*/
function isPlantUMLBlock(block: any): boolean {
const content = block.getContent() || '';
const lines = content.split('\n');
const content = block.getContent() || "";
const lines = content.split("\n");
// Check for PlantUML indicators
return lines.some((line: string) =>
line.trim().startsWith('@startuml') ||
line.trim().startsWith('@start') ||
line.includes('plantuml') ||
line.includes('uml')
return lines.some(
(line: string) =>
line.trim().startsWith("@startuml") ||
line.trim().startsWith("@start") ||
line.includes("plantuml") ||
line.includes("uml"),
);
}
@ -195,14 +199,15 @@ function isPlantUMLBlock(block: any): boolean { @@ -195,14 +199,15 @@ function isPlantUMLBlock(block: any): boolean {
* Checks if a block contains TikZ content
*/
function isTikZBlock(block: any): boolean {
const content = block.getContent() || '';
const lines = content.split('\n');
const content = block.getContent() || "";
const lines = content.split("\n");
// Check for TikZ indicators
return lines.some((line: string) =>
line.trim().startsWith('\\begin{tikzpicture}') ||
line.trim().startsWith('\\tikz') ||
line.includes('tikzpicture') ||
line.includes('tikz')
return lines.some(
(line: string) =>
line.trim().startsWith("\\begin{tikzpicture}") ||
line.trim().startsWith("\\tikz") ||
line.includes("tikzpicture") ||
line.includes("tikz"),
);
}

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

@ -1,4 +1,4 @@ @@ -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,
@ -8,9 +8,9 @@ import { processNostrIdentifiers } from '../nostrUtils'; @@ -8,9 +8,9 @@ import { processNostrIdentifiers } from '../nostrUtils';
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
@ -19,13 +19,30 @@ function normalizeDTag(input: string): string { @@ -19,13 +19,30 @@ function normalizeDTag(input: string): string {
*/
function replaceWikilinks(html: string): string {
// [[target page]] or [[target page|display text]]
return html.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
});
return html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `./events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(
/<a id="([^"]+)"><\/a>/g,
(_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
}
);
}
/**
@ -37,15 +54,16 @@ async function processNostrAddresses(html: string): Promise<string> { @@ -37,15 +54,16 @@ async function processNostrAddresses(html: string): Promise<string> {
function isWithinLink(text: string, index: number): boolean {
// Look backwards from the match position to find the nearest <a> tag
const before = text.slice(0, index);
const lastOpenTag = before.lastIndexOf('<a');
const lastCloseTag = before.lastIndexOf('</a>');
const lastOpenTag = 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
return lastOpenTag > lastCloseTag;
}
// 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;
// Find all nostr addresses
@ -66,9 +84,10 @@ async function processNostrAddresses(html: string): Promise<string> { @@ -66,9 +84,10 @@ async function processNostrAddresses(html: string): Promise<string> {
const processedMatch = await processNostrIdentifiers(fullMatch);
// Replace the match in the HTML
processedHtml = processedHtml.slice(0, matchIndex) +
processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
processedHtml =
processedHtml.slice(0, matchIndex) +
processedMatch +
processedHtml.slice(matchIndex + fullMatch.length);
}
return processedHtml;
@ -85,9 +104,9 @@ function fixStemBlocks(html: string): string { @@ -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,
(_match, 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>`;
}
},
);
}
@ -95,20 +114,24 @@ function fixStemBlocks(html: string): string { @@ -95,20 +114,24 @@ function fixStemBlocks(html: string): string {
* 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.
*/
export async function postProcessAsciidoctorHtml(html: string): Promise<string> {
export async function postProcessAsciidoctorHtml(
html: string,
): Promise<string> {
if (!html) return html;
try {
// First process wikilinks
let processedHtml = replaceWikilinks(html);
console.log('HTML before 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)
processedHtml = await processNostrAddresses(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
return processedHtml;
} catch (error) {
console.error('Error in postProcessAsciidoctorHtml:', error);
console.error("Error in postProcessAsciidoctorHtml:", error);
return html; // Return original HTML if processing fails
}
}

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

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

19
src/lib/utils/mime.ts

@ -6,22 +6,24 @@ @@ -6,22 +6,24 @@
* - Addressable: 30000-39999 (latest per d-tag stored)
* - 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
if (kind >= 30000 && kind < 40000) {
return 'addressable';
return "addressable";
}
if (kind >= 20000 && kind < 30000) {
return 'ephemeral';
return "ephemeral";
}
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) {
return 'replaceable';
return "replaceable";
}
// Everything else is regular
return 'regular';
return "regular";
}
/**
@ -36,9 +38,10 @@ export function getMimeTags(kind: number): [string, string][] { @@ -36,9 +38,10 @@ export function getMimeTags(kind: number): [string, string][] {
// Determine replaceability based on event type
const eventType = getEventType(kind);
const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
? "replaceable"
: "nonreplaceable";
const replaceability =
eventType === "replaceable" || eventType === "addressable"
? "replaceable"
: "nonreplaceable";
switch (kind) {
// Short text note

225
src/lib/utils/nostrUtils.ts

@ -1,22 +1,26 @@ @@ -1,22 +1,26 @@
import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk';
import { npubCache } from './npubCache';
import { get } from "svelte/store";
import { nip19 } from "nostr-tools";
import { ndkInstance } from "$lib/ndk";
import { npubCache } from "./npubCache";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from "@noble/curves/secp256k1";
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
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_PROFILE_REGEX =
/(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX =
/(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile {
name?: string;
@ -34,21 +38,23 @@ export interface NostrProfile { @@ -34,21 +38,23 @@ export interface NostrProfile {
*/
function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#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)
*/
export async function getUserMetadata(identifier: string): Promise<NostrProfile> {
export async function getUserMetadata(
identifier: string,
): Promise<NostrProfile> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
const cleanId = identifier.replace(/^nostr:/, "");
if (npubCache.has(cleanId)) {
return npubCache.get(cleanId)!;
@ -71,17 +77,23 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile> @@ -71,17 +77,23 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
// Handle different identifier types
let pubkey: string;
if (decoded.type === 'npub') {
if (decoded.type === "npub") {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(cleanId, fallback);
return fallback;
}
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
const profileEvent = await fetchEventWithFallback(ndk, {
kinds: [0],
authors: [pubkey],
});
const profile =
profileEvent && profileEvent.content
? JSON.parse(profileEvent.content)
: null;
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
@ -91,7 +103,7 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile> @@ -91,7 +103,7 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16
lud16: profile?.lud16,
};
npubCache.set(cleanId, metadata);
@ -105,8 +117,11 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile> @@ -105,8 +117,11 @@ export async function getUserMetadata(identifier: string): Promise<NostrProfile>
/**
* Create a profile link element
*/
export function createProfileLink(identifier: string, displayText: string | undefined): string {
const cleanId = identifier.replace(/^nostr:/, '');
export function createProfileLink(
identifier: string,
displayText: string | undefined,
): string {
const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
@ -117,15 +132,18 @@ export function createProfileLink(identifier: string, displayText: string | unde @@ -117,15 +132,18 @@ export function createProfileLink(identifier: string, displayText: string | unde
/**
* 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;
if (!ndk) {
return createProfileLink(identifier, displayText);
}
const cleanId = identifier.replace(/^nostr:/, '');
const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId);
const isNpub = cleanId.startsWith('npub');
const isNpub = cleanId.startsWith("npub");
let user: NDKUser;
if (isNpub) {
@ -134,19 +152,23 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -134,19 +152,23 @@ export async function createProfileLinkWithVerification(identifier: string, disp
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 = [
...standardRelays,
...userRelays,
...fallbackRelays
...fallbackRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
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;
if (!nip05) {
@ -155,7 +177,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -155,7 +177,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
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);
@ -164,11 +190,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -164,11 +190,11 @@ export async function createProfileLinkWithVerification(identifier: string, disp
}
// 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) {
case 'edu':
case "edu":
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>`;
}
}
@ -176,7 +202,7 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -176,7 +202,7 @@ export async function createProfileLinkWithVerification(identifier: string, disp
* Create a note link element
*/
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 escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
@ -187,7 +213,9 @@ function createNoteLink(identifier: string): string { @@ -187,7 +213,9 @@ function createNoteLink(identifier: string): string {
/**
* Process Nostr identifiers in text
*/
export async function processNostrIdentifiers(content: string): Promise<string> {
export async function processNostrIdentifiers(
content: string,
): Promise<string> {
let processedContent = content;
// Helper to check if a match is part of a URL
@ -206,8 +234,8 @@ export async function processNostrIdentifiers(content: string): Promise<string> @@ -206,8 +234,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL
}
let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) {
identifier = 'nostr:' + identifier;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
@ -224,8 +252,8 @@ export async function processNostrIdentifiers(content: string): Promise<string> @@ -224,8 +252,8 @@ export async function processNostrIdentifiers(content: string): Promise<string>
continue; // skip if part of a URL
}
let identifier = fullMatch;
if (!identifier.startsWith('nostr:')) {
identifier = 'nostr:' + identifier;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link);
@ -238,7 +266,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -238,7 +266,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.error('NDK not initialized');
console.error("NDK not initialized");
return null;
}
@ -248,7 +276,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -248,7 +276,7 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
}
return user.npub;
} catch (error) {
console.error('Error getting npub from nip05:', error);
console.error("Error getting npub from nip05:", error);
return null;
}
}
@ -266,17 +294,17 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -266,17 +294,17 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
*/
export function withTimeout<T>(
thisOrPromise: Promise<T> | number,
timeoutMsOrPromise?: number | Promise<T>
timeoutMsOrPromise?: number | Promise<T>,
): Promise<T> {
// Handle method-style call (promise.withTimeout(5000))
if (typeof thisOrPromise === 'number') {
if (typeof thisOrPromise === "number") {
const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise<T>;
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
setTimeout(() => reject(new Error("Timeout")), timeoutMs),
),
]);
}
@ -286,8 +314,8 @@ export function withTimeout<T>( @@ -286,8 +314,8 @@ export function withTimeout<T>(
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
setTimeout(() => reject(new Error("Timeout")), timeoutMs),
),
]);
}
@ -298,7 +326,10 @@ declare global { @@ -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);
};
@ -311,14 +342,14 @@ Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number) @@ -311,14 +342,14 @@ Promise.prototype.withTimeout = function<T>(this: Promise<T>, timeoutMs: number)
export async function fetchEventWithFallback(
ndk: NDK,
filterOrId: string | NDKFilter<NDKKind>,
timeoutMs: number = 3000
timeoutMs: number = 3000,
): Promise<NDKEvent | null> {
// Get user relays if logged in
const userRelays = ndk.activeUser ?
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1) // Only use connected relays
.map(r => r.url) :
[];
const userRelays = ndk.activeUser
? Array.from(ndk.pool?.relays.values() || [])
.filter((r) => r.status === 1) // Only use connected relays
.map((r) => r.url)
: [];
// Determine which relays to use based on user authentication status
const isSignedIn = ndk.signer && ndk.activeUser;
@ -326,9 +357,9 @@ export async function fetchEventWithFallback( @@ -326,9 +357,9 @@ export async function fetchEventWithFallback(
// Create three relay sets in priority order
const relaySets = [
NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort)
];
try {
@ -336,24 +367,42 @@ export async function fetchEventWithFallback( @@ -336,24 +367,42 @@ export async function fetchEventWithFallback(
const triedRelaySets: string[] = [];
// 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;
triedRelaySets.push(setName);
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
if (
typeof filterOrId === "string" &&
/^[0-9a-f]{64}$/i.test(filterOrId)
) {
return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
.withTimeout(timeoutMs);
} else {
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
const filter =
typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId;
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
for (const [index, relaySet] of relaySets.entries()) {
const setName = index === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') :
index === 1 ? 'user relays' :
'fallback relays';
const setName =
index === 0
? isSignedIn
? "standard relays"
: "anonymous relays"
: index === 1
? "user relays"
: "fallback relays";
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
@ -361,22 +410,32 @@ export async function fetchEventWithFallback( @@ -361,22 +410,32 @@ export async function fetchEventWithFallback(
if (!found) {
const timeoutSeconds = timeoutMs / 1000;
const relayUrls = relaySets.map((set, i) => {
const setName = i === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') :
i === 1 ? '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.`);
const relayUrls = relaySets
.map((set, i) => {
const setName =
i === 0
? isSignedIn
? "standard relays"
: "anonymous relays"
: i === 1
? "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;
}
// Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) {
console.error('Error in fetchEventWithFallback:', err);
console.error("Error in fetchEventWithFallback:", err);
return null;
}
}
@ -390,7 +449,7 @@ export function toNpub(pubkey: string | undefined): string | null { @@ -390,7 +449,7 @@ export function toNpub(pubkey: string | undefined): string | null {
if (/^[a-f0-9]{64}$/i.test(pubkey)) {
return nip19.npubEncode(pubkey);
}
if (pubkey.startsWith('npub1')) return pubkey;
if (pubkey.startsWith("npub1")) return pubkey;
return null;
} catch {
return null;
@ -432,7 +491,7 @@ export function getEventHash(event: { @@ -432,7 +491,7 @@ export function getEventHash(event: {
event.created_at,
event.kind,
event.tags,
event.content
event.content,
]);
return bytesToHex(sha256(serialized));
}

2
src/lib/utils/npubCache.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import type { NostrProfile } from './nostrUtils';
import type { NostrProfile } from "./nostrUtils";
export type NpubMetadata = NostrProfile;

27
src/routes/+layout.svelte

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

26
src/routes/+layout.ts

@ -1,15 +1,21 @@ @@ -1,15 +1,21 @@
import { feedTypeStorageKey } from '$lib/consts';
import { FeedType } from '$lib/consts';
import { getPersistedLogin, initNdk, loginWithExtension, ndkInstance } from '$lib/ndk';
import Pharos, { pharosInstance } from '$lib/parser';
import { feedType } from '$lib/stores';
import type { LayoutLoad } from './$types';
import { feedTypeStorageKey } from "$lib/consts";
import { FeedType } from "$lib/consts";
import {
getPersistedLogin,
initNdk,
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 load: LayoutLoad = () => {
const initialFeedType = localStorage.getItem(feedTypeStorageKey) as FeedType
?? FeedType.StandardRelays;
const initialFeedType =
(localStorage.getItem(feedTypeStorageKey) as FeedType) ??
FeedType.StandardRelays;
feedType.set(initialFeedType);
const ndk = initNdk();
@ -26,7 +32,9 @@ export const load: LayoutLoad = () => { @@ -26,7 +32,9 @@ export const load: LayoutLoad = () => {
loginWithExtension(pubkey);
}
} 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);

78
src/routes/+page.svelte

@ -1,10 +1,15 @@ @@ -1,10 +1,15 @@
<script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
<script lang="ts">
import {
FeedType,
feedTypeStorageKey,
standardRelays,
fallbackRelays,
} from "$lib/consts";
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from "$lib/components/PublicationFeed.svelte";
import { feedType } from "$lib/stores";
$effect(() => {
localStorage.setItem(feedTypeStorageKey, $feedType);
@ -18,31 +23,38 @@ @@ -18,31 +23,38 @@
const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) {
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return '';
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return "";
}
};
let searchQuery = $state('');
let searchQuery = $state("");
</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'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
<Alert
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"
>
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" />
<span class="font-medium">
Pardon our dust! The publication view is currently using an experimental
loader, and may be unstable.
</span>
</Alert>
<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'>
<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"
>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
{#if $ndkSignedIn}
<ChevronDownOutline class='w-6 h-6' />
<ChevronDownOutline class="w-6 h-6" />
{/if}
</Button>
<Input
@ -52,25 +64,31 @@ @@ -52,25 +64,31 @@
/>
{#if $ndkSignedIn}
<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"
>
<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>
<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>
</Dropdown>
{/if}
</div>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else}
{#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} />
{/if}
</main>

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

@ -1,13 +1,23 @@ @@ -1,13 +1,23 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button, P } from 'flowbite-svelte';
import { goto } from "$app/navigation";
import { Button, P } from "flowbite-svelte";
</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>
<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">
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button>
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button>
<Button class="btn-leather !w-fit" on:click={() => goto("/")}
>Return to Home</Button
>
<Button
class="btn-leather !w-fit"
outline
on:click={() => window.history.back()}>Go Back</Button
>
</div>
</div>

5
src/routes/about/+page.svelte

@ -46,7 +46,10 @@ @@ -46,7 +46,10 @@
</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"
title="GitCitadel Homepage"
target="_blank">homepage</A

295
src/routes/contact/+page.svelte

@ -1,15 +1,24 @@ @@ -1,15 +1,24 @@
<script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
<script lang="ts">
import {
Heading,
P,
A,
Button,
Label,
Textarea,
Input,
Modal,
} from "flowbite-svelte";
import { ndkSignedIn, ndkInstance } from "$lib/ndk";
import { standardRelays } from "$lib/consts";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
// @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte';
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser';
import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
import { userBadge } from '$lib/snippets/UserSnippets.svelte';
import LoginModal from "$lib/components/LoginModal.svelte";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import { nip19 } from "nostr-tools";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
// Function to close the success message
function closeSuccessMessage() {
@ -18,51 +27,53 @@ @@ -18,51 +27,53 @@
}
function clearForm() {
subject = '';
content = '';
submissionError = '';
subject = "";
content = "";
submissionError = "";
isExpanded = false;
activeTab = 'write';
activeTab = "write";
}
let subject = $state('');
let content = $state('');
let subject = $state("");
let content = $state("");
let isSubmitting = $state(false);
let showLoginModal = $state(false);
let submissionSuccess = $state(false);
let submissionError = $state('');
let submissionError = $state("");
let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state('');
let issueLink = $state("");
let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false);
let activeTab = $state('write');
let activeTab = $state("write");
let showConfirmDialog = $state(false);
// Store form data when user needs to login
let savedFormData = {
subject: '',
content: ''
subject: "",
content: "",
};
// Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Hard-coded relays to ensure we have working relays
const allRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
...standardRelays
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...standardRelays,
];
// Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
const repoId = 'Alexandria';
const repoOwnerPubkey =
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1";
const repoId = "Alexandria";
// Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, '');
return url.replace(/\/+$/, "");
}
function toggleSize() {
@ -74,7 +85,7 @@ @@ -74,7 +85,7 @@
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
submissionError = "Please fill in all fields";
return;
}
@ -83,7 +94,7 @@ @@ -83,7 +94,7 @@
// Save form data
savedFormData = {
subject,
content
content,
};
// Show login modal
@ -112,17 +123,17 @@ @@ -112,17 +123,17 @@
ndk: NDK,
relays: Set<string>,
maxRetries: number = 3,
timeout: number = 10000
timeout: number = 10000,
): Promise<string[]> {
const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => {
return new Promise<void>(resolve => {
const publishPromises = Array.from(relays).map((relayUrl) => {
return new Promise<void>((resolve) => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
relay.on("published", (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl);
resolve();
@ -140,13 +151,13 @@ @@ -140,13 +151,13 @@
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout);
setTimeout(() => reject(new Error("Publish timeout")), timeout);
});
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
timeoutPromise,
]);
if (successfulRelays.length > 0) {
@ -155,11 +166,15 @@ @@ -155,11 +166,15 @@
if (attempt < maxRetries) {
// 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) {
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 @@ @@ -169,18 +184,18 @@
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionError = "";
submissionSuccess = false;
try {
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
throw new Error("NDK instance not available");
}
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
@ -189,9 +204,13 @@ @@ -189,9 +204,13 @@
// Collect all unique relays
const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
...(ndk.pool
? Array.from(ndk.pool.relays.values())
.filter(
(relay) => relay.url && !relay.url.includes("wss://nos.lol"),
)
.map((relay) => normalizeRelayUrl(relay.url))
: []),
]);
try {
@ -209,10 +228,10 @@ @@ -209,10 +228,10 @@
clearForm();
submissionSuccess = true;
} catch (error) {
throw new Error('Failed to publish event');
throw new Error("Failed to publish event");
}
} catch (error: any) {
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
submissionError = `Error submitting issue: ${error.message || "Unknown error"}`;
} finally {
isSubmitting = false;
}
@ -224,20 +243,15 @@ @@ -224,20 +243,15 @@
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
event.tags.push(["subject", subject]);
event.tags.push(["alt", `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
event.tags.push(["a", aTagValue, "", "root"]);
// Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]);
event.tags.push(["p", repoOwnerPubkey]);
// Add MIME tags
const mimeTags = getMimeTags(1621);
@ -250,7 +264,7 @@ @@ -250,7 +264,7 @@
try {
await event.sign();
} catch (error) {
throw new Error('Failed to sign event');
throw new Error("Failed to sign event");
}
return event;
@ -269,44 +283,76 @@ @@ -269,44 +283,76 @@
submitIssue();
}
});
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'>
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading>
<div class="w-full flex justify-center">
<main
class="main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4"
>
<Heading tag="h1" class="h-leather mb-2">Contact GitCitadel</Heading>
<P class="mb-3">
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
Make sure that you follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"
>Geyserfund</A
>.
</P>
<P class="mb-3">
You can contact us on Nostr {@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>
<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">
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>
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
<Input
id="subject"
class="w-full bg-white dark:bg-gray-800"
placeholder="Issue subject"
bind:value={subject}
required
autofocus
/>
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full">
<div
class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded
? 'h-[800px]'
: 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"
>
<div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist">
<div
class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"
>
<ul
class="flex flex-wrap -mb-px text-sm font-medium text-center"
role="tablist"
>
<li class="mr-2" role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => activeTab = 'write'}
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'}"
onclick={() => (activeTab = "write")}
role="tab"
>
Write
@ -315,8 +361,11 @@ @@ -315,8 +361,11 @@
<li role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
onclick={() => activeTab = 'preview'}
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'}"
onclick={() => (activeTab = "preview")}
role="tab"
>
Preview
@ -326,7 +375,7 @@ @@ -326,7 +375,7 @@
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'}
{#if activeTab === "write"}
<div class="absolute inset-0 overflow-hidden">
<Textarea
id="content"
@ -373,14 +422,19 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -373,14 +422,19 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
/>
</div>
{: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}
{#await parseAdvancedmarkup(content)}
<p>Loading preview...</p>
{: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}
<p class="text-red-500">Error rendering preview: {error.message}</p>
<p class="text-red-500">
Error rendering preview: {error.message}
</p>
{/await}
{/key}
</div>
@ -394,7 +448,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -394,7 +448,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
color="light"
on:click={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
{isExpanded ? "⌃" : "⌄"}
</Button>
</div>
</div>
@ -413,29 +467,59 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -413,29 +467,59 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div>
{#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<div
class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative"
role="alert"
>
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
<svg
class="w-5 h-5 mr-2 text-success-700 dark:text-success-300"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
<span class="font-medium text-success-800 dark:text-success-200"
>Issue submitted successfully!</span
>
</div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600">
<div
class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"
>
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
<span
>{submittedEvent.tags.find((t) => t[0] === "subject")?.[1] ||
"No subject"}</span
>
</div>
<div>
<span class="font-semibold">Description:</span>
@ -445,7 +529,9 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -445,7 +529,9 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{:then html}
{@html html}
{: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}
</div>
</div>
@ -454,7 +540,11 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -454,7 +540,11 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all">
<A
href={issueLink}
target="_blank"
class="hover:underline text-primary-600 dark:text-primary-500 break-all"
>
{issueLink}
</A>
</div>
@ -473,33 +563,26 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -473,33 +563,26 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
{/if}
{#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}
</div>
{/if}
</form>
</main>
</main>
</div>
<!-- Confirmation Dialog -->
<Modal
bind:open={showConfirmDialog}
size="sm"
autoclose={false}
class="w-full"
>
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full">
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-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?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}>
Cancel
</Button>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
<Button color="alternative" on:click={cancelSubmit}>Cancel</Button>
<Button color="primary" on:click={confirmSubmit}>Submit</Button>
</div>
</div>
</Modal>
@ -507,7 +590,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -507,7 +590,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
<!-- Login Modal -->
<LoginModal
show={showLoginModal}
onClose={() => showLoginModal = false}
onClose={() => (showLoginModal = false)}
onLoginSuccess={() => {
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;

78
src/routes/events/+page.svelte

@ -2,13 +2,13 @@ @@ -2,13 +2,13 @@
import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte';
import { userBadge } from '$lib/snippets/UserSnippets.svelte';
import { getMatchingTags, toNpub } from '$lib/utils/nostrUtils';
import type { NDKEvent } from "$lib/utils/nostrUtils";
import EventSearch from "$lib/components/EventSearch.svelte";
import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
let loading = $state(false);
let error = $state<string | null>(null);
@ -50,17 +50,17 @@ @@ -50,17 +50,17 @@
}
function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, 'summary')[0]?.[1];
return getMatchingTags(event, "summary")[0]?.[1];
}
function getDeferrelNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferrel' tag, e.g. ['deferrel', 'naddr1...']
return getMatchingTags(event, 'deferrel')[0]?.[1];
return getMatchingTags(event, "deferrel")[0]?.[1];
}
$effect(() => {
const id = $page.url.searchParams.get('id');
const dTag = $page.url.searchParams.get('d');
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
if (id !== searchValue) {
searchValue = id;
@ -76,8 +76,8 @@ @@ -76,8 +76,8 @@
onMount(async () => {
// Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
userPubkey = localStorage.getItem("userPubkey");
userRelayPreference = localStorage.getItem("useUserRelays") === "true";
});
</script>
@ -88,8 +88,9 @@ @@ -88,8 +88,9 @@
</div>
<P class="mb-3">
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID).
You can also search for events by d-tag using the format "d:tag-name".
Use this page to view any event (npub, nprofile, nevent, naddr, note,
pubkey, or eventID). You can also search for events by d-tag using the
format "d:tag-name".
</P>
<EventSearch
@ -108,7 +109,7 @@ @@ -108,7 +109,7 @@
{#if userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} />
<CommentBox {event} {userPubkey} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
@ -120,7 +121,8 @@ @@ -120,7 +121,8 @@
{#if searchResults.length > 0}
<div class="mt-8">
<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>
<div class="space-y-4">
{#each searchResults as result, index}
@ -130,27 +132,43 @@ @@ -130,27 +132,43 @@
>
<div class="flex flex-col gap-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="text-xs text-gray-600 dark:text-gray-400">Kind: {result.kind}</span>
<span class="font-medium text-gray-800 dark:text-gray-100"
>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">
{@render userBadge(toNpub(result.pubkey) as string, undefined)}
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
)}
</span>
<span 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
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>
</div>
{#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)}
</div>
{/if}
{#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
<a
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())}
onclick={e => e.stopPropagation()}
href={"/publications?d=" +
encodeURIComponent((dTagValue || "").toLowerCase())}
onclick={(e) => e.stopPropagation()}
tabindex="0"
>
{getDeferrelNaddr(result)}
@ -158,8 +176,12 @@ @@ -158,8 +176,12 @@
</div>
{/if}
{#if result.content}
<div 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
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>
{/if}
</div>

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

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
<script lang='ts'>
<script lang="ts">
import Preview from "$lib/components/Preview.svelte";
import { pharosInstance } from "$lib/parser";
import { Heading } from "flowbite-svelte";
@ -14,11 +14,16 @@ @@ -14,11 +14,16 @@
}
</script>
<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'>
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading>
<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">
<Heading tag="h1" class="h-leather mb-2">Compose</Heading>
{#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}
</main>
</div>

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

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

40
src/routes/publication/+error.svelte

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

46
src/routes/publication/+page.ts

@ -1,28 +1,28 @@ @@ -1,28 +1,28 @@
import { error } from '@sveltejs/kit';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
import { error } from "@sveltejs/kit";
import type { Load } from "@sveltejs/kit";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getActiveRelays } from "$lib/ndk";
import { getMatchingTags } from "$lib/utils/nostrUtils";
/**
* Decodes an naddr identifier and returns a filter object
*/
function decodeNaddr(id: string) {
try {
if (!id.startsWith('naddr')) return {};
if (!id.startsWith("naddr")) return {};
const decoded = nip19.decode(id);
if (decoded.type !== 'naddr') return {};
if (decoded.type !== "naddr") return {};
const data = decoded.data;
return {
kinds: [data.kind],
authors: [data.pubkey],
'#d': [data.identifier]
"#d": [data.identifier],
};
} catch (e) {
console.error('Failed to decode naddr:', e);
console.error("Failed to decode naddr:", e);
return null;
}
}
@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -50,9 +50,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
const hasFilter = Object.keys(filter).length > 0;
try {
const event = await (hasFilter ?
ndk.fetchEvent(filter) :
ndk.fetchEvent(id));
const event = await (hasFilter
? ndk.fetchEvent(filter)
: ndk.fetchEvent(id));
if (!event) {
throw new Error(`Event not found for ID: ${id}`);
@ -69,9 +69,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { @@ -69,9 +69,9 @@ async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> {
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try {
const event = await ndk.fetchEvent(
{ '#d': [dTag] },
{ "#d": [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk)
getActiveRelays(ndk),
);
if (!event) {
@ -83,13 +83,19 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { @@ -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> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
export const load: Load = async ({
url,
parent,
}: {
url: URL;
parent: () => Promise<any>;
}) => {
const id = url.searchParams.get("id");
const dTag = url.searchParams.get("d");
const { ndk, parser } = await parent();
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
@ -97,7 +103,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -97,7 +103,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {

7
src/routes/start/+page.svelte

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

11
src/routes/visualize/+page.svelte

@ -49,7 +49,7 @@ @@ -49,7 +49,7 @@
const indexEvents = await $ndkInstance.fetchEvents(
{
kinds: [INDEX_EVENT_KIND],
limit: $networkFetchLimit
limit: $networkFetchLimit,
},
{
groupable: true,
@ -79,7 +79,9 @@ @@ -79,7 +79,9 @@
debug("Content event IDs to fetch:", contentEventIds.size);
// 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(
{
kinds: CONTENT_EVENT_KINDS,
@ -104,7 +106,6 @@ @@ -104,7 +106,6 @@
}
}
// Fetch events when component mounts
onMount(() => {
debug("Component mounted");
@ -140,7 +141,7 @@ @@ -140,7 +141,7 @@
<span class="sr-only">Loading...</span>
</div>
</div>
<!-- Error message -->
<!-- Error message -->
{:else if error}
<div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
@ -156,7 +157,7 @@ @@ -156,7 +157,7 @@
Retry
</button>
</div>
<!-- Network visualization -->
<!-- Network visualization -->
{:else}
<!-- Event network visualization -->
<EventNetwork {events} onupdate={fetchEvents} />

6
src/styles/base.css

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

6
src/styles/events.css

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

572
src/styles/publications.css

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

30
src/styles/scrollbar.css

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

204
src/styles/visualize.css

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

8
src/types/d3.d.ts vendored

@ -7,13 +7,13 @@ @@ -7,13 +7,13 @@
*/
// Core D3 library
declare module 'd3';
declare module "d3";
// Force simulation module for graph layouts
declare module 'd3-force';
declare module "d3-force";
// DOM selection and manipulation module
declare module 'd3-selection';
declare module "d3-selection";
// Drag behavior module
declare module 'd3-drag';
declare module "d3-drag";

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

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

142
tailwind.config.cjs

@ -12,110 +12,110 @@ const config = { @@ -12,110 +12,110 @@ const config = {
theme: {
extend: {
colors: {
highlight: '#f9f6f1',
highlight: "#f9f6f1",
primary: {
0: '#efe6dc',
50: '#decdb9',
100: '#d6c1a8',
200: '#c6a885',
300: '#b58f62',
400: '#ad8351',
500: '#c6a885',
600: '#795c39',
700: '#564a3e',
800: '#3c352c',
900: '#2a241c',
950: '#1d1812',
1000: '#15110d',
0: "#efe6dc",
50: "#decdb9",
100: "#d6c1a8",
200: "#c6a885",
300: "#b58f62",
400: "#ad8351",
500: "#c6a885",
600: "#795c39",
700: "#564a3e",
800: "#3c352c",
900: "#2a241c",
950: "#1d1812",
1000: "#15110d",
},
success: {
50: '#e3f2e7',
100: '#c7e6cf',
200: '#a2d4ae',
300: '#7dbf8e',
400: '#5ea571',
500: '#4e8e5f',
600: '#3e744c',
700: '#305b3b',
800: '#22412a',
900: '#15281b',
50: "#e3f2e7",
100: "#c7e6cf",
200: "#a2d4ae",
300: "#7dbf8e",
400: "#5ea571",
500: "#4e8e5f",
600: "#3e744c",
700: "#305b3b",
800: "#22412a",
900: "#15281b",
},
info: {
50: '#e7eff6',
100: '#c5d9ea',
200: '#9fbfdb',
300: '#7aa5cc',
400: '#5e90be',
500: '#4779a5',
600: '#365d80',
700: '#27445d',
800: '#192b3a',
900: '#0d161f',
50: "#e7eff6",
100: "#c5d9ea",
200: "#9fbfdb",
300: "#7aa5cc",
400: "#5e90be",
500: "#4779a5",
600: "#365d80",
700: "#27445d",
800: "#192b3a",
900: "#0d161f",
},
warning: {
50: '#fef4e6',
100: '#fde4bf',
200: '#fcd18e',
300: '#fbbc5c',
400: '#f9aa33',
500: '#f7971b',
600: '#c97a14',
700: '#9a5c0e',
800: '#6c3e08',
900: '#3e2404',
50: "#fef4e6",
100: "#fde4bf",
200: "#fcd18e",
300: "#fbbc5c",
400: "#f9aa33",
500: "#f7971b",
600: "#c97a14",
700: "#9a5c0e",
800: "#6c3e08",
900: "#3e2404",
},
danger: {
50: '#fbeaea',
100: '#f5cccc',
200: '#eba5a5',
300: '#e17e7e',
400: '#d96060',
500: '#c94848',
600: '#a53939',
700: '#7c2b2b',
800: '#521c1c',
900: '#2b0e0e',
50: "#fbeaea",
100: "#f5cccc",
200: "#eba5a5",
300: "#e17e7e",
400: "#d96060",
500: "#c94848",
600: "#a53939",
700: "#7c2b2b",
800: "#521c1c",
900: "#2b0e0e",
},
},
listStyleType: {
'upper-alpha': 'upper-alpha', // Uppercase letters
'lower-alpha': 'lower-alpha', // Lowercase letters
"upper-alpha": "upper-alpha", // Uppercase letters
"lower-alpha": "lower-alpha", // Lowercase letters
},
flexGrow: {
'1': '1',
'2': '2',
'3': '3',
1: "1",
2: "2",
3: "3",
},
hueRotate: {
20: '20deg',
}
20: "20deg",
},
},
},
plugins: [
flowbite(),
plugin(function({ addUtilities, matchUtilities }) {
plugin(function ({ addUtilities, matchUtilities }) {
addUtilities({
'.content-visibility-auto': {
'content-visibility': 'auto',
".content-visibility-auto": {
"content-visibility": "auto",
},
'.contain-size': {
contain: 'size',
".contain-size": {
contain: "size",
},
});
matchUtilities({
'contain-intrinsic-w-*': value => ({
"contain-intrinsic-w-*": (value) => ({
width: value,
}),
'contain-intrinsic-h-*': value => ({
"contain-intrinsic-h-*": (value) => ({
height: value,
})
}),
});
})
}),
],
darkMode: 'class',
darkMode: "class",
};
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 @@ @@ -1,18 +1,20 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
// 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.
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 @@ @@ -1,42 +1,50 @@
import { describe, it, expect } from 'vitest';
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser';
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser';
import { readFileSync } from 'fs';
import { join } from 'path';
import { describe, it, expect } from "vitest";
import { parseBasicmarkup } from "../../src/lib/utils/markup/basicMarkupParser";
import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser";
import { readFileSync } from "fs";
import { join } from "path";
const testFilePath = join(__dirname, './markupTestfile.md');
const md = readFileSync(testFilePath, 'utf-8');
const testFilePath = join(__dirname, "./markupTestfile.md");
const md = readFileSync(testFilePath, "utf-8");
describe('Markup Integration Test', () => {
it('parses markupTestfile.md with the basic parser', async () => {
describe("Markup Integration Test", () => {
it("parses markupTestfile.md with the basic parser", async () => {
const output = await parseBasicmarkup(md);
// Headers (should be present as text, not <h1> tags)
expect(output).toContain('This is a test');
expect(output).toContain('============');
expect(output).toContain('### Disclaimer');
// 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("### Disclaimer");
// Unordered list
expect(output).toContain('<ul');
expect(output).toContain('but');
expect(output).toContain("<ul");
expect(output).toContain("but");
// Ordered list
expect(output).toContain('<ol');
expect(output).toContain('first');
expect(output).toContain("<ol");
expect(output).toContain("first");
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain('<blockquote');
expect(output).toContain('This is important information');
expect(output).toContain("<blockquote");
expect(output).toContain("This is important information");
// Inline code
expect(output).toContain('<div class="leather min-h-full w-full flex flex-col items-center">');
expect(output).toContain(
'<div class="leather min-h-full w-full flex flex-col items-center">',
);
// Images
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/);
expect(output).toMatch(
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/,
);
// Links
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
expect(output).toMatch(
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/,
);
// Hashtags
expect(output).toContain('text-primary-600');
expect(output).toContain("text-primary-600");
// Nostr identifiers (should be Alexandria links)
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
expect(output).toContain(
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z",
);
// Wikilinks
expect(output).toContain('wikilink');
expect(output).toContain("wikilink");
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
@ -44,42 +52,50 @@ describe('Markup Integration Test', () => { @@ -44,42 +52,50 @@ describe('Markup Integration Test', () => {
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule (should be present as --- in basic)
expect(output).toContain('---');
expect(output).toContain("---");
// 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)
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);
// Headers
expect(output).toContain('<h1');
expect(output).toContain('<h2');
expect(output).toContain('Disclaimer');
expect(output).toContain("<h1");
expect(output).toContain("<h2");
expect(output).toContain("Disclaimer");
// Unordered list
expect(output).toContain('<ul');
expect(output).toContain('but');
expect(output).toContain("<ul");
expect(output).toContain("but");
// Ordered list
expect(output).toContain('<ol');
expect(output).toContain('first');
expect(output).toContain("<ol");
expect(output).toContain("first");
// Nested lists
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s);
// Blockquotes
expect(output).toContain('<blockquote');
expect(output).toContain('This is important information');
expect(output).toContain("<blockquote");
expect(output).toContain("This is important information");
// Inline code
expect(output).toMatch(/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s);
expect(output).toMatch(
/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s,
);
// Images
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/);
expect(output).toMatch(
/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/,
);
// Links
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/);
expect(output).toMatch(
/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/,
);
// Hashtags
expect(output).toContain('text-primary-600');
expect(output).toContain("text-primary-600");
// Nostr identifiers (should be Alexandria links)
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z');
expect(output).toContain(
"./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z",
);
// Wikilinks
expect(output).toContain('wikilink');
expect(output).toContain("wikilink");
// YouTube iframe
expect(output).toMatch(/<iframe[^>]+youtube/);
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
@ -87,13 +103,13 @@ describe('Markup Integration Test', () => { @@ -87,13 +103,13 @@ describe('Markup Integration Test', () => {
expect(output).not.toMatch(/fbclid/);
expect(output).not.toMatch(/gclid/);
// Horizontal rule
expect(output).toContain('<hr');
expect(output).toContain("<hr");
// Footnote references and section
expect(output).toContain('Footnotes');
expect(output).toContain("Footnotes");
expect(output).toMatch(/<li id=\"fn-1\">/);
// Table
expect(output).toContain('<table');
expect(output).toContain("<table");
// Code blocks
expect(output).toContain('<pre');
expect(output).toContain("<pre");
});
});

115
tests/integration/markupTestfile.md

@ -1,15 +1,19 @@ @@ -1,15 +1,19 @@
This is a test
============
# This is a test
### Disclaimer
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
It is _only_ a test, for **sure**. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of _one asterisk_.[^1]
# H1
## H2
### H3
#### H4
##### H5
###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser.
@ -24,7 +28,7 @@ npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That @@ -24,7 +28,7 @@ npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That
> lines of
> important information
> with a second[^2] footnote.
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984.
> [^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984.
This is a youtube link
https://www.youtube.com/watch?v=9aqVxNCpx9s
@ -33,42 +37,40 @@ And here is a link with tracking tokens: @@ -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
This is an unordered list:
* but
* not
* really
- but
- not
- really
This is an unordered list with nesting:
* but
* not
* really
* but
* yes,
* really
- but
- not
- really
- but
- yes,
- really
## More testing
An ordered list:
1. first
2. second
3. third
Let's nest that:
1. first
2. second indented
3. third
4. fourth indented
5. fifth indented even more
6. sixth under the fourth
7. seventh under the sixth
8. eighth under the third
1. first 2. second indented
2. third 4. fourth indented 5. fifth indented even more 6. sixth under the fourth 7. seventh under the sixth
3. eighth under the third
This is ordered and unordered mixed:
1. first
2. second indented
3. third
* make this a bullet point
4. fourth indented even more
* second bullet point
1. first 2. second indented
2. third
- make this a bullet point 4. fourth indented even more
- second bullet point
Here is a horizontal rule:
@ -130,13 +132,31 @@ in a code block @@ -130,13 +132,31 @@ in a code block
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++:
```cpp
bool getBit(int num, int i) {
return ((num & (1<<i)) != 0);
@ -144,6 +164,7 @@ bool getBit(int num, int i) { @@ -144,6 +164,7 @@ bool getBit(int num, int i) {
```
Asciidoc:
```adoc
= Header 1
@ -155,6 +176,7 @@ some more text @@ -155,6 +176,7 @@ some more text
```
Gherkin:
```gherkin
Feature: Account Holder withdraws cash
@ -169,6 +191,7 @@ Scenario: Account has sufficient funds @@ -169,6 +191,7 @@ Scenario: Account has sufficient funds
```
Go:
```go
package main
@ -190,17 +213,16 @@ package main @@ -190,17 +213,16 @@ package main
or even markup:
```md
A H1 Header
============
# A H1 Header
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:
* this one[^some reference text]
* that one
* the other one
- this one[^some reference text]
- that one
- the other one
Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.
@ -222,17 +244,17 @@ Test out some emojis :heart: and :trophy: @@ -222,17 +244,17 @@ Test out some emojis :heart: and :trophy:
A neat table[^some reference text]:
| Syntax | Description |
| ----------- | ----------- |
| Header | Title |
| Paragraph | Text |
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
A messy table (should render the same as above):
| Syntax | Description |
| --- | ----------- |
| Header | Title |
| Paragraph | Text |
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
Here is a table without a header row:
@ -240,5 +262,6 @@ Here is a table without a header row: @@ -240,5 +262,6 @@ Here is a table without a header row:
| need a | header |
| just | pipes |
[^1]: this is a footnote
[^some reference text]: this is a footnote that isn't a number
[^1]:
this is a footnote
[^some reference text]: this is a footnote that isn't a number

145
tests/unit/advancedMarkupParser.test.ts

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

101
tests/unit/latexRendering.test.ts

@ -0,0 +1,101 @@ @@ -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"; @@ -5,16 +5,20 @@ import { execSync } from "child_process";
// Function to get the latest git tag
function getAppVersionString() {
// 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;
}
try {
// 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;
} catch (error) {
return 'development';
return "development";
}
}
@ -22,20 +26,20 @@ export default defineConfig({ @@ -22,20 +26,20 @@ export default defineConfig({
plugins: [sveltekit()],
resolve: {
alias: {
$lib: './src/lib',
$components: './src/components'
}
$lib: "./src/lib",
$components: "./src/components",
},
},
build: {
rollupOptions: {
external: ['bech32']
}
external: ["bech32"],
},
},
test: {
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts']
include: ["./tests/unit/**/*.test.ts", "./tests/integration/**/*.test.ts"],
},
define: {
// 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