Browse Source

Fixed the bugs. Added the m/M tags. Fixed the formatting. Split the parser into smaller functions and separated by basic/advanced. Added more test cases to the markdown test file.

master
Silberengel 11 months ago
parent
commit
abb3f2a75b
  1. 2
      package.json
  2. 91
      src/app.css
  3. 416
      src/lib/utils/advancedMarkdownParser.ts
  4. 235
      src/lib/utils/basicMarkdownParser.ts
  5. 629
      src/lib/utils/markdownParser.ts
  6. 23
      src/lib/utils/markdownTestfile.md
  7. 96
      src/lib/utils/mime.ts
  8. 161
      src/lib/utils/nostrUtils.ts
  9. 263
      src/routes/contact/+page.svelte

2
package.json

@ -19,7 +19,7 @@
"@popperjs/core": "2.11.x", "@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x", "@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"@types/highlight.js": "^9.12.4", "@types/highlight.js": "^11.11.1",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"d3": "^7.9.0", "d3": "^7.9.0",
"he": "1.2.x", "he": "1.2.x",

91
src/app.css

@ -2,40 +2,38 @@
@import './styles/publications.css'; @import './styles/publications.css';
@import './styles/visualize.css'; @import './styles/visualize.css';
@layer components { /* Custom styles */
/* General */ @layer base {
.leather { .leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200;
} }
.btn-leather.text-xs { .btn-leather.text-xs {
@apply w-7 h-7; @apply px-2 py-1;
} }
.btn-leather.text-xs svg { .btn-leather.text-xs svg {
@apply w-3 h-3; @apply h-3 w-3;
} }
.btn-leather.text-sm { .btn-leather.text-sm {
@apply w-8 h-8; @apply px-3 py-2;
} }
.btn-leather.text-sm svg { .btn-leather.text-sm svg {
@apply w-4 h-4; @apply h-4 w-4;
} }
div[role='tooltip'] button.btn-leather { div[role='tooltip'] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700; @apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700;
} }
/* Images */
.image-border { .image-border {
@apply border border-primary-700; @apply border border-primary-700;
} }
/* Card */
div.card-leather { div.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
} }
@ -52,7 +50,6 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200; @apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
} }
/* Content */
main { main {
@apply max-w-full; @apply max-w-full;
} }
@ -74,7 +71,6 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800; @apply hover:bg-primary-100 dark:hover:bg-primary-800;
} }
/* Section headers */
h1.h-leather, h1.h-leather,
h2.h-leather, h2.h-leather,
h3.h-leather, h3.h-leather,
@ -108,7 +104,6 @@
@apply text-base font-semibold; @apply text-base font-semibold;
} }
/* Modal */
div.modal-leather > div { div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; @apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
} }
@ -126,7 +121,6 @@
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
/* Navbar */
nav.navbar-leather { nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10; @apply bg-primary-0 dark:bg-primary-1000 z-10;
} }
@ -144,23 +138,20 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
/* Sidebar */
aside.sidebar-leather > div { aside.sidebar-leather > div {
@apply bg-gray-100 dark:bg-gray-900; @apply bg-primary-0 dark:bg-primary-1000;
} }
a.sidebar-item-leather { a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800; @apply hover:bg-primary-100 dark:hover:bg-primary-800;
} }
/* Skeleton */
div.skeleton-leather div { div.skeleton-leather div {
@apply bg-gray-400 dark:bg-gray-600; @apply bg-primary-100 dark:bg-primary-800;
} }
/* Textarea */
div.textarea-leather { div.textarea-leather {
@apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600; @apply bg-primary-0 dark:bg-primary-1000;
} }
div.textarea-leather > div:nth-child(1), div.textarea-leather > div:nth-child(1),
@ -169,7 +160,7 @@
} }
div.textarea-leather > div:nth-child(2) { div.textarea-leather > div:nth-child(2) {
@apply bg-gray-100 dark:bg-gray-900; @apply bg-primary-0 dark:bg-primary-1000;
} }
div.textarea-leather, div.textarea-leather,
@ -177,60 +168,66 @@
@apply text-gray-800 dark:text-gray-300; @apply text-gray-800 dark:text-gray-300;
} }
/* Tooltip */
div.tooltip-leather { div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300; @apply text-gray-800 dark:text-gray-300;
} }
div[role='tooltip'] button.btn-leather .tooltip-leather { div[role='tooltip'] button.btn-leather .tooltip-leather {
@apply bg-gray-200 dark:bg-gray-700; @apply bg-primary-100 dark:bg-primary-800;
} }
/* Unordered list */
.ul-leather li a { .ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
.network-link-leather { .network-link-leather {
@apply stroke-gray-400 fill-gray-400; @apply stroke-primary-200 fill-primary-200;
}
.network-node-leather {
@apply stroke-gray-800;
}
.network-node-content {
@apply fill-[#d6c1a8];
} }
/* Code blocks */ .network-node-leather {
.code-block { @apply stroke-primary-600;
@apply relative w-full max-w-[95%] overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-4 my-4 font-mono text-sm whitespace-pre;
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
.code-block::-webkit-scrollbar { .network-node-content {
height: 8px; @apply fill-primary-100;
} }
.code-block::-webkit-scrollbar-track { /* Code block styling - using highlight.js github-dark theme only */
@apply bg-transparent rounded-b-lg; pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
} }
.code-block::-webkit-scrollbar-thumb { .code-block {
@apply bg-gray-400 dark:bg-gray-600 rounded-full; @apply font-mono text-sm rounded-lg p-4 my-4 overflow-x-auto;
} }
/* Inline code */ /* Inline code */
.inline-code { .inline-code {
@apply font-mono text-sm bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded; @apply font-mono text-sm rounded px-1.5 py-0.5;
@apply bg-primary-900 text-gray-200;
} }
}
@layer components {
.leather-legend { .leather-legend {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow @apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow
border border-gray-200 dark:border-gray-800; border border-gray-200 dark:border-gray-800;
} }
.tooltip-leather { .tooltip-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; @apply bg-gray-100 dark:bg-gray-900;
}
/* Adjusting text styles for better contrast */
em, i {
@apply text-gray-700 dark:text-gray-200; /* Darker in light mode, lighter in dark mode */
}
strong, b {
@apply text-gray-900 dark:text-gray-100; /* Darker in light mode, lighter in dark mode */
}
code {
@apply text-gray-800 dark:text-gray-200; /* Adjusted for better contrast */
} }
} }

