Browse Source

implement asciidoc articles

imwald
Silberengel 5 months ago
parent
commit
9b4facff1e
  1. 597
      package-lock.json
  2. 12
      package.json
  3. 212
      src/components/Note/Article/index.tsx
  4. 9
      src/components/Note/DiscussionContent/index.tsx
  5. 143
      src/components/Note/PublicationCard.tsx
  6. 167
      src/components/Note/WikiCard.tsx
  7. 34
      src/components/Note/index.tsx
  8. 12
      src/components/Profile/ProfileArticles.tsx
  9. 13
      src/components/ReplyNote/index.tsx
  10. 216
      src/components/UniversalContent/EnhancedContent.tsx
  11. 209
      src/components/UniversalContent/ParsedContent.tsx
  12. 10
      src/constants.ts
  13. 154
      src/hooks/useContentParser.tsx
  14. 79
      src/lib/article-media.ts
  15. 73
      src/lib/markdown-cleanup.ts
  16. 100
      src/lib/markup-detection.ts
  17. 640
      src/services/content-parser.service.ts

597
package-lock.json generated

@ -1,14 +1,15 @@ @@ -1,14 +1,15 @@
{
"name": "jumble-imwald",
"version": "10.14.0",
"version": "10.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
"version": "10.14.0",
"version": "10.15",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -42,7 +43,6 @@ @@ -42,7 +43,6 @@
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"asciidoctor": "^2.2.8",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
@ -59,7 +59,7 @@ @@ -59,7 +59,7 @@
"highlight.js": "^11.9.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.9",
"katex": "^0.16.25",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
@ -74,7 +74,9 @@ @@ -74,7 +74,9 @@
"react-katex": "^3.0.1",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
@ -127,39 +129,72 @@ @@ -127,39 +129,72 @@
"node": ">=6.0.0"
}
},
"node_modules/@asciidoctor/cli": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@asciidoctor/cli/-/cli-3.5.0.tgz",
"integrity": "sha512-/VMHXcZBnZ9vgWfmqk9Hu0x0gMjPLup0YGq/xA8qCQuk11kUIZNMVQwgSsIUzOEwJqIUD7CgncJdtfwv1Ndxuw==",
"node_modules/@asciidoctor/core": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-3.0.4.tgz",
"integrity": "sha512-41SDMi7iRRBViPe0L6VWFTe55bv6HEOJeRqMj5+E5wB1YPdUPuTucL4UAESPZM6OWmn4t/5qM5LusXomFUVwVQ==",
"license": "MIT",
"dependencies": {
"yargs": "16.2.0"
},
"bin": {
"asciidoctor": "bin/asciidoctor",
"asciidoctorjs": "bin/asciidoctor"
"@asciidoctor/opal-runtime": "3.0.1",
"unxhr": "1.2.0"
},
"engines": {
"node": ">=8.11",
"npm": ">=5.0.0"
"node": ">=16",
"npm": ">=8"
}
},
"node_modules/@asciidoctor/opal-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@asciidoctor/opal-runtime/-/opal-runtime-3.0.1.tgz",
"integrity": "sha512-iW7ACahOG0zZft4A/4CqDcc7JX+fWRNjV5tFAVkNCzwZD+EnFolPaUOPYt8jzadc0+Bgd80cQTtRMQnaaV1kkg==",
"license": "MIT",
"dependencies": {
"glob": "8.1.0",
"unxhr": "1.2.0"
},
"peerDependencies": {
"@asciidoctor/core": "^2.0.0-rc.1"
"engines": {
"node": ">=16"
}
},
"node_modules/@asciidoctor/core": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-2.2.8.tgz",
"integrity": "sha512-oozXk7ZO1RAd/KLFLkKOhqTcG4GO3CV44WwOFg2gMcCsqCUTarvMT7xERIoWW2WurKbB0/ce+98r01p8xPOlBw==",
"node_modules/@asciidoctor/opal-runtime/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"asciidoctor-opal-runtime": "0.3.3",
"unxhr": "1.0.1"
"balanced-match": "^1.0.0"
}
},
"node_modules/@asciidoctor/opal-runtime/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@asciidoctor/opal-runtime/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=8.11",
"npm": ">=5.0.0",
"yarn": ">=1.1.0"
"node": ">=10"
}
},
"node_modules/@babel/code-frame": {
@ -4807,6 +4842,12 @@ @@ -4807,6 +4842,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/katex": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@ -5281,56 +5322,6 @@ @@ -5281,56 +5322,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asciidoctor": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/asciidoctor/-/asciidoctor-2.2.8.tgz",
"integrity": "sha512-G+sDYWnNo+QHRkIvN5k7ASbvrd2bHuNXHlZ83+PjVFYtl0//as5iebq+Bdf3aSwXrkM7akcEJPUpdTjjP0MgYw==",
"license": "MIT",
"dependencies": {
"@asciidoctor/cli": "3.5.0",
"@asciidoctor/core": "2.2.8"
},
"bin": {
"asciidoctor": "bin/asciidoctor",
"asciidoctorjs": "bin/asciidoctor"
},
"engines": {
"node": ">=8.11",
"npm": ">=5.0.0",
"yarn": ">=1.1.0"
}
},
"node_modules/asciidoctor-opal-runtime": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/asciidoctor-opal-runtime/-/asciidoctor-opal-runtime-0.3.3.tgz",
"integrity": "sha512-/CEVNiOia8E5BMO9FLooo+Kv18K4+4JBFRJp8vUy/N5dMRAg+fRNV4HA+o6aoSC79jVU/aT5XvUpxSxSsTS8FQ==",
"license": "MIT",
"dependencies": {
"glob": "7.1.3",
"unxhr": "1.0.1"
},
"engines": {
"node": ">=8.11"
}
},
"node_modules/asciidoctor-opal-runtime/node_modules/glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
"integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -5504,6 +5495,7 @@ @@ -5504,6 +5495,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@ -5774,75 +5766,6 @@ @@ -5774,75 +5766,6 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -6317,7 +6240,8 @@ @@ -6317,7 +6240,8 @@
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/convert-source-map": {
"version": "2.0.0",
@ -6876,6 +6800,7 @@ @@ -6876,6 +6800,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -7355,15 +7280,6 @@ @@ -7355,15 +7280,6 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
@ -7610,6 +7526,101 @@ @@ -7610,6 +7526,101 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
"license": "ISC",
"dependencies": {
"@types/hast": "^3.0.0",
"hastscript": "^9.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html-isomorphic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-dom": "^5.0.0",
"hast-util-from-html": "^2.0.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@ -7636,6 +7647,22 @@ @@ -7636,6 +7647,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unist-util-find-after": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@ -7648,6 +7675,23 @@ @@ -7648,6 +7675,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
@ -8751,6 +8795,25 @@ @@ -8751,6 +8795,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-math": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"longest-streak": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.1.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@ -9067,6 +9130,25 @@ @@ -9067,6 +9130,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-math": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
"license": "MIT",
"dependencies": {
"@types/katex": "^0.16.0",
"devlop": "^1.0.0",
"katex": "^0.16.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@ -9437,6 +9519,7 @@ @@ -9437,6 +9519,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -9737,6 +9820,30 @@ @@ -9737,6 +9820,30 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -9750,6 +9857,7 @@ @@ -9750,6 +9857,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -10584,6 +10692,25 @@ @@ -10584,6 +10692,25 @@
"node": ">=6"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/katex": "^0.16.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.0",
"katex": "^0.16.0",
"unist-util-visit-parents": "^6.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@ -10601,6 +10728,22 @@ @@ -10601,6 +10728,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-math": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-math": "^3.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@ -10646,15 +10789,6 @@ @@ -10646,15 +10789,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -11823,6 +11957,20 @@ @@ -11823,6 +11957,20 @@
"node": ">=8"
}
},
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@ -11847,6 +11995,20 @@ @@ -11847,6 +11995,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-remove-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@ -11896,9 +12058,9 @@ @@ -11896,9 +12058,9 @@
}
},
"node_modules/unxhr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.0.1.tgz",
"integrity": "sha512-MAhukhVHyaLGDjyDYhy8gVjWJyhTECCdNsLwlMoGFoNJ3o79fpQhtQuzmAE4IxCMDwraF4cW8ZjpAV0m9CRQbg==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.2.0.tgz",
"integrity": "sha512-6cGpm8NFXPD9QbSNx0cD2giy7teZ6xOkCUH3U89WKVkL9N9rBrWjlCwhR94Re18ZlAop4MOc3WU1M3Hv/bgpIw==",
"license": "MIT",
"engines": {
"node": ">=8.11"
@ -12033,6 +12195,20 @@ @@ -12033,6 +12195,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@ -12195,6 +12371,16 @@ @@ -12195,6 +12371,16 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@ -12757,15 +12943,6 @@ @@ -12757,15 +12943,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -12783,74 +12960,6 @@ @@ -12783,74 +12960,6 @@
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yet-another-react-lightbox": {
"version": "3.21.7",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.7.tgz",

12
package.json

@ -19,6 +19,7 @@ @@ -19,6 +19,7 @@
"preview": "vite preview"
},
"dependencies": {
"@asciidoctor/core": "^3.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -65,8 +66,10 @@ @@ -65,8 +66,10 @@
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"highlight.js": "^11.9.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.25",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
@ -78,20 +81,19 @@ @@ -78,20 +81,19 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tippy.js": "^6.3.7",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1",
"asciidoctor": "^2.2.8",
"katex": "^0.16.9",
"react-katex": "^3.0.1",
"highlight.js": "^11.9.0"
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

212
src/components/Note/Article/index.tsx

@ -0,0 +1,212 @@ @@ -0,0 +1,212 @@
import { useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import ImageGallery from '@/components/ImageGallery'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import NostrNode from '../LongFormArticle/NostrNode'
import { remarkNostr } from '../LongFormArticle/remarkNostr'
import { Components } from '../LongFormArticle/types'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
import 'katex/dist/katex.min.css'
export default function Article({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
enableSyntaxHighlighting: true
})
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (href?.startsWith('nostr:')) {
return <NostrNode rawText={href} bech32Id={href.slice(6)} />
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
{...props}
>
{children}
<ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if paragraph contains only an image
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
return <div {...props} />
}
return <p {...props} className="break-words" />
},
div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0 max-w-[400px]"
classNames={{
wrapper: 'w-fit max-w-[400px]'
}}
/>
)
}) as Components,
[event.pubkey]
)
if (isLoading) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div>Loading content...</div>
</div>
)
}
if (error) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div className="text-red-500">Error loading content: {error.message}</div>
</div>
)
}
if (!parsedContent) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div>No content available</div>
</div>
)
}
return (
<div className={`${parsedContent.cssClasses} ${className || ''}`}>
{/* Article metadata */}
<h1 className="break-words">{metadata.title}</h1>
{metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-[3/1] object-cover my-0"
/>
)}
{/* Render content based on markup type */}
{parsedContent.markupType === 'asciidoc' ? (
// AsciiDoc content (already processed to HTML)
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
) : (
// Markdown content (let react-markdown handle it)
<Markdown
remarkPlugins={[remarkGfm, remarkMath, remarkNostr]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{event.content}
</Markdown>
)}
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* Media thumbnails */}
{parsedContent.media.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{parsedContent.links.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this article:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Nostr links summary */}
{parsedContent.nostrLinks.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

9
src/components/Note/DiscussionContent/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Event } from 'nostr-tools'
import SimpleContent from '../../UniversalContent/SimpleContent'
import ParsedContent from '../../UniversalContent/ParsedContent'
export default function DiscussionContent({
event,
@ -9,9 +9,14 @@ export default function DiscussionContent({ @@ -9,9 +9,14 @@ export default function DiscussionContent({
className?: string
}) {
return (
<SimpleContent
<ParsedContent
event={event}
field="content"
className={className}
showMedia={true}
showLinks={false}
showHashtags={true}
showNostrLinks={false}
/>
)
}

143
src/components/Note/PublicationCard.tsx

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { BookOpen } from 'lucide-react'
import Image from '../Image'
export default function PublicationCard({
event,
className
}: {
event: Event
className?: string
}) {
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
// Generate naddr for Alexandria URL
const naddr = useMemo(() => {
try {
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || ''
const relays = event.tags
.filter(tag => tag[0] === 'relay')
.map(tag => tag[1])
.filter(Boolean)
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
relays: relays.length > 0 ? relays : undefined
})
} catch (error) {
console.error('Error generating naddr:', error)
return ''
}
}, [event])
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event.id))
}
const handleAlexandriaClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (naddr) {
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer')
}
}
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{metadata.tags.map((tag) => (
<div
key={tag}
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)
const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div>
)
const alexandriaButton = naddr && (
<button
onClick={handleAlexandriaClick}
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-md transition-colors"
>
<BookOpen className="w-4 h-4" />
View in Alexandria
</button>
)
if (isSmallScreen) {
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-video mb-3"
hideIfError
/>
)}
<div className="space-y-2">
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
{alexandriaButton}
</div>
</div>
</div>
</div>
)
}
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
<div className="flex gap-4">
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-2">
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
{alexandriaButton}
</div>
</div>
</div>
</div>
</div>
)
}

