Browse Source

Merges pull request #28

Issue#215 Contact page
master
silberengel 10 months ago
parent
commit
07d17de909
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 5
      .vscode/settings.json
  2. 8
      README.md
  3. 76
      package-lock.json
  4. 2
      package.json
  5. 183
      src/app.css
  6. 13
      src/lib/components/Login.svelte
  7. 77
      src/lib/components/LoginModal.svelte
  8. 1
      src/lib/components/Navigation.svelte
  9. 2
      src/lib/consts.ts
  10. 55
      src/lib/utils/markup/MarkupInfo.md
  11. 389
      src/lib/utils/markup/advancedMarkupParser.ts
  12. 388
      src/lib/utils/markup/basicMarkupParser.ts
  13. 96
      src/lib/utils/mime.ts
  14. 182
      src/lib/utils/nostrUtils.ts
  15. 49
      src/lib/utils/npubCache.ts
  16. 2
      src/routes/about/+page.svelte
  17. 518
      src/routes/contact/+page.svelte
  18. 2
      tailwind.config.cjs
  19. 99
      tests/integration/markupIntegration.test.ts
  20. 244
      tests/integration/markupTestfile.md
  21. 118
      tests/unit/advancedMarkupParser.test.ts
  22. 88
      tests/unit/basicMarkupParser.test.ts
  23. 3
      tests/unit/example.js
  24. 6
      tests/unit/example.unit-test.js
  25. 2
      vite.config.ts

5
.vscode/settings.json vendored

@ -1,3 +1,6 @@ @@ -1,3 +1,6 @@
{
"editor.tabSize": 2
"editor.tabSize": 2,
"files.associations": {
"*.css": "postcss"
}
}

8
README.md

@ -7,7 +7,7 @@ For a thorough introduction, please refer to our [project documention](https://n @@ -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 @@ -114,4 +114,8 @@ 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).

76
package-lock.json generated

@ -16,6 +16,8 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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",

2
package.json

@ -22,6 +22,8 @@ @@ -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": {

183
src/app.css

@ -2,40 +2,38 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}
}

13
src/lib/components/Login.svelte

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
let npub = $state<string | undefined >(undefined);
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
$effect(() => {
if ($ndkSignedIn) {
@ -26,6 +27,9 @@ @@ -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 @@ @@ -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 @@ @@ -52,12 +56,17 @@
placement='bottom'
triggeredBy='#avatar'
>
<div class='w-full flex space-x-2'>
<div class='w-full flex flex-col space-y-2'>
<Button
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
{#if signInFailed}
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}
</div>
{/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}

77
src/lib/components/LoginModal.svelte

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from '$lib/ndk';
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{
show?: boolean;
onClose?: () => void;
onLoginSuccess?: () => void;
}>();
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>('');
$effect(() => {
if ($ndkSignedIn && show) {
onLoginSuccess();
onClose();
}
});
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.');
}
} catch (e: unknown) {
console.error(e);
signInFailed = true;
errorMessage = (e as Error)?.message ?? 'Failed to sign in. Please try again.';
}
}
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50">
<div class="relative w-auto my-6 mx-auto max-w-3xl">
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none">
<!-- Header -->
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t">
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onclick={onClose}
>
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400 mb-6">
You need to be logged in to submit an issue. Your form data will be preserved.
</p>
<div class="flex flex-col space-y-4">
<div class="flex justify-center">
<Button
color="primary"
onclick={handleSignInClick}
>
Sign in with Extension
</Button>
</div>
{#if signInFailed}
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded">
{errorMessage}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}

1
src/lib/components/Navigation.svelte

@ -21,6 +21,7 @@ @@ -21,6 +21,7 @@
<NavLi href='/new/edit'>Publish</NavLi>
<NavLi href='/visualize'>Visualize</NavLi>
<NavLi href='/about'>About</NavLi>
<NavLi href='/contact'>Contact</NavLi>
<NavLi>
<DarkMode btnClass='btn-leather p-0'/>
</NavLi>

2
src/lib/consts.ts

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

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

@ -0,0 +1,55 @@ @@ -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.

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

