29 changed files with 2810 additions and 123 deletions
@ -1,3 +1,6 @@
@@ -1,3 +1,6 @@
|
||||
{ |
||||
"editor.tabSize": 2 |
||||
"editor.tabSize": 2, |
||||
"files.associations": { |
||||
"*.css": "postcss" |
||||
} |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -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} |
||||
@ -1,16 +1,55 @@
@@ -1,16 +1,55 @@
|
||||
export enum LazyStatus { |
||||
Pending, |
||||
Resolved, |
||||
Error, |
||||
} |
||||
|
||||
export class Lazy<T> { |
||||
#value?: T; |
||||
#value: T | null = null; |
||||
#resolver: () => Promise<T>; |
||||
#pendingPromise: Promise<T | null> | null = null; |
||||
|
||||
status: LazyStatus; |
||||
|
||||
constructor(resolver: () => Promise<T>) { |
||||
this.#resolver = resolver; |
||||
this.status = LazyStatus.Pending; |
||||
} |
||||
|
||||
async value(): Promise<T> { |
||||
if (!this.#value) { |
||||
this.#value = await this.#resolver(); |
||||
/** |
||||
* Resolves the lazy object and returns the value. |
||||
*
|
||||
* @returns The resolved value. |
||||
*
|
||||
* @remarks Lazy object resolution is performed as an atomic operation. If a resolution has |
||||
* already been requested when this function is invoked, the pending promise from the earlier |
||||
* invocation is returned. Thus, all calls to this function before it is resolved will depend on |
||||
* a single resolution. |
||||
*/ |
||||
value(): Promise<T | null> { |
||||
if (this.status === LazyStatus.Resolved) { |
||||
return Promise.resolve(this.#value); |
||||
} |
||||
|
||||
if (this.#pendingPromise) { |
||||
return this.#pendingPromise; |
||||
} |
||||
|
||||
return this.#value; |
||||
this.#pendingPromise = this.#resolve(); |
||||
return this.#pendingPromise; |
||||
} |
||||
|
||||
async #resolve(): Promise<T | null> { |
||||
try { |
||||
this.#value = await this.#resolver(); |
||||
this.status = LazyStatus.Resolved; |
||||
return this.#value; |
||||
} catch (error) { |
||||
this.status = LazyStatus.Error; |
||||
console.error(error); |
||||
return null; |
||||
} finally { |
||||
this.#pendingPromise = null; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,3 +0,0 @@
|
||||
export function sum(a, b) { |
||||
return a + b |
||||
} |
||||
@ -1,6 +0,0 @@
@@ -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