25 changed files with 2542 additions and 66 deletions
@ -1,3 +1,6 @@ |
|||||||
{ |
{ |
||||||
"editor.tabSize": 2 |
"editor.tabSize": 2, |
||||||
|
"files.associations": { |
||||||
|
"*.css": "postcss" |
||||||
} |
} |
||||||
|
} |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { loginWithExtension, ndkSignedIn } from '$lib/ndk'; |
||||||
|
|
||||||
|
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{ |
||||||
|
show?: boolean; |
||||||
|
onClose?: () => void; |
||||||
|
onLoginSuccess?: () => void; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let signInFailed = $state<boolean>(false); |
||||||
|
let errorMessage = $state<string>(''); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if ($ndkSignedIn && show) { |
||||||
|
onLoginSuccess(); |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function handleSignInClick() { |
||||||
|
try { |
||||||
|
signInFailed = false; |
||||||
|
errorMessage = ''; |
||||||
|
|
||||||
|
const user = await loginWithExtension(); |
||||||
|
if (!user) { |
||||||
|
throw new Error('The NIP-07 extension did not return a user.'); |
||||||
|
} |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error(e); |
||||||
|
signInFailed = true; |
||||||
|
errorMessage = (e as Error)?.message ?? 'Failed to sign in. Please try again.'; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if show} |
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50"> |
||||||
|
<div class="relative w-auto my-6 mx-auto max-w-3xl"> |
||||||
|
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none"> |
||||||
|
<!-- Header --> |
||||||
|
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"> |
||||||
|
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3> |
||||||
|
<button |
||||||
|
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none" |
||||||
|
onclick={onClose} |
||||||
|
> |
||||||
|
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Body --> |
||||||
|
<div class="relative p-6 flex-auto"> |
||||||
|
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400 mb-6"> |
||||||
|
You need to be logged in to submit an issue. Your form data will be preserved. |
||||||
|
</p> |
||||||
|
<div class="flex flex-col space-y-4"> |
||||||
|
<div class="flex justify-center"> |
||||||
|
<Button |
||||||
|
color="primary" |
||||||
|
onclick={handleSignInClick} |
||||||
|
> |
||||||
|
Sign in with Extension |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
{#if signInFailed} |
||||||
|
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded"> |
||||||
|
{errorMessage} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,55 @@ |
|||||||
|
# Markup Support in Alexandria |
||||||
|
|
||||||
|
Alexandria supports multiple markup formats for different use cases. Below is a summary of the supported tags and features for each parser, as well as the formats used for publications and wikis. |
||||||
|
|
||||||
|
## Basic Markup Parser |
||||||
|
|
||||||
|
The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: |
||||||
|
|
||||||
|
- **Headers:** |
||||||
|
- ATX-style: `# H1` through `###### H6` |
||||||
|
- Setext-style: `H1\n=====` |
||||||
|
- **Bold:** `*bold*` or `**bold**` |
||||||
|
- **Italic:** `_italic_` or `__italic__` |
||||||
|
- **Strikethrough:** `~strikethrough~` or `~~strikethrough~~` |
||||||
|
- **Blockquotes:** `> quoted text` |
||||||
|
- **Unordered lists:** `* item` |
||||||
|
- **Ordered lists:** `1. item` |
||||||
|
- **Links:** `[text](url)` |
||||||
|
- **Images:** `` |
||||||
|
- **Hashtags:** `#hashtag` |
||||||
|
- **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without `nostr:` prefix (note is deprecated) |
||||||
|
- **Emoji shortcodes:** `:smile:` will render as 😄 |
||||||
|
|
||||||
|
## Advanced Markup Parser |
||||||
|
|
||||||
|
The **advanced markup parser** includes all features of the basic parser, plus: |
||||||
|
|
||||||
|
- **Inline code:** `` `code` `` |
||||||
|
- **Syntax highlighting:** for code blocks in many programming languages (from [highlight.js](https://highlightjs.org/)) |
||||||
|
- **Tables:** Pipe-delimited tables with or without headers |
||||||
|
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers |
||||||
|
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended |
||||||
|
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.) |
||||||
|
|
||||||
|
## Publications and Wikis |
||||||
|
|
||||||
|
**Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary markup language, not Markdown. |
||||||
|
|
||||||
|
AsciiDoc supports a much broader set of formatting, semantic, and structural features, including: |
||||||
|
|
||||||
|
- Section and document structure |
||||||
|
- Advanced tables, callouts, admonitions |
||||||
|
- Cross-references, footnotes, and bibliography |
||||||
|
- Custom attributes and macros |
||||||
|
- And much more |
||||||
|
|
||||||
|
For more information on AsciiDoc, see the [AsciiDoc documentation](https://asciidoc.org/). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
**Note:** |
||||||
|
- The markdown parsers are primarily used for comments, issues, and other user-generated content. |
||||||
|
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility. |
||||||
|
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format. |
||||||
|
- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. |
||||||
@ -0,0 +1,389 @@ |
|||||||
|
import { parseBasicmarkup } from './basicMarkupParser'; |
||||||
|
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 markup 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; |
||||||
|
const CODE_BLOCK_REGEX = /^```(\w*)$/; |
||||||
|
|
||||||
|
/** |
||||||
|
* Process headings (both styles) |
||||||
|
*/ |
||||||
|
function processHeadings(content: string): string { |
||||||
|
// Tailwind classes for each heading level
|
||||||
|
const headingClasses = [ |
||||||
|
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
|
||||||
|
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
|
||||||
|
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
|
||||||
|
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
|
||||||
|
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
|
||||||
|
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
|
||||||
|
]; |
||||||
|
|
||||||
|
// Process ATX-style headings (# Heading)
|
||||||
|
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => { |
||||||
|
const headingLevel = Math.min(level.length, 6); |
||||||
|
const classes = headingClasses[headingLevel - 1]; |
||||||
|
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Process Setext-style headings (Heading\n====)
|
||||||
|
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { |
||||||
|
const headingLevel = level[0] === '=' ? 1 : 2; |
||||||
|
const classes = headingClasses[headingLevel - 1]; |
||||||
|
return `<h${headingLevel} class="${classes}">${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 (e: unknown) { |
||||||
|
console.error('Error processing table row:', e); |
||||||
|
return match; |
||||||
|
} |
||||||
|
}); |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error('Error in processTables:', e); |
||||||
|
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 ''; |
||||||
|
|
||||||
|
// Collect all footnote definitions (but do not remove them from the text yet)
|
||||||
|
const footnotes = new Map<string, string>(); |
||||||
|
content.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => { |
||||||
|
footnotes.set(id, text.trim()); |
||||||
|
return match; |
||||||
|
}); |
||||||
|
|
||||||
|
// Remove all footnote definition lines from the main content
|
||||||
|
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, ''); |
||||||
|
|
||||||
|
// Track all references to each footnote
|
||||||
|
const referenceOrder: { id: string, refNum: number, label: string }[] = []; |
||||||
|
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
|
||||||
|
let globalRefNum = 1; |
||||||
|
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 refNum = globalRefNum++; |
||||||
|
if (!referenceMap.has(id)) referenceMap.set(id, []); |
||||||
|
referenceMap.get(id)!.push(refNum); |
||||||
|
referenceOrder.push({ id, refNum, label: id }); |
||||||
|
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Only render footnotes section if there are actual definitions and at least one reference
|
||||||
|
if (footnotes.size > 0 && referenceOrder.length > 0) { |
||||||
|
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n'; |
||||||
|
// Only include each unique footnote once, in order of first reference
|
||||||
|
const seen = new Set<string>(); |
||||||
|
for (const { id, label } of referenceOrder) { |
||||||
|
if (seen.has(id)) continue; |
||||||
|
seen.add(id); |
||||||
|
const text = footnotes.get(id) || ''; |
||||||
|
// List of backrefs for this footnote
|
||||||
|
const refs = referenceMap.get(id) || []; |
||||||
|
const backrefs = refs.map((num, i) => |
||||||
|
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>` |
||||||
|
).join(' '); |
||||||
|
// If label is not a number, show it after all backrefs
|
||||||
|
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : ''; |
||||||
|
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</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(CODE_BLOCK_REGEX); |
||||||
|
|
||||||
|
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: unknown) { |
||||||
|
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: unknown) { |
||||||
|
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: unknown) { |
||||||
|
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 (e: unknown) { |
||||||
|
console.error('Error restoring code block:', e); |
||||||
|
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse markup text with advanced formatting |
||||||
|
*/ |
||||||
|
export async function parseAdvancedmarkup(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 (only references, not definitions)
|
||||||
|
processedText = processFootnotes(processedText); |
||||||
|
|
||||||
|
// Process basic markup (which will also handle Nostr identifiers)
|
||||||
|
processedText = await parseBasicmarkup(processedText); |
||||||
|
|
||||||
|
// Step 3: Restore code blocks
|
||||||
|
processedText = restoreCodeBlocks(processedText, blocks); |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error('Error in parseAdvancedmarkup:', e); |
||||||
|
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,388 @@ |
|||||||
|
import { processNostrIdentifiers } from '../nostrUtils'; |
||||||
|
import * as emoji from 'node-emoji'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
/* Regex constants for basic markup parsing */ |
||||||
|
|
||||||
|
// Text formatting
|
||||||
|
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; |
||||||
|
|
||||||
|
// Block elements
|
||||||
|
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; |
||||||
|
|
||||||
|
// Links and media
|
||||||
|
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; |
||||||
|
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; |
||||||
|
const WSS_URL = /wss:\/\/[^\s<>"]+/g; |
||||||
|
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g; |
||||||
|
|
||||||
|
// Media URL patterns
|
||||||
|
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/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; |
||||||
|
|
||||||
|
// Add this helper function near the top:
|
||||||
|
function replaceAlexandriaNostrLinks(text: string): string { |
||||||
|
// Regex for Alexandria/localhost URLs
|
||||||
|
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; |
||||||
|
// Regex for bech32 Nostr identifiers
|
||||||
|
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; |
||||||
|
// Regex for 64-char hex
|
||||||
|
const hexPattern = /\b[a-fA-F0-9]{64}\b/; |
||||||
|
|
||||||
|
// 1. Alexandria/localhost markup links
|
||||||
|
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return match; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}); |
||||||
|
|
||||||
|
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
|
||||||
|
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return url; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
// For non-Alexandria/localhost URLs, append (View here: nostr:<id>) if a Nostr identifier is present
|
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `${url} (View here: nostr:${nevent})`; |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `${url} (View here: nostr:${bech32Match[0]})`; |
||||||
|
} |
||||||
|
return url; |
||||||
|
}); |
||||||
|
|
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
// Utility to strip tracking parameters from URLs
|
||||||
|
function stripTrackingParams(url: string): string { |
||||||
|
// List of tracking params to remove
|
||||||
|
const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i]; |
||||||
|
try { |
||||||
|
// Absolute URL
|
||||||
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { |
||||||
|
const parsed = new URL(url); |
||||||
|
trackingParams.forEach(pattern => { |
||||||
|
for (const key of Array.from(parsed.searchParams.keys())) { |
||||||
|
if (pattern.test(key)) { |
||||||
|
parsed.searchParams.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
const queryString = parsed.searchParams.toString(); |
||||||
|
return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || ''); |
||||||
|
} else { |
||||||
|
// Relative URL: parse query string manually
|
||||||
|
const [path, queryAndHash = ''] = url.split('?'); |
||||||
|
const [query = '', hash = ''] = queryAndHash.split('#'); |
||||||
|
if (!query) return url; |
||||||
|
const params = query.split('&').filter(Boolean); |
||||||
|
const filtered = params.filter(param => { |
||||||
|
const [key] = param.split('='); |
||||||
|
return !trackingParams.some(pattern => pattern.test(key)); |
||||||
|
}); |
||||||
|
const queryString = filtered.length ? '?' + filtered.join('&') : ''; |
||||||
|
const hashString = hash ? '#' + hash : ''; |
||||||
|
return path + queryString + hashString; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeDTag(input: string): string { |
||||||
|
return input |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\p{L}\p{N}]/gu, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.replace(/^-|-$/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
function replaceWikilinks(text: string): string { |
||||||
|
// [[target page]] or [[target page|display text]]
|
||||||
|
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { |
||||||
|
const normalized = normalizeDTag(target.trim()); |
||||||
|
const display = (label || target).trim(); |
||||||
|
const url = `./publication?d=${normalized}`; |
||||||
|
// Output as a clickable <a> with the [[display]] format and matching link colors
|
||||||
|
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string { |
||||||
|
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] { |
||||||
|
let html = ''; |
||||||
|
let i = start; |
||||||
|
html += `<${type} class="${type === 'ol' ? 'list-decimal' : 'list-disc'} ml-6 mb-2">`; |
||||||
|
while (i < lines.length) { |
||||||
|
const line = lines[i]; |
||||||
|
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); |
||||||
|
if (!match) break; |
||||||
|
const lineIndent = match[1].replace(/\t/g, ' ').length; |
||||||
|
const isOrdered = /\d+\./.test(match[2]); |
||||||
|
const itemType = isOrdered ? 'ol' : 'ul'; |
||||||
|
if (lineIndent > indent) { |
||||||
|
// Nested list
|
||||||
|
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); |
||||||
|
html = html.replace(/<\/li>$/, '') + nestedHtml + '</li>'; |
||||||
|
i = consumed; |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (lineIndent < indent || itemType !== type) { |
||||||
|
break; |
||||||
|
} |
||||||
|
html += `<li class="mb-1">${match[3]}`; |
||||||
|
// Check for next line being a nested list
|
||||||
|
if (i + 1 < lines.length) { |
||||||
|
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
if (nextMatch) { |
||||||
|
const nextIndent = nextMatch[1].replace(/\t/g, ' ').length; |
||||||
|
const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul'; |
||||||
|
if (nextIndent > lineIndent) { |
||||||
|
const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType); |
||||||
|
html += nestedHtml; |
||||||
|
i = consumed - 1; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
html += '</li>'; |
||||||
|
i++; |
||||||
|
} |
||||||
|
html += `</${type}>`; |
||||||
|
return [html, i]; |
||||||
|
} |
||||||
|
if (!lines.length) return ''; |
||||||
|
const firstLine = lines[0]; |
||||||
|
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
const indent = match ? match[1].replace(/\t/g, ' ').length : 0; |
||||||
|
const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul'); |
||||||
|
const [html] = parseList(0, indent, type); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
function processBasicFormatting(content: string): string { |
||||||
|
if (!content) return ''; |
||||||
|
|
||||||
|
let processedText = content; |
||||||
|
|
||||||
|
try { |
||||||
|
// Sanitize Alexandria Nostr links before further processing
|
||||||
|
processedText = replaceAlexandriaNostrLinks(processedText); |
||||||
|
|
||||||
|
// Process markup images first
|
||||||
|
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => { |
||||||
|
url = stripTrackingParams(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>`; |
||||||
|
} |
||||||
|
// Only render <img> if the url ends with a direct image extension
|
||||||
|
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { |
||||||
|
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||||
|
} |
||||||
|
// Otherwise, render as a clickable link
|
||||||
|
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Process markup links
|
||||||
|
processedText = processedText.replace(MARKUP_LINK, (match, text, url) =>
|
||||||
|
`<a href="${stripTrackingParams(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 and auto-link all URLs
|
||||||
|
processedText = processedText.replace(DIRECT_LINK, match => { |
||||||
|
const clean = stripTrackingParams(match); |
||||||
|
if (YOUTUBE_URL_REGEX.test(clean)) { |
||||||
|
const videoId = extractYouTubeVideoId(clean); |
||||||
|
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(clean)) { |
||||||
|
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`; |
||||||
|
} |
||||||
|
if (AUDIO_URL_REGEX.test(clean)) { |
||||||
|
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`; |
||||||
|
} |
||||||
|
// Only render <img> if the url ends with a direct image extension
|
||||||
|
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) { |
||||||
|
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||||
|
} |
||||||
|
// Otherwise, render as a clickable link
|
||||||
|
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</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-primary-600 dark:text-primary-500">#$1</span>'); |
||||||
|
|
||||||
|
// --- Improved List Grouping and Parsing ---
|
||||||
|
const lines = processedText.split('\n'); |
||||||
|
let output = ''; |
||||||
|
let buffer: string[] = []; |
||||||
|
let inList = false; |
||||||
|
for (let i = 0; i < lines.length; i++) { |
||||||
|
const line = lines[i]; |
||||||
|
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { |
||||||
|
buffer.push(line); |
||||||
|
inList = true; |
||||||
|
} else { |
||||||
|
if (inList) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); |
||||||
|
buffer = []; |
||||||
|
inList = false; |
||||||
|
} |
||||||
|
output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n'; |
||||||
|
} |
||||||
|
} |
||||||
|
if (buffer.length) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); |
||||||
|
} |
||||||
|
processedText = output; |
||||||
|
// --- End Improved List Grouping and Parsing ---
|
||||||
|
|
||||||
|
} catch (e: unknown) { |
||||||
|
console.error('Error in processBasicFormatting:', e); |
||||||
|
} |
||||||
|
|
||||||
|
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 (e: unknown) { |
||||||
|
console.error('Error in processBlockquotes:', e); |
||||||
|
return content; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function processEmojiShortcuts(content: string): string { |
||||||
|
try { |
||||||
|
return emoji.emojify(content, { fallback: (name: string) => { |
||||||
|
const emojiChar = emoji.get(name); |
||||||
|
return emojiChar || `:${name}:`; |
||||||
|
}}); |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error('Error in processEmojiShortcuts:', e); |
||||||
|
return content; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function parseBasicmarkup(text: string): Promise<string> { |
||||||
|
if (!text) return ''; |
||||||
|
|
||||||
|
try { |
||||||
|
// Process basic text formatting first
|
||||||
|
let processedText = processBasicFormatting(text); |
||||||
|
|
||||||
|
// Process emoji shortcuts
|
||||||
|
processedText = processEmojiShortcuts(processedText); |
||||||
|
|
||||||
|
// 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); |
||||||
|
|
||||||
|
// Replace wikilinks
|
||||||
|
processedText = replaceWikilinks(processedText); |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error('Error in parseBasicmarkup:', e); |
||||||
|
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
/** |
||||||
|
* Determine the type of Nostr event based on its kind number |
||||||
|
* Following NIP specification for kind ranges: |
||||||
|
* - Replaceable: 0, 3, 10000-19999 (only latest stored) |
||||||
|
* - Ephemeral: 20000-29999 (not stored) |
||||||
|
* - Addressable: 30000-39999 (latest per d-tag stored) |
||||||
|
* - Regular: all other kinds (stored by relays) |
||||||
|
*/ |
||||||
|
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { |
||||||
|
// Check special ranges first
|
||||||
|
if (kind >= 30000 && kind < 40000) { |
||||||
|
return 'addressable'; |
||||||
|
} |
||||||
|
|
||||||
|
if (kind >= 20000 && kind < 30000) { |
||||||
|
return 'ephemeral'; |
||||||
|
} |
||||||
|
|
||||||
|
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { |
||||||
|
return 'replaceable'; |
||||||
|
} |
||||||
|
|
||||||
|
// Everything else is regular
|
||||||
|
return 'regular'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get MIME tags for a Nostr event based on its kind number |
||||||
|
* Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]] |
||||||
|
* Following NKBIP-06 and NIP-94 specifications |
||||||
|
*/ |
||||||
|
export function getMimeTags(kind: number): [string, string][] { |
||||||
|
// Default tags for unknown kinds
|
||||||
|
let mTag: [string, string] = ["m", "text/plain"]; |
||||||
|
let MTag: [string, string] = ["M", "note/generic/nonreplaceable"]; |
||||||
|
|
||||||
|
// Determine replaceability based on event type
|
||||||
|
const eventType = getEventType(kind); |
||||||
|
const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
|
||||||
|
? "replaceable"
|
||||||
|
: "nonreplaceable"; |
||||||
|
|
||||||
|
switch (kind) { |
||||||
|
// Short text note
|
||||||
|
case 1: |
||||||
|
mTag = ["m", "text/plain"]; |
||||||
|
MTag = ["M", `note/microblog/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Generic reply
|
||||||
|
case 1111: |
||||||
|
mTag = ["m", "text/plain"]; |
||||||
|
MTag = ["M", `note/comment/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Issue
|
||||||
|
case 1621: |
||||||
|
mTag = ["m", "text/markup"]; |
||||||
|
MTag = ["M", `git/issue/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Issue comment
|
||||||
|
case 1622: |
||||||
|
mTag = ["m", "text/markup"]; |
||||||
|
MTag = ["M", `git/comment/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Book metadata
|
||||||
|
case 30040: |
||||||
|
mTag = ["m", "application/json"]; |
||||||
|
MTag = ["M", `meta-data/index/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Book content
|
||||||
|
case 30041: |
||||||
|
mTag = ["m", "text/asciidoc"]; |
||||||
|
MTag = ["M", `article/publication-content/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Wiki page
|
||||||
|
case 30818: |
||||||
|
mTag = ["m", "text/asciidoc"]; |
||||||
|
MTag = ["M", `article/wiki/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Long-form note
|
||||||
|
case 30023: |
||||||
|
mTag = ["m", "text/markup"]; |
||||||
|
MTag = ["M", `article/long-form/${replaceability}`]; |
||||||
|
break; |
||||||
|
|
||||||
|
// Add more cases as needed...
|
||||||
|
} |
||||||
|
|
||||||
|
return [mTag, MTag]; |
||||||
|
}
|
||||||
@ -0,0 +1,182 @@ |
|||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { npubCache } from './npubCache'; |
||||||
|
|
||||||
|
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
|
||||||
|
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||||
|
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||||
|
|
||||||
|
/** |
||||||
|
* HTML escape a string |
||||||
|
*/ |
||||||
|
function escapeHtml(text: string): string { |
||||||
|
const htmlEscapes: { [key: string]: string } = { |
||||||
|
'&': '&', |
||||||
|
'<': '<', |
||||||
|
'>': '>', |
||||||
|
'"': '"', |
||||||
|
"'": ''' |
||||||
|
}; |
||||||
|
return text.replace(/[&<>"']/g, char => htmlEscapes[char]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get user metadata for a nostr identifier (npub or nprofile) |
||||||
|
*/ |
||||||
|
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { |
||||||
|
// Remove nostr: prefix if present
|
||||||
|
const cleanId = identifier.replace(/^nostr:/, ''); |
||||||
|
|
||||||
|
if (npubCache.has(cleanId)) { |
||||||
|
return npubCache.get(cleanId)!; |
||||||
|
} |
||||||
|
|
||||||
|
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; |
||||||
|
|
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
const decoded = nip19.decode(cleanId); |
||||||
|
if (!decoded) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
// Handle different identifier types
|
||||||
|
let pubkey: string; |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
pubkey = decoded.data; |
||||||
|
} else if (decoded.type === 'nprofile') { |
||||||
|
pubkey = decoded.data.pubkey; |
||||||
|
} else { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
const user = ndk.getUser({ pubkey: pubkey }); |
||||||
|
if (!user) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const profile = await user.fetchProfile(); |
||||||
|
if (!profile) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
const metadata = { |
||||||
|
name: profile.name || fallback.name, |
||||||
|
displayName: profile.displayName |
||||||
|
}; |
||||||
|
|
||||||
|
npubCache.set(cleanId, metadata); |
||||||
|
return metadata; |
||||||
|
} catch (e) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
npubCache.set(cleanId, fallback); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a profile link element |
||||||
|
*/ |
||||||
|
function createProfileLink(identifier: string, displayText: string | undefined): string { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ''); |
||||||
|
const escapedId = escapeHtml(cleanId); |
||||||
|
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; |
||||||
|
const escapedText = escapeHtml(displayText || defaultText); |
||||||
|
|
||||||
|
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a note link element |
||||||
|
*/ |
||||||
|
function createNoteLink(identifier: string): string { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ''); |
||||||
|
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; |
||||||
|
const escapedId = escapeHtml(cleanId); |
||||||
|
const escapedText = escapeHtml(shortId); |
||||||
|
|
||||||
|
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process Nostr identifiers in text |
||||||
|
*/ |
||||||
|
export async function processNostrIdentifiers(content: string): Promise<string> { |
||||||
|
let processedContent = content; |
||||||
|
|
||||||
|
// Helper to check if a match is part of a URL
|
||||||
|
function isPartOfUrl(text: string, index: number): boolean { |
||||||
|
// Look for http(s):// or www. before the match
|
||||||
|
const before = text.slice(Math.max(0, index - 12), index); |
||||||
|
return /https?:\/\/$|www\.$/i.test(before); |
||||||
|
} |
||||||
|
|
||||||
|
// Process profiles (npub and nprofile)
|
||||||
|
const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX)); |
||||||
|
for (const match of profileMatches) { |
||||||
|
const [fullMatch] = match; |
||||||
|
const matchIndex = match.index ?? 0; |
||||||
|
if (isPartOfUrl(content, matchIndex)) { |
||||||
|
continue; // skip if part of a URL
|
||||||
|
} |
||||||
|
let identifier = fullMatch; |
||||||
|
if (!identifier.startsWith('nostr:')) { |
||||||
|
identifier = 'nostr:' + identifier; |
||||||
|
} |
||||||
|
const metadata = await getUserMetadata(identifier); |
||||||
|
const displayText = metadata.displayName || metadata.name; |
||||||
|
const link = createProfileLink(identifier, displayText); |
||||||
|
processedContent = processedContent.replace(fullMatch, link); |
||||||
|
} |
||||||
|
|
||||||
|
// Process notes (nevent, note, naddr)
|
||||||
|
const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX)); |
||||||
|
for (const match of noteMatches) { |
||||||
|
const [fullMatch] = match; |
||||||
|
const matchIndex = match.index ?? 0; |
||||||
|
if (isPartOfUrl(processedContent, matchIndex)) { |
||||||
|
continue; // skip if part of a URL
|
||||||
|
} |
||||||
|
let identifier = fullMatch; |
||||||
|
if (!identifier.startsWith('nostr:')) { |
||||||
|
identifier = 'nostr:' + identifier; |
||||||
|
} |
||||||
|
const link = createNoteLink(identifier); |
||||||
|
processedContent = processedContent.replace(fullMatch, link); |
||||||
|
} |
||||||
|
|
||||||
|
return processedContent; |
||||||
|
} |
||||||
|
|
||||||
|
export async function getNpubFromNip05(nip05: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) { |
||||||
|
console.error('NDK not initialized'); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const user = await ndk.getUser({ nip05 }); |
||||||
|
if (!user || !user.npub) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return user.npub; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error getting npub from nip05:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
}
|
||||||
@ -0,0 +1,49 @@ |
|||||||
|
export type NpubMetadata = { name?: string; displayName?: string }; |
||||||
|
|
||||||
|
class NpubCache { |
||||||
|
private cache: Record<string, NpubMetadata> = {}; |
||||||
|
|
||||||
|
get(key: string): NpubMetadata | undefined { |
||||||
|
return this.cache[key]; |
||||||
|
} |
||||||
|
|
||||||
|
set(key: string, value: NpubMetadata): void { |
||||||
|
this.cache[key] = value; |
||||||
|
} |
||||||
|
|
||||||
|
has(key: string): boolean { |
||||||
|
return key in this.cache; |
||||||
|
} |
||||||
|
|
||||||
|
delete(key: string): boolean { |
||||||
|
if (key in this.cache) { |
||||||
|
delete this.cache[key]; |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
deleteMany(keys: string[]): number { |
||||||
|
let deleted = 0; |
||||||
|
for (const key of keys) { |
||||||
|
if (this.delete(key)) { |
||||||
|
deleted++; |
||||||
|
} |
||||||
|
} |
||||||
|
return deleted; |
||||||
|
} |
||||||
|
|
||||||
|
clear(): void { |
||||||
|
this.cache = {}; |
||||||
|
} |
||||||
|
|
||||||
|
size(): number { |
||||||
|
return Object.keys(this.cache).length; |
||||||
|
} |
||||||
|
|
||||||
|
getAll(): Record<string, NpubMetadata> { |
||||||
|
return { ...this.cache }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const npubCache = new NpubCache(); |
||||||
@ -0,0 +1,518 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; |
||||||
|
import { ndkSignedIn, ndkInstance } from '$lib/ndk'; |
||||||
|
import { standardRelays } from '$lib/consts'; |
||||||
|
import type NDK from '@nostr-dev-kit/ndk'; |
||||||
|
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; |
||||||
|
// @ts-ignore - Workaround for Svelte component import issue |
||||||
|
import LoginModal from '$lib/components/LoginModal.svelte'; |
||||||
|
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { getMimeTags } from '$lib/utils/mime'; |
||||||
|
|
||||||
|
// Function to close the success message |
||||||
|
function closeSuccessMessage() { |
||||||
|
submissionSuccess = false; |
||||||
|
submittedEvent = null; |
||||||
|
} |
||||||
|
|
||||||
|
function clearForm() { |
||||||
|
subject = ''; |
||||||
|
content = ''; |
||||||
|
submissionError = ''; |
||||||
|
isExpanded = false; |
||||||
|
activeTab = 'write'; |
||||||
|
} |
||||||
|
|
||||||
|
let subject = $state(''); |
||||||
|
let content = $state(''); |
||||||
|
let isSubmitting = $state(false); |
||||||
|
let showLoginModal = $state(false); |
||||||
|
let submissionSuccess = $state(false); |
||||||
|
let submissionError = $state(''); |
||||||
|
let submittedEvent = $state<NDKEvent | null>(null); |
||||||
|
let issueLink = $state(''); |
||||||
|
let successfulRelays = $state<string[]>([]); |
||||||
|
let isExpanded = $state(false); |
||||||
|
let activeTab = $state('write'); |
||||||
|
let showConfirmDialog = $state(false); |
||||||
|
|
||||||
|
// Store form data when user needs to login |
||||||
|
let savedFormData = { |
||||||
|
subject: '', |
||||||
|
content: '' |
||||||
|
}; |
||||||
|
|
||||||
|
// Repository event address from the task |
||||||
|
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; |
||||||
|
|
||||||
|
// Hard-coded relays to ensure we have working relays |
||||||
|
const allRelays = [ |
||||||
|
'wss://relay.damus.io', |
||||||
|
'wss://relay.nostr.band', |
||||||
|
'wss://nos.lol', |
||||||
|
...standardRelays |
||||||
|
]; |
||||||
|
|
||||||
|
// Hard-coded repository owner pubkey and ID from the task |
||||||
|
// These values are extracted from the naddr |
||||||
|
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'; |
||||||
|
const repoId = 'Alexandria'; |
||||||
|
|
||||||
|
// Function to normalize relay URLs by removing trailing slashes |
||||||
|
function normalizeRelayUrl(url: string): string { |
||||||
|
return url.replace(/\/+$/, ''); |
||||||
|
} |
||||||
|
|
||||||
|
function toggleSize() { |
||||||
|
isExpanded = !isExpanded; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleSubmit(e: Event) { |
||||||
|
// Prevent form submission |
||||||
|
e.preventDefault(); |
||||||
|
|
||||||
|
if (!subject || !content) { |
||||||
|
submissionError = 'Please fill in all fields'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is logged in |
||||||
|
if (!$ndkSignedIn) { |
||||||
|
// Save form data |
||||||
|
savedFormData = { |
||||||
|
subject, |
||||||
|
content |
||||||
|
}; |
||||||
|
|
||||||
|
// Show login modal |
||||||
|
showLoginModal = true; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Show confirmation dialog |
||||||
|
showConfirmDialog = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function confirmSubmit() { |
||||||
|
showConfirmDialog = false; |
||||||
|
await submitIssue(); |
||||||
|
} |
||||||
|
|
||||||
|
function cancelSubmit() { |
||||||
|
showConfirmDialog = false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publish event to relays with retry logic |
||||||
|
*/ |
||||||
|
async function publishToRelays( |
||||||
|
event: NDKEvent, |
||||||
|
ndk: NDK, |
||||||
|
relays: Set<string>, |
||||||
|
maxRetries: number = 3, |
||||||
|
timeout: number = 10000 |
||||||
|
): Promise<string[]> { |
||||||
|
const successfulRelays: string[] = []; |
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk); |
||||||
|
|
||||||
|
// Set up listeners for successful publishes |
||||||
|
const publishPromises = Array.from(relays).map(relayUrl => { |
||||||
|
return new Promise<void>(resolve => { |
||||||
|
const relay = ndk.pool?.getRelay(relayUrl); |
||||||
|
if (relay) { |
||||||
|
relay.on('published', (publishedEvent: NDKEvent) => { |
||||||
|
if (publishedEvent.id === event.id) { |
||||||
|
successfulRelays.push(relayUrl); |
||||||
|
resolve(); |
||||||
|
} |
||||||
|
}); |
||||||
|
} else { |
||||||
|
resolve(); // Resolve if relay not available |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Try publishing with retries |
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) { |
||||||
|
try { |
||||||
|
// Start publishing with timeout |
||||||
|
const publishPromise = event.publish(relaySet); |
||||||
|
const timeoutPromise = new Promise((_, reject) => { |
||||||
|
setTimeout(() => reject(new Error('Publish timeout')), timeout); |
||||||
|
}); |
||||||
|
|
||||||
|
await Promise.race([ |
||||||
|
publishPromise, |
||||||
|
Promise.allSettled(publishPromises), |
||||||
|
timeoutPromise |
||||||
|
]); |
||||||
|
|
||||||
|
if (successfulRelays.length > 0) { |
||||||
|
break; // Exit retry loop if we have successful publishes |
||||||
|
} |
||||||
|
|
||||||
|
if (attempt < maxRetries) { |
||||||
|
// Wait before retrying (exponential backoff) |
||||||
|
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
if (attempt === maxRetries && successfulRelays.length === 0) { |
||||||
|
throw new Error('Failed to publish to any relays after multiple attempts'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return successfulRelays; |
||||||
|
} |
||||||
|
|
||||||
|
async function submitIssue() { |
||||||
|
isSubmitting = true; |
||||||
|
submissionError = ''; |
||||||
|
submissionSuccess = false; |
||||||
|
|
||||||
|
try { |
||||||
|
// Get NDK instance |
||||||
|
const ndk = $ndkInstance; |
||||||
|
if (!ndk) { |
||||||
|
throw new Error('NDK instance not available'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!ndk.signer) { |
||||||
|
throw new Error('No signer available. Make sure you are logged in.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create and prepare the event |
||||||
|
const event = await createIssueEvent(ndk); |
||||||
|
|
||||||
|
// Collect all unique relays |
||||||
|
const uniqueRelays = new Set([ |
||||||
|
...allRelays.map(normalizeRelayUrl), |
||||||
|
...(ndk.pool ? Array.from(ndk.pool.relays.values()) |
||||||
|
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol')) |
||||||
|
.map(relay => normalizeRelayUrl(relay.url)) : []) |
||||||
|
]); |
||||||
|
|
||||||
|
try { |
||||||
|
// Publish to relays with retry logic |
||||||
|
successfulRelays = await publishToRelays(event, ndk, uniqueRelays); |
||||||
|
|
||||||
|
// Store the submitted event and create issue link |
||||||
|
submittedEvent = event; |
||||||
|
|
||||||
|
// Create the issue link using the repository address |
||||||
|
const noteId = nip19.noteEncode(event.id); |
||||||
|
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`; |
||||||
|
|
||||||
|
// Clear form and show success message |
||||||
|
clearForm(); |
||||||
|
submissionSuccess = true; |
||||||
|
} catch (error) { |
||||||
|
throw new Error('Failed to publish event'); |
||||||
|
} |
||||||
|
} catch (error: any) { |
||||||
|
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`; |
||||||
|
} finally { |
||||||
|
isSubmitting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create and sign a new issue event |
||||||
|
*/ |
||||||
|
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> { |
||||||
|
const event = new NDKEvent(ndk); |
||||||
|
event.kind = 1621; // issue_kind |
||||||
|
event.tags.push(['subject', subject]); |
||||||
|
event.tags.push(['alt', `git repository issue: ${subject}`]); |
||||||
|
|
||||||
|
// Add repository reference with proper format |
||||||
|
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`; |
||||||
|
event.tags.push([ |
||||||
|
'a', |
||||||
|
aTagValue, |
||||||
|
'', |
||||||
|
'root' |
||||||
|
]); |
||||||
|
|
||||||
|
// Add repository owner as p tag with proper value |
||||||
|
event.tags.push(['p', repoOwnerPubkey]); |
||||||
|
|
||||||
|
// Add MIME tags |
||||||
|
const mimeTags = getMimeTags(1621); |
||||||
|
event.tags.push(...mimeTags); |
||||||
|
|
||||||
|
// Set content |
||||||
|
event.content = content; |
||||||
|
|
||||||
|
// Sign the event |
||||||
|
try { |
||||||
|
await event.sign(); |
||||||
|
} catch (error) { |
||||||
|
throw new Error('Failed to sign event'); |
||||||
|
} |
||||||
|
|
||||||
|
return event; |
||||||
|
} |
||||||
|
|
||||||
|
// Handle login completion |
||||||
|
$effect(() => { |
||||||
|
if ($ndkSignedIn && showLoginModal) { |
||||||
|
showLoginModal = false; |
||||||
|
|
||||||
|
// Restore saved form data |
||||||
|
if (savedFormData.subject) subject = savedFormData.subject; |
||||||
|
if (savedFormData.content) content = savedFormData.content; |
||||||
|
|
||||||
|
// Submit the issue |
||||||
|
submitIssue(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<div class='w-full flex justify-center'> |
||||||
|
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'> |
||||||
|
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A> |
||||||
|
</P> |
||||||
|
|
||||||
|
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. |
||||||
|
</P> |
||||||
|
|
||||||
|
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off"> |
||||||
|
<div> |
||||||
|
<Label for="subject" class="mb-2">Subject</Label> |
||||||
|
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="relative"> |
||||||
|
<Label for="content" class="mb-2">Description</Label> |
||||||
|
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"> |
||||||
|
<div class="h-full flex flex-col"> |
||||||
|
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"> |
||||||
|
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> |
||||||
|
<li class="mr-2" role="presentation"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" |
||||||
|
on:click={() => activeTab = 'write'} |
||||||
|
role="tab" |
||||||
|
> |
||||||
|
Write |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
<li role="presentation"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" |
||||||
|
on:click={() => activeTab = 'preview'} |
||||||
|
role="tab" |
||||||
|
> |
||||||
|
Preview |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 relative"> |
||||||
|
{#if activeTab === 'write'} |
||||||
|
<div class="absolute inset-0 overflow-hidden"> |
||||||
|
<Textarea |
||||||
|
id="content" |
||||||
|
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-400 dark:focus:border-primary-500" |
||||||
|
bind:value={content} |
||||||
|
required |
||||||
|
placeholder="Describe your issue in detail... |
||||||
|
|
||||||
|
The following markup is supported: |
||||||
|
|
||||||
|
# Headers (1-6 levels) |
||||||
|
|
||||||
|
Header 1 |
||||||
|
====== |
||||||
|
|
||||||
|
*Bold* or **bold** |
||||||
|
|
||||||
|
_Italic_ or __italic__ text |
||||||
|
|
||||||
|
~Strikethrough~ or ~~strikethrough~~ text |
||||||
|
|
||||||
|
> Blockquotes |
||||||
|
|
||||||
|
Lists, including nested: |
||||||
|
* Bullets/unordered lists |
||||||
|
1. Numbered/ordered lists |
||||||
|
|
||||||
|
[Links](url) |
||||||
|
 |
||||||
|
|
||||||
|
`Inline code` |
||||||
|
|
||||||
|
```language |
||||||
|
Code blocks with syntax highlighting for over 100 languages |
||||||
|
``` |
||||||
|
|
||||||
|
| Tables | With or without headers | |
||||||
|
|--------|------| |
||||||
|
| Multiple | Rows | |
||||||
|
|
||||||
|
Footnotes[^1] and [^1]: footnote content |
||||||
|
|
||||||
|
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix." |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"> |
||||||
|
{#key content} |
||||||
|
{#await parseAdvancedmarkup(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="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100" |
||||||
|
color="light" |
||||||
|
on:click={toggleSize} |
||||||
|
> |
||||||
|
{isExpanded ? '⌃' : '⌄'} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4"> |
||||||
|
<Button type="button" color="alternative" on:click={clearForm}> |
||||||
|
Clear Form |
||||||
|
</Button> |
||||||
|
<Button type="submit" tabindex={0}> |
||||||
|
{#if isSubmitting} |
||||||
|
Submitting... |
||||||
|
{:else} |
||||||
|
Submit Issue |
||||||
|
{/if} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if submissionSuccess && submittedEvent} |
||||||
|
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert"> |
||||||
|
<!-- Close button --> |
||||||
|
<button |
||||||
|
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100" |
||||||
|
on:click={closeSuccessMessage} |
||||||
|
aria-label="Close" |
||||||
|
> |
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div class="flex items-center mb-3"> |
||||||
|
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> |
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path> |
||||||
|
</svg> |
||||||
|
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"> |
||||||
|
<div class="mb-2"> |
||||||
|
<span class="font-semibold">Subject:</span> |
||||||
|
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<span class="font-semibold">Description:</span> |
||||||
|
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto"> |
||||||
|
{#await parseAdvancedmarkup(submittedEvent.content)} |
||||||
|
<p>Loading...</p> |
||||||
|
{:then html} |
||||||
|
{@html html} |
||||||
|
{:catch error} |
||||||
|
<p class="text-red-500">Error rendering markup: {error.message}</p> |
||||||
|
{/await} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mb-3"> |
||||||
|
<span class="font-semibold">View your issue:</span> |
||||||
|
<div class="mt-1"> |
||||||
|
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all"> |
||||||
|
{issueLink} |
||||||
|
</A> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Display successful relays --> |
||||||
|
<div class="text-sm"> |
||||||
|
<span class="font-semibold">Successfully published to relays:</span> |
||||||
|
<ul class="list-disc list-inside mt-1"> |
||||||
|
{#each successfulRelays as relay} |
||||||
|
<li class="text-success-700 dark:text-success-300">{relay}</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if submissionError} |
||||||
|
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> |
||||||
|
{submissionError} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</form> |
||||||
|
|
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Confirmation Dialog --> |
||||||
|
<Modal |
||||||
|
bind:open={showConfirmDialog} |
||||||
|
size="sm" |
||||||
|
autoclose={false} |
||||||
|
class="w-full" |
||||||
|
> |
||||||
|
<div class="text-center"> |
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"> |
||||||
|
Would you like to submit the issue? |
||||||
|
</h3> |
||||||
|
<div class="flex justify-center gap-4"> |
||||||
|
<Button color="alternative" on:click={cancelSubmit}> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button color="primary" on:click={confirmSubmit}> |
||||||
|
Submit |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<!-- Login Modal --> |
||||||
|
<LoginModal |
||||||
|
show={showLoginModal} |
||||||
|
onClose={() => showLoginModal = false} |
||||||
|
onLoginSuccess={() => { |
||||||
|
// Restore saved form data |
||||||
|
if (savedFormData.subject) subject = savedFormData.subject; |
||||||
|
if (savedFormData.content) content = savedFormData.content; |
||||||
|
|
||||||
|
// Submit the issue |
||||||
|
submitIssue(); |
||||||
|
}} |
||||||
|
/> |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
||||||
|
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
||||||
|
import { readFileSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
|
||||||
|
const testFilePath = join(__dirname, './markupTestfile.md'); |
||||||
|
const md = readFileSync(testFilePath, 'utf-8'); |
||||||
|
|
||||||
|
describe('Markup Integration Test', () => { |
||||||
|
it('parses markupTestfile.md with the basic parser', async () => { |
||||||
|
const output = await parseBasicmarkup(md); |
||||||
|
// Headers (should be present as text, not <h1> tags)
|
||||||
|
expect(output).toContain('This is a test'); |
||||||
|
expect(output).toContain('============'); |
||||||
|
expect(output).toContain('### Disclaimer'); |
||||||
|
// Unordered list
|
||||||
|
expect(output).toContain('<ul'); |
||||||
|
expect(output).toContain('but'); |
||||||
|
// Ordered list
|
||||||
|
expect(output).toContain('<ol'); |
||||||
|
expect(output).toContain('first'); |
||||||
|
// Nested lists
|
||||||
|
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||||
|
// Blockquotes
|
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('This is important information'); |
||||||
|
// Inline code
|
||||||
|
expect(output).toContain('<div class="leather min-h-full w-full flex flex-col items-center">'); |
||||||
|
// Images
|
||||||
|
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
||||||
|
// Links
|
||||||
|
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
||||||
|
// Hashtags
|
||||||
|
expect(output).toContain('text-primary-600'); |
||||||
|
// Nostr identifiers (should be njump.me links)
|
||||||
|
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
||||||
|
// Wikilinks
|
||||||
|
expect(output).toContain('wikilink'); |
||||||
|
// YouTube iframe
|
||||||
|
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||||
|
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||||
|
expect(output).not.toMatch(/utm_/); |
||||||
|
expect(output).not.toMatch(/fbclid/); |
||||||
|
expect(output).not.toMatch(/gclid/); |
||||||
|
// Horizontal rule (should be present as --- in basic)
|
||||||
|
expect(output).toContain('---'); |
||||||
|
// Footnote references (should be present as [^1] in basic)
|
||||||
|
expect(output).toContain('[^1]'); |
||||||
|
// Table (should be present as | Syntax | Description | in basic)
|
||||||
|
expect(output).toContain('| Syntax | Description |'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses markupTestfile.md with the advanced parser', async () => { |
||||||
|
const output = await parseAdvancedmarkup(md); |
||||||
|
// Headers
|
||||||
|
expect(output).toContain('<h1'); |
||||||
|
expect(output).toContain('<h2'); |
||||||
|
expect(output).toContain('Disclaimer'); |
||||||
|
// Unordered list
|
||||||
|
expect(output).toContain('<ul'); |
||||||
|
expect(output).toContain('but'); |
||||||
|
// Ordered list
|
||||||
|
expect(output).toContain('<ol'); |
||||||
|
expect(output).toContain('first'); |
||||||
|
// Nested lists
|
||||||
|
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||||
|
// Blockquotes
|
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('This is important information'); |
||||||
|
// Inline code
|
||||||
|
expect(output).toMatch(/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s); |
||||||
|
// Images
|
||||||
|
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
||||||
|
// Links
|
||||||
|
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
||||||
|
// Hashtags
|
||||||
|
expect(output).toContain('text-primary-600'); |
||||||
|
// Nostr identifiers (should be njump.me links)
|
||||||
|
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
||||||
|
// Wikilinks
|
||||||
|
expect(output).toContain('wikilink'); |
||||||
|
// YouTube iframe
|
||||||
|
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||||
|
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||||
|
expect(output).not.toMatch(/utm_/); |
||||||
|
expect(output).not.toMatch(/fbclid/); |
||||||
|
expect(output).not.toMatch(/gclid/); |
||||||
|
// Horizontal rule
|
||||||
|
expect(output).toContain('<hr'); |
||||||
|
// Footnote references and section
|
||||||
|
expect(output).toContain('Footnotes'); |
||||||
|
expect(output).toMatch(/<li id=\"fn-1\">/); |
||||||
|
// Table
|
||||||
|
expect(output).toContain('<table'); |
||||||
|
// Code blocks
|
||||||
|
expect(output).toContain('<pre'); |
||||||
|
}); |
||||||
|
});
|
||||||
@ -0,0 +1,244 @@ |
|||||||
|
This is a test |
||||||
|
============ |
||||||
|
|
||||||
|
### Disclaimer |
||||||
|
|
||||||
|
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] |
||||||
|
|
||||||
|
# H1 |
||||||
|
## H2 |
||||||
|
### H3 |
||||||
|
#### H4 |
||||||
|
##### H5 |
||||||
|
###### H6 |
||||||
|
|
||||||
|
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser. |
||||||
|
|
||||||
|
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]] |
||||||
|
|
||||||
|
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. |
||||||
|
|
||||||
|
> This is important information |
||||||
|
|
||||||
|
> This is multiple |
||||||
|
> lines of |
||||||
|
> important information |
||||||
|
> with a second[^2] footnote. |
||||||
|
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984. |
||||||
|
|
||||||
|
This is a youtube link |
||||||
|
https://www.youtube.com/watch?v=9aqVxNCpx9s |
||||||
|
|
||||||
|
And here is a link with tracking tokens: |
||||||
|
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU |
||||||
|
|
||||||
|
This is an unordered list: |
||||||
|
* but |
||||||
|
* not |
||||||
|
* really |
||||||
|
|
||||||
|
This is an unordered list with nesting: |
||||||
|
* but |
||||||
|
* not |
||||||
|
* really |
||||||
|
* but |
||||||
|
* yes, |
||||||
|
* really |
||||||
|
|
||||||
|
## More testing |
||||||
|
|
||||||
|
An ordered list: |
||||||
|
1. first |
||||||
|
2. second |
||||||
|
3. third |
||||||
|
|
||||||
|
Let's nest that: |
||||||
|
1. first |
||||||
|
2. second indented |
||||||
|
3. third |
||||||
|
4. fourth indented |
||||||
|
5. fifth indented even more |
||||||
|
6. sixth under the fourth |
||||||
|
7. seventh under the sixth |
||||||
|
8. eighth under the third |
||||||
|
|
||||||
|
This is ordered and unordered mixed: |
||||||
|
1. first |
||||||
|
2. second indented |
||||||
|
3. third |
||||||
|
* make this a bullet point |
||||||
|
4. fourth indented even more |
||||||
|
* second bullet point |
||||||
|
|
||||||
|
Here is a horizontal rule: |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
Try embedded a nostr note with nevent: |
||||||
|
|
||||||
|
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske |
||||||
|
|
||||||
|
Here a note with no prefix |
||||||
|
|
||||||
|
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz |
||||||
|
|
||||||
|
Here with a naddr: |
||||||
|
|
||||||
|
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz |
||||||
|
|
||||||
|
Here's a nonsense one: |
||||||
|
|
||||||
|
nevent123 |
||||||
|
|
||||||
|
And a nonsense one with a prefix: |
||||||
|
|
||||||
|
nostr:naddrwhatever |
||||||
|
|
||||||
|
And some Nostr addresses that should be preserved and have a internal link appended: |
||||||
|
|
||||||
|
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z |
||||||
|
|
||||||
|
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr |
||||||
|
|
||||||
|
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk |
||||||
|
|
||||||
|
URL with a tracking parameter, no markup: |
||||||
|
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale |
||||||
|
|
||||||
|
Image without markup: |
||||||
|
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg |
||||||
|
|
||||||
|
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. |
||||||
|
|
||||||
|
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses: |
||||||
|
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw |
||||||
|
|
||||||
|
But not if they have d-tags: |
||||||
|
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1 |
||||||
|
|
||||||
|
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c). |
||||||
|
|
||||||
|
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25 |
||||||
|
|
||||||
|
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf |
||||||
|
|
||||||
|
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or |
||||||
|
|
||||||
|
``` |
||||||
|
in a code block |
||||||
|
``` |
||||||
|
|
||||||
|
You can even use a multi-line code block, with a json tag. |
||||||
|
|
||||||
|
```json |
||||||
|
{ |
||||||
|
"created_at":1745038670,"content":"# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n\n\n[^1]: this is a footnote\n[^2]: so is this","tags":[["subject","test"],["alt","git repository issue: test"],["a","30617:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:Alexandria","","root"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["t","gitstuff"]],"kind":1621,"pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","id":"e78a689369511fdb3c36b990380c2d8db2b5e62f13f6b836e93ef5a09611afe8","sig":"7a2b3a6f6f61b6ea04de1fe873e46d40f2a220f02cdae004342430aa1df67647a9589459382f22576c651b3d09811546bbd79564cf472deaff032f137e94a865" |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
C or C++: |
||||||
|
```cpp |
||||||
|
bool getBit(int num, int i) { |
||||||
|
return ((num & (1<<i)) != 0); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Asciidoc: |
||||||
|
```adoc |
||||||
|
= Header 1 |
||||||
|
|
||||||
|
preamble goes here |
||||||
|
|
||||||
|
== Header 2 |
||||||
|
|
||||||
|
some more text |
||||||
|
``` |
||||||
|
|
||||||
|
Gherkin: |
||||||
|
```gherkin |
||||||
|
Feature: Account Holder withdraws cash |
||||||
|
|
||||||
|
Scenario: Account has sufficient funds |
||||||
|
Given The account balance is $100 |
||||||
|
And the card is valid |
||||||
|
And the machine contains enough money |
||||||
|
When the Account Holder requests $20 |
||||||
|
Then the ATM should dispense $20 |
||||||
|
And the account balance should be $80 |
||||||
|
And the card should be returned |
||||||
|
``` |
||||||
|
|
||||||
|
Go: |
||||||
|
```go |
||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"bufio" |
||||||
|
"os" |
||||||
|
) |
||||||
|
|
||||||
|
func main() { |
||||||
|
scanner := bufio.NewScanner(os.Stdin) |
||||||
|
fmt.Print("Enter text: ") |
||||||
|
scanner.Scan() |
||||||
|
input := scanner.Text() |
||||||
|
fmt.Println("You entered:", input) |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
or even markup: |
||||||
|
|
||||||
|
```md |
||||||
|
A H1 Header |
||||||
|
============ |
||||||
|
|
||||||
|
Paragraphs are separated by a blank line. |
||||||
|
|
||||||
|
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists |
||||||
|
look like: |
||||||
|
|
||||||
|
* this one[^some reference text] |
||||||
|
* that one |
||||||
|
* the other one |
||||||
|
|
||||||
|
Note that --- not considering the asterisk --- the actual text |
||||||
|
content starts at 4-columns in. |
||||||
|
|
||||||
|
> Block quotes are |
||||||
|
> written like so. |
||||||
|
> |
||||||
|
> They can span multiple paragraphs, |
||||||
|
> if you like. |
||||||
|
``` |
||||||
|
|
||||||
|
Test out some emojis :heart: and :trophy: |
||||||
|
|
||||||
|
#### Here is an image![^some reference text] |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
### I went ahead and implemented tables, too. |
||||||
|
|
||||||
|
A neat table[^some reference text]: |
||||||
|
|
||||||
|
| Syntax | Description | |
||||||
|
| ----------- | ----------- | |
||||||
|
| Header | Title | |
||||||
|
| Paragraph | Text | |
||||||
|
|
||||||
|
A messy table (should render the same as above): |
||||||
|
|
||||||
|
| Syntax | Description | |
||||||
|
| --- | ----------- | |
||||||
|
| Header | Title | |
||||||
|
| Paragraph | Text | |
||||||
|
|
||||||
|
Here is a table without a header row: |
||||||
|
|
||||||
|
| Sometimes | you don't | |
||||||
|
| need a | header | |
||||||
|
| just | pipes | |
||||||
|
|
||||||
|
[^1]: this is a footnote |
||||||
|
[^some reference text]: this is a footnote that isn't a number |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
||||||
|
|
||||||
|
function stripWS(str: string) { |
||||||
|
return str.replace(/\s+/g, ' ').trim(); |
||||||
|
} |
||||||
|
|
||||||
|
describe('Advanced Markup Parser', () => { |
||||||
|
it('parses headers (ATX and Setext)', async () => { |
||||||
|
const input = '# H1\nText\n\nH2\n====\n'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(stripWS(output)).toContain('H1'); |
||||||
|
expect(stripWS(output)).toContain('H2'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses bold, italic, and strikethrough', async () => { |
||||||
|
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<strong>bold</strong>'); |
||||||
|
expect(output).toContain('<em>italic</em>'); |
||||||
|
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses blockquotes', async () => { |
||||||
|
const input = '> quote'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses multi-line blockquotes', async () => { |
||||||
|
const input = '> quote\n> quote'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses unordered lists', async () => { |
||||||
|
const input = '* a\n* b'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<ul'); |
||||||
|
expect(output).toContain('a'); |
||||||
|
expect(output).toContain('b'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses ordered lists', async () => { |
||||||
|
const input = '1. one\n2. two'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<ol'); |
||||||
|
expect(output).toContain('one'); |
||||||
|
expect(output).toContain('two'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses links and images', async () => { |
||||||
|
const input = '[link](https://example.com) '; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<a'); |
||||||
|
expect(output).toContain('<img'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses hashtags', async () => { |
||||||
|
const input = '#hashtag'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('text-primary-600'); |
||||||
|
expect(output).toContain('#hashtag'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses nostr identifiers', async () => { |
||||||
|
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses emoji shortcodes', async () => { |
||||||
|
const input = 'hello :smile:'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toMatch(/😄|:smile:/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses wikilinks', async () => { |
||||||
|
const input = '[[Test Page|display]]'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('wikilink'); |
||||||
|
expect(output).toContain('display'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses tables (with and without headers)', async () => { |
||||||
|
const input = `| Syntax | Description |\n|--------|-------------|\n| Header | Title |\n| Paragraph | Text |\n\n| a | b |\n| c | d |`; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<table'); |
||||||
|
expect(output).toContain('Header'); |
||||||
|
expect(output).toContain('a'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses code blocks (with and without language)', async () => { |
||||||
|
const input = '```js\nconsole.log(1);\n```\n```\nno lang\n```'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
const textOnly = output.replace(/<[^>]+>/g, ''); |
||||||
|
expect(output).toContain('<pre'); |
||||||
|
expect(textOnly).toContain('console.log(1);'); |
||||||
|
expect(textOnly).toContain('no lang'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses horizontal rules', async () => { |
||||||
|
const input = '---'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('<hr'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses footnotes (references and section)', async () => { |
||||||
|
const input = 'Here is a footnote[^1].\n\n[^1]: This is the footnote.'; |
||||||
|
const output = await parseAdvancedmarkup(input); |
||||||
|
expect(output).toContain('Footnotes'); |
||||||
|
expect(output).toContain('This is the footnote'); |
||||||
|
expect(output).toContain('fn-1'); |
||||||
|
}); |
||||||
|
});
|
||||||
@ -0,0 +1,88 @@ |
|||||||
|
import { describe, it, expect } from 'vitest'; |
||||||
|
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
||||||
|
|
||||||
|
// Helper to strip whitespace for easier comparison
|
||||||
|
function stripWS(str: string) { |
||||||
|
return str.replace(/\s+/g, ' ').trim(); |
||||||
|
} |
||||||
|
|
||||||
|
describe('Basic Markup Parser', () => { |
||||||
|
it('parses ATX and Setext headers', async () => { |
||||||
|
const input = '# H1\nText\n\nH2\n====\n'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(stripWS(output)).toContain('H1'); |
||||||
|
expect(stripWS(output)).toContain('H2'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses bold, italic, and strikethrough', async () => { |
||||||
|
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<strong>bold</strong>'); |
||||||
|
expect(output).toContain('<em>italic</em>'); |
||||||
|
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses blockquotes', async () => { |
||||||
|
const input = '> quote'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses multi-line blockquotes', async () => { |
||||||
|
const input = '> quote\n> quote'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<blockquote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
expect(output).toContain('quote'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses unordered lists', async () => { |
||||||
|
const input = '* a\n* b'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<ul'); |
||||||
|
expect(output).toContain('a'); |
||||||
|
expect(output).toContain('b'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses ordered lists', async () => { |
||||||
|
const input = '1. one\n2. two'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<ol'); |
||||||
|
expect(output).toContain('one'); |
||||||
|
expect(output).toContain('two'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses links and images', async () => { |
||||||
|
const input = '[link](https://example.com) '; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('<a'); |
||||||
|
expect(output).toContain('<img'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses hashtags', async () => { |
||||||
|
const input = '#hashtag'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('text-primary-600'); |
||||||
|
expect(output).toContain('#hashtag'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses nostr identifiers', async () => { |
||||||
|
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses emoji shortcodes', async () => { |
||||||
|
const input = 'hello :smile:'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toMatch(/😄|:smile:/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('parses wikilinks', async () => { |
||||||
|
const input = '[[Test Page|display]]'; |
||||||
|
const output = await parseBasicmarkup(input); |
||||||
|
expect(output).toContain('wikilink'); |
||||||
|
expect(output).toContain('display'); |
||||||
|
}); |
||||||
|
});
|
||||||
@ -1,3 +0,0 @@ |
|||||||
export function sum(a, b) { |
|
||||||
return a + b |
|
||||||
} |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
import { expect, test } from 'vitest' |
|
||||||
import { sum } from './example.js' |
|
||||||
|
|
||||||
test('adds 1 + 2 to equal 3', () => { |
|
||||||
expect(sum(1, 2)).toBe(3) |
|
||||||
}) |
|
||||||
Loading…
Reference in new issue