416
src/lib/utils/advancedMarkdownParser.ts

@ -0,0 +1,416 @@
import { parseBasicMarkdown } from './basicMarkdownParser';
import hljs from 'highlight.js';
import 'highlight.js/lib/common'; // Import common languages
import 'highlight.js/styles/github-dark.css'; // Dark theme only
import { processNostrIdentifiers } from './nostrUtils';
// Register common languages
hljs.configure({
ignoreUnescapedHTML: true
});
// Regular expressions for advanced markdown elements
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm;
const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
interface Footnote {
id: string;
text: string;
referenceCount: number;
}
interface FootnoteReference {
id: string;
count: number;
}
/**
* Process headings (both styles)
*/
function processHeadings(content: string): string {
// Process ATX-style headings (# Heading)
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
const headingLevel = level.length;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`;
});
// Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`;
});
return processedContent;
}
/**
* Process tables
*/
function processTables(content: string): string {
try {
if (!content) return '';
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => {
try {
// Split into rows and clean up
const rows = match.split('\n').filter(row => row.trim());
if (rows.length < 1) return match;
// Helper to process a row into cells
const processCells = (row: string): string[] => {
return row
.split('|')
.slice(1, -1) // Remove empty cells from start/end
.map(cell => cell.trim());
};
// Check if second row is a delimiter row (only hyphens)
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/);
// Extract header and body rows
let headerCells: string[] = [];
let bodyRows: string[] = [];
if (hasHeader) {
// If we have a header, first row is header, skip delimiter, rest is body
headerCells = processCells(rows[0]);
bodyRows = rows.slice(2);
} else {
// No header, all rows are body
bodyRows = rows;
}
// Build table HTML
let html = '<div class="overflow-x-auto my-4">\n';
html += '<table class="min-w-full border-collapse">\n';
// Add header if exists
if (hasHeader) {
html += '<thead>\n<tr>\n';
headerCells.forEach(cell => {
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`;
});
html += '</tr>\n</thead>\n';
}
// Add body
html += '<tbody>\n';
bodyRows.forEach(row => {
const cells = processCells(row);
html += '<tr>\n';
cells.forEach(cell => {
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`;
});
html += '</tr>\n';
});
html += '</tbody>\n</table>\n</div>';
return html;
} catch (error) {
console.error('Error processing table row:', error);
return match;
}
});
} catch (error) {
console.error('Error in processTables:', error);
return content;
}
}
/**
* Process links and images
*/
function processLinksAndImages(content: string): string {
// Process images first to avoid conflicts with links
let processedContent = content.replace(IMAGE_REGEX,
'<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy">'
);
// Process links
processedContent = processedContent.replace(LINK_REGEX,
'<a href="$2" class="text-primary-600 hover:underline">$1</a>'
);
return processedContent;
}
/**
* Process horizontal rules
*/
function processHorizontalRules(content: string): string {
return content.replace(HORIZONTAL_RULE_REGEX,
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">'
);
}
/**
* Process footnotes
*/
function processFootnotes(content: string): string {
try {
if (!content) return '';
// First collect all footnote references and definitions
const footnotes = new Map<string, string>();
const references = new Map<string, number>();
const referenceLocations = new Set<string>();
let nextNumber = 1;
// First pass: collect all references to establish order
let processedContent = content.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!referenceLocations.has(id) && !references.has(id)) {
references.set(id, nextNumber++);
}
referenceLocations.add(id);
return match; // Keep the reference for now
});
// Second pass: collect all definitions
processedContent = processedContent.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => {
footnotes.set(id, text.trim());
return ''; // Remove the definition
});
// Third pass: process references with collected information
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
if (!footnotes.has(id)) {
console.warn(`Footnote reference [^${id}] found but no definition exists`);
return match;
}
const num = references.get(id)!;
return `<sup><a href="#fn-${id}" id="fnref-${id}" class="text-primary-600 hover:underline">[${num}]</a></sup>`;
});
// Add footnotes section if we have any
if (references.size > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside">\n';
// Sort footnotes by their reference number
const sortedFootnotes = Array.from(references.entries())
.sort((a, b) => a[1] - b[1])
.filter(([id]) => footnotes.has(id)); // Only include footnotes that have definitions
// Add each footnote in order
for (const [id, num] of sortedFootnotes) {
const text = footnotes.get(id) || '';
processedContent += `<li id="fn-${id}" value="${num}"><span class="marker">${text}</span> <a href="#fnref-${id}" class="text-primary-600 hover:underline">↩</a></li>\n`;
}
processedContent += '</ol>';
}
return processedContent;
} catch (error) {
console.error('Error processing footnotes:', error);
return content;
}
}
/**
* Process blockquotes
*/
function processBlockquotes(content: string): string {
// Match blockquotes that might span multiple lines
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm;
return content.replace(blockquoteRegex, (match) => {
// Remove the '>' prefix from each line and preserve line breaks
const text = match
.split('\n')
.map(line => line.replace(/^>[ \t]?/, ''))
.join('\n')
.trim();
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`;
});
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let blockCount = 0;
let lastWasCodeBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(/^```(\w*)$/);
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
inCodeBlock = true;
currentLanguage = codeBlockStart[1];
currentCode = [];
lastWasCodeBlock = true;
} else {
// Ending current code block
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push(''); // Add spacing before code block
processedLines.push(id);
processedLines.push(''); // Add spacing after code block
inCodeBlock = false;
currentCode = [];
currentLanguage = '';
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
if (lastWasCodeBlock && line.trim()) {
processedLines.push('');
lastWasCodeBlock = false;
}
processedLines.push(line);
}
}
// Handle unclosed code block
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE_BLOCK_${blockCount}`;
const code = currentCode.join('\n');
// Try to format JSON if specified
let formattedCode = code;
if (currentLanguage.toLowerCase() === 'json') {
try {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
} catch (e) {
formattedCode = code;
}
}
blocks.set(id, JSON.stringify({
code: formattedCode,
language: currentLanguage,
raw: true
}));
processedLines.push('');
processedLines.push(id);
processedLines.push('');
}
return {
text: processedLines.join('\n'),
blocks
};
}
/**
* Restore code blocks with proper formatting
*/
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
let result = text;
for (const [id, blockData] of blocks) {
try {
const { code, language } = JSON.parse(blockData);
let html;
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, {
language,
ignoreIllegals: true
}).value;
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
} catch (e) {
console.warn('Failed to highlight code block:', e);
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`;
}
} else {
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`;
}
result = result.replace(id, html);
} catch (error) {
console.error('Error restoring code block:', error);
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>');
}
}
return result;
}
/**
* Parse markdown text with advanced formatting
*/
export async function parseAdvancedMarkdown(text: string): Promise<string> {
try {
if (!text) return '';
// Step 1: Extract and save code blocks first
const { text: withoutCode, blocks } = processCodeBlocks(text);
// Step 2: Process all other markdown
let processedText = withoutCode;
// Process block-level elements
processedText = processTables(processedText);
processedText = processBlockquotes(processedText);
processedText = processHeadings(processedText);
processedText = processHorizontalRules(processedText);
processedText = processLinksAndImages(processedText);
// Process inline elements
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
const escapedCode = code
.trim()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`;
});
// Process footnotes before basic markdown to prevent unwanted paragraph tags
processedText = processFootnotes(processedText);
// Process async elements
processedText = await processNostrIdentifiers(processedText);
processedText = await parseBasicMarkdown(processedText);
// Step 3: Restore code blocks
processedText = restoreCodeBlocks(processedText, blocks);
return processedText;
} catch (error) {
console.error('Error in parseAdvancedMarkdown:', error);
if (error instanceof Error) {
return `<div class="text-red-500">Error processing markdown: ${error.message}</div>`;
}
return '<div class="text-red-500">An error occurred while processing the markdown</div>';
}
}

