Browse Source

Ran `deno fmt`

master
buttercat1791 6 months ago
parent
commit
c06f102e4a
  1. 6
      postcss.config.js
  2. 34
      src/app.css
  3. 14
      src/app.html
  4. 13
      src/lib/a/README.md
  5. 107
      src/lib/components/event_input/eventServices.ts
  6. 2
      src/lib/components/event_input/types.ts
  7. 37
      src/lib/components/event_input/validation.ts
  8. 80
      src/lib/data_structures/publication_tree.ts
  9. 10
      src/lib/nostr/event.ts
  10. 23
      src/lib/nostr/format.ts
  11. 21
      src/lib/nostr/nip05.ts
  12. 148
      src/lib/nostr/nip58.ts
  13. 10
      src/lib/nostr/types.ts
  14. 14
      src/lib/stores/techStore.ts
  15. 14
      src/lib/stores/themeStore.ts
  16. 7
      src/lib/stores/userStore.ts
  17. 4
      src/lib/styles/cva.ts
  18. 61
      src/lib/utils/asciidoc_metadata.ts
  19. 48
      src/lib/utils/cache_manager.ts
  20. 53
      src/lib/utils/event_input_utils.ts
  21. 5
      src/lib/utils/event_search.ts
  22. 2
      src/lib/utils/image_utils.ts
  23. 48
      src/lib/utils/markup/advancedMarkupParser.ts
  24. 12
      src/lib/utils/markup/basicMarkupParser.ts
  25. 5
      src/lib/utils/markup/embeddedMarkupParser.ts
  26. 75
      src/lib/utils/markup/markupUtils.ts
  27. 24
      src/lib/utils/nostrUtils.ts
  28. 99
      src/lib/utils/npubCache.ts
  29. 53
      src/lib/utils/profile_search.ts
  30. 366
      src/lib/utils/subscription_search.ts
  31. 113
      src/lib/utils/user_lists.ts
  32. 1
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  33. 3
      src/routes/publication/[type]/[identifier]/+page.ts
  34. 8
      src/styles/notifications.css
  35. 3
      src/styles/scrollbar.css
  36. 111
      tests/unit/mathProcessing.test.ts
  37. 8
      tests/unit/tagExpansion.test.ts

6
postcss.config.js

@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {} "@tailwindcss/postcss": {},
} },
} };

34
src/app.css

@ -226,7 +226,8 @@
div.note-leather, div.note-leather,
p.note-leather, p.note-leather,
section.note-leather { section.note-leather {
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 p-2 rounded; @apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
p-2 rounded;
} }
.edit div.note-leather:hover:not(:has(.note-leather:hover)), .edit div.note-leather:hover:not(:has(.note-leather:hover)),
@ -278,7 +279,8 @@
} }
div.modal-leather > div { div.modal-leather > div {
@apply bg-primary-50 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; @apply bg-primary-50 dark:bg-primary-950 border-b-[1px] border-primary-100
dark:border-primary-600;
} }
div.modal-leather > div > h1, div.modal-leather > div > h1,
@ -292,7 +294,9 @@
} }
div.modal-leather button { div.modal-leather button {
@apply bg-primary-50 hover:bg-primary-50 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400; @apply bg-primary-50 hover:bg-primary-50 dark:bg-primary-950
dark:hover:bg-primary-950 text-gray-900 hover:text-primary-600
dark:text-gray-100 dark:hover:text-primary-400;
} }
/* Navbar */ /* Navbar */
@ -491,11 +495,11 @@
/* Legend */ /* Legend */
.leather-legend { .leather-legend {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2
rounded; rounded;
@apply shadow-none text-primary-1000 border border-s-4 bg-highlight @apply shadow-none text-primary-1000 border border-s-4 bg-highlight
border-primary-200 has-[:hover]:border-primary-700; border-primary-200 has-[:hover]:border-primary-700;
@apply dark:bg-primary-1000 dark:border-primary-800 @apply dark:bg-primary-1000 dark:border-primary-800
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500;
max-width: 450px; max-width: 450px;
min-width: 300px; min-width: 300px;
overflow-x: auto; overflow-x: auto;
@ -504,7 +508,9 @@
/* Tooltip */ /* Tooltip */
.tooltip-leather { .tooltip-leather {
@apply fixed p-4 rounded shadow-lg bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700 transition-colors duration-200; @apply fixed p-4 rounded shadow-lg bg-primary-50 dark:bg-primary-1000
text-gray-900 dark:text-gray-100 border border-gray-200
dark:border-gray-700 transition-colors duration-200;
max-width: 400px; max-width: 400px;
z-index: 1000; z-index: 1000;
} }
@ -544,11 +550,11 @@
} }
h4 { h4 {
@apply text-xl font-bold; @apply text-xl font-bold;
} }
h5 { h5 {
@apply text-lg font-semibold; @apply text-lg font-semibold;
} }
h6 { h6 {
@ -585,7 +591,8 @@
} }
a { a {
@apply underline cursor-pointer hover:text-primary-600 dark:hover:text-primary-400; @apply underline cursor-pointer hover:text-primary-600
dark:hover:text-primary-400;
} }
.imageblock { .imageblock {
@ -728,14 +735,15 @@
input[type="tel"], input[type="tel"],
input[type="url"], input[type="url"],
textarea { textarea {
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none; @apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100
border-s-4 border-primary-200 rounded shadow-none;
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }
/* Table of Contents highlighting */ /* Table of Contents highlighting */
.toc-highlight { .toc-highlight {
@apply bg-primary-200 dark:bg-primary-700 border-s-4 border-primary-600 rounded @apply bg-primary-200 dark:bg-primary-700 border-s-4 border-primary-600
dark:border-primary-400 font-medium; rounded dark:border-primary-400 font-medium;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }

14
src/app.html

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en" data-tech="off"> <html lang="en" data-tech="off">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -19,7 +19,9 @@
<script> <script>
try { try {
const v = localStorage.getItem("alexandria/showTech"); const v = localStorage.getItem("alexandria/showTech");
document.documentElement.dataset.tech = v === "true" ? "on" : "off"; document.documentElement.dataset.tech = v === "true"
? "on"
: "off";
} catch (_) { } catch (_) {
/* no-op */ /* no-op */
} }
@ -46,14 +48,18 @@
}, },
}; };
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> <script
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
></script>
<!-- highlight.js for code highlighting --> <!-- highlight.js for code highlighting -->
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
/> />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
></script>
%sveltekit.head% %sveltekit.head%
</head> </head>

13
src/lib/a/README.md

@ -1,10 +1,11 @@
# Component Library # Component Library
This folder contains a component library. This folder contains a component library. The idea is to have project-scoped
The idea is to have project-scoped reusable components that centralize theming and style rules, reusable components that centralize theming and style rules, so that main pages
so that main pages and layouts focus on the functionalities. and layouts focus on the functionalities.
All components are based on Flowbite Svelte components, All components are based on Flowbite Svelte components, which are built on top
which are built on top of Tailwind CSS. of Tailwind CSS.
Keeping all the styles in one place allows us to easily change the look and feel of the application by switching themes. Keeping all the styles in one place allows us to easily change the look and feel
of the application by switching themes.

107
src/lib/components/event_input/eventServices.ts

