32 changed files with 1984 additions and 1317 deletions
@ -1,3 +1,5 @@ |
|||||||
{ |
{ |
||||||
"editor.tabSize": 2 |
"editor.tabSize": 2, |
||||||
|
"css.validate": false, |
||||||
|
"tailwindCSS.validate": true |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
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 @@ |
|||||||
|
declare module 'svelte-heros/dist/*.svelte' { |
||||||
|
import { SvelteComponentTyped } from 'svelte'; |
||||||
|
export default class Icon extends SvelteComponentTyped<any, any, any> {} |
||||||
|
}
|
||||||
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
<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