235
src/lib/utils/basicMarkdownParser.ts

@ -0,0 +1,235 @@
import { processNostrIdentifiers } from './nostrUtils';
// Regular expressions for basic markdown elements
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
interface ListItem {
type: 'ul' | 'ol';
indent: number;
content: string;
marker: string;
}
// HTML escape function
function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
}
/**
* Process paragraphs and line breaks
*/
function processParagraphs(content: string): string {
try {
if (!content) return '';
// Split content into paragraphs (double line breaks)
const paragraphs = content.split(/\n\s*\n/);
// Process each paragraph
return paragraphs.map(para => {
if (!para.trim()) return '';
// Handle single line breaks within paragraphs
const lines = para.split('\n');
// Join lines with normal line breaks and add br after paragraph
return `<p>${lines.join('\n')}</p><br>`;
}).filter(Boolean).join('\n');
} catch (error) {
console.error('Error in processParagraphs:', error);
return content;
}
}
/**
* Process basic text formatting (bold, italic, strikethrough, hashtags, inline code)
*/
function processBasicFormatting(content: string): string {
try {
if (!content) return '';
// Process bold first to avoid conflicts
content = content.replace(BOLD_REGEX, '<strong>$2</strong>');
// Then process italic, handling both single and double underscores
content = content.replace(ITALIC_REGEX, match => {
const text = match.replace(/^_+|_+$/g, '');
return `<em>${text}</em>`;
});
// Then process strikethrough, handling both single and double tildes
content = content.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
});
// Finally process hashtags - style them with a lighter color
content = content.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>');
// Process inline code
content = content.replace(INLINE_CODE_REGEX, '<code class="bg-gray-200 dark:bg-gray-800/80 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-700">$1</code>');
return content;
} catch (error) {
console.error('Error in processBasicFormatting:', error);
return content;
}
}
/**
* Process blockquotes
*/
function processBlockquotes(content: string): string {
try {
if (!content) return '';
return content.replace(BLOCKQUOTE_REGEX, match => {
// Split into lines and process each line
const lines = match.split('\n').map(line => {
// Remove the '>' marker and trim any whitespace after it
return line.replace(/^[ \t]*>[ \t]?/, '').trim();
});
// Join the lines with proper spacing and wrap in blockquote
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${
lines.join('\n')
}</blockquote>`;
});
} catch (error) {
console.error('Error in processBlockquotes:', error);
return content;
}
}
/**
* Calculate indentation level from spaces
*/
function getIndentLevel(spaces: string): number {
return Math.floor(spaces.length / 2);
}
/**
* Process lists (ordered and unordered)
*/
function processLists(content: string): string {
const lines = content.split('\n');
const processed: string[] = [];
const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = [];
function closeList() {
if (listStack.length > 0) {
const list = listStack.pop()!;
const listType = list.type;
const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc';
const indentClass = list.level > 0 ? 'ml-6' : 'ml-4';
let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`;
list.items.forEach(item => {
listHtml += `\n <li class="pl-1">${item}</li>`;
});
listHtml += `\n</${listType}>`;
if (listStack.length > 0) {
// If we're in a nested list, add this as an item to the parent
const parentList = listStack[listStack.length - 1];
const lastItem = parentList.items.pop()!;
parentList.items.push(lastItem + '\n' + listHtml);
} else {
processed.push(listHtml);
}
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Count leading spaces to determine nesting level
const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0;
const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level
// Trim the line and check for list markers
const trimmedLine = line.trim();
const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/);
const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/);
if (orderedMatch || unorderedMatch) {
const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || '';
const type = orderedMatch ? 'ol' : 'ul';
// Close any lists that are at a deeper level
while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) {
closeList();
}
// If we're at a new level, start a new list
if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) {
listStack.push({ type, items: [], level: effectiveLevel });
}
// If we're at the same level but different type, close the current list and start a new one
else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) {
closeList();
listStack.push({ type, items: [], level: effectiveLevel });
}
// Add the item to the current list
listStack[listStack.length - 1].items.push(content);
} else {
// Not a list item - close all open lists and add the line
while (listStack.length > 0) {
closeList();
}
processed.push(line);
}
}
// Close any remaining open lists
while (listStack.length > 0) {
closeList();
}
return processed.join('\n');
}
/**
* Parse markdown text with basic formatting
*/
export async function parseBasicMarkdown(text: string): Promise<string> {
try {
if (!text) return '';
let processedText = text;
// Process lists first to handle indentation properly
processedText = processLists(processedText);
// Process blockquotes next
processedText = processBlockquotes(processedText);
// Process paragraphs
processedText = processParagraphs(processedText);
// Process basic text formatting
processedText = processBasicFormatting(processedText);
// Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText);
return processedText;
} catch (error) {
console.error('Error in parseBasicMarkdown:', error);
if (error instanceof Error) {
return `<div class="text-red-500">Error processing markdown: ${error.message}</div>`;
}
return '<div class="text-red-500">An error occurred while processing the markdown</div>';
}
}

