diff --git a/package-lock.json b/package-lock.json index d447e7a..2f75723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,17 @@ "name": "alexandria", "version": "0.0.6", "dependencies": { + "@fontsource/fira-mono": "^5.2.5", "@nostr-dev-kit/ndk": "2.11.x", "@nostr-dev-kit/ndk-cache-dexie": "2.5.x", "@popperjs/core": "2.11.x", "@tailwindcss/forms": "0.5.x", "@tailwindcss/typography": "0.5.x", + "@types/highlight.js": "^9.12.4", "asciidoctor": "3.0.x", "d3": "^7.9.0", "he": "1.2.x", + "highlight.js": "^11.11.1", "nostr-tools": "2.10.x" }, "devDependencies": { @@ -719,6 +722,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@fontsource/fira-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/fira-mono/-/fira-mono-5.2.5.tgz", + "integrity": "sha512-rujrs+J+w2Nmqd6zsNQTzT7eYLKrSQWdF7SuAdjjXVs+Si06Ag6etOYFmF3Mzb0NufmEIPCDUS2ppt6hxX+SLg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1545,6 +1557,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/highlight.js": { + "version": "9.12.4", + "resolved": "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.4.tgz", + "integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3843,6 +3861,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index 2323efa..666574e 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,17 @@ "test": "vitest" }, "dependencies": { + "@fontsource/fira-mono": "^5.2.5", "@nostr-dev-kit/ndk": "2.11.x", "@nostr-dev-kit/ndk-cache-dexie": "2.5.x", "@popperjs/core": "2.11.x", "@tailwindcss/forms": "0.5.x", "@tailwindcss/typography": "0.5.x", + "@types/highlight.js": "^9.12.4", "asciidoctor": "3.0.x", "d3": "^7.9.0", "he": "1.2.x", + "highlight.js": "^11.11.1", "nostr-tools": "2.10.x" }, "devDependencies": { diff --git a/src/app.css b/src/app.css index 011ebd9..89bb409 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,4 @@ +@import '@fontsource/fira-mono'; @import './styles/base.css'; @import './styles/publications.css'; @import './styles/visualize.css'; @@ -199,6 +200,82 @@ .network-node-content { @apply fill-[#d6c1a8]; } + + /* 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; + } + + .code-block::-webkit-scrollbar { + height: 8px; + } + + .code-block::-webkit-scrollbar-track { + @apply bg-transparent rounded-b-lg; + } + + .code-block::-webkit-scrollbar-thumb { + @apply bg-gray-400 dark:bg-gray-600 rounded-full; + } + + /* Inline code */ + .inline-code { + @apply font-mono text-sm bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded; + } + + /* JSON syntax highlighting */ + .code-block[data-language="json"] { + color: #24292e; /* Default text color */ + } + + .code-block[data-language="json"] .json-key { + color: #005cc5; + } + + .code-block[data-language="json"] .json-string { + color: #032f62; + } + + .code-block[data-language="json"] .json-number { + color: #005cc5; + } + + .code-block[data-language="json"] .json-boolean { + color: #d73a49; + } + + .code-block[data-language="json"] .json-null { + color: #d73a49; + } + + /* Dark mode */ + @media (prefers-color-scheme: dark) { + .code-block[data-language="json"] { + color: #e1e4e8; + } + + .code-block[data-language="json"] .json-key { + color: #79b8ff; + } + + .code-block[data-language="json"] .json-string { + color: #9ecbff; + } + + .code-block[data-language="json"] .json-number { + color: #79b8ff; + } + + .code-block[data-language="json"] .json-boolean { + color: #f97583; + } + + .code-block[data-language="json"] .json-null { + color: #f97583; + } + } } @layer components { diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts index 501edfe..935bdc3 100644 --- a/src/lib/utils/markdownParser.ts +++ b/src/lib/utils/markdownParser.ts @@ -5,6 +5,8 @@ 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; @@ -14,6 +16,7 @@ const NOSTR_NOTE_REGEX = /(?:nostr:)?((?:nevent|note|naddr)[a-zA-Z0-9]{20,})/g; const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g; const ITALIC_REGEX = /_([^_]+)_/g; const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; +const ALTERNATE_HEADING_REGEX = /^(.+)\n([=]{3,}|-{3,})$/gm; const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm; const INLINE_CODE_REGEX = /`([^`\n]+)`/g; const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; @@ -21,6 +24,9 @@ const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g; const HASHTAG_REGEX = /(?(); @@ -213,236 +219,6 @@ function processBlockquotes(text: string): string { return result; } -/** - * Format code based on language - */ -function formatCodeByLanguage(code: string, lang: string): string { - const language = lang.trim().toLowerCase(); - - // Remove any trailing whitespace or empty lines at start/end - let formattedCode = code.trim(); - - switch (language) { - case 'json': - try { - return JSON.stringify(JSON.parse(formattedCode), null, 2); - } catch (e) { - return formattedCode; - } - - case 'javascript': - case 'js': - case 'typescript': - case 'ts': - try { - // Basic indentation for JS/TS - formattedCode = formattedCode - .split('\n') - .map(line => line.trim()) - .join('\n'); - - // Add line breaks after certain characters - formattedCode = formattedCode - .replace(/([{([])\s*/g, '$1\n') - .replace(/\s*([\]})])/g, '\n$1') - .replace(/;\s*/g, ';\n') - .replace(/,\s*([^\s])/g, ',\n$1'); - - // Indent based on brackets - let indent = 0; - return formattedCode - .split('\n') - .map(line => { - line = line.trim(); - if (line.match(/[}\])]$/)) indent--; - const formatted = ' '.repeat(Math.max(0, indent)) + line; - if (line.match(/[{([]\s*$/)) indent++; - return formatted; - }) - .filter(line => line.trim()) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'html': - case 'xml': - try { - // Basic indentation for HTML/XML - let indent = 0; - return formattedCode - .replace(/>\n<') - .split('\n') - .map(line => { - line = line.trim(); - if (line.match(/<\/[^>]+>$/)) indent--; - const formatted = ' '.repeat(Math.max(0, indent)) + line; - if (line.match(/<[^/][^>]*>$/) && !line.match(/<[^>]+\/>/)) indent++; - return formatted; - }) - .filter(line => line.trim()) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'css': - try { - // Basic indentation for CSS - return formattedCode - .replace(/\s*{\s*/g, ' {\n') - .replace(/;\s*/g, ';\n') - .replace(/\s*}\s*/g, '\n}\n') - .split('\n') - .map(line => line.trim()) - .filter(line => line) - .map(line => line.startsWith('}') ? line : ' ' + line) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'python': - case 'py': - try { - // Basic indentation for Python - let indent = 0; - return formattedCode - .split('\n') - .map(line => { - line = line.trim(); - if (line.match(/^(return|break|continue|pass|else|elif|except|finally)\b/)) indent--; - const formatted = ' '.repeat(Math.max(0, indent)) + line; - if (line.match(/:\s*$/)) indent++; - return formatted; - }) - .filter(line => line.trim()) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'cpp': - case 'c': - case 'rust': - try { - // Basic indentation for C/C++/Rust - let indent = 0; - return formattedCode - .split('\n') - .map(line => { - line = line.trim(); - if (line.match(/^[}\])]/) || line.match(/^(public|private|protected):/)) indent--; - const formatted = ' '.repeat(Math.max(0, indent)) + line; - if (line.match(/[{[]$/)) indent++; - return formatted; - }) - .filter(line => line.trim()) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'php': - try { - // Basic indentation for PHP - let indent = 0; - return formattedCode - .split('\n') - .map(line => { - line = line.trim(); - if (line.match(/^[}\])]/) || line.match(/^(case|default):/)) indent--; - const formatted = ' '.repeat(Math.max(0, indent)) + line; - if (line.match(/[{[]$/) || line.match(/^(case|default):/)) indent++; - return formatted; - }) - .filter(line => line.trim()) - .join('\n'); - } catch (e) { - return formattedCode; - } - - case 'bash': - case 'shell': - case 'sh': - try { - // Basic formatting for shell scripts - return formattedCode - .split('\n') - .map(line => line.trim()) - .filter(line => line) - .map(line => { - if (line.startsWith('#')) return line; - if (line.endsWith('\\')) return line + '\n'; - if (line.match(/^(if|while|for|case)/)) return line; - if (line.match(/^(then|do|else|elif)/)) return ' ' + line; - if (line.match(/^(fi|done|esac)/)) return line; - return ' ' + line; - }) - .join('\n'); - } catch (e) { - return formattedCode; - } - - default: - return formattedCode; - } -} - -/** - * Process nostr identifiers - */ -async function processNostrIdentifiers(content: string): Promise { - 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, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const escapedDisplayText = displayText - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - - // Create a link with standardized styling - const link = `@${escapedDisplayText}`; - - // 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, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - - // Create a link with standardized styling - const link = `${shortId}`; - - // Replace only the exact match to preserve surrounding text - processedContent = processedContent.replace(fullMatch, link); - } - - return processedContent; -} - /** * Process code blocks by finding consecutive code lines and preserving their content */ @@ -454,7 +230,6 @@ function processCodeBlocks(text: string): { text: string; blocks: Map 0) { blockCount++; - const id = `CODE_BLOCK_${blockCount}`; + const id = `CODE-BLOCK-${blockCount}`; + const code = currentCode.join('\n'); + blocks.set(id, JSON.stringify({ - code: currentCode.join('\n'), - language: currentLanguage, - raw: true + code, + language: currentLanguage })); - processedLines.push(''); processedLines.push(id); - processedLines.push(''); } return { @@ -522,18 +289,66 @@ function processCodeBlocks(text: string): { text: string; blocks: Map): string { let result = text; - - for (const [id, blockData] of blocks) { - const { code, language } = JSON.parse(blockData); + blocks.forEach((blockContent, id) => { + const { code, language } = JSON.parse(blockContent); + let processedCode = code; - // Preserve code exactly as it was written - const html = `
-
${code}
-
`; + // First escape HTML characters + processedCode = processedCode + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); - result = result.replace(id, html); - } + // Format and highlight based on language + if (language === 'json') { + try { + // Parse and format JSON + const parsed = JSON.parse(code); + processedCode = JSON.stringify(parsed, null, 2) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Apply JSON syntax highlighting + processedCode = processedCode + // Match JSON keys (including colons) + .replace(/("[^"]+"):/g, '$1:') + // Match string values (after colons and in arrays) + .replace(/: ("[^"]+")/g, ': $1') + .replace(/\[("[^"]+")/g, '[$1') + .replace(/, ("[^"]+")/g, ', $1') + // Match numbers + .replace(/: (-?\d+\.?\d*)/g, ': $1') + .replace(/\[(-?\d+\.?\d*)/g, '[$1') + .replace(/, (-?\d+\.?\d*)/g, ', $1') + // Match booleans + .replace(/: (true|false)\b/g, ': $1') + // Match null + .replace(/: (null)\b/g, ': $1'); + } catch (e) { + // If JSON parsing fails, use the original escaped code + console.warn('Failed to parse JSON:', e); + } + } else if (language) { + // Use highlight.js for other languages + 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 = `
${processedCode}
`; + result = result.replace(id, replacement); + }); return result; } @@ -547,10 +362,68 @@ function processInlineCode(text: string): string { .replace(//g, '>') .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\\n/g, '\n'); + .replace(/'/g, '''); - return `${escapedCode}`; + return `${escapedCode}`; + }); +} + +/** + * 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 = '
\n'; + table += '\n'; + + // Add header with leather theme styling + table += '\n\n'; + headers.forEach((header: string) => { + table += `\n`; + }); + table += '\n\n'; + + // Add body with leather theme styling + table += '\n'; + rows.forEach((row: string[], index: number) => { + table += `\n`; + row.forEach((cell: string) => { + table += `\n`; + }); + table += '\n'; + }); + table += '\n
${header}
${cell}
\n
'; + + return table; }); } @@ -561,9 +434,21 @@ 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, '$1$2'); content = content.replace(ITALIC_REGEX, '$1'); + + // 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 `${content.trim()}`; + }); + + // 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']; @@ -624,6 +509,60 @@ function processFootnotes(text: string): { text: string, footnotes: Map { + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const escapedDisplayText = displayText + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Create a link with standardized styling + const link = `@${escapedDisplayText}`; + + // 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Create a link with standardized styling + const link = `${shortId}`; + + // 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 */ @@ -655,9 +594,9 @@ export async function parseMarkdown(text: string): Promise { // Handle paragraphs and line breaks, preserving existing HTML content = content .split(/\n{2,}/) - .map(para => para.trim()) - .filter(para => para) - .map(para => para.startsWith('<') ? para : `

${para}

`) + .map((para: string) => para.trim()) + .filter((para: string) => para) + .map((para: string) => para.startsWith('<') ? para : `

${para}

`) .join('\n\n'); // Finally, restore code blocks @@ -672,3 +611,29 @@ export async function parseMarkdown(text: string): Promise { 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, '"$1":') // keys + .replace(/"([^"]+)"/g, '"$1"') // strings + .replace(/\b(true|false)\b/g, '$1') // booleans + .replace(/\b(null)\b/g, '$1') // null + .replace(/\b(\d+\.?\d*)\b/g, '$1'); // numbers + } catch (e) { + // If JSON parsing fails, use the original code + } + } + return `
${code}
`; + }); + + // Process inline code + text = text.replace(/`([^`]+)`/g, '$1'); + + return text; +} diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 7f15d26..2116261 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -1,5 +1,5 @@
-
+
Contact GitCitadel

@@ -258,12 +263,57 @@

- +
-
+
-