From 13a2932cde72e1ae36b450fd58d40808152ec8c4 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sat, 19 Apr 2025 00:19:53 +0200
Subject: [PATCH 01/50] Fixed most of the regex on the page.
---
src/lib/components/LoginModal.svelte | 36 +++
src/lib/components/Navigation.svelte | 1 +
src/lib/utils/markdownParser.ts | 340 +++++++++++++++++++++++
src/routes/contact/+page.svelte | 390 +++++++++++++++++++++++++++
4 files changed, 767 insertions(+)
create mode 100644 src/lib/components/LoginModal.svelte
create mode 100644 src/lib/utils/markdownParser.ts
create mode 100644 src/routes/contact/+page.svelte
diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte
new file mode 100644
index 0000000..b1c5a30
--- /dev/null
+++ b/src/lib/components/LoginModal.svelte
@@ -0,0 +1,36 @@
+
+
+{#if show}
+
+
+
+
+
+
Login Required
+
+ ×
+
+
+
+
+
+
+ You need to be logged in to submit an issue. Your form data will be preserved.
+
+
+
+
+
+
+
+
+{/if}
\ No newline at end of file
diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte
index 2ac6133..e6ca543 100644
--- a/src/lib/components/Navigation.svelte
+++ b/src/lib/components/Navigation.svelte
@@ -21,6 +21,7 @@
Publish
Visualize
About
+ Contact
diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts
new file mode 100644
index 0000000..e0e09ed
--- /dev/null
+++ b/src/lib/utils/markdownParser.ts
@@ -0,0 +1,340 @@
+/**
+ * Markdown parser with special handling for nostr identifiers
+ */
+
+import { get } from 'svelte/store';
+import { ndkInstance } from '$lib/ndk';
+import { nip19 } from 'nostr-tools';
+
+// Regular expressions for nostr identifiers - process these first
+const NOSTR_NPUB_REGEX = /(?:nostr:)?(npub[a-zA-Z0-9]{59,60})/g;
+
+// Regular expressions for markdown elements
+const BLOCKQUOTE_REGEX = /^(?:>[ \t]*.+\n?(?:(?:>[ \t]*\n)*(?:>[ \t]*.+\n?))*)+/gm;
+const ORDERED_LIST_REGEX = /^(\d+)\.[ \t]+(.+)$/gm;
+const UNORDERED_LIST_REGEX = /^[-*][ \t]+(.+)$/gm;
+const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
+const ITALIC_REGEX = /_([^_]+)_/g;
+const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
+const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm;
+const CODE_BLOCK_REGEX = /```([^\n]*)\n([\s\S]*?)```/gm;
+const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
+const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
+const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
+const HASHTAG_REGEX = /(?();
+
+/**
+ * Get user metadata for an npub
+ */
+async function getUserMetadata(npub: string): Promise<{name?: string, displayName?: string}> {
+ if (npubCache.has(npub)) {
+ return npubCache.get(npub)!;
+ }
+
+ const fallback = { name: `${npub.slice(0, 8)}...${npub.slice(-4)}` };
+
+ try {
+ const ndk = get(ndkInstance);
+ if (!ndk) {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+
+ const decoded = nip19.decode(npub);
+ if (decoded.type !== 'npub') {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+
+ const user = ndk.getUser({ npub: npub });
+ if (!user) {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+
+ try {
+ const profile = await user.fetchProfile();
+ if (!profile) {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+
+ const metadata = {
+ name: profile.name || fallback.name,
+ displayName: profile.displayName
+ };
+
+ npubCache.set(npub, metadata);
+ return metadata;
+ } catch (e) {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+ } catch (e) {
+ npubCache.set(npub, fallback);
+ return fallback;
+ }
+}
+
+/**
+ * Process lists (ordered and unordered)
+ */
+function processLists(html: string): string {
+ const lines = html.split('\n');
+ let inList = false;
+ let isOrdered = false;
+ let currentList: string[] = [];
+ const processed: string[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const orderedMatch = ORDERED_LIST_REGEX.exec(line);
+ const unorderedMatch = UNORDERED_LIST_REGEX.exec(line);
+
+ if (orderedMatch || unorderedMatch) {
+ if (!inList) {
+ inList = true;
+ isOrdered = !!orderedMatch;
+ currentList = [];
+ }
+ const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1];
+ currentList.push(content);
+ } else {
+ if (inList) {
+ const listType = isOrdered ? 'ol' : 'ul';
+ const listClass = isOrdered ? 'list-decimal' : 'list-disc';
+ processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
+ currentList.forEach(item => {
+ processed.push(` ${item} `);
+ });
+ processed.push(`${listType}>`);
+ inList = false;
+ currentList = [];
+ }
+ processed.push(line);
+ }
+
+ // Reset regex lastIndex
+ ORDERED_LIST_REGEX.lastIndex = 0;
+ UNORDERED_LIST_REGEX.lastIndex = 0;
+ }
+
+ if (inList) {
+ const listType = isOrdered ? 'ol' : 'ul';
+ const listClass = isOrdered ? 'list-decimal' : 'list-disc';
+ processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
+ currentList.forEach(item => {
+ processed.push(` ${item} `);
+ });
+ processed.push(`${listType}>`);
+ }
+
+ return processed.join('\n');
+}
+
+/**
+ * Process blockquotes using placeholder approach
+ */
+function processBlockquotes(text: string): string {
+ const blockquotes: Array<{id: string, content: string}> = [];
+ let processedText = text;
+
+ // Extract and save blockquotes
+ processedText = processedText.replace(BLOCKQUOTE_REGEX, (match) => {
+ const id = `BLOCKQUOTE_${blockquotes.length}`;
+ const cleanContent = match
+ .split('\n')
+ .map(line => line.replace(/^>[ \t]*/, ''))
+ .join('\n')
+ .trim();
+
+ blockquotes.push({
+ id,
+ content: `${cleanContent} `
+ });
+ return id;
+ });
+
+ // Restore blockquotes
+ blockquotes.forEach(({id, content}) => {
+ processedText = processedText.replace(id, content);
+ });
+
+ return processedText;
+}
+
+/**
+ * Process code blocks and inline code before any HTML escaping
+ */
+function processCode(text: string): string {
+ const blocks: Array<{id: string, content: string}> = [];
+ const inlineCodes: Array<{id: string, content: string}> = [];
+ let processedText = text;
+
+ // First, extract and save code blocks
+ processedText = processedText.replace(CODE_BLOCK_REGEX, (match, lang, code) => {
+ const id = `CODE_BLOCK_${blocks.length}`;
+ blocks.push({
+ id,
+ content: `${escapeHtml(code)} `
+ });
+ return id;
+ });
+
+ // Then extract and save inline code
+ processedText = processedText.replace(INLINE_CODE_REGEX, (match, code) => {
+ const id = `INLINE_CODE_${inlineCodes.length}`;
+ inlineCodes.push({
+ id,
+ content: `${escapeHtml(code.trim())}`
+ });
+ return id;
+ });
+
+ // Now escape HTML in the remaining text
+ processedText = escapeHtml(processedText);
+
+ // Restore code blocks
+ blocks.forEach(({id, content}) => {
+ processedText = processedText.replace(escapeHtml(id), content);
+ });
+
+ // Restore inline code
+ inlineCodes.forEach(({id, content}) => {
+ processedText = processedText.replace(escapeHtml(id), content);
+ });
+
+ return processedText;
+}
+
+/**
+ * Process footnotes with minimal spacing
+ */
+function processFootnotes(text: string): { text: string, footnotes: Map } {
+ const footnotes = new Map();
+ let counter = 0;
+
+ // Extract footnote definitions
+ text = text.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, content) => {
+ const cleanId = id.replace('^', '');
+ footnotes.set(cleanId, content.trim());
+ return '';
+ });
+
+ // Replace references
+ text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
+ const cleanId = id.replace('^', '');
+ if (footnotes.has(cleanId)) {
+ counter++;
+ return `[${counter}] `;
+ }
+ return match;
+ });
+
+ // Add footnotes section if we have any
+ if (footnotes.size > 0) {
+ text += '\n';
+ }
+
+ return { text, footnotes };
+}
+
+/**
+ * Parse markdown text to HTML with special handling for nostr identifiers
+ */
+export async function parseMarkdown(text: string): Promise {
+ if (!text) return '';
+
+ // First, process code blocks (protect these from HTML escaping)
+ let html = processCode(text); // still escape HTML *inside* code blocks
+
+ // 👉 NEW: process blockquotes *before* the rest of HTML is escaped
+ html = processBlockquotes(html);
+
+ // Process nostr identifiers
+ const npubMatches = Array.from(html.matchAll(NOSTR_NPUB_REGEX));
+ const npubPromises = npubMatches.map(async match => {
+ const [fullMatch, npub] = match;
+ const metadata = await getUserMetadata(npub);
+ const displayText = metadata.displayName || metadata.name || `${npub.slice(0, 8)}...${npub.slice(-4)}`;
+ return { fullMatch, npub, displayText };
+ });
+
+ const npubResults = await Promise.all(npubPromises);
+ for (const { fullMatch, npub, displayText } of npubResults) {
+ html = html.replace(
+ fullMatch,
+ `@${displayText} `
+ );
+ }
+
+ // Process lists
+ html = processLists(html);
+
+ // Process footnotes
+ const { text: processedHtml } = processFootnotes(html);
+ html = processedHtml;
+
+ // Process basic markdown elements
+ html = html.replace(BOLD_REGEX, '$1$2 ');
+ html = html.replace(ITALIC_REGEX, '$1 ');
+ html = html.replace(HEADING_REGEX, (match, hashes, content) => {
+ const level = hashes.length;
+ const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
+ return `${content.trim()} `;
+ });
+
+ // Process links and images
+ html = html.replace(IMAGE_REGEX, ' ');
+ html = html.replace(LINK_REGEX, '$1 ');
+
+ // Process hashtags
+ html = html.replace(HASHTAG_REGEX, '#$1 ');
+
+ // Process horizontal rules
+ html = html.replace(HORIZONTAL_RULE_REGEX, ' ');
+
+ // Handle paragraphs and line breaks
+ html = html.replace(/\n{2,}/g, '
');
+ html = html.replace(/\n/g, ' ');
+
+ // Wrap content in paragraph if needed
+ if (!html.startsWith('<')) {
+ html = `
${html}
`;
+ }
+
+ return html;
+}
+
+/**
+ * Escape HTML special characters to prevent XSS
+ */
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Escape special characters in a string for use in a regular expression
+ */
+function escapeRegExp(string: string): string {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte
new file mode 100644
index 0000000..3cfee2d
--- /dev/null
+++ b/src/routes/contact/+page.svelte
@@ -0,0 +1,390 @@
+
+
+
+
+ Contact GitCitadel
+
+
+ Make sure that you follow us on GitHub and Geyserfund .
+
+
+
+ You can contact us on Nostr npub1s3h…75wz or you can view submitted issues on the Alexandria repo page.
+
+
+ Submit an issue
+
+
+ If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
+
+
+
+
+
+
+
+
+ showLoginModal = false}
+/>
+
+
From fdc45858d043ec593eb278736c98157299a04e2b Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sat, 19 Apr 2025 00:47:40 +0200
Subject: [PATCH 02/50] Get the login working.
---
src/lib/components/Login.svelte | 13 +++++++++++--
src/lib/components/LoginModal.svelte | 17 ++++++++++++++---
src/routes/contact/+page.svelte | 10 +++++++++-
3 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte
index 1456149..13d9c93 100644
--- a/src/lib/components/Login.svelte
+++ b/src/lib/components/Login.svelte
@@ -11,6 +11,7 @@
let npub = $state(undefined);
let signInFailed = $state(false);
+ let errorMessage = $state('');
$effect(() => {
if ($ndkSignedIn) {
@@ -26,6 +27,9 @@
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.');
@@ -36,7 +40,7 @@
} catch (e) {
console.error(e);
signInFailed = true;
- // TODO: Show an error message to the user.
+ errorMessage = e instanceof Error ? e.message : 'Failed to sign in. Please try again.';
}
}
@@ -52,12 +56,17 @@
placement='bottom'
triggeredBy='#avatar'
>
-
+
Extension Sign-In
+ {#if signInFailed}
+
+ {errorMessage}
+
+ {/if}
showLoginModal = false}
+ onClose={() => showLoginModal = false}
+ onLoginSuccess={() => {
+ // Restore saved form data
+ if (savedFormData.subject) subject = savedFormData.subject;
+ if (savedFormData.content) content = savedFormData.content;
+
+ // Submit the issue
+ submitIssue();
+ }}
/>
From a7d1e9cf9c0a735ba0c4eff940be08337d916f25 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sat, 19 Apr 2025 10:26:04 +0200
Subject: [PATCH 06/50] remove fira font
---
package-lock.json | 10 ----------
package.json | 1 -
src/app.css | 1 -
3 files changed, 12 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 2f75723..b17069c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,6 @@
"name": "alexandria",
"version": "0.0.6",
"dependencies": {
- "@fontsource/fira-mono": "^5.2.5",
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x",
@@ -722,15 +721,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@fontsource/fira-mono": {
- "version": "5.2.5",
- "resolved": "https://registry.npmjs.org/@fontsource/fira-mono/-/fira-mono-5.2.5.tgz",
- "integrity": "sha512-rujrs+J+w2Nmqd6zsNQTzT7eYLKrSQWdF7SuAdjjXVs+Si06Ag6etOYFmF3Mzb0NufmEIPCDUS2ppt6hxX+SLg==",
- "license": "OFL-1.1",
- "funding": {
- "url": "https://github.com/sponsors/ayuhito"
- }
- },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
diff --git a/package.json b/package.json
index 666574e..55087c2 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,6 @@
"test": "vitest"
},
"dependencies": {
- "@fontsource/fira-mono": "^5.2.5",
"@nostr-dev-kit/ndk": "2.11.x",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.x",
"@popperjs/core": "2.11.x",
diff --git a/src/app.css b/src/app.css
index 89bb409..1a7c399 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,4 +1,3 @@
-@import '@fontsource/fira-mono';
@import './styles/base.css';
@import './styles/publications.css';
@import './styles/visualize.css';
From 0f80ac585aecee657e7d83e43883d2edacce37a4 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sat, 19 Apr 2025 21:25:38 +0200
Subject: [PATCH 07/50] Revert all the special json formatting. Use only the
standard functions.
Fixed the formatting.
Added Cancel button, Preview tab, and Submission confirmation modal.
Added copy buttons to each code block.
Made sure the focus and the workflow are correct.
Changed the text within the input box to Markdown instructions.
Issue #215
---
src/app.css | 52 -------
src/lib/utils/markdownParser.ts | 152 +++++++++---------
src/lib/utils/markdownTestfile.md | 182 ++++++++++++++++++++++
src/routes/contact/+page.svelte | 247 ++++++++++++++++++++++--------
4 files changed, 445 insertions(+), 188 deletions(-)
create mode 100644 src/lib/utils/markdownTestfile.md
diff --git a/src/app.css b/src/app.css
index 1a7c399..ba7dfdb 100644
--- a/src/app.css
+++ b/src/app.css
@@ -223,58 +223,6 @@
.inline-code {
@apply font-mono text-sm bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded;
}
-
- /* JSON syntax highlighting */
- .code-block[data-language="json"] {
- color: #24292e; /* Default text color */
- }
-
- .code-block[data-language="json"] .json-key {
- color: #005cc5;
- }
-
- .code-block[data-language="json"] .json-string {
- color: #032f62;
- }
-
- .code-block[data-language="json"] .json-number {
- color: #005cc5;
- }
-
- .code-block[data-language="json"] .json-boolean {
- color: #d73a49;
- }
-
- .code-block[data-language="json"] .json-null {
- color: #d73a49;
- }
-
- /* Dark mode */
- @media (prefers-color-scheme: dark) {
- .code-block[data-language="json"] {
- color: #e1e4e8;
- }
-
- .code-block[data-language="json"] .json-key {
- color: #79b8ff;
- }
-
- .code-block[data-language="json"] .json-string {
- color: #9ecbff;
- }
-
- .code-block[data-language="json"] .json-number {
- color: #79b8ff;
- }
-
- .code-block[data-language="json"] .json-boolean {
- color: #f97583;
- }
-
- .code-block[data-language="json"] .json-null {
- color: #f97583;
- }
- }
}
@layer components {
diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts
index 935bdc3..5456b49 100644
--- a/src/lib/utils/markdownParser.ts
+++ b/src/lib/utils/markdownParser.ts
@@ -100,48 +100,76 @@ async function getUserMetadata(identifier: string): Promise<{name?: string, disp
*/
function processLists(content: string): string {
const lines = content.split('\n');
- let inList = false;
- let isOrdered = false;
- let currentList: string[] = [];
const processed: string[] = [];
+ const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = [];
+
+ function closeList() {
+ if (listStack.length > 0) {
+ const list = listStack.pop()!;
+ const listType = list.type;
+ const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc';
+ const indentClass = list.level > 0 ? 'ml-6' : 'ml-4';
+ let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`;
+ list.items.forEach(item => {
+ listHtml += `\n ${item} `;
+ });
+ listHtml += `\n${listType}>`;
+
+ if (listStack.length > 0) {
+ // If we're in a nested list, add this as an item to the parent
+ const parentList = listStack[listStack.length - 1];
+ const lastItem = parentList.items.pop()!;
+ parentList.items.push(lastItem + '\n' + listHtml);
+ } else {
+ processed.push(listHtml);
+ }
+ }
+ }
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
- const orderedMatch = line.match(/^(\d+)\.[ \t]+(.+)$/);
- const unorderedMatch = line.match(/^\*[ \t]+(.+)$/);
+ // Count leading spaces to determine nesting level
+ const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0;
+ const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level
+ // Trim the line and check for list markers
+ const trimmedLine = line.trim();
+ const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/);
+ const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/);
+
if (orderedMatch || unorderedMatch) {
- if (!inList) {
- inList = true;
- isOrdered = !!orderedMatch;
- currentList = [];
+ const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || '';
+ const type = orderedMatch ? 'ol' : 'ul';
+
+ // Close any lists that are at a deeper level
+ while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) {
+ closeList();
+ }
+
+ // If we're at a new level, start a new list
+ if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) {
+ listStack.push({ type, items: [], level: effectiveLevel });
+ }
+ // If we're at the same level but different type, close the current list and start a new one
+ else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) {
+ closeList();
+ listStack.push({ type, items: [], level: effectiveLevel });
}
- const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1];
- currentList.push(content);
+
+ // Add the item to the current list
+ listStack[listStack.length - 1].items.push(content);
} else {
- if (inList) {
- const listType = isOrdered ? 'ol' : 'ul';
- const listClass = isOrdered ? 'list-decimal' : 'list-disc';
- processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
- currentList.forEach(item => {
- processed.push(` ${item} `);
- });
- processed.push(`${listType}>`);
- inList = false;
- currentList = [];
+ // Not a list item - close all open lists and add the line
+ while (listStack.length > 0) {
+ closeList();
}
processed.push(line);
}
}
- if (inList) {
- const listType = isOrdered ? 'ol' : 'ul';
- const listClass = isOrdered ? 'list-decimal' : 'list-disc';
- processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
- currentList.forEach(item => {
- processed.push(` ${item} `);
- });
- processed.push(`${listType}>`);
+ // Close any remaining open lists
+ while (listStack.length > 0) {
+ closeList();
}
return processed.join('\n');
@@ -293,48 +321,18 @@ function restoreCodeBlocks(text: string, blocks: Map): string {
const { code, language } = JSON.parse(blockContent);
let processedCode = code;
- // First escape HTML characters
- processedCode = processedCode
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
- // Format and highlight based on language
+ // Format JSON if the language is specified as json
if (language === 'json') {
try {
- // Parse and format JSON
- const parsed = JSON.parse(code);
- processedCode = JSON.stringify(parsed, null, 2)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
- // Apply JSON syntax highlighting
- processedCode = processedCode
- // Match JSON keys (including colons)
- .replace(/("[^"]+"):/g, '$1 :')
- // Match string values (after colons and in arrays)
- .replace(/: ("[^"]+")/g, ': $1 ')
- .replace(/\[("[^"]+")/g, '[$1 ')
- .replace(/, ("[^"]+")/g, ', $1 ')
- // Match numbers
- .replace(/: (-?\d+\.?\d*)/g, ': $1 ')
- .replace(/\[(-?\d+\.?\d*)/g, '[$1 ')
- .replace(/, (-?\d+\.?\d*)/g, ', $1 ')
- // Match booleans
- .replace(/: (true|false)\b/g, ': $1 ')
- // Match null
- .replace(/: (null)\b/g, ': $1 ');
+ const jsonObj = JSON.parse(code.trim());
+ processedCode = JSON.stringify(jsonObj, null, 2);
} catch (e) {
- // If JSON parsing fails, use the original escaped code
console.warn('Failed to parse JSON:', e);
}
- } else if (language) {
- // Use highlight.js for other languages
+ }
+
+ // Apply syntax highlighting if language is specified
+ if (language) {
try {
if (hljs.getLanguage(language)) {
const highlighted = hljs.highlight(processedCode, { language });
@@ -346,7 +344,18 @@ function restoreCodeBlocks(text: string, blocks: Map): string {
}
const languageClass = language ? ` language-${language}` : '';
- const replacement = `${processedCode} `;
+ const replacement = `
+
+
+
+
+
+
${processedCode}
+
`;
result = result.replace(id, replacement);
});
return result;
@@ -357,14 +366,7 @@ function restoreCodeBlocks(text: string, blocks: Map): string {
*/
function processInlineCode(text: string): string {
return text.replace(INLINE_CODE_REGEX, (match, code) => {
- const escapedCode = code
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
- return `${escapedCode}`;
+ return `${code}`;
});
}
diff --git a/src/lib/utils/markdownTestfile.md b/src/lib/utils/markdownTestfile.md
new file mode 100644
index 0000000..c73a4dd
--- /dev/null
+++ b/src/lib/utils/markdownTestfile.md
@@ -0,0 +1,182 @@
+This is a test
+============
+
+### Disclaimer
+
+It is _only_ a test. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
+
+npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as 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.
+
+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
+
+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 with note:
+
+note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz
+
+Here with a naddr:
+
+nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz
+
+This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
+
+You can even include `code inline` 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 *markdown* 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 Markdown](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< Block quotes are
+> written like so.
+>
+> They can span multiple paragraphs,
+> if you like.
+```
+
+#### Here is an image!
+
+
+
+### I went ahead and implemented tables, too.
+
+A neat table:
+
+| Syntax | Description |
+| ----------- | ----------- |
+| Header | Title |
+| Paragraph | Text |
+
+A messy table (should render the same as above):
+
+| Syntax | Description |
+| --- | ----------- |
+| Header | Title |
+| Paragraph | Text |
+
+[^1]: this is a footnote
+[^2]: so is this
\ No newline at end of file
diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte
index 2116261..895dff9 100644
--- a/src/routes/contact/+page.svelte
+++ b/src/routes/contact/+page.svelte
@@ -1,5 +1,5 @@
-
+
Contact GitCitadel
@@ -263,61 +285,107 @@
Subject
-
+
Description
-
-
-
-
+
+
+
+ Clear Form
+
+
{#if isSubmitting}
Submitting...
{:else}
@@ -396,9 +464,31 @@ Use ```language at the start of a code block to enable syntax highlighting."
+
+
+
+
+ Would you like to submit the issue?
+
+
+
+ Cancel
+
+
+ Submit
+
+
+
+
+
showLoginModal = false}
onLoginSuccess={() => {
// Restore saved form data
@@ -446,23 +536,58 @@ Use ```language at the start of a code block to enable syntax highlighting."
color: var(--color-leather-primary);
}
- /* Add custom scrollbar styling */
:global(.description-textarea) {
overflow-y: scroll !important;
- scrollbar-width: thin;
- scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+ scrollbar-width: thin !important;
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
+ min-height: 100% !important;
}
:global(.description-textarea::-webkit-scrollbar) {
- width: 8px;
+ width: 8px !important;
+ display: block !important;
}
:global(.description-textarea::-webkit-scrollbar-track) {
- background: transparent;
+ background: transparent !important;
}
:global(.description-textarea::-webkit-scrollbar-thumb) {
- background-color: rgba(156, 163, 175, 0.5);
- border-radius: 4px;
+ background-color: rgba(156, 163, 175, 0.5) !important;
+ border-radius: 4px !important;
+ }
+
+ :global(.description-textarea::-webkit-scrollbar-thumb:hover) {
+ background-color: rgba(156, 163, 175, 0.7) !important;
+ }
+
+ :global(.prose-content) {
+ overflow-y: scroll !important;
+ scrollbar-width: thin !important;
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent !important;
+ }
+
+ :global(.prose-content::-webkit-scrollbar) {
+ width: 8px !important;
+ display: block !important;
+ }
+
+ :global(.prose-content::-webkit-scrollbar-track) {
+ background: transparent !important;
+ }
+
+ :global(.prose-content::-webkit-scrollbar-thumb) {
+ background-color: rgba(156, 163, 175, 0.5) !important;
+ border-radius: 4px !important;
+ }
+
+ :global(.prose-content::-webkit-scrollbar-thumb:hover) {
+ background-color: rgba(156, 163, 175, 0.7) !important;
+ }
+
+ :global(.tab-content) {
+ position: relative;
+ display: flex;
+ flex-direction: column;
}
From abb3f2a75b7785f330edb51312a4c70159345990 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sun, 20 Apr 2025 23:44:42 +0200
Subject: [PATCH 08/50] Fixed the bugs. Added the m/M tags. Fixed the
formatting. Split the parser into smaller functions and separated by
basic/advanced. Added more test cases to the markdown test file.
---
package.json | 2 +-
src/app.css | 91 ++--
src/lib/utils/advancedMarkdownParser.ts | 416 ++++++++++++++++
src/lib/utils/basicMarkdownParser.ts | 235 +++++++++
src/lib/utils/markdownParser.ts | 629 +-----------------------
src/lib/utils/markdownTestfile.md | 23 +-
src/lib/utils/mime.ts | 96 ++++
src/lib/utils/nostrUtils.ts | 161 ++++++
src/routes/contact/+page.svelte | 263 +++++-----
9 files changed, 1114 insertions(+), 802 deletions(-)
create mode 100644 src/lib/utils/advancedMarkdownParser.ts
create mode 100644 src/lib/utils/basicMarkdownParser.ts
create mode 100644 src/lib/utils/mime.ts
create mode 100644 src/lib/utils/nostrUtils.ts
diff --git a/package.json b/package.json
index 55087c2..96db5de 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"@popperjs/core": "2.11.x",
"@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x",
- "@types/highlight.js": "^9.12.4",
+ "@types/highlight.js": "^11.11.1",
"asciidoctor": "3.0.x",
"d3": "^7.9.0",
"he": "1.2.x",
diff --git a/src/app.css b/src/app.css
index ba7dfdb..a87e1a7 100644
--- a/src/app.css
+++ b/src/app.css
@@ -2,40 +2,38 @@
@import './styles/publications.css';
@import './styles/visualize.css';
-@layer components {
- /* General */
+/* Custom styles */
+@layer base {
.leather {
- @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
+ @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200;
}
.btn-leather.text-xs {
- @apply w-7 h-7;
+ @apply px-2 py-1;
}
.btn-leather.text-xs svg {
- @apply w-3 h-3;
+ @apply h-3 w-3;
}
.btn-leather.text-sm {
- @apply w-8 h-8;
+ @apply px-3 py-2;
}
.btn-leather.text-sm svg {
- @apply w-4 h-4;
+ @apply h-4 w-4;
}
div[role='tooltip'] button.btn-leather {
@apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700;
}
- /* Images */
.image-border {
@apply border border-primary-700;
}
- /* Card */
div.card-leather {
- @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
+ @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
}
@@ -52,7 +50,6 @@
@apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200;
}
- /* Content */
main {
@apply max-w-full;
}
@@ -74,7 +71,6 @@
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
- /* Section headers */
h1.h-leather,
h2.h-leather,
h3.h-leather,
@@ -108,7 +104,6 @@
@apply text-base font-semibold;
}
- /* Modal */
div.modal-leather > div {
@apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600;
}
@@ -126,7 +121,6 @@
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
- /* Navbar */
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
@@ -144,23 +138,20 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
- /* Sidebar */
aside.sidebar-leather > div {
- @apply bg-gray-100 dark:bg-gray-900;
+ @apply bg-primary-0 dark:bg-primary-1000;
}
a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
}
- /* Skeleton */
div.skeleton-leather div {
- @apply bg-gray-400 dark:bg-gray-600;
+ @apply bg-primary-100 dark:bg-primary-800;
}
- /* Textarea */
div.textarea-leather {
- @apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600;
+ @apply bg-primary-0 dark:bg-primary-1000;
}
div.textarea-leather > div:nth-child(1),
@@ -169,7 +160,7 @@
}
div.textarea-leather > div:nth-child(2) {
- @apply bg-gray-100 dark:bg-gray-900;
+ @apply bg-primary-0 dark:bg-primary-1000;
}
div.textarea-leather,
@@ -177,60 +168,66 @@
@apply text-gray-800 dark:text-gray-300;
}
- /* Tooltip */
div.tooltip-leather {
@apply text-gray-800 dark:text-gray-300;
}
div[role='tooltip'] button.btn-leather .tooltip-leather {
- @apply bg-gray-200 dark:bg-gray-700;
+ @apply bg-primary-100 dark:bg-primary-800;
}
-
- /* Unordered list */
+
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
+
.network-link-leather {
- @apply stroke-gray-400 fill-gray-400;
- }
- .network-node-leather {
- @apply stroke-gray-800;
- }
- .network-node-content {
- @apply fill-[#d6c1a8];
+ @apply stroke-primary-200 fill-primary-200;
}
- /* Code blocks */
- .code-block {
- @apply relative w-full max-w-[95%] overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-4 my-4 font-mono text-sm whitespace-pre;
- scrollbar-width: thin;
- scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+ .network-node-leather {
+ @apply stroke-primary-600;
}
- .code-block::-webkit-scrollbar {
- height: 8px;
+ .network-node-content {
+ @apply fill-primary-100;
}
- .code-block::-webkit-scrollbar-track {
- @apply bg-transparent rounded-b-lg;
+ /* Code block styling - using highlight.js github-dark theme only */
+ pre code.hljs {
+ display: block;
+ overflow-x: auto;
+ padding: 1em;
}
- .code-block::-webkit-scrollbar-thumb {
- @apply bg-gray-400 dark:bg-gray-600 rounded-full;
+ .code-block {
+ @apply font-mono text-sm rounded-lg p-4 my-4 overflow-x-auto;
}
/* Inline code */
.inline-code {
- @apply font-mono text-sm bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded;
+ @apply font-mono text-sm rounded px-1.5 py-0.5;
+ @apply bg-primary-900 text-gray-200;
}
-}
-@layer components {
.leather-legend {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow
border border-gray-200 dark:border-gray-800;
}
+
.tooltip-leather {
- @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
+ @apply bg-gray-100 dark:bg-gray-900;
+ }
+
+ /* Adjusting text styles for better contrast */
+ em, i {
+ @apply text-gray-700 dark:text-gray-200; /* Darker in light mode, lighter in dark mode */
+ }
+
+ strong, b {
+ @apply text-gray-900 dark:text-gray-100; /* Darker in light mode, lighter in dark mode */
+ }
+
+ code {
+ @apply text-gray-800 dark:text-gray-200; /* Adjusted for better contrast */
}
}
diff --git a/src/lib/utils/advancedMarkdownParser.ts b/src/lib/utils/advancedMarkdownParser.ts
new file mode 100644
index 0000000..6ddfe56
--- /dev/null
+++ b/src/lib/utils/advancedMarkdownParser.ts
@@ -0,0 +1,416 @@
+import { parseBasicMarkdown } from './basicMarkdownParser';
+import hljs from 'highlight.js';
+import 'highlight.js/lib/common'; // Import common languages
+import 'highlight.js/styles/github-dark.css'; // Dark theme only
+import { processNostrIdentifiers } from './nostrUtils';
+
+// Register common languages
+hljs.configure({
+ ignoreUnescapedHTML: true
+});
+
+// Regular expressions for advanced markdown elements
+const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
+const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm;
+const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
+const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
+const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
+const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm;
+const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g;
+const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
+
+interface Footnote {
+ id: string;
+ text: string;
+ referenceCount: number;
+}
+
+interface FootnoteReference {
+ id: string;
+ count: number;
+}
+
+/**
+ * Process headings (both styles)
+ */
+function processHeadings(content: string): string {
+ // Process ATX-style headings (# Heading)
+ let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
+ const headingLevel = level.length;
+ return `${text.trim()} `;
+ });
+
+ // Process Setext-style headings (Heading\n====)
+ processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
+ const headingLevel = level[0] === '=' ? 1 : 2;
+ return `${text.trim()} `;
+ });
+
+ 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 = '\n';
+ html += '
\n';
+
+ // Add header if exists
+ if (hasHeader) {
+ html += '\n\n';
+ headerCells.forEach(cell => {
+ html += `${cell} \n`;
+ });
+ html += ' \n \n';
+ }
+
+ // Add body
+ html += '\n';
+ bodyRows.forEach(row => {
+ const cells = processCells(row);
+ html += '\n';
+ cells.forEach(cell => {
+ html += `${cell} \n`;
+ });
+ html += ' \n';
+ });
+
+ html += ' \n
\n
';
+ return html;
+ } catch (error) {
+ console.error('Error processing table row:', error);
+ return match;
+ }
+ });
+ } catch (error) {
+ console.error('Error in processTables:', error);
+ return content;
+ }
+}
+
+/**
+ * Process links and images
+ */
+function processLinksAndImages(content: string): string {
+ // Process images first to avoid conflicts with links
+ let processedContent = content.replace(IMAGE_REGEX,
+ ' '
+ );
+
+ // Process links
+ processedContent = processedContent.replace(LINK_REGEX,
+ '$1 '
+ );
+
+ return processedContent;
+}
+
+/**
+ * Process horizontal rules
+ */
+function processHorizontalRules(content: string): string {
+ return content.replace(HORIZONTAL_RULE_REGEX,
+ ' '
+ );
+}
+
+/**
+ * Process footnotes
+ */
+function processFootnotes(content: string): string {
+ try {
+ if (!content) return '';
+
+ // First collect all footnote references and definitions
+ const footnotes = new Map();
+ const references = new Map();
+ const referenceLocations = new Set();
+ let nextNumber = 1;
+
+ // First pass: collect all references to establish order
+ let processedContent = content.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
+ if (!referenceLocations.has(id) && !references.has(id)) {
+ references.set(id, nextNumber++);
+ }
+ referenceLocations.add(id);
+ return match; // Keep the reference for now
+ });
+
+ // Second pass: collect all definitions
+ processedContent = processedContent.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => {
+ footnotes.set(id, text.trim());
+ return ''; // Remove the definition
+ });
+
+ // Third pass: process references with collected information
+ processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
+ if (!footnotes.has(id)) {
+ console.warn(`Footnote reference [^${id}] found but no definition exists`);
+ return match;
+ }
+
+ const num = references.get(id)!;
+ return `[${num}] `;
+ });
+
+ // Add footnotes section if we have any
+ if (references.size > 0) {
+ processedContent += '\n\nFootnotes \n\n';
+
+ // Sort footnotes by their reference number
+ const sortedFootnotes = Array.from(references.entries())
+ .sort((a, b) => a[1] - b[1])
+ .filter(([id]) => footnotes.has(id)); // Only include footnotes that have definitions
+
+ // Add each footnote in order
+ for (const [id, num] of sortedFootnotes) {
+ const text = footnotes.get(id) || '';
+ processedContent += `${text} ↩ \n`;
+ }
+ processedContent += ' ';
+ }
+
+ 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 `${text} `;
+ });
+}
+
+/**
+ * Process code blocks by finding consecutive code lines and preserving their content
+ */
+function processCodeBlocks(text: string): { text: string; blocks: Map } {
+ const lines = text.split('\n');
+ const processedLines: string[] = [];
+ const blocks = new Map();
+ let inCodeBlock = false;
+ let currentCode: string[] = [];
+ let currentLanguage = '';
+ let blockCount = 0;
+ let lastWasCodeBlock = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const codeBlockStart = line.match(/^```(\w*)$/);
+
+ if (codeBlockStart) {
+ if (!inCodeBlock) {
+ // Starting a new code block
+ inCodeBlock = true;
+ currentLanguage = codeBlockStart[1];
+ currentCode = [];
+ lastWasCodeBlock = true;
+ } else {
+ // Ending current code block
+ blockCount++;
+ const id = `CODE_BLOCK_${blockCount}`;
+ const code = currentCode.join('\n');
+
+ // Try to format JSON if specified
+ let formattedCode = code;
+ if (currentLanguage.toLowerCase() === 'json') {
+ try {
+ formattedCode = JSON.stringify(JSON.parse(code), null, 2);
+ } catch (e) {
+ formattedCode = code;
+ }
+ }
+
+ blocks.set(id, JSON.stringify({
+ code: formattedCode,
+ language: currentLanguage,
+ raw: true
+ }));
+
+ processedLines.push(''); // Add spacing before code block
+ processedLines.push(id);
+ processedLines.push(''); // Add spacing after code block
+ inCodeBlock = false;
+ currentCode = [];
+ currentLanguage = '';
+ }
+ } else if (inCodeBlock) {
+ currentCode.push(line);
+ } else {
+ if (lastWasCodeBlock && line.trim()) {
+ processedLines.push('');
+ lastWasCodeBlock = false;
+ }
+ processedLines.push(line);
+ }
+ }
+
+ // Handle unclosed code block
+ if (inCodeBlock && currentCode.length > 0) {
+ blockCount++;
+ const id = `CODE_BLOCK_${blockCount}`;
+ const code = currentCode.join('\n');
+
+ // Try to format JSON if specified
+ let formattedCode = code;
+ if (currentLanguage.toLowerCase() === 'json') {
+ try {
+ formattedCode = JSON.stringify(JSON.parse(code), null, 2);
+ } catch (e) {
+ formattedCode = code;
+ }
+ }
+
+ blocks.set(id, JSON.stringify({
+ code: formattedCode,
+ language: currentLanguage,
+ raw: true
+ }));
+ processedLines.push('');
+ processedLines.push(id);
+ processedLines.push('');
+ }
+
+ return {
+ text: processedLines.join('\n'),
+ blocks
+ };
+}
+
+/**
+ * Restore code blocks with proper formatting
+ */
+function restoreCodeBlocks(text: string, blocks: Map): string {
+ 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 = `${highlighted} `;
+ } catch (e) {
+ console.warn('Failed to highlight code block:', e);
+ html = `${code} `;
+ }
+ } else {
+ html = `${code} `;
+ }
+
+ result = result.replace(id, html);
+ } catch (error) {
+ console.error('Error restoring code block:', error);
+ result = result.replace(id, 'Error processing code block ');
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Parse markdown text with advanced formatting
+ */
+export async function parseAdvancedMarkdown(text: string): Promise {
+ try {
+ if (!text) return '';
+
+ // Step 1: Extract and save code blocks first
+ const { text: withoutCode, blocks } = processCodeBlocks(text);
+
+ // Step 2: Process all other markdown
+ let processedText = withoutCode;
+
+ // Process block-level elements
+ processedText = processTables(processedText);
+ processedText = processBlockquotes(processedText);
+ processedText = processHeadings(processedText);
+ processedText = processHorizontalRules(processedText);
+ processedText = processLinksAndImages(processedText);
+
+ // Process inline elements
+ processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => {
+ const escapedCode = code
+ .trim()
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ return `${escapedCode}`;
+ });
+
+ // Process footnotes before basic markdown to prevent unwanted paragraph tags
+ processedText = processFootnotes(processedText);
+
+ // Process async elements
+ processedText = await processNostrIdentifiers(processedText);
+ processedText = await parseBasicMarkdown(processedText);
+
+ // Step 3: Restore code blocks
+ processedText = restoreCodeBlocks(processedText, blocks);
+
+ return processedText;
+ } catch (error) {
+ console.error('Error in parseAdvancedMarkdown:', error);
+ if (error instanceof Error) {
+ return `Error processing markdown: ${error.message}
`;
+ }
+ return 'An error occurred while processing the markdown
';
+ }
+}
\ No newline at end of file
diff --git a/src/lib/utils/basicMarkdownParser.ts b/src/lib/utils/basicMarkdownParser.ts
new file mode 100644
index 0000000..ab27bf9
--- /dev/null
+++ b/src/lib/utils/basicMarkdownParser.ts
@@ -0,0 +1,235 @@
+import { processNostrIdentifiers } from './nostrUtils';
+
+// Regular expressions for basic markdown elements
+const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
+const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
+const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
+const HASHTAG_REGEX = /(?[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
+const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
+
+interface ListItem {
+ type: 'ul' | 'ol';
+ indent: number;
+ content: string;
+ marker: string;
+}
+
+// HTML escape function
+function escapeHtml(text: string): string {
+ const htmlEscapes: { [key: string]: string } = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
+}
+
+/**
+ * Process paragraphs and line breaks
+ */
+function processParagraphs(content: string): string {
+ try {
+ if (!content) return '';
+
+ // Split content into paragraphs (double line breaks)
+ const paragraphs = content.split(/\n\s*\n/);
+
+ // Process each paragraph
+ return paragraphs.map(para => {
+ if (!para.trim()) return '';
+
+ // Handle single line breaks within paragraphs
+ const lines = para.split('\n');
+
+ // Join lines with normal line breaks and add br after paragraph
+ return `${lines.join('\n')}
`;
+ }).filter(Boolean).join('\n');
+ } catch (error) {
+ console.error('Error in processParagraphs:', error);
+ return content;
+ }
+}
+
+/**
+ * Process basic text formatting (bold, italic, strikethrough, hashtags, inline code)
+ */
+function processBasicFormatting(content: string): string {
+ try {
+ if (!content) return '';
+
+ // Process bold first to avoid conflicts
+ content = content.replace(BOLD_REGEX, '$2 ');
+
+ // Then process italic, handling both single and double underscores
+ content = content.replace(ITALIC_REGEX, match => {
+ const text = match.replace(/^_+|_+$/g, '');
+ return `${text} `;
+ });
+
+ // Then process strikethrough, handling both single and double tildes
+ content = content.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => {
+ const text = doubleText || singleText;
+ return `${text}`;
+ });
+
+ // Finally process hashtags - style them with a lighter color
+ content = content.replace(HASHTAG_REGEX, '#$1 ');
+
+ // Process inline code
+ content = content.replace(INLINE_CODE_REGEX, '$1');
+
+ return content;
+ } catch (error) {
+ console.error('Error in processBasicFormatting:', error);
+ return content;
+ }
+}
+
+/**
+ * Process blockquotes
+ */
+function processBlockquotes(content: string): string {
+ try {
+ if (!content) return '';
+
+ return content.replace(BLOCKQUOTE_REGEX, match => {
+ // Split into lines and process each line
+ const lines = match.split('\n').map(line => {
+ // Remove the '>' marker and trim any whitespace after it
+ return line.replace(/^[ \t]*>[ \t]?/, '').trim();
+ });
+
+ // Join the lines with proper spacing and wrap in blockquote
+ return `${
+ lines.join('\n')
+ } `;
+ });
+ } catch (error) {
+ console.error('Error in processBlockquotes:', error);
+ return content;
+ }
+}
+
+/**
+ * Calculate indentation level from spaces
+ */
+function getIndentLevel(spaces: string): number {
+ return Math.floor(spaces.length / 2);
+}
+
+/**
+ * Process lists (ordered and unordered)
+ */
+function processLists(content: string): string {
+ const lines = content.split('\n');
+ const processed: string[] = [];
+ const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = [];
+
+ function closeList() {
+ if (listStack.length > 0) {
+ const list = listStack.pop()!;
+ const listType = list.type;
+ const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc';
+ const indentClass = list.level > 0 ? 'ml-6' : 'ml-4';
+ let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`;
+ list.items.forEach(item => {
+ listHtml += `\n ${item} `;
+ });
+ listHtml += `\n${listType}>`;
+
+ if (listStack.length > 0) {
+ // If we're in a nested list, add this as an item to the parent
+ const parentList = listStack[listStack.length - 1];
+ const lastItem = parentList.items.pop()!;
+ parentList.items.push(lastItem + '\n' + listHtml);
+ } else {
+ processed.push(listHtml);
+ }
+ }
+ }
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ // Count leading spaces to determine nesting level
+ const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0;
+ const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level
+
+ // Trim the line and check for list markers
+ const trimmedLine = line.trim();
+ const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/);
+ const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/);
+
+ if (orderedMatch || unorderedMatch) {
+ const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || '';
+ const type = orderedMatch ? 'ol' : 'ul';
+
+ // Close any lists that are at a deeper level
+ while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) {
+ closeList();
+ }
+
+ // If we're at a new level, start a new list
+ if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) {
+ listStack.push({ type, items: [], level: effectiveLevel });
+ }
+ // If we're at the same level but different type, close the current list and start a new one
+ else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) {
+ closeList();
+ listStack.push({ type, items: [], level: effectiveLevel });
+ }
+
+ // Add the item to the current list
+ listStack[listStack.length - 1].items.push(content);
+ } else {
+ // Not a list item - close all open lists and add the line
+ while (listStack.length > 0) {
+ closeList();
+ }
+ processed.push(line);
+ }
+ }
+
+ // Close any remaining open lists
+ while (listStack.length > 0) {
+ closeList();
+ }
+
+ return processed.join('\n');
+}
+
+/**
+ * Parse markdown text with basic formatting
+ */
+export async function parseBasicMarkdown(text: string): Promise {
+ try {
+ if (!text) return '';
+
+ let processedText = text;
+
+ // Process lists first to handle indentation properly
+ processedText = processLists(processedText);
+
+ // Process blockquotes next
+ processedText = processBlockquotes(processedText);
+
+ // Process paragraphs
+ processedText = processParagraphs(processedText);
+
+ // Process basic text formatting
+ processedText = processBasicFormatting(processedText);
+
+ // Process Nostr identifiers last
+ processedText = await processNostrIdentifiers(processedText);
+
+ return processedText;
+ } catch (error) {
+ console.error('Error in parseBasicMarkdown:', error);
+ if (error instanceof Error) {
+ return `Error processing markdown: ${error.message}
`;
+ }
+ return 'An error occurred while processing the markdown
';
+ }
+}
\ No newline at end of file
diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts
index 5456b49..a257599 100644
--- a/src/lib/utils/markdownParser.ts
+++ b/src/lib/utils/markdownParser.ts
@@ -1,16 +1,11 @@
/**
- * Markdown parser with special handling for nostr identifiers
+ * Process inline code
*/
-
-import { get } from 'svelte/store';
-import { ndkInstance } from '$lib/ndk';
-import { nip19 } from 'nostr-tools';
-import hljs from 'highlight.js';
-import 'highlight.js/styles/github-dark.css';
-
-// Regular expressions for nostr identifiers - process these first
-const NOSTR_PROFILE_REGEX = /(?:nostr:)?((?:npub|nprofile)[a-zA-Z0-9]{20,})/g;
-const NOSTR_NOTE_REGEX = /(?:nostr:)?((?:nevent|note|naddr)[a-zA-Z0-9]{20,})/g;
+function processInlineCode(text: string): string {
+ return text.replace(INLINE_CODE_REGEX, (match, code) => {
+ return `${code}`;
+ });
+}
// Regular expressions for markdown elements
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
@@ -28,614 +23,4 @@ const TABLE_REGEX = /^\|(.+)\|\r?\n\|([-|\s]+)\|\r?\n((?:\|.+\|\r?\n?)+)$/gm;
const TABLE_ROW_REGEX = /^\|(.+)\|$/gm;
const TABLE_DELIMITER_REGEX = /^[\s-]+$/;
-// Cache for npub metadata
-const npubCache = new Map();
-
-/**
- * Get user metadata for a nostr identifier (npub or nprofile)
- */
-async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
- if (npubCache.has(identifier)) {
- return npubCache.get(identifier)!;
- }
-
- const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
-
- try {
- const ndk = get(ndkInstance);
- if (!ndk) {
- npubCache.set(identifier, fallback);
- return fallback;
- }
-
- const decoded = nip19.decode(identifier);
- if (!decoded) {
- npubCache.set(identifier, 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(identifier, fallback);
- return fallback;
- }
-
- const user = ndk.getUser({ pubkey: pubkey });
- if (!user) {
- npubCache.set(identifier, fallback);
- return fallback;
- }
-
- try {
- const profile = await user.fetchProfile();
- if (!profile) {
- npubCache.set(identifier, fallback);
- return fallback;
- }
-
- const metadata = {
- name: profile.name || fallback.name,
- displayName: profile.displayName
- };
-
- npubCache.set(identifier, metadata);
- return metadata;
- } catch (e) {
- npubCache.set(identifier, fallback);
- return fallback;
- }
- } catch (e) {
- npubCache.set(identifier, fallback);
- return fallback;
- }
-}
-
-/**
- * Process lists (ordered and unordered)
- */
-function processLists(content: string): string {
- const lines = content.split('\n');
- const processed: string[] = [];
- const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = [];
-
- function closeList() {
- if (listStack.length > 0) {
- const list = listStack.pop()!;
- const listType = list.type;
- const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc';
- const indentClass = list.level > 0 ? 'ml-6' : 'ml-4';
- let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`;
- list.items.forEach(item => {
- listHtml += `\n ${item} `;
- });
- listHtml += `\n${listType}>`;
-
- if (listStack.length > 0) {
- // If we're in a nested list, add this as an item to the parent
- const parentList = listStack[listStack.length - 1];
- const lastItem = parentList.items.pop()!;
- parentList.items.push(lastItem + '\n' + listHtml);
- } else {
- processed.push(listHtml);
- }
- }
- }
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- // Count leading spaces to determine nesting level
- const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0;
- const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level
-
- // Trim the line and check for list markers
- const trimmedLine = line.trim();
- const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/);
- const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/);
-
- if (orderedMatch || unorderedMatch) {
- const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || '';
- const type = orderedMatch ? 'ol' : 'ul';
-
- // Close any lists that are at a deeper level
- while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) {
- closeList();
- }
-
- // If we're at a new level, start a new list
- if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) {
- listStack.push({ type, items: [], level: effectiveLevel });
- }
- // If we're at the same level but different type, close the current list and start a new one
- else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) {
- closeList();
- listStack.push({ type, items: [], level: effectiveLevel });
- }
-
- // Add the item to the current list
- listStack[listStack.length - 1].items.push(content);
- } else {
- // Not a list item - close all open lists and add the line
- while (listStack.length > 0) {
- closeList();
- }
- processed.push(line);
- }
- }
-
- // Close any remaining open lists
- while (listStack.length > 0) {
- closeList();
- }
-
- return processed.join('\n');
-}
-
-/**
- * Process blockquotes by finding consecutive quote lines and preserving their structure
- */
-function processBlockquotes(text: string): string {
- const lines = text.split('\n');
- const processedLines: string[] = [];
- let currentQuote: string[] = [];
- let quoteCount = 0;
- let lastLineWasQuote = false;
- const blockquotes: Array<{id: string, content: string}> = [];
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const isQuoteLine = line.startsWith('> ');
-
- if (isQuoteLine) {
- // If we had a gap between quotes, this is a new quote
- if (!lastLineWasQuote && currentQuote.length > 0) {
- quoteCount++;
- const id = `BLOCKQUOTE_${quoteCount}`;
- const quoteContent = currentQuote.join(' ');
- blockquotes.push({
- id,
- content: ``
- });
- processedLines.push(id);
- currentQuote = [];
- }
-
- // Add to current quote
- currentQuote.push(line.substring(2));
- lastLineWasQuote = true;
- } else {
- // If we were in a quote and now we're not, process it
- if (currentQuote.length > 0) {
- quoteCount++;
- const id = `BLOCKQUOTE_${quoteCount}`;
- const quoteContent = currentQuote.join(' ');
- blockquotes.push({
- id,
- content: ``
- });
- processedLines.push(id);
- currentQuote = [];
- }
- processedLines.push(line);
- lastLineWasQuote = false;
- }
- }
-
- // Handle any remaining quote
- if (currentQuote.length > 0) {
- quoteCount++;
- const id = `BLOCKQUOTE_${quoteCount}`;
- const quoteContent = currentQuote.join(' ');
- blockquotes.push({
- id,
- content: ``
- });
- processedLines.push(id);
- }
-
- let result = processedLines.join('\n');
-
- // Restore blockquotes
- blockquotes.forEach(({id, content}) => {
- result = result.replace(id, content);
- });
-
- return result;
-}
-
-/**
- * Process code blocks by finding consecutive code lines and preserving their content
- */
-function processCodeBlocks(text: string): { text: string; blocks: Map } {
- const lines = text.split('\n');
- const processedLines: string[] = [];
- const blocks = new Map();
- let inCodeBlock = false;
- let currentCode: string[] = [];
- let currentLanguage = '';
- let blockCount = 0;
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
- const codeBlockStart = line.match(/^```(\w*)$/);
-
- if (codeBlockStart) {
- if (!inCodeBlock) {
- // Starting a new code block
- inCodeBlock = true;
- currentLanguage = codeBlockStart[1];
- currentCode = [];
- } else {
- // Ending current code block
- blockCount++;
- const id = `CODE-BLOCK-${blockCount}`;
- const code = currentCode.join('\n');
-
- // Store the raw code and language for later processing
- blocks.set(id, JSON.stringify({
- code,
- language: currentLanguage
- }));
-
- processedLines.push(id);
- inCodeBlock = false;
- currentLanguage = '';
- currentCode = [];
- }
- } else if (inCodeBlock) {
- currentCode.push(line);
- } else {
- processedLines.push(line);
- }
- }
-
- // Handle unclosed code block
- if (inCodeBlock && currentCode.length > 0) {
- blockCount++;
- const id = `CODE-BLOCK-${blockCount}`;
- const code = currentCode.join('\n');
-
- blocks.set(id, JSON.stringify({
- code,
- language: currentLanguage
- }));
- processedLines.push(id);
- }
-
- return {
- text: processedLines.join('\n'),
- blocks
- };
-}
-
-/**
- * Restore code blocks with proper formatting
- */
-function restoreCodeBlocks(text: string, blocks: Map): string {
- let result = text;
- blocks.forEach((blockContent, id) => {
- const { code, language } = JSON.parse(blockContent);
- let processedCode = code;
-
- // Format JSON if the language is specified as json
- if (language === 'json') {
- try {
- const jsonObj = JSON.parse(code.trim());
- processedCode = JSON.stringify(jsonObj, null, 2);
- } catch (e) {
- console.warn('Failed to parse JSON:', e);
- }
- }
-
- // Apply syntax highlighting if language is specified
- if (language) {
- try {
- if (hljs.getLanguage(language)) {
- const highlighted = hljs.highlight(processedCode, { language });
- processedCode = highlighted.value;
- }
- } catch (e) {
- console.warn('Failed to apply syntax highlighting:', e);
- }
- }
-
- const languageClass = language ? ` language-${language}` : '';
- const replacement = `
-
-
-
-
-
-
${processedCode}
-
`;
- result = result.replace(id, replacement);
- });
- return result;
-}
-
-/**
- * Process inline code
- */
-function processInlineCode(text: string): string {
- return text.replace(INLINE_CODE_REGEX, (match, code) => {
- return `${code}`;
- });
-}
-
-/**
- * Process markdown tables
- */
-function processTables(content: string): string {
- return content.replace(TABLE_REGEX, (match, headerRow, delimiterRow, bodyRows) => {
- // Process header row
- const headers: string[] = headerRow
- .split('|')
- .map((cell: string) => cell.trim())
- .filter((cell: string) => cell.length > 0);
-
- // Validate delimiter row (should contain only dashes and spaces)
- const delimiters: string[] = delimiterRow
- .split('|')
- .map((cell: string) => cell.trim())
- .filter((cell: string) => cell.length > 0);
-
- if (!delimiters.every(d => TABLE_DELIMITER_REGEX.test(d))) {
- return match;
- }
-
- // Process body rows
- const rows: string[][] = bodyRows
- .trim()
- .split('\n')
- .map((row: string) => {
- return row
- .split('|')
- .map((cell: string) => cell.trim())
- .filter((cell: string) => cell.length > 0);
- })
- .filter((row: string[]) => row.length > 0);
-
- // Generate HTML table with leather theme styling and thicker grid lines
- let table = '\n';
- table += '
\n';
-
- // Add header with leather theme styling
- table += '\n\n';
- headers.forEach((header: string) => {
- table += `${header} \n`;
- });
- table += ' \n \n';
-
- // Add body with leather theme styling
- table += '\n';
- rows.forEach((row: string[], index: number) => {
- table += `\n`;
- row.forEach((cell: string) => {
- table += `${cell} \n`;
- });
- table += ' \n';
- });
- table += ' \n
\n
';
-
- return table;
- });
-}
-
-/**
- * Process other markdown elements (excluding code)
- */
-function processOtherElements(content: string): string {
- // Process blockquotes first
- content = processBlockquotes(content);
-
- // Process tables before other elements
- content = processTables(content);
-
- // Process basic markdown elements
- content = content.replace(BOLD_REGEX, '$1$2 ');
- content = content.replace(ITALIC_REGEX, '$1 ');
-
- // Process alternate heading syntax first (=== or ---)
- content = content.replace(ALTERNATE_HEADING_REGEX, (match, content, level) => {
- const headingLevel = level.startsWith('=') ? 1 : 2;
- const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
- return `${content.trim()} `;
- });
-
- // Process standard heading syntax (#)
- content = content.replace(HEADING_REGEX, (match, hashes, content) => {
- const level = hashes.length;
- const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
- return `${content.trim()} `;
- });
-
- // Process links and images with standardized styling
- content = content.replace(IMAGE_REGEX, ' ');
- content = content.replace(LINK_REGEX, '$1 ');
-
- // Process hashtags with standardized styling
- content = content.replace(HASHTAG_REGEX, '#$1 ');
-
- // Process horizontal rules
- content = content.replace(HORIZONTAL_RULE_REGEX, ' ');
-
- return content;
-}
-
-/**
- * Process footnotes with minimal spacing
- */
-function processFootnotes(text: string): { text: string, footnotes: Map } {
- const footnotes = new Map();
- let counter = 0;
-
- // Extract footnote definitions
- text = text.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, content) => {
- const cleanId = id.replace('^', '');
- footnotes.set(cleanId, content.trim());
- return '';
- });
-
- // Replace references with standardized styling
- text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
- const cleanId = id.replace('^', '');
- if (footnotes.has(cleanId)) {
- counter++;
- return `[${counter}] `;
- }
- return match;
- });
-
- // Add footnotes section if we have any
- if (footnotes.size > 0) {
- text += '\n';
- }
-
- return { text, footnotes };
-}
-
-/**
- * Process nostr identifiers
- */
-async function processNostrIdentifiers(content: string): Promise {
- let processedContent = content;
-
- // Process profiles (npub and nprofile)
- const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX));
- for (const match of profileMatches) {
- const [fullMatch, identifier] = match;
- const metadata = await getUserMetadata(identifier);
- const displayText = metadata.displayName || metadata.name || `${identifier.slice(0, 8)}...${identifier.slice(-4)}`;
- const escapedId = identifier
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- const escapedDisplayText = displayText
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
- // Create a link with standardized styling
- const link = `@${escapedDisplayText} `;
-
- // Replace only the exact match to preserve surrounding text
- 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, identifier] = match;
- const shortId = identifier.slice(0, 12) + '...' + identifier.slice(-8);
- const escapedId = identifier
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-
- // Create a link with standardized styling
- const link = `${shortId} `;
-
- // Replace only the exact match to preserve surrounding text
- processedContent = processedContent.replace(fullMatch, link);
- }
-
- return processedContent;
-}
-
-/**
- * Parse markdown text to content with special handling for nostr identifiers
- */
-export async function parseMarkdown(text: string): Promise {
- if (!text) return '';
-
- // First extract and save code blocks
- const { text: withoutCode, blocks } = processCodeBlocks(text);
-
- // Process nostr identifiers
- let content = await processNostrIdentifiers(withoutCode);
-
- // Process blockquotes
- content = processBlockquotes(content);
-
- // Process lists
- content = processLists(content);
-
- // Process other markdown elements
- content = processOtherElements(content);
-
- // Process inline code (after other elements to prevent conflicts)
- content = processInlineCode(content);
-
- // Process footnotes
- const { text: processedContent } = processFootnotes(content);
- content = processedContent;
-
- // Handle paragraphs and line breaks, preserving existing HTML
- content = content
- .split(/\n{2,}/)
- .map((para: string) => para.trim())
- .filter((para: string) => para)
- .map((para: string) => para.startsWith('<') ? para : `${para}
`)
- .join('\n\n');
-
- // Finally, restore code blocks
- content = restoreCodeBlocks(content, blocks);
-
- return content;
-}
-
-/**
- * Escape special characters in a string for use in a regular expression
- */
-function escapeRegExp(string: string): string {
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
-function processCode(text: string): string {
- // Process code blocks with language specification
- text = text.replace(/```(\w+)?\n([\s\S]+?)\n```/g, (match, lang, code) => {
- if (lang === 'json') {
- try {
- const parsed = JSON.parse(code.trim());
- code = JSON.stringify(parsed, null, 2);
- // Add syntax highlighting classes for JSON
- code = code.replace(/"([^"]+)":/g, '"$1 ":') // keys
- .replace(/"([^"]+)"/g, '"$1 "') // strings
- .replace(/\b(true|false)\b/g, '$1 ') // booleans
- .replace(/\b(null)\b/g, '$1 ') // null
- .replace(/\b(\d+\.?\d*)\b/g, '$1 '); // numbers
- } catch (e) {
- // If JSON parsing fails, use the original code
- }
- }
- return `${code}
`;
- });
-
- // Process inline code
- text = text.replace(/`([^`]+)`/g, '$1');
-
- return text;
-}
+// ... existing code ...
\ No newline at end of file
diff --git a/src/lib/utils/markdownTestfile.md b/src/lib/utils/markdownTestfile.md
index c73a4dd..cb35194 100644
--- a/src/lib/utils/markdownTestfile.md
+++ b/src/lib/utils/markdownTestfile.md
@@ -3,7 +3,9 @@ This is a test
### Disclaimer
-It is _only_ a test. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
+It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
+
+This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser.
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
@@ -40,6 +42,9 @@ Let's nest that:
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
@@ -67,7 +72,7 @@ nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydh
This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
-You can even include `code inline` or
+You can even include `code inline`, like `` or
```
in a code block
@@ -130,7 +135,6 @@ package main
input := scanner.Text()
fmt.Println("You entered:", input)
}
-
```
or even Markdown:
@@ -144,7 +148,7 @@ Paragraphs are separated by a blank line.
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists
look like:
- * this one
+ * this one[^some reference text]
* that one
* the other one
@@ -164,7 +168,7 @@ content starts at 4-columns in.
### I went ahead and implemented tables, too.
-A neat table:
+A neat table[^some reference text]:
| Syntax | Description |
| ----------- | ----------- |
@@ -178,5 +182,12 @@ A messy table (should render the same as above):
| 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
-[^2]: so is this
\ No newline at end of file
+[^2]: so is this
+[^some reference text]: this is a footnote that isn't a number
\ No newline at end of file
diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts
new file mode 100644
index 0000000..878a744
--- /dev/null
+++ b/src/lib/utils/mime.ts
@@ -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/markdown"];
+ MTag = ["M", `git/issue/${replaceability}`];
+ break;
+
+ // Issue comment
+ case 1622:
+ mTag = ["m", "text/markdown"];
+ 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/markdown"];
+ MTag = ["M", `article/long-form/${replaceability}`];
+ break;
+
+ // Add more cases as needed...
+ }
+
+ return [mTag, MTag];
+}
\ No newline at end of file
diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts
new file mode 100644
index 0000000..60db82f
--- /dev/null
+++ b/src/lib/utils/nostrUtils.ts
@@ -0,0 +1,161 @@
+import { get } from 'svelte/store';
+import { nip19 } from 'nostr-tools';
+import { ndkInstance } from '$lib/ndk';
+
+// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
+export const NOSTR_PROFILE_REGEX = /(?();
+
+/**
+ * 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 `
@${escapedText} `;
+}
+
+/**
+ * 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 `
${escapedText} `;
+}
+
+/**
+ * Process Nostr identifiers in text
+ */
+export async function processNostrIdentifiers(content: string): Promise
{
+ let processedContent = content;
+
+ // Process profiles (npub and nprofile)
+ const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX));
+ for (const match of profileMatches) {
+ const [fullMatch, identifier] = match;
+ 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, identifier] = match;
+ const link = createNoteLink(identifier);
+ processedContent = processedContent.replace(fullMatch, link);
+ }
+
+ return processedContent;
+}
+
+export async function getNpubFromNip05(nip05: string): Promise {
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte
index 895dff9..b2798e0 100644
--- a/src/routes/contact/+page.svelte
+++ b/src/routes/contact/+page.svelte
@@ -1,13 +1,14 @@
+
+
+
{#if showTocButton && !showToc}
{#each leaves as leaf, i}
-
setLastElementRef(el, i)}
- />
+ {#if leaf == null}
+
+
+ Error loading content. One or more events could not be loaded.
+
+ {:else}
+ setLastElementRef(el, i)}
+ />
+ {/if}
{/each}
diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte
index 5eb4f24..6b96660 100644
--- a/src/lib/components/PublicationSection.svelte
+++ b/src/lib/components/PublicationSection.svelte
@@ -23,24 +23,38 @@
let leafEvent: Promise = $derived.by(async () =>
await publicationTree.getEvent(address));
+
let rootEvent: Promise = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
+
let publicationType: Promise = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
+
let leafHierarchy: Promise = $derived.by(async () =>
await publicationTree.getHierarchy(address));
+
let leafTitle: Promise = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
+
let leafContent: Promise = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
- const index = leaves.findIndex(leaf => leaf.tagAddress() === address);
- if (index === 0) {
- return null;
- }
- return leaves[index - 1];
+ let index: number;
+ let event: NDKEvent | null = null;
+ let decrement = 1;
+
+ do {
+ index = leaves.findIndex(leaf => leaf?.tagAddress() === address);
+ if (index === 0) {
+ return null;
+ }
+ event = leaves[index - decrement++];
+ } while (event == null && index - decrement >= 0);
+
+ return event;
});
+
let previousLeafHierarchy: Promise = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts
index 6be32fb..1589cba 100644
--- a/src/lib/data_structures/lazy.ts
+++ b/src/lib/data_structures/lazy.ts
@@ -1,16 +1,32 @@
+export enum LazyStatus {
+ Pending,
+ Resolved,
+ Error,
+}
+
export class Lazy {
#value?: T;
#resolver: () => Promise;
+ status: LazyStatus;
+
constructor(resolver: () => Promise) {
this.#resolver = resolver;
+ this.status = LazyStatus.Pending;
}
- async value(): Promise {
+ async value(): Promise {
if (!this.#value) {
- this.#value = await this.#resolver();
+ try {
+ this.#value = await this.#resolver();
+ } catch (error) {
+ this.status = LazyStatus.Error;
+ console.error(error);
+ return null;
+ }
}
+ this.status = LazyStatus.Resolved;
return this.#value;
}
}
\ No newline at end of file
diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts
index 466e676..d703555 100644
--- a/src/lib/data_structures/publication_tree.ts
+++ b/src/lib/data_structures/publication_tree.ts
@@ -8,14 +8,20 @@ enum PublicationTreeNodeType {
Leaf,
}
+enum PublicationTreeNodeStatus {
+ Resolved,
+ Error,
+}
+
interface PublicationTreeNode {
type: PublicationTreeNodeType;
+ status: PublicationTreeNodeStatus;
address: string;
parent?: PublicationTreeNode;
children?: Array>;
}
-export class PublicationTree implements AsyncIterable {
+export class PublicationTree implements AsyncIterable {
/**
* The root node of the tree.
*/
@@ -50,6 +56,7 @@ export class PublicationTree implements AsyncIterable {
const rootAddress = rootEvent.tagAddress();
this.#root = {
type: this.#getNodeType(rootEvent),
+ status: PublicationTreeNodeStatus.Resolved,
address: rootAddress,
children: [],
};
@@ -84,6 +91,7 @@ export class PublicationTree implements AsyncIterable {
const node: PublicationTreeNode = {
type: await this.#getNodeType(event),
+ status: PublicationTreeNodeStatus.Resolved,
address,
parent: parentNode,
children: [],
@@ -134,7 +142,7 @@ export class PublicationTree implements AsyncIterable {
* @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes.
*/
- async getChildAddresses(address: string): Promise {
+ async getChildAddresses(address: string): Promise> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
@@ -142,7 +150,7 @@ export class PublicationTree implements AsyncIterable {
return Promise.all(
node.children?.map(async child =>
- (await child.value()).address
+ (await child.value())?.address ?? null
) ?? []
);
}
@@ -205,20 +213,26 @@ export class PublicationTree implements AsyncIterable {
async tryMoveToFirstChild(): Promise {
if (!this.target) {
- throw new Error("Cursor: Target node is null or undefined.");
+ console.debug("Cursor: Target node is null or undefined.");
+ return false;
}
if (this.target.type === PublicationTreeNodeType.Leaf) {
return false;
}
+
+ if (this.target.children == null || this.target.children.length === 0) {
+ return false;
+ }
- this.target = (await this.target.children?.at(0)?.value())!;
+ this.target = await this.target.children?.at(0)?.value();
return true;
}
async tryMoveToNextSibling(): Promise {
if (!this.target) {
- throw new Error("Cursor: Target node is null or undefined.");
+ console.debug("Cursor: Target node is null or undefined.");
+ return false;
}
const parent = this.target.parent;
@@ -228,25 +242,27 @@ export class PublicationTree implements AsyncIterable {
}
const currentIndex = await siblings.findIndexAsync(
- async (sibling: Lazy) => (await sibling.value()).address === this.target!.address
+ async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address
);
if (currentIndex === -1) {
return false;
}
- const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null;
- if (!nextSibling) {
+ if (currentIndex + 1 >= siblings.length) {
return false;
}
+ const nextSibling = (await siblings.at(currentIndex + 1)?.value());
this.target = nextSibling;
+
return true;
}
tryMoveToParent(): boolean {
if (!this.target) {
- throw new Error("Cursor: Target node is null or undefined.");
+ console.debug("Cursor: Target node is null or undefined.");
+ return false;
}
const parent = this.target.parent;
@@ -263,11 +279,11 @@ export class PublicationTree implements AsyncIterable {
// #region Async Iterator Implementation
- [Symbol.asyncIterator](): AsyncIterator {
+ [Symbol.asyncIterator](): AsyncIterator {
return this;
}
- async next(): Promise> {
+ async next(): Promise> {
if (!this.#cursor.target) {
await this.#cursor.tryMoveTo(this.#bookmark);
}
@@ -297,7 +313,7 @@ export class PublicationTree implements AsyncIterable {
} while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf);
const event = await this.getEvent(this.#cursor.target!.address);
- return { done: false, value: event! };
+ return { done: false, value: event };
}
// #endregion
@@ -396,9 +412,17 @@ export class PublicationTree implements AsyncIterable {
});
if (!event) {
- throw new Error(
+ console.debug(
`PublicationTree: Event with address ${address} not found.`
);
+
+ return {
+ type: PublicationTreeNodeType.Leaf,
+ status: PublicationTreeNodeStatus.Error,
+ address,
+ parent: parentNode,
+ children: [],
+ };
}
this.#events.set(address, event);
@@ -406,7 +430,8 @@ export class PublicationTree implements AsyncIterable {
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const node: PublicationTreeNode = {
- type: await this.#getNodeType(event),
+ type: this.#getNodeType(event),
+ status: PublicationTreeNodeStatus.Resolved,
address,
parent: parentNode,
children: [],
From 557f3c0dfb9911147acbc59a4af81996dfe46bce Mon Sep 17 00:00:00 2001
From: buttercat1791
Date: Tue, 6 May 2025 09:25:57 -0500
Subject: [PATCH 15/50] Add `content-visibility-auto` Tailwind utility
---
src/lib/components/PublicationSection.svelte | 2 +-
tailwind.config.cjs | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte
index 6b96660..ae3170b 100644
--- a/src/lib/components/PublicationSection.svelte
+++ b/src/lib/components/PublicationSection.svelte
@@ -105,7 +105,7 @@
-
+
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index 380981b..d951507 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -1,4 +1,5 @@
import flowbite from "flowbite/plugin";
+import plugin from "tailwindcss/plugin";
/** @type {import('tailwindcss').Config}*/
const config = {
@@ -85,6 +86,25 @@ const config = {
plugins: [
flowbite(),
+ plugin(function({ addUtilities, matchUtilities }) {
+ addUtilities({
+ '.content-visibility-auto': {
+ 'content-visibility': 'auto',
+ },
+ '.contain-size': {
+ contain: 'size',
+ },
+ });
+
+ matchUtilities({
+ 'contain-intrinsic-w-*': value => ({
+ width: value,
+ }),
+ 'contain-intrinsic-h-*': value => ({
+ height: value,
+ })
+ });
+ })
],
darkMode: 'class',
From e9aa513d5fa58297978bc2f7d797b092745df4ac Mon Sep 17 00:00:00 2001
From: buttercat1791
Date: Tue, 6 May 2025 09:27:35 -0500
Subject: [PATCH 16/50] Load only one element at a time on scroll
---
src/lib/components/Publication.svelte | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte
index 1995b67..968ad2a 100644
--- a/src/lib/components/Publication.svelte
+++ b/src/lib/components/Publication.svelte
@@ -138,7 +138,7 @@
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading) {
- loadMore(4);
+ loadMore(1);
}
});
}, { threshold: 0.5 });
From 74d6b306b9bacce853c96639c2e49d5342cddbd2 Mon Sep 17 00:00:00 2001
From: buttercat1791
Date: Tue, 6 May 2025 09:27:54 -0500
Subject: [PATCH 17/50] Track loaded addresses in a publication
---
src/lib/components/Publication.svelte | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte
index 968ad2a..ffb2f28 100644
--- a/src/lib/components/Publication.svelte
+++ b/src/lib/components/Publication.svelte
@@ -30,6 +30,7 @@
// TODO: Test load handling.
let leaves = $state([]);
+ let loadedAddresses = $state>(new Set());
let isLoading = $state(false);
let lastElementRef = $state(null);
@@ -40,10 +41,21 @@
for (let i = 0; i < count; i++) {
const nextItem = await publicationTree.next();
+
+ const nextAddress = nextItem.value?.tagAddress();
+ if (nextAddress && loadedAddresses.has(nextAddress)) {
+ continue;
+ }
+
+ if (nextAddress && !loadedAddresses.has(nextAddress)) {
+ loadedAddresses.add(nextAddress);
+ }
+
if (leaves.includes(nextItem.value) || (nextItem.done && nextItem.value === null)) {
isLoading = false;
return;
}
+
leaves.push(nextItem.value);
}
From b9925813dbfa0e0305a08ffd41a651ffb72b011f Mon Sep 17 00:00:00 2001
From: buttercat1791
Date: Tue, 6 May 2025 21:40:25 -0500
Subject: [PATCH 18/50] Remove completed TODOs
---
src/lib/components/PublicationSection.svelte | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte
index ae3170b..7e14a97 100644
--- a/src/lib/components/PublicationSection.svelte
+++ b/src/lib/components/PublicationSection.svelte
@@ -104,12 +104,10 @@
});
-
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
-
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{/each}
From f72adcb6d3be5f6ffbc7fa5d92b6a2734c122199 Mon Sep 17 00:00:00 2001
From: buttercat1791
Date: Tue, 6 May 2025 21:48:53 -0500
Subject: [PATCH 19/50] Display a "Load More" button if load-on-scroll fails
---
src/lib/components/Publication.svelte | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte
index ffb2f28..eaf3559 100644
--- a/src/lib/components/Publication.svelte
+++ b/src/lib/components/Publication.svelte
@@ -167,8 +167,8 @@
+
-
{#if showTocButton && !showToc}
+
+ {#if isLoading}
+
+ Loading...
+
+ {:else}
+ loadMore(1)}>
+ Show More
+
+ {/if}
+
\ No newline at end of file
diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte
index d8c28be..75324a9 100644
--- a/src/lib/components/EventLimitControl.svelte
+++ b/src/lib/components/EventLimitControl.svelte
@@ -29,23 +29,23 @@
}
-
-
Number of root events:
+
+
+ Number of root events:
Update
diff --git a/src/lib/components/EventRenderLevelLimit.svelte b/src/lib/components/EventRenderLevelLimit.svelte
index 3a7d8a8..b0cb64e 100644
--- a/src/lib/components/EventRenderLevelLimit.svelte
+++ b/src/lib/components/EventRenderLevelLimit.svelte
@@ -28,24 +28,23 @@
}
-
-
Levels to render:
+
+
+ Levels to render:
-
Limit:
Update
diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte
index 13d9c93..717e52f 100644
--- a/src/lib/components/Login.svelte
+++ b/src/lib/components/Login.svelte
@@ -46,33 +46,28 @@
-
+
{#if $ndkSignedIn}
{:else}
-
+
-