167
src/components/Note/WikiCard.tsx

@ -0,0 +1,167 @@ @@ -0,0 +1,167 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { BookOpen, Globe } from 'lucide-react'
import Image from '../Image'
export default function WikiCard({
event,
className
}: {
event: Event
className?: string
}) {
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
// Extract d-tag for Wikistr URL
const dTag = useMemo(() => {
return event.tags.find(tag => tag[0] === 'd')?.[1] || ''
}, [event])
// Generate naddr for Alexandria URL
const naddr = useMemo(() => {
try {
const relays = event.tags
.filter(tag => tag[0] === 'relay')
.map(tag => tag[1])
.filter(Boolean)
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
relays: relays.length > 0 ? relays : undefined
})
} catch (error) {
console.error('Error generating naddr:', error)
return ''
}
}, [event, dTag])
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event.id))
}
const handleWikistrClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (dTag) {
window.open(`https://wikistr.imwald.eu/${dTag}*${event.pubkey}`, '_blank', 'noopener,noreferrer')
}
}
const handleAlexandriaClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (naddr) {
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer')
}
}
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{metadata.tags.map((tag) => (
<div
key={tag}
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)
const summaryComponent = metadata.summary && (
<div className="text-sm text-muted-foreground line-clamp-4">{metadata.summary}</div>
)
const buttons = (
<div className="flex gap-2 flex-wrap">
{dTag && (
<button
onClick={handleWikistrClick}
className="flex items-center gap-2 px-3 py-2 text-sm bg-green-100 hover:bg-green-200 dark:bg-green-900 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded-md transition-colors"
>
<Globe className="w-4 h-4" />
View in Wikistr
</button>
)}
{naddr && (
<button
onClick={handleAlexandriaClick}
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-md transition-colors"
>
<BookOpen className="w-4 h-4" />
View in Alexandria
</button>
)}
</div>
)
if (isSmallScreen) {
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] aspect-video mb-3"
hideIfError
/>
)}
<div className="space-y-2">
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
{buttons}
</div>
</div>
</div>
</div>
)
}
return (
<div className={className}>
<div
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors"
onClick={handleCardClick}
>
<div className="flex gap-4">
{metadata.image && autoLoadMedia && (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-2">
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
{buttons}
</div>
</div>
</div>
</div>
</div>
)
}

34
src/components/Note/index.tsx

@ -10,7 +10,7 @@ import { Event, kinds } from 'nostr-tools' @@ -10,7 +10,7 @@ import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
import ClientTag from '../ClientTag'
import Content from '../Content'
import EnhancedContent from '../UniversalContent/EnhancedContent'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
@ -26,8 +26,10 @@ import Highlight from './Highlight' @@ -26,8 +26,10 @@ import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle'
import LongFormArticlePreview from './LongFormArticlePreview'
import Article from './Article'
import PublicationCard from './PublicationCard'
import WikiCard from './WikiCard'
import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote'
@ -98,10 +100,30 @@ export default function Note({ @@ -98,10 +100,30 @@ export default function Note({
}
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
<LongFormArticle className="mt-2" event={event} />
<Article className="mt-2" event={event} />
) : (
<LongFormArticlePreview className="mt-2" event={event} />
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
content = showFull ? (
<Article className="mt-2" event={event} />
) : (
<WikiCard className="mt-2" event={event} />
)
} else if (event.kind === ExtendedKind.WIKI_CHAPTER) {
content = showFull ? (
<Article className="mt-2" event={event} />
) : (
<div className="mt-2 p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">Wiki Chapter (part of publication)</div>
</div>
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? (
<Article className="mt-2" event={event} />
) : (
<PublicationCard className="mt-2" event={event} />
)
} else if (event.kind === kinds.LiveEvent) {
content = <LiveEvent className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
@ -120,7 +142,7 @@ export default function Note({ @@ -120,7 +142,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
<Content className="mt-2" event={event} />
<EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
<Poll className="mt-2" event={event} />
</>
)
@ -133,11 +155,11 @@ export default function Note({ @@ -133,11 +155,11 @@ export default function Note({
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = <Content className="mt-2" event={event} />
content = <EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
content = <EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
}
return (

12
src/components/Profile/ProfileArticles.tsx

@ -80,18 +80,18 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps @@ -80,18 +80,18 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps
const comprehensiveRelays = await buildComprehensiveRelayList()
console.log('[ProfileArticles] Using comprehensive relay list:', comprehensiveRelays.length, 'relays')
// Fetch longform articles (kind 30023) and highlights (kind 9802)
// Fetch longform articles (kind 30023), wiki articles (kind 30818), publications (kind 30040), and highlights (kind 9802)
const allEvents = await client.fetchEvents(comprehensiveRelays, {
authors: [pubkey],
kinds: [kinds.LongFormArticle, kinds.Highlights], // LongFormArticle and Highlights
kinds: [kinds.LongFormArticle, 30818, 30040, kinds.Highlights], // LongFormArticle, WikiArticle, Publication, and Highlights
limit: 100
})
console.log('[ProfileArticles] Fetched total events:', allEvents.length)
console.log('[ProfileArticles] Sample events:', allEvents.slice(0, 3).map(e => ({ id: e.id, kind: e.kind, content: e.content.substring(0, 50) + '...', tags: e.tags.slice(0, 3) })))
// Show ALL events (both longform articles and highlights)
console.log('[ProfileArticles] Showing all events (articles + highlights):', allEvents.length)
// Show ALL events (longform articles, wiki articles, publications, and highlights)
console.log('[ProfileArticles] Showing all events (articles + publications + highlights):', allEvents.length)
console.log('[ProfileArticles] Events sample:', allEvents.slice(0, 2).map(e => ({ id: e.id, kind: e.kind, content: e.content.substring(0, 50) + '...' })))
const eventsToShow = allEvents
@ -204,7 +204,7 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps @@ -204,7 +204,7 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps
if (events.length === 0) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No articles or highlights found</div>
<div className="text-sm text-muted-foreground">No articles, publications, or highlights found</div>
</div>
)
}
@ -212,7 +212,7 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps @@ -212,7 +212,7 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps
if (filteredEvents.length === 0 && searchQuery.trim()) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No articles or highlights match your search</div>
<div className="text-sm text-muted-foreground">No articles, publications, or highlights match your search</div>
</div>
)
}

13
src/components/ReplyNote/index.tsx

@ -11,7 +11,7 @@ import { useMemo, useState } from 'react' @@ -11,7 +11,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import Collapsible from '../Collapsible'
import Content from '../Content'
import EnhancedContent from '../UniversalContent/EnhancedContent'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
@ -55,7 +55,14 @@ export default function ReplyNote({ @@ -55,7 +55,14 @@ export default function ReplyNote({
return (
<div
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`}
onClick={() => navigateToNote(toNote(event))}
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return
}
navigateToNote(toNote(event))
}}
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
@ -96,7 +103,7 @@ export default function ReplyNote({ @@ -96,7 +103,7 @@ export default function ReplyNote({
/>
)}
{show ? (
<Content className="mt-2" event={event} />
<EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
) : (
<Button
variant="outline"

216
src/components/UniversalContent/EnhancedContent.tsx

@ -0,0 +1,216 @@ @@ -0,0 +1,216 @@
/**
* Enhanced content component that uses the content parser service
* while maintaining compatibility with existing embedded content
*/
import { useTranslatedEvent } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedNote,
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ParsedContent from './ParsedContent'
export default function EnhancedContent({
event,
content,
className,
mustLoadMedia,
useEnhancedParsing = false
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
useEnhancedParsing?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
// If enhanced parsing is enabled and we have an event, use the new parser
if (useEnhancedParsing && event) {
return (
<ParsedContent
event={event}
field="content"
className={className}
showMedia={true}
showLinks={false}
showHashtags={false}
showNostrLinks={false}
/>
)
}
// Fallback to original parsing logic
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return {}
const nodes = parseContent(_content, [
EmbeddedUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
// Always ensure we have a valid image info object
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(node.data)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
if (parsedImeta) {
return parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(node.data), pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
if (imageInfo) {
return imageInfo
}
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
if (parsedImeta) {
return parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(url), pubkey: event?.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImetaInfo[]
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
if (!nodes || nodes.length === 0) {
return null
}
let imageIndex = 0
logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) })
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : node.data })
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
)
}
if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
}

209
src/components/UniversalContent/ParsedContent.tsx

@ -0,0 +1,209 @@ @@ -0,0 +1,209 @@
/**
* Universal content component that uses the content parser service
* for all Nostr content fields
*/
import { useEventFieldParser } from '@/hooks/useContentParser'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import { Components } from '../Note/LongFormArticle/types'
import NostrNode from '../Note/LongFormArticle/NostrNode'
import ImageWithLightbox from '../ImageWithLightbox'
import ImageGallery from '../ImageGallery'
import { ExternalLink } from 'lucide-react'
import WebPreview from '../WebPreview'
import 'katex/dist/katex.min.css'
interface ParsedContentProps {
event: Event
field: 'content' | 'title' | 'summary' | 'description'
className?: string
enableMath?: boolean
enableSyntaxHighlighting?: boolean
showMedia?: boolean
showLinks?: boolean
showHashtags?: boolean
showNostrLinks?: boolean
maxImageWidth?: string
}
export default function ParsedContent({
event,
field,
className = '',
enableMath = true,
enableSyntaxHighlighting = true,
showMedia = true,
showLinks = false,
showHashtags = false,
showNostrLinks = false,
maxImageWidth = '400px'
}: ParsedContentProps) {
const { parsedContent, isLoading, error } = useEventFieldParser(event, field, {
enableMath,
enableSyntaxHighlighting
})
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (href?.startsWith('nostr:')) {
return <NostrNode rawText={href} bech32Id={href.slice(6)} />
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
{...props}
>
{children}
<ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if paragraph contains only an image
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
return <div {...props} />
}
return <p {...props} className="break-words" />
},
div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className={`max-h-[80vh] sm:max-h-[50vh] object-contain my-0`}
classNames={{
wrapper: 'w-fit'
}}
/>
)
}) as Components,
[event.pubkey, maxImageWidth]
)
if (isLoading) {
return (
<div className={`animate-pulse ${className}`}>
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
)
}
if (error) {
return (
<div className={`text-red-500 text-sm ${className}`}>
Error loading content: {error.message}
</div>
)
}
if (!parsedContent) {
return (
<div className={`text-muted-foreground text-sm ${className}`}>
No content available
</div>
)
}
return (
<div className={`${parsedContent.cssClasses} ${className}`}>
{/* Render content based on markup type */}
{parsedContent.markupType === 'asciidoc' ? (
// AsciiDoc content (already processed to HTML)
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
) : (
// Markdown content (let react-markdown handle it)
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{field === 'content' ? event.content : event.tags?.find(tag => tag[0] === field)?.[1] || ''}
</Markdown>
)}
{/* Media thumbnails */}
{showMedia && parsedContent.media.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this content:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{showLinks && parsedContent.links.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this content:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Hashtags */}
{showHashtags && parsedContent.hashtags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* Nostr links summary */}
{showNostrLinks && parsedContent.nostrLinks.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

10
src/constants.ts

@ -127,7 +127,10 @@ export const ExtendedKind = { @@ -127,7 +127,10 @@ export const ExtendedKind = {
RELAY_REVIEW: 31987,
GROUP_METADATA: 39000,
ZAP_REQUEST: 9734,
ZAP_RECEIPT: 9735
ZAP_RECEIPT: 9735,
PUBLICATION: 30040,
WIKI_ARTICLE: 30818,
WIKI_CHAPTER: 30041
}
export const SUPPORTED_KINDS = [
@ -145,7 +148,10 @@ export const SUPPORTED_KINDS = [ @@ -145,7 +148,10 @@ export const SUPPORTED_KINDS = [
kinds.LongFormArticle,
ExtendedKind.RELAY_REVIEW,
ExtendedKind.DISCUSSION,
ExtendedKind.ZAP_RECEIPT
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_CHAPTER
]
export const URL_REGEX =

154
src/hooks/useContentParser.tsx

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
/**
* React hook for content parsing
*/
import { useState, useEffect } from 'react'
import { Event } from 'nostr-tools'
import { contentParserService, ParsedContent, ParseOptions } from '@/services/content-parser.service'
export interface UseContentParserOptions extends ParseOptions {
autoParse?: boolean
}
export interface UseContentParserReturn {
parsedContent: ParsedContent | null
isLoading: boolean
error: Error | null
parse: () => Promise<void>
}
/**
* Hook for parsing content with automatic detection and processing
*/
export function useContentParser(
content: string,
options: UseContentParserOptions = {}
): UseContentParserReturn {
const { autoParse = true, ...parseOptions } = options
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const parse = async () => {
if (!content.trim()) {
setParsedContent(null)
return
}
try {
setIsLoading(true)
setError(null)
const result = await contentParserService.parseContent(content, parseOptions)
setParsedContent(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedContent(null)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [content, autoParse, JSON.stringify(parseOptions)])
return {
parsedContent,
isLoading,
error,
parse
}
}
/**
* Hook for parsing Nostr event fields
*/
export function useEventFieldParser(
event: Event,
field: 'content' | 'title' | 'summary' | 'description',
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {}
): UseContentParserReturn {
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const { autoParse = true, ...parseOptions } = options
const parse = async () => {
try {
setIsLoading(true)
setError(null)
const result = await contentParserService.parseEventField(event, field, parseOptions)
setParsedContent(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedContent(null)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [event.id, field, autoParse, JSON.stringify(parseOptions)])
return {
parsedContent,
isLoading,
error,
parse
}
}
/**
* Hook for parsing multiple event fields at once
*/
export function useEventFieldsParser(
event: Event,
fields: Array<'content' | 'title' | 'summary' | 'description'>,
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {}
) {
const [parsedFields, setParsedFields] = useState<Record<string, ParsedContent | null>>({})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const { autoParse = true, ...parseOptions } = options
const parse = async () => {
try {
setIsLoading(true)
setError(null)
const results: Record<string, ParsedContent | null> = {}
for (const field of fields) {
const result = await contentParserService.parseEventField(event, field, parseOptions)
results[field] = result
}
setParsedFields(results)
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown parsing error'))
setParsedFields({})
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (autoParse) {
parse()
}
}, [event.id, JSON.stringify(fields), autoParse, JSON.stringify(parseOptions)])
return {
parsedFields,
isLoading,
error,
parse
}
}

79
src/lib/article-media.ts

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
import { getImetaInfosFromEvent } from './event'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
/**
* Extract all media URLs from an article event
*/
export function extractArticleMedia(event: Event): TImetaInfo[] {
const media: TImetaInfo[] = []
const seenUrls = new Set<string>()
// Extract from imeta tags
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach(imeta => {
if (!seenUrls.has(imeta.url)) {
seenUrls.add(imeta.url)
media.push(imeta)
}
})
// Extract from metadata tags
const imageTag = event.tags.find(tag => tag[0] === 'image')?.[1]
if (imageTag && !seenUrls.has(imageTag)) {
seenUrls.add(imageTag)
media.push({
url: imageTag,
pubkey: event.pubkey
})
}
// Extract URLs from content (image/video extensions)
const contentUrls = extractUrlsFromContent(event.content)
contentUrls.forEach(url => {
if (!seenUrls.has(url)) {
seenUrls.add(url)
media.push({
url,
pubkey: event.pubkey
})
}
})
return media
}
/**
* Extract URLs from content that look like media files
*/
function extractUrlsFromContent(content: string): string[] {
const urls: string[] = []
// Match URLs in content
const urlRegex = /https?:\/\/[^\s<>"']+/g
const matches = content.match(urlRegex) || []
matches.forEach(url => {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname.toLowerCase()
// Check if it's a media file
const mediaExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv',
'.mp3', '.wav', '.flac', '.aac', '.m4a'
]
const isMediaFile = mediaExtensions.some(ext => pathname.endsWith(ext))
if (isMediaFile) {
urls.push(url)
}
} catch {
// Invalid URL, skip
}
})
return urls
}

73
src/lib/markdown-cleanup.ts

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
/**
* Markdown cleanup utility for leftover markdown syntax after Asciidoc rendering
*/
export function cleanupMarkdown(html: string): string {
let cleaned = html
// Clean up markdown image syntax: ![alt](url)
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
const altText = alt || ''
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />`
})
// Clean up markdown link syntax: [text](url)
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
// Check if it's already an HTML link
if (cleaned.includes(`href="${url}"`)) {
return _match
}
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>`
})
// Clean up markdown table syntax
cleaned = cleanupMarkdownTables(cleaned)
return cleaned
}
function cleanupMarkdownTables(html: string): string {
// Simple markdown table detection and conversion
const tableRegex = /(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g
return html.replace(tableRegex, (match) => {
const lines = match.trim().split('\n').filter(line => line.trim())
if (lines.length < 2) return match
const headerRow = lines[0]
const separatorRow = lines[1]
const dataRows = lines.slice(2)
// Check if it's actually a table (has separator row with dashes)
if (!separatorRow.includes('-')) return match
const headers = headerRow.split('|').map(cell => cell.trim()).filter(cell => cell)
const rows = dataRows.map(row =>
row.split('|').map(cell => cell.trim()).filter(cell => cell)
)
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">\n'
// Header
tableHtml += ' <thead>\n <tr>\n'
headers.forEach(header => {
tableHtml += ` <th class="border border-gray-300 px-4 py-2 bg-gray-50 font-semibold text-left">${header}</th>\n`
})
tableHtml += ' </tr>\n </thead>\n'
// Body
tableHtml += ' <tbody>\n'
rows.forEach(row => {
tableHtml += ' <tr>\n'
row.forEach((cell, index) => {
const tag = index < headers.length ? 'td' : 'td'
tableHtml += ` <${tag} class="border border-gray-300 px-4 py-2">${cell}</${tag}>\n`
})
tableHtml += ' </tr>\n'
})
tableHtml += ' </tbody>\n'
tableHtml += '</table>'
return tableHtml
})
}

100
src/lib/markup-detection.ts

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
/**
* Markup detection and processing utilities
*/
export type MarkupType = 'asciidoc' | 'advanced-markdown' | 'basic-markdown' | 'plain-text'
/**
* Detect the type of markup used in content
*/
export function detectMarkupType(content: string, eventKind?: number): MarkupType {
// Publications and wikis use AsciiDoc
if (eventKind === 30040 || eventKind === 30041 || eventKind === 30818) {
return 'asciidoc'
}
// Check for AsciiDoc syntax patterns
const asciidocPatterns = [
/^=+\s/, // Headers: = Title, == Section
/^\*+\s/, // Lists: * item
/^\.+\s/, // Lists: . item
/^\[\[/, // Cross-references: [[ref]]
/^<</, // Cross-references: <<ref>>
/^include::/, // Includes: include::file[]
/^image::/, // Images: image::url[alt,width]
/^link:/, // Links: link:url[text]
/^footnote:/, // Footnotes: footnote:[text]
/^NOTE:/, // Admonitions: NOTE:, TIP:, WARNING:, etc.
/^TIP:/,
/^WARNING:/,
/^IMPORTANT:/,
/^CAUTION:/,
/^\[source,/, // Source blocks: [source,javascript]
/^----/, // Delimited blocks: ----, ++++, etc.
/^\+\+\+\+/,
/^\|\|/, // Tables: || cell ||
/^\[\[.*\]\]/, // Wikilinks: [[NIP-54]]
]
const hasAsciidocSyntax = asciidocPatterns.some(pattern => pattern.test(content.trim()))
if (hasAsciidocSyntax) {
return 'asciidoc'
}
// Check for advanced Markdown features
const advancedMarkdownPatterns = [
/```[\s\S]*?```/, // Code blocks
/`[^`]+`/, // Inline code
/^\|.*\|.*\|/, // Tables
/\[\^[\w\d]+\]/, // Footnotes: [^1]
/\[\^[\w\d]+\]:/, // Footnote references: [^1]:
/\[\[[\w\-\s]+\]\]/, // Wikilinks: [[NIP-54]]
]
const hasAdvancedMarkdown = advancedMarkdownPatterns.some(pattern => pattern.test(content))
if (hasAdvancedMarkdown) {
return 'advanced-markdown'
}
// Check for basic Markdown features
const basicMarkdownPatterns = [
/^#+\s/, // Headers: # Title
/^\*\s/, // Lists: * item
/^\d+\.\s/, // Ordered lists: 1. item
/\[.*?\]\(.*?\)/, // Links: [text](url)
/!\[.*?\]\(.*?\)/, // Images: ![alt](url)
/^\>\s/, // Blockquotes: > text
/\*.*?\*/, // Bold: *text*
/_.*?_/, // Italic: _text_
/~.*?~/, // Strikethrough: ~text~
/#[\w]+/, // Hashtags: #hashtag
/:[\w]+:/, // Emoji: :smile:
]
const hasBasicMarkdown = basicMarkdownPatterns.some(pattern => pattern.test(content))
if (hasBasicMarkdown) {
return 'basic-markdown'
}
return 'plain-text'
}
/**
* Get the appropriate CSS classes for the detected markup type
*/
export function getMarkupClasses(markupType: MarkupType): string {
const baseClasses = "prose prose-zinc max-w-none dark:prose-invert break-words"
switch (markupType) {
case 'asciidoc':
return `${baseClasses} asciidoc-content`
case 'advanced-markdown':
return `${baseClasses} markdown-content advanced`
case 'basic-markdown':
return `${baseClasses} markdown-content basic`
case 'plain-text':
return `${baseClasses} plain-text`
default:
return baseClasses
}
}

640
src/services/content-parser.service.ts

@ -0,0 +1,640 @@ @@ -0,0 +1,640 @@
/**
* Comprehensive content parsing service for all Nostr content fields
* Supports AsciiDoc, Advanced Markdown, Basic Markdown, and LaTeX
*/
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection'
import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX } from '@/constants'
import { TImetaInfo } from '@/types'
export interface ParsedContent {
html: string
markupType: MarkupType
cssClasses: string
hasMath: boolean
media: TImetaInfo[]
links: Array<{ url: string; text: string; isExternal: boolean }>
hashtags: string[]
nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }>
}
export interface ParseOptions {
eventKind?: number
field?: 'content' | 'title' | 'summary' | 'description'
maxWidth?: string
enableMath?: boolean
enableSyntaxHighlighting?: boolean
}
class ContentParserService {
private asciidoctor: any = null
private isAsciidoctorLoaded = false
/**
* Initialize AsciiDoctor (lazy loading)
*/
private async loadAsciidoctor() {
if (this.isAsciidoctorLoaded) return this.asciidoctor
try {
const Asciidoctor = await import('@asciidoctor/core')
this.asciidoctor = Asciidoctor.default()
this.isAsciidoctorLoaded = true
return this.asciidoctor
} catch (error) {
console.warn('Failed to load AsciiDoctor:', error)
return null
}
}
/**
* Parse content with appropriate markup processor
*/
async parseContent(
content: string,
options: ParseOptions = {},
event?: Event
): Promise<ParsedContent> {
const {
eventKind,
enableMath = true,
enableSyntaxHighlighting = true
} = options
// Detect markup type
const markupType = detectMarkupType(content, eventKind)
const cssClasses = getMarkupClasses(markupType)
// Extract all content elements
const media = this.extractAllMedia(content, event)
const links = this.extractLinks(content)
const hashtags = this.extractHashtags(content)
const nostrLinks = this.extractNostrLinks(content)
// Check for LaTeX math
const hasMath = enableMath && this.hasMathContent(content)
let html = ''
let processedContent = content
try {
switch (markupType) {
case 'asciidoc':
html = await this.parseAsciidoc(content, { enableMath, enableSyntaxHighlighting })
break
case 'advanced-markdown':
processedContent = this.preprocessAdvancedMarkdown(content)
html = await this.parseAdvancedMarkdown(processedContent, { enableMath, enableSyntaxHighlighting })
break
case 'basic-markdown':
processedContent = this.preprocessBasicMarkdown(content)
html = await this.parseBasicMarkdown(processedContent)
break
case 'plain-text':
default:
html = this.parsePlainText(content)
break
}
} catch (error) {
console.error('Content parsing error:', error)
// Fallback to plain text
html = this.parsePlainText(content)
}
return {
html,
markupType,
cssClasses,
hasMath,
media,
links,
hashtags,
nostrLinks
}
}
/**
* Parse AsciiDoc content
*/
private async parseAsciidoc(content: string, options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise<string> {
const asciidoctor = await this.loadAsciidoctor()
if (!asciidoctor) {
return this.parsePlainText(content)
}
try {
const result = asciidoctor.convert(content, {
safe: 'safe',
backend: 'html5',
doctype: 'article',
attributes: {
'showtitle': true,
'sectanchors': true,
'sectlinks': true,
'source-highlighter': options.enableSyntaxHighlighting ? 'highlight.js' : 'none',
'stem': options.enableMath ? 'latexmath' : 'none'
}
})
const htmlString = typeof result === 'string' ? result : result.toString()
// Clean up any leftover markdown syntax
return this.cleanupMarkdown(htmlString)
} catch (error) {
console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content)
}
}
/**
* Parse advanced Markdown content
*/
private async parseAdvancedMarkdown(content: string, _options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise<string> {
// This will be handled by react-markdown with plugins
// Return the processed content for react-markdown to handle
return content
}
/**
* Parse basic Markdown content
*/
private parseBasicMarkdown(content: string): string {
// Basic markdown processing
let processed = content
// Headers
processed = processed.replace(/^### (.*$)/gim, '<h3>$1</h3>')
processed = processed.replace(/^## (.*$)/gim, '<h2>$1</h2>')
processed = processed.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold and italic
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>')
processed = processed.replace(/_(.*?)_/g, '<em>$1</em>')
processed = processed.replace(/~(.*?)~/g, '<del>$1</del>')
// Links and images
processed = this.processLinks(processed)
processed = this.processImages(processed)
// Lists
processed = this.processLists(processed)
// Blockquotes
processed = processed.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
// Line breaks
processed = processed.replace(/\n\n/g, '</p><p>')
processed = `<p>${processed}</p>`
return processed
}
/**
* Parse plain text content
*/
private parsePlainText(content: string): string {
// Convert line breaks to HTML
return content
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>')
.replace(/$/, '</p>')
}
/**
* Preprocess advanced Markdown content
*/
private preprocessAdvancedMarkdown(content: string): string {
// Handle wikilinks: [[NIP-54]] -> [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54)
content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, text) => {
const slug = text.toLowerCase().replace(/\s+/g, '-')
return `[${text}](https://next-alexandria.gitcitadel.eu/publication?d=${slug})`
})
// Handle hashtags: #hashtag -> [#hashtag](/hashtag/hashtag)
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
return `[#${tag}](/hashtag/${tag})`
})
return content
}
/**
* Preprocess basic Markdown content
*/
private preprocessBasicMarkdown(content: string): string {
// Handle hashtags
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
return `[#${tag}](/hashtag/${tag})`
})
// Handle emoji shortcodes
content = content.replace(/:([a-zA-Z0-9_]+):/g, (_match, _emoji) => {
// This would need an emoji mapping - for now just return as-is
return _match
})
return content
}
/**
* Process markdown links
*/
private processLinks(content: string): string {
return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
// Check if it's already an HTML link
if (content.includes(`href="${url}"`)) {
return match
}
// Handle nostr: prefixes
if (url.startsWith('nostr:')) {
return `<span class="nostr-link" data-nostr="${url}">${text}</span>`
}
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>`
})
}
/**
* Process markdown images
*/
private processImages(content: string): string {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
const altText = alt || ''
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />`
})
}
/**
* Process markdown lists
*/
private processLists(content: string): string {
// Unordered lists
content = content.replace(/^[\s]*\* (.+)$/gm, '<li>$1</li>')
content = content.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
// Ordered lists
content = content.replace(/^[\s]*\d+\. (.+)$/gm, '<li>$1</li>')
content = content.replace(/(<li>.*<\/li>)/s, '<ol>$1</ol>')
return content
}
/**
* Clean up leftover markdown syntax after AsciiDoc processing
*/
private cleanupMarkdown(html: string): string {
let cleaned = html
// Clean up markdown image syntax: ![alt](url)
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
const altText = alt || ''
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />`
})
// Clean up markdown link syntax: [text](url)
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
// Check if it's already an HTML link
if (cleaned.includes(`href="${url}"`)) {
return _match
}
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>`
})
// Clean up markdown table syntax
cleaned = this.cleanupMarkdownTables(cleaned)
return cleaned
}
/**
* Clean up markdown tables
*/
private cleanupMarkdownTables(html: string): string {
const tableRegex = /(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g
return html.replace(tableRegex, (match) => {
const lines = match.trim().split('\n').filter(line => line.trim())
if (lines.length < 2) return match
const headerRow = lines[0]
const separatorRow = lines[1]
const dataRows = lines.slice(2)
// Check if it's actually a table (has separator row with dashes)
if (!separatorRow.includes('-')) return match
const headers = headerRow.split('|').map(cell => cell.trim()).filter(cell => cell)
const rows = dataRows.map(row =>
row.split('|').map(cell => cell.trim()).filter(cell => cell)
)
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">\n'
// Header
tableHtml += ' <thead>\n <tr>\n'
headers.forEach(header => {
tableHtml += ` <th class="border border-gray-300 px-4 py-2 bg-gray-50 font-semibold text-left">${header}</th>\n`
})
tableHtml += ' </tr>\n </thead>\n'
// Body
tableHtml += ' <tbody>\n'
rows.forEach(row => {
tableHtml += ' <tr>\n'
row.forEach((cell, index) => {
const tag = index < headers.length ? 'td' : 'td'
tableHtml += ` <${tag} class="border border-gray-300 px-4 py-2">${cell}</${tag}>\n`
})
tableHtml += ' </tr>\n'
})
tableHtml += ' </tbody>\n'
tableHtml += '</table>'
return tableHtml
})
}
/**
* Extract all media from content and event
*/
private extractAllMedia(content: string, event?: Event): TImetaInfo[] {
const media: TImetaInfo[] = []
const seenUrls = new Set<string>()
// 1. Extract from imeta tags if event is provided
if (event) {
const imetaMedia = getImetaInfosFromEvent(event)
imetaMedia.forEach(item => {
if (!seenUrls.has(item.url)) {
media.push(item)
seenUrls.add(item.url)
}
})
}
// 2. Extract from markdown images: ![alt](url)
const imageMatches = content.match(/!\[[^\]]*\]\(([^)]+)\)/g) || []
imageMatches.forEach(match => {
const url = match.match(/!\[[^\]]*\]\(([^)]+)\)/)?.[1]
if (url && !seenUrls.has(url)) {
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
media.push({
url,
pubkey: event?.pubkey || '',
m: isVideo ? 'video/*' : 'image/*'
})
seenUrls.add(url)
}
})
// 3. Extract from asciidoc images: image::url[alt,width]
const asciidocImageMatches = content.match(/image::([^\[]+)\[/g) || []
asciidocImageMatches.forEach(match => {
const url = match.match(/image::([^\[]+)\[/)?.[1]
if (url && !seenUrls.has(url)) {
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
media.push({
url,
pubkey: event?.pubkey || '',
m: isVideo ? 'video/*' : 'image/*'
})
seenUrls.add(url)
}
})
// 4. Extract raw URLs from content
const rawUrls = content.match(URL_REGEX) || []
rawUrls.forEach(url => {
if (!seenUrls.has(url)) {
const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
if (isImage || isVideo) {
media.push({
url,
pubkey: event?.pubkey || '',
m: isVideo ? 'video/*' : 'image/*'
})
seenUrls.add(url)
}
}
})
return media
}
/**
* Extract all links from content
*/
private extractLinks(content: string): Array<{ url: string; text: string; isExternal: boolean }> {
const links: Array<{ url: string; text: string; isExternal: boolean }> = []
const seenUrls = new Set<string>()
// Extract markdown links: [text](url)
const markdownLinks = content.match(/\[([^\]]+)\]\(([^)]+)\)/g) || []
markdownLinks.forEach(_match => {
const linkMatch = _match.match(/\[([^\]]+)\]\(([^)]+)\)/)
if (linkMatch) {
const [, text, url] = linkMatch
if (!seenUrls.has(url)) {
links.push({
url,
text,
isExternal: this.isExternalUrl(url)
})
seenUrls.add(url)
}
}
})
// Extract asciidoc links: link:url[text]
const asciidocLinks = content.match(/link:([^\[]+)\[([^\]]+)\]/g) || []
asciidocLinks.forEach(_match => {
const linkMatch = _match.match(/link:([^\[]+)\[([^\]]+)\]/)
if (linkMatch) {
const [, url, text] = linkMatch
if (!seenUrls.has(url)) {
links.push({
url,
text,
isExternal: this.isExternalUrl(url)
})
seenUrls.add(url)
}
}
})
// Extract raw URLs
const rawUrls = content.match(URL_REGEX) || []
rawUrls.forEach(url => {
if (!seenUrls.has(url) && !this.isNostrUrl(url)) {
links.push({
url,
text: url,
isExternal: this.isExternalUrl(url)
})
seenUrls.add(url)
}
})
return links
}
/**
* Extract hashtags from content
*/
private extractHashtags(content: string): string[] {
const hashtags: string[] = []
const seenTags = new Set<string>()
// Extract hashtags: #hashtag
const hashtagMatches = content.match(/#([a-zA-Z0-9_]+)/g) || []
hashtagMatches.forEach(_match => {
const tag = _match.substring(1) // Remove #
if (!seenTags.has(tag)) {
hashtags.push(tag)
seenTags.add(tag)
}
})
return hashtags
}
/**
* Extract Nostr links from content
*/
private extractNostrLinks(content: string): Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> {
const nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }> = []
// Extract nostr: prefixed links
const nostrMatches = content.match(/nostr:([a-z0-9]+[a-z0-9]{6,})/g) || []
nostrMatches.forEach(_match => {
const id = _match.substring(6) // Remove 'nostr:'
const type = this.getNostrType(id)
if (type) {
nostrLinks.push({
type,
id,
text: _match
})
}
})
// Extract raw nostr identifiers
const rawNostrMatches = content.match(/([a-z0-9]+[a-z0-9]{6,})/g) || []
rawNostrMatches.forEach(_match => {
const type = this.getNostrType(_match)
if (type && !nostrLinks.some(link => link.id === _match)) {
nostrLinks.push({
type,
id: _match,
text: _match
})
}
})
return nostrLinks
}
/**
* Check if URL is external
*/
private isExternalUrl(url: string): boolean {
try {
const urlObj = new URL(url)
return urlObj.hostname !== window.location.hostname
} catch {
return true
}
}
/**
* Check if URL is a Nostr URL
*/
private isNostrUrl(url: string): boolean {
return url.startsWith('nostr:') || this.getNostrType(url) !== null
}
/**
* Get Nostr identifier type
*/
private getNostrType(id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' | null {
if (id.startsWith('npub')) return 'npub'
if (id.startsWith('nprofile')) return 'nprofile'
if (id.startsWith('nevent')) return 'nevent'
if (id.startsWith('naddr')) return 'naddr'
if (id.startsWith('note')) return 'note'
return null
}
/**
* Check if content has LaTeX math
*/
private hasMathContent(content: string): boolean {
// Check for inline math: $...$ or \(...\)
const inlineMath = /\$[^$]+\$|\\\([^)]+\\\)/.test(content)
// Check for block math: $$...$$ or \[...\]
const blockMath = /\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]/.test(content)
return inlineMath || blockMath
}
/**
* Parse content for a specific Nostr event field
*/
async parseEventField(
event: Event,
field: 'content' | 'title' | 'summary' | 'description',
options: Omit<ParseOptions, 'eventKind' | 'field'> = {}
): Promise<ParsedContent> {
const content = this.getFieldContent(event, field)
if (!content) {
return {
html: '',
markupType: 'plain-text',
cssClasses: getMarkupClasses('plain-text'),
hasMath: false,
media: [],
links: [],
hashtags: [],
nostrLinks: []
}
}
return this.parseContent(content, {
...options,
eventKind: event.kind,
field
}, event)
}
/**
* Get content from specific event field
*/
private getFieldContent(event: Event, field: 'content' | 'title' | 'summary' | 'description'): string {
switch (field) {
case 'content':
return event.content
case 'title':
return event.tags.find(tag => tag[0] === 'title')?.[1] || ''
case 'summary':
return event.tags.find(tag => tag[0] === 'summary')?.[1] || ''
case 'description':
return event.tags.find(tag => tag[0] === 'd')?.[1] || ''
default:
return ''
}
}
}
// Export singleton instance
export const contentParserService = new ContentParserService()
export default contentParserService
Loading…
Cancel
Save