Browse Source

fixed image display

fixed user outline display
fixed visualization node links and added backlink
consolidated common parser functions
master
silberengel 7 months ago
parent
commit
93c25f4826
  1. 15
      src/lib/components/CommentBox.svelte
  2. 26
      src/lib/components/Notifications.svelte
  3. 5
      src/lib/components/cards/ProfileHeader.svelte
  4. 46
      src/lib/components/util/ArticleNav.svelte
  5. 4
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  6. 80
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  7. 246
      src/lib/utils/markup/basicMarkupParser.ts
  8. 223
      src/lib/utils/markup/markupServices.ts

15
src/lib/components/CommentBox.svelte

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte"; import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { UserOutline } from "flowbite-svelte-icons";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
@ -454,13 +455,13 @@
class="w-8 h-8 rounded-full object-cover flex-shrink-0" class="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
{:else} {:else}
<div <div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" <UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
></div> </div>
{/if} {/if}
<div class="flex flex-col text-left min-w-0 flex-1"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate"> <span class="font-semibold truncate">
{profile.displayName || profile.name || mentionSearch} {profile.displayName || profile.name || "anon"}
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <span class="text-xs text-gray-500 flex items-center gap-1">
@ -591,15 +592,13 @@
/> />
{:else} {:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center"> <div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
{(userProfile.displayName || userProfile.name || "U").charAt(0).toUpperCase()}
</span>
</div> </div>
{/if} {/if}
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || {userProfile.displayName ||
userProfile.name || userProfile.name ||
`${$userPubkey?.slice(0, 8)}...${$userPubkey?.slice(-4)}`} "anon"}
</span> </span>
</div> </div>
{/if} {/if}

26
src/lib/components/Notifications.svelte

