From 9e35d582050edc076ba8c7f91337de84e38b02eb Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 9 Apr 2025 09:08:15 -0500 Subject: [PATCH 01/75] Update heading snippet for commonality with Preview component --- src/lib/snippets/PublicationSnippets.svelte | 31 ++++----------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte index 26645fa..8f9e0cc 100644 --- a/src/lib/snippets/PublicationSnippets.svelte +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -5,31 +5,12 @@ {#snippet sectionHeading(title: string, depth: number)} - {#if depth === 0} -

- {title} -

- {:else if depth === 1} -

- {title} -

- {:else if depth === 2} -

- {title} -

- {:else if depth === 3} -

- {title} -

- {:else if depth === 4} -
- {title} -
- {:else} -
- {title} -
- {/if} + {@const headingLevel = Math.min(depth + 1, 6)} + + + + {title} + {/snippet} {#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} From 5486c7368576acfb57d6c45b1b27ee8c4c8aff6a Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 9 Apr 2025 09:16:41 -0500 Subject: [PATCH 02/75] Instanatiate PublicationTree and make it available to components --- src/app.d.ts | 5 +++++ src/routes/publication/+page.svelte | 7 ++++++- src/routes/publication/+page.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app.d.ts b/src/app.d.ts index 731967b..418205d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,4 +1,8 @@ // See https://kit.svelte.dev/docs/types#app + +import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; +import Pharos from "./lib/parser.ts"; + // for information about these interfaces declare global { namespace App { @@ -9,6 +13,7 @@ declare global { parser?: Pharos; waitable?: Promise; publicationType?: string; + publicationRootEvent?: NDKEvent; } // interface Platform {} } diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index dbd673f..b566965 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -2,10 +2,15 @@ import Article from "$lib/components/Publication.svelte"; import { TextPlaceholder } from "flowbite-svelte"; import type { PageData } from "./$types"; - import { onDestroy } from "svelte"; + import { onDestroy, setContext } from "svelte"; + import { PublicationTree } from "$lib/data_structures/publication_tree"; let { data }: { data: PageData } = $props(); + const publicationTree = new PublicationTree(data.publicationRootEvent, data.ndk); + + setContext('publicationTree', publicationTree); + onDestroy(() => data.parser.reset()); diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index 286063a..72116ec 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -102,5 +102,6 @@ export const load: PageLoad = async ({ url, parent }: { url: URL; parent: () => return { waitable: fetchPromise, publicationType, + publicationRootEvent: indexEvent, }; }; From 5229e807c714c979bbfa307a4b5ffa89917d3fa4 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 08:57:50 -0500 Subject: [PATCH 03/75] Require SvelteKit >= 2.16.0 --- deno.lock | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deno.lock b/deno.lock index f113237..9438da1 100644 --- a/deno.lock +++ b/deno.lock @@ -2887,7 +2887,7 @@ "npm:@sveltejs/adapter-auto@3", "npm:@sveltejs/adapter-node@^5.2.12", "npm:@sveltejs/adapter-static@3", - "npm:@sveltejs/kit@2", + "npm:@sveltejs/kit@^2.16.0", "npm:@sveltejs/vite-plugin-svelte@4", "npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/typography@0.5", diff --git a/package.json b/package.json index 2323efa..ca9cd67 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@sveltejs/adapter-auto": "3.x", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-static": "3.x", - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "4.x", "@types/he": "1.2.x", "@types/node": "22.x", From c63360d1f0b8b43decf23197c6d26dbd9c70b5f6 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 09:05:56 -0500 Subject: [PATCH 04/75] Update publication page load data handling --- src/app.d.ts | 3 ++- src/routes/publication/+page.svelte | 17 ++++------------- src/routes/publication/+page.ts | 3 ++- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index 418205d..10e50ba 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,7 +13,8 @@ declare global { parser?: Pharos; waitable?: Promise; publicationType?: string; - publicationRootEvent?: NDKEvent; + indexEvent?: NDKEvent; + url?: URL; } // interface Platform {} } diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index e6f532d..032b2f3 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -1,28 +1,19 @@ + + +
+ {#await Promise.all([sectionEvent, publicationType])} + + {:then [sectionEvent, publicationType]} + + {@render contentParagraph(sectionEvent?.content ?? '', publicationType ?? 'article', false)} + {/await} +
+ From 46f61da01dbcae8dc51630246dc0332798c02035 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 23:11:57 -0500 Subject: [PATCH 07/75] Begin to implement import on visibility for publication content --- src/lib/components/Publication.svelte | 82 ++++++++++++++++---- src/lib/components/PublicationSection.svelte | 22 +++++- src/routes/publication/+page.svelte | 6 +- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 4ff9c7c..167cf67 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -9,25 +9,64 @@ TextPlaceholder, Tooltip, } from "flowbite-svelte"; - import { onMount } from "svelte"; + import { getContext, onMount } from "svelte"; import { BookOutline } from "flowbite-svelte-icons"; - import Preview from "./Preview.svelte"; - import { pharosInstance } from "$lib/parser"; import { page } from "$app/state"; - import { ndkInstance } from "$lib/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import PublicationSection from "./PublicationSection.svelte"; + import type { PublicationTree } from "$lib/data_structures/publication_tree"; - let { rootId, publicationType, indexEvent } = $props<{ - rootId: string, + let { rootAddress, publicationType, indexEvent } = $props<{ + rootAddress: string, publicationType: string, indexEvent: NDKEvent }>(); - if (rootId !== $pharosInstance.getRootIndexId()) { - console.error("Root ID does not match parser root index ID"); + const publicationTree = getContext('publicationTree') as PublicationTree; + + // #region Loading + + // TODO: Test load handling. + + let leaves = $state([]); + let isLoading = $state(false); + let lastElementRef = $state(null); + + let observer: IntersectionObserver; + + async function loadMore(count: number) { + isLoading = true; + + for (let i = 0; i < count; i++) { + const nextItem = await publicationTree.next(); + if (nextItem.done) { + break; + } + leaves.push(nextItem.value); + } + + isLoading = false; + } + + function setLastElementRef(el: HTMLElement, i: number) { + if (i === leaves.length - 1) { + lastElementRef = el; + } } + $effect(() => { + if (!lastElementRef) { + return; + } + + observer.observe(lastElementRef!); + return () => observer.unobserve(lastElementRef!); + }); + + // #endregion + + // #region ToC + const tocBreakpoint = 1140; let activeHash = $state(page.url.hash); @@ -82,21 +121,33 @@ } } + // #endregion + onMount(() => { // Always check whether the TOC sidebar should be visible. setTocVisibilityOnResize(); - window.addEventListener("hashchange", scrollToElementWithOffset); // Also handle the case where the user lands on the page with a hash in the URL scrollToElementWithOffset(); - window.addEventListener("resize", setTocVisibilityOnResize); window.addEventListener("click", hideTocOnClick); + // Set up the intersection observer. + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isLoading) { + loadMore(8); + } + }); + }, { threshold: 0.5 }); + loadMore(16); + return () => { window.removeEventListener("hashchange", scrollToElementWithOffset); window.removeEventListener("resize", setTocVisibilityOnResize); window.removeEventListener("click", hideTocOnClick); + + observer.disconnect(); }; }); @@ -115,7 +166,7 @@ Show Table of Contents {/if} - +
- - + {#each leaves as leaf, i} + setLastElementRef(el, i)} + /> + {/each}
diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index cb6f779..4aedc8e 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,20 +1,33 @@ + -
-
+
+ {#if hasError} +
+

Error

+

{errorMessage}

+ +
+ {/if} + +
+ + +
+ + + +
{#if tooltipVisible && tooltipNode} @@ -327,7 +564,7 @@ selected={tooltipNode.id === selectedNodeId} x={tooltipX} y={tooltipY} - on:close={handleTooltipClose} + onclose={handleTooltipClose} /> {/if} diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index 276b871..db2d46b 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -1,35 +1,79 @@ +/** + * Type definitions for the Event Network visualization + * + * This module defines the core data structures used in the D3 force-directed + * graph visualization of Nostr events. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; -export interface NetworkNode extends d3.SimulationNodeDatum { - id: string; - event?: NDKEvent; - level: number; - kind: number; - title: string; - content: string; - author: string; - type: "Index" | "Content"; - naddr?: string; - nevent?: string; - x?: number; - y?: number; - isContainer?: boolean; +/** + * Base interface for nodes in a D3 force simulation + * Represents the physical properties of a node in the simulation + */ +export interface SimulationNodeDatum { + index?: number; // Node index in the simulation + x?: number; // X position + y?: number; // Y position + vx?: number; // X velocity + vy?: number; // Y velocity + fx?: number | null; // Fixed X position (when node is pinned) + fy?: number | null; // Fixed Y position (when node is pinned) +} + +/** + * Base interface for links in a D3 force simulation + * Represents connections between nodes + */ +export interface SimulationLinkDatum { + source: NodeType | string | number; // Source node or identifier + target: NodeType | string | number; // Target node or identifier + index?: number; // Link index in the simulation } -export interface NetworkLink extends d3.SimulationLinkDatum { - source: NetworkNode; - target: NetworkNode; - isSequential: boolean; +/** + * Represents a node in the event network visualization + * Extends the base simulation node with Nostr event-specific properties + */ +export interface NetworkNode extends SimulationNodeDatum { + id: string; // Unique identifier (event ID) + event?: NDKEvent; // Reference to the original NDK event + level: number; // Hierarchy level in the network + kind: number; // Nostr event kind (30040 for index, 30041/30818 for content) + title: string; // Event title + content: string; // Event content + author: string; // Author's public key + type: "Index" | "Content"; // Node type classification + naddr?: string; // NIP-19 naddr identifier + nevent?: string; // NIP-19 nevent identifier + isContainer?: boolean; // Whether this node is a container (index) } +/** + * Represents a link between nodes in the event network + * Extends the base simulation link with event-specific properties + */ +export interface NetworkLink extends SimulationLinkDatum { + source: NetworkNode; // Source node (overridden to be more specific) + target: NetworkNode; // Target node (overridden to be more specific) + isSequential: boolean; // Whether this link represents a sequential relationship +} + +/** + * Represents the complete graph data for visualization + */ export interface GraphData { - nodes: NetworkNode[]; - links: NetworkLink[]; + nodes: NetworkNode[]; // All nodes in the graph + links: NetworkLink[]; // All links in the graph } +/** + * Represents the internal state of the graph during construction + * Used to track relationships and build the final graph + */ export interface GraphState { - nodeMap: Map; - links: NetworkLink[]; - eventMap: Map; - referencedIds: Set; -} \ No newline at end of file + nodeMap: Map; // Maps event IDs to nodes + links: NetworkLink[]; // All links in the graph + eventMap: Map; // Maps event IDs to original events + referencedIds: Set; // Set of event IDs referenced by other events +} diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 2ba8e90..34731b3 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -1,27 +1,100 @@ /** - * D3 force simulation utilities for the event network + * D3 Force Simulation Utilities + * + * This module provides utilities for creating and managing D3 force-directed + * graph simulations for the event network visualization. */ import type { NetworkNode, NetworkLink } from "../types"; -import type { Simulation } from "d3"; import * as d3 from "d3"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const GRAVITY_STRENGTH = 0.05; // Strength of global gravity +const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[ForceSimulation]", ...args); + } +} + +/** + * Type definition for D3 force simulation + * Provides type safety for simulation operations + */ +export interface Simulation { + nodes(): NodeType[]; + nodes(nodes: NodeType[]): this; + alpha(): number; + alpha(alpha: number): this; + alphaTarget(): number; + alphaTarget(target: number): this; + restart(): this; + stop(): this; + tick(): this; + on(type: string, listener: (this: this) => void): this; + force(name: string): any; + force(name: string, force: any): this; +} + /** - * Updates a node's velocity + * Type definition for D3 drag events + * Provides type safety for drag operations + */ +export interface D3DragEvent { + active: number; + sourceEvent: any; + subject: Subject; + x: number; + y: number; + dx: number; + dy: number; + identifier: string | number; +} + +/** + * Updates a node's velocity by applying a force + * + * @param node - The node to update + * @param deltaVx - Change in x velocity + * @param deltaVy - Change in y velocity */ export function updateNodeVelocity( node: NetworkNode, deltaVx: number, deltaVy: number ) { + debug("Updating node velocity", { + nodeId: node.id, + currentVx: node.vx, + currentVy: node.vy, + deltaVx, + deltaVy + }); + if (typeof node.vx === "number" && typeof node.vy === "number") { node.vx = node.vx - deltaVx; node.vy = node.vy - deltaVy; + debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy }); + } else { + debug("Node velocity not defined", { nodeId: node.id }); } } /** - * Applies a logarithmic gravity force to a node + * Applies a logarithmic gravity force pulling the node toward the center + * + * The logarithmic scale ensures that nodes far from the center experience + * stronger gravity, preventing them from drifting too far away. + * + * @param node - The node to apply gravity to + * @param centerX - X coordinate of the center + * @param centerY - Y coordinate of the center + * @param alpha - Current simulation alpha (cooling factor) */ export function applyGlobalLogGravity( node: NetworkNode, @@ -35,102 +108,128 @@ export function applyGlobalLogGravity( if (distance === 0) return; - const force = Math.log(distance + 1) * 0.05 * alpha; + const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Applies gravity between connected nodes + * + * This creates a cohesive force that pulls connected nodes toward their + * collective center of gravity, creating more meaningful clusters. + * + * @param node - The node to apply connected gravity to + * @param links - All links in the network + * @param alpha - Current simulation alpha (cooling factor) */ export function applyConnectedGravity( node: NetworkNode, links: NetworkLink[], alpha: number, ) { + // Find all nodes connected to this node const connectedNodes = links - .filter( - (link) => link.source.id === node.id || link.target.id === node.id, - ) - .map((link) => (link.source.id === node.id ? link.target : link.source)); + .filter(link => link.source.id === node.id || link.target.id === node.id) + .map(link => link.source.id === node.id ? link.target : link.source); if (connectedNodes.length === 0) return; - const cogX = d3.mean(connectedNodes, (n) => n.x); - const cogY = d3.mean(connectedNodes, (n) => n.y); + // Calculate center of gravity of connected nodes + const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x); + const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y); if (cogX === undefined || cogY === undefined) return; + // Calculate force direction and magnitude const dx = (node.x ?? 0) - cogX; const dy = (node.y ?? 0) - cogY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance === 0) return; - const force = distance * 0.3 * alpha; + // Apply force proportional to distance + const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Sets up drag behavior for nodes + * + * This enables interactive dragging of nodes in the visualization. + * + * @param simulation - The D3 force simulation + * @param warmupClickEnergy - Alpha target when dragging starts (0-1) + * @returns D3 drag behavior configured for the simulation */ export function setupDragHandlers( simulation: Simulation, warmupClickEnergy: number = 0.9 ) { return d3 - .drag() - .on( - "start", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) - simulation.alphaTarget(warmupClickEnergy).restart(); - d.fx = d.x; - d.fy = d.y; - }, - ) - .on( - "drag", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - d.fx = event.x; - d.fy = event.y; - }, - ) - .on( - "end", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - }, - ); + .drag() + .on("start", (event: D3DragEvent, d: NetworkNode) => { + // Warm up simulation if it's cooled down + if (!event.active) { + simulation.alphaTarget(warmupClickEnergy).restart(); + } + // Fix node position at current location + d.fx = d.x; + d.fy = d.y; + }) + .on("drag", (event: D3DragEvent, d: NetworkNode) => { + // Update fixed position to mouse position + d.fx = event.x; + d.fy = event.y; + }) + .on("end", (event: D3DragEvent, d: NetworkNode) => { + // Cool down simulation when drag ends + if (!event.active) { + simulation.alphaTarget(0); + } + // Release fixed position + d.fx = null; + d.fy = null; + }); } /** * Creates a D3 force simulation for the network + * + * @param nodes - Array of network nodes + * @param links - Array of network links + * @param nodeRadius - Radius of node circles + * @param linkDistance - Desired distance between linked nodes + * @returns Configured D3 force simulation */ export function createSimulation( nodes: NetworkNode[], links: NetworkLink[], nodeRadius: number, linkDistance: number -) { - return d3 - .forceSimulation(nodes) - .force( - "link", - d3 - .forceLink(links) - .id((d) => d.id) - .distance(linkDistance * 0.1), - ) - .force("collide", d3.forceCollide().radius(nodeRadius * 4)); -} \ No newline at end of file +): Simulation { + debug("Creating simulation", { + nodeCount: nodes.length, + linkCount: links.length, + nodeRadius, + linkDistance + }); + + try { + // Create the simulation with nodes + const simulation = d3 + .forceSimulation(nodes) + .force( + "link", + d3.forceLink(links) + .id((d: NetworkNode) => d.id) + .distance(linkDistance * 0.1) + ) + .force("collide", d3.forceCollide().radius(nodeRadius * 4)); + + debug("Simulation created successfully"); + return simulation; + } catch (error) { + console.error("Error creating simulation:", error); + throw error; + } +} diff --git a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts index d9daa58..4f27c1f 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -1,17 +1,49 @@ +/** + * Network Builder Utilities + * + * This module provides utilities for building a network graph from Nostr events. + * It handles the creation of nodes and links, and the processing of event relationships. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import { nip19 } from "nostr-tools"; import { standardRelays } from "$lib/consts"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const INDEX_EVENT_KIND = 30040; +const CONTENT_EVENT_KIND = 30041; + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[NetworkBuilder]", ...args); + } +} + /** * Creates a NetworkNode from an NDKEvent + * + * Extracts relevant information from the event and creates a node representation + * for the visualization. + * + * @param event - The Nostr event to convert to a node + * @param level - The hierarchy level of the node (default: 0) + * @returns A NetworkNode object representing the event */ export function createNetworkNode( event: NDKEvent, level: number = 0 ): NetworkNode { - const isContainer = event.kind === 30040; + debug("Creating network node", { eventId: event.id, kind: event.kind, level }); + + const isContainer = event.kind === INDEX_EVENT_KIND; + const nodeType = isContainer ? "Index" : "Content"; + // Create the base node with essential properties const node: NetworkNode = { id: event.id, event, @@ -20,13 +52,16 @@ export function createNetworkNode( title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", content: event.content || "", author: event.pubkey || "", - kind: event.kind, - type: event?.kind === 30040 ? "Index" : "Content", + kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined + type: nodeType, }; + // Add NIP-19 identifiers if possible if (event.kind && event.pubkey) { try { const dTag = event.getMatchingTags("d")?.[0]?.[1] || ""; + + // Create naddr (NIP-19 address) for the event node.naddr = nip19.naddrEncode({ pubkey: event.pubkey, identifier: dTag, @@ -34,6 +69,7 @@ export function createNetworkNode( relays: standardRelays, }); + // Create nevent (NIP-19 event reference) for the event node.nevent = nip19.neventEncode({ id: event.id, relays: standardRelays, @@ -47,50 +83,93 @@ export function createNetworkNode( return node; } +/** + * Creates a map of event IDs to events for quick lookup + * + * @param events - Array of Nostr events + * @returns Map of event IDs to events + */ export function createEventMap(events: NDKEvent[]): Map { + debug("Creating event map", { eventCount: events.length }); + const eventMap = new Map(); events.forEach((event) => { if (event.id) { eventMap.set(event.id, event); } }); + + debug("Event map created", { mapSize: eventMap.size }); return eventMap; } +/** + * Extracts an event ID from an 'a' tag + * + * @param tag - The tag array from a Nostr event + * @returns The event ID or null if not found + */ export function extractEventIdFromATag(tag: string[]): string | null { return tag[3] || null; } /** - * Generates a color for an event based on its ID + * Generates a deterministic color for an event based on its ID + * + * This creates visually distinct colors for different index events + * while ensuring the same event always gets the same color. + * + * @param eventId - The event ID to generate a color for + * @returns An HSL color string */ export function getEventColor(eventId: string): string { + // Use first 4 characters of event ID as a hex number const num = parseInt(eventId.slice(0, 4), 16); + // Convert to a hue value (0-359) const hue = num % 360; + // Use fixed saturation and lightness for pastel colors const saturation = 70; const lightness = 75; return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } +/** + * Initializes the graph state from a set of events + * + * Creates nodes for all events and identifies referenced events. + * + * @param events - Array of Nostr events + * @returns Initial graph state + */ export function initializeGraphState(events: NDKEvent[]): GraphState { + debug("Initializing graph state", { eventCount: events.length }); + const nodeMap = new Map(); const eventMap = createEventMap(events); - // Create initial nodes + // Create initial nodes for all events events.forEach((event) => { if (!event.id) return; const node = createNetworkNode(event); nodeMap.set(event.id, node); }); + debug("Node map created", { nodeCount: nodeMap.size }); - // Build referenced IDs set + // Build set of referenced event IDs to identify root events const referencedIds = new Set(); events.forEach((event) => { - event.getMatchingTags("a").forEach((tag) => { + const aTags = event.getMatchingTags("a"); + debug("Processing a-tags for event", { + eventId: event.id, + aTagCount: aTags.length + }); + + aTags.forEach((tag) => { const id = extractEventIdFromATag(tag); if (id) referencedIds.add(id); }); }); + debug("Referenced IDs set created", { referencedCount: referencedIds.size }); return { nodeMap, @@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { }; } +/** + * Processes a sequence of nodes referenced by an index event + * + * Creates links between the index and its content, and between sequential content nodes. + * Also processes nested indices recursively up to the maximum level. + * + * @param sequence - Array of nodes in the sequence + * @param indexEvent - The index event referencing the sequence + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processSequence( sequence: NetworkNode[], indexEvent: NDKEvent, @@ -107,14 +198,15 @@ export function processSequence( state: GraphState, maxLevel: number, ): void { + // Stop if we've reached max level or have no nodes if (level >= maxLevel || sequence.length === 0) return; - // Set levels for sequence nodes + // Set levels for all nodes in the sequence sequence.forEach((node) => { node.level = level + 1; }); - // Create initial link from index to first content + // Create link from index to first content node const indexNode = state.nodeMap.get(indexEvent.id); if (indexNode && sequence[0]) { state.links.push({ @@ -124,7 +216,7 @@ export function processSequence( }); } - // Create sequential links + // Create sequential links between content nodes for (let i = 0; i < sequence.length - 1; i++) { const currentNode = sequence[i]; const nextNode = sequence[i + 1]; @@ -135,16 +227,27 @@ export function processSequence( isSequential: true, }); - processNestedIndex(currentNode, level + 1, state, maxLevel); + // Process nested indices recursively + if (currentNode.isContainer) { + processNestedIndex(currentNode, level + 1, state, maxLevel); + } } - // Process final node if it's an index + // Process the last node if it's an index const lastNode = sequence[sequence.length - 1]; if (lastNode?.isContainer) { processNestedIndex(lastNode, level + 1, state, maxLevel); } } +/** + * Processes a nested index node + * + * @param node - The index node to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processNestedIndex( node: NetworkNode, level: number, @@ -159,6 +262,14 @@ export function processNestedIndex( } } +/** + * Processes an index event and its referenced content + * + * @param indexEvent - The index event to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processIndexEvent( indexEvent: NDKEvent, level: number, @@ -167,6 +278,7 @@ export function processIndexEvent( ): void { if (level >= maxLevel) return; + // Extract the sequence of nodes referenced by this index const sequence = indexEvent .getMatchingTags("a") .map((tag) => extractEventIdFromATag(tag)) @@ -177,19 +289,53 @@ export function processIndexEvent( processSequence(sequence, indexEvent, level, state, maxLevel); } +/** + * Generates a complete graph from a set of events + * + * This is the main entry point for building the network visualization. + * + * @param events - Array of Nostr events + * @param maxLevel - Maximum hierarchy level to process + * @returns Complete graph data for visualization + */ export function generateGraph( events: NDKEvent[], maxLevel: number ): GraphData { + debug("Generating graph", { eventCount: events.length, maxLevel }); + + // Initialize the graph state const state = initializeGraphState(events); - // Process root indices - events - .filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id)) - .forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel)); + // Find root index events (those not referenced by other events) + const rootIndices = events.filter( + (e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) + ); + + debug("Found root indices", { + rootCount: rootIndices.length, + rootIds: rootIndices.map(e => e.id) + }); + + // Process each root index + rootIndices.forEach((rootIndex) => { + debug("Processing root index", { + rootId: rootIndex.id, + aTags: rootIndex.getMatchingTags("a").length + }); + processIndexEvent(rootIndex, 0, state, maxLevel); + }); - return { + // Create the final graph data + const result = { nodes: Array.from(state.nodeMap.values()), links: state.links, }; -} \ No newline at end of file + + debug("Graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length + }); + + return result; +} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index f1f7d33..39eaa06 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -1,3 +1,9 @@ +
-
+ +

Publication Network

- + {#if !loading && !error}
+ {#if !loading && !error && showSettings} -
{/if} + {#if loading}
@@ -140,12 +188,14 @@ Loading...
+ {:else if error} + {:else} - -
+
+ + +
{/if}
diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 59360c9..456a4c1 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -1,4 +1,5 @@ @layer components { + /* Legend styles - specific to visualization */ .legend-list { @apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; } @@ -21,6 +22,84 @@ } .legend-letter { - @apply absolute inset-0 flex items-center justify-center text-black text-xs; + @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; + } + + .legend-text { + @apply text-sm; + } + + /* Network visualization styles - specific to visualization */ + .network-container { + @apply flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4; + } + + .network-svg-container { + @apply h-[calc(100%-130px)] min-h-[300px]; + } + + .network-svg { + @apply w-full h-full border border-gray-300 dark:border-gray-700 rounded; + } + + .network-error { + @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; + } + + .network-error-title { + @apply font-bold text-lg; + } + + .network-error-retry { + @apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700; + } + + .network-debug { + @apply mt-4 text-sm text-gray-500; + } + + /* Zoom controls */ + .network-controls { + @apply absolute bottom-4 right-4 flex flex-col gap-2 z-10; + } + + .network-control-button { + @apply bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 + shadow-md hover:shadow-lg transition-shadow duration-200 + border border-gray-300 dark:border-gray-700; + } + + /* Tooltip styles - specific to visualization tooltips */ + .tooltip-close-btn { + @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 + rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200; + } + + .tooltip-content { + @apply space-y-2 pr-6; + } + + .tooltip-title { + @apply font-bold text-base; + } + + .tooltip-title-link { + @apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; + } + + .tooltip-metadata { + @apply text-gray-600 dark:text-gray-400 text-sm; + } + + .tooltip-summary { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-content-preview { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-help-text { + @apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; } } diff --git a/src/types/d3.d.ts b/src/types/d3.d.ts new file mode 100644 index 0000000..3d230f5 --- /dev/null +++ b/src/types/d3.d.ts @@ -0,0 +1,19 @@ +/** + * Type declarations for D3.js and related modules + * + * These declarations allow TypeScript to recognize D3 imports without requiring + * detailed type definitions. For a project requiring more type safety, consider + * using the @types/d3 package and its related sub-packages. + */ + +// Core D3 library +declare module 'd3'; + +// Force simulation module for graph layouts +declare module 'd3-force'; + +// DOM selection and manipulation module +declare module 'd3-selection'; + +// Drag behavior module +declare module 'd3-drag'; From 13a2932cde72e1ae36b450fd58d40808152ec8c4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 19 Apr 2025 00:19:53 +0200 Subject: [PATCH 11/75] 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(``); + 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(``); + } + + 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
    '; + text += '
      '; + counter = 0; + + for (const [id, content] of footnotes.entries()) { + counter++; + text += `
    1. ${content}
    2. `; + } + + text += '
    '; + } + + 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, '$1'); + 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. +

    + +
    +
    + + +
    + +
    + + + {#if showEmojiPicker} + showEmojiPicker = false} + > + insertEmoji(detail.shortcode)} /> + + {/if} +
    + {#if showHelp} +
    + {@html helpContent} +
    + {/if} +
    + + +
    + {#if submissionError} + + {/if} +
    + + \ No newline at end of file diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index e6ca543..817f99c 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -7,23 +7,23 @@ let leftMenuOpen = $state(false); - -
    - + + -
    + - - Publish - Visualize - About - Contact - - + + Publish + Visualize + About + Contact + + diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 0c85484..b85d99a 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -188,7 +188,7 @@
    @@ -246,12 +246,12 @@ {#if allowEditing && depth > 0}
    {#if hasPreviousSibling && parentId} - {/if} {#if hasNextSibling && parentId} - {/if} diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 3ec008d..3a10bc7 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -12,7 +12,7 @@ } from "flowbite-svelte"; import { getContext, onMount } from "svelte"; import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons"; - import { page } from "$app/state"; + import { page } from "$app/stores"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import PublicationSection from "./PublicationSection.svelte"; import type { PublicationTree } from "$lib/data_structures/publication_tree"; @@ -80,10 +80,12 @@ const tocBreakpoint = 1140; - let activeHash = $state(page.url.hash); + let activeHash = $state($page.url.hash); let showToc: boolean = $state(true); let showTocButton: boolean = $state(false); + let currentPath = $page.url.pathname; + function normalizeHashPath(str: string): string { return str .toLowerCase() @@ -166,41 +168,12 @@ -{#if showTocButton && !showToc} - -{/if} - - -
    +
    {#each leaves as leaf, i} {#if leaf == null} - - - Error loading content. One or more events could not be loaded. + + + Error loading content. One or more events could not be loaded. {:else} {/if} {/each} -
    +
    +{#if showTocButton && !showToc} + + Show Table of Contents +{/if} + +{#if showToc} + + + + {#each leaves as leaf} + {#if leaf && leaf.getMatchingTags('title').length > 0} + + {/if} + {/each} + + + +{/if} + diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 45475c5..4a67fc6 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -12,7 +12,8 @@ import type { } from 'asciidoctor'; import he from 'he'; import { writable, type Writable } from 'svelte/store'; -import { zettelKinds } from './consts.ts'; +import { zettelKinds } from './consts'; +import { replaceEmojisWithUnicode } from './utils/markdown/markdownItParser'; interface IndexMetadata { authors?: string[]; @@ -208,10 +209,11 @@ export default class Pharos { /** * Gets the entire HTML content of the AsciiDoc document. - * @returns The HTML content of the converted document. + * @returns The HTML content of the converted document, with emoji shortcodes replaced by Unicode. */ getHtml(): string { - return this.html?.toString() || ''; + const html = this.html?.toString() || ''; + return replaceEmojisWithUnicode(html); } /** diff --git a/src/lib/types/markdown-it-plugins.d.ts b/src/lib/types/markdown-it-plugins.d.ts new file mode 100644 index 0000000..ee267c6 --- /dev/null +++ b/src/lib/types/markdown-it-plugins.d.ts @@ -0,0 +1,11 @@ +declare module 'markdown-it-footnote' { + import MarkdownIt from 'markdown-it'; + const plugin: MarkdownIt.PluginWithParams; + export default plugin; +} + +declare module 'markdown-it-emoji' { + import MarkdownIt from 'markdown-it'; + const plugin: MarkdownIt.PluginWithParams; + export default plugin; +} \ No newline at end of file diff --git a/src/lib/types/svelte-heros.d.ts b/src/lib/types/svelte-heros.d.ts new file mode 100644 index 0000000..ef5671e --- /dev/null +++ b/src/lib/types/svelte-heros.d.ts @@ -0,0 +1,4 @@ +declare module 'svelte-heros/dist/*.svelte' { + import { SvelteComponentTyped } from 'svelte'; + export default class Icon extends SvelteComponentTyped {} +} \ No newline at end of file diff --git a/src/lib/utils/advancedMarkdownParser.ts b/src/lib/utils/advancedMarkdownParser.ts deleted file mode 100644 index 07851c7..0000000 --- a/src/lib/utils/advancedMarkdownParser.ts +++ /dev/null @@ -1,378 +0,0 @@ -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 - -// 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 HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; -const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; -const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; - -/** - * 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 += `\n`; - }); - html += '\n\n'; - } - - // Add body - html += '\n'; - bodyRows.forEach(row => { - const cells = processCells(row); - html += '\n'; - cells.forEach(cell => { - html += `\n`; - }); - html += '\n'; - }); - - html += '\n
    ${cell}
    ${cell}
    \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 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\n

    Footnotes

    \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 += `
    1. ${text}
    2. \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 { - if (!text) return ''; - - try { - // Step 1: Extract and save code blocks first - const { text: withoutCode, blocks } = processCodeBlocks(text); - let processedText = withoutCode; - - // Step 2: Process block-level elements - processedText = processTables(processedText); - processedText = processBlockquotes(processedText); - processedText = processHeadings(processedText); - processedText = processHorizontalRules(processedText); - - // Process inline elements - processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { - const escapedCode = code - .trim() - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - return `${escapedCode}`; - }); - - // Process footnotes - processedText = processFootnotes(processedText); - - // Process basic markdown (which will also handle Nostr identifiers) - processedText = await parseBasicMarkdown(processedText); - - // Step 3: Restore code blocks - processedText = restoreCodeBlocks(processedText, blocks); - - return processedText; - } catch (error) { - console.error('Error in parseAdvancedMarkdown:', error); - return `
    Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
    `; - } -} \ No newline at end of file diff --git a/src/lib/utils/basicMarkdownParser.ts b/src/lib/utils/basicMarkdownParser.ts deleted file mode 100644 index c30e442..0000000 --- a/src/lib/utils/basicMarkdownParser.ts +++ /dev/null @@ -1,182 +0,0 @@ -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; - -// List regex patterns -const UNORDERED_LIST_REGEX = /^(\s*[-*+]\s+)(.*?)$/gm; -const ORDERED_LIST_REGEX = /^(\s*\d+\.\s+)(.*?)$/gm; - -// Markdown patterns -const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; -const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; - -// URL patterns -const WSS_URL = /wss:\/\/[^\s<>"]+/g; -const DIRECT_LINK = /(?"]+)(?!["'])/g; - -// Media URL patterns -const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/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; - - -function processBasicFormatting(content: string): string { - if (!content) return ''; - - let processedText = content; - - try { - // Process Markdown images first - processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, url) => { - if (YOUTUBE_URL_REGEX.test(url)) { - const videoId = extractYouTubeVideoId(url); - if (videoId) { - return ``; - } - } - - if (VIDEO_URL_REGEX.test(url)) { - return ``; - } - - if (AUDIO_URL_REGEX.test(url)) { - return ``; - } - - return `${alt}`; - }); - - // Process Markdown links - processedText = processedText.replace(MARKDOWN_LINK, (match, text, url) => - `${text}` - ); - - // Process WebSocket URLs - processedText = processedText.replace(WSS_URL, match => { - // Remove 'wss://' from the start and any trailing slashes - const cleanUrl = match.slice(6).replace(/\/+$/, ''); - return `${match}`; - }); - - // Process direct media URLs - processedText = processedText.replace(DIRECT_LINK, match => { - if (YOUTUBE_URL_REGEX.test(match)) { - const videoId = extractYouTubeVideoId(match); - if (videoId) { - return ``; - } - } - - if (VIDEO_URL_REGEX.test(match)) { - return ``; - } - - if (AUDIO_URL_REGEX.test(match)) { - return ``; - } - - if (IMAGE_URL_REGEX.test(match)) { - return `Embedded media`; - } - - return `${match}`; - }); - - // Process text formatting - processedText = processedText.replace(BOLD_REGEX, '$2'); - processedText = processedText.replace(ITALIC_REGEX, match => { - const text = match.replace(/^_+|_+$/g, ''); - return `${text}`; - }); - processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { - const text = doubleText || singleText; - return `${text}`; - }); - - // Process hashtags - processedText = processedText.replace(HASHTAG_REGEX, '#$1'); - } catch (error) { - console.error('Error in processBasicFormatting:', error); - } - - 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 `
    ${ - lines.join('\n') - }
    `; - }); - } catch (error) { - console.error('Error in processBlockquotes:', error); - return content; - } -} - -export async function parseBasicMarkdown(text: string): Promise { - if (!text) return ''; - - try { - // Process basic text formatting first - let processedText = processBasicFormatting(text); - - // Process lists - handle ordered lists first - processedText = processedText - // Process ordered lists - .replace(ORDERED_LIST_REGEX, (match, marker, content) => { - // Count leading spaces to determine nesting level - const indent = marker.match(/^\s*/)[0].length; - const extraIndent = indent > 0 ? ` ml-${indent * 4}` : ''; - return `
  • ${content}
  • `; - }) - .replace(/.*?<\/li>\n?/gs, '
      $&
    ') - - // Process unordered lists - .replace(UNORDERED_LIST_REGEX, (match, marker, content) => { - // Count leading spaces to determine nesting level - const indent = marker.match(/^\s*/)[0].length; - const extraIndent = indent > 0 ? ` ml-${indent * 4}` : ''; - return `
  • ${content}
  • `; - }) - .replace(/.*?<\/li>\n?/gs, '
      $&
    '); - - // Process blockquotes - processedText = processBlockquotes(processedText); - - // Process paragraphs - split by double newlines and wrap in p tags - processedText = processedText - .split(/\n\n+/) - .map(para => para.trim()) - .filter(para => para.length > 0) - .map(para => `

    ${para}

    `) - .join('\n'); - - // Process Nostr identifiers last - processedText = await processNostrIdentifiers(processedText); - - return processedText; - } catch (error) { - console.error('Error in parseBasicMarkdown:', error); - return `
    Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
    `; - } -} \ No newline at end of file diff --git a/src/lib/utils/emoticons.ts b/src/lib/utils/emoticons.ts new file mode 100644 index 0000000..3d676b3 --- /dev/null +++ b/src/lib/utils/emoticons.ts @@ -0,0 +1,85 @@ +// Heroicon Svelte components (assume these are available in src/lib/icons/heroicons) +import Heart from 'svelte-heros/dist/Heart.svelte'; +import FaceSmile from 'svelte-heros/dist/FaceSmile.svelte'; +import FaceFrown from 'svelte-heros/dist/FaceFrown.svelte'; +import Fire from 'svelte-heros/dist/Fire.svelte'; +import HandRaised from 'svelte-heros/dist/HandRaised.svelte'; +import ThumbDown from 'svelte-heros/dist/ThumbDown.svelte'; +import ThumbUp from 'svelte-heros/dist/ThumbUp.svelte'; +import Eye from 'svelte-heros/dist/Eye.svelte'; +import LightBulb from 'svelte-heros/dist/LightBulb.svelte'; +import Pencil from 'svelte-heros/dist/Pencil.svelte'; +import RocketLaunch from 'svelte-heros/dist/RocketLaunch.svelte'; +import Star from 'svelte-heros/dist/Star.svelte'; +import Sun from 'svelte-heros/dist/Sun.svelte'; +import Moon from 'svelte-heros/dist/Moon.svelte'; +import Trash from 'svelte-heros/dist/Trash.svelte'; +import Trophy from 'svelte-heros/dist/Trophy.svelte'; +import Cake from 'svelte-heros/dist/Cake.svelte'; +import CurrencyDollar from 'svelte-heros/dist/CurrencyDollar.svelte'; +import CurrencyEuro from 'svelte-heros/dist/CurrencyEuro.svelte'; +import ExclamationCircle from 'svelte-heros/dist/ExclamationCircle.svelte'; + + +export const heroiconEmoticons = [ + { name: 'Heart', shortcode: ':heart:', component: Heart }, + { name: 'Smile', shortcode: ':face-smile:', component: FaceSmile }, + { name: 'Frown', shortcode: ':face-frown:', component: FaceFrown }, + { name: 'Fire', shortcode: ':fire:', component: Fire }, + { name: 'Hand Raised', shortcode: ':hand-raised:', component: HandRaised }, + { name: 'Thumb Down', shortcode: ':hand-thumb-down:', component: ThumbDown }, + { name: 'Thumb Up', shortcode: ':hand-thumb-up:', component: ThumbUp }, + { name: 'Eye', shortcode: ':eye:', component: Eye }, + { name: 'Light Bulb', shortcode: ':light-bulb:', component: LightBulb }, + { name: 'Pencil Square', shortcode: ':pencil-square:', component: Pencil }, + { name: 'Rocket', shortcode: ':rocket-launch:', component: RocketLaunch }, + { name: 'Star', shortcode: ':star:', component: Star }, + { name: 'Sun', shortcode: ':sun:', component: Sun }, + { name: 'Moon', shortcode: ':moon:', component: Moon }, + { name: 'Trash', shortcode: ':trash:', component: Trash }, + { name: 'Trophy', shortcode: ':trophy:', component: Trophy }, + { name: 'Cake', shortcode: ':cake:', component: Cake }, + { name: 'Dollar Sign', shortcode: ':dollar-sign:', component: CurrencyDollar }, + { name: 'Euro Sign', shortcode: ':euro-sign:', component: CurrencyEuro }, + { name: 'Exclamation Circle', shortcode: ':exclamation-circle:', component: ExclamationCircle } +]; + +// Unicode emojis, excluding those covered by heroicons +export const unicodeEmojis = [ + { name: 'Laughing', shortcode: ':joy:', char: '😂' }, + { name: 'Crying', shortcode: ':sob:', char: '😭' }, + { name: 'Call Me Hand', shortcode: ':call-me-hand:', char: '🤙' }, + { name: 'Waving Hand', shortcode: ':wave:', char: '👋' }, + { name: 'Pinched Fingers', shortcode: ':pinched-fingers:', char: '🤌' }, + // ...add more as needed, ensuring no overlap with heroiconEmoticons +]; + +/** + * Get the Unicode character for a given shortcode, searching both heroicon and unicode lists. + * Returns undefined if not found. + */ +export function getUnicodeEmoji(shortcode: string): string | undefined { + // Map heroicon shortcodes to a reasonable Unicode fallback + const heroiconFallbacks: Record = { + ':heart:': '❤️', + ':face-smile:': '🙂', + ':face-frown:': '🙁', + ':fire:': '🔥', + ':hand-raised:': '✋', + ':hand-thumb-down:': '👎', + ':hand-thumb-up:': '👍', + ':bell:': '🔔', + ':eye:': '👁️', + ':light-bulb:': '💡', + ':pencil-square:': '✏️', + ':rocket-launch:': '🚀', + ':star:': '⭐', + ':sun:': '☀️', + ':moon:': '🌙', + ':trash:': '🗑️', + ':trophy:': '🏆', + }; + if (heroiconFallbacks[shortcode]) return heroiconFallbacks[shortcode]; + const unicode = unicodeEmojis.find(e => e.shortcode === shortcode); + return unicode ? unicode.char : undefined; +} \ No newline at end of file diff --git a/src/lib/utils/markdown/markdownItParser.ts b/src/lib/utils/markdown/markdownItParser.ts new file mode 100644 index 0000000..1fccf21 --- /dev/null +++ b/src/lib/utils/markdown/markdownItParser.ts @@ -0,0 +1,391 @@ +import MarkdownIt from 'markdown-it'; +import footnote from 'markdown-it-footnote'; +import emoji from 'markdown-it-emoji'; +import { processNostrIdentifiers } from '../nostrUtils'; +import hljs from 'highlight.js'; +import 'highlight.js/lib/common'; +import 'highlight.js/styles/github-dark.css'; +import asciidoc from 'highlight.js/lib/languages/asciidoc'; +import { getUnicodeEmoji } from '../emoticons'; + +// Configure highlight.js +hljs.configure({ + ignoreUnescapedHTML: true +}); + +hljs.registerLanguage('asciidoc', asciidoc); + +// URL patterns for custom rendering +const WSS_URL = /wss:\/\/[^\s<>"]+/g; +const IMAGE_URL_REGEX = /https?:\/\/[^\s<]+\.(?:jpg|jpeg|gif|png|webp)(?:[^\s<]*)?/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; + +// Tracking parameters to remove +const TRACKING_PARAMS = new Set([ + // Common tracking parameters + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'ref', 'source', 'campaign', 'si', 't', 'v', 'ab_channel', + // YouTube specific + 'feature', 'hl', 'gl', 'app', 'persist_app', 'app-arg', + 'autoplay', 'loop', 'controls', 'modestbranding', 'rel', + 'showinfo', 'iv_load_policy', 'fs', 'playsinline' +]); + +/** + * Clean URL by removing tracking parameters + */ +function cleanUrl(url: string): string { + try { + const urlObj = new URL(url); + const params = new URLSearchParams(urlObj.search); + + // Remove tracking parameters + for (const param of TRACKING_PARAMS) { + params.delete(param); + } + + // For YouTube URLs, only keep the video ID + if (YOUTUBE_URL_REGEX.test(url)) { + const videoId = url.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `https://www.youtube-nocookie.com/embed/${videoId}`; + } + } + + // Reconstruct URL without tracking parameters + urlObj.search = params.toString(); + return urlObj.toString(); + } catch (e) { + // If URL parsing fails, return original URL + return url; + } +} + +// Create markdown-it instance with plugins +const md = new MarkdownIt({ + html: true, // Enable HTML tags in source + xhtmlOut: true, // Use '/' to close single tags (
    ) + breaks: true, // Convert '\n' in paragraphs into
    + linkify: true, // Autoconvert URL-like text to links + typographer: true, // Enable some language-neutral replacement + quotes beautification + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value; + } catch (__) {} + } + return ''; // use external default escaping + } +}) +.use(footnote) +.use(emoji); + +// Enable strikethrough using markdown-it's built-in rule +md.inline.ruler.after('emphasis', 'strikethrough', (state, silent) => { + let found = false, token, pos = state.pos, max = state.posMax, start = pos, marker = state.src.charCodeAt(pos); + + if (silent) return false; + + if (marker !== 0x7E/* ~ */) return false; + + let scan = pos, mem = pos; + while (scan < max && state.src.charCodeAt(scan) === 0x7E/* ~ */) { scan++; } + let len = scan - mem; + if (len < 2) return false; + + let markup = state.src.slice(mem, scan); + let end = scan; + + while (end < max) { + if (state.src.charCodeAt(end) === marker) { + if (state.src.slice(end, end + len) === markup) { + found = true; + break; + } + } + end++; + } + + if (!found) { + state.pos = scan; + return false; + } + + if (!silent) { + state.pos = mem + len; + token = state.push('s_open', 's', 1); + token.markup = markup; + + token = state.push('text', '', 0); + token.content = state.src.slice(mem + len, end); + + token = state.push('s_close', 's', -1); + token.markup = markup; + } + + state.pos = end + len; + return true; +}); + +// Custom renderer rules for Nostr identifiers +const NOSTR_PROFILE_REGEX = /(? { + const match = /^#([a-zA-Z0-9_]+)(?!\w)/.exec(state.src.slice(state.pos)); + if (!match) return false; + + if (silent) return true; + + const tag = match[1]; + state.pos += match[0].length; + + const token = state.push('hashtag', '', 0); + token.content = tag; + token.markup = '#'; + + return true; +}); + +md.renderer.rules.hashtag = (tokens, idx) => { + const tag = tokens[idx].content; + return `#${tag}`; +}; + +// Override the default link renderer to handle Nostr identifiers and special URLs +const defaultRender = md.renderer.rules.link_open || function(tokens: any[], idx: number, options: any, env: any, self: any): string { + return self.renderToken(tokens, idx, options); +}; + +md.renderer.rules.link_open = function(tokens: any[], idx: number, options: any, env: any, self: any): string { + const token = tokens[idx]; + const hrefIndex = token.attrIndex('href'); + + if (hrefIndex >= 0) { + const href = token.attrs![hrefIndex][1]; + const cleanedHref = cleanUrl(href); + + // Handle Nostr identifiers + if ((NOSTR_PROFILE_REGEX.test(cleanedHref) || NOSTR_NOTE_REGEX.test(cleanedHref)) && !cleanedHref.startsWith('nostr:')) { + token.attrs![hrefIndex][1] = `nostr:${cleanedHref}`; + } + // Handle WebSocket URLs + else if (WSS_URL.test(cleanedHref)) { + const cleanUrl = cleanedHref.slice(6).replace(/\/+$/, ''); + token.attrs![hrefIndex][1] = `https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F`; + } + // Handle media URLs + else if (YOUTUBE_URL_REGEX.test(cleanedHref)) { + const videoId = cleanedHref.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `
    `; + } + } + else if (VIDEO_URL_REGEX.test(cleanedHref)) { + return `
    `; + } + else if (AUDIO_URL_REGEX.test(cleanedHref)) { + return `
    `; + } + else if (IMAGE_URL_REGEX.test(cleanedHref)) { + return `
    Embedded media
    `; + } + else { + // Update the href with cleaned URL + token.attrs![hrefIndex][1] = cleanedHref; + } + } + + return defaultRender(tokens, idx, options, env, self); +}; + +// Override image renderer to handle media URLs +const defaultImageRender = md.renderer.rules.image || function(tokens: any[], idx: number, options: any, env: any, self: any): string { + return self.renderToken(tokens, idx, options); +}; + +md.renderer.rules.image = function(tokens: any[], idx: number, options: any, env: any, self: any): string { + const token = tokens[idx]; + const srcIndex = token.attrIndex('src'); + + if (srcIndex >= 0) { + const src = token.attrs![srcIndex][1]; + const cleanedSrc = cleanUrl(src); + const alt = token.attrs![token.attrIndex('alt')]?.[1] || ''; + + if (YOUTUBE_URL_REGEX.test(cleanedSrc)) { + const videoId = cleanedSrc.match(YOUTUBE_URL_REGEX)?.[1]; + if (videoId) { + return `
    `; + } + } + + if (VIDEO_URL_REGEX.test(cleanedSrc)) { + return `
    `; + } + + if (AUDIO_URL_REGEX.test(cleanedSrc)) { + return `
    `; + } + + // Update the src with cleaned URL + token.attrs![srcIndex][1] = cleanedSrc; + } + + return defaultImageRender(tokens, idx, options, env, self); +}; + +// Add custom rule for alternate heading style +md.block.ruler.before('heading', 'alternate_heading', (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + const content = state.src.slice(start, max).trim(); + + // Check if this line is followed by = or - underline + if (startLine + 1 >= endLine) return false; + + const nextStart = state.bMarks[startLine + 1] + state.tShift[startLine + 1]; + const nextMax = state.eMarks[startLine + 1]; + const nextContent = state.src.slice(nextStart, nextMax).trim(); + + // Check if next line is all = or - + if (!/^[=-]+$/.test(nextContent)) return false; + + // Determine heading level (h1 for =, h2 for -) + const level = nextContent[0] === '=' ? 1 : 2; + + if (silent) return true; + + // Create heading token + state.line = startLine + 2; + + const openToken = state.push('heading_open', 'h' + level, 1); + openToken.markup = '#'.repeat(level); + + const inlineToken = state.push('inline', '', 0); + inlineToken.content = content; + inlineToken.map = [startLine, startLine + 2]; + + const closeToken = state.push('heading_close', 'h' + level, -1); + closeToken.markup = '#'.repeat(level); + + return true; +}); + +// Override the default code inline rule to only support single backticks +md.inline.ruler.after('backticks', 'code_inline', (state, silent) => { + let start = state.pos; + let max = state.posMax; + let marker = state.src.charCodeAt(start); + + // Check for single backtick + if (marker !== 0x60/* ` */) return false; + + // Find the end of the code span + let pos = start + 1; + + // Find the closing backtick + while (pos < max) { + if (state.src.charCodeAt(pos) === 0x60/* ` */) { + pos++; + break; + } + pos++; + } + + if (pos >= max) return false; + + const content = state.src.slice(start + 1, pos - 1); + + if (!content) return false; + + if (silent) return true; + + state.pos = pos; + + const token = state.push('code_inline', 'code', 0); + token.content = content; + token.markup = '`'; + + return true; +}); + +/** + * Replace emoji shortcodes in text with Unicode wrapped in ... + */ +export function replaceEmojisWithUnicode(text: string): string { + return text.replace(/(:[a-z0-9_\-]+:)/gi, (match) => { + const unicode = getUnicodeEmoji(match); + if (unicode) { + return `${unicode}`; + } + return match; + }); +} + +/** + * Parse markdown text with markdown-it and custom processing + */ +export async function parseMarkdown(text: string): Promise { + if (!text) return ''; + + try { + // First pass: Process with markdown-it + let processedText = md.render(text); + + // Second pass: Process Nostr identifiers + processedText = await processNostrIdentifiers(processedText); + + // Third pass: Replace emoji shortcodes with Unicode + processedText = replaceEmojisWithUnicode(processedText); + + // Add custom classes to elements + processedText = processedText + // Add classes to headings + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '

    ') + .replace(/

    /g, '
    ') + .replace(/
    /g, '
    ') + // Add classes to paragraphs + .replace(/

    /g, '

    ') + // Add classes to blockquotes + .replace(/

    /g, '
    ') + // Add classes to code blocks + .replace(/
    /g, '
    ')
    +      // Add classes to inline code
    +      .replace(//g, '')
    +      // Add classes to links
    +      .replace(/')
    +      .replace(/
      /g, '
        ') + // Add classes to list items + .replace(/
      1. /g, '
      2. ') + // Add classes to horizontal rules + .replace(/
        /g, '
        ') + // Add classes to footnotes + .replace(/
      3. ') + // Add classes to images + .replace(//g, '') + .replace(//g, '') + .replace(//g, '') + .replace(/
        /g, '') + .replace(//g, ''); + + return processedText; + } catch (error) { + console.error('Error in parseMarkdown:', error); + return `
        Error processing markdown: ${error instanceof Error ? error.message : 'Unknown error'}
        `; + } +} \ No newline at end of file diff --git a/src/lib/utils/markdownTestfile.md b/src/lib/utils/markdown/markdownTestfile.md similarity index 96% rename from src/lib/utils/markdownTestfile.md rename to src/lib/utils/markdown/markdownTestfile.md index cb35194..65b5423 100644 --- a/src/lib/utils/markdownTestfile.md +++ b/src/lib/utils/markdown/markdownTestfile.md @@ -5,7 +5,9 @@ This is a test 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. +This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser. + +Try out some emojisface with smiling :facesmile:, call-me hand :call-me-hand:, and trophy :trophy:. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index dd838c9..b053adf 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -4,11 +4,11 @@ import { Button, P } from 'flowbite-svelte'; -
        -

        404 - Page Not Found

        -

        The page you are looking for does not exist or has been moved.

        -
        - - +
        +

        404 - Page Not Found

        +

        The page you are looking for does not exist or has been moved.

        +
        + +
        diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index eb0abfc..2777361 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -6,206 +6,223 @@ const isVersionKnown = appVersion !== "development"; -
        -
        -
        - About the Library of Alexandria +
        +
        +
        + About the Library of Alexandria {#if isVersionKnown} - Version: {appVersion} + Version: {appVersion} {/if}
        - Alexandria icon + Alexandria icon -

        +

        Alexandria is a reader and writer for curated publications (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the GitCitadel project team.

        -

        +

        Please submit support issues on the Alexandria repo pageAlexandria repo page and follow us on GitHub and GitHub and Geyserfund.

        -

        +

        We are easiest to contact over our Nostr address npub1s3h…75wznpub1s3h…75wz.

        - Overview + Overview -

        - Alexandria opens up to the landing page, where the user +

        + Alexandria opens up to the landing page, where the user can: login (top-right), select whether to only view the publications - hosted on the thecitadel document relay or add in their own relays, and scroll/search the publications.

        -
        +
        Landing page Relay selection
        -

        +

        There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).

        -

        +

        If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document.

        -

        +

        Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)

        -
        +
        ToC icon Table of contents example
        - Typical use cases + Typical use cases - For e-books + For e-books -

        +

        The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.

        -

        +

        An example of a book is Jane Eyre

        -
        +
        Jane Eyre, by Charlotte Brontë
        - For scientific papers + For scientific papers -

        +

        Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.

        -

        +

        Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.

        -

        +

        Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.

        -

        +

        An example of a research paper is Less Partnering, Less Children, or Both?

        -
        +
        Research paper
        - For documentation + For documentation -

        +

        Our own team uses Alexandria to document the app, to display our blog entriesblog entries, as well as to store copies of our most interesting technical specifications.

        -
        +
        Documentation
        - For wiki pages + For wiki pages -

        +

        Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages, such as this - one about the Sybil utility use the same + one about the Sybil utility use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content.

        -

        +

        Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored. diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index b2798e0..1549469 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -6,9 +6,10 @@ import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; // @ts-ignore - Workaround for Svelte component import issue import LoginModal from '$lib/components/LoginModal.svelte'; - import { parseAdvancedMarkdown } from '$lib/utils/advancedMarkdownParser'; + import { parseAdvancedMarkdown } from '$lib/utils/markdown/advancedMarkdownParser'; import { nip19 } from 'nostr-tools'; import { getMimeTags } from '$lib/utils/mime'; + import MarkdownForm from '$lib/components/MarkdownForm.svelte'; // Function to close the success message function closeSuccessMessage() { @@ -68,29 +69,16 @@ isExpanded = !isExpanded; } - async function handleSubmit(e: Event) { - // Prevent form submission - e.preventDefault(); - + function handleIssueSubmit(subject: string, content: string) { + // Set the local state for subject/content if needed + // subject = subject; + // content = content; + // Call the original handleSubmit logic, but without the event if (!subject || !content) { submissionError = 'Please fill in all fields'; return; } - - // Check if user is logged in - if (!$ndkSignedIn) { - // Save form data - savedFormData = { - subject, - content - }; - - // Show login modal - showLoginModal = true; - return; - } - - // Show confirmation dialog + // Show confirmation dialog or proceed with submission as before showConfirmDialog = true; } @@ -268,8 +256,8 @@ } -

        -
        +
        +
        Contact GitCitadel

        @@ -286,193 +274,92 @@ 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.

        -
        -
        - - -
        - -
        - -
        -
        -
        -
          - -
        • - -
        • -
        -
        - -
        - {#if activeTab === 'write'} -
        - {:else} -
        - - - + + + + - - + + {#if rootIndexId} - + {/if} {/if} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 142eeb5..794af2b 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -115,14 +115,14 @@ }); -
        +
        -
        -

        Publication Network

        +
        +

        Publication Network

        {#if loading} -
        +
        - - \ No newline at end of file diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte index 75324a9..d8c28be 100644 --- a/src/lib/components/EventLimitControl.svelte +++ b/src/lib/components/EventLimitControl.svelte @@ -29,23 +29,23 @@ } -
        -