@ -14,35 +14,47 @@ import { anonymousRelays } from "$lib/consts";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils"; import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, PublishResult, LoadEventResult } from "./types"; import type {
EventData,
LoadEventResult,
PublishResult,
TagData,
} from "./types";
/** /**
* Converts TagData array to NDK-compatible format * Converts TagData array to NDK-compatible format
*/ */
function convertTagsToNDKFormat(tags: TagData[]): string[][] { function convertTagsToNDKFormat(tags: TagData[]): string[][] {
return tags return tags
.filter(tag => tag.key.trim() !== "") .filter((tag) => tag.key.trim() !== "")
.map(tag => [tag.key, ...tag.values]); .map((tag) => [tag.key, ...tag.values]);
} }
/** /**
* Publishes an event to relays * Publishes an event to relays
*/ */
export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise<PublishResult> { export async function publishEvent(
ndk: any,
eventData: EventData,
tags: TagData[],
): Promise<PublishResult> {
if (!ndk) { if (!ndk) {
return { success: false, error: "NDK context not available" }; return { success: false, error: "NDK context not available" };
} }
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey; const pubkey = userState.pubkey;
if (!pubkey) { if (!pubkey) {
return { success: false, error: "User not logged in." }; return { success: false, error: "User not logged in." };
} }
const pubkeyString = String(pubkey); const pubkeyString = String(pubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) {
return { success: false, error: "Invalid public key: must be a 64-character hex string." }; return {
success: false,
error: "Invalid public key: must be a 64-character hex string.",
};
} }
const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt }; const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt };
@ -56,48 +68,56 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (Number(eventData.kind) === 30040) { if (Number(eventData.kind) === 30040) {
console.log("=== 30040 EVENT CREATION START ==="); console.log("=== 30040 EVENT CREATION START ===");
console.log("Creating 30040 event set with content:", eventData.content); console.log("Creating 30040 event set with content:", eventData.content);
try { try {
// Get the current d and title values from the UI // Get the current d and title values from the UI
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || ""; const dTagValue = tags.find((tag) => tag.key === "d")?.values[0] || "";
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || ""; const titleTagValue = tags.find((tag) =>
tag.key === "title"
)?.values[0] || "";
// Convert multi-value tags to the format expected by build30040EventSet // Convert multi-value tags to the format expected by build30040EventSet
// Filter out d and title tags since we'll add them manually // Filter out d and title tags since we'll add them manually
const compatibleTags: [string, string][] = tags const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title") .filter((tag) =>
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title"
)
.map((tag) => [tag.key, tag.values[0] || ""] as [string, string]);
const { indexEvent, sectionEvents } = build30040EventSet( const { indexEvent, sectionEvents } = build30040EventSet(
eventData.content, eventData.content,
compatibleTags, compatibleTags,
baseEvent, baseEvent,
ndk, ndk,
); );
// Override the d and title tags with the UI values if they exist // Override the d and title tags with the UI values if they exist
const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title"); const finalTags = indexEvent.tags.filter((tag) =>
tag[0] !== "d" && tag[0] !== "title"
);
if (dTagValue) { if (dTagValue) {
finalTags.push(["d", dTagValue]); finalTags.push(["d", dTagValue]);
} }
if (titleTagValue) { if (titleTagValue) {
finalTags.push(["title", titleTagValue]); finalTags.push(["title", titleTagValue]);
} }
// Update the index event with the correct tags // Update the index event with the correct tags
indexEvent.tags = finalTags; indexEvent.tags = finalTags;
console.log("Index event:", indexEvent); console.log("Index event:", indexEvent);
console.log("Section events:", sectionEvents); console.log("Section events:", sectionEvents);
// Publish all 30041 section events first, then the 30040 index event // Publish all 30041 section events first, then the 30040 index event
events = [...sectionEvents, indexEvent]; events = [...sectionEvents, indexEvent];
console.log("Total events to publish:", events.length); console.log("Total events to publish:", events.length);
console.log("=== 30040 EVENT CREATION END ==="); console.log("=== 30040 EVENT CREATION END ===");
} catch (error) { } catch (error) {
console.error("Error in build30040EventSet:", error); console.error("Error in build30040EventSet:", error);
return { return {
success: false, success: false,
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}` error: `Failed to build 30040 event set: ${
error instanceof Error ? error.message : "Unknown error"
}`,
}; };
} }
} else { } else {
@ -109,7 +129,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
if (eventData.kind === 30040 || eventData.kind === 30041) { if (eventData.kind === 30040 || eventData.kind === 30041) {
finalContent = removeMetadataFromContent(eventData.content); finalContent = removeMetadataFromContent(eventData.content);
} }
// Prefix Nostr addresses before publishing // Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(finalContent); const prefixedContent = prefixNostrAddresses(finalContent);
@ -150,7 +170,7 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
tags: event.tags.map((tag) => tag.map(String)), tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content), content: String(event.content),
}; };
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
window.nostr && window.nostr &&
@ -178,12 +198,15 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
...get(activeOutboxRelays), ...get(activeOutboxRelays),
...get(activeInboxRelays), ...get(activeInboxRelays),
]; ];
console.log("publishEvent: Publishing to relays:", relays); console.log("publishEvent: Publishing to relays:", relays);
console.log("publishEvent: Anonymous relays:", anonymousRelays); console.log("publishEvent: Anonymous relays:", anonymousRelays);
console.log("publishEvent: Active outbox relays:", get(activeOutboxRelays)); console.log(
"publishEvent: Active outbox relays:",
get(activeOutboxRelays),
);
console.log("publishEvent: Active inbox relays:", get(activeInboxRelays)); console.log("publishEvent: Active inbox relays:", get(activeInboxRelays));
let published = false; let published = false;
for (const relayUrl of relays) { for (const relayUrl of relays) {
@ -234,18 +257,20 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
} }
} catch (signError) { } catch (signError) {
console.error("Error signing/publishing event:", signError); console.error("Error signing/publishing event:", signError);
return { return {
success: false, success: false,
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}` error: `Failed to sign event: ${
signError instanceof Error ? signError.message : "Unknown error"
}`,
}; };
} }
} }
if (atLeastOne) { if (atLeastOne) {
return { return {
success: true, success: true,
eventId: lastEventId || undefined, eventId: lastEventId || undefined,
relays: relaysPublished relays: relaysPublished,
}; };
} else { } else {
return { success: false, error: "Failed to publish to any relay." }; return { success: false, error: "Failed to publish to any relay." };
@ -255,16 +280,22 @@ export async function publishEvent(ndk: any, eventData: EventData, tags: TagData
/** /**
* Loads an event by its hex ID * Loads an event by its hex ID
*/ */
export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventResult | null> { export async function loadEvent(
ndk: any,
eventId: string,
): Promise<LoadEventResult | null> {
if (!ndk) { if (!ndk) {
throw new Error("NDK context not available"); throw new Error("NDK context not available");
} }
console.log("loadEvent: Starting search for event ID:", eventId); console.log("loadEvent: Starting search for event ID:", eventId);
console.log("loadEvent: NDK pool relays:", Array.from(ndk.pool.relays.values()).map((r: any) => r.url)); console.log(
"loadEvent: NDK pool relays:",
Array.from(ndk.pool.relays.values()).map((r: any) => r.url),
);
console.log("loadEvent: Active inbox relays:", get(activeInboxRelays)); console.log("loadEvent: Active inbox relays:", get(activeInboxRelays));
console.log("loadEvent: Active outbox relays:", get(activeOutboxRelays)); console.log("loadEvent: Active outbox relays:", get(activeOutboxRelays));
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000); const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000);
if (foundEvent) { if (foundEvent) {
@ -279,7 +310,7 @@ export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventRes
// Convert NDK tags format to our format // Convert NDK tags format to our format
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({ const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({
key: tag[0] || "", key: tag[0] || "",
values: tag.slice(1) values: tag.slice(1),
})); }));
return { eventData, tags }; return { eventData, tags };

2
src/lib/components/event_input/types.ts

@ -32,7 +32,7 @@ export interface LoadEventResult {
} }
export interface EventPreview { export interface EventPreview {
type: 'standard_event' | '30040_index_event' | 'error'; type: "standard_event" | "30040_index_event" | "error";
event?: { event?: {
id: string; id: string;
pubkey: string; pubkey: string;

37
src/lib/components/event_input/validation.ts

@ -6,22 +6,25 @@ import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
import type { EventData, TagData, ValidationResult } from "./types"; import type { EventData, TagData, ValidationResult } from "./types";
import { import {
validateNotAsciidoc,
validateAsciiDoc,
validate30040EventSet, validate30040EventSet,
validateAsciiDoc,
validateNotAsciidoc,
} from "$lib/utils/event_input_utils"; } from "$lib/utils/event_input_utils";
/** /**
* Validates an event and its tags * Validates an event and its tags
*/ */
export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult { export function validateEvent(
eventData: EventData,
tags: TagData[],
): ValidationResult {
const userState = get(userStore); const userState = get(userStore);
const pubkey = userState.pubkey; const pubkey = userState.pubkey;
if (!pubkey) { if (!pubkey) {
return { valid: false, reason: "Not logged in." }; return { valid: false, reason: "Not logged in." };
} }
// Content validation - 30040 events don't require content // Content validation - 30040 events don't require content
if (eventData.kind !== 30040 && !eventData.content.trim()) { if (eventData.kind !== 30040 && !eventData.content.trim()) {
return { valid: false, reason: "Content required." }; return { valid: false, reason: "Content required." };
@ -32,25 +35,27 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
const v = validateNotAsciidoc(eventData.content); const v = validateNotAsciidoc(eventData.content);
if (!v.valid) return v; if (!v.valid) return v;
} }
if (eventData.kind === 30040) { if (eventData.kind === 30040) {
// Check for required tags // Check for required tags
const versionTag = tags.find(t => t.key === "version"); const versionTag = tags.find((t) => t.key === "version");
const dTag = tags.find(t => t.key === "d"); const dTag = tags.find((t) => t.key === "d");
const titleTag = tags.find(t => t.key === "title"); const titleTag = tags.find((t) => t.key === "title");
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") { if (
!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === ""
) {
return { valid: false, reason: "30040 events require a 'version' tag." }; return { valid: false, reason: "30040 events require a 'version' tag." };
} }
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") { if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'd' tag." }; return { valid: false, reason: "30040 events require a 'd' tag." };
} }
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") { if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") {
return { valid: false, reason: "30040 events require a 'title' tag." }; return { valid: false, reason: "30040 events require a 'title' tag." };
} }
// Validate content format if present // Validate content format if present
if (eventData.content.trim()) { if (eventData.content.trim()) {
const v = validate30040EventSet(eventData.content); const v = validate30040EventSet(eventData.content);
@ -58,7 +63,7 @@ export function validateEvent(eventData: EventData, tags: TagData[]): Validation
if (v.warning) return { valid: true, warning: v.warning }; if (v.warning) return { valid: true, warning: v.warning };
} }
} }
if (eventData.kind === 30041 || eventData.kind === 30818) { if (eventData.kind === 30041 || eventData.kind === 30818) {
const v = validateAsciiDoc(eventData.content); const v = validateAsciiDoc(eventData.content);
if (!v.valid) return v; if (!v.valid) return v;
@ -86,5 +91,5 @@ export function isValidTagKey(key: string): boolean {
* Validates that a tag has at least one value * Validates that a tag has at least one value
*/ */
export function isValidTag(tag: TagData): boolean { export function isValidTag(tag: TagData): boolean {
return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0); return isValidTagKey(tag.key) && tag.values.some((v) => v.trim().length > 0);
} }

80
src/lib/data_structures/publication_tree.ts

@ -253,7 +253,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Clear all nodes except the root to force fresh loading // Clear all nodes except the root to force fresh loading
const rootAddress = this.#root.address; const rootAddress = this.#root.address;
this.#nodes.clear(); this.#nodes.clear();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root))); this.#nodes.set(
rootAddress,
new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)),
);
// Clear events cache to ensure fresh data // Clear events cache to ensure fresh data
this.#events.clear(); this.#events.clear();
this.#eventCache.clear(); this.#eventCache.clear();
@ -496,18 +499,20 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (!this.#cursor.target) { if (!this.#cursor.target) {
return { done, value: null }; return { done, value: null };
} }
const address = this.#cursor.target.address; const address = this.#cursor.target.address;
// AI-NOTE: Check if this node has already been visited // AI-NOTE: Check if this node has already been visited
if (this.#visitedNodes.has(address)) { if (this.#visitedNodes.has(address)) {
console.debug(`[PublicationTree] Skipping already visited node: ${address}`); console.debug(
`[PublicationTree] Skipping already visited node: ${address}`,
);
return { done: false, value: null }; return { done: false, value: null };
} }
// Mark this node as visited // Mark this node as visited
this.#visitedNodes.add(address); this.#visitedNodes.add(address);
const value = (await this.getEvent(address)) ?? null; const value = (await this.getEvent(address)) ?? null;
return { done, value }; return { done, value };
} }
@ -762,8 +767,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
#addNode(address: string, parentNode: PublicationTreeNode) { #addNode(address: string, parentNode: PublicationTreeNode) {
// AI-NOTE: Add debugging to track node addition // AI-NOTE: Add debugging to track node addition
console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`); console.debug(
`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`,
);
const lazyNode = new Lazy<PublicationTreeNode>(() => const lazyNode = new Lazy<PublicationTreeNode>(() =>
this.#resolveNode(address, parentNode) this.#resolveNode(address, parentNode)
); );
@ -902,7 +909,11 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
this.#eventCache.set(address, fetchedEvent); this.#eventCache.set(address, fetchedEvent);
this.#events.set(address, fetchedEvent); this.#events.set(address, fetchedEvent);
return await this.#buildNodeFromEvent(fetchedEvent, address, parentNode); return await this.#buildNodeFromEvent(
fetchedEvent,
address,
parentNode,
);
} }
} catch (error) { } catch (error) {
console.debug( console.debug(
@ -1017,7 +1028,9 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// AI-NOTE: Remove e-tag processing from synchronous method // AI-NOTE: Remove e-tag processing from synchronous method
// E-tags should be resolved asynchronously in #resolveNode method // E-tags should be resolved asynchronously in #resolveNode method
// Adding raw event IDs here causes duplicate processing // Adding raw event IDs here causes duplicate processing
console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`); console.debug(
`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`,
);
} }
const node: PublicationTreeNode = { const node: PublicationTreeNode = {
@ -1033,13 +1046,18 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
// Now directly adds child nodes to current node's children array // Now directly adds child nodes to current node's children array
// Add children in the order they appear in the a-tags to preserve section order // Add children in the order they appear in the a-tags to preserve section order
// Use sequential processing to ensure order is maintained // Use sequential processing to ensure order is maintained
console.log(`[PublicationTree] Adding ${childAddresses.length} children in order:`, childAddresses); console.log(
`[PublicationTree] Adding ${childAddresses.length} children in order:`,
childAddresses,
);
for (const childAddress of childAddresses) { for (const childAddress of childAddresses) {
console.log(`[PublicationTree] Adding child: ${childAddress}`); console.log(`[PublicationTree] Adding child: ${childAddress}`);
try { try {
// Add the child node directly to the current node's children // Add the child node directly to the current node's children
this.#addNode(childAddress, node); this.#addNode(childAddress, node);
console.log(`[PublicationTree] Successfully added child: ${childAddress}`); console.log(
`[PublicationTree] Successfully added child: ${childAddress}`,
);
} catch (error) { } catch (error) {
console.warn( console.warn(
`[PublicationTree] Error adding child ${childAddress} for ${node.address}:`, `[PublicationTree] Error adding child ${childAddress} for ${node.address}:`,
@ -1060,24 +1078,44 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
if (event.kind === 30040) { if (event.kind === 30040) {
// Check if this 30040 has any children (a-tags only, since e-tags are handled separately) // Check if this 30040 has any children (a-tags only, since e-tags are handled separately)
const hasChildren = event.tags.some((tag) => tag[0] === "a"); const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`); console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf; event.tags.find((t) => t[0] === "d")?.[1]
} - hasChildren: ${hasChildren}, type: ${
hasChildren ? "Branch" : "Leaf"
}`,
);
return hasChildren
? PublicationTreeNodeType.Branch
: PublicationTreeNodeType.Leaf;
} }
// Zettel kinds are always leaves // Zettel kinds are always leaves
if ([30041, 30818, 30023].includes(event.kind)) { if ([30041, 30818, 30023].includes(event.kind)) {
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - Zettel kind, type: Leaf`); console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
event.tags.find((t) => t[0] === "d")?.[1]
} - Zettel kind, type: Leaf`,
);
return PublicationTreeNodeType.Leaf; return PublicationTreeNodeType.Leaf;
} }
// For other kinds, check if they have children (a-tags only) // For other kinds, check if they have children (a-tags only)
const hasChildren = event.tags.some((tag) => tag[0] === "a"); const hasChildren = event.tags.some((tag) => tag[0] === "a");
console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`); console.debug(
`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${
return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf; event.tags.find((t) => t[0] === "d")?.[1]
} - hasChildren: ${hasChildren}, type: ${
hasChildren ? "Branch" : "Leaf"
}`,
);
return hasChildren
? PublicationTreeNodeType.Branch
: PublicationTreeNodeType.Leaf;
} }
// #endregion // #endregion

10
src/lib/nostr/event.ts

@ -1 +1,9 @@
export type NostrEvent = { id:string; kind:number; pubkey:string; created_at:number; tags:string[][]; content:string; }; export type AddressPointer = string; export type NostrEvent = {
id: string;
kind: number;
pubkey: string;
created_at: number;
tags: string[][];
content: string;
};
export type AddressPointer = string;

23
src/lib/nostr/format.ts

@ -1 +1,22 @@
export function shortenBech32(id:string, keepPrefix=true, head=8, tail=6){ if(!id) return ''; const i=id.indexOf('1'); const prefix=i>0? id.slice(0,i):''; const data=i>0? id.slice(i+1): id; const short = data.length>head+tail ? `${'${'}data.slice(0,head)}…${'${'}data.slice(-tail)}` : data; return keepPrefix && prefix ? `${'${'}prefix}1${'${'}short}` : short; } export function displayNameFrom(npub:string, p?:{ name?:string; display_name?:string; nip05?:string }){ return (p?.display_name?.trim() || p?.name?.trim() || (p?.nip05 && p.nip05.split('@')[0]) || shortenBech32(npub,true)); } export function shortenBech32(
id: string,
keepPrefix = true,
head = 8,
tail = 6,
) {
if (!id) return "";
const i = id.indexOf("1");
const prefix = i > 0 ? id.slice(0, i) : "";
const data = i > 0 ? id.slice(i + 1) : id;
const short = data.length > head + tail
? `${"${"}data.slice(0,head)}…${"${"}data.slice(-tail)}`
: data;
return keepPrefix && prefix ? `${"${"}prefix}1${"${"}short}` : short;
}
export function displayNameFrom(
npub: string,
p?: { name?: string; display_name?: string; nip05?: string },
) {
return (p?.display_name?.trim() || p?.name?.trim() ||
(p?.nip05 && p.nip05.split("@")[0]) || shortenBech32(npub, true));
}

21
src/lib/nostr/nip05.ts

@ -1 +1,20 @@
export async function verifyNip05(nip05:string, pubkeyHex:string):Promise<boolean>{ try{ if(!nip05||!pubkeyHex) return false; const [name,domain]=nip05.toLowerCase().split('@'); if(!name||!domain) return false; const url=`https://${'${'}domain}/.well-known/nostr.json?name=${'${'}encodeURIComponent(name)}`; const res=await fetch(url,{ headers:{ Accept:'application/json' } }); if(!res.ok) return false; const json=await res.json(); const found=json?.names?.[name]; return typeof found==='string' && found.toLowerCase()===pubkeyHex.toLowerCase(); }catch{ return false; } } export async function verifyNip05(
nip05: string,
pubkeyHex: string,
): Promise<boolean> {
try {
if (!nip05 || !pubkeyHex) return false;
const [name, domain] = nip05.toLowerCase().split("@");
if (!name || !domain) return false;
const url =
`https://${"${"}domain}/.well-known/nostr.json?name=${"${"}encodeURIComponent(name)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return false;
const json = await res.json();
const found = json?.names?.[name];
return typeof found === "string" &&
found.toLowerCase() === pubkeyHex.toLowerCase();
} catch {
return false;
}
}

148
src/lib/nostr/nip58.ts

@ -1 +1,147 @@
import type { NostrEvent, AddressPointer } from './event'; export type BadgeDefinition={ kind:30009; id:string; pubkey:string; d:string; a:AddressPointer; name?:string; description?:string; image?:{ url:string; size?:string }|null; thumbs:{ url:string; size?:string }[]; }; export type BadgeAward={ kind:8; id:string; pubkey:string; a:AddressPointer; recipients:{ pubkey:string; relay?:string }[]; }; export type ProfileBadges={ kind:30008; id:string; pubkey:string; pairs:{ a:AddressPointer; awardId:string; relay?:string }[]; }; export const isKind=(e:NostrEvent,k:number)=>e.kind===k; const val=(tags:string[][],name:string)=>tags.find(t=>t[0]===name)?.[1]; const vals=(tags:string[][],name:string)=>tags.filter(t=>t[0]===name).map(t=>t.slice(1)); export function parseBadgeDefinition(e:NostrEvent):BadgeDefinition|null{ if(e.kind!==30009) return null; const d=val(e.tags,'d'); if(!d) return null; const a:AddressPointer=`30009:${'${'}e.pubkey}:${'${'}d}`; const name=val(e.tags,'name')||undefined; const description=val(e.tags,'description')||undefined; const imageTag=vals(e.tags,'image')[0]; const image=imageTag? { url:imageTag[0], size:imageTag[1] }: null; const thumbs=vals(e.tags,'thumb').map(([url,size])=>({ url, size })); return { kind:30009, id:e.id, pubkey:e.pubkey, d, a, name, description, image, thumbs }; } export function parseBadgeAward(e:NostrEvent):BadgeAward|null{ if(e.kind!==8) return null; const atag=vals(e.tags,'a')[0]; if(!atag) return null; const a:AddressPointer=atag[0]; const recipients=vals(e.tags,'p').map(([pubkey,relay])=>({ pubkey, relay })); return { kind:8, id:e.id, pubkey:e.pubkey, a, recipients }; } export function parseProfileBadges(e:NostrEvent):ProfileBadges|null{ if(e.kind!==30008) return null; const d=val(e.tags,'d'); if(d!=='profile_badges') return null; const pairs: { a:AddressPointer; awardId:string; relay?:string }[]=[]; for(let i=0;i<e.tags.length;i++){ const t=e.tags[i]; if(t[0]==='a'){ const a=t[1]; const nxt=e.tags[i+1]; if(nxt && nxt[0]==='e'){ pairs.push({ a, awardId:nxt[1], relay:nxt[2] }); i++; } } } return { kind:30008, id:e.id, pubkey:e.pubkey, pairs }; } export type DisplayBadge={ def:BadgeDefinition; award:BadgeAward|null; issuer:string; thumbUrl:string|null; title:string; }; export function pickThumb(def:BadgeDefinition, prefer:( '16'|'32'|'64'|'256'|'512')[]=['32','64','256']):string|null{ for(const p of prefer){ const t=def.thumbs.find(t=>(t.size||'').startsWith(p+'x')); if(t) return t.url; } return def.image?.url || null; } export function buildDisplayBadgesForUser(userPubkey:string, defs:BadgeDefinition[], awards:BadgeAward[], profileBadges?:ProfileBadges|null, opts:{ issuerWhitelist?:Set<string>; max?:number }={}):DisplayBadge[]{ const byA=new Map<string,BadgeDefinition>(defs.map(d=>[d.a,d])); const byAwardId=new Map<string,BadgeAward>(awards.map(a=>[a.id,a])); const isWhitelisted=(issuer:string)=>!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer); let out:DisplayBadge[]=[]; if(profileBadges && profileBadges.pubkey===userPubkey){ for(const {a,awardId} of profileBadges.pairs){ const def=byA.get(a); if(!def) continue; const award=byAwardId.get(awardId)||null; if(award && (award.a!==a || !award.recipients.find(r=>r.pubkey===userPubkey))) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } else { for(const aw of awards){ if(!aw.recipients.find(r=>r.pubkey===userPubkey)) continue; const def=byA.get(aw.a); if(!def) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award:aw, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } if(opts.max && out.length>opts.max) out=out.slice(0,opts.max); return out; } import type { AddressPointer, NostrEvent } from "./event";
export type BadgeDefinition = {
kind: 30009;
id: string;
pubkey: string;
d: string;
a: AddressPointer;
name?: string;
description?: string;
image?: { url: string; size?: string } | null;
thumbs: { url: string; size?: string }[];
};
export type BadgeAward = {
kind: 8;
id: string;
pubkey: string;
a: AddressPointer;
recipients: { pubkey: string; relay?: string }[];
};
export type ProfileBadges = {
kind: 30008;
id: string;
pubkey: string;
pairs: { a: AddressPointer; awardId: string; relay?: string }[];
};
export const isKind = (e: NostrEvent, k: number) => e.kind === k;
const val = (tags: string[][], name: string) =>
tags.find((t) => t[0] === name)?.[1];
const vals = (tags: string[][], name: string) =>
tags.filter((t) => t[0] === name).map((t) => t.slice(1));
export function parseBadgeDefinition(e: NostrEvent): BadgeDefinition | null {
if (e.kind !== 30009) return null;
const d = val(e.tags, "d");
if (!d) return null;
const a: AddressPointer = `30009:${"${"}e.pubkey}:${"${"}d}`;
const name = val(e.tags, "name") || undefined;
const description = val(e.tags, "description") || undefined;
const imageTag = vals(e.tags, "image")[0];
const image = imageTag ? { url: imageTag[0], size: imageTag[1] } : null;
const thumbs = vals(e.tags, "thumb").map(([url, size]) => ({ url, size }));
return {
kind: 30009,
id: e.id,
pubkey: e.pubkey,
d,
a,
name,
description,
image,
thumbs,
};
}
export function parseBadgeAward(e: NostrEvent): BadgeAward | null {
if (e.kind !== 8) return null;
const atag = vals(e.tags, "a")[0];
if (!atag) return null;
const a: AddressPointer = atag[0];
const recipients = vals(e.tags, "p").map(([pubkey, relay]) => ({
pubkey,
relay,
}));
return { kind: 8, id: e.id, pubkey: e.pubkey, a, recipients };
}
export function parseProfileBadges(e: NostrEvent): ProfileBadges | null {
if (e.kind !== 30008) return null;
const d = val(e.tags, "d");
if (d !== "profile_badges") return null;
const pairs: { a: AddressPointer; awardId: string; relay?: string }[] = [];
for (let i = 0; i < e.tags.length; i++) {
const t = e.tags[i];
if (t[0] === "a") {
const a = t[1];
const nxt = e.tags[i + 1];
if (nxt && nxt[0] === "e") {
pairs.push({ a, awardId: nxt[1], relay: nxt[2] });
i++;
}
}
}
return { kind: 30008, id: e.id, pubkey: e.pubkey, pairs };
}
export type DisplayBadge = {
def: BadgeDefinition;
award: BadgeAward | null;
issuer: string;
thumbUrl: string | null;
title: string;
};
export function pickThumb(
def: BadgeDefinition,
prefer: ("16" | "32" | "64" | "256" | "512")[] = ["32", "64", "256"],
): string | null {
for (const p of prefer) {
const t = def.thumbs.find((t) => (t.size || "").startsWith(p + "x"));
if (t) return t.url;
}
return def.image?.url || null;
}
export function buildDisplayBadgesForUser(
userPubkey: string,
defs: BadgeDefinition[],
awards: BadgeAward[],
profileBadges?: ProfileBadges | null,
opts: { issuerWhitelist?: Set<string>; max?: number } = {},
): DisplayBadge[] {
const byA = new Map<string, BadgeDefinition>(defs.map((d) => [d.a, d]));
const byAwardId = new Map<string, BadgeAward>(awards.map((a) => [a.id, a]));
const isWhitelisted = (issuer: string) =>
!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer);
let out: DisplayBadge[] = [];
if (profileBadges && profileBadges.pubkey === userPubkey) {
for (const { a, awardId } of profileBadges.pairs) {
const def = byA.get(a);
if (!def) {
continue;
}
const award = byAwardId.get(awardId) || null;
if (
award &&
(award.a !== a ||
!award.recipients.find((r) => r.pubkey === userPubkey))
) continue;
if (!isWhitelisted(def.pubkey)) continue;
out.push({
def,
award,
issuer: def.pubkey,
thumbUrl: pickThumb(def),
title: def.name || def.d,
});
}
} else {for (const aw of awards) {
if (!aw.recipients.find((r) => r.pubkey === userPubkey)) continue;
const def = byA.get(aw.a);
if (!def) continue;
if (!isWhitelisted(def.pubkey)) continue;
out.push({
def,
award: aw,
issuer: def.pubkey,
thumbUrl: pickThumb(def),
title: def.name || def.d,
});
}}
if (opts.max && out.length > opts.max) out = out.slice(0, opts.max);
return out;
}

10
src/lib/nostr/types.ts

@ -1 +1,9 @@
export type NostrProfile = { name?:string; display_name?:string; picture?:string; about?:string; nip05?:string; lud16?:string; badges?: Array<{ label:string; color?:string }>; }; export type NostrProfile = {
name?: string;
display_name?: string;
picture?: string;
about?: string;
nip05?: string;
lud16?: string;
badges?: Array<{ label: string; color?: string }>;
};

14
src/lib/stores/techStore.ts

@ -1,16 +1,16 @@
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
const KEY = 'alexandria/showTech'; const KEY = "alexandria/showTech";
// Default false unless explicitly set to 'true' in localStorage // Default false unless explicitly set to 'true' in localStorage
const initial = typeof localStorage !== 'undefined' const initial = typeof localStorage !== "undefined"
? localStorage.getItem(KEY) === 'true' ? localStorage.getItem(KEY) === "true"
: false; : false;
export const showTech = writable<boolean>(initial); export const showTech = writable<boolean>(initial);
showTech.subscribe(v => { showTech.subscribe((v) => {
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
document.documentElement.dataset.tech = v ? 'on' : 'off'; document.documentElement.dataset.tech = v ? "on" : "off";
localStorage.setItem(KEY, String(v)); localStorage.setItem(KEY, String(v));
} }
}); });

14
src/lib/stores/themeStore.ts

@ -1,18 +1,18 @@
import { writable } from 'svelte/store'; import { writable } from "svelte/store";
const KEY = 'alexandria/theme'; const KEY = "alexandria/theme";
const initial = const initial =
(typeof localStorage !== 'undefined' && localStorage.getItem(KEY)) || (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) ||
'light'; "light";
export const theme = writable(initial); export const theme = writable(initial);
theme.subscribe(v => { theme.subscribe((v) => {
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
document.documentElement.dataset.theme = String(v); document.documentElement.dataset.theme = String(v);
localStorage.setItem(KEY, String(v)); localStorage.setItem(KEY, String(v));
} }
}); });
export const setTheme = (t: string) => theme.set(t); export const setTheme = (t: string) => theme.set(t);

7
src/lib/stores/userStore.ts

@ -16,7 +16,6 @@ import {
import { loginStorageKey } from "../consts.ts"; import { loginStorageKey } from "../consts.ts";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
export interface UserState { export interface UserState {
pubkey: string | null; pubkey: string | null;
npub: string | null; npub: string | null;
@ -248,7 +247,11 @@ export async function loginWithExtension(ndk: NDK) {
/** /**
* Login with Amber (NIP-46) * Login with Amber (NIP-46)
*/ */
export async function loginWithAmber(amberSigner: NDKSigner, user: NDKUser, ndk: NDK) { export async function loginWithAmber(
amberSigner: NDKSigner,
user: NDKUser,
ndk: NDK,
) {
if (!ndk) throw new Error("NDK not initialized"); if (!ndk) throw new Error("NDK not initialized");
// Only clear previous login state after successful login // Only clear previous login state after successful login
const npub = user.npub; const npub = user.npub;

4
src/lib/styles/cva.ts

@ -1,3 +1,3 @@
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export { cva, twMerge, type VariantProps }; export { cva, twMerge, type VariantProps };

61
src/lib/utils/asciidoc_metadata.ts

@ -135,7 +135,7 @@ function mapAttributesToMetadata(
function extractDocumentAuthors(sourceContent: string): string[] { function extractDocumentAuthors(sourceContent: string): string[] {
const authors: string[] = []; const authors: string[] = [];
const lines = sourceContent.split(/\r?\n/); const lines = sourceContent.split(/\r?\n/);
// Find the document title line // Find the document title line
let titleLineIndex = -1; let titleLineIndex = -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@ -144,21 +144,21 @@ function extractDocumentAuthors(sourceContent: string): string[] {
break; break;
} }
} }
if (titleLineIndex === -1) { if (titleLineIndex === -1) {
return authors; return authors;
} }
// Look for authors in the lines immediately following the title // Look for authors in the lines immediately following the title
let i = titleLineIndex + 1; let i = titleLineIndex + 1;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
// Stop if we hit a blank line, section header, or content that's not an author // Stop if we hit a blank line, section header, or content that's not an author
if (line.trim() === "" || line.match(/^==\s+/)) { if (line.trim() === "" || line.match(/^==\s+/)) {
break; break;
} }
if (line.includes("<") && !line.startsWith(":")) { if (line.includes("<") && !line.startsWith(":")) {
// This is an author line like "John Doe <john@example.com>" // This is an author line like "John Doe <john@example.com>"
const authorName = line.split("<")[0].trim(); const authorName = line.split("<")[0].trim();
@ -172,10 +172,10 @@ function extractDocumentAuthors(sourceContent: string): string[] {
// Not an author line, stop looking // Not an author line, stop looking
break; break;
} }
i++; i++;
} }
return authors; return authors;
} }
@ -185,7 +185,7 @@ function extractDocumentAuthors(sourceContent: string): string[] {
function extractSectionAuthors(sectionContent: string): string[] { function extractSectionAuthors(sectionContent: string): string[] {
const authors: string[] = []; const authors: string[] = [];
const lines = sectionContent.split(/\r?\n/); const lines = sectionContent.split(/\r?\n/);
// Find the section title line // Find the section title line
let titleLineIndex = -1; let titleLineIndex = -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
@ -194,21 +194,21 @@ function extractSectionAuthors(sectionContent: string): string[] {
break; break;
} }
} }
if (titleLineIndex === -1) { if (titleLineIndex === -1) {
return authors; return authors;
} }
// Look for authors in the lines immediately following the section title // Look for authors in the lines immediately following the section title
let i = titleLineIndex + 1; let i = titleLineIndex + 1;
while (i < lines.length) { while (i < lines.length) {
const line = lines[i]; const line = lines[i];
// Stop if we hit a blank line, another section header, or content that's not an author // Stop if we hit a blank line, another section header, or content that's not an author
if (line.trim() === "" || line.match(/^==\s+/)) { if (line.trim() === "" || line.match(/^==\s+/)) {
break; break;
} }
if (line.includes("<") && !line.startsWith(":")) { if (line.includes("<") && !line.startsWith(":")) {
// This is an author line like "John Doe <john@example.com>" // This is an author line like "John Doe <john@example.com>"
const authorName = line.split("<")[0].trim(); const authorName = line.split("<")[0].trim();
@ -217,7 +217,7 @@ function extractSectionAuthors(sectionContent: string): string[] {
} }
} else if ( } else if (
line.match(/^[A-Za-z\s]+$/) && line.match(/^[A-Za-z\s]+$/) &&
line.trim() !== "" && line.trim() !== "" &&
line.trim().split(/\s+/).length <= 2 && line.trim().split(/\s+/).length <= 2 &&
!line.startsWith(":") !line.startsWith(":")
) { ) {
@ -230,10 +230,10 @@ function extractSectionAuthors(sectionContent: string): string[] {
// Not an author line, stop looking // Not an author line, stop looking
break; break;
} }
i++; i++;
} }
return authors; return authors;
} }
@ -243,23 +243,23 @@ function extractSectionAuthors(sectionContent: string): string[] {
function stripDocumentHeader(content: string): string { function stripDocumentHeader(content: string): string {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
let contentStart = 0; let contentStart = 0;
// Find where the document header ends // Find where the document header ends
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip title line, author line, revision line, and attribute lines // Skip title line, author line, revision line, and attribute lines
if ( if (
!line.match(/^=\s+/) && !line.match(/^=\s+/) &&
!line.includes("<") && !line.includes("<") &&
!line.match(/^.+,\s*.+:\s*.+$/) && !line.match(/^.+,\s*.+:\s*.+$/) &&
!line.match(/^:[^:]+:\s*.+$/) && !line.match(/^:[^:]+:\s*.+$/) &&
line.trim() !== "" line.trim() !== ""
) { ) {
contentStart = i; contentStart = i;
break; break;
} }
} }
// Filter out all attribute lines and author lines from the content // Filter out all attribute lines and author lines from the content
const contentLines = lines.slice(contentStart); const contentLines = lines.slice(contentStart);
const filteredLines = contentLines.filter((line) => { const filteredLines = contentLines.filter((line) => {
@ -269,7 +269,7 @@ function stripDocumentHeader(content: string): string {
} }
return true; return true;
}); });
// Remove extra blank lines and normalize newlines // Remove extra blank lines and normalize newlines
return filteredLines.join("\n").replace(/\n\s*\n\s*\n/g, "\n\n").replace( return filteredLines.join("\n").replace(/\n\s*\n\s*\n/g, "\n\n").replace(
/\n\s*\n/g, /\n\s*\n/g,
@ -283,23 +283,24 @@ function stripDocumentHeader(content: string): string {
function stripSectionHeader(sectionContent: string): string { function stripSectionHeader(sectionContent: string): string {
const lines = sectionContent.split(/\r?\n/); const lines = sectionContent.split(/\r?\n/);
let contentStart = 0; let contentStart = 0;
// Find where the section header ends // Find where the section header ends
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
// Skip section title line, author line, and attribute lines // Skip section title line, author line, and attribute lines
if ( if (
!line.match(/^==\s+/) && !line.match(/^==\s+/) &&
!line.includes("<") && !line.includes("<") &&
!line.match(/^:[^:]+:\s*.+$/) && !line.match(/^:[^:]+:\s*.+$/) &&
line.trim() !== "" && line.trim() !== "" &&
!(line.match(/^[A-Za-z\s]+$/) && line.trim() !== "" && line.trim().split(/\s+/).length <= 2) !(line.match(/^[A-Za-z\s]+$/) && line.trim() !== "" &&
line.trim().split(/\s+/).length <= 2)
) { ) {
contentStart = i; contentStart = i;
break; break;
} }
} }
// Filter out all attribute lines, author lines, and section headers from the content // Filter out all attribute lines, author lines, and section headers from the content
const contentLines = lines.slice(contentStart); const contentLines = lines.slice(contentStart);
const filteredLines = contentLines.filter((line) => { const filteredLines = contentLines.filter((line) => {
@ -309,7 +310,7 @@ function stripSectionHeader(sectionContent: string): string {
} }
// Skip author lines (simple names without email) // Skip author lines (simple names without email)
if ( if (
line.match(/^[A-Za-z\s]+$/) && line.match(/^[A-Za-z\s]+$/) &&
line.trim() !== "" && line.trim() !== "" &&
line.trim().split(/\s+/).length <= 2 line.trim().split(/\s+/).length <= 2
) { ) {
@ -321,7 +322,7 @@ function stripSectionHeader(sectionContent: string): string {
} }
return true; return true;
}); });
// Remove extra blank lines and normalize newlines // Remove extra blank lines and normalize newlines
return filteredLines.join("\n").replace(/\n\s*\n\s*\n/g, "\n\n").replace( return filteredLines.join("\n").replace(/\n\s*\n\s*\n/g, "\n\n").replace(
/\n\s*\n/g, /\n\s*\n/g,
@ -387,7 +388,7 @@ export function extractDocumentMetadata(inputContent: string): {
inDocumentHeader = false; inDocumentHeader = false;
break; break;
} }
// Process :author: attributes regardless of other content // Process :author: attributes regardless of other content
if (inDocumentHeader) { if (inDocumentHeader) {
const match = line.match(/^:author:\s*(.+)$/); const match = line.match(/^:author:\s*(.+)$/);
@ -464,7 +465,7 @@ export function extractSectionMetadata(inputSectionContent: string): {
// Extract authors from section content // Extract authors from section content
const authors = extractSectionAuthors(inputSectionContent); const authors = extractSectionAuthors(inputSectionContent);
// Get authors from attributes (including multiple :author: lines) // Get authors from attributes (including multiple :author: lines)
const lines = inputSectionContent.split(/\r?\n/); const lines = inputSectionContent.split(/\r?\n/);
for (const line of lines) { for (const line of lines) {
@ -476,7 +477,7 @@ export function extractSectionMetadata(inputSectionContent: string): {
} }
} }
} }
if (authors.length > 0) { if (authors.length > 0) {
metadata.authors = authors; metadata.authors = authors;
} }

48
src/lib/utils/cache_manager.ts

@ -1,11 +1,11 @@
import { unifiedProfileCache } from './npubCache'; import { unifiedProfileCache } from "./npubCache";
import { searchCache } from './searchCache'; import { searchCache } from "./searchCache";
import { indexEventCache } from './indexEventCache'; import { indexEventCache } from "./indexEventCache";
import { clearRelaySetCache } from '../ndk'; import { clearRelaySetCache } from "../ndk";
/** /**
* Clears all application caches * Clears all application caches
* *
* Clears: * Clears:
* - unifiedProfileCache (profile metadata) * - unifiedProfileCache (profile metadata)
* - searchCache (search results) * - searchCache (search results)
@ -13,18 +13,18 @@ import { clearRelaySetCache } from '../ndk';
* - relaySetCache (relay configuration) * - relaySetCache (relay configuration)
*/ */
export function clearAllCaches(): void { export function clearAllCaches(): void {
console.log('[CacheManager] Clearing all application caches...'); console.log("[CacheManager] Clearing all application caches...");
// Clear in-memory caches // Clear in-memory caches
unifiedProfileCache.clear(); unifiedProfileCache.clear();
searchCache.clear(); searchCache.clear();
indexEventCache.clear(); indexEventCache.clear();
clearRelaySetCache(); clearRelaySetCache();
// Clear localStorage caches // Clear localStorage caches
clearLocalStorageCaches(); clearLocalStorageCaches();
console.log('[CacheManager] All caches cleared successfully'); console.log("[CacheManager] All caches cleared successfully");
} }
/** /**
@ -32,41 +32,43 @@ export function clearAllCaches(): void {
* This is useful when profile pictures or metadata are stale * This is useful when profile pictures or metadata are stale
*/ */
export function clearProfileCaches(): void { export function clearProfileCaches(): void {
console.log('[CacheManager] Clearing profile-specific caches...'); console.log("[CacheManager] Clearing profile-specific caches...");
// Clear unified profile cache // Clear unified profile cache
unifiedProfileCache.clear(); unifiedProfileCache.clear();
// Clear profile-related search results // Clear profile-related search results
// Note: searchCache doesn't have a way to clear specific types, so we clear all // Note: searchCache doesn't have a way to clear specific types, so we clear all
// This is acceptable since profile searches are the most common // This is acceptable since profile searches are the most common
searchCache.clear(); searchCache.clear();
console.log('[CacheManager] Profile caches cleared successfully'); console.log("[CacheManager] Profile caches cleared successfully");
} }
/** /**
* Clears localStorage caches * Clears localStorage caches
*/ */
function clearLocalStorageCaches(): void { function clearLocalStorageCaches(): void {
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
const keysToRemove: string[] = []; const keysToRemove: string[] = [];
// Find all localStorage keys that start with 'alexandria' // Find all localStorage keys that start with 'alexandria'
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && key.startsWith('alexandria')) { if (key && key.startsWith("alexandria")) {
keysToRemove.push(key); keysToRemove.push(key);
} }
} }
// Remove the keys // Remove the keys
keysToRemove.forEach(key => { keysToRemove.forEach((key) => {
localStorage.removeItem(key); localStorage.removeItem(key);
}); });
console.log(`[CacheManager] Cleared ${keysToRemove.length} localStorage items`); console.log(
`[CacheManager] Cleared ${keysToRemove.length} localStorage items`,
);
} }
/** /**

53
src/lib/utils/event_input_utils.ts

@ -204,7 +204,6 @@ function extractMarkdownTopHeader(content: string): string | null {
// Event Construction // Event Construction
// ========================= // =========================
/** /**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. * Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section.
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header). * Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
@ -261,31 +260,33 @@ export function build30040EventSet(
console.log("Index event:", { documentTitle, indexDTag }); console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata // Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section: any, i: number) => { const sectionEvents: NDKEvent[] = parsed.sections.map(
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`; (section: any, i: number) => {
console.log(`Creating section ${i}:`, { const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
title: section.title, console.log(`Creating section ${i}:`, {
dTag: sectionDTag, title: section.title,
content: section.content, dTag: sectionDTag,
metadata: section.metadata, content: section.content,
}); metadata: section.metadata,
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata); // Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041, return new NDKEventClass(ndk, {
content: section.content, kind: 30041,
tags: [ content: section.content,
...tags, tags: [
...sectionMetadataTags, ...tags,
["d", sectionDTag], ...sectionMetadataTags,
["title", section.title], ["d", sectionDTag],
], ["title", section.title],
pubkey: baseEvent.pubkey, ],
created_at: baseEvent.created_at, pubkey: baseEvent.pubkey,
}); created_at: baseEvent.created_at,
}); });
},
);
// Create proper a tags with format: kind:pubkey:d-tag // Create proper a tags with format: kind:pubkey:d-tag
const aTags = sectionEvents.map((event) => { const aTags = sectionEvents.map((event) => {

5
src/lib/utils/event_search.ts

@ -10,7 +10,10 @@ import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
/** /**
* Search for a single event by ID or filter * Search for a single event by ID or filter
*/ */
export async function searchEvent(query: string, ndk: NDK): Promise<NDKEvent | null> { export async function searchEvent(
query: string,
ndk: NDK,
): Promise<NDKEvent | null> {
if (!ndk) { if (!ndk) {
console.warn("[Search] No NDK instance available"); console.warn("[Search] No NDK instance available");
return null; return null;

2
src/lib/utils/image_utils.ts

@ -21,4 +21,4 @@ export function generateDarkPastelColor(seed: string): string {
return `#${r.toString(16).padStart(2, "0")}${ return `#${r.toString(16).padStart(2, "0")}${
g.toString(16).padStart(2, "0") g.toString(16).padStart(2, "0")
}${b.toString(16).padStart(2, "0")}`; }${b.toString(16).padStart(2, "0")}`;
} }

48
src/lib/utils/markup/advancedMarkupParser.ts

@ -399,31 +399,37 @@ function processInlineCodeMath(content: string): string {
// Check if the code content contains math expressions // Check if the code content contains math expressions
const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent); const hasInlineMath = /\$((?:[^$\\]|\\.)*?)\$/.test(codeContent);
const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent); const hasDisplayMath = /\$\$[\s\S]*?\$\$/.test(codeContent);
if (!hasInlineMath && !hasDisplayMath) { if (!hasInlineMath && !hasDisplayMath) {
// No math found, return the original inline code // No math found, return the original inline code
return match; return match;
} }
// Process display math ($$...$$) first to avoid conflicts with inline math // Process display math ($$...$$) first to avoid conflicts with inline math
let processedContent = codeContent.replace(/\$\$([\s\S]*?)\$\$/g, (mathMatch: string, mathContent: string) => { let processedContent = codeContent.replace(
// Skip empty math expressions /\$\$([\s\S]*?)\$\$/g,
if (!mathContent.trim()) { (mathMatch: string, mathContent: string) => {
return mathMatch; // Skip empty math expressions
} if (!mathContent.trim()) {
return `<span class="math-display">\\[${mathContent}\\]</span>`; return mathMatch;
}); }
return `<span class="math-display">\\[${mathContent}\\]</span>`;
// Process inline math ($...$) after display math },
// Use a more sophisticated regex that handles escaped dollar signs );
processedContent = processedContent.replace(/\$((?:[^$\\]|\\.)*?)\$/g, (mathMatch: string, mathContent: string) => {
// Skip empty math expressions // Process inline math ($...$) after display math
if (!mathContent.trim()) { // Use a more sophisticated regex that handles escaped dollar signs
return mathMatch; processedContent = processedContent.replace(
} /\$((?:[^$\\]|\\.)*?)\$/g,
return `<span class="math-inline">\\(${mathContent}\\)</span>`; (mathMatch: string, mathContent: string) => {
}); // Skip empty math expressions
if (!mathContent.trim()) {
return mathMatch;
}
return `<span class="math-inline">\\(${mathContent}\\)</span>`;
},
);
return `\`${processedContent}\``; return `\`${processedContent}\``;
}); });
} }

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

@ -7,8 +7,6 @@ import {
processWikilinks, processWikilinks,
} from "./markupUtils.ts"; } from "./markupUtils.ts";
export function preProcessBasicMarkup(text: string): string { export function preProcessBasicMarkup(text: string): string {
try { try {
// Process basic text formatting first // Process basic text formatting first
@ -26,7 +24,10 @@ export function preProcessBasicMarkup(text: string): string {
} }
} }
export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<string> { export async function postProcessBasicMarkup(
text: string,
ndk?: NDK,
): Promise<string> {
try { try {
// Process Nostr identifiers last // Process Nostr identifiers last
let processedText = await processNostrIdentifiersInText(text, ndk); let processedText = await processNostrIdentifiersInText(text, ndk);
@ -40,7 +41,10 @@ export async function postProcessBasicMarkup(text: string, ndk?: NDK): Promise<s
} }
} }
export async function parseBasicMarkup(text: string, ndk?: NDK): Promise<string> { export async function parseBasicMarkup(
text: string,
ndk?: NDK,
): Promise<string> {
if (!text) return ""; if (!text) return "";
try { try {

5
src/lib/utils/markup/embeddedMarkupParser.ts

@ -1,4 +1,7 @@
import { postProcessBasicMarkup, preProcessBasicMarkup } from "./basicMarkupParser.ts"; import {
postProcessBasicMarkup,
preProcessBasicMarkup,
} from "./basicMarkupParser.ts";
import { processNostrIdentifiersWithEmbeddedEvents } from "./markupUtils.ts"; import { processNostrIdentifiersWithEmbeddedEvents } from "./markupUtils.ts";
/** /**

75
src/lib/utils/markup/markupUtils.ts

@ -80,7 +80,10 @@ export function replaceAlexandriaNostrLinks(text: string): string {
return text; return text;
} }
export function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { export function renderListGroup(
lines: string[],
typeHint?: "ol" | "ul",
): string {
function parseList( function parseList(
start: number, start: number,
indent: number, indent: number,
@ -322,7 +325,9 @@ export async function processNostrIdentifiersInText(
metadata = await getUserMetadata(identifier, ndk); metadata = await getUserMetadata(identifier, ndk);
} else { } else {
// Fallback when NDK is not available - just use the identifier // Fallback when NDK is not available - just use the identifier
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) }; metadata = {
name: identifier.slice(0, 8) + "..." + identifier.slice(-4),
};
} }
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText); const link = createProfileLink(identifier, displayText);
@ -391,15 +396,19 @@ export function processAllNostrIdentifiers(text: string): string {
// Pattern for prefixed nostr identifiers (nostr:npub1, nostr:note1, etc.) // Pattern for prefixed nostr identifiers (nostr:npub1, nostr:note1, etc.)
// This handles both full identifiers and partial ones that might appear in content // This handles both full identifiers and partial ones that might appear in content
const prefixedNostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g; const prefixedNostrPattern =
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
// Pattern for bare nostr identifiers (npub1, note1, nevent1, naddr1) // Pattern for bare nostr identifiers (npub1, note1, nevent1, naddr1)
// Exclude matches that are part of URLs to avoid breaking existing links // Exclude matches that are part of URLs to avoid breaking existing links
const bareNostrPattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{20,}/g; const bareNostrPattern =
/(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{20,}/g;
// Process prefixed nostr identifiers first // Process prefixed nostr identifiers first
const prefixedMatches = Array.from(processedText.matchAll(prefixedNostrPattern)); const prefixedMatches = Array.from(
processedText.matchAll(prefixedNostrPattern),
);
// Process them in reverse order to avoid index shifting issues // Process them in reverse order to avoid index shifting issues
for (let i = prefixedMatches.length - 1; i >= 0; i--) { for (let i = prefixedMatches.length - 1; i >= 0; i--) {
const match = prefixedMatches[i]; const match = prefixedMatches[i];
@ -407,11 +416,12 @@ export function processAllNostrIdentifiers(text: string): string {
const matchIndex = match.index ?? 0; const matchIndex = match.index ?? 0;
// Create shortened display text // Create shortened display text
const identifier = fullMatch.replace('nostr:', ''); const identifier = fullMatch.replace("nostr:", "");
const displayText = `${identifier.slice(0, 8)}...${identifier.slice(-4)}`; const displayText = `${identifier.slice(0, 8)}...${identifier.slice(-4)}`;
// Create clickable link // Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`; const replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement + processedText = processedText.slice(0, matchIndex) + replacement +
@ -420,7 +430,7 @@ export function processAllNostrIdentifiers(text: string): string {
// Process bare nostr identifiers // Process bare nostr identifiers
const bareMatches = Array.from(processedText.matchAll(bareNostrPattern)); const bareMatches = Array.from(processedText.matchAll(bareNostrPattern));
// Process them in reverse order to avoid index shifting issues // Process them in reverse order to avoid index shifting issues
for (let i = bareMatches.length - 1; i >= 0; i--) { for (let i = bareMatches.length - 1; i >= 0; i--) {
const match = bareMatches[i]; const match = bareMatches[i];
@ -429,9 +439,10 @@ export function processAllNostrIdentifiers(text: string): string {
// Create shortened display text // Create shortened display text
const displayText = `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`; const displayText = `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`;
// Create clickable link with nostr: prefix for the href // Create clickable link with nostr: prefix for the href
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`; const replacement =
`<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement + processedText = processedText.slice(0, matchIndex) + replacement +
@ -439,9 +450,12 @@ export function processAllNostrIdentifiers(text: string): string {
} }
// Also handle any remaining truncated prefixed identifiers that might be cut off or incomplete // Also handle any remaining truncated prefixed identifiers that might be cut off or incomplete
const truncatedPrefixedPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g; const truncatedPrefixedPattern =
const truncatedPrefixedMatches = Array.from(processedText.matchAll(truncatedPrefixedPattern)); /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g;
const truncatedPrefixedMatches = Array.from(
processedText.matchAll(truncatedPrefixedPattern),
);
for (let i = truncatedPrefixedMatches.length - 1; i >= 0; i--) { for (let i = truncatedPrefixedMatches.length - 1; i >= 0; i--) {
const match = truncatedPrefixedMatches[i]; const match = truncatedPrefixedMatches[i];
const [fullMatch] = match; const [fullMatch] = match;
@ -451,11 +465,14 @@ export function processAllNostrIdentifiers(text: string): string {
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers // Create display text for truncated identifiers
const identifier = fullMatch.replace('nostr:', ''); const identifier = fullMatch.replace("nostr:", "");
const displayText = identifier.length > 12 ? `${identifier.slice(0, 8)}...${identifier.slice(-4)}` : identifier; const displayText = identifier.length > 12
? `${identifier.slice(0, 8)}...${identifier.slice(-4)}`
: identifier;
// Create clickable link // Create clickable link
const replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`; const replacement =
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="${fullMatch}">${displayText}</a>`;
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement + processedText = processedText.slice(0, matchIndex) + replacement +
@ -463,9 +480,12 @@ export function processAllNostrIdentifiers(text: string): string {
} }
// Handle truncated bare identifiers // Handle truncated bare identifiers
const truncatedBarePattern = /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{8,}/g; const truncatedBarePattern =
const truncatedBareMatches = Array.from(processedText.matchAll(truncatedBarePattern)); /(?<!https?:\/\/[^\s]*)(?<!wss?:\/\/[^\s]*)(?<!nostr:)(npub1|note1|nevent1|naddr1)[a-zA-Z0-9]{8,}/g;
const truncatedBareMatches = Array.from(
processedText.matchAll(truncatedBarePattern),
);
for (let i = truncatedBareMatches.length - 1; i >= 0; i--) { for (let i = truncatedBareMatches.length - 1; i >= 0; i--) {
const match = truncatedBareMatches[i]; const match = truncatedBareMatches[i];
const [fullMatch] = match; const [fullMatch] = match;
@ -475,10 +495,13 @@ export function processAllNostrIdentifiers(text: string): string {
if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars
// Create display text for truncated identifiers // Create display text for truncated identifiers
const displayText = fullMatch.length > 12 ? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}` : fullMatch; const displayText = fullMatch.length > 12
? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`
: fullMatch;
// Create clickable link // Create clickable link
const replacement = `<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`; const replacement =
`<a href="/events?id=nostr:${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all" title="nostr:${fullMatch}">${displayText}</a>`;
// Replace the match in the text // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + replacement + processedText = processedText.slice(0, matchIndex) + replacement +

24
src/lib/utils/nostrUtils.ts

@ -7,9 +7,9 @@ import type { Filter } from "./search_types.ts";
import { import {
anonymousRelays, anonymousRelays,
communityRelays, communityRelays,
localRelays,
searchRelays, searchRelays,
secondaryRelays, secondaryRelays,
localRelays,
} from "../consts.ts"; } from "../consts.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
@ -205,22 +205,22 @@ export async function processNostrIdentifiers(
if (/https?:\/\/$|www\.$/i.test(before)) { if (/https?:\/\/$|www\.$/i.test(before)) {
return true; return true;
} }
// Check if the match is part of a larger URL structure // Check if the match is part of a larger URL structure
// Look for common URL patterns that might contain nostr identifiers // Look for common URL patterns that might contain nostr identifiers
const beforeContext = text.slice(Math.max(0, index - 50), index); const beforeContext = text.slice(Math.max(0, index - 50), index);
const afterContext = text.slice(index, Math.min(text.length, index + 50)); const afterContext = text.slice(index, Math.min(text.length, index + 50));
// Check if there's a URL-like structure around the match // Check if there's a URL-like structure around the match
const urlPatterns = [ const urlPatterns = [
/https?:\/\/[^\s]*$/i, // URL starting with http(s):// /https?:\/\/[^\s]*$/i, // URL starting with http(s)://
/www\.[^\s]*$/i, // URL starting with www. /www\.[^\s]*$/i, // URL starting with www.
/[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs /[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs
/[^\s]*\/[^\s]*$/i, // Path-like structures /[^\s]*\/[^\s]*$/i, // Path-like structures
]; ];
const combinedContext = beforeContext + afterContext; const combinedContext = beforeContext + afterContext;
return urlPatterns.some(pattern => pattern.test(combinedContext)); return urlPatterns.some((pattern) => pattern.test(combinedContext));
} }
// Process profiles (npub and nprofile) // Process profiles (npub and nprofile)
@ -440,8 +440,8 @@ export async function fetchEventWithFallback(
// AI-NOTE: Include ALL available relays for comprehensive event discovery // AI-NOTE: Include ALL available relays for comprehensive event discovery
// This ensures we don't miss events that might be on any available relay // This ensures we don't miss events that might be on any available relay
allRelays = [ allRelays = [
...secondaryRelays, ...secondaryRelays,
...searchRelays, ...searchRelays,
...anonymousRelays, ...anonymousRelays,
...inboxRelays, // Include user's inbox relays ...inboxRelays, // Include user's inbox relays
...outboxRelays, // Include user's outbox relays ...outboxRelays, // Include user's outbox relays
@ -528,7 +528,7 @@ export async function fetchEventWithFallback(
* Converts various Nostr identifiers to npub format. * Converts various Nostr identifiers to npub format.
* Handles hex pubkeys, npub strings, and nprofile strings. * Handles hex pubkeys, npub strings, and nprofile strings.
*/ */
export function toNpub(pubkey: string | undefined): string | null { export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null; if (!pubkey) return null;
try { try {
// If it's already an npub, return it // If it's already an npub, return it

99
src/lib/utils/npubCache.ts

@ -59,14 +59,18 @@ class UnifiedProfileCache {
/** /**
* Get profile data, fetching fresh data if needed * Get profile data, fetching fresh data if needed
*/ */
async getProfile(identifier: string, ndk?: NDK, force = false): Promise<NpubMetadata> { async getProfile(
identifier: string,
ndk?: NDK,
force = false,
): Promise<NpubMetadata> {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
// Check cache first (unless forced) // Check cache first (unless forced)
if (!force && this.cache.has(cleanId)) { if (!force && this.cache.has(cleanId)) {
const entry = this.cache.get(cleanId)!; const entry = this.cache.get(cleanId)!;
const now = Date.now(); const now = Date.now();
// Return cached data if not expired // Return cached data if not expired
if ((now - entry.timestamp) < this.maxAge) { if ((now - entry.timestamp) < this.maxAge) {
console.log("UnifiedProfileCache: Returning cached profile:", cleanId); console.log("UnifiedProfileCache: Returning cached profile:", cleanId);
@ -81,8 +85,13 @@ class UnifiedProfileCache {
/** /**
* Fetch profile from all available relays and cache it * Fetch profile from all available relays and cache it
*/ */
private async fetchAndCacheProfile(identifier: string, ndk?: NDK): Promise<NpubMetadata> { private async fetchAndCacheProfile(
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` }; identifier: string,
ndk?: NDK,
): Promise<NpubMetadata> {
const fallback = {
name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}`,
};
try { try {
if (!ndk) { if (!ndk) {
@ -92,7 +101,10 @@ class UnifiedProfileCache {
const decoded = nip19.decode(identifier); const decoded = nip19.decode(identifier);
if (!decoded) { if (!decoded) {
console.warn("UnifiedProfileCache: Failed to decode identifier:", identifier); console.warn(
"UnifiedProfileCache: Failed to decode identifier:",
identifier,
);
return fallback; return fallback;
} }
@ -103,11 +115,17 @@ class UnifiedProfileCache {
} else if (decoded.type === "nprofile") { } else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey; pubkey = decoded.data.pubkey;
} else { } else {
console.warn("UnifiedProfileCache: Unsupported identifier type:", decoded.type); console.warn(
"UnifiedProfileCache: Unsupported identifier type:",
decoded.type,
);
return fallback; return fallback;
} }
console.log("UnifiedProfileCache: Fetching fresh profile for pubkey:", pubkey); console.log(
"UnifiedProfileCache: Fetching fresh profile for pubkey:",
pubkey,
);
// Use fetchEventWithFallback to search ALL available relays // Use fetchEventWithFallback to search ALL available relays
const profileEvent = await fetchEventWithFallback(ndk, { const profileEvent = await fetchEventWithFallback(ndk, {
@ -116,7 +134,10 @@ class UnifiedProfileCache {
}); });
if (!profileEvent || !profileEvent.content) { if (!profileEvent || !profileEvent.content) {
console.warn("UnifiedProfileCache: No profile event found for:", pubkey); console.warn(
"UnifiedProfileCache: No profile event found for:",
pubkey,
);
return fallback; return fallback;
} }
@ -147,7 +168,6 @@ class UnifiedProfileCache {
console.log("UnifiedProfileCache: Cached fresh profile:", metadata); console.log("UnifiedProfileCache: Cached fresh profile:", metadata);
return metadata; return metadata;
} catch (e) { } catch (e) {
console.error("UnifiedProfileCache: Error fetching profile:", e); console.error("UnifiedProfileCache: Error fetching profile:", e);
return fallback; return fallback;
@ -160,7 +180,7 @@ class UnifiedProfileCache {
getCached(identifier: string): NpubMetadata | undefined { getCached(identifier: string): NpubMetadata | undefined {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId); const entry = this.cache.get(cleanId);
if (entry) { if (entry) {
const now = Date.now(); const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) { if ((now - entry.timestamp) < this.maxAge) {
@ -170,14 +190,19 @@ class UnifiedProfileCache {
this.cache.delete(cleanId); this.cache.delete(cleanId);
} }
} }
return undefined; return undefined;
} }
/** /**
* Set profile data in cache * Set profile data in cache
*/ */
set(identifier: string, profile: NpubMetadata, pubkey?: string, relaySource?: string): void { set(
identifier: string,
profile: NpubMetadata,
pubkey?: string,
relaySource?: string,
): void {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
const entry: CacheEntry = { const entry: CacheEntry = {
profile, profile,
@ -199,7 +224,7 @@ class UnifiedProfileCache {
has(identifier: string): boolean { has(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId); const entry = this.cache.get(cleanId);
if (entry) { if (entry) {
const now = Date.now(); const now = Date.now();
if ((now - entry.timestamp) < this.maxAge) { if ((now - entry.timestamp) < this.maxAge) {
@ -209,7 +234,7 @@ class UnifiedProfileCache {
this.cache.delete(cleanId); this.cache.delete(cleanId);
} }
} }
return false; return false;
} }
@ -219,7 +244,7 @@ class UnifiedProfileCache {
delete(identifier: string): boolean { delete(identifier: string): boolean {
const cleanId = identifier.replace(/^nostr:/, ""); const cleanId = identifier.replace(/^nostr:/, "");
const entry = this.cache.get(cleanId); const entry = this.cache.get(cleanId);
if (entry) { if (entry) {
this.cache.delete(cleanId); this.cache.delete(cleanId);
if (entry.pubkey && entry.pubkey !== cleanId) { if (entry.pubkey && entry.pubkey !== cleanId) {
@ -228,7 +253,7 @@ class UnifiedProfileCache {
this.saveToStorage(); this.saveToStorage();
return true; return true;
} }
return false; return false;
} }
@ -264,18 +289,20 @@ class UnifiedProfileCache {
cleanup(): void { cleanup(): void {
const now = Date.now(); const now = Date.now();
const expiredKeys: string[] = []; const expiredKeys: string[] = [];
for (const [key, entry] of this.cache.entries()) { for (const [key, entry] of this.cache.entries()) {
if ((now - entry.timestamp) >= this.maxAge) { if ((now - entry.timestamp) >= this.maxAge) {
expiredKeys.push(key); expiredKeys.push(key);
} }
} }
expiredKeys.forEach(key => this.cache.delete(key)); expiredKeys.forEach((key) => this.cache.delete(key));
if (expiredKeys.length > 0) { if (expiredKeys.length > 0) {
this.saveToStorage(); this.saveToStorage();
console.log(`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`); console.log(
`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`,
);
} }
} }
} }
@ -294,7 +321,8 @@ if (typeof window !== "undefined") {
// but make it use the unified cache internally // but make it use the unified cache internally
export const npubCache = { export const npubCache = {
get: (key: string) => unifiedProfileCache.getCached(key), get: (key: string) => unifiedProfileCache.getCached(key),
set: (key: string, value: NpubMetadata) => unifiedProfileCache.set(key, value), set: (key: string, value: NpubMetadata) =>
unifiedProfileCache.set(key, value),
has: (key: string) => unifiedProfileCache.has(key), has: (key: string) => unifiedProfileCache.has(key),
delete: (key: string) => unifiedProfileCache.delete(key), delete: (key: string) => unifiedProfileCache.delete(key),
clear: () => unifiedProfileCache.clear(), clear: () => unifiedProfileCache.clear(),
@ -303,14 +331,19 @@ export const npubCache = {
}; };
// Legacy compatibility for old profileCache functions // Legacy compatibility for old profileCache functions
export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> { export async function getDisplayName(
pubkey: string,
ndk: NDK,
): Promise<string> {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk); const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
return profile.displayName || profile.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; return profile.displayName || profile.name ||
`${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
} }
export function getDisplayNameSync(pubkey: string): string { export function getDisplayNameSync(pubkey: string): string {
const profile = unifiedProfileCache.getCached(pubkey); const profile = unifiedProfileCache.getCached(pubkey);
return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; return profile?.displayName || profile?.name ||
`${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
} }
export async function batchFetchProfiles( export async function batchFetchProfiles(
@ -319,15 +352,15 @@ export async function batchFetchProfiles(
onProgress?: (fetched: number, total: number) => void, onProgress?: (fetched: number, total: number) => void,
): Promise<NDKEvent[]> { ): Promise<NDKEvent[]> {
const allProfileEvents: NDKEvent[] = []; const allProfileEvents: NDKEvent[] = [];
if (onProgress) onProgress(0, pubkeys.length); if (onProgress) onProgress(0, pubkeys.length);
// Fetch profiles in parallel using the unified cache // Fetch profiles in parallel using the unified cache
const fetchPromises = pubkeys.map(async (pubkey, index) => { const fetchPromises = pubkeys.map(async (pubkey, index) => {
try { try {
const profile = await unifiedProfileCache.getProfile(pubkey, ndk); const profile = await unifiedProfileCache.getProfile(pubkey, ndk);
if (onProgress) onProgress(index + 1, pubkeys.length); if (onProgress) onProgress(index + 1, pubkeys.length);
// Create a mock NDKEvent for compatibility // Create a mock NDKEvent for compatibility
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile); event.content = JSON.stringify(profile);
@ -338,14 +371,14 @@ export async function batchFetchProfiles(
return null; return null;
} }
}); });
const results = await Promise.allSettled(fetchPromises); const results = await Promise.allSettled(fetchPromises);
results.forEach(result => { results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) { if (result.status === "fulfilled" && result.value) {
allProfileEvents.push(result.value); allProfileEvents.push(result.value);
} }
}); });
return allProfileEvents; return allProfileEvents;
} }

53
src/lib/utils/profile_search.ts

@ -1,8 +1,17 @@
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { getNpubFromNip05, getUserMetadata, fetchEventWithFallback } from "./nostrUtils.ts"; import {
fetchEventWithFallback,
getNpubFromNip05,
getUserMetadata,
} from "./nostrUtils.ts";
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts"; import { searchCache } from "./searchCache.ts";
import { communityRelays, searchRelays, secondaryRelays, anonymousRelays } from "../consts.ts"; import {
anonymousRelays,
communityRelays,
searchRelays,
secondaryRelays,
} from "../consts.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import { import {
@ -78,7 +87,7 @@ export async function searchProfiles(
const npub = await getNpubFromNip05(normalizedNip05); const npub = await getNpubFromNip05(normalizedNip05);
if (npub) { if (npub) {
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
@ -94,9 +103,12 @@ export async function searchProfiles(
} }
} }
} catch (e) { } catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e); console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
} }
const profile: NostrProfile & { created_at?: number } = { const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
@ -207,7 +219,7 @@ async function searchNip05Domains(
npub, npub,
); );
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
@ -223,9 +235,12 @@ async function searchNip05Domains(
} }
} }
} catch (e) { } catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e); console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
} }
const profile: NostrProfile & { created_at?: number } = { const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
@ -259,7 +274,7 @@ async function searchNip05Domains(
if (npub) { if (npub) {
console.log("NIP-05 search: found npub for", nip05Address, ":", npub); console.log("NIP-05 search: found npub for", nip05Address, ":", npub);
const metadata = await getUserMetadata(npub, ndk); const metadata = await getUserMetadata(npub, ndk);
// AI-NOTE: Fetch the original event timestamp to preserve created_at // AI-NOTE: Fetch the original event timestamp to preserve created_at
let created_at: number | undefined = undefined; let created_at: number | undefined = undefined;
try { try {
@ -275,9 +290,12 @@ async function searchNip05Domains(
} }
} }
} catch (e) { } catch (e) {
console.warn("profile_search: Failed to fetch original event timestamp:", e); console.warn(
"profile_search: Failed to fetch original event timestamp:",
e,
);
} }
const profile: NostrProfile & { created_at?: number } = { const profile: NostrProfile & { created_at?: number } = {
...metadata, ...metadata,
pubkey: npub, pubkey: npub,
@ -328,12 +346,14 @@ async function quickRelaySearch(
// AI-NOTE: Use ALL available relays for comprehensive profile discovery // AI-NOTE: Use ALL available relays for comprehensive profile discovery
// This ensures we don't miss profiles due to stale cache or limited relay coverage // This ensures we don't miss profiles due to stale cache or limited relay coverage
// Get all available relays from NDK pool (most comprehensive) // Get all available relays from NDK pool (most comprehensive)
const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) => r.url) as string[]; const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) =>
r.url
) as string[];
const userInboxRelays = get(activeInboxRelays); const userInboxRelays = get(activeInboxRelays);
const userOutboxRelays = get(activeOutboxRelays); const userOutboxRelays = get(activeOutboxRelays);
// Combine ALL available relays for maximum coverage // Combine ALL available relays for maximum coverage
const allRelayUrls = [ const allRelayUrls = [
...poolRelays, // All NDK pool relays ...poolRelays, // All NDK pool relays
@ -347,7 +367,10 @@ async function quickRelaySearch(
// Deduplicate relay URLs // Deduplicate relay URLs
const uniqueRelayUrls = [...new Set(allRelayUrls)]; const uniqueRelayUrls = [...new Set(allRelayUrls)];
console.log("Using ALL available relays for profile search:", uniqueRelayUrls); console.log(
"Using ALL available relays for profile search:",
uniqueRelayUrls,
);
console.log("Total relays for profile search:", uniqueRelayUrls.length); console.log("Total relays for profile search:", uniqueRelayUrls.length);
// Create relay sets for parallel search // Create relay sets for parallel search

366
src/lib/utils/subscription_search.ts

@ -27,12 +27,12 @@ const normalizeUrl = (url: string): string => {
// AI-NOTE: Define prioritized event kinds for subscription search // AI-NOTE: Define prioritized event kinds for subscription search
const PRIORITIZED_EVENT_KINDS = new Set([ const PRIORITIZED_EVENT_KINDS = new Set([
1, // Text notes 1, // Text notes
1111, // Comments 1111, // Comments
9802, // Highlights 9802, // Highlights
20, // Article 20, // Article
21, // Article 21, // Article
22, // Article 22, // Article
1222, // Long-form content 1222, // Long-form content
1244, // Long-form content 1244, // Long-form content
30023, // Long-form content 30023, // Long-form content
@ -47,7 +47,7 @@ const PRIORITIZED_EVENT_KINDS = new Set([
* @param maxResults Maximum number of results to return * @param maxResults Maximum number of results to return
* @param ndk NDK instance for user list and community checks * @param ndk NDK instance for user list and community checks
* @returns Prioritized array of events * @returns Prioritized array of events
* *
* Priority tiers: * Priority tiers:
* 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only) * 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only)
* 2. Events from user's follows (if logged in) * 2. Events from user's follows (if logged in)
@ -58,7 +58,7 @@ async function prioritizeSearchEvents(
events: NDKEvent[], events: NDKEvent[],
targetPubkey?: string, targetPubkey?: string,
maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT, maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT,
ndk?: NDK ndk?: NDK,
): Promise<NDKEvent[]> { ): Promise<NDKEvent[]> {
if (events.length === 0) { if (events.length === 0) {
return []; return [];
@ -67,58 +67,75 @@ async function prioritizeSearchEvents(
// AI-NOTE: Get user lists and community status for prioritization // AI-NOTE: Get user lists and community status for prioritization
let userFollowPubkeys = new Set<string>(); let userFollowPubkeys = new Set<string>();
let communityMemberPubkeys = new Set<string>(); let communityMemberPubkeys = new Set<string>();
// Only attempt user list and community checks if NDK is provided // Only attempt user list and community checks if NDK is provided
if (ndk) { if (ndk) {
try { try {
// Import user list functions dynamically to avoid circular dependencies // Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, getPubkeysFromListKind } = await import("./user_lists.ts"); const { fetchCurrentUserLists, getPubkeysFromListKind } = await import(
"./user_lists.ts"
);
const { checkCommunity } = await import("./community_checker.ts"); const { checkCommunity } = await import("./community_checker.ts");
// Get current user's follow lists (if logged in) // Get current user's follow lists (if logged in)
const userLists = await fetchCurrentUserLists(undefined, ndk); const userLists = await fetchCurrentUserLists(undefined, ndk);
userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list
// Check community status for unique pubkeys in events (limit to prevent hanging) // Check community status for unique pubkeys in events (limit to prevent hanging)
const uniquePubkeys = new Set(events.map(e => e.pubkey).filter(Boolean)); const uniquePubkeys = new Set(
events.map((e) => e.pubkey).filter(Boolean),
);
const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys
console.log(`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`); console.log(
`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`,
);
const communityChecks = await Promise.allSettled( const communityChecks = await Promise.allSettled(
pubkeysToCheck.map(async (pubkey) => { pubkeysToCheck.map(async (pubkey) => {
try { try {
const isCommunityMember = await Promise.race([ const isCommunityMember = await Promise.race([
checkCommunity(pubkey), checkCommunity(pubkey),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Community check timeout')), 2000) setTimeout(
) () => reject(new Error("Community check timeout")),
2000,
)
),
]); ]);
return { pubkey, isCommunityMember }; return { pubkey, isCommunityMember };
} catch (error) { } catch (error) {
console.warn(`subscription_search: Community check failed for ${pubkey}:`, error); console.warn(
`subscription_search: Community check failed for ${pubkey}:`,
error,
);
return { pubkey, isCommunityMember: false }; return { pubkey, isCommunityMember: false };
} }
}) }),
); );
// Build set of community member pubkeys // Build set of community member pubkeys
communityChecks.forEach(result => { communityChecks.forEach((result) => {
if (result.status === "fulfilled" && result.value.isCommunityMember) { if (result.status === "fulfilled" && result.value.isCommunityMember) {
communityMemberPubkeys.add(result.value.pubkey); communityMemberPubkeys.add(result.value.pubkey);
} }
}); });
console.log("subscription_search: Prioritization data loaded:", { console.log("subscription_search: Prioritization data loaded:", {
userFollows: userFollowPubkeys.size, userFollows: userFollowPubkeys.size,
communityMembers: communityMemberPubkeys.size, communityMembers: communityMemberPubkeys.size,
totalEvents: events.length totalEvents: events.length,
}); });
} catch (error) { } catch (error) {
console.warn("subscription_search: Failed to load prioritization data:", error); console.warn(
"subscription_search: Failed to load prioritization data:",
error,
);
} }
} else { } else {
console.log("subscription_search: No NDK provided, skipping user list and community checks"); console.log(
"subscription_search: No NDK provided, skipping user list and community checks",
);
} }
// Separate events into priority tiers // Separate events into priority tiers
@ -131,8 +148,10 @@ async function prioritizeSearchEvents(
const isFromTarget = targetPubkey && event.pubkey === targetPubkey; const isFromTarget = targetPubkey && event.pubkey === targetPubkey;
const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0); const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0);
const isFromFollow = userFollowPubkeys.has(event.pubkey || ""); const isFromFollow = userFollowPubkeys.has(event.pubkey || "");
const isFromCommunityMember = communityMemberPubkeys.has(event.pubkey || ""); const isFromCommunityMember = communityMemberPubkeys.has(
event.pubkey || "",
);
// AI-NOTE: Prioritized kinds are always in tier 1 // AI-NOTE: Prioritized kinds are always in tier 1
// Target pubkey priority only applies to n: searches (when targetPubkey is provided) // Target pubkey priority only applies to n: searches (when targetPubkey is provided)
if (isPrioritizedKind || isFromTarget) { if (isPrioritizedKind || isFromTarget) {
@ -154,22 +173,22 @@ async function prioritizeSearchEvents(
// Combine tiers in priority order, respecting the limit // Combine tiers in priority order, respecting the limit
const result: NDKEvent[] = []; const result: NDKEvent[] = [];
// Add tier 1 events (highest priority) // Add tier 1 events (highest priority)
result.push(...tier1); result.push(...tier1);
// Add tier 2 events (follows) if we haven't reached the limit // Add tier 2 events (follows) if we haven't reached the limit
const remainingAfterTier1 = maxResults - result.length; const remainingAfterTier1 = maxResults - result.length;
if (remainingAfterTier1 > 0) { if (remainingAfterTier1 > 0) {
result.push(...tier2.slice(0, remainingAfterTier1)); result.push(...tier2.slice(0, remainingAfterTier1));
} }
// Add tier 3 events (community members) if we haven't reached the limit // Add tier 3 events (community members) if we haven't reached the limit
const remainingAfterTier2 = maxResults - result.length; const remainingAfterTier2 = maxResults - result.length;
if (remainingAfterTier2 > 0) { if (remainingAfterTier2 > 0) {
result.push(...tier3.slice(0, remainingAfterTier2)); result.push(...tier3.slice(0, remainingAfterTier2));
} }
// Add tier 4 events (others) if we haven't reached the limit // Add tier 4 events (others) if we haven't reached the limit
const remainingAfterTier3 = maxResults - result.length; const remainingAfterTier3 = maxResults - result.length;
if (remainingAfterTier3 > 0) { if (remainingAfterTier3 > 0) {
@ -181,7 +200,7 @@ async function prioritizeSearchEvents(
tier2: tier2.length, // User follows tier2: tier2.length, // User follows
tier3: tier3.length, // Community members tier3: tier3.length, // Community members
tier4: tier4.length, // Others tier4: tier4.length, // Others
total: result.length total: result.length,
}); });
return result; return result;
@ -221,61 +240,74 @@ export async function searchBySubscription(
const cachedResult = searchCache.get(searchType, normalizedSearchTerm); const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) { if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult); console.log("subscription_search: Found cached result:", cachedResult);
// AI-NOTE: Ensure cached events have created_at property preserved // AI-NOTE: Ensure cached events have created_at property preserved
// This fixes the "Unknown date" issue when events are retrieved from cache // This fixes the "Unknown date" issue when events are retrieved from cache
const eventsWithCreatedAt = cachedResult.events.map(event => { const eventsWithCreatedAt = cachedResult.events.map((event) => {
if (event && typeof event === 'object' && !event.created_at) { if (event && typeof event === "object" && !event.created_at) {
console.warn("subscription_search: Event missing created_at, setting to 0:", event.id); console.warn(
"subscription_search: Event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0; (event as any).created_at = 0;
} }
return event; return event;
}); });
const secondOrderWithCreatedAt = cachedResult.secondOrder.map(event => { const secondOrderWithCreatedAt = cachedResult.secondOrder.map((event) => {
if (event && typeof event === 'object' && !event.created_at) { if (event && typeof event === "object" && !event.created_at) {
console.warn("subscription_search: Second order event missing created_at, setting to 0:", event.id); console.warn(
"subscription_search: Second order event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0; (event as any).created_at = 0;
} }
return event; return event;
}); });
const tTagEventsWithCreatedAt = cachedResult.tTagEvents.map(event => { const tTagEventsWithCreatedAt = cachedResult.tTagEvents.map((event) => {
if (event && typeof event === 'object' && !event.created_at) { if (event && typeof event === "object" && !event.created_at) {
console.warn("subscription_search: T-tag event missing created_at, setting to 0:", event.id); console.warn(
"subscription_search: T-tag event missing created_at, setting to 0:",
event.id,
);
(event as any).created_at = 0; (event as any).created_at = 0;
} }
return event; return event;
}); });
const resultWithCreatedAt = { const resultWithCreatedAt = {
...cachedResult, ...cachedResult,
events: eventsWithCreatedAt, events: eventsWithCreatedAt,
secondOrder: secondOrderWithCreatedAt, secondOrder: secondOrderWithCreatedAt,
tTagEvents: tTagEventsWithCreatedAt tTagEvents: tTagEventsWithCreatedAt,
}; };
// AI-NOTE: Return cached results immediately but trigger second-order search in background // AI-NOTE: Return cached results immediately but trigger second-order search in background
// This ensures we get fast results while still updating second-order data // This ensures we get fast results while still updating second-order data
console.log("subscription_search: Returning cached result immediately, triggering background second-order search"); console.log(
"subscription_search: Returning cached result immediately, triggering background second-order search",
// Trigger second-order search in background for all search types );
if (ndk) {
// Start second-order search in background for n and d searches only // Trigger second-order search in background for all search types
if (searchType === "n" || searchType === "d") { if (ndk) {
console.log("subscription_search: Triggering background second-order search for cached result"); // Start second-order search in background for n and d searches only
performSecondOrderSearchInBackground( if (searchType === "n" || searchType === "d") {
searchType as "n" | "d", console.log(
eventsWithCreatedAt, "subscription_search: Triggering background second-order search for cached result",
cachedResult.eventIds || new Set(), );
cachedResult.addresses || new Set(), performSecondOrderSearchInBackground(
ndk, searchType as "n" | "d",
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined, eventsWithCreatedAt,
callbacks cachedResult.eventIds || new Set(),
); cachedResult.addresses || new Set(),
} ndk,
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined,
callbacks,
);
} }
}
return resultWithCreatedAt; return resultWithCreatedAt;
} }
@ -316,7 +348,10 @@ export async function searchBySubscription(
// AI-NOTE: Check for preloaded events first (for profile searches) // AI-NOTE: Check for preloaded events first (for profile searches)
if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) { if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) {
console.log("subscription_search: Using preloaded events:", searchFilter.preloadedEvents.length); console.log(
"subscription_search: Using preloaded events:",
searchFilter.preloadedEvents.length,
);
processPrimaryRelayResults( processPrimaryRelayResults(
new Set(searchFilter.preloadedEvents), new Set(searchFilter.preloadedEvents),
searchType, searchType,
@ -326,9 +361,11 @@ export async function searchBySubscription(
abortSignal, abortSignal,
cleanup, cleanup,
); );
if (hasResults(searchState, searchType)) { if (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from preloaded events, returning immediately"); console.log(
"subscription_search: Found results from preloaded events, returning immediately",
);
const immediateResult = createSearchResult( const immediateResult = createSearchResult(
searchState, searchState,
searchType, searchType,
@ -367,19 +404,25 @@ export async function searchBySubscription(
"subscription_search: Searching primary relay with filter:", "subscription_search: Searching primary relay with filter:",
searchFilter.filter, searchFilter.filter,
); );
// Add timeout to primary relay search // Add timeout to primary relay search
const primaryEventsPromise = ndk.fetchEvents( const primaryEventsPromise = ndk.fetchEvents(
searchFilter.filter, searchFilter.filter,
{ closeOnEose: true }, { closeOnEose: true },
primaryRelaySet, primaryRelaySet,
); );
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Primary relay search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH); setTimeout(
() => reject(new Error("Primary relay search timeout")),
TIMEOUTS.SUBSCRIPTION_SEARCH,
);
}); });
const primaryEvents = await Promise.race([primaryEventsPromise, timeoutPromise]) as any; const primaryEvents = await Promise.race([
primaryEventsPromise,
timeoutPromise,
]) as any;
console.log( console.log(
"subscription_search: Primary relay returned", "subscription_search: Primary relay returned",
@ -429,7 +472,7 @@ export async function searchBySubscription(
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms`, `subscription_search: Profile search completed in ${elapsed}ms`,
); );
// Clear the main timeout since we're returning early // Clear the main timeout since we're returning early
cleanup(); cleanup();
return immediateResult; return immediateResult;
@ -471,12 +514,18 @@ export async function searchBySubscription(
{ closeOnEose: true }, { closeOnEose: true },
allRelaySet, allRelaySet,
); );
const fallbackTimeoutPromise = new Promise((_, reject) => { const fallbackTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Fallback search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH); setTimeout(
() => reject(new Error("Fallback search timeout")),
TIMEOUTS.SUBSCRIPTION_SEARCH,
);
}); });
const fallbackEvents = await Promise.race([fallbackEventsPromise, fallbackTimeoutPromise]) as any; const fallbackEvents = await Promise.race([
fallbackEventsPromise,
fallbackTimeoutPromise,
]) as any;
console.log( console.log(
"subscription_search: Fallback search returned", "subscription_search: Fallback search returned",
@ -508,7 +557,7 @@ export async function searchBySubscription(
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms (fallback)`, `subscription_search: Profile search completed in ${elapsed}ms (fallback)`,
); );
// Clear the main timeout since we're returning early // Clear the main timeout since we're returning early
cleanup(); cleanup();
return fallbackResult; return fallbackResult;
@ -518,10 +567,15 @@ export async function searchBySubscription(
"subscription_search: Fallback search failed:", "subscription_search: Fallback search failed:",
fallbackError, fallbackError,
); );
// If it's a timeout error, continue to return empty result // If it's a timeout error, continue to return empty result
if (fallbackError instanceof Error && fallbackError.message.includes("timeout")) { if (
console.log("subscription_search: Fallback search timed out, returning empty result"); fallbackError instanceof Error &&
fallbackError.message.includes("timeout")
) {
console.log(
"subscription_search: Fallback search timed out, returning empty result",
);
} }
} }
@ -538,7 +592,7 @@ export async function searchBySubscription(
console.log( console.log(
`subscription_search: Profile search completed in ${elapsed}ms (not found)`, `subscription_search: Profile search completed in ${elapsed}ms (not found)`,
); );
// Clear the main timeout since we're returning early // Clear the main timeout since we're returning early
cleanup(); cleanup();
return emptyResult; return emptyResult;
@ -553,10 +607,12 @@ export async function searchBySubscription(
`subscription_search: Error searching primary relay:`, `subscription_search: Error searching primary relay:`,
error, error,
); );
// If it's a timeout error, continue to Phase 2 instead of failing // If it's a timeout error, continue to Phase 2 instead of failing
if (error instanceof Error && error.message.includes("timeout")) { if (error instanceof Error && error.message.includes("timeout")) {
console.log("subscription_search: Primary relay search timed out, continuing to Phase 2"); console.log(
"subscription_search: Primary relay search timed out, continuing to Phase 2",
);
} else { } else {
// For other errors, we might want to fail the search // For other errors, we might want to fail the search
throw error; throw error;
@ -669,12 +725,12 @@ async function createSearchFilter(
// This properly handles NIP-05 lookups and name searches // This properly handles NIP-05 lookups and name searches
const { searchProfiles } = await import("./profile_search.ts"); const { searchProfiles } = await import("./profile_search.ts");
const profileResult = await searchProfiles(normalizedSearchTerm, ndk); const profileResult = await searchProfiles(normalizedSearchTerm, ndk);
// Convert profile results to events for compatibility // Convert profile results to events for compatibility
const events = profileResult.profiles.map((profile) => { const events = profileResult.profiles.map((profile) => {
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile); event.content = JSON.stringify(profile);
// AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode // AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode
// The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key // The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key
let hexPubkey = profile.pubkey || ""; let hexPubkey = profile.pubkey || "";
@ -685,26 +741,36 @@ async function createSearchFilter(
hexPubkey = decoded.data as string; hexPubkey = decoded.data as string;
} }
} catch (e) { } catch (e) {
console.warn("subscription_search: Failed to decode npub:", profile.pubkey, e); console.warn(
"subscription_search: Failed to decode npub:",
profile.pubkey,
e,
);
} }
} }
event.pubkey = hexPubkey; event.pubkey = hexPubkey;
event.kind = 0; event.kind = 0;
// AI-NOTE: Use the preserved created_at timestamp from the profile // AI-NOTE: Use the preserved created_at timestamp from the profile
// This ensures the profile cards show the actual creation date instead of "Unknown date" // This ensures the profile cards show the actual creation date instead of "Unknown date"
if ((profile as any).created_at) { if ((profile as any).created_at) {
event.created_at = (profile as any).created_at; event.created_at = (profile as any).created_at;
console.log("subscription_search: Using preserved timestamp:", event.created_at); console.log(
"subscription_search: Using preserved timestamp:",
event.created_at,
);
} else { } else {
// Fallback to current timestamp if no preserved timestamp // Fallback to current timestamp if no preserved timestamp
event.created_at = Math.floor(Date.now() / 1000); event.created_at = Math.floor(Date.now() / 1000);
console.log("subscription_search: Using fallback timestamp:", event.created_at); console.log(
"subscription_search: Using fallback timestamp:",
event.created_at,
);
} }
return event; return event;
}); });
// Return a mock filter since we're using the profile search directly // Return a mock filter since we're using the profile search directly
const nFilter = { const nFilter = {
filter: { kinds: [0], limit: 1 }, // Dummy filter filter: { kinds: [0], limit: 1 }, // Dummy filter
@ -712,7 +778,10 @@ async function createSearchFilter(
searchTerm: normalizedSearchTerm, searchTerm: normalizedSearchTerm,
preloadedEvents: events, // AI-NOTE: Pass preloaded events preloadedEvents: events, // AI-NOTE: Pass preloaded events
}; };
console.log("subscription_search: Created profile filter with preloaded events:", nFilter); console.log(
"subscription_search: Created profile filter with preloaded events:",
nFilter,
);
return nFilter; return nFilter;
} }
default: { default: {
@ -721,8 +790,6 @@ async function createSearchFilter(
} }
} }
/** /**
* Create primary relay set for search operations * Create primary relay set for search operations
* AI-NOTE: Updated to use all available relays to prevent search failures * AI-NOTE: Updated to use all available relays to prevent search failures
@ -816,7 +883,9 @@ function processPrimaryRelayResults(
for (const event of events) { for (const event of events) {
// Check if we've reached the event limit // Check if we've reached the event limit
if (processedCount >= maxEvents) { if (processedCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`); console.log(
`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`,
);
break; break;
} }
@ -1029,13 +1098,15 @@ function searchOtherRelaysInBackground(
sub.on("event", (event: NDKEvent) => { sub.on("event", (event: NDKEvent) => {
// Check if we've reached the event limit // Check if we've reached the event limit
if (eventCount >= maxEvents) { if (eventCount >= maxEvents) {
console.log(`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`); console.log(
`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`,
);
sub.stop(); sub.stop();
return; return;
} }
eventCount++; eventCount++;
try { try {
if (searchType === "n") { if (searchType === "n") {
processProfileEvent( processProfileEvent(
@ -1054,11 +1125,13 @@ function searchOtherRelaysInBackground(
return new Promise<SearchResult>((resolve) => { return new Promise<SearchResult>((resolve) => {
let resolved = false; let resolved = false;
// Add timeout to prevent hanging // Add timeout to prevent hanging
const timeoutId = setTimeout(async () => { const timeoutId = setTimeout(async () => {
if (!resolved) { if (!resolved) {
console.log("subscription_search: Background search timeout, resolving with current results"); console.log(
"subscription_search: Background search timeout, resolving with current results",
);
resolved = true; resolved = true;
sub.stop(); sub.stop();
const result = await processEoseResults( const result = await processEoseResults(
@ -1073,7 +1146,7 @@ function searchOtherRelaysInBackground(
resolve(result); resolve(result);
} }
}, TIMEOUTS.SUBSCRIPTION_SEARCH); }, TIMEOUTS.SUBSCRIPTION_SEARCH);
sub.on("eose", async () => { sub.on("eose", async () => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
@ -1106,7 +1179,12 @@ async function processEoseResults(
if (searchType === "n") { if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, ndk, callbacks); return processProfileEoseResults(searchState, searchFilter, ndk, callbacks);
} else if (searchType === "d") { } else if (searchType === "d") {
return await processContentEoseResults(searchState, searchType, ndk, callbacks); return await processContentEoseResults(
searchState,
searchType,
ndk,
callbacks,
);
} else if (searchType === "t") { } else if (searchType === "t") {
return await processTTagEoseResults(searchState, ndk); return await processTTagEoseResults(searchState, ndk);
} }
@ -1242,7 +1320,7 @@ async function processContentEoseResults(
dedupedEvents, dedupedEvents,
undefined, // No specific target pubkey for d-tag searches undefined, // No specific target pubkey for d-tag searches
SEARCH_LIMITS.GENERAL_CONTENT, SEARCH_LIMITS.GENERAL_CONTENT,
ndk ndk,
); );
// AI-NOTE: Attach profile data to first-order events for display // AI-NOTE: Attach profile data to first-order events for display
@ -1276,7 +1354,10 @@ async function processContentEoseResults(
/** /**
* Process t-tag EOSE results * Process t-tag EOSE results
*/ */
async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<SearchResult> { async function processTTagEoseResults(
searchState: any,
ndk?: NDK,
): Promise<SearchResult> {
if (searchState.tTagEvents.length === 0) { if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult("t", searchState.normalizedSearchTerm); return createEmptySearchResult("t", searchState.normalizedSearchTerm);
} }
@ -1287,7 +1368,7 @@ async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<Sear
searchState.tTagEvents, searchState.tTagEvents,
undefined, // No specific target pubkey for t-tag searches undefined, // No specific target pubkey for t-tag searches
SEARCH_LIMITS.GENERAL_CONTENT, SEARCH_LIMITS.GENERAL_CONTENT,
ndk ndk,
); );
// AI-NOTE: Attach profile data to t-tag events for display // AI-NOTE: Attach profile data to t-tag events for display
@ -1458,10 +1539,12 @@ async function performSecondOrderSearchInBackground(
// Race between fetch and timeout - only timeout the initial event fetching // Race between fetch and timeout - only timeout the initial event fetching
await Promise.race([fetchPromise, fetchTimeoutPromise]); await Promise.race([fetchPromise, fetchTimeoutPromise]);
// Now do the prioritization without timeout // Now do the prioritization without timeout
console.log("subscription_search: Event fetching completed, starting prioritization..."); console.log(
"subscription_search: Event fetching completed, starting prioritization...",
);
// Deduplicate by event ID // Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>(); const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach((event) => { allSecondOrderEvents.forEach((event) => {
@ -1484,18 +1567,18 @@ async function performSecondOrderSearchInBackground(
deduplicatedSecondOrder, deduplicatedSecondOrder,
targetPubkey, targetPubkey,
SEARCH_LIMITS.SECOND_ORDER_RESULTS, SEARCH_LIMITS.SECOND_ORDER_RESULTS,
ndk ndk,
); );
const prioritizationTimeoutPromise = new Promise((_, reject) => { const prioritizationTimeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Prioritization timeout')), 15000); // 15 second timeout setTimeout(() => reject(new Error("Prioritization timeout")), 15000); // 15 second timeout
}); });
let prioritizedSecondOrder: NDKEvent[]; let prioritizedSecondOrder: NDKEvent[];
try { try {
prioritizedSecondOrder = await Promise.race([ prioritizedSecondOrder = await Promise.race([
prioritizationPromise, prioritizationPromise,
prioritizationTimeoutPromise prioritizationTimeoutPromise,
]) as NDKEvent[]; ]) as NDKEvent[];
console.log( console.log(
@ -1504,7 +1587,10 @@ async function performSecondOrderSearchInBackground(
"prioritized results", "prioritized results",
); );
} catch (error) { } catch (error) {
console.warn("subscription_search: Prioritization failed, using simple sorting:", error); console.warn(
"subscription_search: Prioritization failed, using simple sorting:",
error,
);
// Fallback to simple sorting if prioritization fails // Fallback to simple sorting if prioritization fails
prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => { prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => {
// Prioritize events from target pubkey first (for n: searches) // Prioritize events from target pubkey first (for n: searches)
@ -1514,17 +1600,17 @@ async function performSecondOrderSearchInBackground(
if (aIsTarget && !bIsTarget) return -1; if (aIsTarget && !bIsTarget) return -1;
if (!aIsTarget && bIsTarget) return 1; if (!aIsTarget && bIsTarget) return 1;
} }
// Prioritize by event kind (for t: searches and general prioritization) // Prioritize by event kind (for t: searches and general prioritization)
const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0); const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0);
const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0); const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0);
if (aIsPrioritized && !bIsPrioritized) return -1; if (aIsPrioritized && !bIsPrioritized) return -1;
if (!aIsPrioritized && bIsPrioritized) return 1; if (!aIsPrioritized && bIsPrioritized) return 1;
// Then sort by creation time (newest first) // Then sort by creation time (newest first)
return (b.created_at || 0) - (a.created_at || 0); return (b.created_at || 0) - (a.created_at || 0);
}).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); }).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
console.log( console.log(
"subscription_search: Using fallback sorting with", "subscription_search: Using fallback sorting with",
prioritizedSecondOrder.length, prioritizedSecondOrder.length,
@ -1577,20 +1663,27 @@ async function performSecondOrderSearchInBackground(
* @param ndk NDK instance for fetching profile data * @param ndk NDK instance for fetching profile data
* @returns Promise that resolves when profile data is attached * @returns Promise that resolves when profile data is attached
*/ */
async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<void> { async function attachProfileDataToEvents(
events: NDKEvent[],
ndk: NDK,
): Promise<void> {
if (events.length === 0) { if (events.length === 0) {
return; return;
} }
console.log(`subscription_search: Attaching profile data to ${events.length} events`); console.log(
`subscription_search: Attaching profile data to ${events.length} events`,
);
try { try {
// Import user list functions dynamically to avoid circular dependencies // Import user list functions dynamically to avoid circular dependencies
const { fetchCurrentUserLists, isPubkeyInUserLists } = await import("./user_lists.ts"); const { fetchCurrentUserLists, isPubkeyInUserLists } = await import(
"./user_lists.ts"
);
// Get current user's lists for user list status // Get current user's lists for user list status
const userLists = await fetchCurrentUserLists(undefined, ndk); const userLists = await fetchCurrentUserLists(undefined, ndk);
// Get unique pubkeys from events // Get unique pubkeys from events
const uniquePubkeys = new Set<string>(); const uniquePubkeys = new Set<string>();
events.forEach((event) => { events.forEach((event) => {
@ -1599,39 +1692,46 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<
} }
}); });
console.log(`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`); console.log(
`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`,
);
// Fetch profile data for each unique pubkey // Fetch profile data for each unique pubkey
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try { try {
// Import getUserMetadata dynamically to avoid circular dependencies // Import getUserMetadata dynamically to avoid circular dependencies
const { getUserMetadata } = await import("./nostrUtils.ts"); const { getUserMetadata } = await import("./nostrUtils.ts");
const npub = await import("./nostrUtils.ts").then(m => m.toNpub(pubkey)); const npub = await import("./nostrUtils.ts").then((m) =>
m.toNpub(pubkey)
);
if (npub) { if (npub) {
const profileData = await getUserMetadata(npub, ndk, true); const profileData = await getUserMetadata(npub, ndk, true);
if (profileData) { if (profileData) {
// Check if this pubkey is in user's lists // Check if this pubkey is in user's lists
const isInLists = isPubkeyInUserLists(pubkey, userLists); const isInLists = isPubkeyInUserLists(pubkey, userLists);
// Return profile data with user list status // Return profile data with user list status
return { return {
pubkey, pubkey,
profileData: { profileData: {
...profileData, ...profileData,
isInUserLists: isInLists isInUserLists: isInLists,
} },
}; };
} }
} }
} catch (error) { } catch (error) {
console.warn(`subscription_search: Failed to fetch profile for ${pubkey}:`, error); console.warn(
`subscription_search: Failed to fetch profile for ${pubkey}:`,
error,
);
} }
return null; return null;
}); });
const profileResults = await Promise.allSettled(profilePromises); const profileResults = await Promise.allSettled(profilePromises);
// Create a map of pubkey to profile data // Create a map of pubkey to profile data
const profileMap = new Map<string, any>(); const profileMap = new Map<string, any>();
profileResults.forEach((result) => { profileResults.forEach((result) => {
@ -1640,7 +1740,9 @@ async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise<
} }
}); });
console.log(`subscription_search: Successfully fetched ${profileMap.size} profiles`); console.log(
`subscription_search: Successfully fetched ${profileMap.size} profiles`,
);
// Attach profile data to each event // Attach profile data to each event
events.forEach((event) => { events.forEach((event) => {

113
src/lib/utils/user_lists.ts

@ -1,4 +1,4 @@
import { getNdkContext, activeInboxRelays } from "../ndk.ts"; import { activeInboxRelays, getNdkContext } from "../ndk.ts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
@ -11,15 +11,15 @@ import { npubCache } from "./npubCache.ts";
* @see https://github.com/nostr-protocol/nips/blob/master/51.md * @see https://github.com/nostr-protocol/nips/blob/master/51.md
*/ */
export const NIP51_LIST_KINDS = { export const NIP51_LIST_KINDS = {
FOLLOWS: 3, // Follow list FOLLOWS: 3, // Follow list
MUTED: 10000, // Mute list MUTED: 10000, // Mute list
PINNED: 10001, // Pin list PINNED: 10001, // Pin list
RELAYS: 10002, // Relay list RELAYS: 10002, // Relay list
PEOPLE: 30000, // Categorized people list PEOPLE: 30000, // Categorized people list
BOOKMARKS: 30001, // Categorized bookmark list BOOKMARKS: 30001, // Categorized bookmark list
COMMUNITIES: 34550, // Community definition COMMUNITIES: 34550, // Community definition
STARTER_PACKS: 39089, // Starter packs STARTER_PACKS: 39089, // Starter packs
MEDIA_STARTER_PACKS: 39092, // Media starter packs MEDIA_STARTER_PACKS: 39092, // Media starter packs
} as const; } as const;
/** /**
@ -52,7 +52,7 @@ export interface UserListEvent {
export async function fetchUserLists( export async function fetchUserLists(
pubkey: string, pubkey: string,
listKinds: number[] = [...PEOPLE_LIST_KINDS], listKinds: number[] = [...PEOPLE_LIST_KINDS],
ndk?: NDK ndk?: NDK,
): Promise<UserListEvent[]> { ): Promise<UserListEvent[]> {
const ndkInstance = ndk || getNdkContext(); const ndkInstance = ndk || getNdkContext();
if (!ndkInstance) { if (!ndkInstance) {
@ -60,7 +60,10 @@ export async function fetchUserLists(
return []; return [];
} }
console.log(`fetchUserLists: Fetching lists for ${pubkey}, kinds:`, listKinds); console.log(
`fetchUserLists: Fetching lists for ${pubkey}, kinds:`,
listKinds,
);
try { try {
const events = await ndkInstance.fetchEvents({ const events = await ndkInstance.fetchEvents({
@ -72,10 +75,10 @@ export async function fetchUserLists(
for (const event of events) { for (const event of events) {
const pubkeys: string[] = []; const pubkeys: string[] = [];
// Extract pubkeys from p-tags // Extract pubkeys from p-tags
event.tags.forEach(tag => { event.tags.forEach((tag) => {
if (tag[0] === 'p' && tag[1]) { if (tag[0] === "p" && tag[1]) {
pubkeys.push(tag[1]); pubkeys.push(tag[1]);
} }
}); });
@ -83,7 +86,7 @@ export async function fetchUserLists(
// Extract list metadata from content if available // Extract list metadata from content if available
let listName: string | undefined; let listName: string | undefined;
let listDescription: string | undefined; let listDescription: string | undefined;
if (event.content) { if (event.content) {
try { try {
const content = JSON.parse(event.content); const content = JSON.parse(event.content);
@ -96,7 +99,7 @@ export async function fetchUserLists(
// Get list name from d-tag if available (for addressable lists) // Get list name from d-tag if available (for addressable lists)
if (!listName && event.kind >= 30000 && event.kind < 40000) { if (!listName && event.kind >= 30000 && event.kind < 40000) {
const dTag = event.getMatchingTags('d')[0]?.[1]; const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) { if (dTag) {
listName = dTag; listName = dTag;
} }
@ -111,7 +114,11 @@ export async function fetchUserLists(
}); });
} }
console.log(`fetchUserLists: Found ${userLists.length} lists with ${userLists.reduce((sum, list) => sum + list.pubkeys.length, 0)} total pubkeys`); console.log(
`fetchUserLists: Found ${userLists.length} lists with ${
userLists.reduce((sum, list) => sum + list.pubkeys.length, 0)
} total pubkeys`,
);
return userLists; return userLists;
} catch (error) { } catch (error) {
console.error("fetchUserLists: Error fetching user lists:", error); console.error("fetchUserLists: Error fetching user lists:", error);
@ -127,10 +134,10 @@ export async function fetchUserLists(
*/ */
export async function fetchCurrentUserLists( export async function fetchCurrentUserLists(
listKinds: number[] = [...PEOPLE_LIST_KINDS], listKinds: number[] = [...PEOPLE_LIST_KINDS],
ndk?: NDK ndk?: NDK,
): Promise<UserListEvent[]> { ): Promise<UserListEvent[]> {
const userState = get(userStore); const userState = get(userStore);
if (!userState.signedIn || !userState.pubkey) { if (!userState.signedIn || !userState.pubkey) {
console.warn("fetchCurrentUserLists: No active user found in userStore"); console.warn("fetchCurrentUserLists: No active user found in userStore");
return []; return [];
@ -145,11 +152,13 @@ export async function fetchCurrentUserLists(
* @param userLists - Array of UserListEvent objects * @param userLists - Array of UserListEvent objects
* @returns Set of unique pubkeys * @returns Set of unique pubkeys
*/ */
export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string> { export function getPubkeysFromUserLists(
userLists: UserListEvent[],
): Set<string> {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
userLists.forEach(list => { userLists.forEach((list) => {
list.pubkeys.forEach(pubkey => { list.pubkeys.forEach((pubkey) => {
pubkeys.add(pubkey); pubkeys.add(pubkey);
}); });
}); });
@ -163,12 +172,15 @@ export function getPubkeysFromUserLists(userLists: UserListEvent[]): Set<string>
* @param kind - The list kind to filter by * @param kind - The list kind to filter by
* @returns Set of unique pubkeys from the specified list kind * @returns Set of unique pubkeys from the specified list kind
*/ */
export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number): Set<string> { export function getPubkeysFromListKind(
userLists: UserListEvent[],
kind: number,
): Set<string> {
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
userLists.forEach(list => { userLists.forEach((list) => {
if (list.kind === kind) { if (list.kind === kind) {
list.pubkeys.forEach(pubkey => { list.pubkeys.forEach((pubkey) => {
pubkeys.add(pubkey); pubkeys.add(pubkey);
}); });
} }
@ -183,11 +195,22 @@ export function getPubkeysFromListKind(userLists: UserListEvent[], kind: number)
* @param userLists - Array of UserListEvent objects * @param userLists - Array of UserListEvent objects
* @returns True if the pubkey is in any list * @returns True if the pubkey is in any list
*/ */
export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]): boolean { export function isPubkeyInUserLists(
const result = userLists.some(list => list.pubkeys.includes(pubkey)); pubkey: string,
console.log(`isPubkeyInUserLists: Checking ${pubkey} against ${userLists.length} lists, result: ${result}`); userLists: UserListEvent[],
): boolean {
const result = userLists.some((list) => list.pubkeys.includes(pubkey));
console.log(
`isPubkeyInUserLists: Checking ${pubkey} against ${userLists.length} lists, result: ${result}`,
);
if (result) { if (result) {
console.log(`isPubkeyInUserLists: Found ${pubkey} in lists:`, userLists.filter(list => list.pubkeys.includes(pubkey)).map(list => ({ kind: list.kind, name: list.listName }))); console.log(
`isPubkeyInUserLists: Found ${pubkey} in lists:`,
userLists.filter((list) => list.pubkeys.includes(pubkey)).map((list) => ({
kind: list.kind,
name: list.listName,
})),
);
} }
return result; return result;
} }
@ -198,10 +221,13 @@ export function isPubkeyInUserLists(pubkey: string, userLists: UserListEvent[]):
* @param userLists - Array of UserListEvent objects * @param userLists - Array of UserListEvent objects
* @returns Array of list kinds that contain the pubkey * @returns Array of list kinds that contain the pubkey
*/ */
export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[]): number[] { export function getListKindsForPubkey(
pubkey: string,
userLists: UserListEvent[],
): number[] {
return userLists return userLists
.filter(list => list.pubkeys.includes(pubkey)) .filter((list) => list.pubkeys.includes(pubkey))
.map(list => list.kind); .map((list) => list.kind);
} }
/** /**
@ -209,29 +235,32 @@ export function getListKindsForPubkey(pubkey: string, userLists: UserListEvent[]
* This ensures follows are always cached and prioritized * This ensures follows are always cached and prioritized
* @param pubkeys - Array of pubkeys to cache profiles for * @param pubkeys - Array of pubkeys to cache profiles for
*/ */
export async function updateProfileCacheForPubkeys(pubkeys: string[], ndk?: NDK): Promise<void> { export async function updateProfileCacheForPubkeys(
pubkeys: string[],
ndk?: NDK,
): Promise<void> {
if (pubkeys.length === 0) return; if (pubkeys.length === 0) return;
try { try {
console.log(`Updating profile cache for ${pubkeys.length} pubkeys`); console.log(`Updating profile cache for ${pubkeys.length} pubkeys`);
const ndkInstance = ndk || getNdkContext(); const ndkInstance = ndk || getNdkContext();
if (!ndkInstance) { if (!ndkInstance) {
console.warn("updateProfileCacheForPubkeys: No NDK instance available"); console.warn("updateProfileCacheForPubkeys: No NDK instance available");
return; return;
} }
// Fetch profiles for all pubkeys in batches // Fetch profiles for all pubkeys in batches
const batchSize = 20; const batchSize = 20;
for (let i = 0; i < pubkeys.length; i += batchSize) { for (let i = 0; i < pubkeys.length; i += batchSize) {
const batch = pubkeys.slice(i, i + batchSize); const batch = pubkeys.slice(i, i + batchSize);
try { try {
const events = await ndkInstance.fetchEvents({ const events = await ndkInstance.fetchEvents({
kinds: [0], kinds: [0],
authors: batch, authors: batch,
}); });
// Cache each profile // Cache each profile
for (const event of events) { for (const event of events) {
if (event.content) { if (event.content) {
@ -249,7 +278,7 @@ export async function updateProfileCacheForPubkeys(pubkeys: string[], ndk?: NDK)
console.warn("Failed to fetch batch of profiles:", error); console.warn("Failed to fetch batch of profiles:", error);
} }
} }
console.log("Profile cache update completed"); console.log("Profile cache update completed");
} catch (error) { } catch (error) {
console.warn("Failed to update profile cache:", error); console.warn("Failed to update profile cache:", error);

1
src/routes/publication/[type]/[identifier]/+layout.server.ts

@ -1,6 +1,5 @@
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = ({ url }: { url: URL }) => { export const load: LayoutServerLoad = ({ url }: { url: URL }) => {
const currentUrl = `${url.origin}${url.pathname}`; const currentUrl = `${url.origin}${url.pathname}`;

3
src/routes/publication/[type]/[identifier]/+page.ts

@ -49,7 +49,8 @@ export const load: PageLoad = async (
// AI-NOTE: Return null for indexEvent during SSR or when fetch fails // AI-NOTE: Return null for indexEvent during SSR or when fetch fails
// The component will handle client-side loading and error states // The component will handle client-side loading and error states
const publicationType = indexEvent?.tags.find((tag) => tag[0] === "type")?.[1] ?? ""; const publicationType =
indexEvent?.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
const result = { const result = {
publicationType, publicationType,

8
src/styles/notifications.css

@ -107,15 +107,11 @@
} }
.message-container:hover { .message-container:hover {
box-shadow: box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
.dark .message-container:hover { .dark .message-container:hover {
box-shadow: box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2);
0 4px 6px -1px rgb(0 0 0 / 0.3),
0 2px 4px -2px rgb(0 0 0 / 0.2);
} }
/* Filter indicator styling */ /* Filter indicator styling */

3
src/styles/scrollbar.css

@ -1,7 +1,8 @@
@layer components { @layer components {
/* Global scrollbar styles */ /* Global scrollbar styles */
* { * {
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */ scrollbar-color: rgba(87, 66, 41, 0.8)
transparent; /* Transparent track, default scrollbar thumb */
} }
/* Webkit Browsers (Chrome, Safari, Edge) */ /* Webkit Browsers (Chrome, Safari, Edge) */

111
tests/unit/mathProcessing.test.ts

@ -3,29 +3,38 @@ import { parseAdvancedmarkup } from "../../src/lib/utils/markup/advancedMarkupPa
describe("Math Processing in Advanced Markup Parser", () => { describe("Math Processing in Advanced Markup Parser", () => {
it("should process inline math inside code blocks", async () => { it("should process inline math inside code blocks", async () => {
const input = "Here is some inline math: `$x^2 + y^2 = z^2$` in a sentence."; const input =
"Here is some inline math: `$x^2 + y^2 = z^2$` in a sentence.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(x^2 + y^2 = z^2\\)</span>'); expect(result).toContain(
'<span class="math-inline">\\(x^2 + y^2 = z^2\\)</span>',
);
expect(result).toContain("Here is some inline math:"); expect(result).toContain("Here is some inline math:");
expect(result).toContain("in a sentence."); expect(result).toContain("in a sentence.");
}); });
it("should process display math inside code blocks", async () => { it("should process display math inside code blocks", async () => {
const input = "Here is a display equation:\n\n`$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$`\n\nThis is after the equation."; const input =
"Here is a display equation:\n\n`$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$`\n\nThis is after the equation.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n\\]</span>'); expect(result).toContain(
'<span class="math-display">\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n\\]</span>',
);
expect(result).toContain('<p class="my-4">Here is a display equation:</p>'); expect(result).toContain('<p class="my-4">Here is a display equation:</p>');
expect(result).toContain('<p class="my-4">This is after the equation.</p>'); expect(result).toContain('<p class="my-4">This is after the equation.</p>');
}); });
it("should process both inline and display math in the same code block", async () => { it("should process both inline and display math in the same code block", async () => {
const input = "Mixed math: `$\\alpha$ and $$\\beta = \\frac{1}{2}$$` in one block."; const input =
"Mixed math: `$\\alpha$ and $$\\beta = \\frac{1}{2}$$` in one block.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha\\)</span>'); expect(result).toContain('<span class="math-inline">\\(\\alpha\\)</span>');
expect(result).toContain('<span class="math-display">\\[\\beta = \\frac{1}{2}\\]</span>'); expect(result).toContain(
'<span class="math-display">\\[\\beta = \\frac{1}{2}\\]</span>',
);
expect(result).toContain("Mixed math:"); expect(result).toContain("Mixed math:");
expect(result).toContain("in one block."); expect(result).toContain("in one block.");
}); });
@ -33,34 +42,39 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should NOT process math outside of code blocks", async () => { it("should NOT process math outside of code blocks", async () => {
const input = "This math $x^2 + y^2 = z^2$ should not be processed."; const input = "This math $x^2 + y^2 = z^2$ should not be processed.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain("$x^2 + y^2 = z^2$"); expect(result).toContain("$x^2 + y^2 = z^2$");
expect(result).not.toContain('<span class="math-inline">'); expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should NOT process display math outside of code blocks", async () => { it("should NOT process display math outside of code blocks", async () => {
const input = "This display math $$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$ should not be processed."; const input =
"This display math $$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$ should not be processed.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain("$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$"); expect(result).toContain(
"$$\n\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}\n$$",
);
expect(result).not.toContain('<span class="math-inline">'); expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should handle code blocks without math normally", async () => { it("should handle code blocks without math normally", async () => {
const input = "Here is some code: `console.log('hello world')` that should not be processed."; const input =
"Here is some code: `console.log('hello world')` that should not be processed.";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain("`console.log('hello world')`"); expect(result).toContain("`console.log('hello world')`");
expect(result).not.toContain('<span class="math-inline">'); expect(result).not.toContain('<span class="math-inline">');
expect(result).not.toContain('<span class="math-display">'); expect(result).not.toContain('<span class="math-display">');
}); });
it("should handle complex math expressions with nested structures", async () => { it("should handle complex math expressions with nested structures", async () => {
const input = "Complex math: `$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\cdot \\begin{pmatrix} x \\\\ y \\end{pmatrix} = \\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}$$`"; const input =
"Complex math: `$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\cdot \\begin{pmatrix} x \\\\ y \\end{pmatrix} = \\begin{pmatrix} ax + by \\\\ cx + dy \\end{pmatrix}$$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{pmatrix}"); expect(result).toContain("\\begin{pmatrix}");
expect(result).toContain("\\end{pmatrix}"); expect(result).toContain("\\end{pmatrix}");
@ -68,25 +82,34 @@ describe("Math Processing in Advanced Markup Parser", () => {
}); });
it("should handle inline math with special characters", async () => { it("should handle inline math with special characters", async () => {
const input = "Special chars: `$\\alpha, \\beta, \\gamma, \\delta$` and `$\\sum_{i=1}^{n} x_i$`"; const input =
"Special chars: `$\\alpha, \\beta, \\gamma, \\delta$` and `$\\sum_{i=1}^{n} x_i$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(\\alpha, \\beta, \\gamma, \\delta\\)</span>'); expect(result).toContain(
expect(result).toContain('<span class="math-inline">\\(\\sum_{i=1}^{n} x_i\\)</span>'); '<span class="math-inline">\\(\\alpha, \\beta, \\gamma, \\delta\\)</span>',
);
expect(result).toContain(
'<span class="math-inline">\\(\\sum_{i=1}^{n} x_i\\)</span>',
);
}); });
it("should handle multiple math expressions in separate code blocks", async () => { it("should handle multiple math expressions in separate code blocks", async () => {
const input = "First: `$E = mc^2$` and second: `$$F = G\\frac{m_1 m_2}{r^2}$$`"; const input =
"First: `$E = mc^2$` and second: `$$F = G\\frac{m_1 m_2}{r^2}$$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">\\(E = mc^2\\)</span>'); expect(result).toContain('<span class="math-inline">\\(E = mc^2\\)</span>');
expect(result).toContain('<span class="math-display">\\[F = G\\frac{m_1 m_2}{r^2}\\]</span>'); expect(result).toContain(
'<span class="math-display">\\[F = G\\frac{m_1 m_2}{r^2}\\]</span>',
);
}); });
it("should handle math expressions with line breaks in display mode", async () => { it("should handle math expressions with line breaks in display mode", async () => {
const input = "Multi-line: `$$\n\\begin{align}\nx &= a + b \\\\\ny &= c + d\n\\end{align}\n$$`"; const input =
"Multi-line: `$$\n\\begin{align}\nx &= a + b \\\\\ny &= c + d\n\\end{align}\n$$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\begin{align}"); expect(result).toContain("\\begin{align}");
expect(result).toContain("\\end{align}"); expect(result).toContain("\\end{align}");
@ -97,7 +120,7 @@ describe("Math Processing in Advanced Markup Parser", () => {
it("should handle edge case with empty math expressions", async () => { it("should handle edge case with empty math expressions", async () => {
const input = "Empty math: `$$` and `$`"; const input = "Empty math: `$$` and `$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
// Should not crash and should preserve the original content // Should not crash and should preserve the original content
expect(result).toContain("`$$`"); expect(result).toContain("`$$`");
expect(result).toContain("`$`"); expect(result).toContain("`$`");
@ -115,16 +138,18 @@ And display math: \`$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$\`
And more regular text.`; And more regular text.`;
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
// Should preserve regular text // Should preserve regular text
expect(result).toContain("This is a paragraph with regular text."); expect(result).toContain("This is a paragraph with regular text.");
expect(result).toContain("And more regular text."); expect(result).toContain("And more regular text.");
// Should preserve regular code blocks // Should preserve regular code blocks
expect(result).toContain("`console.log('hello')`"); expect(result).toContain("`console.log('hello')`");
// Should process math // Should process math
expect(result).toContain('<span class="math-inline">\\(\\pi \\approx 3.14159\\)</span>'); expect(result).toContain(
'<span class="math-inline">\\(\\pi \\approx 3.14159\\)</span>',
);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\int_0^1 x^2 dx = \\frac{1}{3}"); expect(result).toContain("\\int_0^1 x^2 dx = \\frac{1}{3}");
}); });
@ -132,15 +157,16 @@ And more regular text.`;
it("should handle math expressions with dollar signs in the content", async () => { it("should handle math expressions with dollar signs in the content", async () => {
const input = "Price math: `$\\text{Price} = \\$19.99$`"; const input = "Price math: `$\\text{Price} = \\$19.99$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\text{Price} = \\$19.99"); expect(result).toContain("\\text{Price} = \\$19.99");
}); });
it("should handle display math with dollar signs in the content", async () => { it("should handle display math with dollar signs in the content", async () => {
const input = "Price display: `$$\n\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98\n$$`"; const input =
"Price display: `$$\n\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98\n$$`";
const result = await parseAdvancedmarkup(input); const result = await parseAdvancedmarkup(input);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98"); expect(result).toContain("\\text{Total} = \\$19.99 + \\$5.99 = \\$25.98");
}); });
@ -149,34 +175,37 @@ And more regular text.`;
// Simulate content from JSON where backslashes are escaped // Simulate content from JSON where backslashes are escaped
const jsonContent = "Math from JSON: `$\\\\alpha + \\\\beta = \\\\gamma$`"; const jsonContent = "Math from JSON: `$\\\\alpha + \\\\beta = \\\\gamma$`";
const result = await parseAdvancedmarkup(jsonContent); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\alpha + \\\\beta = \\\\gamma"); expect(result).toContain("\\\\alpha + \\\\beta = \\\\gamma");
}); });
it("should handle JSON content with escaped display math", async () => { it("should handle JSON content with escaped display math", async () => {
// Simulate content from JSON where backslashes are escaped // Simulate content from JSON where backslashes are escaped
const jsonContent = "Display math from JSON: `$$\\\\int_0^1 x^2 dx = \\\\frac{1}{3}$$`"; const jsonContent =
"Display math from JSON: `$$\\\\int_0^1 x^2 dx = \\\\frac{1}{3}$$`";
const result = await parseAdvancedmarkup(jsonContent); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\int_0^1 x^2 dx = \\\\frac{1}{3}"); expect(result).toContain("\\\\int_0^1 x^2 dx = \\\\frac{1}{3}");
}); });
it("should handle JSON content with escaped dollar signs", async () => { it("should handle JSON content with escaped dollar signs", async () => {
// Simulate content from JSON where dollar signs are escaped // Simulate content from JSON where dollar signs are escaped
const jsonContent = "Price math from JSON: `$\\\\text{Price} = \\\\\\$19.99$`"; const jsonContent =
"Price math from JSON: `$\\\\text{Price} = \\\\\\$19.99$`";
const result = await parseAdvancedmarkup(jsonContent); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-inline">'); expect(result).toContain('<span class="math-inline">');
expect(result).toContain("\\\\text{Price} = \\\\\\$19.99"); expect(result).toContain("\\\\text{Price} = \\\\\\$19.99");
}); });
it("should handle complex JSON content with multiple escaped characters", async () => { it("should handle complex JSON content with multiple escaped characters", async () => {
// Simulate complex content from JSON // Simulate complex content from JSON
const jsonContent = "Complex JSON math: `$$\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix} \\\\cdot \\\\begin{pmatrix} x \\\\\\\\ y \\\\end{pmatrix}$$`"; const jsonContent =
"Complex JSON math: `$$\\\\begin{pmatrix} a & b \\\\\\\\ c & d \\\\end{pmatrix} \\\\cdot \\\\begin{pmatrix} x \\\\\\\\ y \\\\end{pmatrix}$$`";
const result = await parseAdvancedmarkup(jsonContent); const result = await parseAdvancedmarkup(jsonContent);
expect(result).toContain('<span class="math-display">'); expect(result).toContain('<span class="math-display">');
expect(result).toContain("\\\\begin{pmatrix}"); expect(result).toContain("\\\\begin{pmatrix}");
expect(result).toContain("\\\\end{pmatrix}"); expect(result).toContain("\\\\end{pmatrix}");

8
tests/unit/tagExpansion.test.ts

@ -335,7 +335,9 @@ describe("Tag Expansion Tests", () => {
); );
// Should not include events without tags // Should not include events without tags
expect(result.publications.map((p: any) => p.id)).not.toContain("no-tags"); expect(result.publications.map((p: any) => p.id)).not.toContain(
"no-tags",
);
}); });
}); });
@ -512,7 +514,9 @@ describe("Tag Expansion Tests", () => {
// Should handle d-tags with colons correctly // Should handle d-tags with colons correctly
expect(result.publications).toHaveLength(3); expect(result.publications).toHaveLength(3);
expect(result.contentEvents.map((c: any) => c.id)).toContain("colon-content"); expect(result.contentEvents.map((c: any) => c.id)).toContain(
"colon-content",
);
}); });
}); });
}); });

Loading…
Cancel
Save