@ -17,7 +17,7 @@
import { Modal, Button } from "flowbite-svelte"; import { Modal, Button } from "flowbite-svelte";
import { searchProfiles } from "$lib/utils/search_utility"; import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_types"; import type { NostrProfile } from "$lib/utils/search_types";
import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons"; import { PlusOutline, ReplyOutline, UserOutline } from "flowbite-svelte-icons";
import { import {
truncateContent, truncateContent,
truncateRenderedContent, truncateRenderedContent,
@ -746,7 +746,7 @@
<div class="filter-indicator mb-4 p-3 rounded-lg"> <div class="filter-indicator mb-4 p-3 rounded-lg">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-blue-700 dark:text-blue-300"> <span class="text-sm text-blue-700 dark:text-blue-300">
Filtered by user: @{authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || filteredByUser?.slice(0, 8) + "..." + filteredByUser?.slice(-4) || "Unknown"} Filtered by user: @{authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || "anon"}
</span> </span>
<button <button
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline font-medium"
@ -775,14 +775,12 @@
/> />
{:else} {:else}
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600"> <div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <UserOutline class="w-5 h-5 text-gray-600 dark:text-gray-300" />
{(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div> </div>
{/if} {/if}
<div class="w-24 text-center"> <div class="w-24 text-center">
<span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words"> <span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words">
@{authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 8) + "..." + message.pubkey.slice(-4)} @{authorProfile?.displayName || authorProfile?.name || "anon"}
</span> </span>
</div> </div>
</div> </div>
@ -897,14 +895,12 @@
/> />
{:else} {:else}
<div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600"> <div class="profile-picture-fallback w-10 h-10 rounded-full flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300"> <UserOutline class="w-5 h-5 text-gray-600 dark:text-gray-300" />
{(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div> </div>
{/if} {/if}
<div class="w-24 text-center"> <div class="w-24 text-center">
<span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words"> <span class="text-xs font-medium text-gray-900 dark:text-gray-100 break-words">
@{authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 8) + "..." + notification.pubkey.slice(-4)} @{authorProfile?.displayName || authorProfile?.name || "anon"}
</span> </span>
</div> </div>
</div> </div>
@ -1009,7 +1005,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each selectedRecipients as recipient} {#each selectedRecipients as recipient}
<span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm"> <span class="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm">
@{recipient.displayName || recipient.name || recipient.pubkey?.slice(0, 8) + "..." + recipient.pubkey?.slice(-4) || "Unknown"} @{recipient.displayName || recipient.name || "anon"}
<button <button
onclick={() => { onclick={() => {
selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey); selectedRecipients = selectedRecipients.filter(r => r.pubkey !== recipient.pubkey);
@ -1134,13 +1130,13 @@
}} }}
/> />
{:else} {:else}
<div <div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0 flex items-center justify-center">
class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" <UserOutline class="w-4 h-4 text-gray-600 dark:text-gray-300" />
></div> </div>
{/if} {/if}
<div class="flex flex-col text-left min-w-0 flex-1"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate"> <span class="font-semibold truncate">
@{profile.displayName || profile.name || profile.pubkey?.slice(0, 8) + "..." + profile.pubkey?.slice(-4) || "Unknown"} @{profile.displayName || profile.name || "anon"}
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <span class="text-xs text-gray-500 flex items-center gap-1">

5
src/lib/components/cards/ProfileHeader.svelte

@ -11,8 +11,7 @@
lnurlpWellKnownUrl, lnurlpWellKnownUrl,
checkCommunity, checkCommunity,
} from "$lib/utils/search_utility"; } from "$lib/utils/search_utility";
// @ts-ignore import { bech32 } from "bech32";
import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@ -154,7 +153,7 @@
{/if} {/if}
{#if profile.lud16} {#if profile.lud16}
<div class="flex items-center gap-2 mt-4"> <div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> <dt class="font-semibold min-w-[120px]">Lightning:</dt>
<dd> <dd>
<Button <Button
class="btn-leather" class="btn-leather"

46
src/lib/components/util/ArticleNav.svelte

@ -12,6 +12,8 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { indexKind } from "$lib/consts";
let { publicationType, indexEvent } = $props<{ let { publicationType, indexEvent } = $props<{
rootId: any; rootId: any;
@ -27,7 +29,7 @@
indexEvent.getMatchingTags("p")[0]?.[1] ?? null, indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
); );
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let isIndexEvent: boolean = $derived(indexEvent.kind === 30040); let isIndexEvent: boolean = $derived(indexEvent.kind === indexKind);
let lastScrollY = $state(0); let lastScrollY = $state(0);
let isVisible = $state(true); let isVisible = $state(true);
@ -105,11 +107,22 @@
} }
} }
// Check if user came from visualization page
let cameFromVisualization = $derived.by(() => {
const url = $page.url;
return url.searchParams.has('from') && url.searchParams.get('from') === 'visualize';
});
function visualizePublication() { function visualizePublication() {
const eventId = indexEvent.id; const eventId = indexEvent.id;
goto(`/visualize?event=${eventId}`); goto(`/visualize?event=${eventId}`);
} }
function returnToVisualization() {
// Go back to visualization page
goto('/visualize');
}
let unsubscribe: () => void; let unsubscribe: () => void;
onMount(() => { onMount(() => {
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
@ -194,16 +207,29 @@
<span class="hidden sm:inline">Discussion</span> <span class="hidden sm:inline">Discussion</span>
</Button> </Button>
{/if} {/if}
<Button {#if cameFromVisualization}
class="btn-leather !w-auto" <Button
outline={true} class="btn-leather !w-auto"
onclick={visualizePublication} outline={true}
title="Visualize publication network" onclick={returnToVisualization}
> title="Return to visualization"
<ChartOutline class="!fill-none inline mr-1" /><span >
class="hidden sm:inline">Visualize Publication</span <CaretLeftOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Return to Visualization</span
>
</Button>
{:else if isIndexEvent}
<Button
class="btn-leather !w-auto"
outline={true}
onclick={visualizePublication}
title="Visualize publication network"
> >
</Button> <ChartOutline class="!fill-none inline mr-1" /><span
class="hidden sm:inline">Visualize Publication</span
>
</Button>
{/if}
</div> </div>
</div> </div>
</nav> </nav>

4
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -98,7 +98,7 @@
*/ */
function getEventUrl(node: NetworkNode): string { function getEventUrl(node: NetworkNode): string {
if (isPublicationEvent(node.kind)) { if (isPublicationEvent(node.kind)) {
return `/publication?id=${node.id}`; return `/publication/id/${node.id}?from=visualize`;
} }
return `/events?id=${node.id}`; return `/events?id=${node.id}`;
} }
@ -188,7 +188,7 @@
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a href="/publication?id={node.id}" class="tooltip-title-link"> <a href={getEventUrl(node)} class="tooltip-title-link">
{getLinkText(node)} {getLinkText(node)}
</a> </a>
</div> </div>

80
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -1,48 +1,6 @@
import { processNostrIdentifiers } from "../nostrUtils"; import { processImageWithReveal, processNostrIdentifiersInText, processWikilinks, processAsciiDocAnchors } from "./markupServices";
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Replaces wikilinks in the format [[target]] or [[target|display]] with
* clickable links to the events page.
*/
function replaceWikilinks(html: string): string {
// [[target page]] or [[target page|display text]]
return html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
// Remove onclick handler to avoid breaking amber session - will be handled by global click handler
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags.
*/
function replaceAsciiDocAnchors(html: string): string {
return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
// Remove onclick handler to avoid breaking amber session - will be handled by global click handler
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}
/** /**
* Processes nostr addresses in HTML content, but skips addresses that are * Processes nostr addresses in HTML content, but skips addresses that are
@ -80,7 +38,7 @@ async function processNostrAddresses(html: string): Promise<string> {
} }
// Process the nostr identifier // Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch); const processedMatch = await processNostrIdentifiersInText(fullMatch);
// Replace the match in the HTML // Replace the match in the HTML
processedHtml = processedHtml =
@ -92,6 +50,32 @@ async function processNostrAddresses(html: string): Promise<string> {
return processedHtml; return processedHtml;
} }
/**
* Processes AsciiDoc image blocks to add reveal/enlarge functionality
*/
function processImageBlocks(html: string): string {
// Process image blocks with reveal functionality
return html.replace(
/<div class="imageblock">\s*<div class="content">\s*<img([^>]+)>\s*<\/div>\s*(?:<div class="title">([^<]+)<\/div>)?\s*<\/div>/g,
(match, imgAttributes, title) => {
// Extract src and alt from img attributes
const srcMatch = imgAttributes.match(/src="([^"]+)"/);
const altMatch = imgAttributes.match(/alt="([^"]*)"/);
const src = srcMatch ? srcMatch[1] : '';
const alt = altMatch ? altMatch[1] : '';
const titleHtml = title ? `<div class="title">${title}</div>` : '';
return `<div class="imageblock">
<div class="content">
${processImageWithReveal(src, alt)}
</div>
${titleHtml}
</div>`;
}
);
}
/** /**
* Fixes AsciiDoctor stem blocks for MathJax rendering. * Fixes AsciiDoctor stem blocks for MathJax rendering.
* Joins split spans and wraps content in $$...$$ for block math. * Joins split spans and wraps content in $$...$$ for block math.
@ -120,12 +104,14 @@ export async function postProcessAsciidoctorHtml(
try { try {
// First process AsciiDoctor-generated anchors // First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html); let processedHtml = processAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain) // Then process wikilinks in [[...]] format (if any remain)
processedHtml = replaceWikilinks(processedHtml); processedHtml = processWikilinks(processedHtml);
// Then process nostr addresses (but not those already in links) // Then process nostr addresses (but not those already in links)
processedHtml = await processNostrAddresses(processedHtml); processedHtml = await processNostrIdentifiersInText(processedHtml);
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
// Process image blocks to add reveal/enlarge functionality
processedHtml = processImageBlocks(processedHtml);
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {

246
src/lib/utils/markup/basicMarkupParser.ts

@ -1,30 +1,26 @@
import { processNostrIdentifiers } from "../nostrUtils.ts";
import * as emoji from "node-emoji"; import * as emoji from "node-emoji";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import {
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText,
processEmojiShortcodes,
processWebSocketUrls,
processHashtags,
processBasicTextFormatting,
processBlockquotes,
processWikilinks,
stripTrackingParams
} from "./markupServices";
/* Regex constants for basic markup parsing */ /* Regex constants for basic markup parsing */
// Text formatting
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g;
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g;
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
// Block elements
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
// Links and media // Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
const WSS_URL = /wss:\/\/[^\s<>"]+/g;
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g; const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX =
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Add this helper function near the top: // Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string { function replaceAlexandriaNostrLinks(text: string): string {
@ -85,75 +81,11 @@ function replaceAlexandriaNostrLinks(text: string): string {
return text; return text;
} }
// Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string {
// List of tracking params to remove
const trackingParams = [
/^utm_/i,
/^fbclid$/i,
/^gclid$/i,
/^tracking$/i,
/^ref$/i,
];
try {
// Absolute URL
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
const parsed = new URL(url);
trackingParams.forEach((pattern) => {
for (const key of Array.from(parsed.searchParams.keys())) {
if (pattern.test(key)) {
parsed.searchParams.delete(key);
}
}
});
const queryString = parsed.searchParams.toString();
return (
parsed.origin +
parsed.pathname +
(queryString ? "?" + queryString : "") +
(parsed.hash || "")
);
} else {
// Relative URL: parse query string manually
const [path, queryAndHash = ""] = url.split("?");
const [query = "", hash = ""] = queryAndHash.split("#");
if (!query) return url;
const params = query.split("&").filter(Boolean);
const filtered = params.filter((param) => {
const [key] = param.split("=");
return !trackingParams.some((pattern) => pattern.test(key));
});
const queryString = filtered.length ? "?" + filtered.join("&") : "";
const hashString = hash ? "#" + hash : "";
return path + queryString + hashString;
}
} catch {
return url;
}
}
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function replaceWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors
// Remove onclick handler to avoid breaking amber session - will be handled by global click handler
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList( function parseList(
@ -225,36 +157,7 @@ function processBasicFormatting(content: string): string {
// Process markup images first // Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (_match, alt, url) => { processedText = processedText.replace(MARKUP_IMAGE, (_match, alt, url) => {
url = stripTrackingParams(url); return processImageWithReveal(url, alt);
if (YOUTUBE_URL_REGEX.test(url)) {
const videoId = extractYouTubeVideoId(url);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(url)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || "Video"}</video>`;
}
if (AUDIO_URL_REGEX.test(url)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || "Audio"}</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split("?")[0])) {
return `<div class="relative inline-block w-[300px] my-4">
<div class="w-full h-48 bg-gradient-to-br from-pink-200 to-purple-200 rounded-lg shadow-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🖼</div>
<div class="text-gray-600 font-medium">Image</div>
</div>
</div>
<img src="${url}" alt="${alt}" class="absolute inset-0 w-full h-full object-cover rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="this.parentElement.querySelector('img').style.opacity='1'; this.style.display='none';">
Reveal Image
</button>
</div>`;
}
// Otherwise, render as a clickable link
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`;
}); });
// Process markup links // Process markup links
@ -264,75 +167,19 @@ function processBasicFormatting(content: string): string {
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`, `<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
); );
// Process WebSocket URLs // Process WebSocket URLs using shared services
processedText = processedText.replace(WSS_URL, (match) => { processedText = processWebSocketUrls(processedText);
// Remove 'wss://' from the start and any trailing slashes
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
// Process direct media URLs and auto-link all URLs // Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => { processedText = processedText.replace(DIRECT_LINK, (match) => {
const clean = stripTrackingParams(match); return processMediaUrl(match);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`;
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return `<div class="relative inline-block w-[300px] my-4">
<div class="w-full h-48 bg-gradient-to-br from-pink-200 to-purple-200 rounded-lg shadow-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🖼</div>
<div class="text-gray-600 font-medium">Image</div>
</div>
</div>
<img src="${clean}" alt="Embedded media" class="absolute inset-0 w-full h-full object-contain rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="const img = this.parentElement.querySelector('img'); const expandBtn = this.parentElement.querySelector('button[title]'); img.style.opacity='1'; this.style.display='none'; expandBtn.style.display='flex'; expandBtn.style.opacity='1'; expandBtn.style.pointerEvents='auto';">
Reveal Image
</button>
<!-- Expand button - initially hidden, shown after image is revealed -->
<button class="absolute top-2 right-2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 opacity-0 pointer-events-none"
onclick="window.open('${clean}', '_blank')"
title="Open image in full size"
style="display: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
// Otherwise, render as a clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
});
// Process text formatting
processedText = processedText.replace(BOLD_REGEX, "<strong>$2</strong>");
processedText = processedText.replace(ITALIC_REGEX, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
}); });
processedText = processedText.replace(
STRIKETHROUGH_REGEX,
(_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
},
);
// Process hashtags as clickable buttons // Process text formatting using shared services
processedText = processedText.replace( processedText = processBasicTextFormatting(processedText);
HASHTAG_REGEX,
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>', // Process hashtags using shared services
); processedText = processHashtags(processedText);
// --- Improved List Grouping and Parsing --- // --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n"); const lines = processedText.split("\n");
@ -369,46 +216,11 @@ function processBasicFormatting(content: string): string {
return processedText; return processedText;
} }
// Helper function to extract YouTube video ID
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
);
return match ? match[1] : null;
}
function processBlockquotes(content: string): string {
try {
if (!content) return "";
return content.replace(BLOCKQUOTE_REGEX, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${lines.join(
"\n",
)}</blockquote>`;
});
} catch (e: unknown) {
console.error("Error in processBlockquotes:", e);
return content;
}
}
function processEmojiShortcuts(content: string): string {
try {
return emoji.emojify(content, {
fallback: (name: string) => {
const emojiChar = emoji.get(name);
return emojiChar || `:${name}:`;
},
});
} catch (e: unknown) {
console.error("Error in processEmojiShortcuts:", e);
return content;
}
}
export async function parseBasicmarkup(text: string): Promise<string> { export async function parseBasicmarkup(text: string): Promise<string> {
if (!text) return ""; if (!text) return "";
@ -418,7 +230,7 @@ export async function parseBasicmarkup(text: string): Promise<string> {
let processedText = processBasicFormatting(text); let processedText = processBasicFormatting(text);
// Process emoji shortcuts // Process emoji shortcuts
processedText = processEmojiShortcuts(processedText); processedText = processEmojiShortcodes(processedText);
// Process blockquotes // Process blockquotes
processedText = processBlockquotes(processedText); processedText = processBlockquotes(processedText);
@ -443,10 +255,10 @@ export async function parseBasicmarkup(text: string): Promise<string> {
.join("\n"); .join("\n");
// Process Nostr identifiers last // Process Nostr identifiers last
processedText = await processNostrIdentifiers(processedText); processedText = await processNostrIdentifiersInText(processedText);
// Replace wikilinks // Replace wikilinks
processedText = replaceWikilinks(processedText); processedText = processWikilinks(processedText);
return processedText; return processedText;
} catch (e: unknown) { } catch (e: unknown) {

223
src/lib/utils/markup/markupServices.ts

@ -0,0 +1,223 @@
import { processNostrIdentifiers } from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i;
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
/**
* Shared service for processing images with reveal/enlarge functionality
*/
export function processImageWithReveal(src: string, alt: string = "Image"): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`;
}
return `<div class="relative inline-block w-[300px] my-4 group">
<!-- Pastel placeholder background -->
<div class="image-bg-placeholder w-full h-48 bg-gradient-to-br from-pink-100 via-purple-100 to-blue-100 dark:from-pink-900 dark:via-purple-900 dark:to-blue-900 rounded-lg shadow-lg flex items-center justify-center border border-pink-200 dark:border-pink-700">
<!-- Decorative pattern -->
<div class="absolute inset-0 opacity-20">
<div class="w-full h-full bg-gradient-to-br from-pink-200/30 via-purple-200/30 to-blue-200/30 dark:from-pink-800/30 dark:via-purple-800/30 dark:to-blue-800/30 rounded-lg"></div>
</div>
<!-- Reveal button -->
<button class="relative z-10 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm text-gray-700 dark:text-gray-200 font-medium px-4 py-2 rounded-lg shadow-md hover:bg-white dark:hover:bg-gray-700 hover:shadow-lg transition-all duration-300 border border-gray-200 dark:border-gray-600 hover:scale-105"
onclick="const container = this.closest('.group'); const img = container.querySelector('img'); const placeholder = container.querySelector('.image-bg-placeholder'); const expandBtn = container.querySelector('button[title]'); img.style.opacity='1'; img.style.position='relative'; img.style.zIndex='1'; placeholder.style.display='none'; this.style.display='none'; expandBtn.style.display='flex'; expandBtn.style.opacity='1'; expandBtn.style.pointerEvents='auto';">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Reveal Image
</button>
</div>
<!-- Hidden image that will be revealed -->
<img src="${src}" alt="${alt}" class="absolute inset-0 w-full h-48 object-contain rounded-lg shadow-lg opacity-0 transition-opacity duration-500" loading="lazy" decoding="async">
<!-- Expand button (initially hidden) -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 opacity-0 pointer-events-none shadow-lg hover:scale-110"
onclick="window.open('${src}', '_blank')"
title="Open image in full size"
style="display: none;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</button>
</div>`;
}
/**
* Shared service for processing media URLs
*/
export function processMediaUrl(url: string, alt?: string): string {
const clean = stripTrackingParams(url);
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">${alt || "Video"}</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">${alt || "Audio"}</audio>`;
}
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return processImageWithReveal(clean, alt || "Embedded media");
}
// Default to clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
}
/**
* Shared service for processing nostr identifiers
*/
export async function processNostrIdentifiersInText(text: string): Promise<string> {
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Find all nostr addresses
const matches = Array.from(processedText.matchAll(nostrPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch);
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + processedMatch + processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing emoji shortcodes
*/
export function processEmojiShortcodes(text: string): string {
return emoji.emojify(text);
}
/**
* Shared service for processing WebSocket URLs
*/
export function processWebSocketUrls(text: string): string {
const wssUrlRegex = /wss:\/\/[^\s<>"]+/g;
return text.replace(wssUrlRegex, (match) => {
const cleanUrl = match.slice(6).replace(/\/+$/, "");
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`;
});
}
/**
* Shared service for processing hashtags
*/
export function processHashtags(text: string): string {
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g;
return text.replace(hashtagRegex, '<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>');
}
/**
* Shared service for processing basic text formatting
*/
export function processBasicTextFormatting(text: string): string {
// Bold: **text** or *text*
text = text.replace(/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, "<strong>$2</strong>");
// Italic: _text_ or __text__
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => {
const text = match.replace(/^_+|_+$/g, "");
return `<em>${text}</em>`;
});
// Strikethrough: ~~text~~ or ~text~
text = text.replace(/~~([^~\n]+)~~|~([^~\n]+)~/g, (_match, doubleText, singleText) => {
const text = doubleText || singleText;
return `<del class="line-through">${text}</del>`;
});
return text;
}
/**
* Shared service for processing blockquotes
*/
export function processBlockquotes(text: string): string {
const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm;
return text.replace(blockquoteRegex, (match) => {
const lines = match.split("\n").map((line) => {
return line.replace(/^[ \t]*>[ \t]?/, "").trim();
});
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${lines.join("\n")}</blockquote>`;
});
}
// Helper functions
export function stripTrackingParams(url: string): string {
try {
const urlObj = new URL(url);
// Remove common tracking parameters
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid'];
trackingParams.forEach(param => urlObj.searchParams.delete(param));
return urlObj.toString();
} catch {
return url;
}
}
function extractYouTubeVideoId(url: string): string | null {
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
/**
* Normalizes a string for use as a d-tag by converting to lowercase,
* replacing non-alphanumeric characters with dashes, and removing
* leading/trailing dashes.
*/
function normalizeDTag(input: string): string {
return input
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Shared service for processing wikilinks in the format [[target]] or [[target|display]]
*/
export function processWikilinks(text: string): string {
// [[target page]] or [[target page|display text]]
return text.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_match, target, label) => {
const normalized = normalizeDTag(target.trim());
const display = (label || target).trim();
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`;
},
);
}
/**
* Shared service for processing AsciiDoc anchor tags
*/
export function processAsciiDocAnchors(text: string): string {
return text.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim());
const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
});
}
Loading…
Cancel
Save