From c37a3f3a738085af8cc80dec1ca0eb627c54a584 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 9 Jul 2025 23:22:04 +0200 Subject: [PATCH] rudimentary LaTeX implementation for Markup --- .onedev-buildspec.yml | 30 +- .prettierrc | 2 +- .vscode/settings.json | 2 +- README.md | 26 +- deno.json | 2 +- docker-compose.yaml | 2 +- import_map.json | 2 +- maintainers.yaml | 10 +- playwright.config.ts | 30 +- postcss.config.js | 5 +- src/app.css | 48 +- src/app.html | 27 +- src/lib/components/CommentBox.svelte | 182 +++--- src/lib/components/EventDetails.svelte | 161 +++-- src/lib/components/EventLimitControl.svelte | 2 +- .../components/EventRenderLevelLimit.svelte | 10 +- src/lib/components/EventSearch.svelte | 185 ++++-- src/lib/components/Login.svelte | 46 +- src/lib/components/LoginModal.svelte | 64 +- src/lib/components/Preview.svelte | 196 ++++-- src/lib/components/Publication.svelte | 2 +- src/lib/components/PublicationFeed.svelte | 164 +++-- src/lib/components/PublicationHeader.svelte | 75 ++- src/lib/components/PublicationSection.svelte | 119 ++-- src/lib/components/RelayActions.svelte | 103 ++-- src/lib/components/RelayDisplay.svelte | 50 +- src/lib/components/RelayStatus.svelte | 91 +-- src/lib/components/Toc.svelte | 36 +- src/lib/components/cards/BlogHeader.svelte | 77 ++- src/lib/components/cards/ProfileHeader.svelte | 233 ++++--- src/lib/components/util/ArticleNav.svelte | 122 ++-- src/lib/components/util/CardActions.svelte | 211 ++++--- .../components/util/CopyToClipboard.svelte | 38 +- src/lib/components/util/Details.svelte | 90 ++- src/lib/components/util/Interactions.svelte | 84 ++- src/lib/components/util/Profile.svelte | 158 ++--- src/lib/components/util/QrCode.svelte | 4 +- src/lib/components/util/TocToggle.svelte | 27 +- src/lib/components/util/ZapOutline.svelte | 4 +- src/lib/consts.ts | 54 +- src/lib/data_structures/lazy.ts | 6 +- src/lib/data_structures/publication_tree.ts | 105 ++-- src/lib/navigator/EventNetwork/Legend.svelte | 40 +- .../navigator/EventNetwork/NodeTooltip.svelte | 90 +-- .../navigator/EventNetwork/Settings.svelte | 52 +- src/lib/navigator/EventNetwork/index.svelte | 270 ++++---- src/lib/navigator/EventNetwork/types.ts | 62 +- .../EventNetwork/utils/forceSimulation.ts | 285 +++++---- .../EventNetwork/utils/networkBuilder.ts | 418 ++++++------- src/lib/ndk.ts | 285 +++++---- src/lib/parser.ts | 576 ++++++++++-------- src/lib/snippets/PublicationSnippets.svelte | 14 +- src/lib/snippets/UserSnippets.svelte | 10 +- src/lib/stores.ts | 5 +- src/lib/stores/relayStore.ts | 4 +- src/lib/types.ts | 8 +- src/lib/utils.ts | 30 +- src/lib/utils/markup/MarkupInfo.md | 7 +- .../advancedAsciidoctorPostProcessor.ts | 99 +-- src/lib/utils/markup/advancedMarkupParser.ts | 375 ++++++++---- src/lib/utils/markup/asciidoctorExtensions.ts | 93 +-- .../utils/markup/asciidoctorPostProcessor.ts | 91 +-- src/lib/utils/markup/basicMarkupParser.ts | 271 ++++---- src/lib/utils/markup/tikzRenderer.ts | 12 +- src/lib/utils/mime.ts | 27 +- src/lib/utils/nostrUtils.ts | 257 +++++--- src/lib/utils/npubCache.ts | 4 +- src/routes/+layout.svelte | 33 +- src/routes/+layout.ts | 26 +- src/routes/+page.svelte | 78 ++- src/routes/[...catchall]/+page.svelte | 22 +- src/routes/about/+page.svelte | 5 +- src/routes/contact/+page.svelte | 397 +++++++----- src/routes/events/+page.svelte | 98 +-- src/routes/new/compose/+page.svelte | 15 +- src/routes/new/edit/+page.svelte | 62 +- src/routes/publication/+error.svelte | 40 +- src/routes/publication/+page.ts | 70 ++- src/routes/start/+page.svelte | 7 +- src/routes/visualize/+page.svelte | 23 +- src/styles/base.css | 8 +- src/styles/events.css | 8 +- src/styles/publications.css | 574 ++++++++--------- src/styles/scrollbar.css | 32 +- src/styles/visualize.css | 204 +++---- src/types/d3.d.ts | 10 +- src/types/global.d.ts | 2 +- src/types/plantuml-encoder.d.ts | 4 +- tailwind.config.cjs | 142 ++--- test_data/latex_markdown.md | 50 ++ tests/e2e/example.pw.spec.ts | 16 +- tests/integration/markupIntegration.test.ts | 114 ++-- tests/integration/markupTestfile.md | 123 ++-- tests/unit/advancedMarkupParser.test.ts | 147 +++-- tests/unit/basicMarkupParser.test.ts | 102 ++-- tests/unit/latexRendering.test.ts | 101 +++ vite.config.ts | 28 +- 97 files changed, 5218 insertions(+), 3593 deletions(-) create mode 100644 test_data/latex_markdown.md create mode 100644 tests/unit/latexRendering.test.ts diff --git a/.onedev-buildspec.yml b/.onedev-buildspec.yml index d1e6dc2..193f40e 100644 --- a/.onedev-buildspec.yml +++ b/.onedev-buildspec.yml @@ -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 \ No newline at end of file + - 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 diff --git a/.prettierrc b/.prettierrc index 5cce348..a05eb6c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,3 @@ { - "plugins":["prettier-plugin-svelte"] + "plugins": ["prettier-plugin-svelte"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index e06c2f4..3ff535b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,4 +11,4 @@ "files.associations": { "*.svelte": "svelte" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index ed56197..5615dac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Roman scrolls](https://i.nostr.build/M5qXa.jpg) +![Roman scrolls](https://i.nostr.build/M5qXa.jpg) # Alexandria @@ -18,45 +18,53 @@ You can also contact us [on Nostr](https://next-alexandria.gitcitadel.eu/events? Make sure that you have [Node.js](https://nodejs.org/en/download/package-manager) (v22 or above) or [Deno](https://docs.deno.com/runtime/getting_started/installation/) (v2) installed. 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 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 ``` @@ -83,7 +93,7 @@ If you want to see the container process (assuming it's the last process to star docker ps -l ``` -which should return something like: +which should return something like: ```bash CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES @@ -92,32 +102,36 @@ 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 ``` ## Markup Support -Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md). \ No newline at end of file +Alexandria supports both Markdown and AsciiDoc markup for different content types. For a detailed list of supported tags and features in the basic and advanced markdown parsers, as well as information about AsciiDoc usage for publications and wikis, see [MarkupInfo.md](./src/lib/utils/markup/MarkupInfo.md). diff --git a/deno.json b/deno.json index e53d975..8b474cb 100644 --- a/deno.json +++ b/deno.json @@ -4,4 +4,4 @@ "allowJs": true, "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"] } -} \ No newline at end of file +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 4c4dfb5..ad37163 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -version: '3' +version: "3" services: wikinostr: diff --git a/import_map.json b/import_map.json index 4c3af16..811c3ed 100644 --- a/import_map.json +++ b/import_map.json @@ -16,4 +16,4 @@ "flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.1.x", "child_process": "node:child_process" } -} \ No newline at end of file +} diff --git a/maintainers.yaml b/maintainers.yaml index 42e1f9b..7b8d3c9 100644 --- a/maintainers.yaml +++ b/maintainers.yaml @@ -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 diff --git a/playwright.config.ts b/playwright.config.ts index dd839c6..cee1e49 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'; * 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({ /* 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({ // 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({ // Maximum time expect() should wait for the condition to be met. timeout: 5000, }, - -}); \ No newline at end of file +}); diff --git a/postcss.config.js b/postcss.config.js index 1105d3a..319ae11 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -2,8 +2,5 @@ import tailwindcss from "tailwindcss"; import autoprefixer from "autoprefixer"; export default { - plugins: [ - tailwindcss(), - autoprefixer(), - ] + plugins: [tailwindcss(), autoprefixer()], }; diff --git a/src/app.css b/src/app.css index 5db7fde..e6cac91 100644 --- a/src/app.css +++ b/src/app.css @@ -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 @@ @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 @@ } /* 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 @@ @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 @@ @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 @@ @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 @@ } @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 @@ thead, tbody { - th, td { @apply border border-gray-200 dark:border-gray-700; @@ -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; } @@ -497,4 +495,4 @@ @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none px-4 py-2; @apply focus:border-primary-600 dark:focus:border-primary-400; } -} \ No newline at end of file +} diff --git a/src/app.html b/src/app.html index b38ec6a..97127be 100644 --- a/src/app.html +++ b/src/app.html @@ -4,28 +4,37 @@ - + - + - + %sveltekit.head% diff --git a/src/lib/components/CommentBox.svelte b/src/lib/components/CommentBox.svelte index 5f279bb..9c6dd9f 100644 --- a/src/lib/components/CommentBox.svelte +++ b/src/lib/components/CommentBox.svelte @@ -1,14 +1,19 @@
{#if event.kind !== 0 && getEventTitle(event)} -

{getEventTitle(event)}

+

+ {getEventTitle(event)} +

{/if}
{#if toNpub(event.pubkey)} - Author: {@render userBadge(toNpub(event.pubkey) as string, authorDisplayName)} + Author: {@render userBadge( + toNpub(event.pubkey) as string, + authorDisplayName, + )} {:else} @@ -138,7 +190,9 @@
Kind: {event.kind} - ({getEventTypeDisplay(event)}) + ({getEventTypeDisplay(event)})
{#if getEventSummary(event)} @@ -153,7 +207,10 @@ Tags:
{#each getEventHashtags(event) as tag} - #{tag} + #{tag} {/each}
@@ -166,7 +223,10 @@
{@html showFullContent ? parsedContent : contentPreview} {#if !showFullContent && parsedContent.length > 250} - + {/if}
{/if} @@ -174,7 +234,11 @@ {#if event.kind === 0} - + {/if} @@ -186,7 +250,8 @@ {@const tagInfo = getTagButtonInfo(tag)} {#if tagInfo.text && tagInfo.gotoValue}
\ No newline at end of file + diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte index a6e3ae8..9a32a56 100644 --- a/src/lib/components/EventLimitControl.svelte +++ b/src/lib/components/EventLimitControl.svelte @@ -45,7 +45,7 @@ /> diff --git a/src/lib/components/EventRenderLevelLimit.svelte b/src/lib/components/EventRenderLevelLimit.svelte index b6addc5..9bce52c 100644 --- a/src/lib/components/EventRenderLevelLimit.svelte +++ b/src/lib/components/EventRenderLevelLimit.svelte @@ -29,10 +29,14 @@
- - + diff --git a/src/lib/components/EventSearch.svelte b/src/lib/components/EventSearch.svelte index 53ea48e..d731169 100644 --- a/src/lib/components/EventSearch.svelte +++ b/src/lib/components/EventSearch.svelte @@ -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 @@ let searchQuery = $state(""); let localError = $state(null); - let relayStatuses = $state>({}); + let relayStatuses = $state>( + {}, + ); let foundEvent = $state(null); let searching = $state(false); @@ -43,25 +53,29 @@ async function searchByDTag(dTag: string) { localError = null; searching = true; - + // Convert d-tag to lowercase for consistent searching 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) { localError = `No events found with d-tag: ${normalizedDTag}`; onSearchResults([]); @@ -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 @@ // 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 @@ 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 @@ 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 @@ 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)} />
{#if localError || error} - \ No newline at end of file + diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte index e0d1171..e24490d 100644 --- a/src/lib/components/Login.svelte +++ b/src/lib/components/Login.svelte @@ -1,21 +1,27 @@ -
{#if $ndkSignedIn} {:else} - + -
- +
+ {#if signInFailed}
{errorMessage} diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte index 65d9a35..b52d44e 100644 --- a/src/lib/components/LoginModal.svelte +++ b/src/lib/components/LoginModal.svelte @@ -1,15 +1,19 @@ {#if show} -
+
-
+
-
-

Login Required

-
- +
-

- You need to be logged in to submit an issue. Your form data will be preserved. +

+ You need to be logged in to submit an issue. Your form data will be + preserved.

-
{#if signInFailed} -
+
{errorMessage}
{/if} @@ -74,4 +92,4 @@
-{/if} \ No newline at end of file +{/if} diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 8a33d01..67f1fa2 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -1,11 +1,26 @@ - -
-
+
+
{#if loading && eventsInView.length === 0} {#each getSkeletonIds() as id} - + {/each} {:else if eventsInView.length > 0} {#each eventsInView as event} {/each} {:else} -
-

No publications found.

+
+

No publications found.

{/if}
{#if !loadingMore && !endOfFeed} -
-
{:else if loadingMore} -
+
{:else} -
-

You've reached the end of the feed.

+
+

You've reached the end of the feed.

{/if}
diff --git a/src/lib/components/PublicationHeader.svelte b/src/lib/components/PublicationHeader.svelte index 1014b80..685449e 100644 --- a/src/lib/components/PublicationHeader.svelte +++ b/src/lib/components/PublicationHeader.svelte @@ -1,12 +1,12 @@ {#if title != null && href != null} - + {#if image} -
- -
+
+ +
{/if} -
+ diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index cb60e0c..1d60966 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -1,13 +1,16 @@ - -
- {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} - +
+ {#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )} + {: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}
diff --git a/src/lib/components/RelayActions.svelte b/src/lib/components/RelayActions.svelte index dc572a9..0f21891 100644 --- a/src/lib/components/RelayActions.svelte +++ b/src/lib/components/RelayActions.svelte @@ -1,10 +1,16 @@
- {#if $ndkInstance?.activeUser} - {/if}
@@ -160,23 +168,32 @@
{#if showRelayModal} -
-
- +
+
+

Relay Search Results

- {#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}
-

+

{groupName}

{#each groupRelays as relay} - + {/each}
{/if} @@ -187,4 +204,4 @@
-{/if} \ No newline at end of file +{/if} diff --git a/src/lib/components/RelayDisplay.svelte b/src/lib/components/RelayDisplay.svelte index ffaa963..f717d42 100644 --- a/src/lib/components/RelayDisplay.svelte +++ b/src/lib/components/RelayDisplay.svelte @@ -1,14 +1,16 @@ -
+
relay icon { (e.target as HTMLImageElement).src = '/favicon.png'; }} + onerror={(e) => { + (e.target as HTMLImageElement).src = "/favicon.png"; + }} /> {relay} {#if showStatus && status} - {#if status === 'pending'} - - - + {#if status === "pending"} + + + - {:else if status === 'found'} + {:else if status === "found"} {:else} {/if} {/if} -
\ No newline at end of file +
diff --git a/src/lib/components/RelayStatus.svelte b/src/lib/components/RelayStatus.svelte index 51bfb1d..92c5028 100644 --- a/src/lib/components/RelayStatus.svelte +++ b/src/lib/components/RelayStatus.svelte @@ -1,11 +1,17 @@

Relay Connection Status

-
@@ -131,8 +129,8 @@ Anonymous Mode

- 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.

{/if} @@ -146,12 +144,17 @@ {getStatusText(status)}
-
+
{/each}
- {#if relayStatuses.some(s => s.requiresAuth && !$ndkSignedIn)} + {#if relayStatuses.some((s) => s.requiresAuth && !$ndkSignedIn)} Authentication Required

@@ -159,4 +162,4 @@

{/if} -
\ No newline at end of file +
diff --git a/src/lib/components/Toc.svelte b/src/lib/components/Toc.svelte index 9d433b5..db49b82 100644 --- a/src/lib/components/Toc.svelte +++ b/src/lib/components/Toc.svelte @@ -1,24 +1,28 @@
-

Table of contents

- +

Table of contents

+
diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index b335074..9b55a28 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -1,24 +1,38 @@ {#if title != null} - -
+ +
{@render userBadge(authorPubkey, author)} - {publishedAt()} + {publishedAt()}
- +
{#if image && active} -
- +
{/if} -
- {#if hashtags} -
- {#each hashtags as tag} - {tag} - {/each} -
+
+ {#each hashtags as tag} + {tag} + {/each} +
{/if}
{#if active} - + {/if}
diff --git a/src/lib/components/cards/ProfileHeader.svelte b/src/lib/components/cards/ProfileHeader.svelte index 63f858a..738dfe8 100644 --- a/src/lib/components/cards/ProfileHeader.svelte +++ b/src/lib/components/cards/ProfileHeader.svelte @@ -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(null); @@ -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"); } } }); {#if profile} - -
- {#if profile.banner} -
- Profile banner { (e.target as HTMLImageElement).style.display = 'none';}} /> -
- {/if} -
- {#if profile.picture} - Profile avatar { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> + +
+ {#if profile.banner} +
+ Profile banner { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
{/if} - {@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.display_name || profile.name || event.pubkey)} -
-
-
-
- {#if profile.name} -
-
Name:
-
{profile.name}
-
- {/if} - {#if profile.displayName} -
-
Display Name:
-
{profile.displayName}
-
- {/if} - {#if profile.about} -
-
About:
-
{profile.about}
-
- {/if} - {#if profile.website} -
-
Website:
-
- {profile.website} -
-
- {/if} - {#if profile.lud16} -
-
Lightning Address:
-
-
- {/if} - {#if profile.nip05} -
-
NIP-05:
-
{profile.nip05}
-
- {/if} - {#each identifiers as id} -
-
{id.label}:
-
{#if id.link}{id.value}{:else}{id.value}{/if}
-
- {/each} -
+
+ {#if profile.picture} + Profile avatar { + (e.target as HTMLImageElement).src = "/favicon.png"; + }} + /> + {/if} + {@render userBadge( + toNpub(event.pubkey) as string, + profile.displayName || + profile.display_name || + profile.name || + event.pubkey, + )} +
+
+
+
+ {#if profile.name} +
+
Name:
+
{profile.name}
+
+ {/if} + {#if profile.displayName} +
+
Display Name:
+
{profile.displayName}
+
+ {/if} + {#if profile.about} +
+
About:
+
{profile.about}
+
+ {/if} + {#if profile.website} +
+
Website:
+
+ {profile.website} +
+
+ {/if} + {#if profile.lud16} +
+
Lightning Address:
+
+ +
+
+ {/if} + {#if profile.nip05} +
+
NIP-05:
+
{profile.nip05}
+
+ {/if} + {#each identifiers as id} +
+
{id.label}:
+
+ {#if id.link}{id.value}{:else}{id.value}{/if} +
+
+ {/each} +
+
-
-
+ - - {#if profile.lud16} -
-
- {@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)} -

{profile.lud16}

-
-
-

Scan the QR code or copy the address

- {#if lnurl} -

- -

- - {:else} -

Couldn't generate address.

- {/if} -
-
- {/if} -
-{/if} \ No newline at end of file + + {#if profile.lud16} +
+
+ {@render userBadge( + toNpub(event.pubkey) as string, + profile?.displayName || profile.name || event.pubkey, + )} +

{profile.lud16}

+
+
+

Scan the QR code or copy the address

+ {#if lnurl} +

+ +

+ + {:else} +

Couldn't generate address.

+ {/if} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index a5b8631..1fcc001 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -1,35 +1,41 @@ - diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte index 987e7b0..ca8ae10 100644 --- a/src/lib/components/util/CardActions.svelte +++ b/src/lib/components/util/CardActions.svelte @@ -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 @@ 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); @@ -43,18 +65,18 @@ (() => { const isUserFeed = $ndkSignedIn && $feedType === FeedType.UserRelays; const relays = isUserFeed ? $inboxRelays : standardRelays; - + console.debug("[CardActions] Selected relays:", { eventId: event.id, isSignedIn: $ndkSignedIn, feedType: $feedType, isUserFeed, relayCount: relays.length, - relayUrls: relays + relayUrls: relays, }); - + return relays; - })() + })(), ); /** @@ -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 @@ * @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; } @@ -91,10 +116,10 @@ * Opens the event details modal */ function viewDetails() { - console.debug("[CardActions] Opening details modal", { + console.debug("[CardActions] Opening details modal", { eventId: event.id, title: event.title, - author: event.author + author: event.author, }); detailsModalOpen = true; } @@ -105,90 +130,128 @@ kind: event.kind, pubkey: event.pubkey, title: event.title, - author: event.author + author: event.author, }); -
+
- {#if isOpen} - -
-
-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
+ +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
-
- + {/if} - +
{#if image} -
- Publication cover +
+ Publication cover
{/if}
-

{title || 'Untitled'}

-

by +

{title || "Untitled"}

+

+ by {#if originalAuthor} - {@render userBadge(originalAuthor, author)} + {@render userBadge(originalAuthor, author)} {:else} - {author || 'Unknown'} + {author || "Unknown"} {/if}

{#if version} -

Version: {version}

+

+ Version: {version} +

{/if}
{#if summary}
-

{summary}

+

{summary}

{/if}
-

Index author: {@render userBadge(event.pubkey, author)}

+

+ Index author: {@render userBadge(event.pubkey, author)} +

{#if source} -
Source: {source}
+
+ Source: {source} +
{/if} {#if type}
Publication type: {type}
@@ -202,12 +265,12 @@ {#if identifier}
Identifier: {identifier}
{/if} - View Event Details
-
\ No newline at end of file +
diff --git a/src/lib/components/util/CopyToClipboard.svelte b/src/lib/components/util/CopyToClipboard.svelte index 600ff65..d848f8c 100644 --- a/src/lib/components/util/CopyToClipboard.svelte +++ b/src/lib/components/util/CopyToClipboard.svelte @@ -1,9 +1,16 @@ - - - - - +
+ + + +
- -

Can't like, zap or highlight yet.

-

You should totally check out the discussion though.

-
\ No newline at end of file + +

Can't like, zap or highlight yet.

+

You should totally check out the discussion though.

+
diff --git a/src/lib/components/util/Profile.svelte b/src/lib/components/util/Profile.svelte index 2b41458..77dbaf9 100644 --- a/src/lib/components/util/Profile.svelte +++ b/src/lib/components/util/Profile.svelte @@ -1,99 +1,111 @@ -
{#if profile} -
- - {#key username || tag} - -
-
- {#if username} -

{username}

- {#if isNav}

@{tag}

{/if} - {/if} - +
-
- - {/key} -
+
+ {/key} +
{/if}
diff --git a/src/lib/components/util/QrCode.svelte b/src/lib/components/util/QrCode.svelte index 616b995..719d9e6 100644 --- a/src/lib/components/util/QrCode.svelte +++ b/src/lib/components/util/QrCode.svelte @@ -1,6 +1,6 @@ - + diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 034f8d2..c661399 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -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 standardRelays = [ - 'wss://thecitadel.nostr1.com', - 'wss://theforest.nostr1.com', - 'wss://profiles.nostr1.com', - 'wss://gitcitadel.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.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', +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://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"; diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts index d6d6035..6959a60 100644 --- a/src/lib/data_structures/lazy.ts +++ b/src/lib/data_structures/lazy.ts @@ -18,9 +18,9 @@ export class Lazy { /** * Resolves the lazy object and returns the value. - * + * * @returns The resolved value. - * + * * @remarks Lazy object resolution is performed as an atomic operation. If a resolution has * already been requested when this function is invoked, the pending promise from the earlier * invocation is returned. Thus, all calls to this function before it is resolved will depend on @@ -52,4 +52,4 @@ export class Lazy { this.#pendingPromise = null; } } -} \ No newline at end of file +} diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index eabaa5b..e0c56fa 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -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 { }; this.#nodes = new Map>(); - this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))); + this.#nodes.set( + rootAddress, + new Lazy(() => Promise.resolve(this.#root)), + ); this.#events = new Map(); this.#events.set(rootAddress, rootEvent); @@ -85,7 +88,7 @@ export class PublicationTree implements AsyncIterable { 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 { 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 { async getChildAddresses(address: string): Promise> { 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,11 +168,13 @@ export class PublicationTree implements AsyncIterable { async getHierarchy(address: string): Promise { 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)!]; - + while (node.parent) { hierarchy.push(this.#events.get(node.parent.address)!); node = node.parent; @@ -187,7 +194,7 @@ export class PublicationTree implements AsyncIterable { // #region Iteration Cursor - #cursor = new class { + #cursor = new (class { target: PublicationTreeNode | null | undefined; #tree: PublicationTree; @@ -199,7 +206,9 @@ export class PublicationTree implements AsyncIterable { 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(); } @@ -224,7 +233,7 @@ export class PublicationTree implements AsyncIterable { if (this.target.children == null || this.target.children.length === 0) { return false; } - + this.target = await this.target.children?.at(0)?.value(); return true; } @@ -234,15 +243,15 @@ export class PublicationTree implements AsyncIterable { console.debug("Cursor: Target node is null or undefined."); return false; } - + if (this.target.type === PublicationTreeNodeType.Leaf) { return false; } - + if (this.target.children == null || this.target.children.length === 0) { return false; } - + this.target = await this.target.children?.at(-1)?.value(); return true; } @@ -260,7 +269,8 @@ export class PublicationTree implements AsyncIterable { } const currentIndex = await siblings.findIndexAsync( - async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address + async (sibling: Lazy) => + (await sibling.value())?.address === this.target!.address, ); if (currentIndex === -1) { @@ -280,25 +290,26 @@ export class PublicationTree implements AsyncIterable { console.debug("Cursor: Target node is null or undefined."); return false; } - + const parent = this.target.parent; const siblings = parent?.children; if (!siblings) { return false; } - + const currentIndex = await siblings.findIndexAsync( - async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address + async (sibling: Lazy) => + (await sibling.value())?.address === this.target!.address, ); if (currentIndex === -1) { return false; } - + if (currentIndex <= 0) { return false; } - + this.target = await siblings.at(currentIndex - 1)?.value(); return true; } @@ -317,7 +328,7 @@ export class PublicationTree implements AsyncIterable { this.target = parent; return true; } - }(this); + })(this); // #endregion @@ -369,7 +380,7 @@ export class PublicationTree implements AsyncIterable { return { done: false, value: event }; } } - + // Based on Raymond Chen's tree traversal algorithm example. // https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 do { @@ -412,17 +423,23 @@ export class PublicationTree implements AsyncIterable { 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 { } 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,38 +482,42 @@ export class PublicationTree implements AsyncIterable { #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(() => this.#resolveNode(address, parentNode)); + const lazyNode = new Lazy(() => + this.#resolveNode(address, parentNode), + ); parentNode.children!.push(lazyNode); this.#nodes.set(address, lazyNode); } /** * Resolves a node address into an event, and creates new nodes for its children. - * + * * This method is intended for use as a {@link Lazy} resolver. - * + * * @param address The address of the node to resolve. * @param parentNode The parent node of the node to resolve. * @returns The resolved node. */ async #resolveNode( address: string, - parentNode: PublicationTreeNode + parentNode: PublicationTreeNode, ): Promise { - 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,8 +531,10 @@ export class PublicationTree implements AsyncIterable { 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), status: PublicationTreeNodeStatus.Resolved, @@ -528,7 +551,7 @@ export class PublicationTree implements AsyncIterable { } #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; } @@ -536,4 +559,4 @@ export class PublicationTree implements AsyncIterable { } // #endregion -} \ No newline at end of file +} diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 024037f..b553cab 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -1,12 +1,12 @@ -
+

Settings

- @@ -528,50 +551,82 @@ {/if}
- + - - + + - - + +
- - -
diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index db2d46b..1667a3a 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -1,6 +1,6 @@ /** * Type definitions for the Event Network visualization - * + * * This module defines the core data structures used in the D3 force-directed * graph visualization of Nostr events. */ @@ -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 { * Represents connections between nodes */ export interface SimulationLinkDatum { - 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 { * 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 { * Extends the base simulation link with event-specific properties */ export interface NetworkLink extends SimulationLinkDatum { - 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 { * Used to track relationships and build the final graph */ export interface GraphState { - nodeMap: Map; // Maps event IDs to nodes - links: NetworkLink[]; // All links in the graph - eventMap: Map; // Maps event IDs to original events - referencedIds: Set; // Set of event IDs referenced by other events + nodeMap: Map; // Maps event IDs to nodes + links: NetworkLink[]; // All links in the graph + eventMap: Map; // Maps event IDs to original events + referencedIds: Set; // Set of event IDs referenced by other events } diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 34731b3..dbcb1e0 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -1,6 +1,6 @@ /** * D3 Force Simulation Utilities - * + * * This module provides utilities for creating and managing D3 force-directed * graph simulations for the event network visualization. */ @@ -27,18 +27,18 @@ function debug(...args: any[]) { * Provides type safety for simulation operations */ export interface Simulation { - 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,155 +46,173 @@ export interface Simulation { * Provides type safety for drag operations */ export interface D3DragEvent { - 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; } /** * Updates a node's velocity by applying a force - * + * * @param node - The node to update * @param deltaVx - Change in x velocity * @param deltaVy - Change in y velocity */ export function updateNodeVelocity( - node: NetworkNode, - deltaVx: number, - deltaVy: number + 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 }); + } } /** * Applies a logarithmic gravity force pulling the node toward the center - * + * * The logarithmic scale ensures that nodes far from the center experience * stronger gravity, preventing them from drifting too far away. - * + * * @param node - The node to apply gravity to * @param centerX - X coordinate of the center * @param centerY - Y coordinate of the center * @param alpha - Current simulation alpha (cooling factor) */ export function applyGlobalLogGravity( - node: NetworkNode, - 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); } /** * Applies gravity between connected nodes - * + * * This creates a cohesive force that pulls connected nodes toward their * collective center of gravity, creating more meaningful clusters. - * + * * @param node - The node to apply connected gravity to * @param links - All links in the network * @param alpha - Current simulation alpha (cooling factor) */ export function applyConnectedGravity( - node: NetworkNode, - links: NetworkLink[], - alpha: number, + 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); } /** * Sets up drag behavior for nodes - * + * * This enables interactive dragging of nodes in the visualization. - * + * * @param simulation - The D3 force simulation * @param warmupClickEnergy - Alpha target when dragging starts (0-1) * @returns D3 drag behavior configured for the simulation */ export function setupDragHandlers( - simulation: Simulation, - warmupClickEnergy: number = 0.9 + simulation: Simulation, + warmupClickEnergy: number = 0.9, ) { - return d3 - .drag() - .on("start", (event: D3DragEvent, 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, d: NetworkNode) => { - // Update fixed position to mouse position - d.fx = event.x; - d.fy = event.y; - }) - .on("end", (event: D3DragEvent, 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, + 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, + d: NetworkNode, + ) => { + // Update fixed position to mouse position + d.fx = event.x; + d.fy = event.y; + }, + ) + .on( + "end", + ( + event: D3DragEvent, + d: NetworkNode, + ) => { + // Cool down simulation when drag ends + if (!event.active) { + simulation.alphaTarget(0); + } + // Release fixed position + d.fx = null; + d.fy = null; + }, + ); } /** * Creates a D3 force simulation for the network - * + * * @param nodes - Array of network nodes * @param links - Array of network links * @param nodeRadius - Radius of node circles @@ -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 { - 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; + } } diff --git a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts index 236f702..e05db37 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -1,6 +1,6 @@ /** * Network Builder Utilities - * + * * This module provides utilities for building a network graph from Nostr events. * It handles the creation of nodes and links, and the processing of event relationships. */ @@ -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 @@ -27,165 +27,169 @@ function debug(...args: any[]) { /** * Creates a NetworkNode from an NDKEvent - * + * * Extracts relevant information from the event and creates a node representation * for the visualization. - * + * * @param event - The Nostr event to convert to a node * @param level - The hierarchy level of the node (default: 0) * @returns A NetworkNode object representing the event */ export function createNetworkNode( - event: NDKEvent, - level: number = 0 + 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"; + debug("Creating network node", { + eventId: event.id, + kind: event.kind, + level, + }); - // Create the base node with essential properties - const node: NetworkNode = { + 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; } /** * Creates a map of event IDs to events for quick lookup - * + * * @param events - Array of Nostr events * @returns Map of event IDs to events */ export function createEventMap(events: NDKEvent[]): Map { - debug("Creating event map", { eventCount: events.length }); - - const eventMap = new Map(); - events.forEach((event) => { - if (event.id) { - eventMap.set(event.id, event); - } - }); - - debug("Event map created", { mapSize: eventMap.size }); - return eventMap; + debug("Creating event map", { eventCount: events.length }); + + const eventMap = new Map(); + events.forEach((event) => { + if (event.id) { + eventMap.set(event.id, event); + } + }); + + debug("Event map created", { mapSize: eventMap.size }); + return eventMap; } /** * Extracts an event ID from an 'a' tag - * + * * @param tag - The tag array from a Nostr event * @returns The event ID or null if not found */ export function extractEventIdFromATag(tag: string[]): string | null { - return tag[3] || null; + return tag[3] || null; } /** * Generates a deterministic color for an event based on its ID - * + * * This creates visually distinct colors for different index events * while ensuring the same event always gets the same color. - * + * * @param eventId - The event ID to generate a color for * @returns An HSL color string */ export function getEventColor(eventId: string): string { - // Use first 4 characters of event ID as a hex number - const num = parseInt(eventId.slice(0, 4), 16); - // Convert to a hue value (0-359) - const hue = num % 360; - // Use fixed saturation and lightness for pastel colors - const saturation = 70; - const lightness = 75; - return `hsl(${hue}, ${saturation}%, ${lightness}%)`; + // Use first 4 characters of event ID as a hex number + const num = parseInt(eventId.slice(0, 4), 16); + // Convert to a hue value (0-359) + const hue = num % 360; + // Use fixed saturation and lightness for pastel colors + const saturation = 70; + const lightness = 75; + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } /** * Initializes the graph state from a set of events - * + * * Creates nodes for all events and identifies referenced events. - * + * * @param events - Array of Nostr events * @returns Initial graph state */ export function initializeGraphState(events: NDKEvent[]): GraphState { - debug("Initializing graph state", { eventCount: events.length }); - - const nodeMap = new Map(); - 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(); + 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(); + 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(); - 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, + }; } /** * Processes a sequence of nodes referenced by an index event - * + * * Creates links between the index and its content, and between sequential content nodes. * Also processes nested indices recursively up to the maximum level. - * + * * @param sequence - Array of nodes in the sequence * @param indexEvent - The index event referencing the sequence * @param level - Current hierarchy level @@ -193,149 +197,147 @@ 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; + // 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; + // 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); + } } /** * Processes a nested index node - * + * * @param node - The index node to process * @param level - Current hierarchy level * @param state - Current graph state * @param maxLevel - Maximum hierarchy level to process */ export function processNestedIndex( - node: NetworkNode, - level: number, - 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); + } } /** * Processes an index event and its referenced content - * + * * @param indexEvent - The index event to process * @param level - Current hierarchy level * @param state - Current graph state * @param maxLevel - Maximum hierarchy level to process */ export function processIndexEvent( - indexEvent: NDKEvent, - level: number, - 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); } /** * Generates a complete graph from a set of events - * + * * This is the main entry point for building the network visualization. - * + * * @param events - Array of Nostr events * @param maxLevel - Maximum hierarchy level to process * @returns Complete graph data for visualization */ -export function generateGraph( - events: NDKEvent[], - maxLevel: number -): GraphData { - debug("Generating graph", { eventCount: events.length, maxLevel }); - - // Initialize the graph state - const state = initializeGraphState(events); - - // 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); - }); +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), + ); - // 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("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, }); - - return result; + processIndexEvent(rootIndex, 0, state, maxLevel); + }); + + // Create the final graph data + const result = { + nodes: Array.from(state.nodeMap.values()), + links: state.links, + }; + + debug("Graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length, + }); + + return result; } diff --git a/src/lib/ndk.ts b/src/lib/ndk.ts index a518ff4..cbdf546 100644 --- a/src/lib/ndk.ts +++ b/src/lib/ndk.ts @@ -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 = writable(); @@ -31,50 +44,63 @@ class CustomRelayAuthPolicy { */ async authenticate(relay: NDKRelay): Promise { 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; } try { 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 { + private async handleAuthChallenge( + relay: NDKRelay, + challenge: string, + ): Promise { 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,35 +109,42 @@ 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 const authNDKEvent = new NDKEvent(this.ndk, authEvent); await authNDKEvent.sign(); - + // 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 { + private async handleAuthRequired( + relay: NDKRelay, + message: string, + ): Promise { 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 { * Checks if the current environment might cause WebSocket protocol downgrade */ export function checkEnvironmentForWebSocketDowngrade(): void { - 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:'; - - 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] 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:"; + + 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 { * 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 { * @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; @@ -182,10 +225,10 @@ export async function testRelayConnection(relayUrl: string, ndk: NDK): Promise<{ }> { return new Promise((resolve) => { console.debug(`[NDK.ts] Testing connection to: ${relayUrl}`); - + // Ensure the URL is using wss:// protocol const secureUrl = ensureSecureWebSocket(relayUrl); - + const relay = new NDKRelay(secureUrl, undefined, new NDK()); let authRequired = false; let connected = false; @@ -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<{ 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 { * @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 { * @param inboxes The user's inbox relays. * @param outboxes The user's outbox relays. */ -function persistRelays(user: NDKUser, inboxes: Set, outboxes: Set): void { +function persistRelays( + user: NDKUser, + inboxes: Set, + outboxes: Set, +): 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, outboxes: Set, Set] { const inboxes = new Set( - JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]') + JSON.parse(localStorage.getItem(getRelayStorageKey(user, "inbox")) ?? "[]"), ); const outboxes = new Set( - 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,12 +377,14 @@ 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; } @@ -342,21 +393,25 @@ function ensureSecureWebSocket(url: string): string { */ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { console.debug(`[NDK.ts] Creating relay with URL: ${url}`); - + // 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); }); } - + return relay; } @@ -364,15 +419,17 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { // Use anonymous relays if user is not signed in const isSignedIn = ndk.signer && ndk.activeUser; const relays = isSignedIn ? standardRelays : anonymousRelays; - + 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,17 +440,20 @@ 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); - - console.debug('[NDK.ts] Initializing NDK with relay URLs:', secureRelayUrls); - + const secureRelayUrls = ( + startingInboxes != null + ? Array.from(startingInboxes.values()) + : anonymousRelays + ).map(ensureSecureWebSocket); + + console.debug("[NDK.ts] Initializing NDK with relay URLs:", secureRelayUrls); + const ndk = new NDK({ autoConnectUserRelays: true, enableOutboxModel: true, @@ -413,7 +473,9 @@ export function initNdk(): NDK { * @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because * NDK is unable to fetch the user's profile or relay lists. */ -export async function loginWithExtension(pubkey?: string): Promise { +export async function loginWithExtension( + pubkey?: string, +): Promise { try { const ndk = get(ndkInstance); const signer = new NDKNip07Signer(); @@ -421,12 +483,13 @@ export async function loginWithExtension(pubkey?: string): Promise 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,14 +538,14 @@ export function logout(user: NDKUser): void { async function getUserPreferredRelays( ndk: NDK, user: NDKUser, - fallbacks: readonly string[] = fallbackRelays + fallbacks: readonly string[] = fallbackRelays, ): Promise<[Set, Set]> { const relayList = await ndk.fetchEvent( { kinds: [10002], authors: [user.pubkey], }, - { + { groupable: false, skipVerification: false, skipValidation: false, @@ -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: diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 5ec4cdd..b35c86f 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -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 { 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,16 +28,16 @@ interface IndexMetadata { export enum SiblingSearchDirection { Previous, - Next + Next, } export enum InsertLocation { Before, - After + After, } /** - * @classdesc Pharos is an extension of the Asciidoctor class that adds Nostr Knowledge Base (NKB) + * @classdesc Pharos is an extension of the Asciidoctor class that adds Nostr Knowledge Base (NKB) * features to core Asciidoctor functionality. Asciidoctor is used to parse an AsciiDoc document * into an Abstract Syntax Tree (AST), and Phraos generates NKB events from the nodes in that tree. * @class @@ -46,12 +46,12 @@ export enum InsertLocation { export default class Pharos { /** * Key to terminology used in the class: - * + * * Nostr Knowledge Base (NKB) entities: * - Zettel: Bite-sized pieces of text contained within kind 30041 events. * - Index: A kind 30040 event describing a collection of zettels or other Nostr events. * - Event: The generic term for a Nostr event. - * + * * Asciidoctor entities: * - Document: The entirety of an AsciiDoc document. The document title is denoted by a level 0 * header, and the document may contain metadata, such as author and edition, immediately below @@ -112,7 +112,10 @@ export default class Pharos { /** * A map of index IDs to the IDs of the nodes they reference. */ - private indexToChildEventsMap: Map> = new Map>(); + private indexToChildEventsMap: Map> = new Map< + string, + Set + >(); /** * A map of node IDs to the Nostr event IDs of the events they generate. @@ -160,34 +163,37 @@ export default class Pharos { */ private async loadAdvancedExtensions(): Promise { 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 { async fetch(event: NDKEvent | string): Promise { 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); @@ -224,7 +230,7 @@ export default class Pharos { /** * Generates and stores Nostr events from the parsed AsciiDoc document. The events can be * modified via the parser's API and retrieved via the `getEvents()` method. - * @param pubkey The public key (as a hex string) of the user that will sign and publish the + * @param pubkey The public key (as a hex string) of the user that will sign and publish the * events. */ generate(pubkey: string): void { @@ -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 { * @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 { */ 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 { * @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 { const block = this.nodes.get(normalizedId!) as AbstractBlock; switch (block.getContext()) { - case 'paragraph': - return block.getContent() ?? ''; + case "paragraph": + return block.getContent() ?? ""; } return block.convert(); @@ -324,9 +332,9 @@ export default class Pharos { if (!normalizedId || !this.nodes.has(normalizedId)) { return false; } - + const context = this.eventToContextMap.get(normalizedId); - return context === 'floating_title'; + return context === "floating_title"; } /** @@ -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 { 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 { // 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 { // * 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 { 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 { * @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 { } 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 { 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 { * - 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 { 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 { } 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()); const parentId = this.normalizeId(section.getParent()?.getId()); @@ -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 { } 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 { * @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 { - let content: string = ''; + private async getPublicationContent( + event: NDKEvent, + depth: number = 0, + ): Promise { + 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 { // 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 @@ -699,17 +741,19 @@ export default class Pharos { const childContentPromises: Promise[] = []; for (let i = 0; i < childEvents.length; i++) { const childEvent = childEvents[i]; - + if (!childEvent) { console.warn(`NDK could not find event ${tags[i][1]}.`); 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; } @@ -754,17 +798,17 @@ export default class Pharos { while (nodeIdStack.length > 0) { 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; + 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; } } @@ -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 { 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 { }; 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,12 +869,15 @@ export default class Pharos { } if (this.rootIndexMetadata.publicationDate) { - event.tags.push(['published_on', this.rootIndexMetadata.publicationDate!]); + event.tags.push([ + "published_on", + this.rootIndexMetadata.publicationDate!, + ]); } } // Event ID generation must be the last step. - const eventId = event.getEventHash(); + const eventId = event.getEventHash(); this.eventIds.set(nodeId, eventId); event.id = eventId; @@ -852,21 +896,21 @@ 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(); event.pubkey = pubkey; // Event ID generation must be the last step. - const eventId = event.getEventHash(); + const eventId = event.getEventHash(); this.eventIds.set(nodeId, eventId); event.id = eventId; @@ -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 { 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; - - default: - this.updateEventBody(dTag, value); - break; + case "document": + case "section": + this.updateEventTitle(dTag, value); + break; + + default: + this.updateEventBody(dTag, value); + break; } } @@ -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 = 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 { // 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"); } diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte index 802edfd..3687062 100644 --- a/src/lib/snippets/PublicationSnippets.svelte +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -1,5 +1,5 @@ - @@ -8,13 +8,17 @@ {@const headingLevel = Math.min(depth + 1, 6)} - + {title} {/snippet} -{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} -
+{#snippet contentParagraph( + content: string, + publicationType: string, + isSectionStart: boolean, +)} +
{@html content}
{/snippet} diff --git a/src/lib/snippets/UserSnippets.svelte b/src/lib/snippets/UserSnippets.svelte index d8c960e..8a0774b 100644 --- a/src/lib/snippets/UserSnippets.svelte +++ b/src/lib/snippets/UserSnippets.svelte @@ -1,5 +1,9 @@ - @@ -14,6 +18,6 @@ {@html createProfileLink(toNpub(identifier) as string, displayText)} {/await} {:else} - {displayText ?? ''} + {displayText ?? ""} {/if} {/snippet} diff --git a/src/lib/stores.ts b/src/lib/stores.ts index e38f0d4..74219db 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -7,14 +7,13 @@ export let alexandriaKinds = readable([30040, 30041, 30818]); export let feedType = writable(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() { subscribe, set, update, - reset: () => set({ ...defaultVisibility }) + reset: () => set({ ...defaultVisibility }), }; } diff --git a/src/lib/stores/relayStore.ts b/src/lib/stores/relayStore.ts index 9c7e635..2c038c7 100644 --- a/src/lib/stores/relayStore.ts +++ b/src/lib/stores/relayStore.ts @@ -1,4 +1,4 @@ -import { writable } from 'svelte/store'; +import { writable } from "svelte/store"; // Initialize with empty array, will be populated from user preferences -export const userRelays = writable([]); \ No newline at end of file +export const userRelays = writable([]); diff --git a/src/lib/types.ts b/src/lib/types.ts index 9b8e84e..e47b037 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 52f5686..b5be33c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -12,11 +12,11 @@ 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({ identifier: dTag, pubkey: event.pubkey, @@ -110,16 +110,14 @@ export function isElementInViewport(el: string | HTMLElement) { export function filterValidIndexEvents(events: Set): Set { // 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): Set { */ export async function findIndexAsync( array: T[], - predicate: (element: T, index: number, array: T[]) => Promise + predicate: (element: T, index: number, array: T[]) => Promise, ): Promise { for (let i = 0; i < array.length; i++) { if (await predicate(array[i], i, array)) { @@ -152,14 +150,14 @@ export async function findIndexAsync( declare global { interface Array { findIndexAsync( - predicate: (element: T, index: number, array: T[]) => Promise + predicate: (element: T, index: number, array: T[]) => Promise, ): Promise; } } -Array.prototype.findIndexAsync = function( +Array.prototype.findIndexAsync = function ( this: T[], - predicate: (element: T, index: number, array: T[]) => Promise + predicate: (element: T, index: number, array: T[]) => Promise, ): Promise { return findIndexAsync(this, predicate); }; @@ -173,7 +171,7 @@ Array.prototype.findIndexAsync = function( */ export function debounce any>( func: T, - wait: number + wait: number, ): (...args: Parameters) => void { let timeout: ReturnType | undefined; diff --git a/src/lib/utils/markup/MarkupInfo.md b/src/lib/utils/markup/MarkupInfo.md index 22a108f..38d78e6 100644 --- a/src/lib/utils/markup/MarkupInfo.md +++ b/src/lib/utils/markup/MarkupInfo.md @@ -6,8 +6,8 @@ Alexandria supports multiple markup formats for different use cases. Below is a The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: -- **Headers:** - - ATX-style: `# H1` through `###### H6` +- **Headers:** + - ATX-style: `# H1` through `###### H6` - Setext-style: `H1\n=====` - **Bold:** `*bold*` or `**bold**` - **Italic:** `_italic_` or `__italic__` @@ -123,7 +123,8 @@ 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. -- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. \ No newline at end of file +- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. diff --git a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts index 36657f9..0700496 100644 --- a/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts +++ b/src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts @@ -1,14 +1,16 @@ -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: * - Math rendering (Asciimath/Latex, stem blocks) * - PlantUML diagrams - * - BPMN diagrams + * - BPMN diagrams * - TikZ diagrams */ -export async function postProcessAdvancedAsciidoctorHtml(html: string): Promise { +export async function postProcessAdvancedAsciidoctorHtml( + html: string, +): Promise { 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< // 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< */ function fixAllMathBlocks(html: string): string { // Unescape \$ to $ for math delimiters - html = html.replace(/\\\$/g, '$'); - - + html = html.replace(/\\\$/g, "$"); // Block math:
...
html = html.replace( /
\s*
([\s\S]*?)<\/div>\s*<\/div>/g, (_match, mathContent) => { let cleanMath = mathContent - .replace(/\$<\/span>/g, '') - .replace(/\$\$<\/span>/g, '') + .replace(/\$<\/span>/g, "") + .replace(/\$\$<\/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 `
$$${cleanMath}$$
`; - } + }, ); // Inline math: $ ... $ (allow whitespace/newlines) html = html.replace( /\$<\/span>\s*([\s\S]+?)\s*\$<\/span>/g, - (_match, mathContent) => `$${mathContent.trim()}$` + (_match, mathContent) => + `$${mathContent.trim()}$`, ); // Inline math: stem:[...] or latexmath:[...] html = html.replace( /stem:\[([^\]]+?)\]/g, - (_match, content) => `$${content.trim()}$` + (_match, content) => `$${content.trim()}$`, ); html = html.replace( /latexmath:\[([^\]]+?)\]/g, - (_match, content) => `\\(${content.trim().replace(/\\\\/g, '\\')}\\)` + (_match, content) => + `\\(${content.trim().replace(/\\\\/g, "\\")}\\)`, ); html = html.replace( /asciimath:\[([^\]]+?)\]/g, - (_match, content) => `\`${content.trim()}\`` + (_match, content) => + `\`${content.trim()}\``, ); return html; } @@ -110,17 +120,20 @@ function processPlantUMLBlocks(html: string): string {
`; } catch (error) { - console.warn('Failed to process PlantUML block:', error); + console.warn("Failed to process PlantUML block:", error); return match; } - } + }, ); // Fallback: match
 blocks whose content starts with @startuml or @start (global, robust)
   html = html.replace(
     /
\s*
\s*
([\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 {
             
           
`; } 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 {
`; } catch (error) { - console.warn('Failed to process BPMN block:', error); + console.warn("Failed to process BPMN block:", error); return match; } - } + }, ); // Fallback: match
 blocks whose content contains 'bpmn:' or '\s*
\s*
([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g,
     (match, content) => {
       const text = content.trim();
-      if (text.includes('bpmn:') || (text.startsWith('
             
@@ -214,12 +230,12 @@ function processBPMNBlocks(html: string): string {
`; } 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 {
`; } catch (error) { - console.warn('Failed to process TikZ block:', error); + console.warn("Failed to process TikZ block:", error); return match; } - } + }, ); // Fallback: match
 blocks whose content starts with \begin{tikzpicture} or contains tikz
   html = html.replace(
     /
\s*
\s*
([\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 `
@@ -283,12 +302,12 @@ function processTikZBlocks(html: string): string {
`; } 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 { * 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; -} \ No newline at end of file +} diff --git a/src/lib/utils/markup/advancedMarkupParser.ts b/src/lib/utils/markup/advancedMarkupParser.ts index 9273857..34785ba 100644 --- a/src/lib/utils/markup/advancedMarkupParser.ts +++ b/src/lib/utils/markup/advancedMarkupParser.ts @@ -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; 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 { }); // 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 `${text.trim()}`; - }); + processedContent = processedContent.replace( + ALTERNATE_HEADING_REGEX, + (_, text, level) => { + const headingLevel = level[0] === "=" ? 1 : 2; + const classes = headingClasses[headingLevel - 1]; + return `${text.trim()}`; + }, + ); return processedContent; } @@ -53,29 +66,30 @@ 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[] = []; let bodyRows: string[] = []; - + if (hasHeader) { // If we have a header, first row is header, skip delimiter, rest is body headerCells = processCells(rows[0]); @@ -91,33 +105,33 @@ function processTables(content: string): string { // Add header if exists if (hasHeader) { - html += '\n\n'; - headerCells.forEach(cell => { + html += "\n\n"; + headerCells.forEach((cell) => { html += `${cell}\n`; }); - html += '\n\n'; + html += "\n\n"; } // Add body - html += '\n'; - bodyRows.forEach(row => { + html += "\n"; + bodyRows.forEach((row) => { const cells = processCells(row); - html += '\n'; - cells.forEach(cell => { + html += "\n"; + cells.forEach((cell) => { html += `${cell}\n`; }); - html += '\n'; + html += "\n"; }); - html += '\n\n
'; + html += "\n\n
"; 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 { * Process horizontal rules */ function processHorizontalRules(content: string): string { - return content.replace(HORIZONTAL_RULE_REGEX, - '
' + return content.replace( + HORIZONTAL_RULE_REGEX, + '
', ); } @@ -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(); @@ -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(); // 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 `[${refNum}]`; - }); + 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 `[${refNum}]`; + }, + ); // Only render footnotes section if there are actual definitions and at least one reference if (footnotes.size > 0 && referenceOrder.length > 0) { - processedContent += '\n\n

Footnotes

\n
    \n'; + processedContent += + '\n\n

    Footnotes

    \n
      \n'; // Only include each unique footnote once, in order of first reference const seen = new Set(); 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) => - `↩${num}` - ).join(' '); + const backrefs = refs + .map( + (num, i) => + `↩${num}`, + ) + .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 += `
    1. ${text} ${backrefs}${labelSuffix}
    2. \n`; } - processedContent += '
    '; + processedContent += "
"; } return processedContent; } catch (error) { - console.error('Error processing footnotes:', error); + console.error("Error processing footnotes:", error); return content; } } @@ -198,15 +222,15 @@ function processFootnotes(content: string): string { function processBlockquotes(content: string): string { // Match blockquotes that might span multiple lines const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm; - + return content.replace(blockquoteRegex, (match) => { // Remove the '>' prefix from each line and preserve line breaks const text = match - .split('\n') - .map(line => line.replace(/^>[ \t]?/, '')) - .join('\n') + .split("\n") + .map((line) => line.replace(/^>[ \t]?/, "")) + .join("\n") .trim(); - + return `
${text}
`; }); } @@ -214,20 +238,23 @@ 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 } { - const lines = text.split('\n'); +function processCodeBlocks(text: string): { + text: string; + blocks: Map; +} { + const lines = text.split("\n"); const processedLines: string[] = []; const blocks = new Map(); let inCodeBlock = false; let currentCode: string[] = []; - let currentLanguage = ''; + let currentLanguage = ""; let blockCount = 0; let lastWasCodeBlock = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const codeBlockStart = line.match(CODE_BLOCK_REGEX); - + if (codeBlockStart) { if (!inCodeBlock) { // Starting a new code block @@ -239,36 +266,39 @@ function processCodeBlocks(text: string): { text: string; blocks: Map 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) { formattedCode = code; } } - - 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, }; } @@ -312,22 +345,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map): string { let result = text; - + for (const [id, blockData] of blocks) { try { const { code, language } = JSON.parse(blockData); - + let html; if (language && hljs.getLanguage(language)) { try { const highlighted = hljs.highlight(code, { language, - ignoreIllegals: true + ignoreIllegals: true, }).value; html = `
${highlighted}
`; } catch (e: unknown) { - console.warn('Failed to highlight code block:', e); - html = `
${code}
`; + console.warn("Failed to highlight code block:", e); + html = `
${code}
`; } } else { html = `
${code}
`; @@ -335,26 +368,140 @@ function restoreCodeBlocks(text: string, blocks: Map): string { result = result.replace(id, html); } catch (e: unknown) { - console.error('Error restoring code block:', e); - result = result.replace(id, '
Error processing code block
'); + console.error("Error restoring code block:", e); + result = result.replace( + id, + '
Error processing code block
', + ); } } 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 += `
$$${token.content}$$
`; + } else { + result += `$${token.content}$`; + } + } + } + + return result; +} + /** * Parse markup text with advanced formatting */ export async function parseAdvancedmarkup(text: string): Promise { - 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

or

+ 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 { processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { const escapedCode = code .trim() - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); return `${escapedCode}`; }); @@ -378,12 +525,12 @@ export async function parseAdvancedmarkup(text: string): Promise { // 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 `
Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
`; + console.error("Error in parseAdvancedmarkup:", e); + return `
Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
`; } -} \ No newline at end of file +} diff --git a/src/lib/utils/markup/asciidoctorExtensions.ts b/src/lib/utils/markup/asciidoctorExtensions.ts index 7121c35..5e7be35 100644 --- a/src/lib/utils/markup/asciidoctorExtensions.ts +++ b/src/lib/utils/markup/asciidoctorExtensions.ts @@ -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 { @@ -18,7 +18,7 @@ function renderPlantUML(content: string): string { // Encode content for PlantUML server const encoded = btoa(unescape(encodeURIComponent(content))); const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; - + return `PlantUML diagram`; } @@ -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 { 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 { const rendered = `
$$${content}$$
`; 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 = `$${content}$`; block.setContent(rendered); } catch (error) { - console.warn('Failed to render inline math:', error); + console.warn("Failed to render inline math:", error); } } } @@ -130,19 +133,19 @@ function processMathBlocks(treeProcessor: any, document: any): void { */ function processPlantUMLBlocks(treeProcessor: any, document: any): void { 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 { // Use simple PlantUML rendering const rendered = renderPlantUML(content); - + // 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 } } @@ -155,19 +158,19 @@ function processPlantUMLBlocks(treeProcessor: any, document: any): void { */ 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 { // Render TikZ to SVG const svg = renderTikZ(content); - + // 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 { * 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 { * 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"), ); -} \ No newline at end of file +} diff --git a/src/lib/utils/markup/asciidoctorPostProcessor.ts b/src/lib/utils/markup/asciidoctorPostProcessor.ts index e85ab75..763c720 100644 --- a/src/lib/utils/markup/asciidoctorPostProcessor.ts +++ b/src/lib/utils/markup/asciidoctorPostProcessor.ts @@ -1,4 +1,4 @@ -import { processNostrIdentifiers } from '../nostrUtils'; +import { processNostrIdentifiers } from "../nostrUtils"; /** * Normalizes a string for use as a d-tag by converting to lowercase, @@ -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 { */ 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 with the [[display]] format and matching link colors - return `${display}`; - }); + 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 with the [[display]] format and matching link colors + return `${display}`; + }, + ); +} + +/** + * Replaces AsciiDoctor-generated empty anchor tags with clickable wikilink-style tags. + */ +function replaceAsciiDocAnchors(html: string): string { + return html.replace( + /<\/a>/g, + (_match, id) => { + const normalized = normalizeDTag(id.trim()); + const url = `./events?d=${normalized}`; + return `${id}`; + } + ); } /** @@ -37,40 +54,42 @@ async function processNostrAddresses(html: string): Promise { function isWithinLink(text: string, index: number): boolean { // Look backwards from the match position to find the nearest tag const before = text.slice(0, index); - const lastOpenTag = before.lastIndexOf(''); - + const lastOpenTag = before.lastIndexOf(""); + // If we find an opening tag after the last closing 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 const matches = Array.from(processedHtml.matchAll(nostrPattern)); - + // Process them in reverse order to avoid index shifting issues for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; const [fullMatch] = match; const matchIndex = match.index ?? 0; - + // Skip if already within a link if (isWithinLink(processedHtml, matchIndex)) { continue; } - + // Process the nostr identifier 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 { /
\s*
\s*\$<\/span>([\s\S]*?)\$<\/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 `
$$${cleanMath}$$
`; - } + }, ); } @@ -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 { +export async function postProcessAsciidoctorHtml( + html: string, +): Promise { 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 } -} \ No newline at end of file +} diff --git a/src/lib/utils/markup/basicMarkupParser.ts b/src/lib/utils/markup/basicMarkupParser.ts index a79833b..f829462 100644 --- a/src/lib/utils/markup/basicMarkupParser.ts +++ b/src/lib/utils/markup/basicMarkupParser.ts @@ -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 = /(?"]+)(?!["'])/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 { // 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 { } }); 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 { 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 with the [[display]] format and matching link colors - return `${display}`; - }); + 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 with the [[display]] format and matching link colors + return `${display}`; + }, + ); } -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 + ''; + html = html.replace(/<\/li>$/, "") + nestedHtml + ""; i = consumed; continue; } @@ -175,35 +198,39 @@ 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 += ''; + html += ""; i++; } html += ``; 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; - + try { // Sanitize Alexandria Nostr links before further processing processedText = replaceAlexandriaNostrLinks(processedText); @@ -214,17 +241,17 @@ function processBasicFormatting(content: string): string { if (YOUTUBE_URL_REGEX.test(url)) { const videoId = extractYouTubeVideoId(url); if (videoId) { - return ``; + return ``; } } if (VIDEO_URL_REGEX.test(url)) { - return ``; + return ``; } if (AUDIO_URL_REGEX.test(url)) { - return ``; + return ``; } // Only render if the url ends with a direct image extension - if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { + if (IMAGE_EXTENSIONS.test(url.split("?")[0])) { return `${alt}`; } // Otherwise, render as a clickable link @@ -232,19 +259,21 @@ function processBasicFormatting(content: string): string { }); // Process markup links - processedText = processedText.replace(MARKUP_LINK, (match, text, url) => - `${text}` + processedText = processedText.replace( + MARKUP_LINK, + (match, text, url) => + `${text}`, ); // 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 `${match}`; }); // 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,30 +288,36 @@ function processBasicFormatting(content: string): string { return ``; } // Only render if the url ends with a direct image extension - if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) { + if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { return `Embedded media`; } // Otherwise, render as a clickable link return `${clean}`; }); - + // Process text formatting - processedText = processedText.replace(BOLD_REGEX, '$2'); - processedText = processedText.replace(ITALIC_REGEX, match => { - const text = match.replace(/^_+|_+$/g, ''); + processedText = processedText.replace(BOLD_REGEX, "$2"); + processedText = processedText.replace(ITALIC_REGEX, (match) => { + const text = match.replace(/^_+|_+$/g, ""); return `${text}`; }); - processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { - const text = doubleText || singleText; - return `${text}`; - }); + processedText = processedText.replace( + STRIKETHROUGH_REGEX, + (match, doubleText, singleText) => { + const text = doubleText || singleText; + return `${text}`; + }, + ); // Process hashtags - processedText = processedText.replace(HASHTAG_REGEX, '#$1'); + processedText = processedText.replace( + HASHTAG_REGEX, + '#$1', + ); // --- 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 { 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,61 +352,72 @@ 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 ''; - - return content.replace(BLOCKQUOTE_REGEX, match => { - const lines = match.split('\n').map(line => { - return line.replace(/^[ \t]*>[ \t]?/, '').trim(); + if (!content) return ""; + + return content.replace(BLOCKQUOTE_REGEX, (match) => { + const lines = match.split("\n").map((line) => { + return line.replace(/^[ \t]*>[ \t]?/, "").trim(); }); - - return `
${ - lines.join('\n') - }
`; + + return `
${lines.join( + "\n", + )}
`; }); } 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 { - if (!text) return ''; - + if (!text) return ""; + try { // Process basic text formatting first let processedText = processBasicFormatting(text); // Process emoji shortcuts processedText = processEmojiShortcuts(processedText); - + // Process blockquotes processedText = processBlockquotes(processedText); - + // Process paragraphs - split by double newlines and wrap in p tags + // 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 => `

${para}

`) - .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 `

${para}

`; + }) + .join("\n"); // Process Nostr identifiers last processedText = await processNostrIdentifiers(processedText); @@ -382,7 +427,7 @@ export async function parseBasicmarkup(text: string): Promise { return processedText; } catch (e: unknown) { - console.error('Error in parseBasicmarkup:', e); - return `
Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
`; + console.error("Error in parseBasicmarkup:", e); + return `
Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
`; } -} \ No newline at end of file +} diff --git a/src/lib/utils/markup/tikzRenderer.ts b/src/lib/utils/markup/tikzRenderer.ts index 68c7e91..3e194b6 100644 --- a/src/lib/utils/markup/tikzRenderer.ts +++ b/src/lib/utils/markup/tikzRenderer.ts @@ -10,13 +10,13 @@ export function renderTikZ(tikzCode: string): string { try { // For now, we'll create a simple SVG placeholder // In a full implementation, this would use node-tikzjax or similar library - + // Extract TikZ content and create a basic SVG const svgContent = createBasicSVG(tikzCode); - + return svgContent; } catch (error) { - console.error('Failed to render TikZ:', error); + console.error("Failed to render TikZ:", error); return `

TikZ Rendering Error

Failed to render TikZ diagram. Original code:

@@ -33,7 +33,7 @@ function createBasicSVG(tikzCode: string): string { // Create a simple SVG with the TikZ code as text const width = 400; const height = 300; - + return ` @@ -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; -} \ No newline at end of file +} diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts index 28f744e..123b46e 100644 --- a/src/lib/utils/mime.ts +++ b/src/lib/utils/mime.ts @@ -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][] { // 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 @@ -93,4 +96,4 @@ export function getMimeTags(kind: number): [string, string][] { } return [mTag, MTag]; -} \ No newline at end of file +} diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts index ff7440b..739c8f5 100644 --- a/src/lib/utils/nostrUtils.ts +++ b/src/lib/utils/nostrUtils.ts @@ -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 = '' +const badgeCheckSvg = + ''; -const graduationCapSvg = ''; +const graduationCapSvg = + ''; // Regular expressions for Nostr identifiers - match the entire identifier including any prefix -export const NOSTR_PROFILE_REGEX = /(?': '>', - '"': '"', - "'": ''' + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", }; - 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 { +export async function getUserMetadata( + identifier: string, +): Promise { // 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 // 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,9 +103,9 @@ export async function getUserMetadata(identifier: string): Promise about: profile?.about, banner: profile?.banner, website: profile?.website, - lud16: profile?.lud16 + lud16: profile?.lud16, }; - + npubCache.set(cleanId, metadata); return metadata; } catch (e) { @@ -105,27 +117,33 @@ export async function getUserMetadata(identifier: string): Promise /** * 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); - + return `@${escapedText}`; } /** * Create a profile link element with a NIP-05 verification indicator. */ -export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise { +export async function createProfileLinkWithVerification( + identifier: string, + displayText: string | undefined, +): Promise { 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 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,20 +177,24 @@ 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); - + if (!isVerified) { return createProfileLink(identifier, displayText); } - + // 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 `@${displayIdentifier}${graduationCapSvg}`; - case 'standard': + case "standard": return `@${displayIdentifier}${badgeCheckSvg}`; } } @@ -176,18 +202,20 @@ 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); - + return `${escapedText}`; } /** * Process Nostr identifiers in text */ -export async function processNostrIdentifiers(content: string): Promise { +export async function processNostrIdentifiers( + content: string, +): Promise { 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 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 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,17 +266,17 @@ export async function getNpubFromNip05(nip05: string): Promise { try { const ndk = get(ndkInstance); if (!ndk) { - console.error('NDK not initialized'); + console.error("NDK not initialized"); return null; } - + const user = await ndk.getUser({ nip05 }); if (!user || !user.npub) { return null; } return user.npub; } catch (error) { - console.error('Error getting npub from nip05:', error); + console.error("Error getting npub from nip05:", error); return null; } } @@ -258,7 +286,7 @@ export async function getNpubFromNip05(nip05: string): Promise { * Can be used in two ways: * 1. Method style: promise.withTimeout(5000) * 2. Function style: withTimeout(promise, 5000) - * + * * @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style) * @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style) * @returns The promise result if completed before timeout, otherwise throws an error @@ -266,28 +294,28 @@ export async function getNpubFromNip05(nip05: string): Promise { */ export function withTimeout( thisOrPromise: Promise | number, - timeoutMsOrPromise?: number | Promise + timeoutMsOrPromise?: number | Promise, ): Promise { // Handle method-style call (promise.withTimeout(5000)) - if (typeof thisOrPromise === 'number') { + if (typeof thisOrPromise === "number") { const timeoutMs = thisOrPromise; const promise = timeoutMsOrPromise as Promise; return Promise.race([ promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), timeoutMs) - ) + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), timeoutMs), + ), ]); } - + // Handle function-style call (withTimeout(promise, 5000)) const promise = thisOrPromise; const timeoutMs = timeoutMsOrPromise as number; return Promise.race([ promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), timeoutMs) - ) + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), timeoutMs), + ), ]); } @@ -298,7 +326,10 @@ declare global { } } -Promise.prototype.withTimeout = function(this: Promise, timeoutMs: number): Promise { +Promise.prototype.withTimeout = function ( + this: Promise, + timeoutMs: number, +): Promise { return withTimeout(timeoutMs, this); }; @@ -311,24 +342,24 @@ Promise.prototype.withTimeout = function(this: Promise, timeoutMs: number) export async function fetchEventWithFallback( ndk: NDK, filterOrId: string | NDKFilter, - timeoutMs: number = 3000 + timeoutMs: number = 3000, ): Promise { // 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; const primaryRelays = isSignedIn ? standardRelays : anonymousRelays; - + // 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,47 +367,75 @@ export async function fetchEventWithFallback( const triedRelaySets: string[] = []; // Helper function to try fetching from a relay set - async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise { + async function tryFetchFromRelaySet( + relaySet: NDKRelaySetFromNDK, + setName: string, + ): Promise { 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; } 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 { 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: { event.created_at, event.kind, event.tags, - event.content + event.content, ]); return bytesToHex(sha256(serialized)); } @@ -447,4 +506,4 @@ export async function signEvent(event: { const id = getEventHash(event); const sig = await schnorr.sign(id, event.pubkey); return bytesToHex(sig); -} \ No newline at end of file +} diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts index c99f879..4fc4405 100644 --- a/src/lib/utils/npubCache.ts +++ b/src/lib/utils/npubCache.ts @@ -1,4 +1,4 @@ -import type { NostrProfile } from './nostrUtils'; +import type { NostrProfile } from "./nostrUtils"; export type NpubMetadata = NostrProfile; @@ -48,4 +48,4 @@ class NpubCache { } } -export const npubCache = new NpubCache(); \ No newline at end of file +export const npubCache = new NpubCache(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 89660e5..9cb3197 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ {title} - - + + - - - + + + - - + + - - - + + + -
- +
+
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 915324a..4ec9145 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -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 = () => { 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); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 02277f7..dc8de28 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,10 +1,15 @@ - - - - - Pardon our dust! The publication view is currently using an experimental loader, and may be unstable. - + + + + Pardon our dust! The publication view is currently using an experimental + loader, and may be unstable. + -
-
+
+
{#if $ndkSignedIn}
  • - Alexandria's Relays + Alexandria's Relays
  • - Your Relays + Your Relays
  • {/if}
    {#if !$ndkSignedIn} - - {:else} - {#if $feedType === FeedType.StandardRelays} - - {:else if $feedType === FeedType.UserRelays} - - {/if} + + {:else if $feedType === FeedType.StandardRelays} + + {:else if $feedType === FeedType.UserRelays} + {/if}
    diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index 18be414..0224b3d 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -1,13 +1,23 @@ -
    +

    404 - Page Not Found

    -

    The page you are looking for does not exist or has been moved.

    +

    The page you are looking for does not exist or has been moved.

    - - + +
    diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index cf37de1..4364a0b 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -46,7 +46,10 @@

    - We are easiest to contact over our Nostr address {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")}. Or, you can visit us on our homepage - 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'; + -

    -
    - Contact GitCitadel - +
    +
    + Contact GitCitadel +

    - Make sure that you follow us on GitHub and Geyserfund. + Make sure that you follow us on GitHub and Geyserfund.

    - You can contact us on Nostr {@render userBadge("npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel")} or you can view submitted issues on the Alexandria repo page. + You can contact us on Nostr {@render userBadge( + "npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", + "GitCitadel", + )} or you can view submitted issues on the Alexandria repo page.

    - - Submit an issue - + + Submit an issue +

    - 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.

    - +
    - +
    -
    +
    -
    -
      +
      +
      • -
      - +
      - {#if activeTab === 'write'} + {#if activeTab === "write"}
      {:else} -
      - - - + + + + - - + + {#if rootIndexId} - + {/if} {/if} diff --git a/src/routes/publication/+error.svelte b/src/routes/publication/+error.svelte index 2cbb819..9d0d347 100644 --- a/src/routes/publication/+error.svelte +++ b/src/routes/publication/+error.svelte @@ -1,29 +1,37 @@ -
      -
      - - - Failed to load publication. - +
      + + Failed to load publication.
      -

      - Alexandria failed to find one or more of the events comprising this publication. +

      + Alexandria failed to find one or more of the events comprising this + publication.

      -

      +

      {page.error?.message}

      -
      - -
      diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index b100f70..b3d0885 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -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; } } @@ -32,7 +32,7 @@ function decodeNaddr(id: string) { */ async function fetchEventById(ndk: any, id: string): Promise { const filter = decodeNaddr(id); - + // Handle the case where filter is null (decoding error) if (filter === null) { // If we can't decode the naddr, try using the raw ID @@ -46,14 +46,14 @@ async function fetchEventById(ndk: any, id: string): Promise { throw error(404, `Failed to fetch publication root event.\n${err}`); } } - + const hasFilter = Object.keys(filter).length > 0; - + try { - const event = await (hasFilter ? - ndk.fetchEvent(filter) : - ndk.fetchEvent(id)); - + const event = await (hasFilter + ? ndk.fetchEvent(filter) + : ndk.fetchEvent(id)); + if (!event) { throw new Error(`Event not found for ID: ${id}`); } @@ -69,11 +69,11 @@ async function fetchEventById(ndk: any, id: string): Promise { async function fetchEventByDTag(ndk: any, dTag: string): Promise { try { const event = await ndk.fetchEvent( - { '#d': [dTag] }, - { closeOnEose: false }, - getActiveRelays(ndk) + { "#d": [dTag] }, + { closeOnEose: false }, + getActiveRelays(ndk), ); - + if (!event) { throw new Error(`Event not found for d tag: ${dTag}`); } @@ -83,21 +83,27 @@ async function fetchEventByDTag(ndk: any, dTag: string): Promise { } } -export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise }) => { - const id = url.searchParams.get('id'); - const dTag = url.searchParams.get('d'); +export const load: Load = async ({ + url, + parent, +}: { + url: URL; + parent: () => Promise; +}) => { + 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 - const indexEvent = id + const indexEvent = id ? 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 { diff --git a/src/routes/start/+page.svelte b/src/routes/start/+page.svelte index 05d0776..fbc77f3 100644 --- a/src/routes/start/+page.svelte +++ b/src/routes/start/+page.svelte @@ -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.)

      diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 21dc2b1..ba2901f 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -11,12 +11,12 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { filterValidIndexEvents } from "$lib/utils"; import { networkFetchLimit } from "$lib/state"; - + // Configuration const DEBUG = false; // Set to true to enable debug logging const INDEX_EVENT_KIND = 30040; const CONTENT_EVENT_KINDS = [30041, 30818]; - + /** * Debug logging function that only logs when DEBUG is true */ @@ -34,7 +34,7 @@ /** * Fetches events from the Nostr network - * + * * This function fetches index events and their referenced content events, * filters them according to NIP-62, and combines them for visualization. */ @@ -47,9 +47,9 @@ // Step 1: Fetch index events debug(`Fetching index events (kind ${INDEX_EVENT_KIND})`); const indexEvents = await $ndkInstance.fetchEvents( - { - kinds: [INDEX_EVENT_KIND], - limit: $networkFetchLimit + { + kinds: [INDEX_EVENT_KIND], + limit: $networkFetchLimit, }, { groupable: true, @@ -68,7 +68,7 @@ validIndexEvents.forEach((event) => { const aTags = event.getMatchingTags("a"); debug(`Event ${event.id} has ${aTags.length} a-tags`); - + aTags.forEach((tag) => { const eventId = tag[3]; if (eventId) { @@ -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 @@ } } - // Fetch events when component mounts onMount(() => { debug("Component mounted"); @@ -140,7 +141,7 @@ Loading...
      - + {:else if error}
      - + {:else} diff --git a/src/styles/base.css b/src/styles/base.css index e655206..943b334 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -3,7 +3,7 @@ @tailwind utilities; @layer components { - body { - @apply bg-primary-0 dark:bg-primary-1000; - } -} \ No newline at end of file + body { + @apply bg-primary-0 dark:bg-primary-1000; + } +} diff --git a/src/styles/events.css b/src/styles/events.css index 9e8c202..3c61536 100644 --- a/src/styles/events.css +++ b/src/styles/events.css @@ -1,5 +1,5 @@ @layer components { - canvas.qr-code { - @apply block mx-auto my-4; - } -} \ No newline at end of file + canvas.qr-code { + @apply block mx-auto my-4; + } +} diff --git a/src/styles/publications.css b/src/styles/publications.css index f09a1cf..71b70b6 100644 --- a/src/styles/publications.css +++ b/src/styles/publications.css @@ -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; - } -} \ No newline at end of file + /* 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; + } +} diff --git a/src/styles/scrollbar.css b/src/styles/scrollbar.css index 8d2735d..4691a9b 100644 --- a/src/styles/scrollbar.css +++ b/src/styles/scrollbar.css @@ -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 */ - } -} \ No newline at end of file + *::-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 */ + } +} diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 1ff732d..a2f8374 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -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; + } } diff --git a/src/types/d3.d.ts b/src/types/d3.d.ts index 3d230f5..2b12771 100644 --- a/src/types/d3.d.ts +++ b/src/types/d3.d.ts @@ -1,19 +1,19 @@ /** * Type declarations for D3.js and related modules - * + * * These declarations allow TypeScript to recognize D3 imports without requiring * detailed type definitions. For a project requiring more type safety, consider * using the @types/d3 package and its related sub-packages. */ // Core D3 library -declare module 'd3'; +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"; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a1ade26..4e2e76a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -2,4 +2,4 @@ interface Window { hljs?: { highlightAll: () => void; }; -} \ No newline at end of file +} diff --git a/src/types/plantuml-encoder.d.ts b/src/types/plantuml-encoder.d.ts index 8149f62..0e6c137 100644 --- a/src/types/plantuml-encoder.d.ts +++ b/src/types/plantuml-encoder.d.ts @@ -1,5 +1,5 @@ -declare module 'plantuml-encoder' { +declare module "plantuml-encoder" { export function encode(text: string): string; const _default: { encode: typeof encode }; export default _default; -} \ No newline at end of file +} diff --git a/tailwind.config.cjs b/tailwind.config.cjs index e28c2eb..5bd3b5f 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -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; diff --git a/test_data/latex_markdown.md b/test_data/latex_markdown.md new file mode 100644 index 0000000..0317f22 --- /dev/null +++ b/test_data/latex_markdown.md @@ -0,0 +1,50 @@ +{ +"created*at": 1752035710, +"content": "## 1 Introduction\n\nThe P versus NP problem asks whether every problem verifiable in polynomial time (NP) can be solved in polynomial time (P) [1]. The NP-complete Boolean Satisfiability (SAT) problem, determining if a conjunctive normal form formula has a satisfying assignment, is central to this question [2]. Proving that 3-SAT requires super-polynomial time would imply $P \\neq NP$, impacting computer science, cryptography, and optimization [3].\n\nWe prove $P \\neq NP$ by reformulating 3-SAT as an optimization problem using categorical and graph-theoretic frameworks. A 2-category models SAT’s logical constraints, while a clause graph captures satisfiability combinatorially [4]. A constraint measure and topological invariant establish that determining satisfiability requires exponential time [5,6]. Unlike combinatorial or algebraic methods [3], our approach leverages category theory and graph theory for a novel perspective.\n\nThe paper is organized as follows: Section 2 defines a 2-category for SAT; Section 3 presents an optimization problem; Section 4 introduces a constraint measure; Section 5 proves exponential time complexity; and Section 6 provides a graph-theoretic reformulation.\n\n## 2 Categorical Reformulation of SAT\n\nTo prove $P \\neq NP$, we reformulate the Boolean Satisfiability (SAT) problem as an optimization problem using a 2-category framework. Variables and clauses of a SAT instance are encoded as vectors and linear transformations in a complex vector space, with their logical structure modeled by a strict 2-category [4,7]. This allows satisfiability to be tested via compositions of transformations, setting up the constraint measure defined in Section 4.\n\n### 2.1 Construction of the 2-Category\n\nFor a SAT instance $\\phi = C_1 \\wedge \\cdots \\wedge C_m$, where each clause $C_j = l*{j1} \\vee \\cdots \\vee l*{jk}$ is a disjunction of $k \\leq n$ literals (with $l*{ji} = x*i$ or $\\neg x_i$ for variables $x_1, \\ldots, x_n$), we define a strict 2-category $\\mathcal{C}$ to encode $\\phi$’s logical structure.\n\n**Definition 2.1 (2-Category $\\mathcal{C}$)** \nThe 2-category $\\mathcal{C}$ consists of:\n- *Objects*: Vectors in the complex vector space $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$, dimension $2^n$, representing variable assignments. For each variable $x_i$, define basis vectors:\n - $\\mathbf{v}_i = (1, 0) \\in \\mathbb{C}^2$, for $x_i = \\text{True}$.\n - $\\mathbf{w}_i = (0, 1) \\in \\mathbb{C}^2$, for $\\neg x_i = \\text{False}$.\n \n A configuration, e.g., $\\mathbf{v}_1 \\otimes \\mathbf{w}_2 \\otimes \\mathbf{v}_3 \\in \\mathcal{V}$, represents $x_1 = \\text{True}, x_2 = \\text{False}, x_3 = \\text{True}$.\n\n- *1-Morphisms*: Linear maps $f: \\mathcal{V} \\to \\mathcal{V}$, including:\n - *Clause projections* $P_j: \\mathcal{V} \\to \\mathcal{V}$, for clause $C_j$ with variables indexed by $I_j \\subseteq \\{1, \\ldots, n\\}$, defined as:\n $$\n P_j = \\bigotimes*{i=1}^n Q*i, \\quad Q_i = \\begin{cases} \n I - |\\mathbf{l}*{ji}\\rangle\\langle \\mathbf{l}_{ji}| & \\text{if } i \\in I_j, \\\\\n I & \\text{otherwise},\n \\end{cases}\n $$\n where $\\mathbf{l}_{ji} = \\mathbf{v}_i$ if $l_{ji} = x*i$, or $\\mathbf{l}*{ji} = \\mathbf{w}_i$ if $l_{ji} = \\neg x*i$, and $I$ is the identity on $\\mathbb{C}^2$. Thus, $P_j v = v$ if $v$ satisfies $C_j$; otherwise, $P_j v$ lies in the orthogonal complement.\n - *Identity maps* $\\text{id}_A: A \\to A$, for subspaces $A \\subseteq \\mathcal{V}$.\n - *Negation maps* $N_i: \\mathcal{V} \\to \\mathcal{V}$, swapping $\\mathbf{v}_i \\leftrightarrow \\mathbf{w}_i$ on the $i$-th tensor factor:\n $$\n N_i = I \\otimes \\cdots \\otimes \\begin{pmatrix} 0 & 1 \\\\ 1 & 0 \\end{pmatrix} \\otimes \\cdots \\otimes I.\n $$\n\n- *2-Morphisms*: Natural transformations $\\alpha: f \\Rightarrow g$ between 1-morphisms $f, g: A \\to B$, where $A, B \\subseteq \\mathcal{V}$. A 2-morphism $\\alpha$ is a linear map ensuring that if $f$ and $g$ represent assignments, $f$ satisfies all clauses satisfied by $g$, preserving the logical structure of $\\phi$ [4].\n\n- *Compositions*: Horizontal composition $\\beta \\circ \\alpha: g \\circ f \\Rightarrow g' \\circ f'$ for 2-morphisms $\\alpha: f \\Rightarrow f'$, $\\beta: g \\Rightarrow g'$, and vertical composition $\\beta \\cdot \\alpha: f \\Rightarrow h$ for $\\alpha: f \\Rightarrow g$, $\\beta: g \\Rightarrow h$, defined via linear map composition. Associativity and identity laws ensure $\\mathcal{C}$ is a strict 2-category [4].\n\nThe 2-category $\\mathcal{C}$ encodes SAT as follows: vectors in $\\mathcal{V}$ represent assignments, projections $P_j$ enforce clause constraints, negation maps $N_i$ handle negated literals, and 2-morphisms preserve logical consistency across transformations [7].\n\n### 2.2 Satisfiability via Projection Composition\n\nSatisfiability of $\\phi$ is tested by composing the clause projections:\n$$\nP = P_m \\circ \\cdots \\circ P_1: \\mathcal{V} \\to \\mathcal{V}.\n$$\nFor a normalized vector $v \\in \\mathcal{V}, \\|v\\|=1$, $\\phi$ is satisfiable if there exists $v$ such that $P v = v$, meaning $P_j v = v$ for all $j = 1, \\ldots, m$, corresponding to a satisfying assignment. If $\\phi$ is unsatisfiable, the intersection of projection images $\\bigcap*{j=1}^m \\text{im}(P*j) = \\emptyset$, so $P v \\neq v$ for all $v$. This composition reformulates SAT as finding a fixed point of $P$, which we analyze as an optimization problem in Section 3 using a distance metric.\n\n### 2.3 Example: 3-SAT Instance\n\nConsider a 3-SAT instance with $n=3$ variables, $\\phi = (x_1 \\vee \\neg x_2 \\vee x_3) \\wedge (\\neg x_1 \\vee x_2 \\vee \\neg x_3)$, encoded in $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes 3}$. Assign $\\mathbf{v}_i = (1, 0)$, $\\mathbf{w}_i = (0, 1)$ for $x_i = \\text{True}$, $\\neg x_i = \\text{False}$. For clause $C_1 = x_1 \\vee \\neg x_2 \\vee x_3$, the projection is:\n$$\nP_1 = I - (I - |\\mathbf{v}_1\\rangle\\langle \\mathbf{v}_1|) \\otimes (I - |\\mathbf{w}_2\\rangle\\langle \\mathbf{w}_2|) \\otimes (I - |\\mathbf{v}_3\\rangle\\langle \\mathbf{v}_3|).\n$$\nFor $C_2 = \\neg x_1 \\vee x_2 \\vee \\neg x_3$:\n$$\nP_2 = I - (I - |\\mathbf{w}_1\\rangle\\langle \\mathbf{w}_1|) \\otimes (I - |\\mathbf{v}_2\\rangle\\langle \\mathbf{v}_2|) \\otimes (I - |\\mathbf{w}_3\\rangle\\langle \\mathbf{w}_3|).\n$$\nThe assignment $x_1 = x_2 = x_3 = \\text{True}$, represented by $v = \\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3$, satisfies $C_1$ ($x_1 = \\text{True}$) and $C_2$ ($x_2 = \\text{True}$). Thus, $P_1 v = v$, $P_2 v = v$, and $P v = P_2 \\circ P_1 v = v$, confirming satisfiability.\n\n## 3 Optimization Problem for SAT\n\nWe reformulate the Boolean Satisfiability (SAT) problem as an optimization problem, where satisfiability is determined by minimizing a distance metric between configurations under the projection composition defined in Section 2.2. Building on the 2-category $\\mathcal{C}$ (Section 2), this approach quantifies deviations from satisfiability, with satisfiable instances achieving zero deviation and unsatisfiable ones exhibiting a positive gap [8].\n\n### 3.1 Configuration Space and Distance Metric\n\n**Definition 3.1 (Configuration Space)** \nThe configuration space $\\mathcal{D}(\\mathcal{V})$ consists of positive semi-definite operators $\\rho$ on $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$, dimension $2^n$, with trace $\\text{Tr}(\\rho) = 1$. Pure configurations, such as $\\rho_v = |v\\rangle\\langle v|$ for a normalized vector $v \\in \\mathcal{V}$, correspond to classical assignments (e.g., $v = \\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3$ for $x_1 = x_2 = x_3 = \\text{True}$, where $\\mathbf{v}_i = (1, 0)$).\n\nThe space $\\mathcal{D}(\\mathcal{V})$ is convex and compact, equipped with a metric to measure distances between configurations [8]. We use the Bures distance due to its compatibility with the transformations in $\\mathcal{C}$.\n\n**Definition 3.2 (Bures Distance)** \nFor $\\rho, \\sigma \\in \\mathcal{D}(\\mathcal{V})$, the Bures distance is:\n$$\nd_B(\\rho, \\sigma) = \\sqrt{2 \\left( 1 - \\sqrt{F(\\rho, \\sigma)} \\right)},\n$$\nwhere the fidelity is $F(\\rho, \\sigma) = \\left( \\text{Tr} \\sqrt{\\sqrt{\\rho} \\sigma \\sqrt{\\rho}} \\right)^2$. For pure configurations $\\rho = |u\\rangle\\langle u|$, $\\sigma = |v\\rangle\\langle v|$ with $u, v \\in \\mathcal{V}, \\|u\\| = \\|v\\| = 1$, it simplifies to:\n$$\nd_B(\\rho, \\sigma) = \\sqrt{2 (1 - |\\langle u | v \\rangle|)},\n$$\nsince $|\\langle u | v \\rangle|$ is real and non-negative for normalized vectors [8].\n\nThe Bures distance is a metric on $\\mathcal{D}(\\mathcal{V})$, satisfying positivity, symmetry, and the triangle inequality [8]. It is suitable for measuring deviations induced by clause projections $P_j: \\mathcal{V} \\to \\mathcal{V}$ (Section 2.1), as it aligns with the 2-category’s structure [9,10].\n\n### 3.2 Optimization Problem\n\nFor the projection composition $P = P_m \\circ \\cdots \\circ P_1: \\mathcal{V} \\to \\mathcal{V}$ (Section 2.2), we define a deviation measure to reformulate SAT as an optimization problem.\n\n**Definition 3.3 (Deviation Measure)** \nThe deviation measure for a configuration $\\rho \\in \\mathcal{D}(\\mathcal{V})$ is:\n$$\nd_B(\\rho, P(\\rho)),\n$$\nwhere:\n$$\nP(\\rho) = \\frac{P \\rho P^\\dagger}{\\text{Tr}(P \\rho P^\\dagger)},\n$$\nif $\\text{Tr}(P \\rho P^\\dagger) \\neq 0$, and $P(\\rho) = 0$ otherwise. The SAT problem is equivalent to minimizing:\n$$\nS[\\rho] = d_B(\\rho, P(\\rho))^2,\n$$\nover $\\rho \\in \\mathcal{D}(\\mathcal{V})$.\n\nThe deviation measure quantifies how far $\\rho$ is from being invariant under $P$. For a pure configuration $\\rho_v = |v\\rangle\\langle v|$, $v \\in \\mathcal{V}, \\|v\\|=1$:\n- If $\\phi$ is satisfiable, there exists $\\rho_v$ such that $P_j \\rho_v = \\rho_v$ for all $j$, so $P(\\rho_v) = \\rho_v$ and $d_B(\\rho_v, P(\\rho_v)) = 0$.\n- If $\\phi$ is unsatisfiable, $\\bigcap*{j=1}^m \\text{im}(P*j) = \\emptyset$, so $P(\\rho) = 0$ for all $\\rho \\in \\mathcal{D}(\\mathcal{V})$, and $d_B(\\rho, P(\\rho)) = \\sqrt{2}$ [8].\n\nThus, the infimum satisfies:\n$$\n\\inf*{\\rho \\in \\mathcal{D}(\\mathcal{V})} S[\\rho] = \\begin{cases} \n0 & \\text{if } \\phi \\text{ is satisfiable}, \\\\\n2 & \\text{if } \\phi \\text{ is unsatisfiable}.\n\\end{cases}\n$$\nWe focus on pure configurations $\\rho_v$, as they correspond to classical assignments and suffice to determine satisfiability, aligning with the constraint measure $\\lambda(v) = \\sum_{j=1}^m M_j(v)$ in Section 4 [8].\n\n### 3.3 Example: 3-SAT Instance\n\nConsider the 3-SAT instance $\\phi = (x_1 \\vee \\neg x_2 \\vee x_3) \\wedge (\\neg x_1 \\vee x_2 \\vee \\neg x_3)$ with $n=3$, as in Section 2.3, using $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes 3}$. For the assignment $x_1 = x_2 = x_3 = \\text{True}$, the pure configuration is $\\rho = |\\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3\\rangle\\langle \\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3|$, where $\\mathbf{v}_i = (1, 0)$. The clause projections are as in Section 2.3. Since $\\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3$ satisfies $C_1$ ($x_1 = \\text{True}$) and $C_2$ ($x_2 = \\text{True}$), we have $P_1 \\rho = \\rho$, $P_2 \\rho = \\rho$, so $P(\\rho) = P_2 (P_1 \\rho) = \\rho$, and:\n$$\nd*B(\\rho, P(\\rho)) = 0.\n$$\nFor an unsatisfiable 3-SAT instance, consider $\\phi = (x_1 \\vee x_2) \\wedge (\\neg x_1 \\vee \\neg x_2) \\wedge (x_1 \\vee \\neg x_2) \\wedge (\\neg x_1 \\vee x_2)$. For any $\\rho \\in \\mathcal{D}(\\mathcal{V})$, the projections conflict, so $P(\\rho) = 0$, yielding:\n$$\nd_B(\\rho, P(\\rho)) = \\sqrt{2}.\n$$\nThis gap ($0$ vs. $\\sqrt{2}$) distinguishes satisfiable from unsatisfiable instances, aligning with the constraint measure in Section 4.\n\n## 4 Constraint Measure for SAT\n\nWe define a constraint measure $\\lambda(v)$ for a SAT instance, quantifying clause violations in the 2-category $\\mathcal{C}$ (Section 2). This measure distinguishes satisfiable from unsatisfiable instances via a positive gap, aligning with the optimization problem in Section 3 and enabling the complexity analysis in Section 5 [2].\n\n### 4.1 Constraint Measure and Satisfiability Gap\n\n**Definition 4.1 (Constraint Measure)** \nFor a SAT instance $\\phi = C_1 \\wedge \\cdots \\wedge C_m$ with $n$ variables, represented in $\\mathcal{C}$, the constraint measure $\\lambda: \\mathcal{V} \\to \\mathbb{R}*{\\geq 0}$ on the configuration space $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$ is:\n$$\n\\lambda(v) = \\sum_{j=1}^m M_j(v),\n$$\nwhere $v \\in \\mathcal{V}, \\|v\\|=1$, and the clause mapping $M_j: \\mathcal{V} \\to \\mathbb{R}_{\\geq 0}$ for clause $C_j$ is:\n$$\nM_j(v) = \\text{Tr}((I - P_j) \\rho_v),\n$$\nwith $\\rho_v = |v\\rangle\\langle v|$ and $P_j: \\mathcal{V} \\to \\mathcal{V}$ the clause projection (Definition 2.1). The minimum penalty is:\n$$\n\\lambda_{\\min} = \\inf_{v \\in \\mathcal{V}, \\|v\\|=1} \\lambda(v).\n$$\n\nThe mapping $M_j(v) = 0$ if $v$ satisfies $C_j$ (i.e., $P_j v = v$), and $M_j(v) \\geq \\delta > 0$ otherwise, where $\\delta$ is a constant reflecting the orthogonal distance to the satisfying subspace, determined by the clause structure (e.g., up to three literals in 3-SAT) [8]. The measure $\\lambda(v)$ sums clause violations, with $\\lambda_{\\min} = 0$ indicating satisfiability. This aligns with the optimization problem in Section 3.2, where $\\lambda(v) = 0$ corresponds to $d_B(\\rho_v, P(\\rho_v)) = 0$ for a pure configuration $\\rho_v = |v\\rangle\\langle v|$ [2].\n\n**Theorem 4.1 (Satisfiability Gap)** \nFor a SAT instance $\\phi$, the minimum penalty satisfies:\n$$\n\\lambda_{\\min} = \\begin{cases} \n0 & \\text{if } \\phi \\text{ is satisfiable}, \\\\\nc & \\text{if } \\phi \\text{ is unsatisfiable},\n\\end{cases}\n$$\nwhere $c \\geq \\delta > 0$ is a constant independent of $n$ or $m$.\n\n**Proof.** \nConsider $\\phi = C_1 \\wedge \\cdots \\wedge C_m$ with configurations in $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$. Each clause $C_j$ has a projection $P_j$ (Section 2.1), where $P_j v = v$ if $v$ satisfies $C_j$, and $P_j v$ lies in the orthogonal complement otherwise.\n\n**Case 1: Satisfiable.** If $\\phi$ is satisfiable, there exists an assignment $a = (a_1, \\ldots, a_n) \\in \\{0,1\\}^n$ satisfying all clauses. Construct $v_a \\in \\mathcal{V}$ as the tensor product of $\\mathbf{v}_i = (1, 0)$ for $a_i = 1$ or $\\mathbf{w}_i = (0, 1)$ for $a_i = 0$, with $\\|v_a\\|=1$. Since $a$ satisfies each $C_j$, we have $P_j v_a = v_a$, so:\n$$\nM_j(v_a) = \\text{Tr}((I - P_j) \\rho_{v_a}) = \\langle v_a | (I - P_j) v_a \\rangle = 0.\n$$\nThus, $\\lambda(v_a) = \\sum_{j=1}^m M_j(v_a) = 0$, and since $\\lambda(v) \\geq 0$, we have $\\lambda_{\\min} = 0$.\n\n**Case 2: Unsatisfiable.** If $\\phi$ is unsatisfiable, no $v \\in \\mathcal{V}, \\|v\\|=1$ satisfies all clauses. For any $v$, at least one clause $C_j$ is violated, so $P_j v \\neq v$, and:\n$$\nM_j(v) = \\langle v | (I - P_j) v \\rangle \\geq \\delta > 0,\n$$\nwhere $\\delta > 0$ is a constant determined by the clause structure [8]. Thus, $\\lambda(v) \\geq \\delta$, and:\n$$\n\\lambda_{\\min} = \\inf_{v \\in \\mathcal{V}, \\|v\\|=1} \\lambda(v) \\geq \\delta.\n$$\nSet $c = \\delta$, independent of $n$ or $m$. The projection composition $P = P_m \\circ \\cdots \\circ P_1$ (Section 2.2) yields $P(\\rho_v) = 0$ for unsatisfiable instances, confirming the gap: $\\lambda_{\\min} \\geq c > 0$. $\\square$\n\nThe gap ($\\lambda_{\\min} = 0$ vs. $c > 0$) mirrors the optimization gap in Section 3.2 ($S[\\rho] = 0$ vs. $2$), linking $\\lambda(v)$ to the complexity analysis in Section 5.\n\n### 4.2 Example: 3-SAT Instance\n\nFor the satisfiable 3-SAT instance $\\phi = (x_1 \\vee \\neg x_2 \\vee x_3) \\wedge (\\neg x_1 \\vee x_2 \\vee \\neg x_3)$ with $n=3$, using $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes 3}$ (Section 2.3), consider the assignment $x_1 = x_2 = x_3 = \\text{True}$, with $v_a = \\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3$, $\\mathbf{v}_i = (1, 0)$, $\\|v_a\\|=1$. The projections $P_1, P_2$ are defined as in Section 2.3. Since $v_a$ satisfies $C_1$ ($x_1 = \\text{True}$) and $C_2$ ($x_2 = \\text{True}$), we have $P_1 v_a = v_a$, $P_2 v_a = v_a$, so:\n$$\nM_1(v_a) = \\text{Tr}((I - P_1) \\rho_{v_a}) = 0, \\quad M_2(v_a) = \\text{Tr}((I - P_2) \\rho_{v_a}) = 0.\n$$\nThus, $\\lambda(v_a) = 0$, so $\\lambda_{\\min} = 0$.\n\nFor an unsatisfiable 3-SAT instance, consider $\\phi = (x_1 \\vee x_2) \\wedge (\\neg x_1 \\vee \\neg x_2) \\wedge (x_1 \\vee \\neg x_2) \\wedge (\\neg x_1 \\vee x_2)$. For any $v \\in \\mathcal{V}, \\|v\\|=1$, at least one clause is violated. For $v = \\mathbf{v}_1 \\otimes \\mathbf{v}_2$, satisfying the first clause, the second clause $\\neg x_1 \\vee \\neg x_2$ is violated, so:\n$$\nP_2 v \\neq v, \\quad M_2(v) = \\text{Tr}((I - P_2) \\rho_v) \\geq \\delta > 0.\n$$\nThus, $\\lambda(v) \\geq \\delta$, and $\\lambda_{\\min} \\geq c = \\delta > 0$. This gap illustrates the theorem’s distinction between satisfiable and unsatisfiable instances.\n\n## 5 Exponential Time Complexity of 3-SAT\n\nWe prove that computing the satisfiability of a 3-SAT instance, an NP-complete problem, requires exponential time in the number of variables $n$, establishing $P \\neq NP$. This builds on the 2-category $\\mathcal{C}$ (Section 2), optimization problem (Section 3), and constraint measure $\\lambda(v)$ (Section 4), showing that computing the minimum penalty $\\lambda_{\\min}$ demands exponential time [1,2].\n\n### 5.1 Hardness of Computing the Minimum Penalty\n\nFor a 3-SAT instance $\\phi = C_1 \\wedge \\cdots \\wedge C_m$ with $n$ variables and $m = O(n)$ clauses, each with up to three literals, satisfiability is equivalent to determining whether $\\lambda_{\\min} = \\inf_{v \\in \\mathcal{V}, \\|v\\|=1} \\lambda(v) = 0$, where $\\lambda(v) = \\sum_{j=1}^m M_j(v)$ is the constraint measure on $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$, with $M_j(v) = \\text{Tr}((I - P_j) \\rho_v)$, $\\rho_v = |v\\rangle\\langle v|$, and $P_j$ the clause projection (Section 4.1). For example, the satisfiable 3-SAT instance from Section 2.3 has $\\lambda_{\\min} = 0$, while the unsatisfiable instance from Section 4.2 has $\\lambda_{\\min} \\geq c$.\n\n**Theorem 5.1 (Exponential Time for $\\lambda_{\\min}$)** \nComputing $\\lambda_{\\min}$ for worst-case 3-SAT instances requires $\\Omega(2^{kn})$ time for some constant $k > 0$, unless $P = NP$.\n\n**Proof.** \nBy the Satisfiability Gap Theorem (Theorem 4.1), $\\lambda_{\\min} = 0$ if $\\phi$ is satisfiable (there exists $v \\in \\mathcal{V}, \\|v\\|=1$ such that $P_j v = v$ for all $j$), and $\\lambda_{\\min} \\geq c = \\delta > 0$ otherwise, where $\\delta$ is a constant. Exact computation of $\\lambda_{\\min}$ over $\\mathcal{V}$, dimension $2^n$, requires evaluating $\\lambda(v)$ for $O(2^n)$ basis configurations, taking $O(2^{3n})$ time due to matrix operations [11]. We show that even approximating $\\lambda_{\\min}$ to decide satisfiability is NP-hard.\n\n**Lemma 5.1 (Hardness of Approximation)** \nApproximating $\\lambda_{\\min}$ to within additive error $\\epsilon < c/m$ requires $\\Omega(2^{kn})$ time for some $k > 0$, unless $P = NP$.\n\n**Proof.** \nFor a satisfiable $\\phi$, there exists $v$ such that $\\lambda(v) = 0$, so $\\lambda_{\\min} = 0$. For an unsatisfiable $\\phi$, every $v$ violates at least one clause, so $\\lambda(v) \\geq \\delta$, and $\\lambda_{\\min} \\geq c = \\delta$. An algorithm outputting a value $< c/m$ for satisfiable instances ($\\lambda_{\\min} = 0$) and $\\geq c/2$ for unsatisfiable instances ($\\lambda_{\\min} \\geq c$) distinguishes $\\lambda_{\\min} = 0$ from $\\lambda_{\\min} \\geq c$, as $c/m < c/2$ for $m \\geq 2$, solving 3-SAT.\n\nSince 3-SAT is NP-complete [1], and MAX-3-SAT inapproximability [6] shows that distinguishing fully satisfiable instances from those with at most a $1 - 1/8$ fraction satisfiable is NP-hard, approximating $\\lambda_{\\min}$ within $\\epsilon < c/m$ (with $m = O(n)$) is equivalent to solving 3-SAT. The projections $P_j$ encode 3-SAT’s combinatorial structure (Section 2.1), requiring $\\Omega(2^{kn})$ evaluations of $\\lambda(v)$ to find a satisfying configuration [5,6]. A polynomial-time approximation algorithm would imply $P = NP$. $\\square$\n\nThus, computing $\\lambda_{\\min}$ requires $\\Omega(2^{kn})$ time unless $P = NP$. $\\square$\n\n### 5.2 Implications and Complexity Barriers\n\nThe exponential time requirement for computing $\\lambda_{\\min}$ for 3-SAT implies that no polynomial-time algorithm exists for 3-SAT unless $P = NP$. Since 3-SAT is reducible to any NP problem [1], this extends to all NP problems, yielding:\n$$\n\\boxed{P \\neq NP}\n$$\n\nOur categorical approach avoids known complexity barriers [12,13]. The _relativization barrier_ [12] is sidestepped because the proof relies on the categorical structure of $\\mathcal{C}$ and the linear algebraic properties of $\\mathcal{V}$, which encode 3-SAT’s constraints non-relativizingly, unlike diagonalization techniques [2,4]. The _natural proofs barrier_ [13] is avoided as the proof is non-constructive (no efficient algorithm is provided) and problem-specific to 3-SAT’s clause structure, not broadly applicable to Boolean functions. These properties ensure the proof’s robustness, relying on standard NP-hardness assumptions [1,5,6].\n\n## 6 Graph-Theoretic Reformulation of 3-SAT\n\nTo reinforce the proof that $P \\neq NP$, we reformulate the 3-SAT problem as a graph-theoretic problem on a clause graph, preserving the constraint measure $\\lambda(v)$ (Section 4) as a combinatorial invariant. By showing that computing this invariant requires exponential time, we provide an alternative confirmation of the exponential complexity of 3-SAT, supporting the result of Section 5 [1,2].\n\n### 6.1 Clause Graph and Connectivity Index\n\nFor a 3-SAT instance $\\phi = C_1 \\wedge \\cdots \\wedge C_m$ with $n$ variables and $m = O(n)$ clauses, we define a clause graph to encode satisfiability combinatorially.\n\n**Definition 6.1 (Clause Graph)** \nThe clause graph $G_\\phi = (V, E)$ is defined as:\n- _Vertices_ $V$: Configurations in $\\mathcal{V} = (\\mathbb{C}^2)^{\\otimes n}$, representing variable assignments (Section 2.1).\n- _Edges_ $E$: Pairs $(v, v')$ where $v, v' \\in \\mathcal{V}, \\|v\\| = \\|v'\\| = 1$, differ in at most one variable, and satisfy the same clauses $C_j$, i.e., $P_j v = v$ and $P_j v' = v'$ for some $j$, with $P_j$ the clause projection (Definition 2.1).\n\nThe graph $G_\\phi$ connects configurations with similar clause satisfaction profiles. For a satisfiable $\\phi$, there exists a configuration $v$ such that $P_j v = v$ for all $j$, forming a connected component in $G_\\phi$ where all vertices satisfy $\\phi$. For an unsatisfiable $\\phi$, no such component exists, as every $v$ violates at least one clause (Section 4.1). For the satisfiable instance $\\phi = (x_1 \\vee \\neg x_2 \\vee x_3) \\wedge (\\neg x_1 \\vee x_2 \\vee \\neg x_3)$ (Section 2.3) with $n=3$, the clause graph $G_\\phi$ has $2^3 = 8$ vertices, and includes a connected component containing $v = \\mathbf{v}_1 \\otimes \\mathbf{v}_2 \\otimes \\mathbf{v}_3$, with $\\kappa_\\phi = 1$. For the unsatisfiable instance $\\phi = (x_1 \\vee x_2) \\wedge (\\neg x_1 \\vee \\neg x_2) \\wedge (x_1 \\vee \\neg x_2) \\wedge (\\neg x_1 \\vee x_2)$ (Section 4.2) with $n=2$, the graph has $2^2 = 4$ vertices, and no such component exists, so $\\kappa_\\phi = 0$.\n\n**Definition 6.2 (Connectivity Index)** \nThe connectivity index $\\kappa_\\phi$ is 1 if there exists a connected component in $G_\\phi$ where all vertices satisfy $\\phi$ (i.e., $P_j v = v$ for all $j$), and 0 otherwise.\n\nThe index $\\kappa_\\phi$ mirrors the constraint measure’s minimum penalty $\\lambda_{\\min}$ (Section 4.1). If $\\lambda_{\\min} = 0$, there exists $v$ with $\\lambda(v) = 0$, corresponding to $\\kappa_\\phi = 1$. If $\\lambda_{\\min} \\geq c > 0$, no configuration satisfies all clauses, so $\\kappa_\\phi = 0$. This invariant captures satisfiability combinatorially [2].\n\n### 6.2 Exponential Time Complexity\n\n**Theorem 6.1** \nComputing the connectivity index $\\kappa_\\phi$ for worst-case 3-SAT instances requires $\\Omega(2^{kn})$ time for some constant $k > 0$, unless $P = NP$.\n\n**Proof.** \nComputing $\\kappa_\\phi$ requires identifying a connected component in $G_\\phi$ where all vertices satisfy $\\phi$. Each vertex $v \\in \\mathcal{V}$, dimension $2^n$, represents a variable assignment, and edges connect $v$ to $O(n)$ neighbors differing in one variable. For satisfiable $\\phi$, there exists a component where all vertices have $\\lambda(v) = 0$ (Section 4.1), so $\\kappa_\\phi = 1$. For unsatisfiable $\\phi$, every vertex violates at least one clause, so $\\kappa_\\phi = 0$. Since 3-SAT’s combinatorial structure ensures that any satisfying configuration $v$ (where $P_j v = v$ for all $j$) implies a non-empty component, checking one such $v$ is equivalent to solving 3-SAT.\n\nDetermining whether $\\kappa_\\phi = 1$ is equivalent to finding a configuration $v$ such that $P_j v = v$ for all $j$, i.e., solving 3-SAT. Since $\\mathcal{V}$ has $2^n$ vertices, evaluating clause satisfaction (via projections $P_j$) for each vertex and checking connectivity requires $\\Omega(2^n)$ operations. The NP-completeness of 3-SAT [1] and MAX-3-SAT inapproximability [6] imply that distinguishing $\\kappa_\\phi = 1$ from $\\kappa_\\phi = 0$ is NP-hard, requiring $\\Omega(2^{kn})$ time for some $k > 0$ due to the combinatorial structure of clause interactions [5]. A polynomial-time algorithm for computing $\\kappa_\\phi$ would solve 3-SAT, implying $P = NP$. $\\square$\n\nThis graph-theoretic reformulation reinforces the exponential time complexity of 3-SAT (Section 5), as computing $\\kappa_\\phi$ mirrors the hardness of computing $\\lambda_{\\min}$, confirming $P \\neq NP$.\n\n## 7 Conclusion\n\nWe prove that $P \\neq NP$ by reformulating the NP-complete 3-SAT problem in categorical and graph-theoretic frameworks. A 2-category and a clause graph model 3-SAT, enabling an optimization problem and connectivity analysis that confirm $P \\neq NP$ (Sections 2, 6). By defining a constraint measure and a topological invariant, we show that determining satisfiability requires exponential time (Sections 4, 5, 6) [1,5,6]. Unlike combinatorial or algebraic approaches [3], our methods leverage category theory and graph theory, offering novel insights into computational complexity. The proof avoids relativization and natural proofs barriers by being non-relativizing and specific to 3-SAT, ensuring robustness [12,13]. This result confirms that NP-complete problems require super-polynomial time unless $P = NP$. Future work could extend these frameworks to other NP-complete problems [2,4].\n\n$$\n\\boxed{P \\neq NP}\n$$\n\n---\n\n## References\n\n1. Cook, Stephen A. \"The complexity of theorem-proving procedures.\" _Proceedings of the Third Annual ACM Symposium on Theory of Computing (STOC '71)_, 151–158, ACM, New York, NY, USA, 1971. DOI: 10.1145/800157.805047.\n2. Arora, Sanjeev and Barak, Boaz. _Computational Complexity: A Modern Approach_. Cambridge University Press, Cambridge, UK, 2009.\n3. Fortnow, Lance. \"The status of the P versus NP problem.\" _Communications of the ACM_ 56(9): 78–86, 2013. DOI: 10.1145/2500468.2500487.\n4. Leinster, Tom. _Basic Category Theory_. Cambridge University Press, Cambridge, UK, 2014.\n5. Dinur, Irit and Safra, Shmuel. \"On the hardness of approximating minimum vertex cover.\" _Annals of Mathematics_ 162(1): 439–485, 2007. DOI: 10.4007/annals.2007.162.439.\n6. Håstad, Johan. \"Some optimal inapproximability results.\" _Journal of the ACM_ 48(4): 798–859, 2001. DOI: 10.1145/502090.502098.\n7. Mac Lane, Saunders. _Categories for the Working Mathematician_, 2nd ed. Springer, New York, NY, USA, 1998.\n8. Bengtsson, Ingemar and Życzkowski, Karol. _Geometry of Quantum States: An Introduction to Quantum Entanglement_. Cambridge University Press, Cambridge, UK, 2006.\n9. Petz, Dénes. \"Monotone metrics on matrix spaces.\" _Linear Algebra and its Applications_ 244: 81–96, 1996. DOI: 10.1016/0024-3795(94)00211-8.\n10. Petz, Dénes and Sudár, Csaba. \"Geometries of quantum states.\" _Journal of Mathematical Physics_ 37(6): 2662–2673, 1996. DOI: 10.1063/1.531551.\n11. Golub, Gene H. and Van Loan, Charles F. _Matrix Computations_, 3rd ed. Johns Hopkins University Press, Baltimore, MD, USA, 1996.\n12. Baker, Theodore P. and Gill, John and Solovay, Robert. \"Relativizations of the P =? NP question.\" _SIAM Journal on Computing_ 4(4): 431–442, 1975. DOI: 10.1137/0204037.\n13. Razborov, Alexander A. and Rudich, Steven. \"Natural proofs.\" _Journal of Computer and System Sciences_ 55(1): 24–35, 1997. DOI: 10.1006/jcss.1997.1494.", +"tags": [ +[ +"d", +"1752035287698" +], +[ +"title", +"Proving P ≠ NP via Categorical and Graph-Theoretic 3-SAT" +], +[ +"summary", +"We prove that $P \\neq NP$ by reformulating the NP-complete 3-SAT problem as an optimization problem using categorical and graph-theoretic frameworks. A 2-category encodes 3-SAT’s variables and clauses as vectors and transformations in a complex vector space, while a clause graph captures satisfiability as a connectivity property, with a constraint measure and invariant distinguishing satisfiable and unsatisfiable cases. Computing either requires exponential time, establishing $P \\neq NP$. This dual approach, leveraging category theory and graph theory, offers a novel perspective on computational complexity." +], +[ +"t", +"math" +], +[ +"t", +"p vs np" +], +[ +"t", +"complexity theory" +], +[ +"t", +"category theory" +], +[ +"t", +"graph theory" +], +[ +"published_at", +"1752035704" +], +[ +"alt", +"This is a long form article, you can read it in https://habla.news/a/naddr1qvzqqqr4gupzqwe6gtf5eu9pgqk334fke8f2ct43ccqe4y2nhetssnypvhge9ce9qqxnzde4xgcrxdfj8qmnvwfc69lg5m" +] +], +"kind": 30023, +"pubkey": "3b3a42d34cf0a1402d18d536c9d2ac2eb1c6019a9153be57084c8165d192e325", +"id": "4afdd068904f12c370913ca3c8744b71fae258e59457fad6f3c28ddffb8f0f41", +"sig": "6be4cf6472b98c80c659e472d8db3bc8c144a1c551c821d1cfd925dade26b395690f71b38631e49d180d7ec79fbbbbcb148df27a40955ef22479e7bec36bd6ad" +} diff --git a/tests/e2e/example.pw.spec.ts b/tests/e2e/example.pw.spec.ts index 54a906a..b60fe7c 100644 --- a/tests/e2e/example.pw.spec.ts +++ b/tests/e2e/example.pw.spec.ts @@ -1,18 +1,20 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test('has title', async ({ page }) => { - 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(); }); diff --git a/tests/integration/markupIntegration.test.ts b/tests/integration/markupIntegration.test.ts index b4de512..a834593 100644 --- a/tests/integration/markupIntegration.test.ts +++ b/tests/integration/markupIntegration.test.ts @@ -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

      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(']*>.*]*>/s); // Blockquotes - expect(output).toContain(''); + expect(output).toContain( + '
      ', + ); // Images - expect(output).toMatch(/]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); + expect(output).toMatch( + /]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/, + ); // Links - expect(output).toMatch(/]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); + expect(output).toMatch( + /]+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(/]+youtube/); // Tracking token removal: should not contain utm_, fbclid, or gclid in any link @@ -44,42 +52,50 @@ describe('Markup Integration Test', () => { expect(output).not.toMatch(/fbclid/); expect(output).not.toMatch(/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(']*>.*]*>/s); // Blockquotes - expect(output).toContain(']*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s); + expect(output).toMatch( + /]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s, + ); // Images - expect(output).toMatch(/]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); + expect(output).toMatch( + /]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/, + ); // Links - expect(output).toMatch(/]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); + expect(output).toMatch( + /]+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(/]+youtube/); // Tracking token removal: should not contain utm_, fbclid, or gclid in any link @@ -87,13 +103,13 @@ describe('Markup Integration Test', () => { expect(output).not.toMatch(/fbclid/); expect(output).not.toMatch(/gclid/); // Horizontal rule - expect(output).toContain('/); // Table - expect(output).toContain(' 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 +This is a youtube link https://www.youtube.com/watch?v=9aqVxNCpx9s And here is a link with tracking tokens: https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU This is an unordered list: -* but -* not -* really + +- 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 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< { - 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('bold'); - expect(output).toContain('italic'); + expect(output).toContain("bold"); + expect(output).toContain("italic"); expect(output).toContain('strikethrough'); }); - it('parses blockquotes', async () => { - const input = '> quote'; + it("parses blockquotes", async () => { + const input = "> quote"; const output = await parseAdvancedmarkup(input); - expect(output).toContain(' { - const input = '> quote\n> quote'; + it("parses multi-line blockquotes", async () => { + const input = "> quote\n> quote"; const output = await parseAdvancedmarkup(input); - expect(output).toContain(' { - const input = '* a\n* b'; + it("parses unordered lists", async () => { + const input = "* a\n* b"; const output = await parseAdvancedmarkup(input); - expect(output).toContain(' { - 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(' { - 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(' { - 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(' { - 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(']+>/g, ""); + expect(output).toContain(" { - const input = '---'; + it("parses horizontal rules", async () => { + const input = "---"; const output = await parseAdvancedmarkup(input); - expect(output).toContain(' { - 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"); }); -}); \ No newline at end of file + + 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(" { - 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('bold'); - expect(output).toContain('italic'); + expect(output).toContain("bold"); + expect(output).toContain("italic"); expect(output).toContain('strikethrough'); }); - it('parses blockquotes', async () => { - const input = '> quote'; + it("parses blockquotes", async () => { + const input = "> quote"; const output = await parseBasicmarkup(input); - expect(output).toContain(' { - const input = '> quote\n> quote'; + it("parses multi-line blockquotes", async () => { + const input = "> quote\n> quote"; const output = await parseBasicmarkup(input); - expect(output).toContain(' { - const input = '* a\n* b'; + it("parses unordered lists", async () => { + const input = "* a\n* b"; const output = await parseBasicmarkup(input); - expect(output).toContain(' { - 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(' { - 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(' { - 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"); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/latexRendering.test.ts b/tests/unit/latexRendering.test.ts new file mode 100644 index 0000000..7096a8a --- /dev/null +++ b/tests/unit/latexRendering.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupParser"; +import { readFileSync } from "fs"; +import { join } from "path"; + +describe("LaTeX Math Rendering", () => { + const mdPath = join(__dirname, "../../test_data/latex_markdown.md"); + const raw = readFileSync(mdPath, "utf-8"); + // Extract the markdown content field from the JSON + const content = JSON.parse(raw).content; + + it('renders inline math as ', async () => { + const html = await parseAdvancedmarkup(content); + expect(html).toMatch(/\$P \\neq NP\$<\/span>/); + expect(html).toMatch( + /\$x_1 = \\text\{True\}\$<\/span>/, + ); + }); + + it('renders display math as
      \$\$\s*P_j = \\bigotimes/, + ); + expect(html).toMatch( + /
      \$\$[\s\S]*?\\begin\{pmatrix\}/, + ); + expect(html).toMatch( + /
      \$\$\\boxed\{P \\neq NP\}\$\$<\/div>/, + ); + }); + + it("does not wrap display math in

      or

      ", async () => { + const html = await parseAdvancedmarkup(content); + // No

      or

      directly wrapping math-block + expect(html).not.toMatch(/]*>\s*
      { + const html = await parseAdvancedmarkup(content); + // Check that pmatrix is properly rendered within a display math block + expect(html).toMatch( + /
      \$\$[\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>/g, '') + .replace(/
      \$\$[\s\S]*?\$\$<\/div>/g, '') + .replace(/
      [\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(//); + expect(html).not.toMatch(//); + }); + + 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(/
      \$\$d_B\([^$]+\) = [^$]+\$\$<\/div>/); + // Example: P(\rho) = ... + expect(html).toMatch(/
      \$\$P\([^$]+\) = [^$]+\$\$<\/div>/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 61e619b..dfbd6e3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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({ 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()), + }, });