@ -0,0 +1,389 @@ @@ -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 `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
});
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 = '<div class="overflow-x-auto my-4">\n';
html += '<table class="min-w-full border-collapse">\n';
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
});
html += '</tr>\n</thead>\n';
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
});
html += '</tr>\n';
});
html += '</tbody>\n</table>\n</div>';
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,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
);
}
/**
* 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<string, string>();
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<string, number[]>(); // id -> [refNum, ...]
let globalRefNum = 1;
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
return match;
}
const refNum = globalRefNum++;
if (!referenceMap.has(id)) referenceMap.set(id, []);
referenceMap.get(id)!.push(refNum);
referenceOrder.push({ id, refNum, label: id });
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
});
// Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference
const seen = new Set<string>();
for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue;
seen.add(id);
const text = footnotes.get(id) || '';
// List of backrefs for this footnote
const refs = referenceMap.get(id) || [];
const backrefs = refs.map((num, i) =>
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>`
).join(' ');
// If label is not a number, show it after all backrefs
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : '';
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`;
}
processedContent += '</ol>';
}
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 `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
});
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map<string, string>();
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, string>): 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 = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e: unknown) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
}
result = result.replace(id, html);
} catch (e: unknown) {
console.error('Error restoring code block:', e);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
}
}
return result;
}
/**
* Parse markup text with advanced formatting
*/
export async function parseAdvancedmarkup(text: string): Promise<string> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
// 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 `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
}
}

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

@ -0,0 +1,388 @@ @@ -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 = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
// Block elements
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \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 = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/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:<id>) 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 <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
});
}
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string {
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] {
let html = '';
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 + '</li>';
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${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 += '</li>';
i++;
}
html += `</${type}>`;
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 `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(url)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || 'Video'}</video>`;
}
if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) {
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`;
});
// Process markup links
processedText = processedText.replace(MARKUP_LINK, (match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`
);
// 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 `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, match => {
const clean = stripTrackingParams(match);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) {
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
// Otherwise, render as a clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
});
// Process text formatting
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>');
processedText = processedText.replace(ITALIC_REGEX, match => {
const text = match.replace(/^_+|_+$/g, '');
return `<em>${text}</em>`;
});
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
});
// Process hashtags
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-primary-600 dark:text-primary-500">#$1</span>');
// --- Improved List Grouping and Parsing ---
const lines = processedText.split('\n');
let output = '';
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 `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join('\n')
}</blockquote>`;
});
} catch (e: unknown) {
console.error('Error in processBlockquotes:', e);
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<string> {
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 => `<p class="my-4">${para}</p>`)
.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 `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`;
}
}

96
src/lib/utils/mime.ts

@ -0,0 +1,96 @@ @@ -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];
}

182
src/lib/utils/nostrUtils.ts

@ -0,0 +1,182 @@ @@ -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 = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
/**
* HTML escape a string
*/
function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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 `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`;
}
/**
* 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 `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
}
/**
* Process Nostr identifiers in text
*/
export async function processNostrIdentifiers(content: string): Promise<string> {
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<string | null> {
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;
}
}

49
src/lib/utils/npubCache.ts

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
export type NpubMetadata = { name?: string; displayName?: string };
class NpubCache {
private cache: Record<string, NpubMetadata> = {};
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<string, NpubMetadata> {
return { ...this.cache };
}
}
export const npubCache = new NpubCache();

2
src/routes/about/+page.svelte

@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (Markdown). It is produced by the <A
articles (markup). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1"
>GitCitadel project team</A
>.

518
src/routes/contact/+page.svelte

