diff --git a/.vscode/settings.json b/.vscode/settings.json index ce072c8..65737f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "editor.tabSize": 2 + "editor.tabSize": 2, +"files.associations": { + "*.css": "postcss" } +} \ No newline at end of file diff --git a/README.md b/README.md index 3248ab3..b7cffbb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ For a thorough introduction, please refer to our [project documention](https://n ## Issues and Patches -If you would like to suggest a feature or report a bug, or submit a patch for review, please use the [Nostr git interface](https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyt8wumn8ghj7ur4wfcxcetjv4kxz7fwvdhk6tcqpfqkcetcv9hxgunfvyamcf5z) on our homepage. +If you would like to suggest a feature or report a bug, please use the [Alexandria Contact page](https://next-alexandria.gitcitadel.eu/contact). You can also contact us [on Nostr](https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg), directly. @@ -114,4 +114,8 @@ npm run test For the Playwright end-to-end (e2e) tests: ```bash npx playwright test -``` \ No newline at end of file +``` + +## 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 diff --git a/package-lock.json b/package-lock.json index 59e5f20..c3927fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "asciidoctor": "3.0.x", "d3": "^7.9.0", "he": "1.2.x", + "highlight.js": "^11.11.1", + "node-emoji": "^2.2.0", "nostr-tools": "2.10.x" }, "devDependencies": { @@ -23,7 +25,7 @@ "@sveltejs/adapter-auto": "3.x", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-static": "3.x", - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "4.x", "@types/d3": "^7.4.3", "@types/he": "1.2.x", @@ -1363,6 +1365,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sveltejs/adapter-auto": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", @@ -2453,6 +2467,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/character-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", @@ -3161,6 +3184,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4135,6 +4164,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4765,6 +4803,21 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC" }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5796,6 +5849,18 @@ "node": ">=18" } }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6532,6 +6597,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unxhr": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.2.0.tgz", diff --git a/package.json b/package.json index b0f1151..6993ab2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "asciidoctor": "3.0.x", "d3": "^7.9.0", "he": "1.2.x", + "highlight.js": "^11.11.1", + "node-emoji": "^2.2.0", "nostr-tools": "2.10.x" }, "devDependencies": { diff --git a/src/app.css b/src/app.css index eba8caf..dc4ccc5 100644 --- a/src/app.css +++ b/src/app.css @@ -2,40 +2,38 @@ @import './styles/publications.css'; @import './styles/visualize.css'; -@layer components { - /* General */ +/* Custom styles */ +@layer base { .leather { - @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; + @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200; } .btn-leather.text-xs { - @apply w-7 h-7; + @apply px-2 py-1; } .btn-leather.text-xs svg { - @apply w-3 h-3; + @apply h-3 w-3; } .btn-leather.text-sm { - @apply w-8 h-8; + @apply px-3 py-2; } .btn-leather.text-sm svg { - @apply w-4 h-4; + @apply h-4 w-4; } div[role='tooltip'] button.btn-leather { @apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700; } - /* Images */ .image-border { @apply border border-primary-700; } - /* Card */ div.card-leather { - @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; + @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; } @@ -52,7 +50,6 @@ @apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200; } - /* Content */ main { @apply max-w-full; } @@ -74,7 +71,6 @@ @apply hover:bg-primary-100 dark:hover:bg-primary-800; } - /* Section headers */ h1.h-leather, h2.h-leather, h3.h-leather, @@ -108,17 +104,16 @@ @apply text-base font-semibold; } - /* Modal */ - 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-800 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-300; } @@ -126,7 +121,6 @@ @apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } - /* Navbar */ nav.navbar-leather { @apply bg-primary-0 dark:bg-primary-1000 z-10; } @@ -144,32 +138,29 @@ @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } - /* Sidebar */ - aside.sidebar-leather > div { - @apply bg-gray-100 dark:bg-gray-900; + aside.sidebar-leather>div { + @apply bg-primary-0 dark:bg-primary-1000; } a.sidebar-item-leather { @apply hover:bg-primary-100 dark:hover:bg-primary-800; } - /* Skeleton */ div.skeleton-leather div { - @apply bg-gray-400 dark:bg-gray-600; + @apply bg-primary-100 dark:bg-primary-800; } - /* Textarea */ div.textarea-leather { - @apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600; + @apply bg-primary-0 dark:bg-primary-1000; } - div.textarea-leather > div:nth-child(1), + div.textarea-leather>div:nth-child(1), div.toolbar-leather { @apply border-none; } - div.textarea-leather > div:nth-child(2) { - @apply bg-gray-100 dark:bg-gray-900; + div.textarea-leather>div:nth-child(2) { + @apply bg-primary-0 dark:bg-primary-1000; } div.textarea-leather, @@ -177,24 +168,25 @@ @apply text-gray-800 dark:text-gray-300; } - /* Tooltip */ div.tooltip-leather { @apply text-gray-800 dark:text-gray-300; } div[role='tooltip'] button.btn-leather .tooltip-leather { - @apply bg-gray-200 dark:bg-gray-700; + @apply bg-primary-100 dark:bg-primary-800; } - + /* Network visualization */ .network-link-leather { - @apply stroke-gray-400 fill-gray-400; + @apply stroke-primary-200 fill-primary-200; } + .network-node-leather { - @apply stroke-gray-800; + @apply stroke-primary-600; } + .network-node-content { - @apply fill-[#d6c1a8]; + @apply fill-primary-100; } } @@ -240,22 +232,21 @@ } @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; - @apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; + @apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; } - + /* Tooltip */ .tooltip-leather { - @apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 - text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 - transition-colors duration-200; + @apply fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700 transition-colors duration-200; max-width: 400px; z-index: 1000; } - + .leather-legend button { @apply dark:text-white; } @@ -264,7 +255,12 @@ .publication-leather { @apply flex flex-col space-y-4; - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { @apply h-leather; } @@ -353,11 +349,104 @@ @apply text-sm; } - thead, tbody { - th, td { + thead, + tbody { + + th, + td { @apply border border-gray-200 dark:border-gray-700; } } } } -} + + /* Footnotes */ + .footnote-ref { + text-decoration: none; + color: var(--color-primary); + } + + .footnotes { + margin-top: 2rem; + font-size: 0.875rem; + color: var(--color-text-muted); + } + + .footnotes hr { + margin: 1rem 0; + border-top: 1px solid var(--color-border); + } + + .footnotes ol { + padding-left: 1rem; + } + + .footnotes li { + margin-bottom: 0.5rem; + } + + .footnote-backref { + text-decoration: none; + margin-left: 0.5rem; + color: var(--color-primary); + } + + .note-leather .footnote-ref, + .note-leather .footnote-backref { + color: var(--color-leather-primary); + } + + /* Scrollable content */ + .description-textarea, + .prose-content { + overflow-y: scroll !important; + scrollbar-width: thin !important; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important; + } + + .description-textarea { + min-height: 100% !important; + } + + .description-textarea::-webkit-scrollbar, + .prose-content::-webkit-scrollbar { + width: 8px !important; + display: block !important; + } + + .description-textarea::-webkit-scrollbar-track, + .prose-content::-webkit-scrollbar-track { + background: transparent !important; + } + + .description-textarea::-webkit-scrollbar-thumb, + .prose-content::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5) !important; + border-radius: 4px !important; + } + + .description-textarea::-webkit-scrollbar-thumb:hover, + .prose-content::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.7) !important; + } + + /* Tab content */ + .tab-content { + position: relative; + display: flex; + flex-direction: column; + } + + /* Input styles */ + input[type="text"], + input[type="email"], + input[type="password"], + input[type="search"], + input[type="number"], + input[type="tel"], + input[type="url"], + textarea { + @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded shadow-none px-4 py-2; + @apply focus:border-primary-400 dark:focus:border-primary-500; + } +} \ No newline at end of file diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte index 1456149..13d9c93 100644 --- a/src/lib/components/Login.svelte +++ b/src/lib/components/Login.svelte @@ -11,6 +11,7 @@ let npub = $state(undefined); let signInFailed = $state(false); + let errorMessage = $state(''); $effect(() => { if ($ndkSignedIn) { @@ -26,6 +27,9 @@ async function handleSignInClick() { try { + signInFailed = false; + errorMessage = ''; + const user = await loginWithExtension(); if (!user) { throw new Error('The NIP-07 extension did not return a user.'); @@ -36,7 +40,7 @@ } catch (e) { console.error(e); signInFailed = true; - // TODO: Show an error message to the user. + errorMessage = e instanceof Error ? e.message : 'Failed to sign in. Please try again.'; } } @@ -52,12 +56,17 @@ placement='bottom' triggeredBy='#avatar' > -
+
+ {#if signInFailed} +
+ {errorMessage} +
+ {/if} +
+

Login Required

+ +
+ + +
+

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

+
+
+ +
+ {#if signInFailed} +
+ {errorMessage} +
+ {/if} +
+
+
+
+ +{/if} \ No newline at end of file diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index 2ac6133..e6ca543 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -21,6 +21,7 @@ Publish Visualize About + Contact diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 17b8b87..22e9719 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -1,7 +1,7 @@ export const wikiKind = 30818; export const indexKind = 30040; export const zettelKinds = [ 30041, 30818 ]; -export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ]; +export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; export enum FeedType { diff --git a/src/lib/utils/markup/MarkupInfo.md b/src/lib/utils/markup/MarkupInfo.md new file mode 100644 index 0000000..dc8c18f --- /dev/null +++ b/src/lib/utils/markup/MarkupInfo.md @@ -0,0 +1,55 @@ +# Markup Support in Alexandria + +Alexandria supports multiple markup formats for different use cases. Below is a summary of the supported tags and features for each parser, as well as the formats used for publications and wikis. + +## Basic Markup Parser + +The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: + +- **Headers:** + - ATX-style: `# H1` through `###### H6` + - Setext-style: `H1\n=====` +- **Bold:** `*bold*` or `**bold**` +- **Italic:** `_italic_` or `__italic__` +- **Strikethrough:** `~strikethrough~` or `~~strikethrough~~` +- **Blockquotes:** `> quoted text` +- **Unordered lists:** `* item` +- **Ordered lists:** `1. item` +- **Links:** `[text](url)` +- **Images:** `![alt](url)` +- **Hashtags:** `#hashtag` +- **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without `nostr:` prefix (note is deprecated) +- **Emoji shortcodes:** `:smile:` will render as 😄 + +## Advanced Markup Parser + +The **advanced markup parser** includes all features of the basic parser, plus: + +- **Inline code:** `` `code` `` +- **Syntax highlighting:** for code blocks in many programming languages (from [highlight.js](https://highlightjs.org/)) +- **Tables:** Pipe-delimited tables with or without headers +- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers +- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended +- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.) + +## Publications and Wikis + +**Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary markup language, not Markdown. + +AsciiDoc supports a much broader set of formatting, semantic, and structural features, including: + +- Section and document structure +- Advanced tables, callouts, admonitions +- Cross-references, footnotes, and bibliography +- Custom attributes and macros +- And much more + +For more information on AsciiDoc, see the [AsciiDoc documentation](https://asciidoc.org/). + +--- + +**Note:** +- The markdown parsers are primarily used for comments, issues, and other user-generated content. +- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility. +- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format. +- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. \ No newline at end of file diff --git a/src/lib/utils/markup/advancedMarkupParser.ts b/src/lib/utils/markup/advancedMarkupParser.ts new file mode 100644 index 0000000..9273857 --- /dev/null +++ b/src/lib/utils/markup/advancedMarkupParser.ts @@ -0,0 +1,389 @@ +import { parseBasicmarkup } from './basicMarkupParser'; +import hljs from 'highlight.js'; +import 'highlight.js/lib/common'; // Import common languages +import 'highlight.js/styles/github-dark.css'; // Dark theme only + +// Register common languages +hljs.configure({ + ignoreUnescapedHTML: true +}); + +// Regular expressions for advanced markup elements +const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; +const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm; +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; +const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; +const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; +const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; +const CODE_BLOCK_REGEX = /^```(\w*)$/; + +/** + * Process headings (both styles) + */ +function processHeadings(content: string): string { + // Tailwind classes for each heading level + const headingClasses = [ + 'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1 + 'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2 + 'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3 + 'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4 + 'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5 + 'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6 + ]; + + // Process ATX-style headings (# Heading) + let processedContent = content.replace(HEADING_REGEX, (_, level, text) => { + const headingLevel = Math.min(level.length, 6); + const classes = headingClasses[headingLevel - 1]; + return `${text.trim()}`; + }); + + // 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()}`; + }); + + return processedContent; +} + +/** + * Process tables + */ +function processTables(content: string): string { + try { + if (!content) return ''; + + return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => { + try { + // Split into rows and clean up + const rows = match.split('\n').filter(row => row.trim()); + if (rows.length < 1) return match; + + // Helper to process a row into cells + const processCells = (row: string): string[] => { + return row + .split('|') + .slice(1, -1) // Remove empty cells from start/end + .map(cell => cell.trim()); + }; + + // Check if second row is a delimiter row (only hyphens) + const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); + + // Extract header and body rows + let headerCells: string[] = []; + let bodyRows: string[] = []; + + if (hasHeader) { + // If we have a header, first row is header, skip delimiter, rest is body + headerCells = processCells(rows[0]); + bodyRows = rows.slice(2); + } else { + // No header, all rows are body + bodyRows = rows; + } + + // Build table HTML + let html = '
\n'; + html += '\n'; + + // Add header if exists + if (hasHeader) { + html += '\n\n'; + headerCells.forEach(cell => { + html += `\n`; + }); + html += '\n\n'; + } + + // Add body + html += '\n'; + bodyRows.forEach(row => { + const cells = processCells(row); + html += '\n'; + cells.forEach(cell => { + html += `\n`; + }); + html += '\n'; + }); + + html += '\n
${cell}
${cell}
\n
'; + return html; + } catch (e: unknown) { + console.error('Error processing table row:', e); + return match; + } + }); + } catch (e: unknown) { + console.error('Error in processTables:', e); + return content; + } +} + +/** + * Process horizontal rules + */ +function processHorizontalRules(content: string): string { + return content.replace(HORIZONTAL_RULE_REGEX, + '
' + ); +} + +/** + * Process footnotes + */ +function processFootnotes(content: string): string { + try { + if (!content) return ''; + + // Collect all footnote definitions (but do not remove them from the text yet) + const footnotes = new Map(); + content.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => { + footnotes.set(id, text.trim()); + return match; + }); + + // Remove all footnote definition lines from the main content + let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, ''); + + // Track all references to each footnote + const referenceOrder: { id: string, refNum: number, label: string }[] = []; + const referenceMap = new Map(); // 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}]`; + }); + + // 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'; + // 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) || ''; + // List of backrefs for this footnote + const refs = referenceMap.get(id) || []; + 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}` : ''; + processedContent += `
  1. ${text} ${backrefs}${labelSuffix}
  2. \n`; + } + processedContent += '
'; + } + + return processedContent; + } catch (error) { + console.error('Error processing footnotes:', error); + return content; + } +} + +/** + * Process blockquotes + */ +function processBlockquotes(content: string): string { + // Match blockquotes that might span multiple lines + const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm; + + return content.replace(blockquoteRegex, (match) => { + // Remove the '>' prefix from each line and preserve line breaks + const text = match + .split('\n') + .map(line => line.replace(/^>[ \t]?/, '')) + .join('\n') + .trim(); + + return `
${text}
`; + }); +} + +/** + * 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'); + const processedLines: string[] = []; + const blocks = new Map(); + let inCodeBlock = false; + let currentCode: string[] = []; + let currentLanguage = ''; + let blockCount = 0; + let lastWasCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const codeBlockStart = line.match(CODE_BLOCK_REGEX); + + if (codeBlockStart) { + if (!inCodeBlock) { + // Starting a new code block + inCodeBlock = true; + currentLanguage = codeBlockStart[1]; + currentCode = []; + lastWasCodeBlock = true; + } else { + // Ending current code block + blockCount++; + const id = `CODE_BLOCK_${blockCount}`; + const code = currentCode.join('\n'); + + // Try to format JSON if specified + let formattedCode = code; + if (currentLanguage.toLowerCase() === 'json') { + try { + formattedCode = JSON.stringify(JSON.parse(code), null, 2); + } catch (e: unknown) { + formattedCode = code; + } + } + + blocks.set(id, JSON.stringify({ + code: formattedCode, + language: currentLanguage, + raw: true + })); + + processedLines.push(''); // Add spacing before code block + processedLines.push(id); + processedLines.push(''); // Add spacing after code block + inCodeBlock = false; + currentCode = []; + currentLanguage = ''; + } + } else if (inCodeBlock) { + currentCode.push(line); + } else { + if (lastWasCodeBlock && line.trim()) { + processedLines.push(''); + lastWasCodeBlock = false; + } + processedLines.push(line); + } + } + + // Handle unclosed code block + if (inCodeBlock && currentCode.length > 0) { + blockCount++; + const id = `CODE_BLOCK_${blockCount}`; + const code = currentCode.join('\n'); + + // Try to format JSON if specified + let formattedCode = code; + if (currentLanguage.toLowerCase() === 'json') { + try { + formattedCode = JSON.stringify(JSON.parse(code), null, 2); + } catch (e: unknown) { + formattedCode = code; + } + } + + blocks.set(id, JSON.stringify({ + code: formattedCode, + language: currentLanguage, + raw: true + })); + processedLines.push(''); + processedLines.push(id); + processedLines.push(''); + } + + return { + text: processedLines.join('\n'), + blocks + }; +} + +/** + * Restore code blocks with proper formatting + */ +function restoreCodeBlocks(text: string, blocks: Map): string { + let result = text; + + for (const [id, blockData] of blocks) { + try { + const { code, language } = JSON.parse(blockData); + + let html; + if (language && hljs.getLanguage(language)) { + try { + const highlighted = hljs.highlight(code, { + language, + ignoreIllegals: true + }).value; + html = `
${highlighted}
`; + } catch (e: unknown) { + console.warn('Failed to highlight code block:', e); + html = `
${code}
`; + } + } else { + html = `
${code}
`; + } + + result = result.replace(id, html); + } catch (e: unknown) { + console.error('Error restoring code block:', e); + result = result.replace(id, '
Error processing code block
'); + } + } + + return result; +} + +/** + * Parse markup text with advanced formatting + */ +export async function parseAdvancedmarkup(text: string): Promise { + if (!text) return ''; + + try { + // Step 1: Extract and save code blocks first + const { text: withoutCode, blocks } = processCodeBlocks(text); + let processedText = withoutCode; + + // Step 2: Process block-level elements + processedText = processTables(processedText); + processedText = processBlockquotes(processedText); + processedText = processHeadings(processedText); + processedText = processHorizontalRules(processedText); + + // Process inline elements + processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { + const escapedCode = code + .trim() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return `${escapedCode}`; + }); + + // Process footnotes (only references, not definitions) + processedText = processFootnotes(processedText); + + // Process basic markup (which will also handle Nostr identifiers) + processedText = await parseBasicmarkup(processedText); + + // Step 3: Restore code blocks + processedText = restoreCodeBlocks(processedText, blocks); + + return processedText; + } catch (e: unknown) { + console.error('Error in parseAdvancedmarkup:', e); + return `
Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
`; + } +} \ No newline at end of file diff --git a/src/lib/utils/markup/basicMarkupParser.ts b/src/lib/utils/markup/basicMarkupParser.ts new file mode 100644 index 0000000..cbd843b --- /dev/null +++ b/src/lib/utils/markup/basicMarkupParser.ts @@ -0,0 +1,388 @@ +import { processNostrIdentifiers } from '../nostrUtils'; +import * as emoji from 'node-emoji'; +import { nip19 } from 'nostr-tools'; + +/* Regex constants for basic markup parsing */ + +// Text formatting +const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g; +const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g; +const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g; +const HASHTAG_REGEX = /(?[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; + +// Links and media +const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; +const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; +const WSS_URL = /wss:\/\/[^\s<>"]+/g; +const DIRECT_LINK = /(?"]+)(?!["'])/g; + +// Media URL patterns +const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; +const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; +const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; +const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i; + +// Add this helper function near the top: +function replaceAlexandriaNostrLinks(text: string): string { + // Regex for Alexandria/localhost URLs + const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; + // Regex for bech32 Nostr identifiers + const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; + // Regex for 64-char hex + const hexPattern = /\b[a-fA-F0-9]{64}\b/; + + // 1. Alexandria/localhost markup links + text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => { + if (alexandriaPattern.test(url)) { + if (/[?&]d=/.test(url)) return match; + const hexMatch = url.match(hexPattern); + if (hexMatch) { + try { + const nevent = nip19.neventEncode({ id: hexMatch[0] }); + return `nostr:${nevent}`; + } catch { + return match; + } + } + const bech32Match = url.match(bech32Pattern); + if (bech32Match) { + return `nostr:${bech32Match[0]}`; + } + } + return match; + }); + + // 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers + text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { + if (alexandriaPattern.test(url)) { + if (/[?&]d=/.test(url)) return url; + const hexMatch = url.match(hexPattern); + if (hexMatch) { + try { + const nevent = nip19.neventEncode({ id: hexMatch[0] }); + return `nostr:${nevent}`; + } catch { + return url; + } + } + const bech32Match = url.match(bech32Pattern); + if (bech32Match) { + return `nostr:${bech32Match[0]}`; + } + } + // For non-Alexandria/localhost URLs, append (View here: nostr:) if a Nostr identifier is present + const hexMatch = url.match(hexPattern); + if (hexMatch) { + try { + const nevent = nip19.neventEncode({ id: hexMatch[0] }); + return `${url} (View here: nostr:${nevent})`; + } catch { + return url; + } + } + const bech32Match = url.match(bech32Pattern); + if (bech32Match) { + return `${url} (View here: nostr:${bech32Match[0]})`; + } + return url; + }); + + return text; +} + +// Utility to strip tracking parameters from URLs +function stripTrackingParams(url: string): string { + // List of tracking params to remove + const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i]; + try { + // Absolute URL + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { + const parsed = new URL(url); + trackingParams.forEach(pattern => { + for (const key of Array.from(parsed.searchParams.keys())) { + if (pattern.test(key)) { + parsed.searchParams.delete(key); + } + } + }); + const queryString = parsed.searchParams.toString(); + return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || ''); + } else { + // Relative URL: parse query string manually + const [path, queryAndHash = ''] = url.split('?'); + const [query = '', hash = ''] = queryAndHash.split('#'); + if (!query) return url; + const params = query.split('&').filter(Boolean); + const filtered = params.filter(param => { + const [key] = param.split('='); + return !trackingParams.some(pattern => pattern.test(key)); + }); + const queryString = filtered.length ? '?' + filtered.join('&') : ''; + const hashString = hash ? '#' + hash : ''; + return path + queryString + hashString; + } + } catch { + return url; + } +} + +function normalizeDTag(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}]/gu, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function replaceWikilinks(text: string): string { + // [[target page]] or [[target page|display text]] + return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { + const normalized = normalizeDTag(target.trim()); + const display = (label || target).trim(); + const url = `./publication?d=${normalized}`; + // Output as a clickable 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 = ''; + let i = start; + html += `<${type} class="${type === 'ol' ? 'list-decimal' : 'list-disc'} ml-6 mb-2">`; + while (i < lines.length) { + const line = lines[i]; + const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); + if (!match) break; + const lineIndent = match[1].replace(/\t/g, ' ').length; + const isOrdered = /\d+\./.test(match[2]); + const itemType = isOrdered ? 'ol' : 'ul'; + if (lineIndent > indent) { + // Nested list + const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); + html = html.replace(/<\/li>$/, '') + nestedHtml + ''; + i = consumed; + continue; + } + if (lineIndent < indent || itemType !== type) { + break; + } + html += `
  • ${match[3]}`; + // Check for next line being a nested list + if (i + 1 < lines.length) { + const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); + if (nextMatch) { + const nextIndent = nextMatch[1].replace(/\t/g, ' ').length; + const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul'; + if (nextIndent > lineIndent) { + const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType); + html += nestedHtml; + i = consumed - 1; + } + } + } + html += '
  • '; + i++; + } + html += ``; + return [html, i]; + } + if (!lines.length) return ''; + const firstLine = lines[0]; + const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); + const indent = match ? match[1].replace(/\t/g, ' ').length : 0; + const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul'); + const [html] = parseList(0, indent, type); + return html; +} + +function processBasicFormatting(content: string): string { + if (!content) return ''; + + let processedText = content; + + try { + // Sanitize Alexandria Nostr links before further processing + processedText = replaceAlexandriaNostrLinks(processedText); + + // Process markup images first + processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => { + url = stripTrackingParams(url); + if (YOUTUBE_URL_REGEX.test(url)) { + const videoId = extractYouTubeVideoId(url); + if (videoId) { + return ``; + } + } + if (VIDEO_URL_REGEX.test(url)) { + return ``; + } + if (AUDIO_URL_REGEX.test(url)) { + return ``; + } + // Only render if the url ends with a direct image extension + if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { + return `${alt}`; + } + // Otherwise, render as a clickable link + return `${alt || url}`; + }); + + // Process markup links + processedText = processedText.replace(MARKUP_LINK, (match, text, url) => + `${text}` + ); + + // Process WebSocket URLs + processedText = processedText.replace(WSS_URL, match => { + // Remove 'wss://' from the start and any trailing slashes + const cleanUrl = match.slice(6).replace(/\/+$/, ''); + return `${match}`; + }); + + // Process direct media URLs and auto-link all URLs + processedText = processedText.replace(DIRECT_LINK, match => { + const clean = stripTrackingParams(match); + if (YOUTUBE_URL_REGEX.test(clean)) { + const videoId = extractYouTubeVideoId(clean); + if (videoId) { + return ``; + } + } + if (VIDEO_URL_REGEX.test(clean)) { + return ``; + } + if (AUDIO_URL_REGEX.test(clean)) { + return ``; + } + // Only render if the url ends with a direct image extension + 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, ''); + return `${text}`; + }); + processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { + const text = doubleText || singleText; + return `${text}`; + }); + + // Process hashtags + processedText = processedText.replace(HASHTAG_REGEX, '#$1'); + + // --- Improved List Grouping and Parsing --- + const lines = processedText.split('\n'); + let output = ''; + let buffer: string[] = []; + let inList = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { + buffer.push(line); + inList = true; + } else { + if (inList) { + const firstLine = buffer[0]; + const isOrdered = /^\s*\d+\.\s+/.test(firstLine); + output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); + buffer = []; + inList = false; + } + output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n'; + } + } + if (buffer.length) { + const firstLine = buffer[0]; + const isOrdered = /^\s*\d+\.\s+/.test(firstLine); + output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); + } + processedText = output; + // --- End Improved List Grouping and Parsing --- + + } catch (e: unknown) { + console.error('Error in processBasicFormatting:', e); + } + + return processedText; +} + +// Helper function to extract YouTube video ID +function extractYouTubeVideoId(url: string): string | null { + const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/); + return match ? match[1] : null; +} + +function processBlockquotes(content: string): string { + try { + if (!content) return ''; + + return content.replace(BLOCKQUOTE_REGEX, match => { + const lines = match.split('\n').map(line => { + return line.replace(/^[ \t]*>[ \t]?/, '').trim(); + }); + + return `
    ${ + lines.join('\n') + }
    `; + }); + } catch (e: unknown) { + console.error('Error in processBlockquotes:', e); + return content; + } +} + +function processEmojiShortcuts(content: string): string { + try { + return emoji.emojify(content, { fallback: (name: string) => { + const emojiChar = emoji.get(name); + return emojiChar || `:${name}:`; + }}); + } catch (e: unknown) { + console.error('Error in processEmojiShortcuts:', e); + return content; + } +} + +export async function parseBasicmarkup(text: string): Promise { + if (!text) return ''; + + try { + // Process basic text formatting first + let processedText = processBasicFormatting(text); + + // Process emoji shortcuts + processedText = processEmojiShortcuts(processedText); + + // Process blockquotes + processedText = processBlockquotes(processedText); + + // Process paragraphs - split by double newlines and wrap in p tags + processedText = processedText + .split(/\n\n+/) + .map(para => para.trim()) + .filter(para => para.length > 0) + .map(para => `

    ${para}

    `) + .join('\n'); + + // Process Nostr identifiers last + processedText = await processNostrIdentifiers(processedText); + + // Replace wikilinks + processedText = replaceWikilinks(processedText); + + return processedText; + } catch (e: unknown) { + console.error('Error in parseBasicmarkup:', e); + return `
    Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
    `; + } +} \ No newline at end of file diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts new file mode 100644 index 0000000..3b3bd6e --- /dev/null +++ b/src/lib/utils/mime.ts @@ -0,0 +1,96 @@ +/** + * Determine the type of Nostr event based on its kind number + * Following NIP specification for kind ranges: + * - Replaceable: 0, 3, 10000-19999 (only latest stored) + * - Ephemeral: 20000-29999 (not stored) + * - Addressable: 30000-39999 (latest per d-tag stored) + * - Regular: all other kinds (stored by relays) + */ +function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { + // Check special ranges first + if (kind >= 30000 && kind < 40000) { + return 'addressable'; + } + + if (kind >= 20000 && kind < 30000) { + return 'ephemeral'; + } + + if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { + return 'replaceable'; + } + + // Everything else is regular + return 'regular'; +} + +/** + * Get MIME tags for a Nostr event based on its kind number + * Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]] + * Following NKBIP-06 and NIP-94 specifications + */ +export function getMimeTags(kind: number): [string, string][] { + // Default tags for unknown kinds + let mTag: [string, string] = ["m", "text/plain"]; + let MTag: [string, string] = ["M", "note/generic/nonreplaceable"]; + + // Determine replaceability based on event type + const eventType = getEventType(kind); + const replaceability = (eventType === 'replaceable' || eventType === 'addressable') + ? "replaceable" + : "nonreplaceable"; + + switch (kind) { + // Short text note + case 1: + mTag = ["m", "text/plain"]; + MTag = ["M", `note/microblog/${replaceability}`]; + break; + + // Generic reply + case 1111: + mTag = ["m", "text/plain"]; + MTag = ["M", `note/comment/${replaceability}`]; + break; + + // Issue + case 1621: + mTag = ["m", "text/markup"]; + MTag = ["M", `git/issue/${replaceability}`]; + break; + + // Issue comment + case 1622: + mTag = ["m", "text/markup"]; + MTag = ["M", `git/comment/${replaceability}`]; + break; + + // Book metadata + case 30040: + mTag = ["m", "application/json"]; + MTag = ["M", `meta-data/index/${replaceability}`]; + break; + + // Book content + case 30041: + mTag = ["m", "text/asciidoc"]; + MTag = ["M", `article/publication-content/${replaceability}`]; + break; + + // Wiki page + case 30818: + mTag = ["m", "text/asciidoc"]; + MTag = ["M", `article/wiki/${replaceability}`]; + break; + + // Long-form note + case 30023: + mTag = ["m", "text/markup"]; + MTag = ["M", `article/long-form/${replaceability}`]; + break; + + // Add more cases as needed... + } + + return [mTag, MTag]; +} \ No newline at end of file diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts new file mode 100644 index 0000000..f702d24 --- /dev/null +++ b/src/lib/utils/nostrUtils.ts @@ -0,0 +1,182 @@ +import { get } from 'svelte/store'; +import { nip19 } from 'nostr-tools'; +import { ndkInstance } from '$lib/ndk'; +import { npubCache } from './npubCache'; + +// Regular expressions for Nostr identifiers - match the entire identifier including any prefix +export const NOSTR_PROFILE_REGEX = /(?': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, char => htmlEscapes[char]); +} + +/** + * Get user metadata for a nostr identifier (npub or nprofile) + */ +export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { + // Remove nostr: prefix if present + const cleanId = identifier.replace(/^nostr:/, ''); + + if (npubCache.has(cleanId)) { + return npubCache.get(cleanId)!; + } + + const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; + + try { + const ndk = get(ndkInstance); + if (!ndk) { + npubCache.set(cleanId, fallback); + return fallback; + } + + const decoded = nip19.decode(cleanId); + if (!decoded) { + npubCache.set(cleanId, fallback); + return fallback; + } + + // Handle different identifier types + let pubkey: string; + if (decoded.type === 'npub') { + pubkey = decoded.data; + } else if (decoded.type === 'nprofile') { + pubkey = decoded.data.pubkey; + } else { + npubCache.set(cleanId, fallback); + return fallback; + } + + const user = ndk.getUser({ pubkey: pubkey }); + if (!user) { + npubCache.set(cleanId, fallback); + return fallback; + } + + try { + const profile = await user.fetchProfile(); + if (!profile) { + npubCache.set(cleanId, fallback); + return fallback; + } + + const metadata = { + name: profile.name || fallback.name, + displayName: profile.displayName + }; + + npubCache.set(cleanId, metadata); + return metadata; + } catch (e) { + npubCache.set(cleanId, fallback); + return fallback; + } + } catch (e) { + npubCache.set(cleanId, fallback); + return fallback; + } +} + +/** + * Create a profile link element + */ +function createProfileLink(identifier: string, displayText: string | undefined): string { + const cleanId = identifier.replace(/^nostr:/, ''); + const escapedId = escapeHtml(cleanId); + const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; + const escapedText = escapeHtml(displayText || defaultText); + + return `@${escapedText}`; +} + +/** + * Create a note link element + */ +function createNoteLink(identifier: string): string { + const cleanId = identifier.replace(/^nostr:/, ''); + const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; + const escapedId = escapeHtml(cleanId); + const escapedText = escapeHtml(shortId); + + return `${escapedText}`; +} + +/** + * Process Nostr identifiers in text + */ +export async function processNostrIdentifiers(content: string): Promise { + let processedContent = content; + + // Helper to check if a match is part of a URL + function isPartOfUrl(text: string, index: number): boolean { + // Look for http(s):// or www. before the match + const before = text.slice(Math.max(0, index - 12), index); + return /https?:\/\/$|www\.$/i.test(before); + } + + // Process profiles (npub and nprofile) + const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX)); + for (const match of profileMatches) { + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + if (isPartOfUrl(content, matchIndex)) { + continue; // skip if part of a URL + } + let identifier = fullMatch; + if (!identifier.startsWith('nostr:')) { + identifier = 'nostr:' + identifier; + } + const metadata = await getUserMetadata(identifier); + const displayText = metadata.displayName || metadata.name; + const link = createProfileLink(identifier, displayText); + processedContent = processedContent.replace(fullMatch, link); + } + + // Process notes (nevent, note, naddr) + const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX)); + for (const match of noteMatches) { + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + if (isPartOfUrl(processedContent, matchIndex)) { + continue; // skip if part of a URL + } + let identifier = fullMatch; + if (!identifier.startsWith('nostr:')) { + identifier = 'nostr:' + identifier; + } + const link = createNoteLink(identifier); + processedContent = processedContent.replace(fullMatch, link); + } + + return processedContent; +} + +export async function getNpubFromNip05(nip05: string): Promise { + try { + const ndk = get(ndkInstance); + if (!ndk) { + console.error('NDK not initialized'); + return null; + } + + const user = await ndk.getUser({ nip05 }); + if (!user || !user.npub) { + return null; + } + return user.npub; + } catch (error) { + console.error('Error getting npub from nip05:', error); + return null; + } +} \ No newline at end of file diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts new file mode 100644 index 0000000..085c148 --- /dev/null +++ b/src/lib/utils/npubCache.ts @@ -0,0 +1,49 @@ +export type NpubMetadata = { name?: string; displayName?: string }; + +class NpubCache { + private cache: Record = {}; + + get(key: string): NpubMetadata | undefined { + return this.cache[key]; + } + + set(key: string, value: NpubMetadata): void { + this.cache[key] = value; + } + + has(key: string): boolean { + return key in this.cache; + } + + delete(key: string): boolean { + if (key in this.cache) { + delete this.cache[key]; + return true; + } + return false; + } + + deleteMany(keys: string[]): number { + let deleted = 0; + for (const key of keys) { + if (this.delete(key)) { + deleted++; + } + } + return deleted; + } + + clear(): void { + this.cache = {}; + } + + size(): number { + return Object.keys(this.cache).length; + } + + getAll(): Record { + return { ...this.cache }; + } +} + +export const npubCache = new NpubCache(); \ No newline at end of file diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index eb0abfc..468dd78 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -26,7 +26,7 @@ href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1" >curated publications (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form - articles (Markdown). It is produced by the GitCitadel project team. diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte new file mode 100644 index 0000000..dd81515 --- /dev/null +++ b/src/routes/contact/+page.svelte @@ -0,0 +1,518 @@ + + +
    +
    + Contact GitCitadel + +

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

    + +

    + You can contact us on Nostr GitCitadel or you can view submitted issues on the Alexandria repo page. +

    + + 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 activeTab === 'write'} +
    +