Browse Source

Revert "Interim changes"

This reverts commit b35774b9a0.
master
Silberengel 10 months ago
parent
commit
ffe1098a7d
  1. 4
      .vscode/settings.json
  2. 55
      README.md
  3. 206
      package-lock.json
  4. 8
      package.json
  5. 649
      src/app.css
  6. 80
      src/lib/components/EmoticonPicker.svelte
  7. 16
      src/lib/components/EventLimitControl.svelte
  8. 11
      src/lib/components/EventRenderLevelLimit.svelte
  9. 23
      src/lib/components/Login.svelte
  10. 35
      src/lib/components/LoginModal.svelte
  11. 238
      src/lib/components/MarkdownForm.svelte
  12. 22
      src/lib/components/Navigation.svelte
  13. 10
      src/lib/components/Preview.svelte
  14. 79
      src/lib/components/Publication.svelte
  15. 4
      src/lib/components/PublicationFeed.svelte
  16. 2
      src/lib/components/PublicationHeader.svelte
  17. 4
      src/lib/components/PublicationSection.svelte
  18. 8
      src/lib/components/Toc.svelte
  19. 8
      src/lib/parser.ts
  20. 11
      src/lib/types/markdown-it-plugins.d.ts
  21. 4
      src/lib/types/svelte-heros.d.ts
  22. 378
      src/lib/utils/advancedMarkdownParser.ts
  23. 182
      src/lib/utils/basicMarkdownParser.ts
  24. 85
      src/lib/utils/emoticons.ts
  25. 391
      src/lib/utils/markdown/markdownItParser.ts
  26. 2
      src/lib/utils/markdownTestfile.md
  27. 12
      src/routes/[...catchall]/+page.svelte
  28. 123
      src/routes/about/+page.svelte
  29. 267
      src/routes/contact/+page.svelte
  30. 125
      src/routes/contact/ContactForm.svelte
  31. 38
      src/routes/new/edit/+page.svelte
  32. 19
      src/routes/visualize/+page.svelte

4
.vscode/settings.json vendored

@ -1,5 +1,3 @@ @@ -1,5 +1,3 @@
{
"editor.tabSize": 2,
"css.validate": false,
"tailwindCSS.validate": true
"editor.tabSize": 2
}

55
README.md

@ -115,58 +115,3 @@ For the Playwright end-to-end (e2e) tests: @@ -115,58 +115,3 @@ For the Playwright end-to-end (e2e) tests:
```bash
npx playwright test
```
## Markdown Support
In addition to the [standard Asciidoc](https://asciidoc.org/) support for publication and wiki events, Alexandria supports a rich set of Markdown features for content creation:
### Text Formatting
- **Bold**: `*text*` or `**text**`
- **Italic**: `_text_` or `__text__`
- **Strikethrough**: `~text~` or `~~text~~`
- **Inline Code**: `` `code` ``
- **Links**: `[text](url)`
- **Images**: `![alt](url)`
### Structure
- **Headings**:
- ATX style: `# Heading 1` through `###### Heading 6`
- Setext style:
```markdown
Heading 1
========
```
- **Lists**:
- Unordered: `* item`
- Ordered: `1. item`, `2. item`
- Nested lists supported
- **Code Blocks with language-specific highlighting**:
```markdown
```javascript
const code = 'example';
```
```
- **Blockquotes**: `> Quote text`
- **Horizontal Rules**: `---`
- **Tables**:
```markdown
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
```
### Special Features
- **Footnotes**: `[^1]` and `[^1]: definition`
- **Emojis**: `:smile:`
- **Hashtags**: `#tag`
- **Nostr Identifiers**:
- Profiles: `npub...` or `nprofile...`
- Notes: `note...`, `nevent...`, or `naddr...`
### Media Support
- **YouTube Videos**: Automatically embedded
- **Video Files**: mp4, webm, mov, avi
- **Audio Files**: mp3, wav, ogg, m4a
- **Images**: jpg, jpeg, gif, png, webp
All media URLs are automatically cleaned of tracking parameters for privacy.

206
package-lock.json generated

@ -15,25 +15,19 @@ @@ -15,25 +15,19 @@
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"d3": "^7.9.0",
"easymde": "^2.20.0",
"he": "1.2.x",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"nostr-tools": "2.10.x",
"svelte-heros": "^7.0.2"
"nostr-tools": "2.10.x"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/kit": "2.x",
"@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/markdown-it": "^14.1.2",
"@types/node": "22.x",
"autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x",
@ -71,6 +65,7 @@ @@ -71,6 +65,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@ -1531,15 +1526,6 @@ @@ -1531,15 +1526,6 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
}
},
"node_modules/@types/codemirror": {
"version": "5.60.15",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
"integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==",
"license": "MIT",
"dependencies": {
"@types/tern": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -1835,6 +1821,7 @@ @@ -1835,6 +1821,7 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
@ -1859,37 +1846,6 @@ @@ -1859,37 +1846,6 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/marked": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz",
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT"
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
@ -1907,15 +1863,6 @@ @@ -1907,15 +1863,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@vitest/expect": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz",
@ -2056,6 +2003,7 @@ @@ -2056,6 +2003,7 @@
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -2078,6 +2026,7 @@ @@ -2078,6 +2026,7 @@
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": ">=8.9.0"
@ -2182,12 +2131,15 @@ @@ -2182,12 +2131,15 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
"dev": true,
"license": "Python-2.0",
"peer": true
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -2285,6 +2237,7 @@ @@ -2285,6 +2237,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@ -2567,21 +2520,6 @@ @@ -2567,21 +2520,6 @@
"wrap-ansi": "^7.0.0"
}
},
"node_modules/codemirror": {
"version": "5.65.19",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz",
"integrity": "sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==",
"license": "MIT"
},
"node_modules/codemirror-spell-checker": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz",
"integrity": "sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ==",
"license": "MIT",
"dependencies": {
"typo-js": "*"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3196,19 +3134,6 @@ @@ -3196,19 +3134,6 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/easymde": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz",
"integrity": "sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==",
"license": "MIT",
"dependencies": {
"@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7",
"codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@ -3237,18 +3162,6 @@ @@ -3237,18 +3162,6 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3600,6 +3513,7 @@ @@ -3600,6 +3513,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esniff": {
@ -3654,6 +3568,7 @@ @@ -3654,6 +3568,7 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.3.2.tgz",
"integrity": "sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@ -4424,6 +4339,7 @@ @@ -4424,6 +4339,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@ -4637,19 +4553,11 @@ @@ -4637,19 +4553,11 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -4704,52 +4612,12 @@ @@ -4704,52 +4612,12 @@
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-emoji": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-3.0.0.tgz",
"integrity": "sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==",
"license": "MIT"
},
"node_modules/markdown-it-footnote": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-4.0.0.tgz",
"integrity": "sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==",
"license": "MIT"
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4759,12 +4627,6 @@ @@ -4759,12 +4627,6 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5688,15 +5550,6 @@ @@ -5688,15 +5550,6 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -6156,6 +6009,7 @@ @@ -6156,6 +6009,7 @@
"version": "5.14.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.14.4.tgz",
"integrity": "sha512-2iR/UHHA2Dsldo4JdXDcdqT+spueuh+uNYw1FoTKBbpnFEECVISeqSo0uubPS4AfBE0xI6u7DGHxcdq3DTDmoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
@ -6306,19 +6160,6 @@ @@ -6306,19 +6160,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/svelte-heros": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/svelte-heros/-/svelte-heros-7.0.2.tgz",
"integrity": "sha512-JsnGvthDn2EJq+1JbRqebHnUIJTLMFtPJt7IpjdHoY2Ei24DQOdQ9QQDmFZ/7oUB6/d6Wwne4G+vnp4mvvYSDg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0",
"npm": ">=7.0.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
@ -6681,18 +6522,6 @@ @@ -6681,18 +6522,6 @@
"integrity": "sha512-Jp57Qyy8wXeMkdNuZiglE6v2Cypg13eDA1chHwDG6kq51X7gk4K7P7HaDdzZKCxkegXkVHNcPD0n5aW6OZH3aA==",
"license": "MIT"
},
"node_modules/typo-js": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz",
"integrity": "sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A==",
"license": "BSD-3-Clause"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@ -7194,6 +7023,7 @@ @@ -7194,6 +7023,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT"
}
}

8
package.json

@ -21,14 +21,9 @@ @@ -21,14 +21,9 @@
"@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x",
"d3": "^7.9.0",
"easymde": "^2.20.0",
"he": "1.2.x",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-footnote": "^4.0.0",
"nostr-tools": "2.10.x",
"svelte-heros": "^7.0.2"
"nostr-tools": "2.10.x"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
@ -39,7 +34,6 @@ @@ -39,7 +34,6 @@
"@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",
"@types/markdown-it": "^14.1.2",
"@types/node": "22.x",
"autoprefixer": "10.x",
"eslint-plugin-svelte": "2.x",

649
src/app.css

