Browse Source

fixed image display

fixed user outline display
fixed visualization node links and added backlink
consolidated common parser functions
master
silberengel 10 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