629
src/lib/utils/markdownParser.ts

@ -1,16 +1,11 @@
/** /**
* Markdown parser with special handling for nostr identifiers * Process inline code
*/ */
function processInlineCode(text: string): string {
import { get } from 'svelte/store'; return text.replace(INLINE_CODE_REGEX, (match, code) => {
import { ndkInstance } from '$lib/ndk'; return `<code class="bg-gray-200 dark:bg-gray-800/80 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-700">${code}</code>`;
import { nip19 } from 'nostr-tools'; });
import hljs from 'highlight.js'; }
import 'highlight.js/styles/github-dark.css';
// Regular expressions for nostr identifiers - process these first
const NOSTR_PROFILE_REGEX = /(?:nostr:)?((?:npub|nprofile)[a-zA-Z0-9]{20,})/g;
const NOSTR_NOTE_REGEX = /(?:nostr:)?((?:nevent|note|naddr)[a-zA-Z0-9]{20,})/g;
// Regular expressions for markdown elements // Regular expressions for markdown elements
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g; const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
@ -28,614 +23,4 @@ const TABLE_REGEX = /^\|(.+)\|\r?\n\|([-|\s]+)\|\r?\n((?:\|.+\|\r?\n?)+)$/gm;
const TABLE_ROW_REGEX = /^\|(.+)\|$/gm; const TABLE_ROW_REGEX = /^\|(.+)\|$/gm;
const TABLE_DELIMITER_REGEX = /^[\s-]+$/; const TABLE_DELIMITER_REGEX = /^[\s-]+$/;
// Cache for npub metadata // ... existing code ...
const npubCache = new Map<string, {name?: string, displayName?: string}>();
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
if (npubCache.has(identifier)) {
return npubCache.get(identifier)!;
}
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
npubCache.set(identifier, fallback);
return fallback;
}
const decoded = nip19.decode(identifier);
if (!decoded) {
npubCache.set(identifier, fallback);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(identifier, fallback);
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(identifier, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(identifier, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(identifier, metadata);
return metadata;
} catch (e) {
npubCache.set(identifier, fallback);
return fallback;
}
} catch (e) {
npubCache.set(identifier, fallback);
return fallback;
}
}
/**
* Process lists (ordered and unordered)
*/
function processLists(content: string): string {
const lines = content.split('\n');
const processed: string[] = [];
const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = [];
function closeList() {
if (listStack.length > 0) {
const list = listStack.pop()!;
const listType = list.type;
const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc';
const indentClass = list.level > 0 ? 'ml-6' : 'ml-4';
let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`;
list.items.forEach(item => {
listHtml += `\n <li class="pl-1">${item}</li>`;
});
listHtml += `\n</${listType}>`;
if (listStack.length > 0) {
// If we're in a nested list, add this as an item to the parent
const parentList = listStack[listStack.length - 1];
const lastItem = parentList.items.pop()!;
parentList.items.push(lastItem + '\n' + listHtml);
} else {
processed.push(listHtml);
}
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Count leading spaces to determine nesting level
const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0;
const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level
// Trim the line and check for list markers
const trimmedLine = line.trim();
const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/);
const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/);
if (orderedMatch || unorderedMatch) {
const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || '';
const type = orderedMatch ? 'ol' : 'ul';
// Close any lists that are at a deeper level
while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) {
closeList();
}
// If we're at a new level, start a new list
if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) {
listStack.push({ type, items: [], level: effectiveLevel });
}
// If we're at the same level but different type, close the current list and start a new one
else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) {
closeList();
listStack.push({ type, items: [], level: effectiveLevel });
}
// Add the item to the current list
listStack[listStack.length - 1].items.push(content);
} else {
// Not a list item - close all open lists and add the line
while (listStack.length > 0) {
closeList();
}
processed.push(line);
}
}
// Close any remaining open lists
while (listStack.length > 0) {
closeList();
}
return processed.join('\n');
}
/**
* Process blockquotes by finding consecutive quote lines and preserving their structure
*/
function processBlockquotes(text: string): string {
const lines = text.split('\n');
const processedLines: string[] = [];
let currentQuote: string[] = [];
let quoteCount = 0;
let lastLineWasQuote = false;
const blockquotes: Array<{id: string, content: string}> = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isQuoteLine = line.startsWith('> ');
if (isQuoteLine) {
// If we had a gap between quotes, this is a new quote
if (!lastLineWasQuote && currentQuote.length > 0) {
quoteCount++;
const id = `BLOCKQUOTE_${quoteCount}`;
const quoteContent = currentQuote.join('<br>');
blockquotes.push({
id,
content: `<div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2">${quoteContent}</p></div>`
});
processedLines.push(id);
currentQuote = [];
}
// Add to current quote
currentQuote.push(line.substring(2));
lastLineWasQuote = true;
} else {
// If we were in a quote and now we're not, process it
if (currentQuote.length > 0) {
quoteCount++;
const id = `BLOCKQUOTE_${quoteCount}`;
const quoteContent = currentQuote.join('<br>');
blockquotes.push({
id,
content: `<div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2">${quoteContent}</p></div>`
});
processedLines.push(id);
currentQuote = [];
}
processedLines.push(line);
lastLineWasQuote = false;
}
}
// Handle any remaining quote
if (currentQuote.length > 0) {
quoteCount++;
const id = `BLOCKQUOTE_${quoteCount}`;
const quoteContent = currentQuote.join('<br>');
blockquotes.push({
id,
content: `<div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2">${quoteContent}</p></div>`
});
processedLines.push(id);
}
let result = processedLines.join('\n');
// Restore blockquotes
blockquotes.forEach(({id, content}) => {
result = result.replace(id, content);
});
return result;
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map<string, string>();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let blockCount = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(/^```(\w*)$/);
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
inCodeBlock = true;
currentLanguage = codeBlockStart[1];
currentCode = [];
} else {
// Ending current code block
blockCount++;
const id = `CODE-BLOCK-${blockCount}`;
const code = currentCode.join('\n');
// Store the raw code and language for later processing
blocks.set(id, JSON.stringify({
code,
language: currentLanguage
}));
processedLines.push(id);
inCodeBlock = false;
currentLanguage = '';
currentCode = [];
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
processedLines.push(line);
}
}
// Handle unclosed code block
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE-BLOCK-${blockCount}`;
const code = currentCode.join('\n');
blocks.set(id, JSON.stringify({
code,
language: currentLanguage
}));
processedLines.push(id);
}
return {
text: processedLines.join('\n'),
blocks
};
}
/**
* Restore code blocks with proper formatting
*/
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string {
let result = text;
blocks.forEach((blockContent, id) => {
const { code, language } = JSON.parse(blockContent);
let processedCode = code;
// Format JSON if the language is specified as json
if (language === 'json') {
try {
const jsonObj = JSON.parse(code.trim());
processedCode = JSON.stringify(jsonObj, null, 2);
} catch (e) {
console.warn('Failed to parse JSON:', e);
}
}
// Apply syntax highlighting if language is specified
if (language) {
try {
if (hljs.getLanguage(language)) {
const highlighted = hljs.highlight(processedCode, { language });
processedCode = highlighted.value;
}
} catch (e) {
console.warn('Failed to apply syntax highlighting:', e);
}
}
const languageClass = language ? ` language-${language}` : '';
const replacement = `<div class="relative group my-2">
<button
type="button"
class="absolute right-2 top-2 p-2 bg-gray-700/50 hover:bg-gray-600/50 rounded opacity-0 group-hover:opacity-100 transition-opacity"
onclick="event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText(this.parentElement.querySelector('code').textContent);"
>
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
</svg>
</button>
<pre class="code-block whitespace-pre overflow-x-auto bg-gray-900 rounded-md p-3" data-language="${language || ''}"><code class="hljs${languageClass}">${processedCode}</code></pre>
</div>`;
result = result.replace(id, replacement);
});
return result;
}
/**
* Process inline code
*/
function processInlineCode(text: string): string {
return text.replace(INLINE_CODE_REGEX, (match, code) => {
return `<code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm font-mono">${code}</code>`;
});
}
/**
* Process markdown tables
*/
function processTables(content: string): string {
return content.replace(TABLE_REGEX, (match, headerRow, delimiterRow, bodyRows) => {
// Process header row
const headers: string[] = headerRow
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
// Validate delimiter row (should contain only dashes and spaces)
const delimiters: string[] = delimiterRow
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
if (!delimiters.every(d => TABLE_DELIMITER_REGEX.test(d))) {
return match;
}
// Process body rows
const rows: string[][] = bodyRows
.trim()
.split('\n')
.map((row: string) => {
return row
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
})
.filter((row: string[]) => row.length > 0);
// Generate HTML table with leather theme styling and thicker grid lines
let table = '<div class="my-6 overflow-x-auto rounded-lg border-2 border-gray-300 dark:border-gray-600">\n';
table += '<table class="min-w-full border-collapse">\n';
// Add header with leather theme styling
table += '<thead class="bg-gray-100 dark:bg-gray-800">\n<tr>\n';
headers.forEach((header: string) => {
table += `<th class="px-6 py-3 text-left text-sm font-semibold text-gray-800 dark:text-gray-200 border-2 border-gray-300 dark:border-gray-600">${header}</th>\n`;
});
table += '</tr>\n</thead>\n';
// Add body with leather theme styling
table += '<tbody class="bg-primary-0 dark:bg-primary-1000">\n';
rows.forEach((row: string[], index: number) => {
table += `<tr class="hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">\n`;
row.forEach((cell: string) => {
table += `<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap border-2 border-gray-300 dark:border-gray-600">${cell}</td>\n`;
});
table += '</tr>\n';
});
table += '</tbody>\n</table>\n</div>';
return table;
});
}
/**
* Process other markdown elements (excluding code)
*/
function processOtherElements(content: string): string {
// Process blockquotes first
content = processBlockquotes(content);
// Process tables before other elements
content = processTables(content);
// Process basic markdown elements
content = content.replace(BOLD_REGEX, '<strong>$1$2</strong>');
content = content.replace(ITALIC_REGEX, '<em>$1</em>');
// Process alternate heading syntax first (=== or ---)
content = content.replace(ALTERNATE_HEADING_REGEX, (match, content, level) => {
const headingLevel = level.startsWith('=') ? 1 : 2;
const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
return `<h${headingLevel} class="${sizes[headingLevel-1]} font-bold mt-4 mb-2">${content.trim()}</h${headingLevel}>`;
});
// Process standard heading syntax (#)
content = content.replace(HEADING_REGEX, (match, hashes, content) => {
const level = hashes.length;
const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
return `<h${level} class="${sizes[level-1]} font-bold mt-4 mb-2">${content.trim()}</h${level}>`;
});
// Process links and images with standardized styling
content = content.replace(IMAGE_REGEX, '<img src="$2" alt="$1" class="max-w-full h-auto rounded">');
content = content.replace(LINK_REGEX, '<a href="$2" target="_blank" class="hover:underline text-primary-600 dark:text-primary-500">$1</a>');
// Process hashtags with standardized styling
content = content.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>');
// Process horizontal rules
content = content.replace(HORIZONTAL_RULE_REGEX, '<hr class="my-6 border-t-2 border-gray-300 dark:border-gray-600">');
return content;
}
/**
* Process footnotes with minimal spacing
*/
function processFootnotes(text: string): { text: string, footnotes: Map<string, string> } {
const footnotes = new Map<string, string>();
let counter = 0;
// Extract footnote definitions
text = text.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, content) => {
const cleanId = id.replace('^', '');
footnotes.set(cleanId, content.trim());
return '';
});
// Replace references with standardized styling
text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
const cleanId = id.replace('^', '');
if (footnotes.has(cleanId)) {
counter++;
return `<sup><a href="#footnote-${cleanId}" id="ref-${cleanId}" class="hover:underline text-primary-600 dark:text-primary-500 scroll-mt-32">[${counter}]</a></sup>`;
}
return match;
});
// Add footnotes section if we have any
if (footnotes.size > 0) {
text += '\n<div class="footnotes mt-8 pt-4 border-t border-gray-300 dark:border-gray-600">';
text += '<ol class="list-decimal pl-6 space-y-0.5">';
counter = 0;
for (const [id, content] of footnotes.entries()) {
counter++;
text += `<li id="footnote-${id}" class="text-sm text-gray-600 dark:text-gray-400 scroll-mt-32">${content}<a href="#ref-${id}" class="hover:underline text-primary-600 dark:text-primary-500 ml-1 scroll-mt-32">↩</a></li>`;
}
text += '</ol></div>';
}
return { text, footnotes };
}
/**
* Process nostr identifiers
*/
async function processNostrIdentifiers(content: string): Promise<string> {
let processedContent = content;
// Process profiles (npub and nprofile)
const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX));
for (const match of profileMatches) {
const [fullMatch, identifier] = match;
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name || `${identifier.slice(0, 8)}...${identifier.slice(-4)}`;
const escapedId = identifier
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const escapedDisplayText = displayText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Create a link with standardized styling
const link = `<a href="https://njump.me/${escapedId}" target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 items-center" title="${escapedId}">@${escapedDisplayText}</a>`;
// Replace only the exact match to preserve surrounding text
processedContent = processedContent.replace(fullMatch, link);
}
// Process notes (nevent, note, naddr)
const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX));
for (const match of noteMatches) {
const [fullMatch, identifier] = match;
const shortId = identifier.slice(0, 12) + '...' + identifier.slice(-8);
const escapedId = identifier
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Create a link with standardized styling
const link = `<a href="https://njump.me/${escapedId}" target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all items-center" title="${escapedId}">${shortId}</a>`;
// Replace only the exact match to preserve surrounding text
processedContent = processedContent.replace(fullMatch, link);
}
return processedContent;
}
/**
* Parse markdown text to content with special handling for nostr identifiers
*/
export async function parseMarkdown(text: string): Promise<string> {
if (!text) return '';
// First extract and save code blocks
const { text: withoutCode, blocks } = processCodeBlocks(text);
// Process nostr identifiers
let content = await processNostrIdentifiers(withoutCode);
// Process blockquotes
content = processBlockquotes(content);
// Process lists
content = processLists(content);
// Process other markdown elements
content = processOtherElements(content);
// Process inline code (after other elements to prevent conflicts)
content = processInlineCode(content);
// Process footnotes
const { text: processedContent } = processFootnotes(content);
content = processedContent;
// Handle paragraphs and line breaks, preserving existing HTML
content = content
.split(/\n{2,}/)
.map((para: string) => para.trim())
.filter((para: string) => para)
.map((para: string) => para.startsWith('<') ? para : `<p class="my-4 break-words">${para}</p>`)
.join('\n\n');
// Finally, restore code blocks
content = restoreCodeBlocks(content, blocks);
return content;
}
/**
* Escape special characters in a string for use in a regular expression
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function processCode(text: string): string {
// Process code blocks with language specification
text = text.replace(/```(\w+)?\n([\s\S]+?)\n```/g, (match, lang, code) => {
if (lang === 'json') {
try {
const parsed = JSON.parse(code.trim());
code = JSON.stringify(parsed, null, 2);
// Add syntax highlighting classes for JSON
code = code.replace(/"([^"]+)":/g, '"<span class="json-key">$1</span>":') // keys
.replace(/"([^"]+)"/g, '"<span class="json-string">$1</span>"') // strings
.replace(/\b(true|false)\b/g, '<span class="json-boolean">$1</span>') // booleans
.replace(/\b(null)\b/g, '<span class="json-null">$1</span>') // null
.replace(/\b(\d+\.?\d*)\b/g, '<span class="json-number">$1</span>'); // numbers
} catch (e) {
// If JSON parsing fails, use the original code
}
}
return `<div class="code-block" data-language="${lang || ''}">${code}</div>`;
});
// Process inline code
text = text.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
return text;
}

23
src/lib/utils/markdownTestfile.md

@ -3,7 +3,9 @@ This is a test
### Disclaimer ### Disclaimer
It is _only_ a test. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser.
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
@ -40,6 +42,9 @@ Let's nest that:
3. third 3. third
4. fourth indented 4. fourth indented
5. fifth indented even more 5. fifth indented even more
6. sixth under the fourth
7. seventh under the sixth
8. eighth under the third
This is ordered and unordered mixed: This is ordered and unordered mixed:
1. first 1. first
@ -67,7 +72,7 @@ nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydh
This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even include `code inline` or You can even include `code inline`, like `<div class="leather min-h-full w-full flex flex-col items-center">` or
``` ```
in a code block in a code block
@ -130,7 +135,6 @@ package main
input := scanner.Text() input := scanner.Text()
fmt.Println("You entered:", input) fmt.Println("You entered:", input)
} }
``` ```
or even Markdown: or even Markdown:
@ -144,7 +148,7 @@ Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists 2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists
look like: look like:
* this one * this one[^some reference text]
* that one * that one
* the other one * the other one
@ -164,7 +168,7 @@ content starts at 4-columns in.
### I went ahead and implemented tables, too. ### I went ahead and implemented tables, too.
A neat table: A neat table[^some reference text]:
| Syntax | Description | | Syntax | Description |
| ----------- | ----------- | | ----------- | ----------- |
@ -178,5 +182,12 @@ A messy table (should render the same as above):
| Header | Title | | Header | Title |
| Paragraph | Text | | Paragraph | Text |
Here is a table without a header row:
| Sometimes | you don't |
| need a | header |
| just | pipes |
[^1]: this is a footnote [^1]: this is a footnote
[^2]: so is this [^2]: so is this
[^some reference text]: this is a footnote that isn't a number

96
src/lib/utils/mime.ts

@ -0,0 +1,96 @@
/**
* Determine the type of Nostr event based on its kind number
* Following NIP specification for kind ranges:
* - Replaceable: 0, 3, 10000-19999 (only latest stored)
* - Ephemeral: 20000-29999 (not stored)
* - Addressable: 30000-39999 (latest per d-tag stored)
* - Regular: all other kinds (stored by relays)
*/
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' {
// Check special ranges first
if (kind >= 30000 && kind < 40000) {
return 'addressable';
}
if (kind >= 20000 && kind < 30000) {
return 'ephemeral';
}
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) {
return 'replaceable';
}
// Everything else is regular
return 'regular';
}
/**
* Get MIME tags for a Nostr event based on its kind number
* Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]]
* Following NKBIP-06 and NIP-94 specifications
*/
export function getMimeTags(kind: number): [string, string][] {
// Default tags for unknown kinds
let mTag: [string, string] = ["m", "text/plain"];
let MTag: [string, string] = ["M", "note/generic/nonreplaceable"];
// Determine replaceability based on event type
const eventType = getEventType(kind);
const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
? "replaceable"
: "nonreplaceable";
switch (kind) {
// Short text note
case 1:
mTag = ["m", "text/plain"];
MTag = ["M", `note/microblog/${replaceability}`];
break;
// Generic reply
case 1111:
mTag = ["m", "text/plain"];
MTag = ["M", `note/comment/${replaceability}`];
break;
// Issue
case 1621:
mTag = ["m", "text/markdown"];
MTag = ["M", `git/issue/${replaceability}`];
break;
// Issue comment
case 1622:
mTag = ["m", "text/markdown"];
MTag = ["M", `git/comment/${replaceability}`];
break;
// Book metadata
case 30040:
mTag = ["m", "application/json"];
MTag = ["M", `meta-data/index/${replaceability}`];
break;
// Book content
case 30041:
mTag = ["m", "text/asciidoc"];
MTag = ["M", `article/publication-content/${replaceability}`];
break;
// Wiki page
case 30818:
mTag = ["m", "text/asciidoc"];
MTag = ["M", `article/wiki/${replaceability}`];
break;
// Long-form note
case 30023:
mTag = ["m", "text/markdown"];
MTag = ["M", `article/long-form/${replaceability}`];
break;
// Add more cases as needed...
}
return [mTag, MTag];
}

