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. 87
      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. 21
      src/lib/utils/markdownTestfile.md
  7. 96
      src/lib/utils/mime.ts
  8. 161
      src/lib/utils/nostrUtils.ts
  9. 239
      src/routes/contact/+page.svelte

2
package.json

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

87
src/app.css

@ -2,38 +2,36 @@ @@ -2,38 +2,36 @@
@import './styles/publications.css';
@import './styles/visualize.css';
@layer components {
/* General */
/* Custom styles */
@layer base {
.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 {
@apply w-7 h-7;
@apply px-2 py-1;
}
.btn-leather.text-xs svg {
@apply w-3 h-3;
@apply h-3 w-3;
}
.btn-leather.text-sm {
@apply w-8 h-8;
@apply px-3 py-2;
}
.btn-leather.text-sm svg {
@apply w-4 h-4;
@apply h-4 w-4;
}
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;
}
/* Images */
.image-border {
@apply border border-primary-700;
}
/* Card */
div.card-leather {
@apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
@ -52,7 +50,6 @@ @@ -52,7 +50,6 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
}
/* Content */
main {
@apply max-w-full;
}
@ -74,7 +71,6 @@ @@ -74,7 +71,6 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Section headers */
h1.h-leather,
h2.h-leather,
h3.h-leather,
@ -108,7 +104,6 @@ @@ -108,7 +104,6 @@
@apply text-base font-semibold;
}
/* Modal */
div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
}
@ -126,7 +121,6 @@ @@ -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;
}
/* Navbar */
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
@ -144,23 +138,20 @@ @@ -144,23 +138,20 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
/* Sidebar */
aside.sidebar-leather > div {
@apply bg-gray-100 dark:bg-gray-900;
@apply bg-primary-0 dark:bg-primary-1000;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
/* Skeleton */
div.skeleton-leather div {
@apply bg-gray-400 dark:bg-gray-600;
@apply bg-primary-100 dark:bg-primary-800;
}
/* Textarea */
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),
@ -169,7 +160,7 @@ @@ -169,7 +160,7 @@
}
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,
@ -177,60 +168,66 @@ @@ -177,60 +168,66 @@
@apply text-gray-800 dark:text-gray-300;
}
/* Tooltip */
div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300;
}
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 {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.network-link-leather {
@apply stroke-gray-400 fill-gray-400;
}
.network-node-leather {
@apply stroke-gray-800;
}
.network-node-content {
@apply fill-[#d6c1a8];
@apply stroke-primary-200 fill-primary-200;
}
/* Code blocks */
.code-block {
@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;
.network-node-leather {
@apply stroke-primary-600;
}
.code-block::-webkit-scrollbar {
height: 8px;
.network-node-content {
@apply fill-primary-100;
}
.code-block::-webkit-scrollbar-track {
@apply bg-transparent rounded-b-lg;
/* Code block styling - using highlight.js github-dark theme only */
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
.code-block::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-600 rounded-full;
.code-block {
@apply font-mono text-sm rounded-lg p-4 my-4 overflow-x-auto;
}
/* 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 {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow
border border-gray-200 dark:border-gray-800;
}
.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 @@ @@ -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 @@ @@ -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 @@ @@ -1,16 +1,11 @@
/**
* Markdown parser with special handling for nostr identifiers
* Process inline code
*/
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
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;
function processInlineCode(text: string): string {
return text.replace(INLINE_CODE_REGEX, (match, code) => {
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>`;
});
}
// Regular expressions for markdown elements
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
@ -28,614 +23,4 @@ const TABLE_REGEX = /^\|(.+)\|\r?\n\|([-|\s]+)\|\r?\n((?:\|.+\|\r?\n?)+)$/gm; @@ -28,614 +23,4 @@ const TABLE_REGEX = /^\|(.+)\|\r?\n\|([-|\s]+)\|\r?\n((?:\|.+\|\r?\n?)+)$/gm;
const TABLE_ROW_REGEX = /^\|(.+)\|$/gm;
const TABLE_DELIMITER_REGEX = /^[\s-]+$/;
// Cache for npub metadata
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;
}
// ... existing code ...

21
src/lib/utils/markdownTestfile.md

@ -3,7 +3,9 @@ This is a test @@ -3,7 +3,9 @@ This is a test
### 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.
@ -40,6 +42,9 @@ Let's nest that: @@ -40,6 +42,9 @@ Let's nest that:
3. third
4. fourth indented
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:
1. first
@ -67,7 +72,7 @@ nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydh @@ -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.
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
@ -130,7 +135,6 @@ package main @@ -130,7 +135,6 @@ package main
input := scanner.Text()
fmt.Println("You entered:", input)
}
```
or even Markdown:
@ -144,7 +148,7 @@ Paragraphs are separated by a blank line. @@ -144,7 +148,7 @@ Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists
look like:
* this one
* this one[^some reference text]
* that one
* the other one
@ -164,7 +168,7 @@ content starts at 4-columns in. @@ -164,7 +168,7 @@ content starts at 4-columns in.
### I went ahead and implemented tables, too.
A neat table:
A neat table[^some reference text]:
| Syntax | Description |
| ----------- | ----------- |
@ -178,5 +182,12 @@ A messy table (should render the same as above): @@ -178,5 +182,12 @@ A messy table (should render the same as above):
| Header | Title |
| Paragraph | Text |
Here is a table without a header row:
| Sometimes | you don't |
| need a | header |
| just | pipes |
[^1]: this is a footnote
[^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 @@ @@ -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 @@ @@ -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;
}
}

239
src/routes/contact/+page.svelte

@ -1,13 +1,14 @@ @@ -1,13 +1,14 @@
<script lang='ts'>
import { Heading, P, A, Button, Label, Textarea, Input, Tabs, TabItem, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance, activePubkey } from '$lib/ndk';
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte';
import { ndkSignedIn, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import { onMount } from 'svelte';
import NDK, { NDKEvent, NDKRelay, NDKRelaySet } from '@nostr-dev-kit/ndk';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue
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 { getMimeTags } from '$lib/utils/mime';
// Function to close the success message
function closeSuccessMessage() {
@ -46,7 +47,7 @@ @@ -46,7 +47,7 @@
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
// Hard-coded relays to ensure we have working relays
const hardcodedRelays = [
const allRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
@ -58,11 +59,6 @@ @@ -58,11 +59,6 @@
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
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 normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, '');
@ -107,92 +103,27 @@ @@ -107,92 +103,27 @@
showConfirmDialog = false;
}
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionSuccess = false;
try {
console.log('Starting issue submission...');
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
}
if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.');
}
console.log('NDK instance available with signer');
console.log('Active pubkey:', $activePubkey);
// 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
const uniqueRelays = new Set([
...hardcodedRelays.map(normalizeRelayUrl),
...standardRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
]);
console.log('Publishing to relays:', Array.from(uniqueRelays));
try {
// Create NDK relay set
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(uniqueRelays), ndk);
// Track successful relays
successfulRelays = [];
/**
* 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(uniqueRelays).map(relayUrl => {
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) {
console.log(`Event published to relay: ${relayUrl}`);
successfulRelays = [...successfulRelays, relayUrl];
successfulRelays.push(relayUrl);
resolve();
}
});
@ -202,32 +133,70 @@ @@ -202,32 +133,70 @@
});
});
// 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')), 10000);
setTimeout(() => reject(new Error('Publish timeout')), timeout);
});
try {
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
console.log('Event published successfully to', successfulRelays.length, 'relays');
if (successfulRelays.length > 0) {
break; // Exit retry loop if we have successful publishes
}
if (successfulRelays.length === 0) {
console.warn('Event published but no relay confirmations received');
if (attempt < maxRetries) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
} catch (error) {
if (successfulRelays.length > 0) {
console.warn('Partial publish success:', error);
} else {
throw new Error('Failed to publish to any relays');
if (attempt === maxRetries && successfulRelays.length === 0) {
throw new Error('Failed to publish to any relays after multiple attempts');
}
}
}
return successfulRelays;
}
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionSuccess = false;
try {
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
}
if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.');
}
// Create and prepare the event
const event = await createIssueEvent(ndk);
// Collect all unique relays
const uniqueRelays = new Set([
...allRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
]);
try {
// Publish to relays with retry logic
successfulRelays = await publishToRelays(event, ndk, uniqueRelays);
// Store the submitted event and create issue link
submittedEvent = event;
@ -235,22 +204,57 @@ @@ -235,22 +204,57 @@
const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Reset form and show success message
subject = '';
content = '';
// Clear form and show success message
clearForm();
submissionSuccess = true;
} catch (error) {
console.error('Failed to publish event:', error);
throw new Error('Failed to publish event');
}
} catch (error: any) {
console.error('Error submitting issue:', error);
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
} finally {
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
$: if ($ndkSignedIn && showLoginModal) {
showLoginModal = false;
@ -290,7 +294,7 @@ @@ -290,7 +294,7 @@
<div class="relative">
<Label for="content" class="mb-2">Description</Label>
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[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="border-b border-gray-300 dark:border-gray-600">
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist">
@ -327,13 +331,15 @@ @@ -327,13 +331,15 @@
required
placeholder="Describe your issue in detail...
Markdown formatting is supported:
The following Markdown is supported:
# Headers (1-6 levels)
**Bold** or *Bold*
*Bold* or **bold**
_Italic_ text
_Italic_ or __italic__ text
~Strikethrough~ or ~~strikethrough~~ text
> Blockquotes
@ -343,11 +349,14 @@ Lists, including nested: @@ -343,11 +349,14 @@ Lists, including nested:
[Links](url)
![Images](url)
`Inline code`
```language
Code blocks with syntax highlighting for over 100 languages
```
| Tables | With |
| Tables | With or without headers |
|--------|------|
| Multiple | Rows |
@ -358,13 +367,15 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -358,13 +367,15 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div>
{:else}
<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}
{#await parseAdvancedMarkdown(content)}
<p>Loading preview...</p>
{:then html}
{@html html}
{@html html || '<p class="text-gray-500">Nothing to preview</p>'}
{:catch error}
<p class="text-red-500">Error rendering markdown: {error.message}</p>
<p class="text-red-500">Error rendering preview: {error.message}</p>
{/await}
{/key}
</div>
{/if}
</div>
@ -421,8 +432,8 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi @@ -421,8 +432,8 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather">
{#await parseMarkdown(submittedEvent.content)}
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto">
{#await parseAdvancedMarkdown(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}

Loading…
Cancel
Save