From 13a2932cde72e1ae36b450fd58d40808152ec8c4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 19 Apr 2025 00:19:53 +0200 Subject: [PATCH 01/33] Fixed most of the regex on the page. --- src/lib/components/LoginModal.svelte | 36 +++ src/lib/components/Navigation.svelte | 1 + src/lib/utils/markdownParser.ts | 340 +++++++++++++++++++++++ src/routes/contact/+page.svelte | 390 +++++++++++++++++++++++++++ 4 files changed, 767 insertions(+) create mode 100644 src/lib/components/LoginModal.svelte create mode 100644 src/lib/utils/markdownParser.ts create mode 100644 src/routes/contact/+page.svelte diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte new file mode 100644 index 0000000..b1c5a30 --- /dev/null +++ b/src/lib/components/LoginModal.svelte @@ -0,0 +1,36 @@ + + +{#if show} +
+
+
+ +
+

Login Required

+ +
+ + +
+

+ You need to be logged in to submit an issue. Your form data will be preserved. +

+
+ +
+
+
+
+
+{/if} \ No newline at end of file diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index 2ac6133..e6ca543 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -21,6 +21,7 @@ Publish Visualize About + Contact diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts new file mode 100644 index 0000000..e0e09ed --- /dev/null +++ b/src/lib/utils/markdownParser.ts @@ -0,0 +1,340 @@ +/** + * Markdown parser with special handling for nostr identifiers + */ + +import { get } from 'svelte/store'; +import { ndkInstance } from '$lib/ndk'; +import { nip19 } from 'nostr-tools'; + +// Regular expressions for nostr identifiers - process these first +const NOSTR_NPUB_REGEX = /(?:nostr:)?(npub[a-zA-Z0-9]{59,60})/g; + +// Regular expressions for markdown elements +const BLOCKQUOTE_REGEX = /^(?:>[ \t]*.+\n?(?:(?:>[ \t]*\n)*(?:>[ \t]*.+\n?))*)+/gm; +const ORDERED_LIST_REGEX = /^(\d+)\.[ \t]+(.+)$/gm; +const UNORDERED_LIST_REGEX = /^[-*][ \t]+(.+)$/gm; +const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g; +const ITALIC_REGEX = /_([^_]+)_/g; +const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; +const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm; +const CODE_BLOCK_REGEX = /```([^\n]*)\n([\s\S]*?)```/gm; +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; +const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; +const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g; +const HASHTAG_REGEX = /(?(); + +/** + * Get user metadata for an npub + */ +async function getUserMetadata(npub: string): Promise<{name?: string, displayName?: string}> { + if (npubCache.has(npub)) { + return npubCache.get(npub)!; + } + + const fallback = { name: `${npub.slice(0, 8)}...${npub.slice(-4)}` }; + + try { + const ndk = get(ndkInstance); + if (!ndk) { + npubCache.set(npub, fallback); + return fallback; + } + + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + npubCache.set(npub, fallback); + return fallback; + } + + const user = ndk.getUser({ npub: npub }); + if (!user) { + npubCache.set(npub, fallback); + return fallback; + } + + try { + const profile = await user.fetchProfile(); + if (!profile) { + npubCache.set(npub, fallback); + return fallback; + } + + const metadata = { + name: profile.name || fallback.name, + displayName: profile.displayName + }; + + npubCache.set(npub, metadata); + return metadata; + } catch (e) { + npubCache.set(npub, fallback); + return fallback; + } + } catch (e) { + npubCache.set(npub, fallback); + return fallback; + } +} + +/** + * Process lists (ordered and unordered) + */ +function processLists(html: string): string { + const lines = html.split('\n'); + let inList = false; + let isOrdered = false; + let currentList: string[] = []; + const processed: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const orderedMatch = ORDERED_LIST_REGEX.exec(line); + const unorderedMatch = UNORDERED_LIST_REGEX.exec(line); + + if (orderedMatch || unorderedMatch) { + if (!inList) { + inList = true; + isOrdered = !!orderedMatch; + currentList = []; + } + const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1]; + currentList.push(content); + } else { + if (inList) { + const listType = isOrdered ? 'ol' : 'ul'; + const listClass = isOrdered ? 'list-decimal' : 'list-disc'; + processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`); + currentList.forEach(item => { + processed.push(`
  • ${item}
  • `); + }); + processed.push(``); + inList = false; + currentList = []; + } + processed.push(line); + } + + // Reset regex lastIndex + ORDERED_LIST_REGEX.lastIndex = 0; + UNORDERED_LIST_REGEX.lastIndex = 0; + } + + if (inList) { + const listType = isOrdered ? 'ol' : 'ul'; + const listClass = isOrdered ? 'list-decimal' : 'list-disc'; + processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`); + currentList.forEach(item => { + processed.push(`
  • ${item}
  • `); + }); + processed.push(``); + } + + return processed.join('\n'); +} + +/** + * Process blockquotes using placeholder approach + */ +function processBlockquotes(text: string): string { + const blockquotes: Array<{id: string, content: string}> = []; + let processedText = text; + + // Extract and save blockquotes + processedText = processedText.replace(BLOCKQUOTE_REGEX, (match) => { + const id = `BLOCKQUOTE_${blockquotes.length}`; + const cleanContent = match + .split('\n') + .map(line => line.replace(/^>[ \t]*/, '')) + .join('\n') + .trim(); + + blockquotes.push({ + id, + content: `
    ${cleanContent}
    ` + }); + return id; + }); + + // Restore blockquotes + blockquotes.forEach(({id, content}) => { + processedText = processedText.replace(id, content); + }); + + return processedText; +} + +/** + * Process code blocks and inline code before any HTML escaping + */ +function processCode(text: string): string { + const blocks: Array<{id: string, content: string}> = []; + const inlineCodes: Array<{id: string, content: string}> = []; + let processedText = text; + + // First, extract and save code blocks + processedText = processedText.replace(CODE_BLOCK_REGEX, (match, lang, code) => { + const id = `CODE_BLOCK_${blocks.length}`; + blocks.push({ + id, + content: `
    ${escapeHtml(code)}
    ` + }); + return id; + }); + + // Then extract and save inline code + processedText = processedText.replace(INLINE_CODE_REGEX, (match, code) => { + const id = `INLINE_CODE_${inlineCodes.length}`; + inlineCodes.push({ + id, + content: `${escapeHtml(code.trim())}` + }); + return id; + }); + + // Now escape HTML in the remaining text + processedText = escapeHtml(processedText); + + // Restore code blocks + blocks.forEach(({id, content}) => { + processedText = processedText.replace(escapeHtml(id), content); + }); + + // Restore inline code + inlineCodes.forEach(({id, content}) => { + processedText = processedText.replace(escapeHtml(id), content); + }); + + return processedText; +} + +/** + * Process footnotes with minimal spacing + */ +function processFootnotes(text: string): { text: string, footnotes: Map } { + const footnotes = new Map(); + 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 + text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { + const cleanId = id.replace('^', ''); + if (footnotes.has(cleanId)) { + counter++; + return `[${counter}]`; + } + return match; + }); + + // Add footnotes section if we have any + if (footnotes.size > 0) { + text += '\n
    '; + text += '
      '; + counter = 0; + + for (const [id, content] of footnotes.entries()) { + counter++; + text += `
    1. ${content}
    2. `; + } + + text += '
    '; + } + + return { text, footnotes }; +} + +/** + * Parse markdown text to HTML with special handling for nostr identifiers + */ +export async function parseMarkdown(text: string): Promise { + if (!text) return ''; + + // First, process code blocks (protect these from HTML escaping) + let html = processCode(text); // still escape HTML *inside* code blocks + + // 👉 NEW: process blockquotes *before* the rest of HTML is escaped + html = processBlockquotes(html); + + // Process nostr identifiers + const npubMatches = Array.from(html.matchAll(NOSTR_NPUB_REGEX)); + const npubPromises = npubMatches.map(async match => { + const [fullMatch, npub] = match; + const metadata = await getUserMetadata(npub); + const displayText = metadata.displayName || metadata.name || `${npub.slice(0, 8)}...${npub.slice(-4)}`; + return { fullMatch, npub, displayText }; + }); + + const npubResults = await Promise.all(npubPromises); + for (const { fullMatch, npub, displayText } of npubResults) { + html = html.replace( + fullMatch, + `@${displayText}` + ); + } + + // Process lists + html = processLists(html); + + // Process footnotes + const { text: processedHtml } = processFootnotes(html); + html = processedHtml; + + // Process basic markdown elements + html = html.replace(BOLD_REGEX, '$1$2'); + html = html.replace(ITALIC_REGEX, '$1'); + html = html.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 `${content.trim()}`; + }); + + // Process links and images + html = html.replace(IMAGE_REGEX, '$1'); + html = html.replace(LINK_REGEX, '$1'); + + // Process hashtags + html = html.replace(HASHTAG_REGEX, '#$1'); + + // Process horizontal rules + html = html.replace(HORIZONTAL_RULE_REGEX, '
    '); + + // Handle paragraphs and line breaks + html = html.replace(/\n{2,}/g, '

    '); + html = html.replace(/\n/g, '
    '); + + // Wrap content in paragraph if needed + if (!html.startsWith('<')) { + html = `

    ${html}

    `; + } + + return html; +} + +/** + * Escape HTML special characters to prevent XSS + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Escape special characters in a string for use in a regular expression + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte new file mode 100644 index 0000000..3cfee2d --- /dev/null +++ b/src/routes/contact/+page.svelte @@ -0,0 +1,390 @@ + + +
    +
    + Contact GitCitadel + +

    + Make sure that you follow us on GitHub and Geyserfund. +

    + +

    + You can contact us on Nostr npub1s3h…75wz or you can view submitted issues on the Alexandria repo page. +

    + + Submit an issue + +

    + If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. +

    + +
    +
    + + +
    + +
    + + + {#if showEmojiPicker} + showEmojiPicker = false} + > + insertEmoji(detail.shortcode)} /> + + {/if} +
    + {#if showHelp} +
    + {@html helpContent} +
    + {/if} +
    + + +
    + {#if submissionError} + + {/if} +
    + + \ No newline at end of file diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index e6ca543..817f99c 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -7,23 +7,23 @@ let leftMenuOpen = $state(false); - -
    - + + -
    + - - Publish - Visualize - About - Contact - - + + Publish + Visualize + About + Contact + + diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 0c85484..b85d99a 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -188,7 +188,7 @@
    @@ -246,12 +246,12 @@ {#if allowEditing && depth > 0}
    {#if hasPreviousSibling && parentId} - {/if} {#if hasNextSibling && parentId} - {/if} diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 3ec008d..3a10bc7 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -12,7 +12,7 @@ } from "flowbite-svelte"; import { getContext, onMount } from "svelte"; import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons"; - import { page } from "$app/state"; + import { page } from "$app/stores"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import PublicationSection from "./PublicationSection.svelte"; import type { PublicationTree } from "$lib/data_structures/publication_tree"; @@ -80,10 +80,12 @@ const tocBreakpoint = 1140; - let activeHash = $state(page.url.hash); + let activeHash = $state($page.url.hash); let showToc: boolean = $state(true); let showTocButton: boolean = $state(false); + let currentPath = $page.url.pathname; + function normalizeHashPath(str: string): string { return str .toLowerCase() @@ -166,41 +168,12 @@ -{#if showTocButton && !showToc} - -{/if} - - -
    +
    {#each leaves as leaf, i} {#if leaf == null} - - - Error loading content. One or more events could not be loaded. + + + Error loading content. One or more events could not be loaded. {:else} {/if} {/each} -
    +
    +{#if showTocButton && !showToc} + + Show Table of Contents +{/if} + +{#if showToc} + + + + {#each leaves as leaf} + {#if leaf && leaf.getMatchingTags('title').length > 0} + + {/if} + {/each} + + + +{/if} + diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 45475c5..4a67fc6 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -12,7 +12,8 @@ import type { } from 'asciidoctor'; import he from 'he'; import { writable, type Writable } from 'svelte/store'; -import { zettelKinds } from './consts.ts'; +import { zettelKinds } from './consts'; +import { replaceEmojisWithUnicode } from './utils/markdown/markdownItParser'; interface IndexMetadata { authors?: string[]; @@ -208,10 +209,11 @@ export default class Pharos { /** * Gets the entire HTML content of the AsciiDoc document. - * @returns The HTML content of the converted document. + * @returns The HTML content of the converted document, with emoji shortcodes replaced by Unicode. */ getHtml(): string { - return this.html?.toString() || ''; + const html = this.html?.toString() || ''; + return replaceEmojisWithUnicode(html); } /** diff --git a/src/lib/types/markdown-it-plugins.d.ts b/src/lib/types/markdown-it-plugins.d.ts new file mode 100644 index 0000000..ee267c6 --- /dev/null +++ b/src/lib/types/markdown-it-plugins.d.ts @@ -0,0 +1,11 @@ +declare module 'markdown-it-footnote' { + import MarkdownIt from 'markdown-it'; + const plugin: MarkdownIt.PluginWithParams; + export default plugin; +} + +declare module 'markdown-it-emoji' { + import MarkdownIt from 'markdown-it'; + const plugin: MarkdownIt.PluginWithParams; + export default plugin; +} \ No newline at end of file diff --git a/src/lib/types/svelte-heros.d.ts b/src/lib/types/svelte-heros.d.ts new file mode 100644 index 0000000..ef5671e --- /dev/null +++ b/src/lib/types/svelte-heros.d.ts @@ -0,0 +1,4 @@ +declare module 'svelte-heros/dist/*.svelte' { + import { SvelteComponentTyped } from 'svelte'; + export default class Icon extends SvelteComponentTyped {} +} \ No newline at end of file diff --git a/src/lib/utils/advancedMarkdownParser.ts b/src/lib/utils/advancedMarkdownParser.ts deleted file mode 100644 index 07851c7..0000000 --- a/src/lib/utils/advancedMarkdownParser.ts +++ /dev/null @@ -1,378 +0,0 @@ -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 - -// 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 HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; -const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; -const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; - -/** - * 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 `${text.trim()}`; - }); - - // Process Setext-style headings (Heading\n====) - processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { - const headingLevel = level[0] === '=' ? 1 : 2; - return `${text.trim()}`; - }); - - 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 = '
    \n'; - html += '\n'; - - // Add header if exists - if (hasHeader) { - html += '\n\n'; - headerCells.forEach(cell => { - html += `\n`; - }); - html += '\n\n'; - } - - // Add body - html += '\n'; - bodyRows.forEach(row => { - const cells = processCells(row); - html += '\n'; - cells.forEach(cell => { - html += `\n`; - }); - html += '\n'; - }); - - html += '\n
    ${cell}
    ${cell}
    \n
    '; - return html; - } catch (error) { - console.error('Error processing table row:', error); - return match; - } - }); - } catch (error) { - console.error('Error in processTables:', error); - return content; - } -} - -/** - * Process horizontal rules - */ -function processHorizontalRules(content: string): string { - return content.replace(HORIZONTAL_RULE_REGEX, - '
    ' - ); -} - -/** - * Process footnotes - */ -function processFootnotes(content: string): string { - try { - if (!content) return ''; - - // First collect all footnote references and definitions - const footnotes = new Map(); - const references = new Map(); - const referenceLocations = new Set(); - 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 `[${num}]`; - }); - - // Add footnotes section if we have any - if (references.size > 0) { - processedContent += '\n\n

    Footnotes

    \n
      \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 += `
    1. ${text}
    2. \n`; - } - processedContent += '
    '; - } - - 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 `
    ${text}
    `; - }); -} - -/** - * Process code blocks by finding consecutive code lines and preserving their content - */ -function processCodeBlocks(text: string): { text: string; blocks: Map } { - const lines = text.split('\n'); - const processedLines: string[] = []; - const blocks = new Map(); - 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 { - 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 = `
    ${highlighted}
    `; - } catch (e) { - console.warn('Failed to highlight code block:', e); - html = `
    ${code}
    `; - } - } else { - html = `
    ${code}
    `; - } - - result = result.replace(id, html); - } catch (error) { - console.error('Error restoring code block:', error); - result = result.replace(id, '
    Error processing code block
    '); - } - } - - return result; -} - -/** - * Parse markdown text with advanced formatting - */ -export async function parseAdvancedMarkdown(text: string): Promise { - if (!text) return ''; - - try { - // Step 1: Extract and save code blocks first - const { text: withoutCode, blocks } = processCodeBlocks(text); - let processedText = withoutCode; - - // Step 2: Process block-level elements - processedText = processTables(processedText); - processedText = processBlockquotes(processedText); - processedText = processHeadings(processedText); - processedText = processHorizontalRules(processedText); - - // Process inline elements - processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { - const escapedCode = code - .trim() - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - return `${escapedCode}`; - }); - - // Process footnotes - processedText = processFootnotes(processedText); - - // Process basic markdown (which will also handle Nostr identifiers) - processedText = await parseBasicMarkdown(processedText); - - // Step 3: Restore code blocks - processedText = restoreCodeBlocks(processedText, blocks); - - return processedText; - } catch (error) { - console.error('Error in parseAdvancedMarkdown:', error); - return `
    Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
    `; - } -} \ No newline at end of file diff --git a/src/lib/utils/basicMarkdownParser.ts b/src/lib/utils/basicMarkdownParser.ts deleted file mode 100644 index c30e442..0000000 --- a/src/lib/utils/basicMarkdownParser.ts +++ /dev/null @@ -1,182 +0,0 @@ -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 = /(?[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; - -// List regex patterns -const UNORDERED_LIST_REGEX = /^(\s*[-*+]\s+)(.*?)$/gm; -const ORDERED_LIST_REGEX = /^(\s*\d+\.\s+)(.*?)$/gm; - -// Markdown patterns -const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; -const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; - -// URL patterns -const WSS_URL = /wss:\/\/[^\s<>"]+/g; -const DIRECT_LINK = /(?"]+)(?!["'])/g; - -// Media URL patterns -const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i; -const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; -const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; -const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i; - - -function processBasicFormatting(content: string): string { - if (!content) return ''; - - let processedText = content; - - try { - // Process Markdown images first - processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, url) => { - if (YOUTUBE_URL_REGEX.test(url)) { - const videoId = extractYouTubeVideoId(url); - if (videoId) { - return ``; - } - } - - if (VIDEO_URL_REGEX.test(url)) { - return ``; - } - - if (AUDIO_URL_REGEX.test(url)) { - return ``; - } - - return `${alt}`; - }); - - // Process Markdown links - processedText = processedText.replace(MARKDOWN_LINK, (match, text, url) => - `${text}` - ); - - // Process WebSocket URLs - processedText = processedText.replace(WSS_URL, match => { - // Remove 'wss://' from the start and any trailing slashes - const cleanUrl = match.slice(6).replace(/\/+$/, ''); - return `${match}`; - }); - - // Process direct media URLs - processedText = processedText.replace(DIRECT_LINK, match => { - if (YOUTUBE_URL_REGEX.test(match)) { - const videoId = extractYouTubeVideoId(match); - if (videoId) { - return ``; - } - } - - if (VIDEO_URL_REGEX.test(match)) { - return ``; - } - - if (AUDIO_URL_REGEX.test(match)) { - return ``; - } - - if (IMAGE_URL_REGEX.test(match)) { - return `Embedded media`; - } - - return `${match}`; - }); - - // Process text formatting - processedText = processedText.replace(BOLD_REGEX, '$2'); - processedText = processedText.replace(ITALIC_REGEX, match => { - const text = match.replace(/^_+|_+$/g, ''); - return `${text}`; - }); - processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { - const text = doubleText || singleText; - return `${text}`; - }); - - // Process hashtags - processedText = processedText.replace(HASHTAG_REGEX, '#$1'); - } catch (error) { - console.error('Error in processBasicFormatting:', error); - } - - return processedText; -} - -// Helper function to extract YouTube video ID -function extractYouTubeVideoId(url: string): string | null { - const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/); - return match ? match[1] : null; -} - -function processBlockquotes(content: string): string { - try { - if (!content) return ''; - - return content.replace(BLOCKQUOTE_REGEX, match => { - const lines = match.split('\n').map(line => { - return line.replace(/^[ \t]*>[ \t]?/, '').trim(); - }); - - return `
    ${ - lines.join('\n') - }
    `; - }); - } catch (error) { - console.error('Error in processBlockquotes:', error); - return content; - } -} - -export async function parseBasicMarkdown(text: string): Promise { - if (!text) return ''; - - try { - // Process basic text formatting first - let processedText = processBasicFormatting(text); - - // Process lists - handle ordered lists first - processedText = processedText - // Process ordered lists - .replace(ORDERED_LIST_REGEX, (match, marker, content) => { - // Count leading spaces to determine nesting level - const indent = marker.match(/^\s*/)[0].length; - const extraIndent = indent > 0 ? ` ml-${indent * 4}` : ''; - return `
  • ${content}
  • `; - }) - .replace(/.*?<\/li>\n?/gs, '
      $&
    ') - - // Process unordered lists - .replace(UNORDERED_LIST_REGEX, (match, marker, content) => { - // Count leading spaces to determine nesting level - const indent = marker.match(/^\s*/)[0].length; - const extraIndent = indent > 0 ? ` ml-${indent * 4}` : ''; - return `
  • ${content}
  • `; - }) - .replace(/.*?<\/li>\n?/gs, '
      $&
    '); - - // Process blockquotes - processedText = processBlockquotes(processedText); - - // Process paragraphs - split by double newlines and wrap in p tags - processedText = processedText - .split(/\n\n+/) - .map(para => para.trim()) - .filter(para => para.length > 0) - .map(para => `

    ${para}

    `) - .join('\n'); - - // Process Nostr identifiers last - processedText = await processNostrIdentifiers(processedText); - - return processedText; - } catch (error) { - console.error('Error in parseBasicMarkdown:', error); - return `
    Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
    `; - } -} \ No newline at end of file diff --git a/src/lib/utils/emoticons.ts b/src/lib/utils/emoticons.ts new file mode 100644 index 0000000..3d676b3 --- /dev/null +++ b/src/lib/utils/emoticons.ts @@ -0,0 +1,85 @@ +// Heroicon Svelte components (assume these are available in src/lib/icons/heroicons) +import Heart from 'svelte-heros/dist/Heart.svelte'; +import FaceSmile from 'svelte-heros/dist/FaceSmile.svelte'; +import FaceFrown from 'svelte-heros/dist/FaceFrown.svelte'; +import Fire from 'svelte-heros/dist/Fire.svelte'; +import HandRaised from 'svelte-heros/dist/HandRaised.svelte'; +import ThumbDown from 'svelte-heros/dist/ThumbDown.svelte'; +import ThumbUp from 'svelte-heros/dist/ThumbUp.svelte'; +import Eye from 'svelte-heros/dist/Eye.svelte'; +import LightBulb from 'svelte-heros/dist/LightBulb.svelte'; +import Pencil from 'svelte-heros/dist/Pencil.svelte'; +import RocketLaunch from 'svelte-heros/dist/RocketLaunch.svelte'; +import Star from 'svelte-heros/dist/Star.svelte'; +import Sun from 'svelte-heros/dist/Sun.svelte'; +import Moon from 'svelte-heros/dist/Moon.svelte'; +import Trash from 'svelte-heros/dist/Trash.svelte'; +import Trophy from 'svelte-heros/dist/Trophy.svelte'; +import Cake from 'svelte-heros/dist/Cake.svelte'; +import CurrencyDollar from 'svelte-heros/dist/CurrencyDollar.svelte'; +import CurrencyEuro from 'svelte-heros/dist/CurrencyEuro.svelte'; +import ExclamationCircle from 'svelte-heros/dist/ExclamationCircle.svelte'; + + +export const heroiconEmoticons = [ + { name: 'Heart', shortcode: ':heart:', component: Heart }, + { name: 'Smile', shortcode: ':face-smile:', component: FaceSmile }, + { name: 'Frown', shortcode: ':face-frown:', component: FaceFrown }, + { name: 'Fire', shortcode: ':fire:', component: Fire }, + { name: 'Hand Raised', shortcode: ':hand-raised:', component: HandRaised }, + { name: 'Thumb Down', shortcode: ':hand-thumb-down:', component: ThumbDown }, + { name: 'Thumb Up', shortcode: ':hand-thumb-up:', component: ThumbUp }, + { name: 'Eye', shortcode: ':eye:', component: Eye }, + { name: 'Light Bulb', shortcode: ':light-bulb:', component: LightBulb }, + { name: 'Pencil Square', shortcode: ':pencil-square:', component: Pencil }, + { name: 'Rocket', shortcode: ':rocket-launch:', component: RocketLaunch }, + { name: 'Star', shortcode: ':star:', component: Star }, + { name: 'Sun', shortcode: ':sun:', component: Sun }, + { name: 'Moon', shortcode: ':moon:', component: Moon }, + { name: 'Trash', shortcode: ':trash:', component: Trash }, + { name: 'Trophy', shortcode: ':trophy:', component: Trophy }, + { name: 'Cake', shortcode: ':cake:', component: Cake }, + { name: 'Dollar Sign', shortcode: ':dollar-sign:', component: CurrencyDollar }, + { name: 'Euro Sign', shortcode: ':euro-sign:', component: CurrencyEuro }, + { name: 'Exclamation Circle', shortcode: ':exclamation-circle:', component: ExclamationCircle } +]; + +// Unicode emojis, excluding those covered by heroicons +export const unicodeEmojis = [ + { name: 'Laughing', shortcode: ':joy:', char: '😂' }, + { name: 'Crying', shortcode: ':sob:', char: '😭' }, + { name: 'Call Me Hand', shortcode: ':call-me-hand:', char: '🤙' }, + { name: 'Waving Hand', shortcode: ':wave:', char: '👋' }, + { name: 'Pinched Fingers', shortcode: ':pinched-fingers:', char: '🤌' }, + // ...add more as needed, ensuring no overlap with heroiconEmoticons +]; + +/** + * Get the Unicode character for a given shortcode, searching both heroicon and unicode lists. + * Returns undefined if not found. + */ +export function getUnicodeEmoji(shortcode: string): string | undefined { + // Map heroicon shortcodes to a reasonable Unicode fallback + const heroiconFallbacks: Record = { + ':heart:': '❤️', + ':face-smile:': '🙂', + ':face-frown:': '🙁', + ':fire:': '🔥', + ':hand-raised:': '✋', + ':hand-thumb-down:': '👎', + ':hand-thumb-up:': '👍', + ':bell:': '🔔', + ':eye:': '👁️', + ':light-bulb:': '💡', + ':pencil-square:': '✏️', + ':rocket-launch:': '🚀', + ':star:': '⭐', + ':sun:': '☀️', + ':moon:': '🌙', + ':trash:': '🗑️', + ':trophy:': '🏆', + }; + if (heroiconFallbacks[shortcode]) return heroiconFallbacks[shortcode]; + const unicode = unicodeEmojis.find(e => e.shortcode === shortcode); + return unicode ? unicode.char : undefined; +} \ No newline at end of file diff --git a/src/lib/utils/markdown/markdownItParser.ts b/src/lib/utils/markdown/markdownItParser.ts new file mode 100644 index 0000000..1fccf21 --- /dev/null +++ b/src/lib/utils/markdown/markdownItParser.ts @@ -0,0 +1,391 @@ +import MarkdownIt from 'markdown-it'; +import footnote from 'markdown-it-footnote'; +import emoji from 'markdown-it-emoji'; +import { processNostrIdentifiers } from '../nostrUtils'; +import hljs from 'highlight.js'; +import 'highlight.js/lib/common'; +import 'highlight.js/styles/github-dark.css'; +import asciidoc from 'highlight.js/lib/languages/asciidoc'; +import { getUnicodeEmoji } from '../emoticons'; + +// Configure highlight.js +hljs.configure({ + ignoreUnescapedHTML: true +}); + +hljs.registerLanguage('asciidoc', asciidoc); + +// URL patterns for custom rendering +const WSS_URL = /wss:\/\/[^\s<>"]+/g; +const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/i; +const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; +const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; +const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i; + +// Tracking parameters to remove +const TRACKING_PARAMS = new Set([ + // Common tracking parameters + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'ref', 'source', 'campaign', 'si', 't', 'v', 'ab_channel', + // YouTube specific + 'feature', 'hl', 'gl', 'app', 'persist_app', 'app-arg', + 'autoplay', 'loop', 'controls', 'modestbranding', 'rel', + 'showinfo', 'iv_load_policy', 'fs', 'playsinline' +]); + +/** + * Clean URL by removing tracking parameters + */ +function cleanUrl(url: string): string { + try { + const urlObj = new URL(url); + const params = new URLSearchParams(urlObj.search); + + // Remove tracking parameters + for (const param of TRACKING_PARAMS) { + params.delete(param); + } + + // For YouTube URLs, only keep the video ID + if (YOUTUBE_URL_REGEX.test(url)) { + const videoId = url.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + } + + // Reconstruct URL without tracking parameters + urlObj.search = params.toString(); + return urlObj.toString(); + } catch (e) { + // If URL parsing fails, return original URL + return url; + } +} + +// Create markdown-it instance with plugins +const md = new MarkdownIt({ + html: true, // Enable HTML tags in source + xhtmlOut: true, // Use '/' to close single tags (
    ) + breaks: true, // Convert '\n' in paragraphs into
    + linkify: true, // Autoconvert URL-like text to links + typographer: true, // Enable some language-neutral replacement + quotes beautification + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value; + } catch (__) {} + } + return ''; // use external default escaping + } +}) +.use(footnote) +.use(emoji); + +// Enable strikethrough using markdown-it's built-in rule +md.inline.ruler.after('emphasis', 'strikethrough', (state, silent) => { + let found = false, token, pos = state.pos, max = state.posMax, start = pos, marker = state.src.charCodeAt(pos); + + if (silent) return false; + + if (marker !== 0x7E/* ~ */) return false; + + let scan = pos, mem = pos; + while (scan < max && state.src.charCodeAt(scan) === 0x7E/* ~ */) { scan++; } + let len = scan - mem; + if (len < 2) return false; + + let markup = state.src.slice(mem, scan); + let end = scan; + + while (end < max) { + if (state.src.charCodeAt(end) === marker) { + if (state.src.slice(end, end + len) === markup) { + found = true; + break; + } + } + end++; + } + + if (!found) { + state.pos = scan; + return false; + } + + if (!silent) { + state.pos = mem + len; + token = state.push('s_open', 's', 1); + token.markup = markup; + + token = state.push('text', '', 0); + token.content = state.src.slice(mem + len, end); + + token = state.push('s_close', 's', -1); + token.markup = markup; + } + + state.pos = end + len; + return true; +}); + +// Custom renderer rules for Nostr identifiers +const NOSTR_PROFILE_REGEX = /(? { + const match = /^#([a-zA-Z0-9_]+)(?!\w)/.exec(state.src.slice(state.pos)); + if (!match) return false; + + if (silent) return true; + + const tag = match[1]; + state.pos += match[0].length; + + const token = state.push('hashtag', '', 0); + token.content = tag; + token.markup = '#'; + + return true; +}); + +md.renderer.rules.hashtag = (tokens, idx) => { + const tag = tokens[idx].content; + return `#${tag}`; +}; + +// Override the default link renderer to handle Nostr identifiers and special URLs +const defaultRender = md.renderer.rules.link_open || function(tokens: any[], idx: number, options: any, env: any, self: any): string { + return self.renderToken(tokens, idx, options); +}; + +md.renderer.rules.link_open = function(tokens: any[], idx: number, options: any, env: any, self: any): string { + const token = tokens[idx]; + const hrefIndex = token.attrIndex('href'); + + if (hrefIndex >= 0) { + const href = token.attrs![hrefIndex][1]; + const cleanedHref = cleanUrl(href); + + // Handle Nostr identifiers + if ((NOSTR_PROFILE_REGEX.test(cleanedHref) || NOSTR_NOTE_REGEX.test(cleanedHref)) && !cleanedHref.startsWith('nostr:')) { + token.attrs![hrefIndex][1] = `nostr:${cleanedHref}`; + } + // Handle WebSocket URLs + else if (WSS_URL.test(cleanedHref)) { + const cleanUrl = cleanedHref.slice(6).replace(/\/+$/, ''); + token.attrs![hrefIndex][1] = `https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F`; + } + // Handle media URLs + else if (YOUTUBE_URL_REGEX.test(cleanedHref)) { + const videoId = cleanedHref.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `
    `; + } + } + else if (VIDEO_URL_REGEX.test(cleanedHref)) { + return `
    `; + } + else if (AUDIO_URL_REGEX.test(cleanedHref)) { + return `
    `; + } + else if (IMAGE_URL_REGEX.test(cleanedHref)) { + return `
    Embedded media
    `; + } + else { + // Update the href with cleaned URL + token.attrs![hrefIndex][1] = cleanedHref; + } + } + + return defaultRender(tokens, idx, options, env, self); +}; + +// Override image renderer to handle media URLs +const defaultImageRender = md.renderer.rules.image || function(tokens: any[], idx: number, options: any, env: any, self: any): string { + return self.renderToken(tokens, idx, options); +}; + +md.renderer.rules.image = function(tokens: any[], idx: number, options: any, env: any, self: any): string { + const token = tokens[idx]; + const srcIndex = token.attrIndex('src'); + + if (srcIndex >= 0) { + const src = token.attrs![srcIndex][1]; + const cleanedSrc = cleanUrl(src); + const alt = token.attrs![token.attrIndex('alt')]?.[1] || ''; + + if (YOUTUBE_URL_REGEX.test(cleanedSrc)) { + const videoId = cleanedSrc.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `
    `; + } + } + + if (VIDEO_URL_REGEX.test(cleanedSrc)) { + return `
    `; + } + + if (AUDIO_URL_REGEX.test(cleanedSrc)) { + return `
    `; + } + + // Update the src with cleaned URL + token.attrs![srcIndex][1] = cleanedSrc; + } + + return defaultImageRender(tokens, idx, options, env, self); +}; + +// Add custom rule for alternate heading style +md.block.ruler.before('heading', 'alternate_heading', (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + const content = state.src.slice(start, max).trim(); + + // Check if this line is followed by = or - underline + if (startLine + 1 >= endLine) return false; + + const nextStart = state.bMarks[startLine + 1] + state.tShift[startLine + 1]; + const nextMax = state.eMarks[startLine + 1]; + const nextContent = state.src.slice(nextStart, nextMax).trim(); + + // Check if next line is all = or - + if (!/^[=-]+$/.test(nextContent)) return false; + + // Determine heading level (h1 for =, h2 for -) + const level = nextContent[0] === '=' ? 1 : 2; + + if (silent) return true; + + // Create heading token + state.line = startLine + 2; + + const openToken = state.push('heading_open', 'h' + level, 1); + openToken.markup = '#'.repeat(level); + + const inlineToken = state.push('inline', '', 0); + inlineToken.content = content; + inlineToken.map = [startLine, startLine + 2]; + + const closeToken = state.push('heading_close', 'h' + level, -1); + closeToken.markup = '#'.repeat(level); + + return true; +}); + +// Override the default code inline rule to only support single backticks +md.inline.ruler.after('backticks', 'code_inline', (state, silent) => { + let start = state.pos; + let max = state.posMax; + let marker = state.src.charCodeAt(start); + + // Check for single backtick + if (marker !== 0x60/* ` */) return false; + + // Find the end of the code span + let pos = start + 1; + + // Find the closing backtick + while (pos < max) { + if (state.src.charCodeAt(pos) === 0x60/* ` */) { + pos++; + break; + } + pos++; + } + + if (pos >= max) return false; + + const content = state.src.slice(start + 1, pos - 1); + + if (!content) return false; + + if (silent) return true; + + state.pos = pos; + + const token = state.push('code_inline', 'code', 0); + token.content = content; + token.markup = '`'; + + return true; +}); + +/** + * Replace emoji shortcodes in text with Unicode wrapped in ... + */ +export function replaceEmojisWithUnicode(text: string): string { + return text.replace(/(:[a-z0-9_\-]+:)/gi, (match) => { + const unicode = getUnicodeEmoji(match); + if (unicode) { + return `${unicode}`; + } + return match; + }); +} + +/** + * Parse markdown text with markdown-it and custom processing + */ +export async function parseMarkdown(text: string): Promise { + if (!text) return ''; + + try { + // First pass: Process with markdown-it + let processedText = md.render(text); + + // Second pass: Process Nostr identifiers + processedText = await processNostrIdentifiers(processedText); + + // Third pass: Replace emoji shortcodes with Unicode + processedText = replaceEmojisWithUnicode(processedText); + + // Add custom classes to elements + processedText = processedText + // Add classes to headings + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '
    ') + .replace(/
    /g, '
    ') + // Add classes to paragraphs + .replace(/

    /g, '

    ') + // Add classes to blockquotes + .replace(/

    /g, '
    ') + // Add classes to code blocks + .replace(/
    /g, '
    ')
    +      // Add classes to inline code
    +      .replace(//g, '')
    +      // Add classes to links
    +      .replace(/')
    +      .replace(/
      /g, '
        ') + // Add classes to list items + .replace(/
      1. /g, '
      2. ') + // Add classes to horizontal rules + .replace(/
        /g, '
        ') + // Add classes to footnotes + .replace(/
      3. ') + // Add classes to images + .replace(//g, '') + .replace(//g, '') + .replace(//g, '') + .replace(/
        /g, '') + .replace(//g, ''); + + return processedText; + } catch (error) { + console.error('Error in parseMarkdown:', error); + return `
        Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
        `; + } +} \ No newline at end of file diff --git a/src/lib/utils/markdownTestfile.md b/src/lib/utils/markdown/markdownTestfile.md similarity index 96% rename from src/lib/utils/markdownTestfile.md rename to src/lib/utils/markdown/markdownTestfile.md index cb35194..65b5423 100644 --- a/src/lib/utils/markdownTestfile.md +++ b/src/lib/utils/markdown/markdownTestfile.md @@ -5,7 +5,9 @@ This is a test 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. +This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser. + +Try out some emojisface with smiling :facesmile:, call-me hand :call-me-hand:, and trophy :trophy:. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index dd838c9..b053adf 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -4,11 +4,11 @@ import { Button, P } from 'flowbite-svelte'; -
        -

        404 - Page Not Found

        -

        The page you are looking for does not exist or has been moved.

        -
        - - +
        +

        404 - Page Not Found

        +

        The page you are looking for does not exist or has been moved.

        +
        + +
        diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index eb0abfc..2777361 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -6,206 +6,223 @@ const isVersionKnown = appVersion !== "development"; -
        -
        -
        - About the Library of Alexandria +
        +
        +
        + About the Library of Alexandria {#if isVersionKnown} - Version: {appVersion} + Version: {appVersion} {/if}
        - Alexandria icon + Alexandria icon -

        +

        Alexandria is a reader and writer for curated publications (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the GitCitadel project team.

        -

        +

        Please submit support issues on the Alexandria repo pageAlexandria repo page and follow us on GitHub and GitHub and Geyserfund.

        -

        +

        We are easiest to contact over our Nostr address npub1s3h…75wznpub1s3h…75wz.

        - Overview + Overview -

        - Alexandria opens up to the landing page, where the user +

        + Alexandria opens up to the landing page, where the user can: login (top-right), select whether to only view the publications - hosted on the thecitadel document relay or add in their own relays, and scroll/search the publications.

        -
        +
        Landing page Relay selection
        -

        +

        There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).

        -

        +

        If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document.

        -

        +

        Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)

        -
        +
        ToC icon Table of contents example
        - Typical use cases + Typical use cases - For e-books + For e-books -

        +

        The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.

        -

        +

        An example of a book is Jane Eyre

        -
        +
        Jane Eyre, by Charlotte Brontë
        - For scientific papers + For scientific papers -

        +

        Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.

        -

        +

        Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.

        -

        +

        Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.

        -

        +

        An example of a research paper is Less Partnering, Less Children, or Both?

        -
        +
        Research paper
        - For documentation + For documentation -

        +

        Our own team uses Alexandria to document the app, to display our blog entriesblog entries, as well as to store copies of our most interesting technical specifications.

        -
        +
        Documentation
        - For wiki pages + For wiki pages -

        +

        Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages, such as this - one about the Sybil utility use the same + one about the Sybil utility use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content.

        -

        +

        Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored. diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index b2798e0..1549469 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -6,9 +6,10 @@ import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; // @ts-ignore - Workaround for Svelte component import issue import LoginModal from '$lib/components/LoginModal.svelte'; - import { parseAdvancedMarkdown } from '$lib/utils/advancedMarkdownParser'; + import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser'; import { nip19 } from 'nostr-tools'; import { getMimeTags } from '$lib/utils/mime'; + import MarkdownForm from '$lib/components/MarkdownForm.svelte'; // Function to close the success message function closeSuccessMessage() { @@ -68,29 +69,16 @@ isExpanded = !isExpanded; } - async function handleSubmit(e: Event) { - // Prevent form submission - e.preventDefault(); - + function handleIssueSubmit(subject: string, content: string) { + // Set the local state for subject/content if needed + // subject = subject; + // content = content; + // Call the original handleSubmit logic, but without the event if (!subject || !content) { submissionError = 'Please fill in all fields'; return; } - - // Check if user is logged in - if (!$ndkSignedIn) { - // Save form data - savedFormData = { - subject, - content - }; - - // Show login modal - showLoginModal = true; - return; - } - - // Show confirmation dialog + // Show confirmation dialog or proceed with submission as before showConfirmDialog = true; } @@ -268,8 +256,8 @@ } -

        -
        +
        +
        Contact GitCitadel

        @@ -286,193 +274,92 @@ If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.

        -
        -
        - - -
        - -
        - -
        -
        -
        -
          - -
        • - -
        • -
        -
        - -
        - {#if activeTab === 'write'} -
        - {:else} -
        - - - + + + + - - + + {#if rootIndexId} - + {/if} {/if} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 142eeb5..794af2b 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -115,14 +115,14 @@ }); -
        +
        -
        -

        Publication Network

        +
        +

        Publication Network

        {#if loading} -
        +
        - - \ No newline at end of file diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte index 75324a9..d8c28be 100644 --- a/src/lib/components/EventLimitControl.svelte +++ b/src/lib/components/EventLimitControl.svelte @@ -29,23 +29,23 @@ } -
        -