161
src/lib/utils/nostrUtils.ts

@ -0,0 +1,161 @@
import { get } from 'svelte/store';
import { nip19 } from 'nostr-tools';
import { ndkInstance } from '$lib/ndk';
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
// Cache for npub metadata
const npubCache = new Map<string, {name?: string, displayName?: string}>();
/**
* HTML escape a string
*/
function escapeHtml(text: string): string {
const htmlEscapes: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
}
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
if (npubCache.has(cleanId)) {
return npubCache.get(cleanId)!;
}
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
npubCache.set(cleanId, fallback);
return fallback;
}
const decoded = nip19.decode(cleanId);
if (!decoded) {
npubCache.set(cleanId, fallback);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(cleanId, fallback);
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(cleanId, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(cleanId, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
}
/**
* Create a profile link element
*/
function createProfileLink(identifier: string, displayText: string | undefined): string {
const cleanId = identifier.replace(/^nostr:/, '');
const escapedId = escapeHtml(cleanId);
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`;
const escapedText = escapeHtml(displayText || defaultText);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`;
}
/**
* Create a note link element
*/
function createNoteLink(identifier: string): string {
const cleanId = identifier.replace(/^nostr:/, '');
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`;
const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId);
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`;
}
/**
* Process Nostr identifiers in text
*/
export async function processNostrIdentifiers(content: string): Promise<string> {
let processedContent = content;
// Process profiles (npub and nprofile)
const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX));
for (const match of profileMatches) {
const [fullMatch, identifier] = match;
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
processedContent = processedContent.replace(fullMatch, link);
}
// Process notes (nevent, note, naddr)
const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX));
for (const match of noteMatches) {
const [fullMatch, identifier] = match;
const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link);
}
return processedContent;
}
export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.error('NDK not initialized');
return null;
}
const user = await ndk.getUser({ nip05 });
if (!user || !user.npub) {
return null;
}
return user.npub;
} catch (error) {
console.error('Error getting npub from nip05:', error);
return null;
}
}

