32 changed files with 1984 additions and 1317 deletions
@ -1,3 +1,5 @@
@@ -1,3 +1,5 @@
|
||||
{ |
||||
"editor.tabSize": 2 |
||||
"editor.tabSize": 2, |
||||
"css.validate": false, |
||||
"tailwindCSS.validate": true |
||||
} |
||||
|
||||
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
<script lang="ts"> |
||||
import { heroiconEmoticons, unicodeEmojis } from '../utils/emoticons'; |
||||
import { createEventDispatcher } from 'svelte'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
const dispatch = createEventDispatcher(); |
||||
let search = ''; |
||||
let showMore = false; |
||||
let filteredHeroicons = heroiconEmoticons; |
||||
let filteredUnicode = unicodeEmojis; |
||||
|
||||
function handleSelect(shortcode: string) { |
||||
dispatch('select', { shortcode }); |
||||
} |
||||
|
||||
function filterEmoticons() { |
||||
const s = search.trim().toLowerCase(); |
||||
filteredHeroicons = heroiconEmoticons.filter(e => |
||||
e.name.toLowerCase().includes(s) || e.shortcode.includes(s) |
||||
); |
||||
filteredUnicode = unicodeEmojis.filter(e => |
||||
e.name.toLowerCase().includes(s) || e.shortcode.includes(s) |
||||
); |
||||
} |
||||
|
||||
$: filterEmoticons(); |
||||
</script> |
||||
|
||||
<div class="emoticon-picker bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg p-2 w-72"> |
||||
<input |
||||
type="text" |
||||
class="emoticon-search mb-2 w-full px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-sm" |
||||
placeholder="Search emoticons..." |
||||
bind:value={search} |
||||
on:input={filterEmoticons} |
||||
autocomplete="off" |
||||
/> |
||||
<div class="flex flex-wrap gap-2 mb-2"> |
||||
{#each filteredHeroicons as emoticon} |
||||
<button |
||||
type="button" |
||||
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded" |
||||
title={emoticon.name + ' ' + emoticon.shortcode} |
||||
on:click={() => handleSelect(emoticon.shortcode)} |
||||
> |
||||
<svelte:component this={emoticon.component} class="w-6 h-6 text-gray-700 dark:text-gray-200" /> |
||||
<span class="text-xs text-gray-500">{emoticon.shortcode}</span> |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
<button |
||||
type="button" |
||||
class="emoticon-more-btn w-full text-center py-1 text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800" |
||||
on:click={() => showMore = !showMore} |
||||
> |
||||
{showMore ? 'Hide more...' : '... more'} |
||||
</button> |
||||
{#if showMore} |
||||
<div class="flex flex-wrap gap-2 mt-2 max-h-32 overflow-y-auto"> |
||||
{#each filteredUnicode as emoticon} |
||||
<button |
||||
type="button" |
||||
class="emoticon-btn flex flex-col items-center justify-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded" |
||||
title={emoticon.name + ' ' + emoticon.shortcode} |
||||
on:click={() => handleSelect(emoticon.shortcode)} |
||||
> |
||||
<span class="w-6 h-6 text-2xl emoji-muted">{emoticon.char}</span> |
||||
<span class="text-xs text-gray-500">{emoticon.shortcode}</span> |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.emoji-muted { |
||||
filter: grayscale(1) opacity(0.7); |
||||
display: inline-block; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
|
||||
<script lang="ts"> |
||||
import { onMount, onDestroy } from 'svelte'; |
||||
import EasyMDE from 'easymde'; |
||||
import 'easymde/dist/easymde.min.css'; |
||||
import { createEventDispatcher } from 'svelte'; |
||||
import { parseMarkdown } from '../utils/markdown/markdownItParser'; |
||||
import EmoticonPicker from './EmoticonPicker.svelte'; |
||||
import { Popover } from 'flowbite-svelte'; |
||||
|
||||
const dispatch = createEventDispatcher(); |
||||
|
||||
export let labelSubject = 'Subject'; |
||||
export let labelContent = 'Content'; |
||||
export let initialSubject = ''; |
||||
export let initialContent = ''; |
||||
export let submitLabel = 'Submit'; |
||||
export let showSubject = true; |
||||
|
||||
let subject = initialSubject; |
||||
let content = initialContent; |
||||
let submissionError = ''; |
||||
let easyMDE: EasyMDE | null = null; |
||||
let textareaEl: HTMLTextAreaElement; |
||||
let showHelp = false; |
||||
let showEmojiPicker = false; |
||||
let emojiButtonEl: HTMLButtonElement; |
||||
|
||||
const markdownHelp = `# Markdown Guide |
||||
|
||||
## Text Formatting |
||||
- **Bold**: \`*text*\` or \`**text**\` |
||||
- **Italic**: \`_text_\` or \`__text__\` |
||||
- **Strikethrough**: \`~text~\` or \`~~text~~\` |
||||
- **Inline Code**: \`\` \`code\` \`\` |
||||
- **Links**: \`[text](url)\` |
||||
- **Images**: \`\` |
||||
|
||||
## Structure |
||||
- **Headings**: |
||||
- \`# Heading 1\` through \`###### Heading 6\` |
||||
- Or: |
||||
\`\`\` |
||||
Heading 1 |
||||
======== |
||||
|
||||
Heading 2 |
||||
--------- |
||||
\`\`\` |
||||
- **Lists**: |
||||
- Unordered: \`- item\`, \`* item\`, or \`+ item\` |
||||
- Ordered: \`1. item\`, \`2. item\` |
||||
- **Code Blocks**: |
||||
\`\`\`javascript |
||||
\`\`\`language |
||||
const code = 'example'; |
||||
\`\`\` |
||||
\`\`\` |
||||
- **Blockquotes**: \`> Quote text\` |
||||
- **Tables**: |
||||
\`\`\` |
||||
| Header 1 | Header 2 | |
||||
|----------|----------| |
||||
| Cell 1 | Cell 2 | |
||||
\`\`\` |
||||
|
||||
## Special Features |
||||
- **Footnotes**: \`[^1]\` and \`[^1]: definition\` |
||||
- **Emojis**: \`:smile:\` |
||||
- **Hashtags**: \`#tag\` |
||||
- **Nostr Identifiers**: |
||||
- Profiles: \`npub...\` or \`nprofile...\` |
||||
- Notes: \`note...\`, \`nevent...\`, or \`naddr...\` |
||||
|
||||
## Media Support |
||||
- **YouTube Videos**: Automatically embedded |
||||
- **Video Files**: mp4, webm, mov, avi |
||||
- **Audio Files**: mp3, wav, ogg, m4a |
||||
- **Images**: jpg, jpeg, gif, png, webp |
||||
|
||||
All media URLs are automatically cleaned of tracking parameters for privacy.`; |
||||
|
||||
let helpContent = ''; |
||||
|
||||
async function toggleHelp() { |
||||
showHelp = !showHelp; |
||||
if (showHelp) { |
||||
helpContent = await parseMarkdown(markdownHelp); |
||||
} |
||||
} |
||||
|
||||
function handleSubmit(e: Event) { |
||||
e.preventDefault(); |
||||
if ((showSubject && !subject) || !content) { |
||||
submissionError = 'Please fill in all fields'; |
||||
return; |
||||
} |
||||
// Emit submit event with markdown content |
||||
dispatch('submit', { subject, content }); |
||||
} |
||||
|
||||
function insertEmoji(shortcode: string) { |
||||
if (easyMDE) { |
||||
const cm = easyMDE.codemirror; |
||||
const doc = cm.getDoc(); |
||||
const cursor = doc.getCursor(); |
||||
doc.replaceRange(shortcode, cursor); |
||||
showEmojiPicker = false; |
||||
} |
||||
} |
||||
|
||||
onMount(() => { |
||||
easyMDE = new EasyMDE({ |
||||
element: textareaEl, |
||||
initialValue: content, |
||||
toolbar: [ |
||||
'bold', 'italic', 'heading', '|', |
||||
'quote', 'unordered-list', 'ordered-list', '|', |
||||
'link', 'image', '|', |
||||
{ |
||||
name: 'emoji', |
||||
action: () => showEmojiPicker = !showEmojiPicker, |
||||
className: 'fa fa-heart', |
||||
title: 'Insert Emoji', |
||||
icon: `<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor' class='w-5 h-5'><path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11.995 21.003c-.512 0-1.023-.195-1.414-.586l-7.003-7.003a5.002 5.002 0 017.072-7.072l.345.345.345-.345a5.002 5.002 0 017.072 7.072l-7.003 7.003a1.997 1.997 0 01-1.414.586z'/></svg>` |
||||
}, |
||||
'|', |
||||
'preview', 'side-by-side', 'fullscreen', '|', |
||||
'guide' |
||||
], |
||||
status: false, |
||||
spellChecker: false, |
||||
previewRender: (text: string, previewElement: HTMLElement) => { |
||||
parseMarkdown(text).then(html => { |
||||
previewElement.innerHTML = html; |
||||
}); |
||||
return null; |
||||
} |
||||
}); |
||||
|
||||
if (easyMDE) { |
||||
easyMDE.codemirror.on('change', () => { |
||||
content = easyMDE!.value(); |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
onDestroy(() => { |
||||
if (easyMDE) { |
||||
easyMDE.toTextArea(); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<form class="contact-form" on:submit={handleSubmit}> |
||||
<div class="flex justify-between items-center mb-4"> |
||||
<div class="flex-1"> |
||||
{#if showSubject} |
||||
<div> |
||||
<label for="subject" class="mb-2">{labelSubject}</label> |
||||
<input id="subject" class="contact-form-input" bind:value={subject} required /> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
<button |
||||
type="button" |
||||
class="btn-secondary btn-sm ml-4" |
||||
on:click={toggleHelp} |
||||
title="Markdown Help" |
||||
> |
||||
? |
||||
</button> |
||||
</div> |
||||
<div class="relative"> |
||||
<label for="content" class="mb-2">{labelContent}</label> |
||||
<textarea bind:this={textareaEl} class="hidden"></textarea> |
||||
{#if showEmojiPicker} |
||||
<Popover |
||||
class="emoji-picker-popover" |
||||
placement="bottom" |
||||
trigger="click" |
||||
open={showEmojiPicker} |
||||
on:clickoutside={() => showEmojiPicker = false} |
||||
> |
||||
<EmoticonPicker on:select={({ detail }) => insertEmoji(detail.shortcode)} /> |
||||
</Popover> |
||||
{/if} |
||||
</div> |
||||
{#if showHelp} |
||||
<div class="mt-4 p-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 prose dark:prose-invert max-w-none"> |
||||
{@html helpContent} |
||||
</div> |
||||
{/if} |
||||
<div class="contact-form-actions"> |
||||
<button type="button" on:click={() => { subject = ''; content = ''; submissionError = ''; }}> |
||||
Clear Form |
||||
</button> |
||||
<button type="submit">{submitLabel}</button> |
||||
</div> |
||||
{#if submissionError} |
||||
<div class="contact-form-error" role="alert"> |
||||
{submissionError} |
||||
</div> |
||||
{/if} |
||||
</form> |
||||
|
||||
<style> |
||||
:global(.EasyMDEContainer) { |
||||
@apply border border-gray-300 dark:border-gray-600 rounded-lg; |
||||
} |
||||
|
||||
:global(.EasyMDEContainer .CodeMirror) { |
||||
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300; |
||||
} |
||||
|
||||
:global(.EasyMDEContainer .editor-toolbar) { |
||||
@apply border-b border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700; |
||||
} |
||||
|
||||
:global(.EasyMDEContainer .editor-toolbar button) { |
||||
@apply text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white; |
||||
} |
||||
|
||||
:global(.EasyMDEContainer .editor-toolbar button.active) { |
||||
@apply text-primary-600 dark:text-primary-400; |
||||
} |
||||
|
||||
:global(.EasyMDEContainer .editor-preview) { |
||||
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300; |
||||
} |
||||
|
||||
:global(.emoji-button) { |
||||
@apply p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded; |
||||
} |
||||
|
||||
:global(.emoji-picker-popover) { |
||||
@apply z-50; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,11 @@
@@ -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; |
||||
}
|
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
declare module 'svelte-heros/dist/*.svelte' { |
||||
import { SvelteComponentTyped } from 'svelte'; |
||||
export default class Icon extends SvelteComponentTyped<any, any, any> {} |
||||
}
|
||||
@ -1,378 +0,0 @@
@@ -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 `<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 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> { |
||||
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, '"') |
||||
.replace(/'/g, '''); |
||||
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
|
||||
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 `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`; |
||||
} |
||||
} |
||||
@ -1,182 +0,0 @@
@@ -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 = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g; |
||||
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \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 = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/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 `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`; |
||||
} |
||||
} |
||||
|
||||
if (VIDEO_URL_REGEX.test(url)) { |
||||
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || 'Video'}</video>`; |
||||
} |
||||
|
||||
if (AUDIO_URL_REGEX.test(url)) { |
||||
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`; |
||||
} |
||||
|
||||
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||
}); |
||||
|
||||
// Process Markdown links
|
||||
processedText = processedText.replace(MARKDOWN_LINK, (match, text, url) =>
|
||||
`<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>` |
||||
); |
||||
|
||||
// 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 `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`; |
||||
}); |
||||
|
||||
// Process direct media URLs
|
||||
processedText = processedText.replace(DIRECT_LINK, match => { |
||||
if (YOUTUBE_URL_REGEX.test(match)) { |
||||
const videoId = extractYouTubeVideoId(match); |
||||
if (videoId) { |
||||
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`; |
||||
} |
||||
} |
||||
|
||||
if (VIDEO_URL_REGEX.test(match)) { |
||||
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${match}">Your browser does not support the video tag.</video>`; |
||||
} |
||||
|
||||
if (AUDIO_URL_REGEX.test(match)) { |
||||
return `<audio controls class="w-full my-4" preload="none"><source src="${match}">Your browser does not support the audio tag.</audio>`; |
||||
} |
||||
|
||||
if (IMAGE_URL_REGEX.test(match)) { |
||||
return `<img src="${match}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||
} |
||||
|
||||
return `<a href="${match}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${match}</a>`; |
||||
}); |
||||
|
||||
// Process text formatting
|
||||
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>'); |
||||
processedText = processedText.replace(ITALIC_REGEX, match => { |
||||
const text = match.replace(/^_+|_+$/g, ''); |
||||
return `<em>${text}</em>`; |
||||
}); |
||||
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { |
||||
const text = doubleText || singleText; |
||||
return `<del class="line-through">${text}</del>`; |
||||
}); |
||||
|
||||
// Process hashtags
|
||||
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>'); |
||||
} 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 `<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; |
||||
} |
||||
} |
||||
|
||||
export async function parseBasicMarkdown(text: string): Promise<string> { |
||||
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 `<li class="py-2${extraIndent}">${content}</li>`; |
||||
}) |
||||
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ol class="list-decimal my-4 ml-8">$&</ol>') |
||||
|
||||
// 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 `<li class="py-2${extraIndent}">${content}</li>`; |
||||
}) |
||||
.replace(/<li.*?>.*?<\/li>\n?/gs, '<ul class="list-disc my-4 ml-8">$&</ul>'); |
||||
|
||||
// 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 => `<p class="my-4">${para}</p>`) |
||||
.join('\n'); |
||||
|
||||
// Process Nostr identifiers last
|
||||
processedText = await processNostrIdentifiers(processedText); |
||||
|
||||
return processedText; |
||||
} catch (error) { |
||||
console.error('Error in parseBasicMarkdown:', error); |
||||
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`; |
||||
} |
||||
} |
||||
@ -0,0 +1,391 @@
@@ -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 (<br />)
|
||||
breaks: true, // Convert '\n' in paragraphs into <br>
|
||||
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 = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||
const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||
|
||||
// Add custom rule for hashtags
|
||||
md.inline.ruler.after('emphasis', 'hashtag', (state, silent) => { |
||||
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 `<span class="text-secondary">#${tag}</span>`; |
||||
}; |
||||
|
||||
// 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 `<div class="videoblock"><div class="content"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div></div>`; |
||||
} |
||||
} |
||||
else if (VIDEO_URL_REGEX.test(cleanedHref)) { |
||||
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedHref}">Your browser does not support the video tag.</video></div></div>`; |
||||
} |
||||
else if (AUDIO_URL_REGEX.test(cleanedHref)) { |
||||
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedHref}">Your browser does not support the audio tag.</audio></div></div>`; |
||||
} |
||||
else if (IMAGE_URL_REGEX.test(cleanedHref)) { |
||||
return `<div class="imageblock"><div class="content"><img src="${cleanedHref}" alt="Embedded media" loading="lazy" decoding="async"></div></div>`; |
||||
} |
||||
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 `<div class="videoblock"><div class="content"><iframe src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe></div></div>`; |
||||
} |
||||
} |
||||
|
||||
if (VIDEO_URL_REGEX.test(cleanedSrc)) { |
||||
return `<div class="videoblock"><div class="content"><video controls preload="none" playsinline><source src="${cleanedSrc}">${alt || 'Video'}</video></div></div>`; |
||||
} |
||||
|
||||
if (AUDIO_URL_REGEX.test(cleanedSrc)) { |
||||
return `<div class="audioblock"><div class="content"><audio controls preload="none"><source src="${cleanedSrc}">${alt || 'Audio'}</audio></div></div>`; |
||||
} |
||||
|
||||
// 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 <span class="emoji-muted">...</span> |
||||
*/ |
||||
export function replaceEmojisWithUnicode(text: string): string { |
||||
return text.replace(/(:[a-z0-9_\-]+:)/gi, (match) => { |
||||
const unicode = getUnicodeEmoji(match); |
||||
if (unicode) { |
||||
return `<span class=\"emoji-muted\">${unicode}</span>`; |
||||
} |
||||
return match; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Parse markdown text with markdown-it and custom processing |
||||
*/ |
||||
export async function parseMarkdown(text: string): Promise<string> { |
||||
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(/<h1>/g, '<h1 class="h1-leather">') |
||||
.replace(/<h2>/g, '<h2 class="h2-leather">') |
||||
.replace(/<h3>/g, '<h3 class="h3-leather">') |
||||
.replace(/<h4>/g, '<h4 class="h4-leather">') |
||||
.replace(/<h5>/g, '<h5 class="h5-leather">') |
||||
.replace(/<h6>/g, '<h6 class="h6-leather">') |
||||
// Add classes to paragraphs
|
||||
.replace(/<p>/g, '<p class="text-primary">') |
||||
// Add classes to blockquotes
|
||||
.replace(/<blockquote>/g, '<blockquote class="quoteblock">') |
||||
// Add classes to code blocks
|
||||
.replace(/<pre>/g, '<pre class="listingblock">') |
||||
// Add classes to inline code
|
||||
.replace(/<code>/g, '<code class="literalblock">') |
||||
// Add classes to links
|
||||
.replace(/<a href=/g, '<a class="link" href=') |
||||
// Add classes to lists
|
||||
.replace(/<ul>/g, '<ul class="ulist">') |
||||
.replace(/<ol>/g, '<ol class="olist arabic">') |
||||
// Add classes to list items
|
||||
.replace(/<li>/g, '<li class="text-primary">') |
||||
// Add classes to horizontal rules
|
||||
.replace(/<hr>/g, '<hr class="border-b border-gray-300 dark:border-gray-600">') |
||||
// Add classes to footnotes
|
||||
.replace(/<div class="footnotes">/g, '<div class="footnotes mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">') |
||||
.replace(/<a href="#fnref/g, '<a class="link" href="#fnref') |
||||
.replace(/<a href="#fn-/g, '<a class="link" href="#fn-') |
||||
.replace(/<sup id="fnref/g, '<sup id="fnref" class="text-sm-secondary">') |
||||
.replace(/<li id="fn-/g, '<li id="fn-" class="text-sm-secondary">') |
||||
// Add classes to images
|
||||
.replace(/<img/g, '<img class="imageblock"') |
||||
// Add classes to tables
|
||||
.replace(/<table>/g, '<table class="tableblock">') |
||||
.replace(/<thead>/g, '<thead class="text-primary">') |
||||
.replace(/<tbody>/g, '<tbody class="text-primary">') |
||||
.replace(/<th>/g, '<th class="text-primary font-semibold">') |
||||
.replace(/<td>/g, '<td class="text-primary">'); |
||||
|
||||
return processedText; |
||||
} catch (error) { |
||||
console.error('Error in parseMarkdown:', error); |
||||
return `<div class="text-red-500">Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}</div>`; |
||||
} |
||||
}
|
||||
@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
|
||||
<script lang='ts'> |
||||
import { P, Button, Label, Textarea, Input } from 'flowbite-svelte'; |
||||
import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser'; |
||||
import { createEventDispatcher } from 'svelte'; |
||||
|
||||
// Props for initial state |
||||
export let initialSubject = ''; |
||||
export let initialContent = ''; |
||||
|
||||
// State |
||||
let subject = initialSubject; |
||||
let content = initialContent; |
||||
let isSubmitting = false; |
||||
let isExpanded = false; |
||||
let activeTab = 'write'; |
||||
let submissionError = ''; |
||||
|
||||
const dispatch = createEventDispatcher(); |
||||
|
||||
function clearForm() { |
||||
subject = ''; |
||||
content = ''; |
||||
submissionError = ''; |
||||
isExpanded = false; |
||||
activeTab = 'write'; |
||||
} |
||||
|
||||
function toggleSize() { |
||||
isExpanded = !isExpanded; |
||||
} |
||||
|
||||
function handleSubmit(e: Event) { |
||||
e.preventDefault(); |
||||
if (!subject || !content) { |
||||
submissionError = 'Please fill in all fields'; |
||||
return; |
||||
} |
||||
dispatch('submit', { subject, content }); |
||||
} |
||||
</script> |
||||
|
||||
<form class="contact-form" onsubmit={handleSubmit}> |
||||
<div> |
||||
<Label for="subject" class="mb-2">Subject</Label> |
||||
<Input id="subject" class="contact-form-input" placeholder="Issue subject" bind:value={subject} required autofocus /> |
||||
</div> |
||||
<div class="relative"> |
||||
<Label for="content" class="mb-2">Description</Label> |
||||
<div class="contact-form-textarea-container {isExpanded ? 'expanded' : 'collapsed'}"> |
||||
<div class="h-full flex flex-col"> |
||||
<div class="border-b border-gray-300 dark:border-gray-600"> |
||||
<ul class="contact-form-tabs" role="tablist"> |
||||
<li class="mr-2" role="presentation"> |
||||
<button |
||||
type="button" |
||||
class="contact-form-tab {activeTab === 'write' ? 'active' : 'inactive'}" |
||||
onclick={() => activeTab = 'write'} |
||||
role="tab" |
||||
> |
||||
Write |
||||
</button> |
||||
</li> |
||||
<li role="presentation"> |
||||
<button |
||||
type="button" |
||||
class="contact-form-tab {activeTab === 'preview' ? 'active' : 'inactive'}" |
||||
onclick={() => activeTab = 'preview'} |
||||
role="tab" |
||||
> |
||||
Preview |
||||
</button> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
<div class="flex-1 min-h-0 relative"> |
||||
{#if activeTab === 'write'} |
||||
<div class="contact-form-tab-content"> |
||||
<Textarea |
||||
id="content" |
||||
class="contact-form-textarea" |
||||
bind:value={content} |
||||
required |
||||
placeholder="Describe your issue in detail..." |
||||
/> |
||||
</div> |
||||
{:else} |
||||
<div class="contact-form-preview"> |
||||
{#key content} |
||||
{#await parseAdvancedMarkdown(content)} |
||||
<p>Loading preview...</p> |
||||
{:then html} |
||||
{@html html || '<p class="text-gray-500">Nothing to preview</p>'} |
||||
{:catch error} |
||||
<p class="text-red-500">Error rendering preview: {error.message}</p> |
||||
{/await} |
||||
{/key} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
<Button |
||||
type="button" |
||||
size="xs" |
||||
class="contact-form-toggle" |
||||
color="light" |
||||
onclick={toggleSize} |
||||
> |
||||
{isExpanded ? '⌃' : '⌄'} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
<div class="contact-form-actions"> |
||||
<Button type="button" color="alternative" onclick={clearForm}> |
||||
Clear Form |
||||
</Button> |
||||
<Button type="submit" tabindex={0}> |
||||
Submit Issue |
||||
</Button> |
||||
</div> |
||||
{#if submissionError} |
||||
<div class="contact-form-error" role="alert"> |
||||
{submissionError} |
||||
</div> |
||||
{/if} |
||||
</form> |
||||
Loading…
Reference in new issue