- {#if !user.signedIn}
+ {#if !$userPubkey}
- Please sign in to post comments. Your comments will be signed with your current account.
+ Please sign in to post comments. Your comments will be signed with your
+ current account.
{/if}
-{/if}
\ No newline at end of file
+
diff --git a/src/lib/components/RelayDisplay.svelte b/src/lib/components/RelayDisplay.svelte
index 1161f7c..f717d42 100644
--- a/src/lib/components/RelayDisplay.svelte
+++ b/src/lib/components/RelayDisplay.svelte
@@ -1,14 +1,16 @@
-
`;
+ } catch (error) {
+ console.warn("Failed to process TikZ fallback block:", error);
+ return match;
+ }
+ }
+ return match;
+ },
+ );
+ return html;
+}
+
+/**
+ * Escapes HTML characters for safe display
+ */
+function escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/src/lib/utils/markup/advancedMarkupParser.ts b/src/lib/utils/markup/advancedMarkupParser.ts
index 9273857..2e4721f 100644
--- a/src/lib/utils/markup/advancedMarkupParser.ts
+++ b/src/lib/utils/markup/advancedMarkupParser.ts
@@ -1,13 +1,29 @@
-import { parseBasicmarkup } from './basicMarkupParser';
-import hljs from 'highlight.js';
-import 'highlight.js/lib/common'; // Import common languages
-import 'highlight.js/styles/github-dark.css'; // Dark theme only
+import { parseBasicmarkup } from "./basicMarkupParser";
+import hljs from "highlight.js";
+import "highlight.js/lib/common"; // Import common languages
+import "highlight.js/styles/github-dark.css"; // Dark theme only
// Register common languages
hljs.configure({
- ignoreUnescapedHTML: true
+ ignoreUnescapedHTML: true,
});
+// Escapes HTML characters for safe display
+function escapeHtml(text: string): string {
+ const div = typeof document !== 'undefined' ? document.createElement('div') : null;
+ if (div) {
+ div.textContent = text;
+ return div.innerHTML;
+ }
+ // Fallback for non-browser environments
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
// Regular expressions for advanced markup elements
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
@@ -17,18 +33,28 @@ const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
const CODE_BLOCK_REGEX = /^```(\w*)$/;
+// LaTeX math regex patterns
+const INLINE_MATH_REGEX = /\$([^$\n]+?)\$/g;
+const DISPLAY_MATH_REGEX = /\$\$([\s\S]*?)\$\$/g;
+const LATEX_BLOCK_REGEX = /\\\[([\s\S]*?)\\\]/g;
+const LATEX_INLINE_REGEX = /\\\(([^)]+?)\\\)/g;
+// Add regex for LaTeX display math environments (e.g., \begin{pmatrix}...\end{pmatrix})
+// Improved regex: match optional whitespace/linebreaks before and after, and allow for indented environments
+const LATEX_ENV_BLOCK_REGEX =
+ /(?:^|\n)\s*\\begin\{([a-zA-Z*]+)\}([\s\S]*?)\\end\{\1\}\s*(?=\n|$)/gm;
+
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Tailwind classes for each heading level
const headingClasses = [
- 'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
- 'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
- 'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
- 'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
- 'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
- 'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
+ "text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h1
+ "text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h2
+ "text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h3
+ "text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h4
+ "text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h5
+ "text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300", // h6
];
// Process ATX-style headings (# Heading)
@@ -39,11 +65,14 @@ function processHeadings(content: string): string {
});
// Process Setext-style headings (Heading\n====)
- processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
- const headingLevel = level[0] === '=' ? 1 : 2;
- const classes = headingClasses[headingLevel - 1];
- return `${text.trim()}`;
- });
+ processedContent = processedContent.replace(
+ ALTERNATE_HEADING_REGEX,
+ (_, text, level) => {
+ const headingLevel = level[0] === "=" ? 1 : 2;
+ const classes = headingClasses[headingLevel - 1];
+ return `${text.trim()}`;
+ },
+ );
return processedContent;
}
@@ -53,29 +82,30 @@ function processHeadings(content: string): string {
*/
function processTables(content: string): string {
try {
- if (!content) return '';
-
+ if (!content) return "";
+
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
- const rows = match.split('\n').filter(row => row.trim());
+ const rows = match.split("\n").filter((row) => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
- .split('|')
+ .split("|")
.slice(1, -1) // Remove empty cells from start/end
- .map(cell => cell.trim());
+ .map((cell) => cell.trim());
};
// Check if second row is a delimiter row (only hyphens)
- const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
-
+ const hasHeader =
+ rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
+
// Extract header and body rows
let headerCells: string[] = [];
let bodyRows: string[] = [];
-
+
if (hasHeader) {
// If we have a header, first row is header, skip delimiter, rest is body
headerCells = processCells(rows[0]);
@@ -91,33 +121,33 @@ function processTables(content: string): string {
// Add header if exists
if (hasHeader) {
- html += '\n
\n';
- headerCells.forEach(cell => {
+ html += "\n
\n";
+ headerCells.forEach((cell) => {
html += `
${cell}
\n`;
});
- html += '
\n\n';
+ html += "
\n\n";
}
// Add body
- html += '\n';
- bodyRows.forEach(row => {
+ html += "\n";
+ bodyRows.forEach((row) => {
const cells = processCells(row);
- html += '
\n';
- cells.forEach(cell => {
+ html += "
\n";
+ cells.forEach((cell) => {
html += `
${cell}
\n`;
});
- html += '
\n';
+ html += "\n";
});
- html += '\n\n
';
+ html += "\n\n
";
return html;
} catch (e: unknown) {
- console.error('Error processing table row:', e);
+ console.error("Error processing table row:", e);
return match;
}
});
} catch (e: unknown) {
- console.error('Error in processTables:', e);
+ console.error("Error in processTables:", e);
return content;
}
}
@@ -126,8 +156,9 @@ function processTables(content: string): string {
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
- return content.replace(HORIZONTAL_RULE_REGEX,
- ''
+ return content.replace(
+ HORIZONTAL_RULE_REGEX,
+ '',
);
}
@@ -136,7 +167,7 @@ function processHorizontalRules(content: string): string {
*/
function processFootnotes(content: string): string {
try {
- if (!content) return '';
+ if (!content) return "";
// Collect all footnote definitions (but do not remove them from the text yet)
const footnotes = new Map();
@@ -146,48 +177,57 @@ function processFootnotes(content: string): string {
});
// Remove all footnote definition lines from the main content
- let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, '');
+ let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, "");
// Track all references to each footnote
- const referenceOrder: { id: string, refNum: number, label: string }[] = [];
+ const referenceOrder: { id: string; refNum: number; label: string }[] = [];
const referenceMap = new Map(); // id -> [refNum, ...]
let globalRefNum = 1;
- processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
- if (!footnotes.has(id)) {
- console.warn(`Footnote reference [^${id}] found but no definition exists`);
- return match;
- }
- const refNum = globalRefNum++;
- if (!referenceMap.has(id)) referenceMap.set(id, []);
- referenceMap.get(id)!.push(refNum);
- referenceOrder.push({ id, refNum, label: id });
- return `[${refNum}]`;
- });
+ processedContent = processedContent.replace(
+ FOOTNOTE_REFERENCE_REGEX,
+ (match, id) => {
+ if (!footnotes.has(id)) {
+ console.warn(
+ `Footnote reference [^${id}] found but no definition exists`,
+ );
+ return match;
+ }
+ const refNum = globalRefNum++;
+ if (!referenceMap.has(id)) referenceMap.set(id, []);
+ referenceMap.get(id)!.push(refNum);
+ referenceOrder.push({ id, refNum, label: id });
+ return `[${refNum}]`;
+ },
+ );
// Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) {
- processedContent += '\n\n
Footnotes
\n\n';
+ processedContent +=
+ '\n\n
Footnotes
\n\n';
// Only include each unique footnote once, in order of first reference
const seen = new Set();
for (const { id, label } of referenceOrder) {
if (seen.has(id)) continue;
seen.add(id);
- const text = footnotes.get(id) || '';
+ const text = footnotes.get(id) || "";
// List of backrefs for this footnote
const refs = referenceMap.get(id) || [];
- const backrefs = refs.map((num, i) =>
- `↩${num}`
- ).join(' ');
+ const backrefs = refs
+ .map(
+ (num, i) =>
+ `↩${num}`,
+ )
+ .join(" ");
// If label is not a number, show it after all backrefs
- const labelSuffix = isNaN(Number(label)) ? ` ${label}` : '';
+ const labelSuffix = isNaN(Number(label)) ? ` ${label}` : "";
processedContent += `
${text} ${backrefs}${labelSuffix}
\n`;
}
- processedContent += '';
+ processedContent += "";
}
return processedContent;
} catch (error) {
- console.error('Error processing footnotes:', error);
+ console.error("Error processing footnotes:", error);
return content;
}
}
@@ -198,15 +238,15 @@ function processFootnotes(content: string): string {
function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
-
+
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
- .split('\n')
- .map(line => line.replace(/^>[ \t]?/, ''))
- .join('\n')
+ .split("\n")
+ .map((line) => line.replace(/^>[ \t]?/, ""))
+ .join("\n")
.trim();
-
+
return `
${text}
`;
});
}
@@ -214,20 +254,23 @@ function processBlockquotes(content: string): string {
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
-function processCodeBlocks(text: string): { text: string; blocks: Map } {
- const lines = text.split('\n');
+function processCodeBlocks(text: string): {
+ text: string;
+ blocks: Map;
+} {
+ const lines = text.split("\n");
const processedLines: string[] = [];
const blocks = new Map();
let inCodeBlock = false;
let currentCode: string[] = [];
- let currentLanguage = '';
+ let currentLanguage = "";
let blockCount = 0;
let lastWasCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(CODE_BLOCK_REGEX);
-
+
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
@@ -239,36 +282,39 @@ function processCodeBlocks(text: string): { text: string; blocks: Map 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
- const code = currentCode.join('\n');
-
+ const code = currentCode.join("\n");
+
// Try to format JSON if specified
let formattedCode = code;
- if (currentLanguage.toLowerCase() === 'json') {
+ if (currentLanguage.toLowerCase() === "json") {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e: unknown) {
formattedCode = code;
}
}
-
- blocks.set(id, JSON.stringify({
- code: formattedCode,
- language: currentLanguage,
- raw: true
- }));
- processedLines.push('');
+
+ blocks.set(
+ id,
+ JSON.stringify({
+ code: formattedCode,
+ language: currentLanguage,
+ raw: true,
+ }),
+ );
+ processedLines.push("");
processedLines.push(id);
- processedLines.push('');
+ processedLines.push("");
}
return {
- text: processedLines.join('\n'),
- blocks
+ text: processedLines.join("\n"),
+ blocks,
};
}
@@ -312,22 +361,22 @@ function processCodeBlocks(text: string): { text: string; blocks: Map): string {
let result = text;
-
+
for (const [id, blockData] of blocks) {
try {
const { code, language } = JSON.parse(blockData);
-
+
let html;
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, {
language,
- ignoreIllegals: true
+ ignoreIllegals: true,
}).value;
html = `
${highlighted}
`;
} catch (e: unknown) {
- console.warn('Failed to highlight code block:', e);
- html = `
${code}
`;
+ console.warn("Failed to highlight code block:", e);
+ html = `
',
+ );
}
}
return result;
}
+/**
+ * Process $...$ and $$...$$ math blocks: render as LaTeX if recognized, otherwise as AsciiMath
+ * This must run BEFORE any paragraph or inline code formatting.
+ */
+function processDollarMath(content: string): string {
+ // Display math: $$...$$ (multi-line, not empty)
+ content = content.replace(/\$\$([\s\S]*?\S[\s\S]*?)\$\$/g, (match, expr) => {
+ if (isLaTeXContent(expr)) {
+ return `
$$${expr}$$
`;
+ } else {
+ // Strip all $ or $$ from AsciiMath
+ const clean = expr.replace(/\$+/g, '').trim();
+ return `
${clean}
`;
+ }
+ });
+ // Inline math: $...$ (not empty, not just whitespace)
+ content = content.replace(/\$([^\s$][^$\n]*?)\$/g, (match, expr) => {
+ if (isLaTeXContent(expr)) {
+ return `$${expr}$`;
+ } else {
+ const clean = expr.replace(/\$+/g, '').trim();
+ return `${clean}`;
+ }
+ });
+ return content;
+}
+
+/**
+ * Process LaTeX math expressions only within inline code blocks
+ */
+function processMathExpressions(content: string): string {
+ // Only process LaTeX within inline code blocks (backticks)
+ return content.replace(INLINE_CODE_REGEX, (match, code) => {
+ const trimmedCode = code.trim();
+
+ // Check for unsupported LaTeX environments (like tabular) first
+ if (/\\begin\{tabular\}|\\\\begin\{tabular\}/.test(trimmedCode)) {
+ return `
+
+ Unrendered, as it is LaTeX typesetting, not a formula:
+
+
+ ${escapeHtml(trimmedCode)}
+
+
`;
+ }
+
+ // Check if the code contains LaTeX syntax
+ if (isLaTeXContent(trimmedCode)) {
+ // Detect LaTeX display math (\\[...\\])
+ if (/^\\\[[\s\S]*\\\]$/.test(trimmedCode)) {
+ // Remove the delimiters for rendering
+ const inner = trimmedCode.replace(/^\\\[|\\\]$/g, '');
+ return `
$$${inner}$$
`;
+ }
+ // Detect display math ($$...$$)
+ if (/^\$\$[\s\S]*\$\$$/.test(trimmedCode)) {
+ // Remove the delimiters for rendering
+ const inner = trimmedCode.replace(/^\$\$|\$\$$/g, '');
+ return `
$$${inner}$$
`;
+ }
+ // Detect inline math ($...$)
+ if (/^\$[\s\S]*\$$/.test(trimmedCode)) {
+ // Remove the delimiters for rendering
+ const inner = trimmedCode.replace(/^\$|\$$/g, '');
+ return `$${inner}$`;
+ }
+ // Default to inline math for any other LaTeX content
+ return `$${trimmedCode}$`;
+ } else {
+ // Check for edge cases that should remain as code, not math
+ // These patterns indicate code that contains dollar signs but is not math
+ const codePatterns = [
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/, // Variable assignment like "const price ="
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/, // Function call like "echo("
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\{/, // Object literal like "const obj = {"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\[/, // Array literal like "const arr = ["
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*, // JSX or HTML like "const element = <"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*`/, // Template literal like "const str = `"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*'/, // String literal like "const str = '"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*"/, // String literal like "const str = \""
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*;/, // Statement ending like "const x = 1;"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*$/, // Just a variable name
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Operator like "const x = 1 +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Two identifiers like "const price = amount"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Number like "const x = 1"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Complex expression like "const price = amount +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Three identifiers like "const price = amount + tax"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]/, // Two identifiers and number like "const price = amount + 1"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Identifier, number, operator like "const x = 1 +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Identifier, number, identifier like "const x = 1 + y"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[0-9]/, // Identifier, number, number like "const x = 1 + 2"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Complex like "const x = 1 + y"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Complex like "const x = 1 + 2"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + y +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + y + z"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + y + 2"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Very complex like "const x = 1 + 2 +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Very complex like "const x = 1 + 2 + y"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Very complex like "const x = 1 + 2 + 3"
+ // Additional patterns for JavaScript template literals and other code
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*`/, // Template literal assignment like "const str = `"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*'/, // String assignment like "const str = '"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*"/, // String assignment like "const str = \""
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]/, // Number assignment like "const x = 1"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Variable assignment like "const x = y"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[+\-*/%=<>!&|^~]/, // Assignment with operator like "const x = +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]/, // Assignment with variable and operator like "const x = y +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with two variables and operator like "const x = y + z"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]/, // Assignment with number and operator like "const x = 1 +"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[a-zA-Z_$][a-zA-Z0-9_$]*/, // Assignment with number, operator, variable like "const x = 1 + y"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with variable, operator, number like "const x = y + 1"
+ /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=\s*[0-9]\s*[+\-*/%=<>!&|^~]\s*[0-9]/, // Assignment with number, operator, number like "const x = 1 + 2"
+ ];
+
+ // If it matches code patterns, treat as regular code
+ if (codePatterns.some(pattern => pattern.test(trimmedCode))) {
+ const escapedCode = trimmedCode
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ return `${escapedCode}`;
+ }
+
+ // Return as regular inline code
+ const escapedCode = trimmedCode
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ return `${escapedCode}`;
+ }
+ });
+}
+
+/**
+ * Checks if content contains LaTeX syntax
+ */
+function isLaTeXContent(content: string): boolean {
+ const trimmed = content.trim();
+
+ // Check for simple math expressions first (like AsciiMath)
+ if (/^\$[^$]+\$$/.test(trimmed)) {
+ return true;
+ }
+
+ // Check for display math
+ if (/^\$\$[\s\S]*\$\$$/.test(trimmed)) {
+ return true;
+ }
+
+ // Check for LaTeX display math
+ if (/^\\\[[\s\S]*\\\]$/.test(trimmed)) {
+ return true;
+ }
+
+ // Check for LaTeX environments with double backslashes (like tabular)
+ if (/\\\\begin\{[^}]+\}/.test(trimmed) || /\\\\end\{[^}]+\}/.test(trimmed)) {
+ return true;
+ }
+
+ // Check for common LaTeX patterns
+ const latexPatterns = [
+ /\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
+ /\\\\[a-zA-Z]+/, // LaTeX commands with double backslashes like \\frac, \\sum, etc.
+ /\\[\(\)\[\]]/, // LaTeX delimiters like \(, \), \[, \]
+ /\\\\[\(\)\[\]]/, // LaTeX delimiters with double backslashes like \\(, \\), \\[, \\]
+ /\\\[[\s\S]*?\\\]/, // LaTeX display math \[ ... \]
+ /\\\\\[[\s\S]*?\\\\\]/, // LaTeX display math with double backslashes \\[ ... \\]
+ /\\begin\{/, // LaTeX environments
+ /\\\\begin\{/, // LaTeX environments with double backslashes
+ /\\end\{/, // LaTeX environments
+ /\\\\end\{/, // LaTeX environments with double backslashes
+ /\\begin\{array\}/, // LaTeX array environment
+ /\\\\begin\{array\}/, // LaTeX array environment with double backslashes
+ /\\end\{array\}/,
+ /\\\\end\{array\}/,
+ /\\begin\{matrix\}/, // LaTeX matrix environment
+ /\\\\begin\{matrix\}/, // LaTeX matrix environment with double backslashes
+ /\\end\{matrix\}/,
+ /\\\\end\{matrix\}/,
+ /\\begin\{bmatrix\}/, // LaTeX bmatrix environment
+ /\\\\begin\{bmatrix\}/, // LaTeX bmatrix environment with double backslashes
+ /\\end\{bmatrix\}/,
+ /\\\\end\{bmatrix\}/,
+ /\\begin\{pmatrix\}/, // LaTeX pmatrix environment
+ /\\\\begin\{pmatrix\}/, // LaTeX pmatrix environment with double backslashes
+ /\\end\{pmatrix\}/,
+ /\\\\end\{pmatrix\}/,
+ /\\begin\{tabular\}/, // LaTeX tabular environment
+ /\\\\begin\{tabular\}/, // LaTeX tabular environment with double backslashes
+ /\\end\{tabular\}/,
+ /\\\\end\{tabular\}/,
+ /\$\$/, // Display math delimiters
+ /\$[^$]+\$/, // Inline math delimiters
+ /\\text\{/, // LaTeX text command
+ /\\\\text\{/, // LaTeX text command with double backslashes
+ /\\mathrm\{/, // LaTeX mathrm command
+ /\\\\mathrm\{/, // LaTeX mathrm command with double backslashes
+ /\\mathbf\{/, // LaTeX bold command
+ /\\\\mathbf\{/, // LaTeX bold command with double backslashes
+ /\\mathit\{/, // LaTeX italic command
+ /\\\\mathit\{/, // LaTeX italic command with double backslashes
+ /\\sqrt/, // Square root
+ /\\\\sqrt/, // Square root with double backslashes
+ /\\frac/, // Fraction
+ /\\\\frac/, // Fraction with double backslashes
+ /\\sum/, // Sum
+ /\\\\sum/, // Sum with double backslashes
+ /\\int/, // Integral
+ /\\\\int/, // Integral with double backslashes
+ /\\lim/, // Limit
+ /\\\\lim/, // Limit with double backslashes
+ /\\infty/, // Infinity
+ /\\\\infty/, // Infinity with double backslashes
+ /\\alpha/, // Greek letters
+ /\\\\alpha/, // Greek letters with double backslashes
+ /\\beta/,
+ /\\\\beta/,
+ /\\gamma/,
+ /\\\\gamma/,
+ /\\delta/,
+ /\\\\delta/,
+ /\\theta/,
+ /\\\\theta/,
+ /\\lambda/,
+ /\\\\lambda/,
+ /\\mu/,
+ /\\\\mu/,
+ /\\pi/,
+ /\\\\pi/,
+ /\\sigma/,
+ /\\\\sigma/,
+ /\\phi/,
+ /\\\\phi/,
+ /\\omega/,
+ /\\\\omega/,
+ /\\partial/, // Partial derivative
+ /\\\\partial/, // Partial derivative with double backslashes
+ /\\nabla/, // Nabla
+ /\\\\nabla/, // Nabla with double backslashes
+ /\\cdot/, // Dot product
+ /\\\\cdot/, // Dot product with double backslashes
+ /\\times/, // Times
+ /\\\\times/, // Times with double backslashes
+ /\\div/, // Division
+ /\\\\div/, // Division with double backslashes
+ /\\pm/, // Plus-minus
+ /\\\\pm/, // Plus-minus with double backslashes
+ /\\mp/, // Minus-plus
+ /\\\\mp/, // Minus-plus with double backslashes
+ /\\leq/, // Less than or equal
+ /\\\\leq/, // Less than or equal with double backslashes
+ /\\geq/, // Greater than or equal
+ /\\\\geq/, // Greater than or equal with double backslashes
+ /\\neq/, // Not equal
+ /\\\\neq/, // Not equal with double backslashes
+ /\\approx/, // Approximately equal
+ /\\\\approx/, // Approximately equal with double backslashes
+ /\\equiv/, // Equivalent
+ /\\\\equiv/, // Equivalent with double backslashes
+ /\\propto/, // Proportional
+ /\\\\propto/, // Proportional with double backslashes
+ /\\in/, // Element of
+ /\\\\in/, // Element of with double backslashes
+ /\\notin/, // Not element of
+ /\\\\notin/, // Not element of with double backslashes
+ /\\subset/, // Subset
+ /\\\\subset/, // Subset with double backslashes
+ /\\supset/, // Superset
+ /\\\\supset/, // Superset with double backslashes
+ /\\cup/, // Union
+ /\\\\cup/, // Union with double backslashes
+ /\\cap/, // Intersection
+ /\\\\cap/, // Intersection with double backslashes
+ /\\emptyset/, // Empty set
+ /\\\\emptyset/, // Empty set with double backslashes
+ /\\mathbb\{/, // Blackboard bold
+ /\\\\mathbb\{/, // Blackboard bold with double backslashes
+ /\\mathcal\{/, // Calligraphic
+ /\\\\mathcal\{/, // Calligraphic with double backslashes
+ /\\mathfrak\{/, // Fraktur
+ /\\\\mathfrak\{/, // Fraktur with double backslashes
+ /\\mathscr\{/, // Script
+ /\\\\mathscr\{/, // Script with double backslashes
+ ];
+
+ return latexPatterns.some(pattern => pattern.test(trimmed));
+}
+
/**
* Parse markup text with advanced formatting
*/
export async function parseAdvancedmarkup(text: string): Promise {
- if (!text) return '';
-
+ if (!text) return "";
+
try {
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
let processedText = withoutCode;
- // Step 2: Process block-level elements
+ // Step 2: Process $...$ and $$...$$ math blocks (LaTeX or AsciiMath)
+ processedText = processDollarMath(processedText);
+
+ // Step 3: Process LaTeX math expressions ONLY within inline code blocks (legacy support)
+ processedText = processMathExpressions(processedText);
+
+ // Step 4: Process block-level elements (tables, blockquotes, headings, horizontal rules)
processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText);
- // Process inline elements
- processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
- const escapedCode = code
- .trim()
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- return `${escapedCode}`;
- });
-
- // Process footnotes (only references, not definitions)
+ // Step 5: Process footnotes (only references, not definitions)
processedText = processFootnotes(processedText);
- // Process basic markup (which will also handle Nostr identifiers)
+ // Step 6: Process basic markup (which will also handle Nostr identifiers)
+ // This includes paragraphs, inline code, links, lists, etc.
processedText = await parseBasicmarkup(processedText);
- // Step 3: Restore code blocks
+ // Step 7: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (e: unknown) {
- console.error('Error in parseAdvancedmarkup:', e);
- return `
Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
`;
+ console.error("Error in parseAdvancedmarkup:", e);
+ return `
Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
`;
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/utils/markup/asciidoctorExtensions.ts b/src/lib/utils/markup/asciidoctorExtensions.ts
new file mode 100644
index 0000000..5e7be35
--- /dev/null
+++ b/src/lib/utils/markup/asciidoctorExtensions.ts
@@ -0,0 +1,213 @@
+import { renderTikZ } from "./tikzRenderer";
+import asciidoctor from "asciidoctor";
+
+// Simple math rendering using MathJax CDN
+function renderMath(content: string): string {
+ return `
+
${content}
+
+
`;
+}
+
+// Simple PlantUML rendering using PlantUML server
+function renderPlantUML(content: string): string {
+ // Encode content for PlantUML server
+ const encoded = btoa(unescape(encodeURIComponent(content)));
+ const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`;
+
+ return ``;
+}
+
+/**
+ * Creates Asciidoctor extensions for advanced content rendering
+ * including Asciimath/Latex, PlantUML, BPMN, and TikZ
+ */
+export function createAdvancedExtensions(): any {
+ const Asciidoctor = asciidoctor();
+ const extensions = Asciidoctor.Extensions.create();
+
+ // Math rendering extension (Asciimath/Latex)
+ extensions.treeProcessor(function (this: any) {
+ const dsl = this;
+ dsl.process(function (this: any, document: any) {
+ const treeProcessor = this;
+ processMathBlocks(treeProcessor, document);
+ });
+ });
+
+ // PlantUML rendering extension
+ extensions.treeProcessor(function (this: any) {
+ const dsl = this;
+ dsl.process(function (this: any, document: any) {
+ const treeProcessor = this;
+ processPlantUMLBlocks(treeProcessor, document);
+ });
+ });
+
+ // TikZ rendering extension
+ extensions.treeProcessor(function (this: any) {
+ const dsl = this;
+ dsl.process(function (this: any, document: any) {
+ const treeProcessor = this;
+ processTikZBlocks(treeProcessor, document);
+ });
+ });
+
+ // --- NEW: Support [plantuml], [tikz], [bpmn] as source blocks ---
+ // Helper to register a block for a given name and treat it as a source block
+ function registerDiagramBlock(name: string) {
+ extensions.block(name, function (this: any) {
+ const self = this;
+ self.process(function (parent: any, reader: any, attrs: any) {
+ // Read the block content
+ const lines = reader.getLines();
+ // Create a source block with the correct language and lang attributes
+ const block = self.createBlock(parent, "source", lines, {
+ ...attrs,
+ language: name,
+ lang: name,
+ style: "source",
+ role: name,
+ });
+ block.setAttribute("language", name);
+ block.setAttribute("lang", name);
+ block.setAttribute("style", "source");
+ block.setAttribute("role", name);
+ block.setOption("source", true);
+ block.setOption("listing", true);
+ block.setStyle("source");
+ return block;
+ });
+ });
+ }
+ registerDiagramBlock("plantuml");
+ registerDiagramBlock("tikz");
+ registerDiagramBlock("bpmn");
+ // --- END NEW ---
+
+ return extensions;
+}
+
+/**
+ * Processes math blocks (stem blocks) and converts them to rendered HTML
+ */
+function processMathBlocks(treeProcessor: any, document: any): void {
+ const blocks = document.getBlocks();
+ for (const block of blocks) {
+ if (block.getContext() === "stem") {
+ const content = block.getContent();
+ if (content) {
+ try {
+ // Output as a single div with delimiters for MathJax
+ const rendered = `
$$${content}$$
`;
+ block.setContent(rendered);
+ } catch (error) {
+ console.warn("Failed to render math:", error);
+ }
+ }
+ }
+ // Inline math: context 'inline' and style 'stem' or 'latexmath'
+ if (
+ block.getContext() === "inline" &&
+ (block.getStyle() === "stem" || block.getStyle() === "latexmath")
+ ) {
+ const content = block.getContent();
+ if (content) {
+ try {
+ const rendered = `$${content}$`;
+ block.setContent(rendered);
+ } catch (error) {
+ console.warn("Failed to render inline math:", error);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Processes PlantUML blocks and converts them to rendered SVG
+ */
+function processPlantUMLBlocks(treeProcessor: any, document: any): void {
+ const blocks = document.getBlocks();
+
+ for (const block of blocks) {
+ if (block.getContext() === "listing" && isPlantUMLBlock(block)) {
+ const content = block.getContent();
+ if (content) {
+ try {
+ // Use simple PlantUML rendering
+ const rendered = renderPlantUML(content);
+
+ // Replace the block content with the image
+ block.setContent(rendered);
+ } catch (error) {
+ console.warn("Failed to render PlantUML:", error);
+ // Keep original content if rendering fails
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Processes TikZ blocks and converts them to rendered SVG
+ */
+function processTikZBlocks(treeProcessor: any, document: any): void {
+ const blocks = document.getBlocks();
+
+ for (const block of blocks) {
+ if (block.getContext() === "listing" && isTikZBlock(block)) {
+ const content = block.getContent();
+ if (content) {
+ try {
+ // Render TikZ to SVG
+ const svg = renderTikZ(content);
+
+ // Replace the block content with the SVG
+ block.setContent(svg);
+ } catch (error) {
+ console.warn("Failed to render TikZ:", error);
+ // Keep original content if rendering fails
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Checks if a block contains PlantUML content
+ */
+function isPlantUMLBlock(block: any): boolean {
+ const content = block.getContent() || "";
+ const lines = content.split("\n");
+
+ // Check for PlantUML indicators
+ return lines.some(
+ (line: string) =>
+ line.trim().startsWith("@startuml") ||
+ line.trim().startsWith("@start") ||
+ line.includes("plantuml") ||
+ line.includes("uml"),
+ );
+}
+
+/**
+ * Checks if a block contains TikZ content
+ */
+function isTikZBlock(block: any): boolean {
+ const content = block.getContent() || "";
+ const lines = content.split("\n");
+
+ // Check for TikZ indicators
+ return lines.some(
+ (line: string) =>
+ line.trim().startsWith("\\begin{tikzpicture}") ||
+ line.trim().startsWith("\\tikz") ||
+ line.includes("tikzpicture") ||
+ line.includes("tikz"),
+ );
+}
diff --git a/src/lib/utils/markup/asciidoctorPostProcessor.ts b/src/lib/utils/markup/asciidoctorPostProcessor.ts
new file mode 100644
index 0000000..8664c02
--- /dev/null
+++ b/src/lib/utils/markup/asciidoctorPostProcessor.ts
@@ -0,0 +1,136 @@
+import { processNostrIdentifiers } from "../nostrUtils";
+
+/**
+ * Normalizes a string for use as a d-tag by converting to lowercase,
+ * replacing non-alphanumeric characters with dashes, and removing
+ * leading/trailing dashes.
+ */
+function normalizeDTag(input: string): string {
+ return input
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]/gu, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+/**
+ * Replaces wikilinks in the format [[target]] or [[target|display]] with
+ * clickable links to the events page.
+ */
+function replaceWikilinks(html: string): string {
+ // [[target page]] or [[target page|display text]]
+ return html.replace(
+ /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
+ (_match, target, label) => {
+ const normalized = normalizeDTag(target.trim());
+ const display = (label || target).trim();
+ const url = `./events?d=${normalized}`;
+ // Output as a clickable with the [[display]] format and matching link colors
+ return `${display}`;
+ },
+ );
+}
+
+/**
+ * Replaces AsciiDoctor-generated empty anchor tags with clickable wikilink-style tags.
+ */
+function replaceAsciiDocAnchors(html: string): string {
+ return html.replace(
+ /<\/a>/g,
+ (_match, id) => {
+ const normalized = normalizeDTag(id.trim());
+ const url = `./events?d=${normalized}`;
+ return `${id}`;
+ }
+ );
+}
+
+/**
+ * Processes nostr addresses in HTML content, but skips addresses that are
+ * already within hyperlink tags.
+ */
+async function processNostrAddresses(html: string): Promise {
+ // Helper to check if a match is within an existing tag
+ function isWithinLink(text: string, index: number): boolean {
+ // Look backwards from the match position to find the nearest tag
+ const before = text.slice(0, index);
+ const lastOpenTag = before.lastIndexOf("");
+
+ // If we find an opening tag after the last closing tag, we're inside a link
+ return lastOpenTag > lastCloseTag;
+ }
+
+ // Process nostr addresses that are not within existing links
+ const nostrPattern =
+ /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
+ let processedHtml = html;
+
+ // Find all nostr addresses
+ const matches = Array.from(processedHtml.matchAll(nostrPattern));
+
+ // Process them in reverse order to avoid index shifting issues
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const match = matches[i];
+ const [fullMatch] = match;
+ const matchIndex = match.index ?? 0;
+
+ // Skip if already within a link
+ if (isWithinLink(processedHtml, matchIndex)) {
+ continue;
+ }
+
+ // Process the nostr identifier
+ const processedMatch = await processNostrIdentifiers(fullMatch);
+
+ // Replace the match in the HTML
+ processedHtml =
+ processedHtml.slice(0, matchIndex) +
+ processedMatch +
+ processedHtml.slice(matchIndex + fullMatch.length);
+ }
+
+ return processedHtml;
+}
+
+/**
+ * Fixes AsciiDoctor stem blocks for MathJax rendering.
+ * Joins split spans and wraps content in $$...$$ for block math.
+ */
+function fixStemBlocks(html: string): string {
+ // Replace
`;
+ })
+ .join("\n");
// Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText);
@@ -382,7 +427,7 @@ export async function parseBasicmarkup(text: string): Promise {
return processedText;
} catch (e: unknown) {
- console.error('Error in parseBasicmarkup:', e);
- return `
Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}
`;
+ console.error("Error in parseBasicmarkup:", e);
+ return `
Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
`;
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/utils/markup/tikzRenderer.ts b/src/lib/utils/markup/tikzRenderer.ts
new file mode 100644
index 0000000..3e194b6
--- /dev/null
+++ b/src/lib/utils/markup/tikzRenderer.ts
@@ -0,0 +1,60 @@
+/**
+ * TikZ renderer using node-tikzjax
+ * Converts TikZ LaTeX code to SVG for browser rendering
+ */
+
+// We'll use a simple approach for now since node-tikzjax might not be available
+// This is a placeholder implementation that can be enhanced later
+
+export function renderTikZ(tikzCode: string): string {
+ try {
+ // For now, we'll create a simple SVG placeholder
+ // In a full implementation, this would use node-tikzjax or similar library
+
+ // Extract TikZ content and create a basic SVG
+ const svgContent = createBasicSVG(tikzCode);
+
+ return svgContent;
+ } catch (error) {
+ console.error("Failed to render TikZ:", error);
+ return `
+
TikZ Rendering Error
+
Failed to render TikZ diagram. Original code:
+
${tikzCode}
+
`;
+ }
+}
+
+/**
+ * Creates a basic SVG placeholder for TikZ content
+ * This is a temporary implementation until proper TikZ rendering is available
+ */
+function createBasicSVG(tikzCode: string): string {
+ // Create a simple SVG with the TikZ code as text
+ const width = 400;
+ const height = 300;
+
+ return `
+
+
+ TikZ Diagram
+
+
+ (Rendering not yet implemented)
+
+
+
+
${escapeHtml(tikzCode)}
+
+
+ `;
+}
+
+/**
+ * Escapes HTML characters for safe display
+ */
+function escapeHtml(text: string): string {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts
index 28f744e..979c294 100644
--- a/src/lib/utils/mime.ts
+++ b/src/lib/utils/mime.ts
@@ -1,3 +1,5 @@
+import { EVENT_KINDS } from './search_constants';
+
/**
* Determine the type of Nostr event based on its kind number
* Following NIP specification for kind ranges:
@@ -6,22 +8,25 @@
* - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays)
*/
-export function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
+export function getEventType(
+ kind: number,
+): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first
- if (kind >= 30000 && kind < 40000) {
- return 'addressable';
+ if (kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind < EVENT_KINDS.ADDRESSABLE.MAX) {
+ return "addressable";
}
-
- if (kind >= 20000 && kind < 30000) {
- return 'ephemeral';
+
+ if (kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN && kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX) {
+ return "ephemeral";
}
-
- if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) {
- return 'replaceable';
+
+ if ((kind >= EVENT_KINDS.REPLACEABLE.MIN && kind < EVENT_KINDS.REPLACEABLE.MAX) ||
+ EVENT_KINDS.REPLACEABLE.SPECIFIC.includes(kind as 0 | 3)) {
+ return "replaceable";
}
-
+
// Everything else is regular
- return 'regular';
+ return "regular";
}
/**
@@ -36,9 +41,10 @@ export function getMimeTags(kind: number): [string, string][] {
// Determine replaceability based on event type
const eventType = getEventType(kind);
- const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
- ? "replaceable"
- : "nonreplaceable";
+ const replaceability =
+ eventType === "replaceable" || eventType === "addressable"
+ ? "replaceable"
+ : "nonreplaceable";
switch (kind) {
// Short text note
@@ -93,4 +99,4 @@ export function getMimeTags(kind: number): [string, string][] {
}
return [mTag, MTag];
-}
\ No newline at end of file
+}
diff --git a/src/lib/utils/nostrEventService.ts b/src/lib/utils/nostrEventService.ts
new file mode 100644
index 0000000..8a59d8e
--- /dev/null
+++ b/src/lib/utils/nostrEventService.ts
@@ -0,0 +1,421 @@
+import { nip19 } from "nostr-tools";
+import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils";
+import { standardRelays, fallbackRelays } from "$lib/consts";
+import { userRelays } from "$lib/stores/relayStore";
+import { get } from "svelte/store";
+import { goto } from "$app/navigation";
+import type { NDKEvent } from "./nostrUtils";
+import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
+
+export interface RootEventInfo {
+ rootId: string;
+ rootPubkey: string;
+ rootRelay: string;
+ rootKind: number;
+ rootAddress: string;
+ rootIValue: string;
+ rootIRelay: string;
+ isRootA: boolean;
+ isRootI: boolean;
+}
+
+export interface ParentEventInfo {
+ parentId: string;
+ parentPubkey: string;
+ parentRelay: string;
+ parentKind: number;
+ parentAddress: string;
+}
+
+export interface EventPublishResult {
+ success: boolean;
+ relay?: string;
+ eventId?: string;
+ error?: string;
+}
+
+/**
+ * Helper function to find a tag by its first element
+ */
+function findTag(tags: string[][], tagName: string): string[] | undefined {
+ return tags?.find((t: string[]) => t[0] === tagName);
+}
+
+/**
+ * Helper function to get tag value safely
+ */
+function getTagValue(tags: string[][], tagName: string, index: number = 1): string {
+ const tag = findTag(tags, tagName);
+ return tag?.[index] || '';
+}
+
+/**
+ * Helper function to create a tag array
+ */
+function createTag(name: string, ...values: (string | number)[]): string[] {
+ return [name, ...values.map(v => String(v))];
+}
+
+/**
+ * Helper function to add tags to an array
+ */
+function addTags(tags: string[][], ...newTags: string[][]): void {
+ tags.push(...newTags);
+}
+
+/**
+ * Extract root event information from parent event tags
+ */
+export function extractRootEventInfo(parent: NDKEvent): RootEventInfo {
+ const rootInfo: RootEventInfo = {
+ rootId: parent.id,
+ rootPubkey: getPubkeyString(parent.pubkey),
+ rootRelay: getRelayString(parent.relay),
+ rootKind: parent.kind || 1,
+ rootAddress: '',
+ rootIValue: '',
+ rootIRelay: '',
+ isRootA: false,
+ isRootI: false,
+ };
+
+ if (!parent.tags) return rootInfo;
+
+ const rootE = findTag(parent.tags, 'E');
+ const rootA = findTag(parent.tags, 'A');
+ const rootI = findTag(parent.tags, 'I');
+
+ rootInfo.isRootA = !!rootA;
+ rootInfo.isRootI = !!rootI;
+
+ if (rootE) {
+ rootInfo.rootId = rootE[1];
+ rootInfo.rootRelay = getRelayString(rootE[2]);
+ rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey);
+ rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
+ } else if (rootA) {
+ rootInfo.rootAddress = rootA[1];
+ rootInfo.rootRelay = getRelayString(rootA[2]);
+ rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey);
+ rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
+ } else if (rootI) {
+ rootInfo.rootIValue = rootI[1];
+ rootInfo.rootIRelay = getRelayString(rootI[2]);
+ rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind;
+ }
+
+ return rootInfo;
+}
+
+/**
+ * Extract parent event information
+ */
+export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo {
+ const dTag = getTagValue(parent.tags || [], 'd');
+ const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : '';
+
+ return {
+ parentId: parent.id,
+ parentPubkey: getPubkeyString(parent.pubkey),
+ parentRelay: getRelayString(parent.relay),
+ parentKind: parent.kind || 1,
+ parentAddress,
+ };
+}
+
+/**
+ * Build root scope tags for NIP-22 threading
+ */
+function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] {
+ const tags: string[][] = [];
+
+ if (rootInfo.rootAddress) {
+ const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E';
+ addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay));
+ } else if (rootInfo.rootIValue) {
+ addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay));
+ } else {
+ addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay));
+ }
+
+ addTags(tags, createTag('K', rootInfo.rootKind));
+
+ if (rootInfo.rootPubkey && !rootInfo.rootIValue) {
+ addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay));
+ }
+
+ return tags;
+}
+
+/**
+ * Build parent scope tags for NIP-22 threading
+ */
+function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] {
+ const tags: string[][] = [];
+
+ if (parentInfo.parentAddress) {
+ const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e';
+ addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay));
+ }
+
+ addTags(
+ tags,
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+
+ return tags;
+}
+
+/**
+ * Build tags for a reply event based on parent and root information
+ */
+export function buildReplyTags(
+ parent: NDKEvent,
+ rootInfo: RootEventInfo,
+ parentInfo: ParentEventInfo,
+ kind: number
+): string[][] {
+ const tags: string[][] = [];
+
+ const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
+ const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
+ const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
+
+ if (kind === 1) {
+ // Kind 1 replies use simple e/p tags
+ addTags(
+ tags,
+ createTag('e', parent.id, parentInfo.parentRelay, 'root'),
+ createTag('p', parentInfo.parentPubkey)
+ );
+
+ // Add address for replaceable events
+ if (isParentReplaceable) {
+ const dTag = getTagValue(parent.tags || [], 'd');
+ if (dTag) {
+ const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
+ addTags(tags, createTag('a', parentAddress, '', 'root'));
+ }
+ }
+ } else {
+ // Kind 1111 (comment) uses NIP-22 threading format
+ if (isParentReplaceable) {
+ const dTag = getTagValue(parent.tags || [], 'd');
+ if (dTag) {
+ const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`;
+
+ if (isReplyToComment) {
+ // Root scope (uppercase) - use the original article
+ addTags(
+ tags,
+ createTag('A', parentAddress, parentInfo.parentRelay),
+ createTag('K', rootInfo.rootKind),
+ createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)
+ );
+ // Parent scope (lowercase) - the comment we're replying to
+ addTags(
+ tags,
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+ } else {
+ // Top-level comment - root and parent are the same
+ addTags(
+ tags,
+ createTag('A', parentAddress, parentInfo.parentRelay),
+ createTag('K', rootInfo.rootKind),
+ createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
+ createTag('a', parentAddress, parentInfo.parentRelay),
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+ }
+ } else {
+ // Fallback to E/e tags if no d-tag found
+ if (isReplyToComment) {
+ addTags(
+ tags,
+ createTag('E', rootInfo.rootId, rootInfo.rootRelay),
+ createTag('K', rootInfo.rootKind),
+ createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+ } else {
+ addTags(
+ tags,
+ createTag('E', parent.id, rootInfo.rootRelay),
+ createTag('K', rootInfo.rootKind),
+ createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay),
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+ }
+ }
+ } else {
+ // For regular events, use E/e tags
+ if (isReplyToComment) {
+ // Reply to a comment - distinguish root from parent
+ addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
+ addTags(
+ tags,
+ createTag('e', parent.id, parentInfo.parentRelay),
+ createTag('k', parentInfo.parentKind),
+ createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay)
+ );
+ } else {
+ // Top-level comment or regular event
+ addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo));
+ addTags(tags, ...buildParentScopeTags(parent, parentInfo, rootInfo));
+ }
+ }
+ }
+
+ return tags;
+}
+
+/**
+ * Create and sign a Nostr event
+ */
+export async function createSignedEvent(
+ content: string,
+ pubkey: string,
+ kind: number,
+ tags: string[][]
+): Promise<{ id: string; sig: string; event: any }> {
+ const prefixedContent = prefixNostrAddresses(content);
+
+ const eventToSign = {
+ kind: Number(kind),
+ created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)),
+ tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
+ content: String(prefixedContent),
+ pubkey: pubkey,
+ };
+
+ let sig, id;
+ if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
+ const signed = await window.nostr.signEvent(eventToSign);
+ sig = signed.sig as string;
+ id = 'id' in signed ? signed.id as string : getEventHash(eventToSign);
+ } else {
+ id = getEventHash(eventToSign);
+ sig = await signEvent(eventToSign);
+ }
+
+ return {
+ id,
+ sig,
+ event: {
+ ...eventToSign,
+ id,
+ sig,
+ }
+ };
+}
+
+/**
+ * Publish event to a single relay
+ */
+async function publishToRelay(relayUrl: string, signedEvent: any): Promise {
+ const ws = new WebSocket(relayUrl);
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ ws.close();
+ reject(new Error("Timeout"));
+ }, TIMEOUTS.GENERAL);
+
+ ws.onopen = () => {
+ ws.send(JSON.stringify(["EVENT", signedEvent]));
+ };
+
+ ws.onmessage = (e) => {
+ const [type, id, ok, message] = JSON.parse(e.data);
+ if (type === "OK" && id === signedEvent.id) {
+ clearTimeout(timeout);
+ if (ok) {
+ ws.close();
+ resolve();
+ } else {
+ ws.close();
+ reject(new Error(message));
+ }
+ }
+ };
+
+ ws.onerror = () => {
+ clearTimeout(timeout);
+ ws.close();
+ reject(new Error("WebSocket error"));
+ };
+ });
+}
+
+/**
+ * Publish event to relays
+ */
+export async function publishEvent(
+ signedEvent: any,
+ useOtherRelays = false,
+ useFallbackRelays = false,
+ userRelayPreference = false
+): Promise {
+ // Determine which relays to use
+ let relays = userRelayPreference ? get(userRelays) : standardRelays;
+ if (useOtherRelays) {
+ relays = userRelayPreference ? standardRelays : get(userRelays);
+ }
+ if (useFallbackRelays) {
+ relays = fallbackRelays;
+ }
+
+ // Try to publish to relays
+ for (const relayUrl of relays) {
+ try {
+ await publishToRelay(relayUrl, signedEvent);
+ return {
+ success: true,
+ relay: relayUrl,
+ eventId: signedEvent.id
+ };
+ } catch (e) {
+ console.error(`Failed to publish to ${relayUrl}:`, e);
+ }
+ }
+
+ return {
+ success: false,
+ error: "Failed to publish to any relays"
+ };
+}
+
+/**
+ * Navigate to the published event
+ */
+export function navigateToEvent(eventId: string): void {
+ const nevent = nip19.neventEncode({ id: eventId });
+ goto(`/events?id=${nevent}`);
+}
+
+// Helper functions to ensure relay and pubkey are always strings
+function getRelayString(relay: any): string {
+ if (!relay) return '';
+ if (typeof relay === 'string') return relay;
+ if (typeof relay.url === 'string') return relay.url;
+ return '';
+}
+
+function getPubkeyString(pubkey: any): string {
+ if (!pubkey) return '';
+ if (typeof pubkey === 'string') return pubkey;
+ if (typeof pubkey.hex === 'function') return pubkey.hex();
+ if (typeof pubkey.pubkey === 'string') return pubkey.pubkey;
+ return '';
+}
\ No newline at end of file
diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts
index faa001b..dd272d4 100644
--- a/src/lib/utils/nostrUtils.ts
+++ b/src/lib/utils/nostrUtils.ts
@@ -1,22 +1,28 @@
-import { get } from 'svelte/store';
-import { nip19 } from 'nostr-tools';
-import { ndkInstance } from '$lib/ndk';
-import { npubCache } from './npubCache';
+import { get } from "svelte/store";
+import { nip19 } from "nostr-tools";
+import { ndkInstance } from "$lib/ndk";
+import { npubCache } from "./npubCache";
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
-import { standardRelays, fallbackRelays } from "$lib/consts";
-import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
-import { sha256 } from '@noble/hashes/sha256';
-import { schnorr } from '@noble/curves/secp256k1';
-import { bytesToHex } from '@noble/hashes/utils';
+import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts";
+import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
+import { sha256 } from "@noble/hashes/sha256";
+import { schnorr } from "@noble/curves/secp256k1";
+import { bytesToHex } from "@noble/hashes/utils";
+import { wellKnownUrl } from "./search_utility";
+import { TIMEOUTS, VALIDATION } from './search_constants';
-const badgeCheckSvg = ''
+const badgeCheckSvg =
+ '';
-const graduationCapSvg = '';
+const graduationCapSvg =
+ '';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
-export const NOSTR_PROFILE_REGEX = /(?': '>',
- '"': '"',
- "'": '''
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
};
- return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
+ return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
}
/**
@@ -71,29 +77,35 @@ export async function getUserMetadata(identifier: string, force = false): Promis
// Handle different identifier types
let pubkey: string;
- if (decoded.type === 'npub') {
+ if (decoded.type === "npub") {
pubkey = decoded.data;
- } else if (decoded.type === 'nprofile') {
+ } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(cleanId, fallback);
return fallback;
}
- const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
- const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
+ const profileEvent = await fetchEventWithFallback(ndk, {
+ kinds: [0],
+ authors: [pubkey],
+ });
+ const profile =
+ profileEvent && profileEvent.content
+ ? JSON.parse(profileEvent.content)
+ : null;
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
- displayName: profile?.displayName,
+ displayName: profile?.displayName || profile?.display_name,
nip05: profile?.nip05,
picture: profile?.picture || profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
- lud16: profile?.lud16
+ lud16: profile?.lud16,
};
-
+
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
@@ -105,8 +117,11 @@ export async function getUserMetadata(identifier: string, force = false): Promis
/**
* Create a profile link element
*/
-export function createProfileLink(identifier: string, displayText: string | undefined): string {
- const cleanId = identifier.replace(/^nostr:/, '');
+export function createProfileLink(
+ identifier: string,
+ displayText: string | undefined,
+): string {
+ const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
@@ -118,15 +133,18 @@ export function createProfileLink(identifier: string, displayText: string | unde
/**
* Create a profile link element with a NIP-05 verification indicator.
*/
-export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise {
+export async function createProfileLinkWithVerification(
+ identifier: string,
+ displayText: string | undefined,
+): Promise {
const ndk = get(ndkInstance) as NDK;
if (!ndk) {
return createProfileLink(identifier, displayText);
}
- const cleanId = identifier.replace(/^nostr:/, '');
+ const cleanId = identifier.replace(/^nostr:/, "");
const escapedId = escapeHtml(cleanId);
- const isNpub = cleanId.startsWith('npub');
+ const isNpub = cleanId.startsWith("npub");
let user: NDKUser;
if (isNpub) {
@@ -135,19 +153,23 @@ export async function createProfileLinkWithVerification(identifier: string, disp
user = ndk.getUser({ pubkey: cleanId });
}
- const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url);
+ const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
+ (r) => r.url,
+ );
const allRelays = [
...standardRelays,
...userRelays,
- ...fallbackRelays
+ ...fallbackRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
- relaySet
+ relaySet,
);
- const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null;
+ const profile = profileEvent?.content
+ ? JSON.parse(profileEvent.content)
+ : null;
const nip05 = profile?.nip05;
if (!nip05) {
@@ -156,16 +178,20 @@ export async function createProfileLinkWithVerification(identifier: string, disp
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
- const displayIdentifier = profile?.displayName ?? profile?.name ?? escapedText;
+ const displayIdentifier =
+ profile?.displayName ??
+ profile?.display_name ??
+ profile?.name ??
+ escapedText;
const isVerified = await user.validateNip05(nip05);
-
+
if (!isVerified) {
return createProfileLink(identifier, displayText);
}
-
+
// TODO: Make this work with an enum in case we add more types.
- const type = nip05.endsWith('edu') ? 'edu' : 'standard';
+ const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) {
case 'edu':
return `@${displayIdentifier}${graduationCapSvg}`;
@@ -177,18 +203,20 @@ export async function createProfileLinkWithVerification(identifier: string, disp
* Create a note link element
*/
function createNoteLink(identifier: string): string {
- const cleanId = identifier.replace(/^nostr:/, '');
+ const cleanId = identifier.replace(/^nostr:/, "");
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
-
- return `${escapedText}`;
+
+ return `${escapedText}`;
}
/**
* Process Nostr identifiers in text
*/
-export async function processNostrIdentifiers(content: string): Promise {
+export async function processNostrIdentifiers(
+ content: string,
+): Promise {
let processedContent = content;
// Helper to check if a match is part of a URL
@@ -207,8 +235,8 @@ export async function processNostrIdentifiers(content: string): Promise
continue; // skip if part of a URL
}
let identifier = fullMatch;
- if (!identifier.startsWith('nostr:')) {
- identifier = 'nostr:' + identifier;
+ if (!identifier.startsWith("nostr:")) {
+ identifier = "nostr:" + identifier;
}
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
@@ -225,8 +253,8 @@ export async function processNostrIdentifiers(content: string): Promise
continue; // skip if part of a URL
}
let identifier = fullMatch;
- if (!identifier.startsWith('nostr:')) {
- identifier = 'nostr:' + identifier;
+ if (!identifier.startsWith("nostr:")) {
+ identifier = "nostr:" + identifier;
}
const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link);
@@ -237,19 +265,35 @@ export async function processNostrIdentifiers(content: string): Promise
export async function getNpubFromNip05(nip05: string): Promise {
try {
- const ndk = get(ndkInstance);
- if (!ndk) {
- console.error('NDK not initialized');
+ // Parse the NIP-05 address
+ const [name, domain] = nip05.split('@');
+ if (!name || !domain) {
+ console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05);
return null;
}
+
+ // Fetch the well-known.json file
+ const url = wellKnownUrl(domain, name);
- const user = await ndk.getUser({ nip05 });
- if (!user || !user.npub) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
return null;
}
- return user.npub;
+
+ const data = await response.json();
+
+ const pubkey = data.names?.[name];
+ if (!pubkey) {
+ console.error('[getNpubFromNip05] No pubkey found for name:', name);
+ return null;
+ }
+
+ // Convert pubkey to npub
+ const npub = nip19.npubEncode(pubkey);
+ return npub;
} catch (error) {
- console.error('Error getting npub from nip05:', error);
+ console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null;
}
}
@@ -257,9 +301,9 @@ export async function getNpubFromNip05(nip05: string): Promise {
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
- * 1. Method style: promise.withTimeout(5000)
- * 2. Function style: withTimeout(promise, 5000)
- *
+ * 1. Method style: promise.withTimeout(TIMEOUTS.GENERAL)
+ * 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL)
+ *
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
* @returns The promise result if completed before timeout, otherwise throws an error
@@ -267,28 +311,28 @@ export async function getNpubFromNip05(nip05: string): Promise {
*/
export function withTimeout(
thisOrPromise: Promise | number,
- timeoutMsOrPromise?: number | Promise
+ timeoutMsOrPromise?: number | Promise,
): Promise {
// Handle method-style call (promise.withTimeout(5000))
- if (typeof thisOrPromise === 'number') {
+ if (typeof thisOrPromise === "number") {
const timeoutMs = thisOrPromise;
const promise = timeoutMsOrPromise as Promise;
return Promise.race([
promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('Timeout')), timeoutMs)
- )
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("Timeout")), timeoutMs),
+ ),
]);
}
-
+
// Handle function-style call (withTimeout(promise, 5000))
const promise = thisOrPromise;
const timeoutMs = timeoutMsOrPromise as number;
return Promise.race([
promise,
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('Timeout')), timeoutMs)
- )
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("Timeout")), timeoutMs),
+ ),
]);
}
@@ -299,7 +343,10 @@ declare global {
}
}
-Promise.prototype.withTimeout = function(this: Promise, timeoutMs: number): Promise {
+Promise.prototype.withTimeout = function (
+ this: Promise,
+ timeoutMs: number,
+): Promise {
return withTimeout(timeoutMs, this);
};
@@ -312,20 +359,24 @@ Promise.prototype.withTimeout = function(this: Promise, timeoutMs: number)
export async function fetchEventWithFallback(
ndk: NDK,
filterOrId: string | NDKFilter,
- timeoutMs: number = 3000
+ timeoutMs: number = 3000,
): Promise {
// Get user relays if logged in
- const userRelays = ndk.activeUser ?
- Array.from(ndk.pool?.relays.values() || [])
- .filter(r => r.status === 1) // Only use connected relays
- .map(r => r.url) :
- [];
-
+ const userRelays = ndk.activeUser
+ ? Array.from(ndk.pool?.relays.values() || [])
+ .filter((r) => r.status === 1) // Only use connected relays
+ .map((r) => r.url)
+ : [];
+
+ // Determine which relays to use based on user authentication status
+ const isSignedIn = ndk.signer && ndk.activeUser;
+ const primaryRelays = isSignedIn ? standardRelays : anonymousRelays;
+
// Create three relay sets in priority order
const relaySets = [
- NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
- NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
- NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
+ NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous)
+ NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
+ NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk), // 3. fallback relays (last resort)
];
try {
@@ -333,47 +384,75 @@ export async function fetchEventWithFallback(
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
- async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise {
+ async function tryFetchFromRelaySet(
+ relaySet: NDKRelaySetFromNDK,
+ setName: string,
+ ): Promise {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
-
- if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) {
- return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs);
+
+ if (
+ typeof filterOrId === "string" &&
+ new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId)
+ ) {
+ return await ndk
+ .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
+ .withTimeout(timeoutMs);
} else {
- const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId;
- const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs);
- return results instanceof Set ? Array.from(results)[0] as NDKEvent : null;
+ const filter =
+ typeof filterOrId === "string" ? { ids: [filterOrId] } : filterOrId;
+ const results = await ndk
+ .fetchEvents(filter, undefined, relaySet)
+ .withTimeout(timeoutMs);
+ return results instanceof Set
+ ? (Array.from(results)[0] as NDKEvent)
+ : null;
}
}
// Try each relay set in order
for (const [index, relaySet] of relaySets.entries()) {
- const setName = index === 0 ? 'standard relays' :
- index === 1 ? 'user relays' :
- 'fallback relays';
-
+ const setName =
+ index === 0
+ ? isSignedIn
+ ? "standard relays"
+ : "anonymous relays"
+ : index === 1
+ ? "user relays"
+ : "fallback relays";
+
found = await tryFetchFromRelaySet(relaySet, setName);
if (found) break;
}
if (!found) {
const timeoutSeconds = timeoutMs / 1000;
- const relayUrls = relaySets.map((set, i) => {
- const setName = i === 0 ? 'standard relays' :
- i === 1 ? 'user relays' :
- 'fallback relays';
- const urls = Array.from(set.relays).map(r => r.url);
- return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null;
- }).filter(Boolean).join(', then ');
-
- console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`);
+ const relayUrls = relaySets
+ .map((set, i) => {
+ const setName =
+ i === 0
+ ? isSignedIn
+ ? "standard relays"
+ : "anonymous relays"
+ : i === 1
+ ? "user relays"
+ : "fallback relays";
+ const urls = Array.from(set.relays).map((r) => r.url);
+ return urls.length > 0 ? `${setName} (${urls.join(", ")})` : null;
+ })
+ .filter(Boolean)
+ .join(", then ");
+
+ console.warn(
+ `Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`,
+ );
return null;
}
// Always wrap as NDKEvent
return found instanceof NDKEvent ? found : new NDKEvent(ndk, found);
} catch (err) {
- console.error('Error in fetchEventWithFallback:', err);
+ console.error("Error in fetchEventWithFallback:", err);
return null;
}
}
@@ -384,10 +463,10 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
- if (/^[a-f0-9]{64}$/i.test(pubkey)) {
+ if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(pubkey)) {
return nip19.npubEncode(pubkey);
}
- if (pubkey.startsWith('npub1')) return pubkey;
+ if (pubkey.startsWith("npub1")) return pubkey;
return null;
} catch {
return null;
@@ -429,7 +508,7 @@ export function getEventHash(event: {
event.created_at,
event.kind,
event.tags,
- event.content
+ event.content,
]);
return bytesToHex(sha256(serialized));
}
@@ -444,4 +523,80 @@ export async function signEvent(event: {
const id = getEventHash(event);
const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig);
-}
\ No newline at end of file
+}
+
+/**
+ * Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
+ * if they are not already prefixed and are not part of a hyperlink
+ */
+export function prefixNostrAddresses(content: string): string {
+ // Regex to match Nostr addresses that are not already prefixed with "nostr:"
+ // and are not part of a markdown link or HTML link
+ // Must be followed by at least 20 alphanumeric characters to be considered an address
+ const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g;
+
+ return content.replace(nostrAddressPattern, (match, offset) => {
+ // Check if this match is part of a markdown link [text](url)
+ const beforeMatch = content.substring(0, offset);
+ const afterMatch = content.substring(offset + match.length);
+
+ // Check if it's part of a markdown link
+ const beforeBrackets = beforeMatch.lastIndexOf('[');
+ const afterParens = afterMatch.indexOf(')');
+
+ if (beforeBrackets !== -1 && afterParens !== -1) {
+ const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets);
+ const lastOpenBracket = textBeforeBrackets.lastIndexOf('[');
+ const lastCloseBracket = textBeforeBrackets.lastIndexOf(']');
+
+ // If we have [text] before this, it might be a markdown link
+ if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) {
+ return match; // Don't prefix if it's part of a markdown link
+ }
+ }
+
+ // Check if it's part of an HTML link
+ const beforeHref = beforeMatch.lastIndexOf('href=');
+ if (beforeHref !== -1) {
+ const afterHref = afterMatch.indexOf('"');
+ if (afterHref !== -1) {
+ return match; // Don't prefix if it's part of an HTML link
+ }
+ }
+
+ // Check if it's already prefixed with "nostr:"
+ const beforeNostr = beforeMatch.lastIndexOf('nostr:');
+ if (beforeNostr !== -1) {
+ const textAfterNostr = beforeMatch.substring(beforeNostr + 6);
+ if (!textAfterNostr.includes(' ')) {
+ return match; // Already prefixed
+ }
+ }
+
+ // Additional check: ensure it's actually a valid Nostr address format
+ // The part after the prefix should be a valid bech32 string
+ const addressPart = match.substring(4); // Remove npub, nprofile, etc.
+ if (addressPart.length < 20) {
+ return match; // Too short to be a valid address
+ }
+
+ // Check if it looks like a valid bech32 string (alphanumeric, no special chars)
+ if (!/^[a-zA-Z0-9]+$/.test(addressPart)) {
+ return match; // Not a valid bech32 format
+ }
+
+ // Additional check: ensure the word before is not a common word that would indicate
+ // this is just a general reference, not an actual address
+ const wordBefore = beforeMatch.match(/\b(\w+)\s*$/);
+ if (wordBefore) {
+ const beforeWord = wordBefore[1].toLowerCase();
+ const commonWords = ['the', 'a', 'an', 'this', 'that', 'my', 'your', 'his', 'her', 'their', 'our'];
+ if (commonWords.includes(beforeWord)) {
+ return match; // Likely just a general reference, not an actual address
+ }
+ }
+
+ // Prefix with "nostr:"
+ return `nostr:${match}`;
+ });
+}
diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts
index c99f879..4fc4405 100644
--- a/src/lib/utils/npubCache.ts
+++ b/src/lib/utils/npubCache.ts
@@ -1,4 +1,4 @@
-import type { NostrProfile } from './nostrUtils';
+import type { NostrProfile } from "./nostrUtils";
export type NpubMetadata = NostrProfile;
@@ -48,4 +48,4 @@ class NpubCache {
}
}
-export const npubCache = new NpubCache();
\ No newline at end of file
+export const npubCache = new NpubCache();
diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts
new file mode 100644
index 0000000..29dc408
--- /dev/null
+++ b/src/lib/utils/profile_search.ts
@@ -0,0 +1,233 @@
+import { ndkInstance } from '$lib/ndk';
+import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
+import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
+import { searchCache } from '$lib/utils/searchCache';
+import { communityRelay, profileRelay } from '$lib/consts';
+import { get } from 'svelte/store';
+import type { NostrProfile, ProfileSearchResult } from './search_types';
+import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
+import { checkCommunityStatus } from './community_checker';
+import { TIMEOUTS } from './search_constants';
+
+/**
+ * Search for profiles by various criteria (display name, name, NIP-05, npub)
+ */
+export async function searchProfiles(searchTerm: string): Promise {
+ const normalizedSearchTerm = searchTerm.toLowerCase().trim();
+
+ // Check cache first
+ const cachedResult = searchCache.get('profile', normalizedSearchTerm);
+ if (cachedResult) {
+ const profiles = cachedResult.events.map(event => {
+ try {
+ const profileData = JSON.parse(event.content);
+ return createProfileFromEvent(event, profileData);
+ } catch {
+ return null;
+ }
+ }).filter(Boolean) as NostrProfile[];
+
+ const communityStatus = await checkCommunityStatus(profiles);
+ return { profiles, Status: communityStatus };
+ }
+
+ const ndk = get(ndkInstance);
+ if (!ndk) {
+ throw new Error('NDK not initialized');
+ }
+
+ let foundProfiles: NostrProfile[] = [];
+ let timeoutId: ReturnType | null = null;
+
+ // Set a timeout to force completion after profile search timeout
+ timeoutId = setTimeout(() => {
+ if (foundProfiles.length === 0) {
+ // Timeout reached, but no need to log this
+ }
+ }, TIMEOUTS.PROFILE_SEARCH);
+
+ try {
+ // Check if it's a valid npub/nprofile first
+ if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) {
+ try {
+ const metadata = await getUserMetadata(normalizedSearchTerm);
+ if (metadata) {
+ foundProfiles = [metadata];
+ }
+ } catch (error) {
+ console.error('Error fetching metadata for npub:', error);
+ }
+ } else if (normalizedSearchTerm.includes('@')) {
+ // Check if it's a NIP-05 address
+ try {
+ const npub = await getNpubFromNip05(normalizedSearchTerm);
+ if (npub) {
+ const metadata = await getUserMetadata(npub);
+ const profile: NostrProfile = {
+ ...metadata,
+ pubkey: npub
+ };
+ foundProfiles = [profile];
+ }
+ } catch (e) {
+ console.error('[Search] NIP-05 lookup failed:', e);
+ // If NIP-05 lookup fails, continue with regular search
+ }
+ } else {
+ // Try searching for NIP-05 addresses that match the search term
+ foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
+
+ // If no NIP-05 results found, search for profiles across relays
+ if (foundProfiles.length === 0) {
+ foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk);
+ }
+ }
+
+ // Wait for search to complete or timeout
+ await new Promise((resolve) => {
+ const checkComplete = () => {
+ if (timeoutId === null || foundProfiles.length > 0) {
+ resolve();
+ } else {
+ setTimeout(checkComplete, 100);
+ }
+ };
+ checkComplete();
+ });
+
+ // Cache the results
+ if (foundProfiles.length > 0) {
+ const events = foundProfiles.map(profile => {
+ const event = new NDKEvent(ndk);
+ event.content = JSON.stringify(profile);
+ event.pubkey = profile.pubkey || '';
+ return event;
+ });
+
+ const result = {
+ events,
+ secondOrder: [],
+ tTagEvents: [],
+ eventIds: new Set(),
+ addresses: new Set