263
src/routes/contact/+page.svelte

@ -1,13 +1,14 @@
<script lang='ts'> <script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Tabs, TabItem, Modal } from 'flowbite-svelte'; import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance, activePubkey } from '$lib/ndk'; import { ndkSignedIn, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
import { onMount } from 'svelte'; import type NDK from '@nostr-dev-kit/ndk';
import NDK, { NDKEvent, NDKRelay, NDKRelaySet } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue // @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte'; import LoginModal from '$lib/components/LoginModal.svelte';
import { parseMarkdown } from '$lib/utils/markdownParser'; import { parseAdvancedMarkdown } from '$lib/utils/advancedMarkdownParser';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { getMimeTags } from '$lib/utils/mime';
// Function to close the success message // Function to close the success message
function closeSuccessMessage() { function closeSuccessMessage() {
@ -46,7 +47,7 @@
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
// Hard-coded relays to ensure we have working relays // Hard-coded relays to ensure we have working relays
const hardcodedRelays = [ const allRelays = [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.nostr.band', 'wss://relay.nostr.band',
'wss://nos.lol', 'wss://nos.lol',
@ -58,11 +59,6 @@
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'; const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
const repoId = 'Alexandria'; const repoId = 'Alexandria';
onMount(() => {
console.log('Repository owner pubkey:', repoOwnerPubkey);
console.log('Repository ID:', repoId);
});
// Function to normalize relay URLs by removing trailing slashes // Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string { function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, ''); return url.replace(/\/+$/, '');
@ -107,14 +103,75 @@
showConfirmDialog = false; showConfirmDialog = false;
} }
/**
* Publish event to relays with retry logic
*/
async function publishToRelays(
event: NDKEvent,
ndk: NDK,
relays: Set<string>,
maxRetries: number = 3,
timeout: number = 10000
): Promise<string[]> {
const successfulRelays: string[] = [];
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk);
// Set up listeners for successful publishes
const publishPromises = Array.from(relays).map(relayUrl => {
return new Promise<void>(resolve => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
successfulRelays.push(relayUrl);
resolve();
}
});
} else {
resolve(); // Resolve if relay not available
}
});
});
// Try publishing with retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), timeout);
});
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
if (successfulRelays.length > 0) {
break; // Exit retry loop if we have successful publishes
}
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
} catch (error) {
if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts');
}
}
}
return successfulRelays;
}
async function submitIssue() { async function submitIssue() {
isSubmitting = true; isSubmitting = true;
submissionError = ''; submissionError = '';
submissionSuccess = false; submissionSuccess = false;
try { try {
console.log('Starting issue submission...');
// Get NDK instance // Get NDK instance
const ndk = $ndkInstance; const ndk = $ndkInstance;
if (!ndk) { if (!ndk) {
@ -125,109 +182,21 @@
throw new Error('No signer available. Make sure you are logged in.'); throw new Error('No signer available. Make sure you are logged in.');
} }
console.log('NDK instance available with signer'); // Create and prepare the event
console.log('Active pubkey:', $activePubkey); const event = await createIssueEvent(ndk);
// Log the repository reference values
console.log('Using repository reference values:', { repoOwnerPubkey, repoId });
// Create a new NDK event
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
console.log('Adding a tag with value:', aTagValue);
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value
console.log('Adding p tag with value:', repoOwnerPubkey);
event.tags.push(['p', repoOwnerPubkey]);
// Set content
event.content = content;
console.log('Created NDK event:', event);
// Sign the event
console.log('Signing event...');
try {
await event.sign();
console.log('Event signed successfully');
} catch (error) {
console.error('Failed to sign event:', error);
throw new Error('Failed to sign event');
}
// Collect all unique relays // Collect all unique relays
const uniqueRelays = new Set([ const uniqueRelays = new Set([
...hardcodedRelays.map(normalizeRelayUrl), ...allRelays.map(normalizeRelayUrl),
...standardRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values()) ...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol')) .filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : []) .map(relay => normalizeRelayUrl(relay.url)) : [])
]); ]);
console.log('Publishing to relays:', Array.from(uniqueRelays));
try { try {
// Create NDK relay set // Publish to relays with retry logic
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(uniqueRelays), ndk); successfulRelays = await publishToRelays(event, ndk, uniqueRelays);
// Track successful relays
successfulRelays = [];
// Set up listeners for successful publishes
const publishPromises = Array.from(uniqueRelays).map(relayUrl => {
return new Promise<void>(resolve => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
console.log(`Event published to relay: ${relayUrl}`);
successfulRelays = [...successfulRelays, relayUrl];
resolve();
}
});
} else {
resolve(); // Resolve if relay not available
}
});
});
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), 10000);
});
try {
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
console.log('Event published successfully to', successfulRelays.length, 'relays');
if (successfulRelays.length === 0) {
console.warn('Event published but no relay confirmations received');
}
} catch (error) {
if (successfulRelays.length > 0) {
console.warn('Partial publish success:', error);
} else {
throw new Error('Failed to publish to any relays');
}
}
// Store the submitted event and create issue link // Store the submitted event and create issue link
submittedEvent = event; submittedEvent = event;
@ -235,22 +204,57 @@
const noteId = nip19.noteEncode(event.id); const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`; issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Reset form and show success message // Clear form and show success message
subject = ''; clearForm();
content = '';
submissionSuccess = true; submissionSuccess = true;
} catch (error) { } catch (error) {
console.error('Failed to publish event:', error);
throw new Error('Failed to publish event'); throw new Error('Failed to publish event');
} }
} catch (error: any) { } catch (error: any) {
console.error('Error submitting issue:', error);
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`; submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
} }
/**
* Create and sign a new issue event
*/
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> {
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value
event.tags.push(['p', repoOwnerPubkey]);
// Add MIME tags
const mimeTags = getMimeTags(1621);
event.tags.push(...mimeTags);
// Set content
event.content = content;
// Sign the event
try {
await event.sign();
} catch (error) {
throw new Error('Failed to sign event');
}
return event;
}
// Handle login completion // Handle login completion
$: if ($ndkSignedIn && showLoginModal) { $: if ($ndkSignedIn && showLoginModal) {
showLoginModal = false; showLoginModal = false;
@ -290,7 +294,7 @@
<div class="relative"> <div class="relative">
<Label for="content" class="mb-2">Description</Label> <Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[1200px]' : 'h-[300px]'} transition-all duration-200"> <div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full">
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<div class="border-b border-gray-300 dark:border-gray-600"> <div class="border-b border-gray-300 dark:border-gray-600">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> <ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist">
@ -327,13 +331,15 @@
required required
placeholder="Describe your issue in detail... placeholder="Describe your issue in detail...
Markdown formatting is supported: The following Markdown is supported:
# Headers (1-6 levels) # Headers (1-6 levels)
**Bold** or *Bold* *Bold* or **bold**
_Italic_ text _Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes > Blockquotes
@ -343,11 +349,14 @@ Lists, including nested:
[Links](url) [Links](url)
![Images](url) ![Images](url)
`Inline code` `Inline code`
```language ```language
Code blocks with syntax highlighting for over 100 languages Code blocks with syntax highlighting for over 100 languages
``` ```
| Tables | With |
| Tables | With or without headers |
|--------|------| |--------|------|
| Multiple | Rows | | Multiple | Rows |
@ -358,13 +367,15 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div> </div>
{:else} {:else}
<div class="absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content"> <div class="absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content">
{#await parseMarkdown(content)} {#key content}
<p>Loading preview...</p> {#await parseAdvancedMarkdown(content)}
{:then html} <p>Loading preview...</p>
{@html html} {:then html}
{:catch error} {@html html || '<p class="text-gray-500">Nothing to preview</p>'}
<p class="text-red-500">Error rendering markdown: {error.message}</p> {:catch error}
{/await} <p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div> </div>
{/if} {/if}
</div> </div>
@ -421,8 +432,8 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div> </div>
<div> <div>
<span class="font-semibold">Description:</span> <span class="font-semibold">Description:</span>
<div class="mt-1 note-leather"> <div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseMarkdown(submittedEvent.content)} {#await parseAdvancedMarkdown(submittedEvent.content)}
<p>Loading...</p> <p>Loading...</p>
{:then html} {:then html}
{@html html} {@html html}

Loading…
Cancel
Save