51 changed files with 6025 additions and 1377 deletions
@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
|
||||
--- |
||||
description: |
||||
globs: |
||||
alwaysApply: true |
||||
--- |
||||
# Project Alexandria |
||||
|
||||
You are senior full-stack software engineer with 20 years of experience writing web apps. You have been working with the Svelte web development framework for 8 years, since it was first released, and you currently are a leading expert on Svelte 5 and SvelteKit 2. Additionally, you are a pioneer developer on the Nostr protocol, and have developing production-quality Nostr apps for 4 years. |
||||
|
||||
## Project Overview |
||||
|
||||
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree. |
||||
|
||||
### Reader Features |
||||
|
||||
In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view. |
||||
|
||||
### Tech Stack |
||||
|
||||
Svelte components in Alexandria use TypeScript exclusively over plain JavaScript. Styles are defined via Tailwind 4 utility classes, and some custom utility classes are defined in [app.css](mdc:src/app.css). The app runs on Deno, but maintains compatibility with Node.js. |
||||
|
||||
## General Guidelines |
||||
|
||||
When responding to prompts, adhere to the following rules: |
||||
|
||||
- Avoid making apologetic or conciliatory statements. |
||||
- Avoid verbose responses; be direct and to the point. |
||||
- Provide links to relevant documentation so that I can do further reading on the tools or techniques discussed and used in your responses. |
||||
- When I tell you a response is incorrect, avoid simply agreeing with me; think about the points raised and provide well-reasoned explanations for your subsequent responses. |
||||
- Avoid proposing code edits unless I specifically tell you to do so. |
||||
- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily. |
||||
|
||||
## Code Style |
||||
|
||||
Observe the following style guidelines when writing code: |
||||
|
||||
### General Guidance |
||||
|
||||
- Use PascalCase names for Svelte 5 components and their files. |
||||
- Use snake_case names for plain TypeScript files. |
||||
- Use comments sparingly; code should be self-documenting. |
||||
|
||||
### JavaScript/TypeScript |
||||
|
||||
- Use an indentation size of 2 spaces. |
||||
- Use camelCase names for variables, classes, and functions. |
||||
- Give variables, classes, and functions descriptive names that reflect their content and purpose. |
||||
- Use Svelte 5 features, such as runes. Avoid using legacy Svelte 4 features. |
||||
- Write JSDoc comments for all functions. |
||||
- Use blocks enclosed by curly brackets when writing control flow expressions such as `for` and `while` loops, and `if` and `switch` statements. |
||||
- Begin `case` expressions in a `switch` statement at the same indentation level as the `switch` itself. Indent code within a `case` block. |
||||
- Limit line length to 100 characters; break statements across lines if necessary. |
||||
- Default to single quotes. |
||||
|
||||
### HTML |
||||
|
||||
- Use an indentation size of 2 spaces. |
||||
- Break long tags across multiple lines. |
||||
- Use Tailwind 4 utility classes for styling. |
||||
- Default to single quotes. |
||||
|
||||
|
||||
@ -1,3 +1,6 @@
@@ -1,3 +1,6 @@
|
||||
{ |
||||
"editor.tabSize": 2 |
||||
"editor.tabSize": 2, |
||||
"files.associations": { |
||||
"*.css": "postcss" |
||||
} |
||||
} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
<script lang="ts"> |
||||
import { Button } from "flowbite-svelte"; |
||||
import { loginWithExtension, ndkSignedIn } from '$lib/ndk'; |
||||
|
||||
const { show = false, onClose = () => {}, onLoginSuccess = () => {} } = $props<{ |
||||
show?: boolean; |
||||
onClose?: () => void; |
||||
onLoginSuccess?: () => void; |
||||
}>(); |
||||
|
||||
let signInFailed = $state<boolean>(false); |
||||
let errorMessage = $state<string>(''); |
||||
|
||||
$effect(() => { |
||||
if ($ndkSignedIn && show) { |
||||
onLoginSuccess(); |
||||
onClose(); |
||||
} |
||||
}); |
||||
|
||||
async function handleSignInClick() { |
||||
try { |
||||
signInFailed = false; |
||||
errorMessage = ''; |
||||
|
||||
const user = await loginWithExtension(); |
||||
if (!user) { |
||||
throw new Error('The NIP-07 extension did not return a user.'); |
||||
} |
||||
} catch (e: unknown) { |
||||
console.error(e); |
||||
signInFailed = true; |
||||
errorMessage = (e as Error)?.message ?? 'Failed to sign in. Please try again.'; |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if show} |
||||
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50"> |
||||
<div class="relative w-auto my-6 mx-auto max-w-3xl"> |
||||
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none"> |
||||
<!-- Header --> |
||||
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"> |
||||
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">Login Required</h3> |
||||
<button |
||||
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none" |
||||
onclick={onClose} |
||||
> |
||||
<span class="bg-transparent text-gray-500 dark:text-gray-400 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span> |
||||
</button> |
||||
</div> |
||||
|
||||
<!-- Body --> |
||||
<div class="relative p-6 flex-auto"> |
||||
<p class="text-base leading-relaxed text-gray-500 dark:text-gray-400 mb-6"> |
||||
You need to be logged in to submit an issue. Your form data will be preserved. |
||||
</p> |
||||
<div class="flex flex-col space-y-4"> |
||||
<div class="flex justify-center"> |
||||
<Button |
||||
color="primary" |
||||
onclick={handleSignInClick} |
||||
> |
||||
Sign in with Extension |
||||
</Button> |
||||
</div> |
||||
{#if signInFailed} |
||||
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900 rounded"> |
||||
{errorMessage} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
@ -1,28 +1,37 @@
@@ -1,28 +1,37 @@
|
||||
<script lang="ts"> |
||||
import { DarkMode, Navbar, NavLi, NavUl, NavHamburger, NavBrand } from 'flowbite-svelte'; |
||||
import Login from './Login.svelte'; |
||||
import { |
||||
DarkMode, |
||||
Navbar, |
||||
NavLi, |
||||
NavUl, |
||||
NavHamburger, |
||||
NavBrand, |
||||
} from "flowbite-svelte"; |
||||
import Login from "./Login.svelte"; |
||||
|
||||
let { class: className = '' } = $props(); |
||||
let { class: className = "" } = $props(); |
||||
|
||||
let leftMenuOpen = $state(false); |
||||
</script> |
||||
|
||||
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}> |
||||
<div class='flex flex-grow justify-between'> |
||||
<NavBrand href='/'> |
||||
<div class="flex flex-grow justify-between"> |
||||
<NavBrand href="/"> |
||||
<h1>Alexandria</h1> |
||||
</NavBrand> |
||||
</div> |
||||
<div class='flex md:order-2'> |
||||
<div class="flex md:order-2"> |
||||
<Login /> |
||||
<NavHamburger class='btn-leather' /> |
||||
<NavHamburger class="btn-leather" /> |
||||
</div> |
||||
<NavUl class='ul-leather'> |
||||
<NavLi href='/new/edit'>Publish</NavLi> |
||||
<NavLi href='/visualize'>Visualize</NavLi> |
||||
<NavLi href='/about'>About</NavLi> |
||||
<NavLi> |
||||
<DarkMode btnClass='btn-leather p-0'/> |
||||
<NavUl class="ul-leather"> |
||||
<NavLi href="/new/edit">Publish</NavLi> |
||||
<NavLi href="/visualize">Visualize</NavLi> |
||||
<NavLi href="/start">Getting Started</NavLi> |
||||
<NavLi href="/about">About</NavLi> |
||||
<NavLi href="/contact">Contact</NavLi> |
||||
<NavLi> |
||||
<DarkMode btnClass="btn-leather p-0" /> |
||||
</NavLi> |
||||
</NavUl> |
||||
</Navbar> |
||||
|
||||
@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
|
||||
<script lang='ts'> |
||||
import type { PublicationTree } from "$lib/data_structures/publication_tree"; |
||||
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; |
||||
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { TextPlaceholder } from "flowbite-svelte"; |
||||
import { getContext } from "svelte"; |
||||
import type { Asciidoctor, Document } from "asciidoctor"; |
||||
|
||||
let { |
||||
address, |
||||
rootAddress, |
||||
leaves, |
||||
ref, |
||||
}: { |
||||
address: string, |
||||
rootAddress: string, |
||||
leaves: Array<NDKEvent | null>, |
||||
ref: (ref: HTMLElement) => void, |
||||
} = $props(); |
||||
|
||||
const publicationTree: PublicationTree = getContext('publicationTree'); |
||||
const asciidoctor: Asciidoctor = getContext('asciidoctor'); |
||||
|
||||
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||
await publicationTree.getEvent(address)); |
||||
|
||||
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||
await publicationTree.getEvent(rootAddress)); |
||||
|
||||
let publicationType: Promise<string | undefined> = $derived.by(async () => |
||||
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); |
||||
|
||||
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => |
||||
await publicationTree.getHierarchy(address)); |
||||
|
||||
let leafTitle: Promise<string | undefined> = $derived.by(async () => |
||||
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); |
||||
|
||||
let leafContent: Promise<string | Document> = $derived.by(async () => |
||||
asciidoctor.convert((await leafEvent)?.content ?? '')); |
||||
|
||||
let previousLeafEvent: NDKEvent | null = $derived.by(() => { |
||||
let index: number; |
||||
let event: NDKEvent | null = null; |
||||
let decrement = 1; |
||||
|
||||
do { |
||||
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); |
||||
if (index === 0) { |
||||
return null; |
||||
} |
||||
event = leaves[index - decrement++]; |
||||
} while (event == null && index - decrement >= 0); |
||||
|
||||
return event; |
||||
}); |
||||
|
||||
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { |
||||
if (!previousLeafEvent) { |
||||
return null; |
||||
} |
||||
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); |
||||
}); |
||||
|
||||
let divergingBranches = $derived.by(async () => { |
||||
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); |
||||
|
||||
const branches: [NDKEvent, number][] = []; |
||||
|
||||
if (!previousLeafHierarchyValue) { |
||||
for (let i = 0; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
return branches; |
||||
} |
||||
|
||||
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); |
||||
|
||||
// Find the first diverging node. |
||||
let divergingIndex = 0; |
||||
while ( |
||||
divergingIndex < minLength && |
||||
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() |
||||
) { |
||||
divergingIndex++; |
||||
} |
||||
|
||||
// Add all branches from the first diverging node to the current leaf. |
||||
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
|
||||
return branches; |
||||
}); |
||||
|
||||
let sectionRef: HTMLElement; |
||||
|
||||
$effect(() => { |
||||
if (!sectionRef) { |
||||
return; |
||||
} |
||||
|
||||
ref(sectionRef); |
||||
}); |
||||
</script> |
||||
|
||||
<section bind:this={sectionRef} class='publication-leather content-visibility-auto'> |
||||
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} |
||||
<TextPlaceholder size='xxl' /> |
||||
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} |
||||
{#each divergingBranches as [branch, depth]} |
||||
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} |
||||
{/each} |
||||
{#if leafTitle} |
||||
{@const leafDepth = leafHierarchy.length - 1} |
||||
{@render sectionHeading(leafTitle, leafDepth)} |
||||
{/if} |
||||
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} |
||||
{/await} |
||||
</section> |
||||
@ -1,16 +1,55 @@
@@ -1,16 +1,55 @@
|
||||
export enum LazyStatus { |
||||
Pending, |
||||
Resolved, |
||||
Error, |
||||
} |
||||
|
||||
export class Lazy<T> { |
||||
#value?: T; |
||||
#value: T | null = null; |
||||
#resolver: () => Promise<T>; |
||||
#pendingPromise: Promise<T | null> | null = null; |
||||
|
||||
status: LazyStatus; |
||||
|
||||
constructor(resolver: () => Promise<T>) { |
||||
this.#resolver = resolver; |
||||
this.status = LazyStatus.Pending; |
||||
} |
||||
|
||||
async value(): Promise<T> { |
||||
if (!this.#value) { |
||||
this.#value = await this.#resolver(); |
||||
/** |
||||
* Resolves the lazy object and returns the value. |
||||
*
|
||||
* @returns The resolved value. |
||||
*
|
||||
* @remarks Lazy object resolution is performed as an atomic operation. If a resolution has |
||||
* already been requested when this function is invoked, the pending promise from the earlier |
||||
* invocation is returned. Thus, all calls to this function before it is resolved will depend on |
||||
* a single resolution. |
||||
*/ |
||||
value(): Promise<T | null> { |
||||
if (this.status === LazyStatus.Resolved) { |
||||
return Promise.resolve(this.#value); |
||||
} |
||||
|
||||
if (this.#pendingPromise) { |
||||
return this.#pendingPromise; |
||||
} |
||||
|
||||
return this.#value; |
||||
this.#pendingPromise = this.#resolve(); |
||||
return this.#pendingPromise; |
||||
} |
||||
|
||||
async #resolve(): Promise<T | null> { |
||||
try { |
||||
this.#value = await this.#resolver(); |
||||
this.status = LazyStatus.Resolved; |
||||
return this.#value; |
||||
} catch (error) { |
||||
this.status = LazyStatus.Error; |
||||
console.error(error); |
||||
return null; |
||||
} finally { |
||||
this.#pendingPromise = null; |
||||
} |
||||
} |
||||
} |
||||
@ -1,30 +1,75 @@
@@ -1,30 +1,75 @@
|
||||
<!-- Legend Component (Svelte 5, Runes Mode) --> |
||||
|
||||
<script lang="ts"> |
||||
export let className: string = ""; |
||||
import {Button} from 'flowbite-svelte'; |
||||
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
||||
let { |
||||
collapsedOnInteraction = false, |
||||
className = "" |
||||
} = $props<{collapsedOnInteraction: boolean, className: string}>(); |
||||
|
||||
let expanded = $state(true); |
||||
|
||||
$effect(() => { |
||||
if (collapsedOnInteraction) { |
||||
expanded = false; |
||||
} |
||||
}); |
||||
|
||||
function toggle() { |
||||
expanded = !expanded; |
||||
} |
||||
</script> |
||||
|
||||
<div class="leather-legend {className}"> |
||||
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3> |
||||
<ul class="legend-list"> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)"> |
||||
</span> |
||||
<span class="legend-letter">I</span> |
||||
</div> |
||||
<span>Index events (kind 30040) - Each with a unique pastel color</span> |
||||
</li> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle content"></span> |
||||
<span class="legend-letter">C</span> |
||||
</div> |
||||
<span>Content events (kinds 30041, 30818) - Publication sections</span> |
||||
</li> |
||||
<li class="legend-item"> |
||||
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
||||
<path d="M4 12h16M16 6l6 6-6 6" class="network-link-leather" /> |
||||
</svg> |
||||
<span>Arrows indicate reading/sequence order</span> |
||||
</li> |
||||
</ul> |
||||
<div class={`leather-legend ${className}`}> |
||||
<div class="flex items-center justify-between space-x-3"> |
||||
<h3 class="h-leather">Legend</h3> |
||||
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" > |
||||
{#if expanded} |
||||
<CaretUpOutline /> |
||||
{:else} |
||||
<CaretDownOutline /> |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
|
||||
{#if expanded} |
||||
<ul class="legend-list"> |
||||
<!-- Index event node --> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span |
||||
class="legend-circle" |
||||
style="background-color: hsl(200, 70%, 75%)" |
||||
> |
||||
<span class="legend-letter">I</span> |
||||
</span> |
||||
</div> |
||||
<span class="legend-text">Index events (kind 30040) - Each with a unique pastel color</span> |
||||
</li> |
||||
|
||||
<!-- Content event node --> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle content"> |
||||
<span class="legend-letter">C</span> |
||||
</span> |
||||
</div> |
||||
<span class="legend-text">Content events (kinds 30041, 30818) - Publication sections</span> |
||||
</li> |
||||
|
||||
<!-- Link arrow --> |
||||
<li class="legend-item"> |
||||
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
||||
<path |
||||
d="M4 12h16M16 6l6 6-6 6" |
||||
class="network-link-leather" |
||||
stroke-width="2" |
||||
fill="none" |
||||
/> |
||||
</svg> |
||||
<span class="legend-text">Arrows indicate reading/sequence order</span> |
||||
</li> |
||||
</ul> |
||||
{/if} |
||||
</div> |
||||
|
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<!-- |
||||
Settings Component |
||||
--> |
||||
<script lang="ts"> |
||||
import {Button} from 'flowbite-svelte'; |
||||
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
||||
import { fly } from "svelte/transition"; |
||||
import { quintOut } from "svelte/easing"; |
||||
import EventLimitControl from "$lib/components/EventLimitControl.svelte"; |
||||
import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; |
||||
import { networkFetchLimit } from "$lib/state"; |
||||
|
||||
let { |
||||
count = 0, |
||||
onupdate |
||||
} = $props<{count: number, onupdate: () => void}>(); |
||||
|
||||
let expanded = $state(false); |
||||
|
||||
function toggle() { |
||||
expanded = !expanded; |
||||
} |
||||
/** |
||||
* Handles updates to visualization settings |
||||
*/ |
||||
function handleLimitUpdate() { |
||||
onupdate(); |
||||
} |
||||
</script> |
||||
|
||||
<div class="leather-legend sm:!right-1 sm:!left-auto" > |
||||
<div class="flex items-center justify-between space-x-3"> |
||||
<h3 class="h-leather">Settings</h3> |
||||
<Button color='none' outline size='xs' onclick={toggle} class="rounded-full" > |
||||
{#if expanded} |
||||
<CaretUpOutline /> |
||||
{:else} |
||||
<CaretDownOutline /> |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
|
||||
{#if expanded} |
||||
<div class="space-y-4"> |
||||
<span class="leather bg-transparent legend-text"> |
||||
Showing {count} events from {$networkFetchLimit} headers |
||||
</span> |
||||
<EventLimitControl on:update={handleLimitUpdate} /> |
||||
<EventRenderLevelLimit on:update={handleLimitUpdate} /> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
@ -1,35 +1,79 @@
@@ -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<NodeType> { |
||||
source: NodeType | string | number; // Source node or identifier
|
||||
target: NodeType | string | number; // Target node or identifier
|
||||
index?: number; // Link index in the simulation
|
||||
} |
||||
|
||||
/** |
||||
* 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)
|
||||
} |
||||
|
||||
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> { |
||||
source: NetworkNode; |
||||
target: NetworkNode; |
||||
isSequential: boolean; |
||||
/** |
||||
* Represents a link between nodes in the event network |
||||
* Extends the base simulation link with event-specific properties |
||||
*/ |
||||
export interface NetworkLink extends SimulationLinkDatum<NetworkNode> { |
||||
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<string, NetworkNode>; |
||||
links: NetworkLink[]; |
||||
eventMap: Map<string, NDKEvent>; |
||||
referencedIds: Set<string>; |
||||
nodeMap: Map<string, NetworkNode>; // Maps event IDs to nodes
|
||||
links: NetworkLink[]; // All links in the graph
|
||||
eventMap: Map<string, NDKEvent>; // Maps event IDs to original events
|
||||
referencedIds: Set<string>; // Set of event IDs referenced by other events
|
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
# Markup Support in Alexandria |
||||
|
||||
Alexandria supports multiple markup formats for different use cases. Below is a summary of the supported tags and features for each parser, as well as the formats used for publications and wikis. |
||||
|
||||
## Basic Markup Parser |
||||
|
||||
The **basic markup parser** follows the [Nostr best-practice guidelines](https://github.com/nostrability/nostrability/issues/146) and supports: |
||||
|
||||
- **Headers:** |
||||
- ATX-style: `# H1` through `###### H6` |
||||
- Setext-style: `H1\n=====` |
||||
- **Bold:** `*bold*` or `**bold**` |
||||
- **Italic:** `_italic_` or `__italic__` |
||||
- **Strikethrough:** `~strikethrough~` or `~~strikethrough~~` |
||||
- **Blockquotes:** `> quoted text` |
||||
- **Unordered lists:** `* item` |
||||
- **Ordered lists:** `1. item` |
||||
- **Links:** `[text](url)` |
||||
- **Images:** `` |
||||
- **Hashtags:** `#hashtag` |
||||
- **Nostr identifiers:** npub, nprofile, nevent, naddr, note, with or without `nostr:` prefix (note is deprecated) |
||||
- **Emoji shortcodes:** `:smile:` will render as 😄 |
||||
|
||||
## Advanced Markup Parser |
||||
|
||||
The **advanced markup parser** includes all features of the basic parser, plus: |
||||
|
||||
- **Inline code:** `` `code` `` |
||||
- **Syntax highlighting:** for code blocks in many programming languages (from [highlight.js](https://highlightjs.org/)) |
||||
- **Tables:** Pipe-delimited tables with or without headers |
||||
- **Footnotes:** `[^1]` or `[^Smith]`, which should appear where the footnote shall be placed, and will be displayed as unique, consecutive numbers |
||||
- **Footnote References:** `[^1]: footnote text` or `[^Smith]: Smith, Adam. 1984 "The Wiggle Mysteries`, which will be listed in order, at the bottom of the event, with back-reference links to the footnote, and text footnote labels appended |
||||
- **Wikilinks:** `[[NIP-54]]` will render as a hyperlink and goes to [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54) (Will later go to our new disambiguation page.) |
||||
|
||||
## Publications and Wikis |
||||
|
||||
**Publications** and **wikis** in Alexandria use **AsciiDoc** as their primary markup language, not Markdown. |
||||
|
||||
AsciiDoc supports a much broader set of formatting, semantic, and structural features, including: |
||||
|
||||
- Section and document structure |
||||
- Advanced tables, callouts, admonitions |
||||
- Cross-references, footnotes, and bibliography |
||||
- Custom attributes and macros |
||||
- And much more |
||||
|
||||
For more information on AsciiDoc, see the [AsciiDoc documentation](https://asciidoc.org/). |
||||
|
||||
--- |
||||
|
||||
**Note:** |
||||
- The markdown parsers are primarily used for comments, issues, and other user-generated content. |
||||
- Publications and wikis are rendered using AsciiDoc for maximum expressiveness and compatibility. |
||||
- All URLs are sanitized to remove tracking parameters, and YouTube links are presented in a clean, privacy-friendly format. |
||||
- [Here is a test markup file](/tests/integration/markupTestfile.md) that you can use to test out the parser and see how things should be formatted. |
||||
@ -0,0 +1,389 @@
@@ -0,0 +1,389 @@
|
||||
import { parseBasicmarkup } from './basicMarkupParser'; |
||||
import hljs from 'highlight.js'; |
||||
import 'highlight.js/lib/common'; // Import common languages
|
||||
import 'highlight.js/styles/github-dark.css'; // Dark theme only
|
||||
|
||||
// Register common languages
|
||||
hljs.configure({ |
||||
ignoreUnescapedHTML: true |
||||
}); |
||||
|
||||
// Regular expressions for advanced markup elements
|
||||
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; |
||||
const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm; |
||||
const INLINE_CODE_REGEX = /`([^`\n]+)`/g; |
||||
const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; |
||||
const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; |
||||
const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; |
||||
const CODE_BLOCK_REGEX = /^```(\w*)$/; |
||||
|
||||
/** |
||||
* Process headings (both styles) |
||||
*/ |
||||
function processHeadings(content: string): string { |
||||
// Tailwind classes for each heading level
|
||||
const headingClasses = [ |
||||
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
|
||||
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
|
||||
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
|
||||
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
|
||||
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
|
||||
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
|
||||
]; |
||||
|
||||
// Process ATX-style headings (# Heading)
|
||||
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => { |
||||
const headingLevel = Math.min(level.length, 6); |
||||
const classes = headingClasses[headingLevel - 1]; |
||||
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; |
||||
}); |
||||
|
||||
// Process Setext-style headings (Heading\n====)
|
||||
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { |
||||
const headingLevel = level[0] === '=' ? 1 : 2; |
||||
const classes = headingClasses[headingLevel - 1]; |
||||
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`; |
||||
}); |
||||
|
||||
return processedContent; |
||||
} |
||||
|
||||
/** |
||||
* Process tables |
||||
*/ |
||||
function processTables(content: string): string { |
||||
try { |
||||
if (!content) return ''; |
||||
|
||||
return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => { |
||||
try { |
||||
// Split into rows and clean up
|
||||
const rows = match.split('\n').filter(row => row.trim()); |
||||
if (rows.length < 1) return match; |
||||
|
||||
// Helper to process a row into cells
|
||||
const processCells = (row: string): string[] => { |
||||
return row |
||||
.split('|') |
||||
.slice(1, -1) // Remove empty cells from start/end
|
||||
.map(cell => cell.trim()); |
||||
}; |
||||
|
||||
// Check if second row is a delimiter row (only hyphens)
|
||||
const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); |
||||
|
||||
// Extract header and body rows
|
||||
let headerCells: string[] = []; |
||||
let bodyRows: string[] = []; |
||||
|
||||
if (hasHeader) { |
||||
// If we have a header, first row is header, skip delimiter, rest is body
|
||||
headerCells = processCells(rows[0]); |
||||
bodyRows = rows.slice(2); |
||||
} else { |
||||
// No header, all rows are body
|
||||
bodyRows = rows; |
||||
} |
||||
|
||||
// Build table HTML
|
||||
let html = '<div class="overflow-x-auto my-4">\n'; |
||||
html += '<table class="min-w-full border-collapse">\n'; |
||||
|
||||
// Add header if exists
|
||||
if (hasHeader) { |
||||
html += '<thead>\n<tr>\n'; |
||||
headerCells.forEach(cell => { |
||||
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`; |
||||
}); |
||||
html += '</tr>\n</thead>\n'; |
||||
} |
||||
|
||||
// Add body
|
||||
html += '<tbody>\n'; |
||||
bodyRows.forEach(row => { |
||||
const cells = processCells(row); |
||||
html += '<tr>\n'; |
||||
cells.forEach(cell => { |
||||
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`; |
||||
}); |
||||
html += '</tr>\n'; |
||||
}); |
||||
|
||||
html += '</tbody>\n</table>\n</div>'; |
||||
return html; |
||||
} catch (e: unknown) { |
||||
console.error('Error processing table row:', e); |
||||
return match; |
||||
} |
||||
}); |
||||
} catch (e: unknown) { |
||||
console.error('Error in processTables:', e); |
||||
return content; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Process horizontal rules |
||||
*/ |
||||
function processHorizontalRules(content: string): string { |
||||
return content.replace(HORIZONTAL_RULE_REGEX, |
||||
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">' |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Process footnotes |
||||
*/ |
||||
function processFootnotes(content: string): string { |
||||
try { |
||||
if (!content) return ''; |
||||
|
||||
// Collect all footnote definitions (but do not remove them from the text yet)
|
||||
const footnotes = new Map<string, string>(); |
||||
content.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => { |
||||
footnotes.set(id, text.trim()); |
||||
return match; |
||||
}); |
||||
|
||||
// Remove all footnote definition lines from the main content
|
||||
let processedContent = content.replace(FOOTNOTE_DEFINITION_REGEX, ''); |
||||
|
||||
// Track all references to each footnote
|
||||
const referenceOrder: { id: string, refNum: number, label: string }[] = []; |
||||
const referenceMap = new Map<string, number[]>(); // id -> [refNum, ...]
|
||||
let globalRefNum = 1; |
||||
processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { |
||||
if (!footnotes.has(id)) { |
||||
console.warn(`Footnote reference [^${id}] found but no definition exists`); |
||||
return match; |
||||
} |
||||
const refNum = globalRefNum++; |
||||
if (!referenceMap.has(id)) referenceMap.set(id, []); |
||||
referenceMap.get(id)!.push(refNum); |
||||
referenceOrder.push({ id, refNum, label: id }); |
||||
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`; |
||||
}); |
||||
|
||||
// Only render footnotes section if there are actual definitions and at least one reference
|
||||
if (footnotes.size > 0 && referenceOrder.length > 0) { |
||||
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n'; |
||||
// Only include each unique footnote once, in order of first reference
|
||||
const seen = new Set<string>(); |
||||
for (const { id, label } of referenceOrder) { |
||||
if (seen.has(id)) continue; |
||||
seen.add(id); |
||||
const text = footnotes.get(id) || ''; |
||||
// List of backrefs for this footnote
|
||||
const refs = referenceMap.get(id) || []; |
||||
const backrefs = refs.map((num, i) => |
||||
`<a href=\"#fnref-${id}-${i + 1}\" class=\"text-primary-600 hover:underline footnote-backref\">↩${num}</a>` |
||||
).join(' '); |
||||
// If label is not a number, show it after all backrefs
|
||||
const labelSuffix = isNaN(Number(label)) ? ` ${label}` : ''; |
||||
processedContent += `<li id=\"fn-${id}\"><span class=\"marker\">${text}</span> ${backrefs}${labelSuffix}</li>\n`; |
||||
} |
||||
processedContent += '</ol>'; |
||||
} |
||||
|
||||
return processedContent; |
||||
} catch (error) { |
||||
console.error('Error processing footnotes:', error); |
||||
return content; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Process blockquotes |
||||
*/ |
||||
function processBlockquotes(content: string): string { |
||||
// Match blockquotes that might span multiple lines
|
||||
const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm; |
||||
|
||||
return content.replace(blockquoteRegex, (match) => { |
||||
// Remove the '>' prefix from each line and preserve line breaks
|
||||
const text = match |
||||
.split('\n') |
||||
.map(line => line.replace(/^>[ \t]?/, '')) |
||||
.join('\n') |
||||
.trim(); |
||||
|
||||
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Process code blocks by finding consecutive code lines and preserving their content |
||||
*/ |
||||
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } { |
||||
const lines = text.split('\n'); |
||||
const processedLines: string[] = []; |
||||
const blocks = new Map<string, string>(); |
||||
let inCodeBlock = false; |
||||
let currentCode: string[] = []; |
||||
let currentLanguage = ''; |
||||
let blockCount = 0; |
||||
let lastWasCodeBlock = false; |
||||
|
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
const codeBlockStart = line.match(CODE_BLOCK_REGEX); |
||||
|
||||
if (codeBlockStart) { |
||||
if (!inCodeBlock) { |
||||
// Starting a new code block
|
||||
inCodeBlock = true; |
||||
currentLanguage = codeBlockStart[1]; |
||||
currentCode = []; |
||||
lastWasCodeBlock = true; |
||||
} else { |
||||
// Ending current code block
|
||||
blockCount++; |
||||
const id = `CODE_BLOCK_${blockCount}`; |
||||
const code = currentCode.join('\n'); |
||||
|
||||
// Try to format JSON if specified
|
||||
let formattedCode = code; |
||||
if (currentLanguage.toLowerCase() === 'json') { |
||||
try { |
||||
formattedCode = JSON.stringify(JSON.parse(code), null, 2); |
||||
} catch (e: unknown) { |
||||
formattedCode = code; |
||||
} |
||||
} |
||||
|
||||
blocks.set(id, JSON.stringify({ |
||||
code: formattedCode, |
||||
language: currentLanguage, |
||||
raw: true |
||||
})); |
||||
|
||||
processedLines.push(''); // Add spacing before code block
|
||||
processedLines.push(id); |
||||
processedLines.push(''); // Add spacing after code block
|
||||
inCodeBlock = false; |
||||
currentCode = []; |
||||
currentLanguage = ''; |
||||
} |
||||
} else if (inCodeBlock) { |
||||
currentCode.push(line); |
||||
} else { |
||||
if (lastWasCodeBlock && line.trim()) { |
||||
processedLines.push(''); |
||||
lastWasCodeBlock = false; |
||||
} |
||||
processedLines.push(line); |
||||
} |
||||
} |
||||
|
||||
// Handle unclosed code block
|
||||
if (inCodeBlock && currentCode.length > 0) { |
||||
blockCount++; |
||||
const id = `CODE_BLOCK_${blockCount}`; |
||||
const code = currentCode.join('\n'); |
||||
|
||||
// Try to format JSON if specified
|
||||
let formattedCode = code; |
||||
if (currentLanguage.toLowerCase() === 'json') { |
||||
try { |
||||
formattedCode = JSON.stringify(JSON.parse(code), null, 2); |
||||
} catch (e: unknown) { |
||||
formattedCode = code; |
||||
} |
||||
} |
||||
|
||||
blocks.set(id, JSON.stringify({ |
||||
code: formattedCode, |
||||
language: currentLanguage, |
||||
raw: true |
||||
})); |
||||
processedLines.push(''); |
||||
processedLines.push(id); |
||||
processedLines.push(''); |
||||
} |
||||
|
||||
return { |
||||
text: processedLines.join('\n'), |
||||
blocks |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Restore code blocks with proper formatting |
||||
*/ |
||||
function restoreCodeBlocks(text: string, blocks: Map<string, string>): string { |
||||
let result = text; |
||||
|
||||
for (const [id, blockData] of blocks) { |
||||
try { |
||||
const { code, language } = JSON.parse(blockData); |
||||
|
||||
let html; |
||||
if (language && hljs.getLanguage(language)) { |
||||
try { |
||||
const highlighted = hljs.highlight(code, { |
||||
language, |
||||
ignoreIllegals: true |
||||
}).value; |
||||
html = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`; |
||||
} catch (e: unknown) { |
||||
console.warn('Failed to highlight code block:', e); |
||||
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`; |
||||
} |
||||
} else { |
||||
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`; |
||||
} |
||||
|
||||
result = result.replace(id, html); |
||||
} catch (e: unknown) { |
||||
console.error('Error restoring code block:', e); |
||||
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>'); |
||||
} |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Parse markup text with advanced formatting |
||||
*/ |
||||
export async function parseAdvancedmarkup(text: string): Promise<string> { |
||||
if (!text) return ''; |
||||
|
||||
try { |
||||
// Step 1: Extract and save code blocks first
|
||||
const { text: withoutCode, blocks } = processCodeBlocks(text); |
||||
let processedText = withoutCode; |
||||
|
||||
// Step 2: Process block-level elements
|
||||
processedText = processTables(processedText); |
||||
processedText = processBlockquotes(processedText); |
||||
processedText = processHeadings(processedText); |
||||
processedText = processHorizontalRules(processedText); |
||||
|
||||
// Process inline elements
|
||||
processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { |
||||
const escapedCode = code |
||||
.trim() |
||||
.replace(/&/g, '&') |
||||
.replace(/</g, '<') |
||||
.replace(/>/g, '>') |
||||
.replace(/"/g, '"') |
||||
.replace(/'/g, '''); |
||||
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`; |
||||
}); |
||||
|
||||
// Process footnotes (only references, not definitions)
|
||||
processedText = processFootnotes(processedText); |
||||
|
||||
// Process basic markup (which will also handle Nostr identifiers)
|
||||
processedText = await parseBasicmarkup(processedText); |
||||
|
||||
// Step 3: Restore code blocks
|
||||
processedText = restoreCodeBlocks(processedText, blocks); |
||||
|
||||
return processedText; |
||||
} catch (e: unknown) { |
||||
console.error('Error in parseAdvancedmarkup:', e); |
||||
return `<div class=\"text-red-500\">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; |
||||
} |
||||
} |
||||
@ -0,0 +1,388 @@
@@ -0,0 +1,388 @@
|
||||
import { processNostrIdentifiers } from '../nostrUtils'; |
||||
import * as emoji from 'node-emoji'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
/* Regex constants for basic markup parsing */ |
||||
|
||||
// Text formatting
|
||||
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g; |
||||
const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g; |
||||
const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g; |
||||
const HASHTAG_REGEX = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g; |
||||
|
||||
// Block elements
|
||||
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; |
||||
|
||||
// Links and media
|
||||
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; |
||||
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; |
||||
const WSS_URL = /wss:\/\/[^\s<>"]+/g; |
||||
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g; |
||||
|
||||
// Media URL patterns
|
||||
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; |
||||
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; |
||||
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; |
||||
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i; |
||||
|
||||
// Add this helper function near the top:
|
||||
function replaceAlexandriaNostrLinks(text: string): string { |
||||
// Regex for Alexandria/localhost URLs
|
||||
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; |
||||
// Regex for bech32 Nostr identifiers
|
||||
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; |
||||
// Regex for 64-char hex
|
||||
const hexPattern = /\b[a-fA-F0-9]{64}\b/; |
||||
|
||||
// 1. Alexandria/localhost markup links
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => { |
||||
if (alexandriaPattern.test(url)) { |
||||
if (/[?&]d=/.test(url)) return match; |
||||
const hexMatch = url.match(hexPattern); |
||||
if (hexMatch) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||
return `nostr:${nevent}`; |
||||
} catch { |
||||
return match; |
||||
} |
||||
} |
||||
const bech32Match = url.match(bech32Pattern); |
||||
if (bech32Match) { |
||||
return `nostr:${bech32Match[0]}`; |
||||
} |
||||
} |
||||
return match; |
||||
}); |
||||
|
||||
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
|
||||
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { |
||||
if (alexandriaPattern.test(url)) { |
||||
if (/[?&]d=/.test(url)) return url; |
||||
const hexMatch = url.match(hexPattern); |
||||
if (hexMatch) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||
return `nostr:${nevent}`; |
||||
} catch { |
||||
return url; |
||||
} |
||||
} |
||||
const bech32Match = url.match(bech32Pattern); |
||||
if (bech32Match) { |
||||
return `nostr:${bech32Match[0]}`; |
||||
} |
||||
} |
||||
// For non-Alexandria/localhost URLs, append (View here: nostr:<id>) if a Nostr identifier is present
|
||||
const hexMatch = url.match(hexPattern); |
||||
if (hexMatch) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||
return `${url} (View here: nostr:${nevent})`; |
||||
} catch { |
||||
return url; |
||||
} |
||||
} |
||||
const bech32Match = url.match(bech32Pattern); |
||||
if (bech32Match) { |
||||
return `${url} (View here: nostr:${bech32Match[0]})`; |
||||
} |
||||
return url; |
||||
}); |
||||
|
||||
return text; |
||||
} |
||||
|
||||
// Utility to strip tracking parameters from URLs
|
||||
function stripTrackingParams(url: string): string { |
||||
// List of tracking params to remove
|
||||
const trackingParams = [/^utm_/i, /^fbclid$/i, /^gclid$/i, /^tracking$/i, /^ref$/i]; |
||||
try { |
||||
// Absolute URL
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { |
||||
const parsed = new URL(url); |
||||
trackingParams.forEach(pattern => { |
||||
for (const key of Array.from(parsed.searchParams.keys())) { |
||||
if (pattern.test(key)) { |
||||
parsed.searchParams.delete(key); |
||||
} |
||||
} |
||||
}); |
||||
const queryString = parsed.searchParams.toString(); |
||||
return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || ''); |
||||
} else { |
||||
// Relative URL: parse query string manually
|
||||
const [path, queryAndHash = ''] = url.split('?'); |
||||
const [query = '', hash = ''] = queryAndHash.split('#'); |
||||
if (!query) return url; |
||||
const params = query.split('&').filter(Boolean); |
||||
const filtered = params.filter(param => { |
||||
const [key] = param.split('='); |
||||
return !trackingParams.some(pattern => pattern.test(key)); |
||||
}); |
||||
const queryString = filtered.length ? '?' + filtered.join('&') : ''; |
||||
const hashString = hash ? '#' + hash : ''; |
||||
return path + queryString + hashString; |
||||
} |
||||
} catch { |
||||
return url; |
||||
} |
||||
} |
||||
|
||||
function normalizeDTag(input: string): string { |
||||
return input |
||||
.toLowerCase() |
||||
.replace(/[^\p{L}\p{N}]/gu, '-') |
||||
.replace(/-+/g, '-') |
||||
.replace(/^-|-$/g, ''); |
||||
} |
||||
|
||||
function replaceWikilinks(text: string): string { |
||||
// [[target page]] or [[target page|display text]]
|
||||
return text.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { |
||||
const normalized = normalizeDTag(target.trim()); |
||||
const display = (label || target).trim(); |
||||
const url = `./publication?d=${normalized}`; |
||||
// Output as a clickable <a> with the [[display]] format and matching link colors
|
||||
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; |
||||
}); |
||||
} |
||||
|
||||
function renderListGroup(lines: string[], typeHint?: 'ol' | 'ul'): string { |
||||
function parseList(start: number, indent: number, type: 'ol' | 'ul'): [string, number] { |
||||
let html = ''; |
||||
let i = start; |
||||
html += `<${type} class="${type === 'ol' ? 'list-decimal' : 'list-disc'} ml-6 mb-2">`; |
||||
while (i < lines.length) { |
||||
const line = lines[i]; |
||||
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); |
||||
if (!match) break; |
||||
const lineIndent = match[1].replace(/\t/g, ' ').length; |
||||
const isOrdered = /\d+\./.test(match[2]); |
||||
const itemType = isOrdered ? 'ol' : 'ul'; |
||||
if (lineIndent > indent) { |
||||
// Nested list
|
||||
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); |
||||
html = html.replace(/<\/li>$/, '') + nestedHtml + '</li>'; |
||||
i = consumed; |
||||
continue; |
||||
} |
||||
if (lineIndent < indent || itemType !== type) { |
||||
break; |
||||
} |
||||
html += `<li class="mb-1">${match[3]}`; |
||||
// Check for next line being a nested list
|
||||
if (i + 1 < lines.length) { |
||||
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||
if (nextMatch) { |
||||
const nextIndent = nextMatch[1].replace(/\t/g, ' ').length; |
||||
const nextType = /\d+\./.test(nextMatch[2]) ? 'ol' : 'ul'; |
||||
if (nextIndent > lineIndent) { |
||||
const [nestedHtml, consumed] = parseList(i + 1, nextIndent, nextType); |
||||
html += nestedHtml; |
||||
i = consumed - 1; |
||||
} |
||||
} |
||||
} |
||||
html += '</li>'; |
||||
i++; |
||||
} |
||||
html += `</${type}>`; |
||||
return [html, i]; |
||||
} |
||||
if (!lines.length) return ''; |
||||
const firstLine = lines[0]; |
||||
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||
const indent = match ? match[1].replace(/\t/g, ' ').length : 0; |
||||
const type = typeHint || (match && /\d+\./.test(match[2]) ? 'ol' : 'ul'); |
||||
const [html] = parseList(0, indent, type); |
||||
return html; |
||||
} |
||||
|
||||
function processBasicFormatting(content: string): string { |
||||
if (!content) return ''; |
||||
|
||||
let processedText = content; |
||||
|
||||
try { |
||||
// Sanitize Alexandria Nostr links before further processing
|
||||
processedText = replaceAlexandriaNostrLinks(processedText); |
||||
|
||||
// Process markup images first
|
||||
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => { |
||||
url = stripTrackingParams(url); |
||||
if (YOUTUBE_URL_REGEX.test(url)) { |
||||
const videoId = extractYouTubeVideoId(url); |
||||
if (videoId) { |
||||
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || 'YouTube video'}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`; |
||||
} |
||||
} |
||||
if (VIDEO_URL_REGEX.test(url)) { |
||||
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${url}">${alt || 'Video'}</video>`; |
||||
} |
||||
if (AUDIO_URL_REGEX.test(url)) { |
||||
return `<audio controls class="w-full my-4" preload="none"><source src="${url}">${alt || 'Audio'}</audio>`; |
||||
} |
||||
// Only render <img> if the url ends with a direct image extension
|
||||
if (IMAGE_EXTENSIONS.test(url.split('?')[0])) { |
||||
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||
} |
||||
// Otherwise, render as a clickable link
|
||||
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`; |
||||
}); |
||||
|
||||
// Process markup links
|
||||
processedText = processedText.replace(MARKUP_LINK, (match, text, url) =>
|
||||
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>` |
||||
); |
||||
|
||||
// Process WebSocket URLs
|
||||
processedText = processedText.replace(WSS_URL, match => { |
||||
// Remove 'wss://' from the start and any trailing slashes
|
||||
const cleanUrl = match.slice(6).replace(/\/+$/, ''); |
||||
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`; |
||||
}); |
||||
|
||||
// Process direct media URLs and auto-link all URLs
|
||||
processedText = processedText.replace(DIRECT_LINK, match => { |
||||
const clean = stripTrackingParams(match); |
||||
if (YOUTUBE_URL_REGEX.test(clean)) { |
||||
const videoId = extractYouTubeVideoId(clean); |
||||
if (videoId) { |
||||
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation" class="text-primary-600 dark:text-primary-500 hover:underline"></iframe>`; |
||||
} |
||||
} |
||||
if (VIDEO_URL_REGEX.test(clean)) { |
||||
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">Your browser does not support the video tag.</video>`; |
||||
} |
||||
if (AUDIO_URL_REGEX.test(clean)) { |
||||
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">Your browser does not support the audio tag.</audio>`; |
||||
} |
||||
// Only render <img> if the url ends with a direct image extension
|
||||
if (IMAGE_EXTENSIONS.test(clean.split('?')[0])) { |
||||
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||
} |
||||
// Otherwise, render as a clickable link
|
||||
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`; |
||||
}); |
||||
|
||||
// Process text formatting
|
||||
processedText = processedText.replace(BOLD_REGEX, '<strong>$2</strong>'); |
||||
processedText = processedText.replace(ITALIC_REGEX, match => { |
||||
const text = match.replace(/^_+|_+$/g, ''); |
||||
return `<em>${text}</em>`; |
||||
}); |
||||
processedText = processedText.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { |
||||
const text = doubleText || singleText; |
||||
return `<del class="line-through">${text}</del>`; |
||||
}); |
||||
|
||||
// Process hashtags
|
||||
processedText = processedText.replace(HASHTAG_REGEX, '<span class="text-primary-600 dark:text-primary-500">#$1</span>'); |
||||
|
||||
// --- Improved List Grouping and Parsing ---
|
||||
const lines = processedText.split('\n'); |
||||
let output = ''; |
||||
let buffer: string[] = []; |
||||
let inList = false; |
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { |
||||
buffer.push(line); |
||||
inList = true; |
||||
} else { |
||||
if (inList) { |
||||
const firstLine = buffer[0]; |
||||
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); |
||||
buffer = []; |
||||
inList = false; |
||||
} |
||||
output += (output && !output.endsWith('\n') ? '\n' : '') + line + '\n'; |
||||
} |
||||
} |
||||
if (buffer.length) { |
||||
const firstLine = buffer[0]; |
||||
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||
output += renderListGroup(buffer, isOrdered ? 'ol' : 'ul'); |
||||
} |
||||
processedText = output; |
||||
// --- End Improved List Grouping and Parsing ---
|
||||
|
||||
} catch (e: unknown) { |
||||
console.error('Error in processBasicFormatting:', e); |
||||
} |
||||
|
||||
return processedText; |
||||
} |
||||
|
||||
// Helper function to extract YouTube video ID
|
||||
function extractYouTubeVideoId(url: string): string | null { |
||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/); |
||||
return match ? match[1] : null; |
||||
} |
||||
|
||||
function processBlockquotes(content: string): string { |
||||
try { |
||||
if (!content) return ''; |
||||
|
||||
return content.replace(BLOCKQUOTE_REGEX, match => { |
||||
const lines = match.split('\n').map(line => { |
||||
return line.replace(/^[ \t]*>[ \t]?/, '').trim(); |
||||
}); |
||||
|
||||
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${ |
||||
lines.join('\n') |
||||
}</blockquote>`;
|
||||
}); |
||||
} catch (e: unknown) { |
||||
console.error('Error in processBlockquotes:', e); |
||||
return content; |
||||
} |
||||
} |
||||
|
||||
function processEmojiShortcuts(content: string): string { |
||||
try { |
||||
return emoji.emojify(content, { fallback: (name: string) => { |
||||
const emojiChar = emoji.get(name); |
||||
return emojiChar || `:${name}:`; |
||||
}}); |
||||
} catch (e: unknown) { |
||||
console.error('Error in processEmojiShortcuts:', e); |
||||
return content; |
||||
} |
||||
} |
||||
|
||||
export async function parseBasicmarkup(text: string): Promise<string> { |
||||
if (!text) return ''; |
||||
|
||||
try { |
||||
// Process basic text formatting first
|
||||
let processedText = processBasicFormatting(text); |
||||
|
||||
// Process emoji shortcuts
|
||||
processedText = processEmojiShortcuts(processedText); |
||||
|
||||
// Process blockquotes
|
||||
processedText = processBlockquotes(processedText); |
||||
|
||||
// Process paragraphs - split by double newlines and wrap in p tags
|
||||
processedText = processedText |
||||
.split(/\n\n+/) |
||||
.map(para => para.trim()) |
||||
.filter(para => para.length > 0) |
||||
.map(para => `<p class="my-4">${para}</p>`) |
||||
.join('\n'); |
||||
|
||||
// Process Nostr identifiers last
|
||||
processedText = await processNostrIdentifiers(processedText); |
||||
|
||||
// Replace wikilinks
|
||||
processedText = replaceWikilinks(processedText); |
||||
|
||||
return processedText; |
||||
} catch (e: unknown) { |
||||
console.error('Error in parseBasicmarkup:', e); |
||||
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? 'Unknown error'}</div>`; |
||||
} |
||||
} |
||||
@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
|
||||
/** |
||||
* Determine the type of Nostr event based on its kind number |
||||
* Following NIP specification for kind ranges: |
||||
* - Replaceable: 0, 3, 10000-19999 (only latest stored) |
||||
* - Ephemeral: 20000-29999 (not stored) |
||||
* - Addressable: 30000-39999 (latest per d-tag stored) |
||||
* - Regular: all other kinds (stored by relays) |
||||
*/ |
||||
function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { |
||||
// Check special ranges first
|
||||
if (kind >= 30000 && kind < 40000) { |
||||
return 'addressable'; |
||||
} |
||||
|
||||
if (kind >= 20000 && kind < 30000) { |
||||
return 'ephemeral'; |
||||
} |
||||
|
||||
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { |
||||
return 'replaceable'; |
||||
} |
||||
|
||||
// Everything else is regular
|
||||
return 'regular'; |
||||
} |
||||
|
||||
/** |
||||
* Get MIME tags for a Nostr event based on its kind number |
||||
* Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]] |
||||
* Following NKBIP-06 and NIP-94 specifications |
||||
*/ |
||||
export function getMimeTags(kind: number): [string, string][] { |
||||
// Default tags for unknown kinds
|
||||
let mTag: [string, string] = ["m", "text/plain"]; |
||||
let MTag: [string, string] = ["M", "note/generic/nonreplaceable"]; |
||||
|
||||
// Determine replaceability based on event type
|
||||
const eventType = getEventType(kind); |
||||
const replaceability = (eventType === 'replaceable' || eventType === 'addressable')
|
||||
? "replaceable"
|
||||
: "nonreplaceable"; |
||||
|
||||
switch (kind) { |
||||
// Short text note
|
||||
case 1: |
||||
mTag = ["m", "text/plain"]; |
||||
MTag = ["M", `note/microblog/${replaceability}`]; |
||||
break; |
||||
|
||||
// Generic reply
|
||||
case 1111: |
||||
mTag = ["m", "text/plain"]; |
||||
MTag = ["M", `note/comment/${replaceability}`]; |
||||
break; |
||||
|
||||
// Issue
|
||||
case 1621: |
||||
mTag = ["m", "text/markup"]; |
||||
MTag = ["M", `git/issue/${replaceability}`]; |
||||
break; |
||||
|
||||
// Issue comment
|
||||
case 1622: |
||||
mTag = ["m", "text/markup"]; |
||||
MTag = ["M", `git/comment/${replaceability}`]; |
||||
break; |
||||
|
||||
// Book metadata
|
||||
case 30040: |
||||
mTag = ["m", "application/json"]; |
||||
MTag = ["M", `meta-data/index/${replaceability}`]; |
||||
break; |
||||
|
||||
// Book content
|
||||
case 30041: |
||||
mTag = ["m", "text/asciidoc"]; |
||||
MTag = ["M", `article/publication-content/${replaceability}`]; |
||||
break; |
||||
|
||||
// Wiki page
|
||||
case 30818: |
||||
mTag = ["m", "text/asciidoc"]; |
||||
MTag = ["M", `article/wiki/${replaceability}`]; |
||||
break; |
||||
|
||||
// Long-form note
|
||||
case 30023: |
||||
mTag = ["m", "text/markup"]; |
||||
MTag = ["M", `article/long-form/${replaceability}`]; |
||||
break; |
||||
|
||||
// Add more cases as needed...
|
||||
} |
||||
|
||||
return [mTag, MTag]; |
||||
}
|
||||
@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
|
||||
import { get } from 'svelte/store'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { ndkInstance } from '$lib/ndk'; |
||||
import { npubCache } from './npubCache'; |
||||
|
||||
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix
|
||||
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||
|
||||
/** |
||||
* HTML escape a string |
||||
*/ |
||||
function escapeHtml(text: string): string { |
||||
const htmlEscapes: { [key: string]: string } = { |
||||
'&': '&', |
||||
'<': '<', |
||||
'>': '>', |
||||
'"': '"', |
||||
"'": ''' |
||||
}; |
||||
return text.replace(/[&<>"']/g, char => htmlEscapes[char]); |
||||
} |
||||
|
||||
/** |
||||
* Get user metadata for a nostr identifier (npub or nprofile) |
||||
*/ |
||||
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { |
||||
// Remove nostr: prefix if present
|
||||
const cleanId = identifier.replace(/^nostr:/, ''); |
||||
|
||||
if (npubCache.has(cleanId)) { |
||||
return npubCache.get(cleanId)!; |
||||
} |
||||
|
||||
const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; |
||||
|
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
|
||||
const decoded = nip19.decode(cleanId); |
||||
if (!decoded) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
|
||||
// Handle different identifier types
|
||||
let pubkey: string; |
||||
if (decoded.type === 'npub') { |
||||
pubkey = decoded.data; |
||||
} else if (decoded.type === 'nprofile') { |
||||
pubkey = decoded.data.pubkey; |
||||
} else { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
|
||||
const user = ndk.getUser({ pubkey: pubkey }); |
||||
if (!user) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
|
||||
try { |
||||
const profile = await user.fetchProfile(); |
||||
if (!profile) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
|
||||
const metadata = { |
||||
name: profile.name || fallback.name, |
||||
displayName: profile.displayName |
||||
}; |
||||
|
||||
npubCache.set(cleanId, metadata); |
||||
return metadata; |
||||
} catch (e) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
} catch (e) { |
||||
npubCache.set(cleanId, fallback); |
||||
return fallback; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create a profile link element |
||||
*/ |
||||
function createProfileLink(identifier: string, displayText: string | undefined): string { |
||||
const cleanId = identifier.replace(/^nostr:/, ''); |
||||
const escapedId = escapeHtml(cleanId); |
||||
const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; |
||||
const escapedText = escapeHtml(displayText || defaultText); |
||||
|
||||
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`; |
||||
} |
||||
|
||||
/** |
||||
* Create a note link element |
||||
*/ |
||||
function createNoteLink(identifier: string): string { |
||||
const cleanId = identifier.replace(/^nostr:/, ''); |
||||
const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; |
||||
const escapedId = escapeHtml(cleanId); |
||||
const escapedText = escapeHtml(shortId); |
||||
|
||||
return `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; |
||||
} |
||||
|
||||
/** |
||||
* Process Nostr identifiers in text |
||||
*/ |
||||
export async function processNostrIdentifiers(content: string): Promise<string> { |
||||
let processedContent = content; |
||||
|
||||
// Helper to check if a match is part of a URL
|
||||
function isPartOfUrl(text: string, index: number): boolean { |
||||
// Look for http(s):// or www. before the match
|
||||
const before = text.slice(Math.max(0, index - 12), index); |
||||
return /https?:\/\/$|www\.$/i.test(before); |
||||
} |
||||
|
||||
// Process profiles (npub and nprofile)
|
||||
const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX)); |
||||
for (const match of profileMatches) { |
||||
const [fullMatch] = match; |
||||
const matchIndex = match.index ?? 0; |
||||
if (isPartOfUrl(content, matchIndex)) { |
||||
continue; // skip if part of a URL
|
||||
} |
||||
let identifier = fullMatch; |
||||
if (!identifier.startsWith('nostr:')) { |
||||
identifier = 'nostr:' + identifier; |
||||
} |
||||
const metadata = await getUserMetadata(identifier); |
||||
const displayText = metadata.displayName || metadata.name; |
||||
const link = createProfileLink(identifier, displayText); |
||||
processedContent = processedContent.replace(fullMatch, link); |
||||
} |
||||
|
||||
// Process notes (nevent, note, naddr)
|
||||
const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX)); |
||||
for (const match of noteMatches) { |
||||
const [fullMatch] = match; |
||||
const matchIndex = match.index ?? 0; |
||||
if (isPartOfUrl(processedContent, matchIndex)) { |
||||
continue; // skip if part of a URL
|
||||
} |
||||
let identifier = fullMatch; |
||||
if (!identifier.startsWith('nostr:')) { |
||||
identifier = 'nostr:' + identifier; |
||||
} |
||||
const link = createNoteLink(identifier); |
||||
processedContent = processedContent.replace(fullMatch, link); |
||||
} |
||||
|
||||
return processedContent; |
||||
} |
||||
|
||||
export async function getNpubFromNip05(nip05: string): Promise<string | null> { |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
if (!ndk) { |
||||
console.error('NDK not initialized'); |
||||
return null; |
||||
} |
||||
|
||||
const user = await ndk.getUser({ nip05 }); |
||||
if (!user || !user.npub) { |
||||
return null; |
||||
} |
||||
return user.npub; |
||||
} catch (error) { |
||||
console.error('Error getting npub from nip05:', error); |
||||
return null; |
||||
} |
||||
}
|
||||
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
export type NpubMetadata = { name?: string; displayName?: string }; |
||||
|
||||
class NpubCache { |
||||
private cache: Record<string, NpubMetadata> = {}; |
||||
|
||||
get(key: string): NpubMetadata | undefined { |
||||
return this.cache[key]; |
||||
} |
||||
|
||||
set(key: string, value: NpubMetadata): void { |
||||
this.cache[key] = value; |
||||
} |
||||
|
||||
has(key: string): boolean { |
||||
return key in this.cache; |
||||
} |
||||
|
||||
delete(key: string): boolean { |
||||
if (key in this.cache) { |
||||
delete this.cache[key]; |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
deleteMany(keys: string[]): number { |
||||
let deleted = 0; |
||||
for (const key of keys) { |
||||
if (this.delete(key)) { |
||||
deleted++; |
||||
} |
||||
} |
||||
return deleted; |
||||
} |
||||
|
||||
clear(): void { |
||||
this.cache = {}; |
||||
} |
||||
|
||||
size(): number { |
||||
return Object.keys(this.cache).length; |
||||
} |
||||
|
||||
getAll(): Record<string, NpubMetadata> { |
||||
return { ...this.cache }; |
||||
} |
||||
} |
||||
|
||||
export const npubCache = new NpubCache(); |
||||
@ -1,118 +1,58 @@
@@ -1,118 +1,58 @@
|
||||
<script lang='ts'> |
||||
<script lang="ts"> |
||||
import { Heading, Img, P, A } from "flowbite-svelte"; |
||||
|
||||
// Get the git tag version from environment variables |
||||
const appVersion = import.meta.env.APP_VERSION || 'development'; |
||||
const isVersionKnown = appVersion !== 'development'; |
||||
const appVersion = import.meta.env.APP_VERSION || "development"; |
||||
const isVersionKnown = appVersion !== "development"; |
||||
</script> |
||||
|
||||
<div class='w-full flex justify-center'> |
||||
<main class='main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4'> |
||||
<div class="w-full flex justify-center"> |
||||
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> |
||||
<div class="flex justify-between items-center"> |
||||
<Heading tag='h1' class='h-leather mb-2'>About the Library of Alexandria</Heading> |
||||
<Heading tag="h1" class="h-leather mb-2" |
||||
>About the Library of Alexandria</Heading |
||||
> |
||||
{#if isVersionKnown} |
||||
<span class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap">Version: {appVersion}</span> |
||||
<span |
||||
class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap" |
||||
>Version: {appVersion}</span |
||||
> |
||||
{/if} |
||||
</div> |
||||
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" /> |
||||
|
||||
<P class="mb-3"> |
||||
Alexandria is a reader and writer for <A href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1">curated publications</A> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the <A href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1">GitCitadel project team</A>. |
||||
</P> |
||||
Alexandria is a reader and writer for <A |
||||
href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1" |
||||
>curated publications</A |
||||
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form |
||||
articles (markup). It is produced by the <A |
||||
href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1" |
||||
>GitCitadel project team</A |
||||
>. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
Please submit support issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page</A> and follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>. |
||||
</P> |
||||
|
||||
<P> |
||||
We are easiest to contact over our Nostr address <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A>. |
||||
</P> |
||||
|
||||
<Heading tag='h2' class='h-leather mt-4 mb-2'>Overview</Heading> |
||||
|
||||
<P class="mb-4"> |
||||
Alexandria opens up to the <A href="./">landing page</A>, where the user can: login (top-right), select whether to only view the publications hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank">thecitadel document relay</A> or add in their own relays, and scroll/search the publications. |
||||
</P> |
||||
|
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border rounded-lg' width="400" /> |
||||
<Img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<P class="mb-3"> |
||||
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). |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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.) |
||||
</P> |
||||
|
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border rounded-lg' width="400" /> |
||||
<Img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<Heading tag='h2' class='h-leather mt-4 mb-2'>Typical use cases</Heading> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-3'>For e-books</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
An example of a book is <A href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition">Jane Eyre</A> |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-3'>For scientific papers</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
An example of a research paper is <A href="/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1">Less Partnering, Less Children, or Both?</A> |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-3'>For documentation</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
Our own team uses Alexandria to document the app, to display our <A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A>, as well as to store copies of our most interesting <A href="/publication?d=gitcitadel-project-documentation-by-stella-v-1">technical specifications</A>. |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/Documentation.png" alt="Documentation" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-3'>For wiki pages</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
</main> |
||||
<P class="mb-3"> |
||||
Please submit support issues on the <A href="/contact" |
||||
>Alexandria contact page</A |
||||
> and follow us on <A |
||||
href="https://github.com/ShadowySupercode/gitcitadel" |
||||
target="_blank">GitHub</A |
||||
> and <A href="https://geyser.fund/project/gitcitadel" target="_blank" |
||||
>Geyserfund</A |
||||
>. |
||||
</P> |
||||
|
||||
<P> |
||||
We are easiest to contact over our Nostr address <A |
||||
href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" |
||||
title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" |
||||
target="_blank">GitCitadel</A |
||||
>. Or, you can visit us on our <A |
||||
href="https://gitcitadel.com" |
||||
title="GitCitadel Homepage" |
||||
target="_blank">homepage</A |
||||
> and find out more about us, and the many projects we are working on. |
||||
</P> |
||||
</main> |
||||
</div> |
||||
|
||||
@ -0,0 +1,518 @@
@@ -0,0 +1,518 @@
|
||||
<script lang='ts'> |
||||
import { Heading, P, A, Button, Label, Textarea, Input, Modal } from 'flowbite-svelte'; |
||||
import { ndkSignedIn, ndkInstance } from '$lib/ndk'; |
||||
import { standardRelays } from '$lib/consts'; |
||||
import type NDK from '@nostr-dev-kit/ndk'; |
||||
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'; |
||||
// @ts-ignore - Workaround for Svelte component import issue |
||||
import LoginModal from '$lib/components/LoginModal.svelte'; |
||||
import { parseAdvancedmarkup } from '$lib/utils/markup/advancedMarkupParser'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { getMimeTags } from '$lib/utils/mime'; |
||||
|
||||
// Function to close the success message |
||||
function closeSuccessMessage() { |
||||
submissionSuccess = false; |
||||
submittedEvent = null; |
||||
} |
||||
|
||||
function clearForm() { |
||||
subject = ''; |
||||
content = ''; |
||||
submissionError = ''; |
||||
isExpanded = false; |
||||
activeTab = 'write'; |
||||
} |
||||
|
||||
let subject = $state(''); |
||||
let content = $state(''); |
||||
let isSubmitting = $state(false); |
||||
let showLoginModal = $state(false); |
||||
let submissionSuccess = $state(false); |
||||
let submissionError = $state(''); |
||||
let submittedEvent = $state<NDKEvent | null>(null); |
||||
let issueLink = $state(''); |
||||
let successfulRelays = $state<string[]>([]); |
||||
let isExpanded = $state(false); |
||||
let activeTab = $state('write'); |
||||
let showConfirmDialog = $state(false); |
||||
|
||||
// Store form data when user needs to login |
||||
let savedFormData = { |
||||
subject: '', |
||||
content: '' |
||||
}; |
||||
|
||||
// Repository event address from the task |
||||
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr'; |
||||
|
||||
// Hard-coded relays to ensure we have working relays |
||||
const allRelays = [ |
||||
'wss://relay.damus.io', |
||||
'wss://relay.nostr.band', |
||||
'wss://nos.lol', |
||||
...standardRelays |
||||
]; |
||||
|
||||
// Hard-coded repository owner pubkey and ID from the task |
||||
// These values are extracted from the naddr |
||||
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1'; |
||||
const repoId = 'Alexandria'; |
||||
|
||||
// Function to normalize relay URLs by removing trailing slashes |
||||
function normalizeRelayUrl(url: string): string { |
||||
return url.replace(/\/+$/, ''); |
||||
} |
||||
|
||||
function toggleSize() { |
||||
isExpanded = !isExpanded; |
||||
} |
||||
|
||||
async function handleSubmit(e: Event) { |
||||
// Prevent form submission |
||||
e.preventDefault(); |
||||
|
||||
if (!subject || !content) { |
||||
submissionError = 'Please fill in all fields'; |
||||
return; |
||||
} |
||||
|
||||
// Check if user is logged in |
||||
if (!$ndkSignedIn) { |
||||
// Save form data |
||||
savedFormData = { |
||||
subject, |
||||
content |
||||
}; |
||||
|
||||
// Show login modal |
||||
showLoginModal = true; |
||||
return; |
||||
} |
||||
|
||||
// Show confirmation dialog |
||||
showConfirmDialog = true; |
||||
} |
||||
|
||||
async function confirmSubmit() { |
||||
showConfirmDialog = false; |
||||
await submitIssue(); |
||||
} |
||||
|
||||
function cancelSubmit() { |
||||
showConfirmDialog = false; |
||||
} |
||||
|
||||
/** |
||||
* Publish event to relays with retry logic |
||||
*/ |
||||
async function publishToRelays( |
||||
event: NDKEvent, |
||||
ndk: NDK, |
||||
relays: Set<string>, |
||||
maxRetries: number = 3, |
||||
timeout: number = 10000 |
||||
): Promise<string[]> { |
||||
const successfulRelays: string[] = []; |
||||
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(relays), ndk); |
||||
|
||||
// Set up listeners for successful publishes |
||||
const publishPromises = Array.from(relays).map(relayUrl => { |
||||
return new Promise<void>(resolve => { |
||||
const relay = ndk.pool?.getRelay(relayUrl); |
||||
if (relay) { |
||||
relay.on('published', (publishedEvent: NDKEvent) => { |
||||
if (publishedEvent.id === event.id) { |
||||
successfulRelays.push(relayUrl); |
||||
resolve(); |
||||
} |
||||
}); |
||||
} else { |
||||
resolve(); // Resolve if relay not available |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
// Try publishing with retries |
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) { |
||||
try { |
||||
// Start publishing with timeout |
||||
const publishPromise = event.publish(relaySet); |
||||
const timeoutPromise = new Promise((_, reject) => { |
||||
setTimeout(() => reject(new Error('Publish timeout')), timeout); |
||||
}); |
||||
|
||||
await Promise.race([ |
||||
publishPromise, |
||||
Promise.allSettled(publishPromises), |
||||
timeoutPromise |
||||
]); |
||||
|
||||
if (successfulRelays.length > 0) { |
||||
break; // Exit retry loop if we have successful publishes |
||||
} |
||||
|
||||
if (attempt < maxRetries) { |
||||
// Wait before retrying (exponential backoff) |
||||
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000)); |
||||
} |
||||
} catch (error) { |
||||
if (attempt === maxRetries && successfulRelays.length === 0) { |
||||
throw new Error('Failed to publish to any relays after multiple attempts'); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return successfulRelays; |
||||
} |
||||
|
||||
async function submitIssue() { |
||||
isSubmitting = true; |
||||
submissionError = ''; |
||||
submissionSuccess = false; |
||||
|
||||
try { |
||||
// Get NDK instance |
||||
const ndk = $ndkInstance; |
||||
if (!ndk) { |
||||
throw new Error('NDK instance not available'); |
||||
} |
||||
|
||||
if (!ndk.signer) { |
||||
throw new Error('No signer available. Make sure you are logged in.'); |
||||
} |
||||
|
||||
// Create and prepare the event |
||||
const event = await createIssueEvent(ndk); |
||||
|
||||
// Collect all unique relays |
||||
const uniqueRelays = new Set([ |
||||
...allRelays.map(normalizeRelayUrl), |
||||
...(ndk.pool ? Array.from(ndk.pool.relays.values()) |
||||
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol')) |
||||
.map(relay => normalizeRelayUrl(relay.url)) : []) |
||||
]); |
||||
|
||||
try { |
||||
// Publish to relays with retry logic |
||||
successfulRelays = await publishToRelays(event, ndk, uniqueRelays); |
||||
|
||||
// Store the submitted event and create issue link |
||||
submittedEvent = event; |
||||
|
||||
// Create the issue link using the repository address |
||||
const noteId = nip19.noteEncode(event.id); |
||||
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`; |
||||
|
||||
// Clear form and show success message |
||||
clearForm(); |
||||
submissionSuccess = true; |
||||
} catch (error) { |
||||
throw new Error('Failed to publish event'); |
||||
} |
||||
} catch (error: any) { |
||||
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`; |
||||
} finally { |
||||
isSubmitting = false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Create and sign a new issue event |
||||
*/ |
||||
async function createIssueEvent(ndk: NDK): Promise<NDKEvent> { |
||||
const event = new NDKEvent(ndk); |
||||
event.kind = 1621; // issue_kind |
||||
event.tags.push(['subject', subject]); |
||||
event.tags.push(['alt', `git repository issue: ${subject}`]); |
||||
|
||||
// Add repository reference with proper format |
||||
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`; |
||||
event.tags.push([ |
||||
'a', |
||||
aTagValue, |
||||
'', |
||||
'root' |
||||
]); |
||||
|
||||
// Add repository owner as p tag with proper value |
||||
event.tags.push(['p', repoOwnerPubkey]); |
||||
|
||||
// Add MIME tags |
||||
const mimeTags = getMimeTags(1621); |
||||
event.tags.push(...mimeTags); |
||||
|
||||
// Set content |
||||
event.content = content; |
||||
|
||||
// Sign the event |
||||
try { |
||||
await event.sign(); |
||||
} catch (error) { |
||||
throw new Error('Failed to sign event'); |
||||
} |
||||
|
||||
return event; |
||||
} |
||||
|
||||
// Handle login completion |
||||
$effect(() => { |
||||
if ($ndkSignedIn && showLoginModal) { |
||||
showLoginModal = false; |
||||
|
||||
// Restore saved form data |
||||
if (savedFormData.subject) subject = savedFormData.subject; |
||||
if (savedFormData.content) content = savedFormData.content; |
||||
|
||||
// Submit the issue |
||||
submitIssue(); |
||||
} |
||||
}); |
||||
|
||||
</script> |
||||
|
||||
<div class='w-full flex justify-center'> |
||||
<main class='main-leather flex flex-col space-y-6 max-w-3xl w-full my-6 px-6 sm:px-4'> |
||||
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">GitCitadel</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A> |
||||
</P> |
||||
|
||||
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page. |
||||
</P> |
||||
|
||||
<form class="space-y-4" on:submit={handleSubmit} autocomplete="off"> |
||||
<div> |
||||
<Label for="subject" class="mb-2">Subject</Label> |
||||
<Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus /> |
||||
</div> |
||||
|
||||
<div class="relative"> |
||||
<Label for="content" class="mb-2">Description</Label> |
||||
<div class="relative border border-gray-300 dark:border-gray-600 rounded-lg {isExpanded ? 'h-[800px]' : 'h-[200px]'} transition-all duration-200 sm:w-[95vw] md:w-full"> |
||||
<div class="h-full flex flex-col"> |
||||
<div class="border-b border-gray-300 dark:border-gray-600 rounded-t-lg"> |
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center" role="tablist"> |
||||
<li class="mr-2" role="presentation"> |
||||
<button |
||||
type="button" |
||||
class="inline-block p-4 rounded-t-lg {activeTab === 'write' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" |
||||
on:click={() => activeTab = 'write'} |
||||
role="tab" |
||||
> |
||||
Write |
||||
</button> |
||||
</li> |
||||
<li role="presentation"> |
||||
<button |
||||
type="button" |
||||
class="inline-block p-4 rounded-t-lg {activeTab === 'preview' ? 'border-b-2 border-primary-600 text-primary-600' : 'hover:text-gray-600 hover:border-gray-300'}" |
||||
on:click={() => activeTab = 'preview'} |
||||
role="tab" |
||||
> |
||||
Preview |
||||
</button> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<div class="flex-1 min-h-0 relative"> |
||||
{#if activeTab === 'write'} |
||||
<div class="absolute inset-0 overflow-hidden"> |
||||
<Textarea |
||||
id="content" |
||||
class="w-full h-full resize-none bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 border-s-4 border-primary-200 rounded-b-lg rounded-t-none shadow-none px-4 py-2 focus:border-primary-400 dark:focus:border-primary-500" |
||||
bind:value={content} |
||||
required |
||||
placeholder="Describe your issue in detail... |
||||
|
||||
The following markup is supported: |
||||
|
||||
# Headers (1-6 levels) |
||||
|
||||
Header 1 |
||||
====== |
||||
|
||||
*Bold* or **bold** |
||||
|
||||
_Italic_ or __italic__ text |
||||
|
||||
~Strikethrough~ or ~~strikethrough~~ text |
||||
|
||||
> Blockquotes |
||||
|
||||
Lists, including nested: |
||||
* Bullets/unordered lists |
||||
1. Numbered/ordered lists |
||||
|
||||
[Links](url) |
||||
 |
||||
|
||||
`Inline code` |
||||
|
||||
```language |
||||
Code blocks with syntax highlighting for over 100 languages |
||||
``` |
||||
|
||||
| Tables | With or without headers | |
||||
|--------|------| |
||||
| Multiple | Rows | |
||||
|
||||
Footnotes[^1] and [^1]: footnote content |
||||
|
||||
Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. With or without the nostr: prefix." |
||||
/> |
||||
</div> |
||||
{:else} |
||||
<div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markup-content"> |
||||
{#key content} |
||||
{#await parseAdvancedmarkup(content)} |
||||
<p>Loading preview...</p> |
||||
{:then html} |
||||
{@html html || '<p class="text-gray-500">Nothing to preview</p>'} |
||||
{:catch error} |
||||
<p class="text-red-500">Error rendering preview: {error.message}</p> |
||||
{/await} |
||||
{/key} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
<Button |
||||
type="button" |
||||
size="xs" |
||||
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100" |
||||
color="light" |
||||
on:click={toggleSize} |
||||
> |
||||
{isExpanded ? '⌃' : '⌄'} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="flex justify-end space-x-4"> |
||||
<Button type="button" color="alternative" on:click={clearForm}> |
||||
Clear Form |
||||
</Button> |
||||
<Button type="submit" tabindex={0}> |
||||
{#if isSubmitting} |
||||
Submitting... |
||||
{:else} |
||||
Submit Issue |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
|
||||
{#if submissionSuccess && submittedEvent} |
||||
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert"> |
||||
<!-- Close button --> |
||||
<button |
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100" |
||||
on:click={closeSuccessMessage} |
||||
aria-label="Close" |
||||
> |
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> |
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> |
||||
</svg> |
||||
</button> |
||||
|
||||
<div class="flex items-center mb-3"> |
||||
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> |
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path> |
||||
</svg> |
||||
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span> |
||||
</div> |
||||
|
||||
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600"> |
||||
<div class="mb-2"> |
||||
<span class="font-semibold">Subject:</span> |
||||
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span> |
||||
</div> |
||||
<div> |
||||
<span class="font-semibold">Description:</span> |
||||
<div class="mt-1 note-leather max-h-[400px] overflow-y-auto"> |
||||
{#await parseAdvancedmarkup(submittedEvent.content)} |
||||
<p>Loading...</p> |
||||
{:then html} |
||||
{@html html} |
||||
{:catch error} |
||||
<p class="text-red-500">Error rendering markup: {error.message}</p> |
||||
{/await} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="mb-3"> |
||||
<span class="font-semibold">View your issue:</span> |
||||
<div class="mt-1"> |
||||
<A href={issueLink} target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all"> |
||||
{issueLink} |
||||
</A> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Display successful relays --> |
||||
<div class="text-sm"> |
||||
<span class="font-semibold">Successfully published to relays:</span> |
||||
<ul class="list-disc list-inside mt-1"> |
||||
{#each successfulRelays as relay} |
||||
<li class="text-success-700 dark:text-success-300">{relay}</li> |
||||
{/each} |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if submissionError} |
||||
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> |
||||
{submissionError} |
||||
</div> |
||||
{/if} |
||||
</form> |
||||
|
||||
</main> |
||||
</div> |
||||
|
||||
<!-- Confirmation Dialog --> |
||||
<Modal |
||||
bind:open={showConfirmDialog} |
||||
size="sm" |
||||
autoclose={false} |
||||
class="w-full" |
||||
> |
||||
<div class="text-center"> |
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400"> |
||||
Would you like to submit the issue? |
||||
</h3> |
||||
<div class="flex justify-center gap-4"> |
||||
<Button color="alternative" on:click={cancelSubmit}> |
||||
Cancel |
||||
</Button> |
||||
<Button color="primary" on:click={confirmSubmit}> |
||||
Submit |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</Modal> |
||||
|
||||
<!-- Login Modal --> |
||||
<LoginModal |
||||
show={showLoginModal} |
||||
onClose={() => showLoginModal = false} |
||||
onLoginSuccess={() => { |
||||
// Restore saved form data |
||||
if (savedFormData.subject) subject = savedFormData.subject; |
||||
if (savedFormData.content) content = savedFormData.content; |
||||
|
||||
// Submit the issue |
||||
submitIssue(); |
||||
}} |
||||
/> |
||||
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
<script lang="ts"> |
||||
import { Heading, Img, P, A } from "flowbite-svelte"; |
||||
|
||||
// Get the git tag version from environment variables |
||||
const appVersion = import.meta.env.APP_VERSION || "development"; |
||||
const isVersionKnown = appVersion !== "development"; |
||||
</script> |
||||
|
||||
<div class="w-full flex justify-center"> |
||||
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> |
||||
<Heading tag="h1" class="h-leather mb-2" |
||||
>Getting Started with Alexandria</Heading |
||||
> |
||||
|
||||
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading> |
||||
|
||||
<P class="mb-4"> |
||||
Alexandria opens up to the <A href="./">landing page</A>, where the user |
||||
can: login (top-right), select whether to only view the publications |
||||
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank" |
||||
>thecitadel document relay</A |
||||
> or add in their own relays, and scroll/search the publications. |
||||
</P> |
||||
|
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img |
||||
src="/screenshots/LandingPage.png" |
||||
alt="Landing page" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
<Img |
||||
src="/screenshots/YourRelays.png" |
||||
alt="Relay selection" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
</div> |
||||
|
||||
<P class="mb-3"> |
||||
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). |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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.) |
||||
</P> |
||||
|
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img |
||||
src="/screenshots/ToC_icon.png" |
||||
alt="ToC icon" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
<Img |
||||
src="/screenshots/TableOfContents.png" |
||||
alt="Table of contents example" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
</div> |
||||
|
||||
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading> |
||||
|
||||
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
The most common use for Alexandria is for e-books: both those the 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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
An example of a book is <A |
||||
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition" |
||||
>Jane Eyre</A |
||||
> |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img |
||||
src="/screenshots/JaneEyre.png" |
||||
alt="Jane Eyre, by Charlotte Brontë" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
</div> |
||||
|
||||
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
Euler will also pioneer the HTTP-based (rather than websocket-based) |
||||
e-paper compatible version of the web app. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
An example of a research paper is <A |
||||
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1" |
||||
>Less Partnering, Less Children, or Both?</A |
||||
> |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img |
||||
src="/screenshots/ResearchPaper.png" |
||||
alt="Research paper" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
</div> |
||||
|
||||
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
Our own team uses Alexandria to document the app, to display our <A |
||||
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A |
||||
>, as well as to store copies of our most interesting <A |
||||
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1" |
||||
>technical specifications</A |
||||
>. |
||||
</P> |
||||
|
||||
<div class="flex justify-center my-4"> |
||||
<Img |
||||
src="/screenshots/Documentation.png" |
||||
alt="Documentation" |
||||
class="image-border rounded-lg" |
||||
width="400" |
||||
/> |
||||
</div> |
||||
|
||||
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading> |
||||
|
||||
<P class="mb-3"> |
||||
Alexandria now supports wiki pages (kind 30818), allowing for |
||||
collaborative knowledge bases and documentation. Wiki pages, such as this |
||||
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same |
||||
Asciidoc format as other publications but are specifically designed for interconnected, |
||||
evolving content. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
</main> |
||||
</div> |
||||
@ -0,0 +1,19 @@
@@ -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'; |
||||
@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'; |
||||
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
||||
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
||||
import { readFileSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
|
||||
const testFilePath = join(__dirname, './markupTestfile.md'); |
||||
const md = readFileSync(testFilePath, 'utf-8'); |
||||
|
||||
describe('Markup Integration Test', () => { |
||||
it('parses markupTestfile.md with the basic parser', async () => { |
||||
const output = await parseBasicmarkup(md); |
||||
// Headers (should be present as text, not <h1> tags)
|
||||
expect(output).toContain('This is a test'); |
||||
expect(output).toContain('============'); |
||||
expect(output).toContain('### Disclaimer'); |
||||
// Unordered list
|
||||
expect(output).toContain('<ul'); |
||||
expect(output).toContain('but'); |
||||
// Ordered list
|
||||
expect(output).toContain('<ol'); |
||||
expect(output).toContain('first'); |
||||
// Nested lists
|
||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||
// Blockquotes
|
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('This is important information'); |
||||
// Inline code
|
||||
expect(output).toContain('<div class="leather min-h-full w-full flex flex-col items-center">'); |
||||
// Images
|
||||
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
||||
// Links
|
||||
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
||||
// Hashtags
|
||||
expect(output).toContain('text-primary-600'); |
||||
// Nostr identifiers (should be njump.me links)
|
||||
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
||||
// Wikilinks
|
||||
expect(output).toContain('wikilink'); |
||||
// YouTube iframe
|
||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||
expect(output).not.toMatch(/utm_/); |
||||
expect(output).not.toMatch(/fbclid/); |
||||
expect(output).not.toMatch(/gclid/); |
||||
// Horizontal rule (should be present as --- in basic)
|
||||
expect(output).toContain('---'); |
||||
// Footnote references (should be present as [^1] in basic)
|
||||
expect(output).toContain('[^1]'); |
||||
// Table (should be present as | Syntax | Description | in basic)
|
||||
expect(output).toContain('| Syntax | Description |'); |
||||
}); |
||||
|
||||
it('parses markupTestfile.md with the advanced parser', async () => { |
||||
const output = await parseAdvancedmarkup(md); |
||||
// Headers
|
||||
expect(output).toContain('<h1'); |
||||
expect(output).toContain('<h2'); |
||||
expect(output).toContain('Disclaimer'); |
||||
// Unordered list
|
||||
expect(output).toContain('<ul'); |
||||
expect(output).toContain('but'); |
||||
// Ordered list
|
||||
expect(output).toContain('<ol'); |
||||
expect(output).toContain('first'); |
||||
// Nested lists
|
||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
||||
// Blockquotes
|
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('This is important information'); |
||||
// Inline code
|
||||
expect(output).toMatch(/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s); |
||||
// Images
|
||||
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
||||
// Links
|
||||
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
||||
// Hashtags
|
||||
expect(output).toContain('text-primary-600'); |
||||
// Nostr identifiers (should be njump.me links)
|
||||
expect(output).toContain('https://njump.me/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
||||
// Wikilinks
|
||||
expect(output).toContain('wikilink'); |
||||
// YouTube iframe
|
||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
||||
expect(output).not.toMatch(/utm_/); |
||||
expect(output).not.toMatch(/fbclid/); |
||||
expect(output).not.toMatch(/gclid/); |
||||
// Horizontal rule
|
||||
expect(output).toContain('<hr'); |
||||
// Footnote references and section
|
||||
expect(output).toContain('Footnotes'); |
||||
expect(output).toMatch(/<li id=\"fn-1\">/); |
||||
// Table
|
||||
expect(output).toContain('<table'); |
||||
// Code blocks
|
||||
expect(output).toContain('<pre'); |
||||
}); |
||||
});
|
||||
@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
|
||||
This is a test |
||||
============ |
||||
|
||||
### Disclaimer |
||||
|
||||
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] |
||||
|
||||
# H1 |
||||
## H2 |
||||
### H3 |
||||
#### H4 |
||||
##### H5 |
||||
###### H6 |
||||
|
||||
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser. |
||||
|
||||
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]] |
||||
|
||||
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. |
||||
|
||||
> This is important information |
||||
|
||||
> This is multiple |
||||
> lines of |
||||
> important information |
||||
> with a second[^2] footnote. |
||||
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984. |
||||
|
||||
This is a youtube link |
||||
https://www.youtube.com/watch?v=9aqVxNCpx9s |
||||
|
||||
And here is a link with tracking tokens: |
||||
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU |
||||
|
||||
This is an unordered list: |
||||
* but |
||||
* not |
||||
* really |
||||
|
||||
This is an unordered list with nesting: |
||||
* but |
||||
* not |
||||
* really |
||||
* but |
||||
* yes, |
||||
* really |
||||
|
||||
## More testing |
||||
|
||||
An ordered list: |
||||
1. first |
||||
2. second |
||||
3. third |
||||
|
||||
Let's nest that: |
||||
1. first |
||||
2. second indented |
||||
3. third |
||||
4. fourth indented |
||||
5. fifth indented even more |
||||
6. sixth under the fourth |
||||
7. seventh under the sixth |
||||
8. eighth under the third |
||||
|
||||
This is ordered and unordered mixed: |
||||
1. first |
||||
2. second indented |
||||
3. third |
||||
* make this a bullet point |
||||
4. fourth indented even more |
||||
* second bullet point |
||||
|
||||
Here is a horizontal rule: |
||||
|
||||
--- |
||||
|
||||
Try embedded a nostr note with nevent: |
||||
|
||||
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske |
||||
|
||||
Here a note with no prefix |
||||
|
||||
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz |
||||
|
||||
Here with a naddr: |
||||
|
||||
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz |
||||
|
||||
Here's a nonsense one: |
||||
|
||||
nevent123 |
||||
|
||||
And a nonsense one with a prefix: |
||||
|
||||
nostr:naddrwhatever |
||||
|
||||
And some Nostr addresses that should be preserved and have a internal link appended: |
||||
|
||||
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z |
||||
|
||||
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr |
||||
|
||||
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk |
||||
|
||||
URL with a tracking parameter, no markup: |
||||
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale |
||||
|
||||
Image without markup: |
||||
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg |
||||
|
||||
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. |
||||
|
||||
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses: |
||||
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw |
||||
|
||||
But not if they have d-tags: |
||||
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1 |
||||
|
||||
And within a markup tag: [markup link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c). |
||||
|
||||
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25 |
||||
|
||||
http://localhost:4173/profile?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf |
||||
|
||||
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or |
||||
|
||||
``` |
||||
in a code block |
||||
``` |
||||
|
||||
You can even use a multi-line code block, with a json tag. |
||||
|
||||
```json |
||||
{ |
||||
"created_at":1745038670,"content":"# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n\n\n[^1]: this is a footnote\n[^2]: so is this","tags":[["subject","test"],["alt","git repository issue: test"],["a","30617:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:Alexandria","","root"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["t","gitstuff"]],"kind":1621,"pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","id":"e78a689369511fdb3c36b990380c2d8db2b5e62f13f6b836e93ef5a09611afe8","sig":"7a2b3a6f6f61b6ea04de1fe873e46d40f2a220f02cdae004342430aa1df67647a9589459382f22576c651b3d09811546bbd79564cf472deaff032f137e94a865" |
||||
} |
||||
``` |
||||
|
||||
C or C++: |
||||
```cpp |
||||
bool getBit(int num, int i) { |
||||
return ((num & (1<<i)) != 0); |
||||
} |
||||
``` |
||||
|
||||
Asciidoc: |
||||
```adoc |
||||
= Header 1 |
||||
|
||||
preamble goes here |
||||
|
||||
== Header 2 |
||||
|
||||
some more text |
||||
``` |
||||
|
||||
Gherkin: |
||||
```gherkin |
||||
Feature: Account Holder withdraws cash |
||||
|
||||
Scenario: Account has sufficient funds |
||||
Given The account balance is $100 |
||||
And the card is valid |
||||
And the machine contains enough money |
||||
When the Account Holder requests $20 |
||||
Then the ATM should dispense $20 |
||||
And the account balance should be $80 |
||||
And the card should be returned |
||||
``` |
||||
|
||||
Go: |
||||
```go |
||||
package main |
||||
|
||||
import ( |
||||
"fmt" |
||||
"bufio" |
||||
"os" |
||||
) |
||||
|
||||
func main() { |
||||
scanner := bufio.NewScanner(os.Stdin) |
||||
fmt.Print("Enter text: ") |
||||
scanner.Scan() |
||||
input := scanner.Text() |
||||
fmt.Println("You entered:", input) |
||||
} |
||||
``` |
||||
|
||||
or even markup: |
||||
|
||||
```md |
||||
A H1 Header |
||||
============ |
||||
|
||||
Paragraphs are separated by a blank line. |
||||
|
||||
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists |
||||
look like: |
||||
|
||||
* this one[^some reference text] |
||||
* that one |
||||
* the other one |
||||
|
||||
Note that --- not considering the asterisk --- the actual text |
||||
content starts at 4-columns in. |
||||
|
||||
> Block quotes are |
||||
> written like so. |
||||
> |
||||
> They can span multiple paragraphs, |
||||
> if you like. |
||||
``` |
||||
|
||||
Test out some emojis :heart: and :trophy: |
||||
|
||||
#### Here is an image![^some reference text] |
||||
|
||||
 |
||||
|
||||
### I went ahead and implemented tables, too. |
||||
|
||||
A neat table[^some reference text]: |
||||
|
||||
| Syntax | Description | |
||||
| ----------- | ----------- | |
||||
| Header | Title | |
||||
| Paragraph | Text | |
||||
|
||||
A messy table (should render the same as above): |
||||
|
||||
| Syntax | Description | |
||||
| --- | ----------- | |
||||
| Header | Title | |
||||
| Paragraph | Text | |
||||
|
||||
Here is a table without a header row: |
||||
|
||||
| Sometimes | you don't | |
||||
| need a | header | |
||||
| just | pipes | |
||||
|
||||
[^1]: this is a footnote |
||||
[^some reference text]: this is a footnote that isn't a number |
||||
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest'; |
||||
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
||||
|
||||
function stripWS(str: string) { |
||||
return str.replace(/\s+/g, ' ').trim(); |
||||
} |
||||
|
||||
describe('Advanced Markup Parser', () => { |
||||
it('parses headers (ATX and Setext)', async () => { |
||||
const input = '# H1\nText\n\nH2\n====\n'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(stripWS(output)).toContain('H1'); |
||||
expect(stripWS(output)).toContain('H2'); |
||||
}); |
||||
|
||||
it('parses bold, italic, and strikethrough', async () => { |
||||
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<strong>bold</strong>'); |
||||
expect(output).toContain('<em>italic</em>'); |
||||
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
||||
}); |
||||
|
||||
it('parses blockquotes', async () => { |
||||
const input = '> quote'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('quote'); |
||||
}); |
||||
|
||||
it('parses multi-line blockquotes', async () => { |
||||
const input = '> quote\n> quote'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('quote'); |
||||
expect(output).toContain('quote'); |
||||
}); |
||||
|
||||
it('parses unordered lists', async () => { |
||||
const input = '* a\n* b'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<ul'); |
||||
expect(output).toContain('a'); |
||||
expect(output).toContain('b'); |
||||
}); |
||||
|
||||
it('parses ordered lists', async () => { |
||||
const input = '1. one\n2. two'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<ol'); |
||||
expect(output).toContain('one'); |
||||
expect(output).toContain('two'); |
||||
}); |
||||
|
||||
it('parses links and images', async () => { |
||||
const input = '[link](https://example.com) '; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<a'); |
||||
expect(output).toContain('<img'); |
||||
}); |
||||
|
||||
it('parses hashtags', async () => { |
||||
const input = '#hashtag'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('text-primary-600'); |
||||
expect(output).toContain('#hashtag'); |
||||
}); |
||||
|
||||
it('parses nostr identifiers', async () => { |
||||
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
||||
}); |
||||
|
||||
it('parses emoji shortcodes', async () => { |
||||
const input = 'hello :smile:'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toMatch(/😄|:smile:/); |
||||
}); |
||||
|
||||
it('parses wikilinks', async () => { |
||||
const input = '[[Test Page|display]]'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('wikilink'); |
||||
expect(output).toContain('display'); |
||||
}); |
||||
|
||||
it('parses tables (with and without headers)', async () => { |
||||
const input = `| Syntax | Description |\n|--------|-------------|\n| Header | Title |\n| Paragraph | Text |\n\n| a | b |\n| c | d |`; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<table'); |
||||
expect(output).toContain('Header'); |
||||
expect(output).toContain('a'); |
||||
}); |
||||
|
||||
it('parses code blocks (with and without language)', async () => { |
||||
const input = '```js\nconsole.log(1);\n```\n```\nno lang\n```'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
const textOnly = output.replace(/<[^>]+>/g, ''); |
||||
expect(output).toContain('<pre'); |
||||
expect(textOnly).toContain('console.log(1);'); |
||||
expect(textOnly).toContain('no lang'); |
||||
}); |
||||
|
||||
it('parses horizontal rules', async () => { |
||||
const input = '---'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('<hr'); |
||||
}); |
||||
|
||||
it('parses footnotes (references and section)', async () => { |
||||
const input = 'Here is a footnote[^1].\n\n[^1]: This is the footnote.'; |
||||
const output = await parseAdvancedmarkup(input); |
||||
expect(output).toContain('Footnotes'); |
||||
expect(output).toContain('This is the footnote'); |
||||
expect(output).toContain('fn-1'); |
||||
}); |
||||
});
|
||||
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'; |
||||
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
||||
|
||||
// Helper to strip whitespace for easier comparison
|
||||
function stripWS(str: string) { |
||||
return str.replace(/\s+/g, ' ').trim(); |
||||
} |
||||
|
||||
describe('Basic Markup Parser', () => { |
||||
it('parses ATX and Setext headers', async () => { |
||||
const input = '# H1\nText\n\nH2\n====\n'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(stripWS(output)).toContain('H1'); |
||||
expect(stripWS(output)).toContain('H2'); |
||||
}); |
||||
|
||||
it('parses bold, italic, and strikethrough', async () => { |
||||
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<strong>bold</strong>'); |
||||
expect(output).toContain('<em>italic</em>'); |
||||
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
||||
}); |
||||
|
||||
it('parses blockquotes', async () => { |
||||
const input = '> quote'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('quote'); |
||||
}); |
||||
|
||||
it('parses multi-line blockquotes', async () => { |
||||
const input = '> quote\n> quote'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<blockquote'); |
||||
expect(output).toContain('quote'); |
||||
expect(output).toContain('quote'); |
||||
}); |
||||
|
||||
it('parses unordered lists', async () => { |
||||
const input = '* a\n* b'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<ul'); |
||||
expect(output).toContain('a'); |
||||
expect(output).toContain('b'); |
||||
}); |
||||
|
||||
it('parses ordered lists', async () => { |
||||
const input = '1. one\n2. two'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<ol'); |
||||
expect(output).toContain('one'); |
||||
expect(output).toContain('two'); |
||||
}); |
||||
|
||||
it('parses links and images', async () => { |
||||
const input = '[link](https://example.com) '; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('<a'); |
||||
expect(output).toContain('<img'); |
||||
}); |
||||
|
||||
it('parses hashtags', async () => { |
||||
const input = '#hashtag'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('text-primary-600'); |
||||
expect(output).toContain('#hashtag'); |
||||
}); |
||||
|
||||
it('parses nostr identifiers', async () => { |
||||
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('https://njump.me/npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
||||
}); |
||||
|
||||
it('parses emoji shortcodes', async () => { |
||||
const input = 'hello :smile:'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toMatch(/😄|:smile:/); |
||||
}); |
||||
|
||||
it('parses wikilinks', async () => { |
||||
const input = '[[Test Page|display]]'; |
||||
const output = await parseBasicmarkup(input); |
||||
expect(output).toContain('wikilink'); |
||||
expect(output).toContain('display'); |
||||
}); |
||||
});
|
||||
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
export function sum(a, b) { |
||||
return a + b |
||||
} |
||||
@ -1,6 +0,0 @@
@@ -1,6 +0,0 @@
|
||||
import { expect, test } from 'vitest' |
||||
import { sum } from './example.js' |
||||
|
||||
test('adds 1 + 2 to equal 3', () => { |
||||
expect(sum(1, 2)).toBe(3) |
||||
}) |
||||
Loading…
Reference in new issue