@ -0,0 +1,518 @@ @@ -0,0 +1,518 @@
<script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte';
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser';
import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
// Function to close the success message
function closeSuccessMessage() {
submissionSuccess = false;
submittedEvent = null;
}
function clearForm() {
subject = '';
content = '';
submissionError = '';
isExpanded = false;
activeTab = 'write';
}
let subject = $state('');
let content = $state('');
let isSubmitting = $state(false);
let showLoginModal = $state(false);
let submissionSuccess = $state(false);
let submissionError = $state('');
let submittedEvent = $state<NDKEvent | null>(null);
let issueLink = $state('');
let successfulRelays = $state<string[]>([]);
let isExpanded = $state(false);
let activeTab = $state('write');
let showConfirmDialog = $state(false);
// Store form data when user needs to login
let savedFormData = {
subject: '',
content: ''
};
// Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
// Hard-coded relays to ensure we have working relays
const allRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
...standardRelays
];
// Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
const repoId = 'Alexandria';
// Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, '');
}
function toggleSize() {
isExpanded = !isExpanded;
}
async function handleSubmit(e: Event) {
// Prevent form submission
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Check if user is logged in
if (!$ndkSignedIn) {
// Save form data
savedFormData = {
subject,
content
};
// Show login modal
showLoginModal = true;
return;
}
// Show confirmation dialog
showConfirmDialog = true;
}
async function confirmSubmit() {
showConfirmDialog = false;
await submitIssue();
}
function cancelSubmit() {
showConfirmDialog = false;
}
/**
* Publish event to relays with retry logic
*/
async function publishToRelays(
event: NDKEvent,
ndk: NDK,
relays: Set<string>,
maxRetries: number = 3,
timeout: number = 10000
): Promise<string[]> {
const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => {
return new Promise<void>(resolve => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl);
resolve();
}
});
} else {
resolve(); // Resolve if relay not available
}
});
});
// Try publishing with retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout);
});
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
if (successfulRelays.length > 0) {
break; // Exit retry loop if we have successful publishes
}
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
} catch (error) {
if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts');
}
}
}
return successfulRelays;
}
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionSuccess = false;
try {
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
}
if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.');
}
// Create and prepare the event
const event = await createIssueEvent(ndk);
// Collect all unique relays
const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
]);
try {
// Publish to relays with retry logic
successfulRelays = await publishToRelays(event, ndk, uniqueRelays);
// Store the submitted event and create issue link
submittedEvent = event;
// Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Clear form and show success message
clearForm();
submissionSuccess = true;
} catch (error) {
throw new Error('Failed to publish event');
}
} catch (error: any) {
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
} finally {
isSubmitting = false;
}
}
/**
* Create and sign a new issue event
*/
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]);
// Add MIME tags
const mimeTags = getMimeTags(1621);
event.tags.push(...mimeTags);
// Set content
event.content = content;
// Sign the event
try {
await event.sign();
} catch (error) {
throw new Error('Failed to sign event');
}
return event;
}
// Handle login completion
$effect(() => {
if ($ndkSignedIn && showLoginModal) {
showLoginModal = false;
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content;
// Submit the issue
submitIssue();
}
});
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'>
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading>
<P class="mb-3">
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
</P>
<P class="mb-3">
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
</P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>
<P class="mb-3">
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P>
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off">
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full">
<div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist">
<li class="mr-2" role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'write'}
role="tab"
>
Write
</button>
</li>
<li role="presentation">
<button
type="button"
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}"
on:click={() => activeTab = 'preview'}
role="tab"
>
Preview
</button>
</li>
</ul>
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'}
<div class="absolute inset-0 overflow-hidden">
<Textarea
id="content"
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-400 dark:focus:border-primary-500"
bind:value={content}
required
placeholder="Describe your issue in detail...
The following markup is supported:
# Headers (1-6 levels)
Header 1
======
*Bold* or **bold**
_Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes
Lists, including nested:
* Bullets/unordered lists
1. Numbered/ordered lists
[Links](url)
![Images](url)
`Inline code`
```language
Code blocks with syntax highlighting for over 100 languages
```
| Tables | With or without headers |
|--------|------|
| Multiple | Rows |
Footnotes[^1] and [^1]: footnote content
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix."
/>
</div>
{:else}
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content">
{#key content}
{#await parseAdvancedmarkup(content)}
<p>Loading preview...</p>
{:then html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div>
{/if}
</div>
</div>
<Button
type="button"
size="xs"
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100"
color="light"
on:click={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
</Button>
</div>
</div>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" on:click={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
{#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
</div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600">
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedmarkup(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">Error rendering markup: {error.message}</p>
{/await}
</div>
</div>
</div>
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all">
{issueLink}
</A>
</div>
</div>
<!-- Display successful relays -->
<div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span>
<ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if submissionError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{submissionError}
</div>
{/if}
</form>
</main>
</div>
<!-- Confirmation Dialog -->
<Modal
bind:open={showConfirmDialog}
size="sm"
autoclose={false}
class="w-full"
>
<div class="text-center">
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Would you like to submit the issue?
</h3>
<div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}>
Cancel
</Button>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
</div>
</div>
</Modal>
<!-- Login Modal -->
<LoginModal
show={showLoginModal}
onClose={() => showLoginModal = false}
onLoginSuccess={() => {
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content;
// Submit the issue
submitIssue();
}}
/>

2
tailwind.config.cjs

@ -20,7 +20,7 @@ const config = { @@ -20,7 +20,7 @@ const config = {
200: '#c6a885',
300: '#b58f62',
400: '#ad8351',
500: '#9c7649',
500: '#c6a885',
600: '#795c39',
700: '#574229',
800: '#342718',

99
tests/integration/markupIntegration.test.ts

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

244
tests/integration/markupTestfile.md

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
This is a test
============
### Disclaimer
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
# H1
## H2
### H3
#### H4
##### H5
###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser.
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]]
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
> This is important information
> This is multiple
> 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.
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
This is an unordered list with nesting:
* 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
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
Here is a horizontal rule:
---
Try embedded a nostr note with nevent:
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske
Here a note with no prefix
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz
Here with a naddr:
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz
Here's a nonsense one:
nevent123
And a nonsense one with a prefix:
nostr:naddrwhatever
And some Nostr addresses that should be preserved and have a internal link appended:
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk
URL with a tracking parameter, no markup:
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale
Image without markup:
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags:
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or
```
in a code block
```
You can even use a multi-line code block, with a json tag.
```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"
}
```
C or C++:
```cpp
bool getBit(int num, int i) {
return ((num & (1<<i)) != 0);
}
```
Asciidoc:
```adoc
= Header 1
preamble goes here
== Header 2
some more text
```
Gherkin:
```gherkin
Feature: Account Holder withdraws cash
Scenario: Account has sufficient funds
Given The account balance is $100
And the card is valid
And the machine contains enough money
When the Account Holder requests $20
Then the ATM should dispense $20
And the account balance should be $80
And the card should be returned
```
Go:
```go
package main
import (
"fmt"
"bufio"
"os"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Enter text: ")
scanner.Scan()
input := scanner.Text()
fmt.Println("You entered:", input)
}
```
or even markup:
```md
A H1 Header
============
Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists
look like:
* this one[^some reference text]
* that one
* the other one
Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.
> Block quotes are
> written like so.
>
> They can span multiple paragraphs,
> if you like.
```
Test out some emojis :heart: and :trophy:
#### Here is an image![^some reference text]
![Nostr logo](https://user-images.githubusercontent.com/99301796/219900773-d6d02038-e2a0-4334-9f28-c14d40ab6fe7.png)
### I went ahead and implemented tables, too.
A neat table[^some reference text]:
| Syntax | Description |
| ----------- | ----------- |
| Header | Title |
| Paragraph | Text |
A messy table (should render the same as above):
| Syntax | Description |
| --- | ----------- |
| Header | Title |
| Paragraph | Text |
Here is a table without a header row:
| Sometimes | you don't |
| need a | header |
| just | pipes |
[^1]: this is a footnote
[^some reference text]: this is a footnote that isn't a number

118
tests/unit/advancedMarkupParser.test.ts

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

88
tests/unit/basicMarkupParser.test.ts

@ -0,0 +1,88 @@ @@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser';
// Helper to strip whitespace for easier comparison
function stripWS(str: string) {
return str.replace(/\s+/g, ' ').trim();
}
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');
});
it('parses bold, italic, and strikethrough', async () => {
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~';
const output = await parseBasicmarkup(input);
expect(output).toContain('<strong>bold</strong>');
expect(output).toContain('<em>italic</em>');
expect(output).toContain('<del class="line-through">strikethrough</del>');
});
it('parses blockquotes', async () => {
const input = '> quote';
const output = await parseBasicmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
});
it('parses multi-line blockquotes', async () => {
const input = '> quote\n> quote';
const output = await parseBasicmarkup(input);
expect(output).toContain('<blockquote');
expect(output).toContain('quote');
expect(output).toContain('quote');
});
it('parses unordered lists', async () => {
const input = '* a\n* b';
const output = await parseBasicmarkup(input);
expect(output).toContain('<ul');
expect(output).toContain('a');
expect(output).toContain('b');
});
it('parses ordered lists', async () => {
const input = '1. one\n2. two';
const output = await parseBasicmarkup(input);
expect(output).toContain('<ol');
expect(output).toContain('one');
expect(output).toContain('two');
});
it('parses links and images', async () => {
const input = '[link](https://example.com) ![alt](https://img.com/x.png)';
const output = await parseBasicmarkup(input);
expect(output).toContain('<a');
expect(output).toContain('<img');
});
it('parses hashtags', async () => {
const input = '#hashtag';
const output = await parseBasicmarkup(input);
expect(output).toContain('text-primary-600');
expect(output).toContain('#hashtag');
});
it('parses nostr identifiers', async () => {
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
const output = await parseBasicmarkup(input);
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq');
});
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]]';
const output = await parseBasicmarkup(input);
expect(output).toContain('wikilink');
expect(output).toContain('display');
});
});

3
tests/unit/example.js

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
export function sum(a, b) {
return a + b
}

6
tests/unit/example.unit-test.js

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
import { expect, test } from 'vitest'
import { sum } from './example.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})

2
vite.config.ts

@ -27,7 +27,7 @@ export default defineConfig({ @@ -27,7 +27,7 @@ export default defineConfig({
}
},
test: {
include: ['./tests/unit/**/*.unit-test.js']
include: ['./tests/unit/**/*.test.ts', './tests/integration/**/*.test.ts']
},
define: {
// Expose the app version as a global variable

Loading…
Cancel
Save