@ -2,610 +2,361 @@ @@ -2,610 +2,361 @@
@import './styles/publications.css';
@import './styles/visualize.css';
/* Base styles */
/* Custom styles */
@layer base {
/* Common text colors and typography */
.text-primary {
@apply text-gray-800 dark:text-gray-300;
}
.text-secondary {
@apply text-gray-600 dark:text-gray-400;
}
.text-sm-secondary {
@apply text-sm text-gray-600 dark:text-gray-400;
}
/* Common link styles */
.link {
@apply underline cursor-pointer hover:text-primary-700 dark:hover:text-primary-400;
}
.link-secondary {
@apply text-gray-800 hover:text-primary-700 dark:text-gray-300 dark:hover:text-primary-400;
}
/* Common container styles */
.container-base {
@apply w-full flex justify-center;
}
.container-main {
@apply main-leather flex flex-col space-y-4 max-w-2xl w-full my-6 px-4;
}
.container-main-wide {
@apply container-main max-w-3xl;
}
/* Common form styles */
.form-base {
@apply space-y-4 mt-6;
}
.form-input {
@apply w-full;
.leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200;
}
.form-textarea {
@apply w-full h-full resize-none border-0 focus:ring-0 bg-white dark:bg-gray-800 p-4;
.btn-leather.text-xs {
@apply px-2 py-1;
}
.form-preview {
@apply absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content;
.btn-leather.text-xs svg {
@apply h-3 w-3;
}
/* Common button styles */
.btn-base {
@apply btn-leather !w-fit;
.btn-leather.text-sm {
@apply px-3 py-2;
}
.btn-secondary {
@apply btn-base outline;
.btn-leather.text-sm svg {
@apply h-4 w-4;
}
.btn-sm {
@apply text-sm px-3 py-2;
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;
}
.btn-xs {
@apply text-xs px-2 py-1;
.image-border {
@apply border border-primary-700;
}
/* Common message styles */
.message-base {
@apply p-4 mb-4 text-sm rounded-lg;
div.card-leather {
@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;
}
.message-success {
@apply message-base bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 relative;
}
.message-error {
@apply message-base text-red-700 bg-red-100;
}
/* Common image styles */
.image-container {
@apply flex flex-col items-center space-y-4 my-4;
div.card-leather h1,
div.card-leather h2,
div.card-leather h3,
div.card-leather h4,
div.card-leather h5,
div.card-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.image-bordered {
@apply border border-primary-700 rounded-lg;
div.card-leather .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
}
/* Common heading styles */
.heading-base {
@apply text-gray-800 dark:text-gray-300 font-bold;
main {
@apply max-w-full;
}
.heading-1 {
@apply heading-base text-4xl;
main.main-leather,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
}
.heading-2 {
@apply heading-base text-3xl;
div.note-leather,
p.note-leather,
section.note-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded;
}
.heading-3 {
@apply heading-base text-2xl;
div.note-leather:hover:not(:has(.note-leather:hover)),
p.note-leather:hover:not(:has(.note-leather:hover)),
section.note-leather:hover:not(:has(.note-leather:hover)) {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
.heading-4 {
@apply heading-base text-xl;
h1.h-leather,
h2.h-leather,
h3.h-leather,
h4.h-leather,
h5.h-leather,
h6.h-leather {
@apply text-gray-800 dark:text-gray-300;
}
.heading-5 {
@apply heading-base text-lg font-semibold;
h1.h-leather {
@apply text-4xl font-bold;
}
.heading-6 {
@apply heading-base text-base font-semibold;
h2.h-leather {
@apply text-3xl font-bold;
}
/* Update link colors for better contrast */
.link {
@apply underline cursor-pointer hover:text-primary-700 dark:hover:text-primary-400;
h3.h-leather {
@apply text-2xl font-bold;
}
/* Update secondary text colors for better contrast */
.text-gray-500 {
@apply text-gray-600 dark:text-gray-400;
h4.h-leather {
@apply text-xl font-bold;
}
/* Update link colors in notes */
.note-leather p a {
@apply underline hover:text-primary-700 dark:hover:text-primary-400;
h5.h-leather {
@apply text-lg font-semibold;
}
/* Update link colors in lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-700 dark:text-gray-300 dark:hover:text-primary-400;
}
h6.h-leather {
@apply text-base font-semibold;
}
/* Component styles */
@layer components {
/* Page containers */
.about-container,
.contact-container,
.edit-container,
.visualize-container,
.not-found-container {
@apply container-base;
div.modal-leather>div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
}
.about-main,
.contact-main {
@apply container-main-wide;
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;
}
.edit-main {
@apply container-main;
div.modal-leather button {
@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;
}
/* About page */
.about-header {
@apply flex justify-between items-center;
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
.about-version {
@apply text-sm-secondary bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap;
nav.navbar-leather svg {
@apply fill-gray-800 hover:fill-primary-400 dark:fill-gray-300 dark:hover:fill-primary-500;
}
.about-image-container {
@apply image-container;
nav.navbar-leather h1,
nav.navbar-leather h2,
nav.navbar-leather h3,
nav.navbar-leather h4,
nav.navbar-leather h5,
nav.navbar-leather h6 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.about-image {
@apply image-bordered;
aside.sidebar-leather>div {
@apply bg-primary-0 dark:bg-primary-1000;
}
/* Contact page */
.contact-form {
@apply form-base;
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
.contact-form-input {
@apply form-input;
div.skeleton-leather div {
@apply bg-primary-100 dark:bg-primary-800;
}
.contact-form-textarea-container {
@apply relative border border-gray-300 dark:border-gray-600 rounded-lg transition-all duration-200 sm:w-[95vw] md:w-full;
div.textarea-leather {
@apply bg-primary-0 dark:bg-primary-1000;
}
.contact-form-textarea-container.expanded {
@apply h-[800px];
div.textarea-leather>div:nth-child(1),
div.toolbar-leather {
@apply border-none;
}
.contact-form-textarea-container.collapsed {
@apply h-[200px];
div.textarea-leather>div:nth-child(2) {
@apply bg-primary-0 dark:bg-primary-1000;
}
.contact-form-tabs {
@apply flex flex-wrap -mb-px text-sm font-medium text-center;
div.textarea-leather,
div.textarea-leather textarea {
@apply text-gray-800 dark:text-gray-300;
}
.contact-form-tab {
@apply inline-block p-4 rounded-t-lg;
div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300;
}
.contact-form-tab.active {
@apply border-b-2 border-primary-600 text-primary-600;
div[role='tooltip'] button.btn-leather .tooltip-leather {
@apply bg-primary-100 dark:bg-primary-800;
}
.contact-form-tab.inactive {
@apply hover:text-gray-600 hover:border-gray-300;
/* Network visualization */
.network-link-leather {
@apply stroke-primary-200 fill-primary-200;
}
.contact-form-tab-content {
@apply absolute inset-0;
.network-node-leather {
@apply stroke-primary-600;
}
.contact-form-textarea {
@apply form-textarea;
.network-node-content {
@apply fill-primary-100;
}
}
.contact-form-preview {
@apply form-preview;
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
}
.contact-form-toggle {
@apply absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100;
.h1-leather {
@apply text-4xl font-bold;
}
.contact-form-actions {
@apply flex justify-end space-x-4;
.h2-leather {
@apply text-3xl font-bold;
}
.contact-form-success {
@apply message-success;
.h3-leather {
@apply text-2xl font-bold;
}
.contact-form-error {
@apply message-error;
.h4-leather {
@apply text-xl font-bold;
}
/* Edit page */
.edit-preview-form {
@apply border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit;
.h5-leather {
@apply text-lg font-semibold;
}
.edit-preview-toolbar {
@apply toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800;
.h6-leather {
@apply text-base font-semibold;
}
/* Visualize page */
.visualize-header {
@apply flex items-center mb-4;
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.visualize-loading {
@apply flex justify-center items-center h-64;
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
}
.visualize-error {
@apply message-error;
}
@layer components {
.visualize-error-title {
@apply font-bold mb-2;
/* 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 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
.visualize-error-retry {
@apply btn-base text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800;
/* 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;
max-width: 400px;
z-index: 1000;
}
/* 404 page */
.not-found-container {
@apply leather flex flex-col items-center justify-center min-h-screen text-center px-4;
.leather-legend button {
@apply dark:text-white;
}
.not-found-title {
@apply heading-1 mb-4;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
.not-found-message {
@apply text-secondary mb-6;
h1,
h2,
h3,
h4,
h5,
h6 {
@apply h-leather;
}
.not-found-actions {
@apply flex space-x-4;
h1 {
@apply h1-leather;
}
.not-found-button {
@apply btn-base;
h2 {
@apply h2-leather;
}
/* Publication component styles */
.publication-container {
@apply flex flex-col space-y-4;
h3 {
@apply h3-leather;
}
.publication-header {
@apply flex flex-col space-y-2;
h4 {
@apply h4-leather;
}
.publication-title {
@apply heading-1;
h5 {
@apply h5-leather;
}
.publication-metadata {
@apply text-sm-secondary;
h6 {
@apply h6-leather;
}
.publication-content {
div {
@apply flex flex-col space-y-4;
}
.publication-section {
.olist {
@apply flex flex-col space-y-4;
}
.publication-section-title {
@apply heading-2;
}
.publication-section-content {
@apply text-secondary;
}
/* Login component styles */
.login-container {
@apply flex flex-col items-center justify-center min-h-screen p-4;
}
.login-form {
@apply form-base w-full max-w-md;
}
.login-input {
@apply form-input;
}
.login-button {
@apply btn-base w-full;
}
.login-error {
@apply message-error;
}
/* Modal component styles */
.modal-container {
@apply fixed inset-0 z-50 overflow-y-auto;
}
.modal-backdrop {
@apply fixed inset-0 bg-black bg-opacity-50 transition-opacity;
}
.modal-content {
@apply relative bg-white dark:bg-gray-800 rounded-lg shadow-xl mx-auto my-8 max-w-lg p-6;
}
.modal-header {
@apply flex items-center justify-between mb-4;
}
.modal-title {
@apply heading-2;
}
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
.modal-close {
@apply text-gray-400 hover:text-gray-500 dark:hover:text-gray-300;
li {
.paragraph {
@apply py-2;
}
.modal-body {
@apply text-secondary;
}
.modal-footer {
@apply flex justify-end space-x-4 mt-6;
}
/* Navigation component styles */
.nav-container {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
.nav-content {
@apply flex items-center justify-between p-4;
}
.nav-brand {
@apply heading-3;
}
.nav-links {
@apply flex items-center space-x-4;
}
.nav-link {
@apply link-secondary;
}
/* Table of Contents styles */
.toc-container {
@apply fixed top-4 left-4 z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-xs;
}
.toc-title {
@apply heading-3 mb-2;
}
.toc-list {
@apply space-y-2;
}
.toc-item {
@apply link-secondary text-sm;
}
/* Event control styles */
.event-control-container {
@apply flex items-center space-x-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg;
}
.event-control-label {
@apply text-sm-secondary;
}
.event-control-input {
@apply form-input max-w-24;
}
/* Preview component styles */
.preview-container {
.ulist {
@apply flex flex-col space-y-4;
}
.preview-header {
@apply flex items-center justify-between;
}
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
.preview-title {
@apply heading-2;
li {
.paragraph {
@apply py-2;
}
.preview-content {
@apply text-secondary;
}
/* Publication Feed styles */
.feed-container {
@apply flex flex-col space-y-4;
}
.feed-header {
@apply flex items-center justify-between;
}
.feed-title {
@apply heading-2;
a {
@apply link;
}
.feed-list {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
}
.imageblock {
@apply flex flex-col items-center;
.feed-item {
@apply card-leather p-4;
.title {
@apply text-sm text-center;
}
.feed-item-title {
@apply heading-3;
}
.feed-item-metadata {
@apply text-sm-secondary mt-2;
}
.stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
}
/* Keep existing utility classes */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
.literalblock {
pre {
@apply text-wrap;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
table {
@apply w-full overflow-x-auto;
.h3-leather {
@apply text-2xl font-bold;
caption {
@apply text-sm;
}
.h4-leather {
@apply text-xl font-bold;
}
thead,
tbody {
.h5-leather {
@apply text-lg font-semibold;
th,
td {
@apply border border-gray-200 dark:border-gray-700;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
.avatar-small {
@apply w-6 h-6 cursor-pointer;
}
.toc-toggle-btn {
@apply fixed top-20 left-4 h-6 w-6;
}
.no-padding {
@apply p-0;
}
.flex { @apply flex; }
.flex-col { @apply flex-col; }
.flex-row { @apply flex-row; }
.space-x-2 { @apply space-x-2; }
.space-y-2 { @apply space-y-2; }
.justify-between { @apply justify-between; }
.justify-end { @apply justify-end; }
.justify-start { @apply justify-start; }
.w-full { @apply w-full; }
.min-w-fit { @apply min-w-fit; }
.visible { @apply visible; }
.invisible { @apply invisible; }
.break-words { @apply break-words; }
.text-wrap { @apply text-wrap; }
.whitespace-normal { @apply whitespace-normal; }
.w-lg { @apply w-[32rem]; }
.col { @apply flex-col; }
.align-middle { @apply align-middle; }
.max-h-36 { @apply max-h-36; }
.max-w-24 { @apply max-w-24; }
.overflow-hidden { @apply overflow-hidden; }
.rounded { @apply rounded; }
.object-cover { @apply object-cover; }
.text-lg { @apply text-lg; }
.text-base { @apply text-base; }
.font-bold { @apply font-bold; }
.font-normal { @apply font-normal; }
.font-thin { @apply font-thin; }
.line-clamp-2 { @apply line-clamp-2; }
.mb-2 { @apply mb-2; }
.mb-3 { @apply mb-3; }
.mt-4 { @apply mt-4; }
.mr-2 { @apply mr-2; }
.flex-1 { @apply flex-1; }
.min-h-0 { @apply min-h-0; }
.relative { @apply relative; }
.border-b { @apply border-b; }
.border-gray-300 { @apply border-gray-300; }
.dark\:border-gray-600 { @apply dark:border-gray-600; }
.text-gray-500 { @apply text-gray-500; }
.text-red-500 { @apply text-red-500; }
.w-5 { @apply w-5; }
.h-5 { @apply h-5; }
.items-center { @apply items-center; }
.font-semibold { @apply font-semibold; }
.mt-1 { @apply mt-1; }
.hover\:underline:hover { @apply underline; }
.text-primary-600 { @apply text-primary-600; }
.dark\:text-primary-500 { @apply dark:text-primary-500; }
.break-all { @apply break-all; }
.text-sm { @apply text-sm; }
.list-disc { @apply list-disc; }
.list-inside { @apply list-inside; }
.text-success-700 { @apply text-success-700; }
.dark\:text-success-300 { @apply dark:text-success-300; }
}
.backdrop {
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50;
}
.Modal {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-lg w-full;
}
.toc {
@apply p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-xs mx-auto;
}
.toc-title {
text-align: center;
}

80
src/lib/components/EmoticonPicker.svelte

@ -1,80 +0,0 @@ @@ -1,80 +0,0 @@
<script lang="ts">
import { heroiconEmoticons, unicodeEmojis } from '../utils/emoticons';
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
const dispatch = createEventDispatcher();
let search = '';
let showMore = false;
let filteredHeroicons = heroiconEmoticons;
let filteredUnicode = unicodeEmojis;
function handleSelect(shortcode: string) {
dispatch('select', { shortcode });
}
function filterEmoticons() {
const s = search.trim().toLowerCase();
filteredHeroicons = heroiconEmoticons.filter(e =>
e.name.toLowerCase().includes(s) || e.shortcode.includes(s)
);
filteredUnicode = unicodeEmojis.filter(e =>
e.name.toLowerCase().includes(s) || e.shortcode.includes(s)
);
}
$: filterEmoticons();
</script>
<div class="emoticon-picker bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg p-2 w-72">
<input
type="text"
class="emoticon-search mb-2 w-full px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-sm"
placeholder="Search emoticons..."
bind:value={search}
on:input={filterEmoticons}
autocomplete="off"
/>
<div class="flex flex-wrap gap-2 mb-2">
{#each filteredHeroicons as emoticon}
<button
type="button"
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title={emoticon.name + ' ' + emoticon.shortcode}
on:click={() => handleSelect(emoticon.shortcode)}
>
<svelte:component this={emoticon.component} class="w-6 h-6 text-gray-700 dark:text-gray-200" />
<span class="text-xs text-gray-500">{emoticon.shortcode}</span>
</button>
{/each}
</div>
<button
type="button"
class="emoticon-more-btn w-full text-center py-1 text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
on:click={() => showMore = !showMore}
>
{showMore ? 'Hide more...' : '... more'}
</button>
{#if showMore}
<div class="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto">
{#each filteredUnicode as emoticon}
<button
type="button"
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
title={emoticon.name + ' ' + emoticon.shortcode}
on:click={() => handleSelect(emoticon.shortcode)}
>
<span class="w-6 h-6 text-2xl emoji-muted">{emoticon.char}</span>
<span class="text-xs text-gray-500">{emoticon.shortcode}</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.emoji-muted {
filter: grayscale(1) opacity(0.7);
display: inline-block;
}
</style>

16
src/lib/components/EventLimitControl.svelte

@ -29,23 +29,23 @@ @@ -29,23 +29,23 @@
}
</script>
<div class="event-control-container">
<label for="event-limit" class="event-control-label">
Number of root events:
<div class="flex items-center gap-2 mb-4">
<label for="event-limit" class="leather bg-transparent text-sm font-medium"
>Number of root events:
</label>
<input
type="number"
id="event-limit"
min="1"
max="50"
class="event-control-input"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}
on:input={handleInput}
on:keydown={handleKeyDown}
/>
<button
onclick={handleUpdate}
class="btn-base"
on:click={handleUpdate}
class="btn-leather px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

11
src/lib/components/EventRenderLevelLimit.svelte

@ -28,23 +28,24 @@ @@ -28,23 +28,24 @@
}
</script>
<div class="event-control-container">
<label for="levels-to-render" class="event-control-label">
Levels to render:
<div class="flex items-center gap-2 mb-4">
<label for="levels-to-render" class="leather bg-transparent text-sm font-medium"
>Levels to render:
</label>
<label for="event-limit" class="leather bg-transparent text-sm font-medium">Limit: </label>
<input
type="number"
id="levels-to-render"
min="1"
max="50"
class="event-control-input"
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white"
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeyDown}
/>
<button
onclick={handleUpdate}
class="btn-base"
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
Update
</button>

23
src/lib/components/Login.svelte

@ -46,28 +46,33 @@ @@ -46,28 +46,33 @@
</script>
<div class="login-container">
<div class="m-4">
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{:else}
<Avatar rounded class="avatar-small" id="avatar" />
<Avatar rounded class='h-6 w-6 cursor-pointer' id='avatar' />
<Popover
class="modal-content"
placement="bottom"
triggeredBy="#avatar"
class='popover-leather w-fit'
placement='bottom'
triggeredBy='#avatar'
>
<div class="login-form">
<div class='w-full flex flex-col space-y-2'>
<Button
class="login-button"
on:click={handleSignInClick}
onclick={handleSignInClick}
>
Extension Sign-In
</Button>
{#if signInFailed}
<div class="login-error">
<div class="p-2 text-sm text-red-600 bg-red-100 rounded">
{errorMessage}
</div>
{/if}
<!-- <Button
color='alternative'
on:click={signInWithBunker}
>
Bunker Sign-In
</Button> -->
</div>
</Popover>
{/if}

35
src/lib/components/LoginModal.svelte

@ -18,29 +18,23 @@ @@ -18,29 +18,23 @@
</script>
{#if show}
<div class="modal-container">
<Button
class="modal-backdrop"
aria-label="Close modal"
color="none"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5);"
onclick={onClose}
></Button>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Login Required</h3>
<Button
class="modal-close"
type="button"
aria-label="Close"
color="light"
<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 outline-none focus:outline-none">
<!-- Header -->
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 rounded-t">
<h3 class="text-xl font-medium text-gray-900">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>×</span>
</Button>
<span class="bg-transparent text-gray-500 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span>
</button>
</div>
<div class="modal-body">
<p class="text-secondary mb-4">
<!-- Body -->
<div class="relative p-6 flex-auto">
<p class="text-base leading-relaxed text-gray-500 mb-4">
You need to be logged in to submit an issue. Your form data will be preserved.
</p>
<div class="flex justify-center">
@ -49,4 +43,5 @@ @@ -49,4 +43,5 @@
</div>
</div>
</div>
</div>
{/if}

238
src/lib/components/MarkdownForm.svelte

@ -1,238 +0,0 @@ @@ -1,238 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import { createEventDispatcher } from 'svelte';
import { parseMarkdown } from '../utils/markdown/markdownItParser';
import EmoticonPicker from './EmoticonPicker.svelte';
import { Popover } from 'flowbite-svelte';
const dispatch = createEventDispatcher();
export let labelSubject = 'Subject';
export let labelContent = 'Content';
export let initialSubject = '';
export let initialContent = '';
export let submitLabel = 'Submit';
export let showSubject = true;
let subject = initialSubject;
let content = initialContent;
let submissionError = '';
let easyMDE: EasyMDE | null = null;
let textareaEl: HTMLTextAreaElement;
let showHelp = false;
let showEmojiPicker = false;
let emojiButtonEl: HTMLButtonElement;
const markdownHelp = `# Markdown Guide
## Text Formatting
- **Bold**: \`*text*\` or \`**text**\`
- **Italic**: \`_text_\` or \`__text__\`
- **Strikethrough**: \`~text~\` or \`~~text~~\`
- **Inline Code**: \`\` \`code\` \`\`
- **Links**: \`[text](url)\`
- **Images**: \`![alt](url)\`
## Structure
- **Headings**:
- \`# Heading 1\` through \`###### Heading 6\`
- Or:
\`\`\`
Heading 1
========
Heading 2
---------
\`\`\`
- **Lists**:
- Unordered: \`- item\`, \`* item\`, or \`+ item\`
- Ordered: \`1. item\`, \`2. item\`
- **Code Blocks**:
\`\`\`javascript
\`\`\`language
const code = 'example';
\`\`\`
\`\`\`
- **Blockquotes**: \`> Quote text\`
- **Tables**:
\`\`\`
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
\`\`\`
## Special Features
- **Footnotes**: \`[^1]\` and \`[^1]: definition\`
- **Emojis**: \`:smile:\`
- **Hashtags**: \`#tag\`
- **Nostr Identifiers**:
- Profiles: \`npub...\` or \`nprofile...\`
- Notes: \`note...\`, \`nevent...\`, or \`naddr...\`
## Media Support
- **YouTube Videos**: Automatically embedded
- **Video Files**: mp4, webm, mov, avi
- **Audio Files**: mp3, wav, ogg, m4a
- **Images**: jpg, jpeg, gif, png, webp
All media URLs are automatically cleaned of tracking parameters for privacy.`;
let helpContent = '';
async function toggleHelp() {
showHelp = !showHelp;
if (showHelp) {
helpContent = await parseMarkdown(markdownHelp);
}
}
function handleSubmit(e: Event) {
e.preventDefault();
if ((showSubject && !subject) || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Emit submit event with markdown content
dispatch('submit', { subject, content });
}
function insertEmoji(shortcode: string) {
if (easyMDE) {
const cm = easyMDE.codemirror;
const doc = cm.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(shortcode, cursor);
showEmojiPicker = false;
}
}
onMount(() => {
easyMDE = new EasyMDE({
element: textareaEl,
initialValue: content,
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', '|',
{
name: 'emoji',
action: () => showEmojiPicker = !showEmojiPicker,
className: 'fa fa-heart',
title: 'Insert Emoji',
icon: `<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor' class='w-5 h-5'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11.995 21.003c-.512 0-1.023-.195-1.414-.586l-7.003-7.003a5.002 5.002 0 017.072-7.072l.345.345.345-.345a5.002 5.002 0 017.072 7.072l-7.003 7.003a1.997 1.997 0 01-1.414.586z'/></svg>`
},
'|',
'preview', 'side-by-side', 'fullscreen', '|',
'guide'
],
status: false,
spellChecker: false,
previewRender: (text: string, previewElement: HTMLElement) => {
parseMarkdown(text).then(html => {
previewElement.innerHTML = html;
});
return null;
}
});
if (easyMDE) {
easyMDE.codemirror.on('change', () => {
content = easyMDE!.value();
});
}
});
onDestroy(() => {
if (easyMDE) {
easyMDE.toTextArea();
}
});
</script>
<form class="contact-form" on:submit={handleSubmit}>
<div class="flex justify-between items-center mb-4">
<div class="flex-1">
{#if showSubject}
<div>
<label for="subject" class="mb-2">{labelSubject}</label>
<input id="subject" class="contact-form-input" bind:value={subject} required />
</div>
{/if}
</div>
<button
type="button"
class="btn-secondary btn-sm ml-4"
on:click={toggleHelp}
title="Markdown Help"
>
?
</button>
</div>
<div class="relative">
<label for="content" class="mb-2">{labelContent}</label>
<textarea bind:this={textareaEl} class="hidden"></textarea>
{#if showEmojiPicker}
<Popover
class="emoji-picker-popover"
placement="bottom"
trigger="click"
open={showEmojiPicker}
on:clickoutside={() => showEmojiPicker = false}
>
<EmoticonPicker on:select={({ detail }) => insertEmoji(detail.shortcode)} />
</Popover>
{/if}
</div>
{#if showHelp}
<div class="mt-4 p-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 prose dark:prose-invert max-w-none">
{@html helpContent}
</div>
{/if}
<div class="contact-form-actions">
<button type="button" on:click={() => { subject = ''; content = ''; submissionError = ''; }}>
Clear Form
</button>
<button type="submit">{submitLabel}</button>
</div>
{#if submissionError}
<div class="contact-form-error" role="alert">
{submissionError}
</div>
{/if}
</form>
<style>
:global(.EasyMDEContainer) {
@apply border border-gray-300 dark:border-gray-600 rounded-lg;
}
:global(.EasyMDEContainer .CodeMirror) {
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300;
}
:global(.EasyMDEContainer .editor-toolbar) {
@apply border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700;
}
:global(.EasyMDEContainer .editor-toolbar button) {
@apply text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white;
}
:global(.EasyMDEContainer .editor-toolbar button.active) {
@apply text-primary-600 dark:text-primary-400;
}
:global(.EasyMDEContainer .editor-preview) {
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300;
}
:global(.emoji-button) {
@apply p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded;
}
:global(.emoji-picker-popover) {
@apply z-50;
}
</style>

22
src/lib/components/Navigation.svelte

@ -7,23 +7,23 @@ @@ -7,23 +7,23 @@
let leftMenuOpen = $state(false);
</script>
<Navbar class={`nav-container ${className}`}>
<div class="nav-content">
<NavBrand href="/" class="nav-brand">
<Navbar class={`Navbar navbar-leather ${className}`}>
<div class='flex flex-grow justify-between'>
<NavBrand href='/'>
<h1>Alexandria</h1>
</NavBrand>
</div>
<div class="nav-links">
<div class='flex md:order-2'>
<Login />
<NavHamburger class="btn-base" />
<NavHamburger class='btn-leather' />
</div>
<NavUl class="nav-links">
<NavLi href="/new/edit" class="nav-link">Publish</NavLi>
<NavLi href="/visualize" class="nav-link">Visualize</NavLi>
<NavLi href="/about" class="nav-link">About</NavLi>
<NavLi href="/contact" class="nav-link">Contact</NavLi>
<NavUl class='ul-leather'>
<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-base no-padding" />
<DarkMode btnClass='btn-leather p-0'/>
</NavLi>
</NavUl>
</Navbar>

10
src/lib/components/Preview.svelte

@ -188,7 +188,7 @@ @@ -188,7 +188,7 @@
<div slot='footer' class='flex space-x-2 justify-end'>
<Button
type='reset'
class='btn-base min-w-fit'
class='btn-leather min-w-fit'
size='sm'
outline
onclick={() => toggleEditing(rootId, false)}
@ -197,7 +197,7 @@ @@ -197,7 +197,7 @@
</Button>
<Button
type='submit'
class='btn-base min-w-fit'
class='btn-leather min-w-fit'
size='sm'
onclick={() => toggleEditing(rootId, true)}
>
@ -217,7 +217,7 @@ @@ -217,7 +217,7 @@
<Input type='text' class='input-leather' size='lg' bind:value={title}>
<CloseButton slot='right' onclick={() => toggleEditing(rootId, false)} />
</Input>
<Button class='btn-base' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}>
<Button class='btn-leather' color='primary' size='lg' onclick={() => toggleEditing(rootId, true)}>
Save
</Button>
</ButtonGroup>
@ -246,12 +246,12 @@ @@ -246,12 +246,12 @@
{#if allowEditing && depth > 0}
<div class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? 'visible' : 'invisible'}`}>
{#if hasPreviousSibling && parentId}
<Button class='btn-base' size='sm' outline onclick={() => moveUp(rootId, parentId)}>
<Button class='btn-leather' size='sm' outline onclick={() => moveUp(rootId, parentId)}>
<CaretUpSolid />
</Button>
{/if}
{#if hasNextSibling && parentId}
<Button class='btn-base' size='sm' outline onclick={() => moveDown(rootId, parentId)}>
<Button class='btn-leather' size='sm' outline onclick={() => moveDown(rootId, parentId)}>
<CaretDownSolid />
</Button>
{/if}

79
src/lib/components/Publication.svelte

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
} from "flowbite-svelte";
import { getContext, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/stores";
import { page } from "$app/state";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
@ -80,12 +80,10 @@ @@ -80,12 +80,10 @@
const tocBreakpoint = 1140;
let activeHash = $state($page.url.hash);
let activeHash = $state(page.url.hash);
let showToc: boolean = $state(true);
let showTocButton: boolean = $state(false);
let currentPath = $page.url.pathname;
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
@ -168,11 +166,40 @@ @@ -168,11 +166,40 @@
<!-- TODO: Keep track of already-loaded leaves. -->
<!-- TODO: Handle entering mid-document and scrolling up. -->
<div class="publication-container">
{#if showTocButton && !showToc}
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip> -->
{/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl">
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class="message-error">
<ExclamationCircleOutline class="w-5 h-5" />
<Alert class='flex space-x-2'>
<ExclamationCircleOutline class='w-5 h-5' />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
@ -184,51 +211,19 @@ @@ -184,51 +211,19 @@
/>
{/if}
{/each}
<div class="publication-footer">
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled class="btn-base">
<Button disabled color="primary">
Loading...
</Button>
{:else if !isDone}
<Button class="btn-base" on:click={() => loadMore(1)}>
<Button color="primary" on:click={() => loadMore(1)}>
Show More
</Button>
{/if}
</div>
</div>
{#if showTocButton && !showToc}
<Button
class="btn-secondary toc-toggle-btn"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip>
{/if}
{#if showToc}
<Sidebar class="toc-container">
<SidebarWrapper>
<SidebarGroup class="toc-list">
{#each leaves as leaf}
{#if leaf && leaf.getMatchingTags('title').length > 0}
<SidebarItem
class="toc-item"
label={leaf.getMatchingTags('title')[0][1]}
href={`${currentPath}#${normalizeHashPath(leaf.getMatchingTags('title')[0][1])}`}
/>
{/if}
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}
<style>
:global(.sidebar-group-leather) {
max-height: calc(100vh - 8rem);

4
src/lib/components/PublicationFeed.svelte

@ -80,7 +80,7 @@ @@ -80,7 +80,7 @@
});
</script>
<div class='feed-container'>
<div class='leather flex flex-col space-y-4'>
{#if eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
@ -109,7 +109,7 @@ @@ -109,7 +109,7 @@
</div>
{:else}
<div class='flex justify-center mt-4 mb-8'>
<P class='text-sm-secondary'>You've reached the end of the feed.</P>
<P class='text-sm text-gray-600'>You've reached the end of the feed.</P>
</div>
{/if}
</div>

2
src/lib/components/PublicationHeader.svelte

@ -32,7 +32,7 @@ @@ -32,7 +32,7 @@
</script>
{#if title != null && href != null}
<Card class='article-box card-leather w-lg flex flex-row space-x-2'>
<Card class='ArticleBox card-leather w-lg flex flex-row space-x-2'>
{#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/>

4
src/lib/components/PublicationSection.svelte

@ -104,9 +104,9 @@ @@ -104,9 +104,9 @@
});
</script>
<section bind:this={sectionRef} class="publication-section content-visibility-auto">
<section bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size="xxl" />
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}

8
src/lib/components/Toc.svelte

@ -9,10 +9,16 @@ @@ -9,10 +9,16 @@
</script>
<div class="toc">
<h2 class="toc-title">Table of contents</h2>
<h2>Table of contents</h2>
<ul>
{#each notes as note}
<li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li>
{/each}
</ul>
</div>
<style>
.toc h2 {
text-align: center;
}
</style>

8
src/lib/parser.ts

@ -12,8 +12,7 @@ import type { @@ -12,8 +12,7 @@ import type {
} from 'asciidoctor';
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts';
import { replaceEmojisWithUnicode } from './utils/markdown/markdownItParser';
import { zettelKinds } from './consts.ts';
interface IndexMetadata {
authors?: string[];
@ -209,11 +208,10 @@ export default class Pharos { @@ -209,11 +208,10 @@ export default class Pharos {
/**
* Gets the entire HTML content of the AsciiDoc document.
* @returns The HTML content of the converted document, with emoji shortcodes replaced by Unicode.
* @returns The HTML content of the converted document.
*/
getHtml(): string {
const html = this.html?.toString() || '';
return replaceEmojisWithUnicode(html);
return this.html?.toString() || '';
}
/**

11
src/lib/types/markdown-it-plugins.d.ts vendored

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
declare module 'markdown-it-footnote' {
import MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginWithParams;
export default plugin;
}
declare module 'markdown-it-emoji' {
import MarkdownIt from 'markdown-it';
const plugin: MarkdownIt.PluginWithParams;
export default plugin;
}

4
src/lib/types/svelte-heros.d.ts vendored

@ -1,4 +0,0 @@ @@ -1,4 +0,0 @@
declare module 'svelte-heros/dist/*.svelte' {
import { SvelteComponentTyped } from 'svelte';
export default class Icon extends SvelteComponentTyped<any, any, any> {}
}

378
src/lib/utils/advancedMarkdownParser.ts

@ -0,0 +1,378 @@ @@ -0,0 +1,378 @@
import { parseBasicMarkdown } from './basicMarkdownParser';
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 markdown 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;
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Process ATX-style headings (# Heading)
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
const headingLevel = level.length;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`;
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${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 (error) {
console.error('Error processing table row:', error);
return match;
}
});
} catch (error) {
console.error('Error in processTables:', error);
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 '';
// First collect all footnote references and definitions
const footnotes = new Map<string, string>();
const references = new Map<string, number>();
const referenceLocations = new Set<string>();
let nextNumber = 1;
// First pass: collect all references to establish order
let processedContent = content.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!referenceLocations.has(id) && !references.has(id)) {
references.set(id, nextNumber++);
}
referenceLocations.add(id);
return match; // Keep the reference for now
});
// Second pass: collect all definitions
processedContent = processedContent.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => {
footnotes.set(id, text.trim());
return ''; // Remove the definition
});
// Third pass: process references with collected information
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 num = references.get(id)!;
return `<sup><a href="#fn-${id}" id="fnref-${id}" class="text-primary-600 hover:underline">[${num}]</a></sup>`;
});
// Add footnotes section if we have any
if (references.size > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside">\n';
// Sort footnotes by their reference number
const sortedFootnotes = Array.from(references.entries())
.sort((a, b) => a[1] - b[1])
.filter(([id]) => footnotes.has(id)); // Only include footnotes that have definitions
// Add each footnote in order
for (const [id, num] of sortedFootnotes) {
const text = footnotes.get(id) || '';
processedContent += `<li id="fn-${id}" value="${num}"><span class="marker">${text}</span> <a href="#fnref-${id}" class="text-primary-600 hover:underline">↩</a></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(/^```(\w*)$/);
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) {
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) {
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) {
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 (error) {
console.error('Error restoring code block:', error);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
}
}
return result;
}
/**
* Parse markdown text with advanced formatting
*/
export async function parseAdvancedMarkdown(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
processedText = processFootnotes(processedText);
// Process basic markdown (which will also handle Nostr identifiers)
processedText = await parseBasicMarkdown(processedText);
// Step 3: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (error) {
console.error('Error in parseAdvancedMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

182
src/lib/utils/basicMarkdownParser.ts

@ -0,0 +1,182 @@ @@ -0,0 +1,182 @@
import { processNostrIdentifiers } from './nostrUtils';
// Regular expressions for basic markdown elements
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;
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
// List regex patterns
const UNORDERED_LIST_REGEX = /^(\s*[-*+]\s+)(.*?)$/gm;
const ORDERED_LIST_REGEX = /^(\s*\d+\.\s+)(.*?)$/gm;
// Markdown patterns
const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
// URL patterns
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Media URL patterns
const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
function processBasicFormatting(content: string): string {
if (!content) return '';
let processedText = content;
try {
// Process Markdown images first
processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, 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>`;
}
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
});
// Process Markdown links
processedText = processedText.replace(MARKDOWN_LINK, (match, text, url) =>
`<a href="${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
processedText = processedText.replace(DIRECT_LINK, match => {
if (YOUTUBE_URL_REGEX.test(match)) {
const videoId = extractYouTubeVideoId(match);
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(match)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${match}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(match)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${match}">Your browser does not support the audio tag.</audio>`;
}
if (IMAGE_URL_REGEX.test(match)) {
return `<img src="${match}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
}
return `<a href="${match}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${match}</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-gray-500 dark:text-gray-400">#$1</span>');
} catch (error) {
console.error('Error in processBasicFormatting:', error);
}
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 (error) {
console.error('Error in processBlockquotes:', error);
return content;
}
}
export async function parseBasicMarkdown(text: string): Promise<string> {
if (!text) return '';
try {
// Process basic text formatting first
let processedText = processBasicFormatting(text);
// Process lists - handle ordered lists first
processedText = processedText
// Process ordered lists
.replace(ORDERED_LIST_REGEX, (match, marker, content) => {
// Count leading spaces to determine nesting level
const indent = marker.match(/^\s*/)[0].length;
const extraIndent = indent > 0 ? ` ml-${indent * 4}` : '';
return `<li class="py-2${extraIndent}">${content}</li>`;
})
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ol class="list-decimal my-4 ml-8">$&</ol>')
// Process unordered lists
.replace(UNORDERED_LIST_REGEX, (match, marker, content) => {
// Count leading spaces to determine nesting level
const indent = marker.match(/^\s*/)[0].length;
const extraIndent = indent > 0 ? ` ml-${indent * 4}` : '';
return `<li class="py-2${extraIndent}">${content}</li>`;
})
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ul class="list-disc my-4 ml-8">$&</ul>');
// 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);
return processedText;
} catch (error) {
console.error('Error in parseBasicMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

85
src/lib/utils/emoticons.ts

@ -1,85 +0,0 @@ @@ -1,85 +0,0 @@
// Heroicon Svelte components (assume these are available in src/lib/icons/heroicons)
import Heart from 'svelte-heros/dist/Heart.svelte';
import FaceSmile from 'svelte-heros/dist/FaceSmile.svelte';
import FaceFrown from 'svelte-heros/dist/FaceFrown.svelte';
import Fire from 'svelte-heros/dist/Fire.svelte';
import HandRaised from 'svelte-heros/dist/HandRaised.svelte';
import ThumbDown from 'svelte-heros/dist/ThumbDown.svelte';
import ThumbUp from 'svelte-heros/dist/ThumbUp.svelte';
import Eye from 'svelte-heros/dist/Eye.svelte';
import LightBulb from 'svelte-heros/dist/LightBulb.svelte';
import Pencil from 'svelte-heros/dist/Pencil.svelte';
import RocketLaunch from 'svelte-heros/dist/RocketLaunch.svelte';
import Star from 'svelte-heros/dist/Star.svelte';
import Sun from 'svelte-heros/dist/Sun.svelte';
import Moon from 'svelte-heros/dist/Moon.svelte';
import Trash from 'svelte-heros/dist/Trash.svelte';
import Trophy from 'svelte-heros/dist/Trophy.svelte';
import Cake from 'svelte-heros/dist/Cake.svelte';
import CurrencyDollar from 'svelte-heros/dist/CurrencyDollar.svelte';
import CurrencyEuro from 'svelte-heros/dist/CurrencyEuro.svelte';
import ExclamationCircle from 'svelte-heros/dist/ExclamationCircle.svelte';
export const heroiconEmoticons = [
{ name: 'Heart', shortcode: ':heart:', component: Heart },
{ name: 'Smile', shortcode: ':face-smile:', component: FaceSmile },
{ name: 'Frown', shortcode: ':face-frown:', component: FaceFrown },
{ name: 'Fire', shortcode: ':fire:', component: Fire },
{ name: 'Hand Raised', shortcode: ':hand-raised:', component: HandRaised },
{ name: 'Thumb Down', shortcode: ':hand-thumb-down:', component: ThumbDown },
{ name: 'Thumb Up', shortcode: ':hand-thumb-up:', component: ThumbUp },
{ name: 'Eye', shortcode: ':eye:', component: Eye },
{ name: 'Light Bulb', shortcode: ':light-bulb:', component: LightBulb },
{ name: 'Pencil Square', shortcode: ':pencil-square:', component: Pencil },
{ name: 'Rocket', shortcode: ':rocket-launch:', component: RocketLaunch },
{ name: 'Star', shortcode: ':star:', component: Star },
{ name: 'Sun', shortcode: ':sun:', component: Sun },
{ name: 'Moon', shortcode: ':moon:', component: Moon },
{ name: 'Trash', shortcode: ':trash:', component: Trash },
{ name: 'Trophy', shortcode: ':trophy:', component: Trophy },
{ name: 'Cake', shortcode: ':cake:', component: Cake },
{ name: 'Dollar Sign', shortcode: ':dollar-sign:', component: CurrencyDollar },
{ name: 'Euro Sign', shortcode: ':euro-sign:', component: CurrencyEuro },
{ name: 'Exclamation Circle', shortcode: ':exclamation-circle:', component: ExclamationCircle }
];
// Unicode emojis, excluding those covered by heroicons
export const unicodeEmojis = [
{ name: 'Laughing', shortcode: ':joy:', char: '😂' },
{ name: 'Crying', shortcode: ':sob:', char: '😭' },
{ name: 'Call Me Hand', shortcode: ':call-me-hand:', char: '🤙' },
{ name: 'Waving Hand', shortcode: ':wave:', char: '👋' },
{ name: 'Pinched Fingers', shortcode: ':pinched-fingers:', char: '🤌' },
// ...add more as needed, ensuring no overlap with heroiconEmoticons
];
/**
* Get the Unicode character for a given shortcode, searching both heroicon and unicode lists.
* Returns undefined if not found.
*/
export function getUnicodeEmoji(shortcode: string): string | undefined {
// Map heroicon shortcodes to a reasonable Unicode fallback
const heroiconFallbacks: Record<string, string> = {
':heart:': '❤',
':face-smile:': '🙂',
':face-frown:': '🙁',
':fire:': '🔥',
':hand-raised:': '✋',
':hand-thumb-down:': '👎',
':hand-thumb-up:': '👍',
':bell:': '🔔',
':eye:': '👁',
':light-bulb:': '💡',
':pencil-square:': '✏',
':rocket-launch:': '🚀',
':star:': '⭐',
':sun:': '☀',
':moon:': '🌙',
':trash:': '🗑',
':trophy:': '🏆',
};
if (heroiconFallbacks[shortcode]) return heroiconFallbacks[shortcode];
const unicode = unicodeEmojis.find(e => e.shortcode === shortcode);
return unicode ? unicode.char : undefined;
}

391
src/lib/utils/markdown/markdownItParser.ts

@ -1,391 +0,0 @@ @@ -1,391 +0,0 @@
import MarkdownIt from 'markdown-it';
import footnote from 'markdown-it-footnote';
import emoji from 'markdown-it-emoji';
import { processNostrIdentifiers } from '../nostrUtils';
import hljs from 'highlight.js';
import 'highlight.js/lib/common';
import 'highlight.js/styles/github-dark.css';
import asciidoc from 'highlight.js/lib/languages/asciidoc';
import { getUnicodeEmoji } from '../emoticons';
// Configure highlight.js
hljs.configure({
ignoreUnescapedHTML: true
});
hljs.registerLanguage('asciidoc', asciidoc);
// URL patterns for custom rendering
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Tracking parameters to remove
const TRACKING_PARAMS = new Set([
// Common tracking parameters
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'ref', 'source', 'campaign', 'si', 't', 'v', 'ab_channel',
// YouTube specific
'feature', 'hl', 'gl', 'app', 'persist_app', 'app-arg',
'autoplay', 'loop', 'controls', 'modestbranding', 'rel',
'showinfo', 'iv_load_policy', 'fs', 'playsinline'
]);
/**
* Clean URL by removing tracking parameters
*/
function cleanUrl(url: string): string {
try {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
// Remove tracking parameters
for (const param of TRACKING_PARAMS) {
params.delete(param);
}
// For YouTube URLs, only keep the video ID
if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = url.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `https://www.youtube-nocookie.com/embed/${videoId}`;
}
}
// Reconstruct URL without tracking parameters
urlObj.search = params.toString();
return urlObj.toString();
} catch (e) {
// If URL parsing fails, return original URL
return url;
}
}
// Create markdown-it instance with plugins
const md = new MarkdownIt({
html: true, // Enable HTML tags in source
xhtmlOut: true, // Use '/' to close single tags (<br />)
breaks: true, // Convert '\n' in paragraphs into <br>
linkify: true, // Autoconvert URL-like text to links
typographer: true, // Enable some language-neutral replacement + quotes beautification
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
} catch (__) {}
}
return ''; // use external default escaping
}
})
.use(footnote)
.use(emoji);
// Enable strikethrough using markdown-it's built-in rule
md.inline.ruler.after('emphasis', 'strikethrough', (state, silent) => {
let found = false, token, pos = state.pos, max = state.posMax, start = pos, marker = state.src.charCodeAt(pos);
if (silent) return false;
if (marker !== 0x7E/* ~ */) return false;
let scan = pos, mem = pos;
while (scan < max && state.src.charCodeAt(scan) === 0x7E/* ~ */) { scan++; }
let len = scan - mem;
if (len < 2) return false;
let markup = state.src.slice(mem, scan);
let end = scan;
while (end < max) {
if (state.src.charCodeAt(end) === marker) {
if (state.src.slice(end, end + len) === markup) {
found = true;
break;
}
}
end++;
}
if (!found) {
state.pos = scan;
return false;
}
if (!silent) {
state.pos = mem + len;
token = state.push('s_open', 's', 1);
token.markup = markup;
token = state.push('text', '', 0);
token.content = state.src.slice(mem + len, end);
token = state.push('s_close', 's', -1);
token.markup = markup;
}
state.pos = end + len;
return true;
});
// Custom renderer rules for Nostr identifiers
const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
// Add custom rule for hashtags
md.inline.ruler.after('emphasis', 'hashtag', (state, silent) => {
const match = /^#([a-zA-Z0-9_]+)(?!\w)/.exec(state.src.slice(state.pos));
if (!match) return false;
if (silent) return true;
const tag = match[1];
state.pos += match[0].length;
const token = state.push('hashtag', '', 0);
token.content = tag;
token.markup = '#';
return true;
});
md.renderer.rules.hashtag = (tokens, idx) => {
const tag = tokens[idx].content;
return `<span class="text-secondary">#${tag}</span>`;
};
// Override the default link renderer to handle Nostr identifiers and special URLs
const defaultRender = md.renderer.rules.link_open || function(tokens: any[], idx: number, options: any, env: any, self: any): string {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function(tokens: any[], idx: number, options: any, env: any, self: any): string {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
const cleanedHref = cleanUrl(href);
// Handle Nostr identifiers
if ((NOSTR_PROFILE_REGEX.test(cleanedHref) || NOSTR_NOTE_REGEX.test(cleanedHref)) && !cleanedHref.startsWith('nostr:')) {
token.attrs![hrefIndex][1] = `nostr:${cleanedHref}`;
}
// Handle WebSocket URLs
else if (WSS_URL.test(cleanedHref)) {
const cleanUrl = cleanedHref.slice(6).replace(/\/+$/, '');
token.attrs![hrefIndex][1] = `https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F`;
}
// Handle media URLs
else if (YOUTUBE_URL_REGEX.test(cleanedHref)) {
const videoId = cleanedHref.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `<div class="videoblock"><div class="content"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div></div>`;
}
}
else if (VIDEO_URL_REGEX.test(cleanedHref)) {
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedHref}">Your browser does not support the video tag.</video></div></div>`;
}
else if (AUDIO_URL_REGEX.test(cleanedHref)) {
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedHref}">Your browser does not support the audio tag.</audio></div></div>`;
}
else if (IMAGE_URL_REGEX.test(cleanedHref)) {
return `<div class="imageblock"><div class="content"><img src="${cleanedHref}" alt="Embedded media" loading="lazy" decoding="async"></div></div>`;
}
else {
// Update the href with cleaned URL
token.attrs![hrefIndex][1] = cleanedHref;
}
}
return defaultRender(tokens, idx, options, env, self);
};
// Override image renderer to handle media URLs
const defaultImageRender = md.renderer.rules.image || function(tokens: any[], idx: number, options: any, env: any, self: any): string {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image = function(tokens: any[], idx: number, options: any, env: any, self: any): string {
const token = tokens[idx];
const srcIndex = token.attrIndex('src');
if (srcIndex >= 0) {
const src = token.attrs![srcIndex][1];
const cleanedSrc = cleanUrl(src);
const alt = token.attrs![token.attrIndex('alt')]?.[1] || '';
if (YOUTUBE_URL_REGEX.test(cleanedSrc)) {
const videoId = cleanedSrc.match(YOUTUBE_URL_REGEX)?.[1];
if (videoId) {
return `<div class="videoblock"><div class="content"><iframe 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></div></div>`;
}
}
if (VIDEO_URL_REGEX.test(cleanedSrc)) {
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedSrc}">${alt || 'Video'}</video></div></div>`;
}
if (AUDIO_URL_REGEX.test(cleanedSrc)) {
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedSrc}">${alt || 'Audio'}</audio></div></div>`;
}
// Update the src with cleaned URL
token.attrs![srcIndex][1] = cleanedSrc;
}
return defaultImageRender(tokens, idx, options, env, self);
};
// Add custom rule for alternate heading style
md.block.ruler.before('heading', 'alternate_heading', (state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const content = state.src.slice(start, max).trim();
// Check if this line is followed by = or - underline
if (startLine + 1 >= endLine) return false;
const nextStart = state.bMarks[startLine + 1] + state.tShift[startLine + 1];
const nextMax = state.eMarks[startLine + 1];
const nextContent = state.src.slice(nextStart, nextMax).trim();
// Check if next line is all = or -
if (!/^[=-]+$/.test(nextContent)) return false;
// Determine heading level (h1 for =, h2 for -)
const level = nextContent[0] === '=' ? 1 : 2;
if (silent) return true;
// Create heading token
state.line = startLine + 2;
const openToken = state.push('heading_open', 'h' + level, 1);
openToken.markup = '#'.repeat(level);
const inlineToken = state.push('inline', '', 0);
inlineToken.content = content;
inlineToken.map = [startLine, startLine + 2];
const closeToken = state.push('heading_close', 'h' + level, -1);
closeToken.markup = '#'.repeat(level);
return true;
});
// Override the default code inline rule to only support single backticks
md.inline.ruler.after('backticks', 'code_inline', (state, silent) => {
let start = state.pos;
let max = state.posMax;
let marker = state.src.charCodeAt(start);
// Check for single backtick
if (marker !== 0x60/* ` */) return false;
// Find the end of the code span
let pos = start + 1;
// Find the closing backtick
while (pos < max) {
if (state.src.charCodeAt(pos) === 0x60/* ` */) {
pos++;
break;
}
pos++;
}
if (pos >= max) return false;
const content = state.src.slice(start + 1, pos - 1);
if (!content) return false;
if (silent) return true;
state.pos = pos;
const token = state.push('code_inline', 'code', 0);
token.content = content;
token.markup = '`';
return true;
});
/**
* Replace emoji shortcodes in text with Unicode wrapped in <span class="emoji-muted">...</span>
*/
export function replaceEmojisWithUnicode(text: string): string {
return text.replace(/(:[a-z0-9_\-]+:)/gi, (match) => {
const unicode = getUnicodeEmoji(match);
if (unicode) {
return `<span class=\"emoji-muted\">${unicode}</span>`;
}
return match;
});
}
/**
* Parse markdown text with markdown-it and custom processing
*/
export async function parseMarkdown(text: string): Promise<string> {
if (!text) return '';
try {
// First pass: Process with markdown-it
let processedText = md.render(text);
// Second pass: Process Nostr identifiers
processedText = await processNostrIdentifiers(processedText);
// Third pass: Replace emoji shortcodes with Unicode
processedText = replaceEmojisWithUnicode(processedText);
// Add custom classes to elements
processedText = processedText
// Add classes to headings
.replace(/<h1>/g, '<h1 class="h1-leather">')
.replace(/<h2>/g, '<h2 class="h2-leather">')
.replace(/<h3>/g, '<h3 class="h3-leather">')
.replace(/<h4>/g, '<h4 class="h4-leather">')
.replace(/<h5>/g, '<h5 class="h5-leather">')
.replace(/<h6>/g, '<h6 class="h6-leather">')
// Add classes to paragraphs
.replace(/<p>/g, '<p class="text-primary">')
// Add classes to blockquotes
.replace(/<blockquote>/g, '<blockquote class="quoteblock">')
// Add classes to code blocks
.replace(/<pre>/g, '<pre class="listingblock">')
// Add classes to inline code
.replace(/<code>/g, '<code class="literalblock">')
// Add classes to links
.replace(/<a href=/g, '<a class="link" href=')
// Add classes to lists
.replace(/<ul>/g, '<ul class="ulist">')
.replace(/<ol>/g, '<ol class="olist arabic">')
// Add classes to list items
.replace(/<li>/g, '<li class="text-primary">')
// Add classes to horizontal rules
.replace(/<hr>/g, '<hr class="border-b border-gray-300 dark:border-gray-600">')
// Add classes to footnotes
.replace(/<div class="footnotes">/g, '<div class="footnotes mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">')
.replace(/<a href="#fnref/g, '<a class="link" href="#fnref')
.replace(/<a href="#fn-/g, '<a class="link" href="#fn-')
.replace(/<sup id="fnref/g, '<sup id="fnref" class="text-sm-secondary">')
.replace(/<li id="fn-/g, '<li id="fn-" class="text-sm-secondary">')
// Add classes to images
.replace(/<img/g, '<img class="imageblock"')
// Add classes to tables
.replace(/<table>/g, '<table class="tableblock">')
.replace(/<thead>/g, '<thead class="text-primary">')
.replace(/<tbody>/g, '<tbody class="text-primary">')
.replace(/<th>/g, '<th class="text-primary font-semibold">')
.replace(/<td>/g, '<td class="text-primary">');
return processedText;
} catch (error) {
console.error('Error in parseMarkdown:', error);
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`;
}
}

2
src/lib/utils/markdown/markdownTestfile.md → src/lib/utils/markdownTestfile.md

@ -7,8 +7,6 @@ It is _only_ a test, for __sure__. I just wanted to see if the markdown renders @@ -7,8 +7,6 @@ It is _only_ a test, for __sure__. I just wanted to see if the markdown renders
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser.
Try out some emojisface with smiling :facesmile:, call-me hand :call-me-hand:, and trophy :trophy:.
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
> This is important information

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

@ -4,11 +4,11 @@ @@ -4,11 +4,11 @@
import { Button, P } from 'flowbite-svelte';
</script>
<div class="not-found-container">
<h1 class="heading-1">404 - Page Not Found</h1>
<P class="text-secondary mb-6">The page you are looking for does not exist or has been moved.</P>
<div class="not-found-actions">
<Button class="btn-base" on:click={() => goto('/')}>Return to Home</Button>
<Button class="btn-secondary" on:click={() => window.history.back()}>Go Back</Button>
<div class="leather flex flex-col items-center justify-center min-h-screen text-center px-4">
<h1 class="h-leather mb-4">404 - Page Not Found</h1>
<P class="note-leather mb-6">The page you are looking for does not exist or has been moved.</P>
<div class="flex space-x-4">
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button>
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button>
</div>
</div>

123
src/routes/about/+page.svelte

@ -6,223 +6,206 @@ @@ -6,223 +6,206 @@
const isVersionKnown = appVersion !== "development";
</script>
<div class="about-container">
<main class="about-main">
<div class="about-header">
<Heading tag="h1" class="heading-1 mb-2">About the Library of Alexandria</Heading>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2"
>About the Library of Alexandria</Heading
>
{#if isVersionKnown}
<span class="about-version">Version: {appVersion}</span>
<span
class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap"
>Version: {appVersion}</span
>
{/if}
</div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" class="image-bordered" />
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="text-secondary mb-3">
<P class="mb-3">
Alexandria is a reader and writer for <A
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
class="link"
>curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (Markdown). It is produced by the <A
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1"
class="link"
>GitCitadel project team</A
>.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
Please submit support issues on the <A
href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues"
target="_blank"
class="link"
>Alexandria repo page</A
target="_blank">Alexandria repo page</A
> and follow us on <A
href="https://github.com/ShadowySupercode/gitcitadel"
target="_blank"
class="link"
>GitHub</A
> and <A
href="https://geyser.fund/project/gitcitadel"
target="_blank"
class="link"
target="_blank">GitHub</A
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank"
>Geyserfund</A
>.
</P>
<P class="text-secondary">
<P>
We are easiest to contact over our Nostr address <A
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg"
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz"
target="_blank"
class="link"
>npub1s3h…75wz</A
target="_blank">npub1s3h…75wz</A
>.
</P>
<Heading tag="h2" class="heading-2 mt-4 mb-2">Overview</Heading>
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading>
<P class="text-secondary mb-4">
Alexandria opens up to the <A href="./" class="link">landing page</A>, where the user
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user
can: login (top-right), select whether to only view the publications
hosted on the <A
href="https://thecitadel.nostr1.com/"
target="_blank"
class="link"
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank"
>thecitadel document relay</A
> or add in their own relays, and scroll/search the publications.
</P>
<div class="about-image-container">
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/LandingPage.png"
alt="Landing page"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/YourRelays.png"
alt="Relay selection"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
</div>
<P class="text-secondary mb-3">
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you
click on "Visualize", and to publish an e-book or other document (coming
soon).
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the
associated reading view opens to the publication. The app then pulls all
of the content events (30041s and 30818s for wiki pages), in the order in
which they are indexed, and displays them as a single document.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. (This
functionality has been temporarily disabled.)
</P>
<div class="about-image-container">
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ToC_icon.png"
alt="ToC icon"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/TableOfContents.png"
alt="Table of contents example"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h2" class="heading-2 mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading>
<Heading tag="h3" class="heading-3 mb-3">For e-books</Heading>
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading>
<P class="text-secondary mb-3">
<P class="mb-3">
The most common use for Alexandria is for e-books: both those users have
written themselves and those uploaded to Nostr from other sources. The
first minor version of the app, Gutenberg, is focused on displaying and
producing these publications.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
An example of a book is <A
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
class="link"
>Jane Eyre</A
>
</P>
<div class="image-container">
<div class="flex justify-center my-4">
<Img
src="/screenshots/JaneEyre.png"
alt="Jane Eyre, by Charlotte Brontë"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="heading-3 mb-3">For scientific papers</Heading>
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading>
<P class="text-secondary mb-3">
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX
embedding, and the normal advanced formatting options available for
Asciidoc. In addition, we will be implementing special citation events,
which will serve as an alternative or addition to the normal footnotes.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them
to be reviewed (with kind 1111 comments), and annotated (with highlights)
by users, is the focus of the second minor version, Euler.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based)
e-paper compatible version of the web app.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
An example of a research paper is <A
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
class="link"
>Less Partnering, Less Children, or Both?</A
>
</P>
<div class="image-container">
<div class="flex justify-center my-4">
<Img
src="/screenshots/ResearchPaper.png"
alt="Research paper"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="heading-3 mb-3">For documentation</Heading>
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading>
<P class="text-secondary mb-3">
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1"
class="link"
>blog entries</A
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A
>, as well as to store copies of our most interesting <A
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1"
class="link"
>technical specifications</A
>.
</P>
<div class="image-container">
<div class="flex justify-center my-4">
<Img
src="/screenshots/Documentation.png"
alt="Documentation"
class="about-image"
class="image-border rounded-lg"
width="400"
/>
</div>
<Heading tag="h3" class="heading-3 mb-3">For wiki pages</Heading>
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading>
<P class="text-secondary mb-3">
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for
collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <A
href="/publication?d=sybil"
class="link"
>Sybil utility</A> use the same
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same
Asciidoc format as other publications but are specifically designed for interconnected,
evolving content.
</P>
<P class="text-secondary mb-3">
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links
to other wiki pages, creating a web of knowledge that can be navigated and
explored.

267
src/routes/contact/+page.svelte

@ -6,10 +6,9 @@ @@ -6,10 +6,9 @@
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte';
import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser';
import { parseAdvancedMarkdown } from '$lib/utils/advancedMarkdownParser';
import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
import MarkdownForm from '$lib/components/MarkdownForm.svelte';
// Function to close the success message
function closeSuccessMessage() {
@ -69,16 +68,29 @@ @@ -69,16 +68,29 @@
isExpanded = !isExpanded;
}
function handleIssueSubmit(subject: string, content: string) {
// Set the local state for subject/content if needed
// subject = subject;
// content = content;
// Call the original handleSubmit logic, but without the event
async function handleSubmit(e: Event) {
// Prevent form submission
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Show confirmation dialog or proceed with submission as before
// 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;
}
@ -256,8 +268,8 @@ @@ -256,8 +268,8 @@
}
</script>
<div class='contact-form-container'>
<main class='contact-form-main'>
<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">
@ -274,16 +286,114 @@ @@ -274,16 +286,114 @@
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>
<MarkdownForm
labelSubject="Subject"
labelContent="Description"
submitLabel="Submit Issue"
showSubject={true}
on:submit={({ detail }) => handleIssueSubmit(detail.subject, detail.content)}
<form class="space-y-4 mt-6" on:submit|preventDefault={handleSubmit}>
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full" 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">
<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">
<Textarea
id="content"
class="w-full h-full resize-none border-0 focus:ring-0 bg-white dark:bg-gray-800 p-4 description-textarea"
bind:value={content}
required
placeholder="Describe your issue in detail...
The following Markdown is supported:
# Headers (1-6 levels)
*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 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content">
{#key content}
{#await parseAdvancedMarkdown(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="contact-form-actions">
<Button type="button" color="alternative" onclick={clearForm}>
<div class="flex justify-end space-x-4">
<Button type="button" color="alternative" on:click={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
@ -296,10 +406,11 @@ @@ -296,10 +406,11 @@
</div>
{#if submissionSuccess && submittedEvent}
<div class="contact-form-success" role="alert">
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<!-- Close button -->
<button
class="contact-form-success-close"
onclick={closeSuccessMessage}
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">
@ -308,20 +419,20 @@ @@ -308,20 +419,20 @@
</button>
<div class="flex items-center mb-3">
<svg class="contact-form-success-icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<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="contact-form-success-title">Issue submitted successfully!</span>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
</div>
<div class="contact-form-success-content">
<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="contact-form-success-description">
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedMarkdown(submittedEvent.content)}
<p>Loading...</p>
{:then html}
@ -355,10 +466,12 @@ @@ -355,10 +466,12 @@
{/if}
{#if submissionError}
<div class="contact-form-error" role="alert">
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{submissionError}
</div>
{/if}
</form>
</main>
</div>
@ -369,15 +482,15 @@ @@ -369,15 +482,15 @@
autoclose={false}
class="w-full"
>
<div class="contact-form-confirm-dialog">
<h3 class="contact-form-confirm-title">
<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="contact-form-confirm-actions">
<Button color="alternative" onclick={cancelSubmit}>
<div class="flex justify-center gap-4">
<Button color="alternative" on:click={cancelSubmit}>
Cancel
</Button>
<Button color="primary" onclick={confirmSubmit}>
<Button color="primary" on:click={confirmSubmit}>
Submit
</Button>
</div>
@ -397,3 +510,95 @@ @@ -397,3 +510,95 @@
submitIssue();
}}
/>
<style>
:global(.footnote-ref) {
text-decoration: none;
color: var(--color-primary);
}
:global(.footnotes) {
margin-top: 2rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
:global(.footnotes hr) {
margin: 1rem 0;
border-top: 1px solid var(--color-border);
}
:global(.footnotes ol) {
padding-left: 1rem;
}
:global(.footnotes li) {
margin-bottom: 0.5rem;
}
:global(.footnote-backref) {
text-decoration: none;
margin-left: 0.5rem;
color: var(--color-primary);
}
:global(.note-leather) :global(.footnote-ref),
:global(.note-leather) :global(.footnote-backref) {
color: var(--color-leather-primary);
}
:global(.description-textarea) {
overflow-y: scroll !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
min-height: 100% !important;
}
:global(.description-textarea::-webkit-scrollbar) {
width: 8px !important;
display: block !important;
}
:global(.description-textarea::-webkit-scrollbar-track) {
background: transparent !important;
}
:global(.description-textarea::-webkit-scrollbar-thumb) {
background-color: rgba(156, 163, 175, 0.5) !important;
border-radius: 4px !important;
}
:global(.description-textarea::-webkit-scrollbar-thumb:hover) {
background-color: rgba(156, 163, 175, 0.7) !important;
}
:global(.prose-content) {
overflow-y: scroll !important;
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
}
:global(.prose-content::-webkit-scrollbar) {
width: 8px !important;
display: block !important;
}
:global(.prose-content::-webkit-scrollbar-track) {
background: transparent !important;
}
:global(.prose-content::-webkit-scrollbar-thumb) {
background-color: rgba(156, 163, 175, 0.5) !important;
border-radius: 4px !important;
}
:global(.prose-content::-webkit-scrollbar-thumb:hover) {
background-color: rgba(156, 163, 175, 0.7) !important;
}
:global(.tab-content) {
position: relative;
display: flex;
flex-direction: column;
}
</style>

125
src/routes/contact/ContactForm.svelte

@ -1,125 +0,0 @@ @@ -1,125 +0,0 @@
<script lang='ts'>
import { P, Button, Label, Textarea, Input } from 'flowbite-svelte';
import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser';
import { createEventDispatcher } from 'svelte';
// Props for initial state
export let initialSubject = '';
export let initialContent = '';
// State
let subject = initialSubject;
let content = initialContent;
let isSubmitting = false;
let isExpanded = false;
let activeTab = 'write';
let submissionError = '';
const dispatch = createEventDispatcher();
function clearForm() {
subject = '';
content = '';
submissionError = '';
isExpanded = false;
activeTab = 'write';
}
function toggleSize() {
isExpanded = !isExpanded;
}
function handleSubmit(e: Event) {
e.preventDefault();
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
dispatch('submit', { subject, content });
}
</script>
<form class="contact-form" onsubmit={handleSubmit}>
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="contact-form-input" placeholder="Issue subject" bind:value={subject} required autofocus />
</div>
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="contact-form-textarea-container {isExpanded ? 'expanded' : 'collapsed'}">
<div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600">
<ul class="contact-form-tabs" role="tablist">
<li class="mr-2" role="presentation">
<button
type="button"
class="contact-form-tab {activeTab === 'write' ? 'active' : 'inactive'}"
onclick={() => activeTab = 'write'}
role="tab"
>
Write
</button>
</li>
<li role="presentation">
<button
type="button"
class="contact-form-tab {activeTab === 'preview' ? 'active' : 'inactive'}"
onclick={() => activeTab = 'preview'}
role="tab"
>
Preview
</button>
</li>
</ul>
</div>
<div class="flex-1 min-h-0 relative">
{#if activeTab === 'write'}
<div class="contact-form-tab-content">
<Textarea
id="content"
class="contact-form-textarea"
bind:value={content}
required
placeholder="Describe your issue in detail..."
/>
</div>
{:else}
<div class="contact-form-preview">
{#key content}
{#await parseAdvancedMarkdown(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="contact-form-toggle"
color="light"
onclick={toggleSize}
>
{isExpanded ? '⌃' : '⌄'}
</Button>
</div>
</div>
<div class="contact-form-actions">
<Button type="button" color="alternative" onclick={clearForm}>
Clear Form
</Button>
<Button type="submit" tabindex={0}>
Submit Issue
</Button>
</div>
{#if submissionError}
<div class="contact-form-error" role="alert">
{submissionError}
</div>
{/if}
</form>

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

@ -47,40 +47,40 @@ @@ -47,40 +47,40 @@
}
</script>
<div class="edit-container">
<main class="edit-main">
<Heading tag="h1" class="heading-1 mb-2">Edit</Heading>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'>
<Heading tag='h1' class='h-leather mb-2'>Edit</Heading>
{#if isEditing}
<form class="form-base">
<form>
<Textarea
id="article-content"
class="form-textarea"
id='article-content'
class='textarea-leather'
rows={8}
placeholder="Write AsciiDoc content"
placeholder='Write AsciiDoc content'
bind:value={editorText}
>
<Toolbar slot="header" embedded>
<ToolbarButton name="Preview" on:click={showPreview}>
<EyeSolid class="w-6 h-6" />
<Toolbar slot='header' embedded>
<ToolbarButton name='Preview' on:click={showPreview}>
<EyeSolid class='w-6 h-6' />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
<ToolbarButton name='Review' slot='end' on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' />
</ToolbarButton>
</Toolbar>
</Textarea>
</form>
{:else}
<form class="edit-preview-form">
<Toolbar class="edit-preview-toolbar">
<ToolbarButton name="Edit" on:click={hidePreview}>
<CodeOutline class="w-6 h-6" />
<form class='border border-gray-400 dark:border-gray-600 rounded-lg flex flex-col space-y-2 h-fit'>
<Toolbar class='toolbar-leather rounded-b-none bg-gray-200 dark:bg-gray-800'>
<ToolbarButton name='Edit' on:click={hidePreview}>
<CodeOutline class='w-6 h-6' />
</ToolbarButton>
<ToolbarButton name="Review" slot="end" on:click={prepareReview}>
<PaperPlaneOutline class="w=6 h-6 rotate-90" />
<ToolbarButton name='Review' slot='end' on:click={prepareReview}>
<PaperPlaneOutline class='w=6 h-6 rotate-90' />
</ToolbarButton>
</Toolbar>
{#if rootIndexId}
<Preview sectionClass="m-2" rootId={rootIndexId} />
<Preview sectionClass='m-2' rootId={rootIndexId} />
{/if}
</form>
{/if}

19
src/routes/visualize/+page.svelte

@ -115,14 +115,14 @@ @@ -115,14 +115,14 @@
});
</script>
<div class="visualize-container">
<div class="leather w-full p-4 relative">
<!-- Header with title and settings button -->
<div class="visualize-header">
<h1 class="heading-1">Publication Network</h1>
<div class="flex items-center mb-4">
<h1 class="h-leather">Publication Network</h1>
</div>
<!-- Loading spinner -->
{#if loading}
<div class="visualize-loading">
<div class="flex justify-center items-center h-64">
<div role="status">
<svg
aria-hidden="true"
@ -145,12 +145,15 @@ @@ -145,12 +145,15 @@
</div>
<!-- Error message -->
{:else if error}
<div class="visualize-error" role="alert">
<p class="heading-3">Error loading network:</p>
<p class="text-secondary mb-3">{error}</p>
<div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
role="alert"
>
<p class="font-bold mb-2">Error loading network:</p>
<p class="mb-3">{error}</p>
<button
type="button"
class="btn-base text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
on:click={fetchEvents}
>
Retry

Loading…
Cancel
Save