diff --git a/package-lock.json b/package-lock.json
index 5d05c56..4478a70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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 @@
"@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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
"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 @@
}
},
"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 @@
"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 @@
"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 @@
"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 @@
"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",
diff --git a/package.json b/package.json
index bd19b6d..4d0609f 100644
--- a/package.json
+++ b/package.json
@@ -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 @@
"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 @@
"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",
diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx
new file mode 100644
index 0000000..a381085
--- /dev/null
+++ b/src/components/Note/Article/index.tsx
@@ -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 }) => ,
+ a: ({ href, children, ...props }) => {
+ if (href?.startsWith('nostr:')) {
+ return
+ }
+ return (
+
+ {children}
+
+
+ )
+ },
+ p: (props) => {
+ // Check if paragraph contains only an image
+ if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
+ return
+ }
+ return
+ },
+ div: (props) => ,
+ code: (props) => ,
+ img: (props) => (
+
+ )
+ }) as Components,
+ [event.pubkey]
+ )
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
Error loading content: {error.message}
+
+ )
+ }
+
+ if (!parsedContent) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Article metadata */}
+
{metadata.title}
+ {metadata.summary && (
+
+ {metadata.summary}
+
+ )}
+ {metadata.image && (
+
+ )}
+
+
+ {/* Render content based on markup type */}
+ {parsedContent.markupType === 'asciidoc' ? (
+ // AsciiDoc content (already processed to HTML)
+
+ ) : (
+ // Markdown content (let react-markdown handle it)
+
{
+ if (url.startsWith('nostr:')) {
+ return url.slice(6) // Remove 'nostr:' prefix for rendering
+ }
+ return url
+ }}
+ components={components}
+ >
+ {event.content}
+
+ )}
+
+ {/* Hashtags */}
+ {parsedContent.hashtags.length > 0 && (
+
+ {parsedContent.hashtags.map((tag) => (
+
{
+ e.stopPropagation()
+ push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
+ }}
+ >
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* Media thumbnails */}
+ {parsedContent.media.length > 0 && (
+
+
Images in this article:
+
+ {parsedContent.media.map((media, index) => (
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Links summary with OpenGraph previews */}
+ {parsedContent.links.length > 0 && (
+
+
Links in this article:
+
+ {parsedContent.links.map((link, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Nostr links summary */}
+ {parsedContent.nostrLinks.length > 0 && (
+
+
Nostr references:
+
+ {parsedContent.nostrLinks.map((link, index) => (
+
+ {link.type}:{' '}
+ {link.id}
+
+ ))}
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/Note/DiscussionContent/index.tsx b/src/components/Note/DiscussionContent/index.tsx
index 558f4dc..541049d 100644
--- a/src/components/Note/DiscussionContent/index.tsx
+++ b/src/components/Note/DiscussionContent/index.tsx
@@ -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({
className?: string
}) {
return (
-
)
}
diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx
new file mode 100644
index 0000000..b303fae
--- /dev/null
+++ b/src/components/Note/PublicationCard.tsx
@@ -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 = {metadata.title}
+
+ const tagsComponent = metadata.tags.length > 0 && (
+
+ {metadata.tags.map((tag) => (
+
{
+ e.stopPropagation()
+ push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
+ }}
+ >
+ #{tag}
+
+ ))}
+
+ )
+
+ const summaryComponent = metadata.summary && (
+ {metadata.summary}
+ )
+
+ const alexandriaButton = naddr && (
+
+ )
+
+ if (isSmallScreen) {
+ return (
+
+
+ {metadata.image && autoLoadMedia && (
+
+ )}
+
+ {titleComponent}
+ {summaryComponent}
+ {tagsComponent}
+
+ {alexandriaButton}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {metadata.image && autoLoadMedia && (
+
+ )}
+
+ {titleComponent}
+ {summaryComponent}
+ {tagsComponent}
+
+ {alexandriaButton}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Note/WikiCard.tsx b/src/components/Note/WikiCard.tsx
new file mode 100644
index 0000000..31db1fb
--- /dev/null
+++ b/src/components/Note/WikiCard.tsx
@@ -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 = {metadata.title}
+
+ const tagsComponent = metadata.tags.length > 0 && (
+
+ {metadata.tags.map((tag) => (
+
{
+ e.stopPropagation()
+ push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
+ }}
+ >
+ #{tag}
+
+ ))}
+
+ )
+
+ const summaryComponent = metadata.summary && (
+ {metadata.summary}
+ )
+
+ const buttons = (
+
+ {dTag && (
+
+ )}
+ {naddr && (
+
+ )}
+
+ )
+
+ if (isSmallScreen) {
+ return (
+
+
+ {metadata.image && autoLoadMedia && (
+
+ )}
+
+ {titleComponent}
+ {summaryComponent}
+ {tagsComponent}
+
+ {buttons}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {metadata.image && autoLoadMedia && (
+
+ )}
+
+ {titleComponent}
+ {summaryComponent}
+ {tagsComponent}
+
+ {buttons}
+
+
+
+
+
+ )
+}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 2e3d3e4..bed9430 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -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'
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({
}
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
-
+
) : (
)
+ } else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
+ content = showFull ? (
+
+ ) : (
+
+ )
+ } else if (event.kind === ExtendedKind.WIKI_CHAPTER) {
+ content = showFull ? (
+
+ ) : (
+
+
Wiki Chapter (part of publication)
+
+ )
+ } else if (event.kind === ExtendedKind.PUBLICATION) {
+ content = showFull ? (
+
+ ) : (
+
+ )
} else if (event.kind === kinds.LiveEvent) {
content =
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
@@ -120,7 +142,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
-
+
>
)
@@ -133,11 +155,11 @@ export default function Note({
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content =
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
- content =
+ content =
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content =
} else {
- content =
+ content =
}
return (
diff --git a/src/components/Profile/ProfileArticles.tsx b/src/components/Profile/ProfileArticles.tsx
index 9cedb86..94370bb 100644
--- a/src/components/Profile/ProfileArticles.tsx
+++ b/src/components/Profile/ProfileArticles.tsx
@@ -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
if (events.length === 0) {
return (
-
No articles or highlights found
+
No articles, publications, or highlights found
)
}
@@ -212,7 +212,7 @@ const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps
if (filteredEvents.length === 0 && searchQuery.trim()) {
return (
-
No articles or highlights match your search
+
No articles, publications, or highlights match your search
)
}
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx
index 9cbeb2e..8ea32d8 100644
--- a/src/components/ReplyNote/index.tsx
+++ b/src/components/ReplyNote/index.tsx
@@ -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({
return (
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))
+ }}
>
@@ -96,7 +103,7 @@ export default function ReplyNote({
/>
)}
{show ? (
-
+
) : (
+ )
+ }
+
+ // 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 (
+
+ {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 (
+
+ )
+ }
+ if (node.type === 'media') {
+ return (
+
+ )
+ }
+ if (node.type === 'url') {
+ return
+ }
+ if (node.type === 'invoice') {
+ return
+ }
+ if (node.type === 'websocket-url') {
+ return
+ }
+ if (node.type === 'event') {
+ const id = node.data.split(':')[1]
+ return
+ }
+ if (node.type === 'mention') {
+ return
+ }
+ if (node.type === 'hashtag') {
+ return
+ }
+ if (node.type === 'emoji') {
+ const shortcode = node.data.split(':')[1]
+ const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
+ if (!emoji) return node.data
+ return
+ }
+ if (node.type === 'youtube') {
+ return (
+
+ )
+ }
+ return null
+ })}
+ {lastNormalUrl && }
+
+ )
+}
diff --git a/src/components/UniversalContent/ParsedContent.tsx b/src/components/UniversalContent/ParsedContent.tsx
new file mode 100644
index 0000000..27d56ce
--- /dev/null
+++ b/src/components/UniversalContent/ParsedContent.tsx
@@ -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 }) =>
,
+ a: ({ href, children, ...props }) => {
+ if (href?.startsWith('nostr:')) {
+ return
+ }
+ return (
+
+ {children}
+
+
+ )
+ },
+ p: (props) => {
+ // Check if paragraph contains only an image
+ if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
+ return
+ }
+ return
+ },
+ div: (props) =>
,
+ code: (props) =>
,
+ img: (props) => (
+
+ )
+ }) as Components,
+ [event.pubkey, maxImageWidth]
+ )
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ Error loading content: {error.message}
+
+ )
+ }
+
+ if (!parsedContent) {
+ return (
+
+ No content available
+
+ )
+ }
+
+ return (
+
+ {/* Render content based on markup type */}
+ {parsedContent.markupType === 'asciidoc' ? (
+ // AsciiDoc content (already processed to HTML)
+
+ ) : (
+ // Markdown content (let react-markdown handle it)
+
{
+ 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] || ''}
+
+ )}
+
+ {/* Media thumbnails */}
+ {showMedia && parsedContent.media.length > 0 && (
+
+
Images in this content:
+
+ {parsedContent.media.map((media, index) => (
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Links summary with OpenGraph previews */}
+ {showLinks && parsedContent.links.length > 0 && (
+
+
Links in this content:
+
+ {parsedContent.links.map((link, index) => (
+
+ ))}
+
+
+ )}
+
+ {/* Hashtags */}
+ {showHashtags && parsedContent.hashtags.length > 0 && (
+
+ {parsedContent.hashtags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* Nostr links summary */}
+ {showNostrLinks && parsedContent.nostrLinks.length > 0 && (
+
+
Nostr references:
+
+ {parsedContent.nostrLinks.map((link, index) => (
+
+ {link.type}:{' '}
+ {link.id}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/constants.ts b/src/constants.ts
index 6ba7e0a..d069f16 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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 = [
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 =
diff --git a/src/hooks/useContentParser.tsx b/src/hooks/useContentParser.tsx
new file mode 100644
index 0000000..0ebd050
--- /dev/null
+++ b/src/hooks/useContentParser.tsx
@@ -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
+}
+
+/**
+ * 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(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(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 = {}
+): UseContentParserReturn {
+ const [parsedContent, setParsedContent] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(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 = {}
+) {
+ const [parsedFields, setParsedFields] = useState>({})
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const { autoParse = true, ...parseOptions } = options
+
+ const parse = async () => {
+ try {
+ setIsLoading(true)
+ setError(null)
+
+ const results: Record = {}
+
+ 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
+ }
+}
diff --git a/src/lib/article-media.ts b/src/lib/article-media.ts
new file mode 100644
index 0000000..12f50c4
--- /dev/null
+++ b/src/lib/article-media.ts
@@ -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()
+
+ // 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
+}
diff --git a/src/lib/markdown-cleanup.ts b/src/lib/markdown-cleanup.ts
new file mode 100644
index 0000000..b19d830
--- /dev/null
+++ b/src/lib/markdown-cleanup.ts
@@ -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: 
+ cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
+ const altText = alt || ''
+ return `
`
+ })
+
+ // 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 `${text} `
+ })
+
+ // 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 = '\n'
+
+ // Header
+ tableHtml += ' \n \n'
+ headers.forEach(header => {
+ tableHtml += ` | ${header} | \n`
+ })
+ tableHtml += '
\n \n'
+
+ // Body
+ tableHtml += ' \n'
+ rows.forEach(row => {
+ tableHtml += ' \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 += '
\n'
+ })
+ tableHtml += ' \n'
+ tableHtml += '
'
+
+ return tableHtml
+ })
+}
diff --git a/src/lib/markup-detection.ts b/src/lib/markup-detection.ts
new file mode 100644
index 0000000..8bf333b
--- /dev/null
+++ b/src/lib/markup-detection.ts
@@ -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: <[>
+ /^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: 
+ /^\>\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
+ }
+}
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
new file mode 100644
index 0000000..802b2da
--- /dev/null
+++ b/src/services/content-parser.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ // 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, ']$1
')
+ processed = processed.replace(/^## (.*$)/gim, '$1
')
+ processed = processed.replace(/^# (.*$)/gim, '$1
')
+
+ // Bold and italic
+ processed = processed.replace(/\*\*(.*?)\*\*/g, '$1')
+ processed = processed.replace(/\*(.*?)\*/g, '$1')
+ processed = processed.replace(/_(.*?)_/g, '$1')
+ processed = processed.replace(/~(.*?)~/g, '$1')
+
+ // Links and images
+ processed = this.processLinks(processed)
+ processed = this.processImages(processed)
+
+ // Lists
+ processed = this.processLists(processed)
+
+ // Blockquotes
+ processed = processed.replace(/^> (.*$)/gim, '$1
')
+
+ // Line breaks
+ processed = processed.replace(/\n\n/g, '')
+ processed = `
${processed}
`
+
+ return processed
+ }
+
+ /**
+ * Parse plain text content
+ */
+ private parsePlainText(content: string): string {
+ // Convert line breaks to HTML
+ return content
+ .replace(/\n\n/g, '')
+ .replace(/\n/g, '
')
+ .replace(/^/, '
')
+ .replace(/$/, '
')
+ }
+
+ /**
+ * 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 `${text}`
+ }
+
+ return `${text} `
+ })
+ }
+
+ /**
+ * Process markdown images
+ */
+ private processImages(content: string): string {
+ return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
+ const altText = alt || ''
+ return `
`
+ })
+ }
+
+ /**
+ * Process markdown lists
+ */
+ private processLists(content: string): string {
+ // Unordered lists
+ content = content.replace(/^[\s]*\* (.+)$/gm, '$1')
+ content = content.replace(/(.*<\/li>)/s, '')
+
+ // Ordered lists
+ content = content.replace(/^[\s]*\d+\. (.+)$/gm, '$1')
+ content = content.replace(/(.*<\/li>)/s, '$1
')
+
+ return content
+ }
+
+ /**
+ * Clean up leftover markdown syntax after AsciiDoc processing
+ */
+ private cleanupMarkdown(html: string): string {
+ let cleaned = html
+
+ // Clean up markdown image syntax: 
+ cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
+ const altText = alt || ''
+ return `
`
+ })
+
+ // 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 `${text} `
+ })
+
+ // 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 = '\n'
+
+ // Header
+ tableHtml += ' \n \n'
+ headers.forEach(header => {
+ tableHtml += ` | ${header} | \n`
+ })
+ tableHtml += '
\n \n'
+
+ // Body
+ tableHtml += ' \n'
+ rows.forEach(row => {
+ tableHtml += ' \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 += '
\n'
+ })
+ tableHtml += ' \n'
+ tableHtml += '
'
+
+ return tableHtml
+ })
+ }
+
+ /**
+ * Extract all media from content and event
+ */
+ private extractAllMedia(content: string, event?: Event): TImetaInfo[] {
+ const media: TImetaInfo[] = []
+ const seenUrls = new Set()
+
+ // 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: 
+ 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()
+
+ // 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()
+
+ // 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 = {}
+ ): Promise {
+ 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