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. +

    + +
    +
    + + +
    + +
    + +