Browse Source

Make nostr address parsing conform to Elsat's replacement rules

master
Silberengel 10 months ago
parent
commit
2df526f338
  1. 5
      .vscode/settings.json
  2. 5
      src/app.css
  3. 22
      src/lib/utils/markdown/advancedMarkdownParser.ts
  4. 69
      src/lib/utils/markdown/basicMarkdownParser.ts
  5. 19
      src/lib/utils/markdown/markdownTestfile.md
  6. 4
      src/routes/contact/+page.svelte

5
.vscode/settings.json vendored

@ -1,3 +1,6 @@
{ {
"editor.tabSize": 2 "editor.tabSize": 2,
"files.associations": {
"*.css": "postcss"
}
} }

5
src/app.css

@ -251,6 +251,11 @@
@apply dark:text-white; @apply dark:text-white;
} }
/* Footnotes */
:global(.footnotes-ol) {
list-style-type: decimal !important;
}
/* Rendered publication content */ /* Rendered publication content */
.publication-leather { .publication-leather {
@apply flex flex-col space-y-4; @apply flex flex-col space-y-4;

22
src/lib/utils/markdown/advancedMarkdownParser.ts

@ -20,16 +20,28 @@ const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm;
* Process headings (both styles) * Process headings (both styles)
*/ */
function processHeadings(content: string): string { function processHeadings(content: string): string {
// Tailwind classes for each heading level
const headingClasses = [
'text-4xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h1
'text-3xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h2
'text-2xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h3
'text-xl font-bold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h4
'text-lg font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h5
'text-base font-semibold mt-6 mb-4 text-gray-800 dark:text-gray-300', // h6
];
// Process ATX-style headings (# Heading) // Process ATX-style headings (# Heading)
let processedContent = content.replace(HEADING_REGEX, (_, level, text) => { let processedContent = content.replace(HEADING_REGEX, (_, level, text) => {
const headingLevel = level.length; const headingLevel = Math.min(level.length, 6);
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`; const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
}); });
// Process Setext-style headings (Heading\n====) // Process Setext-style headings (Heading\n====)
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => {
const headingLevel = level[0] === '=' ? 1 : 2; const headingLevel = level[0] === '=' ? 1 : 2;
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`; const classes = headingClasses[headingLevel - 1];
return `<h${headingLevel} class="${classes}">${text.trim()}</h${headingLevel}>`;
}); });
return processedContent; return processedContent;
@ -148,9 +160,9 @@ function processFootnotes(content: string): string {
return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`; return `<sup><a href="#fn-${id}" id="fnref-${id}-${referenceMap.get(id)!.length}" class="text-primary-600 hover:underline">[${refNum}]</a></sup>`;
}); });
// Add footnotes section if we have any // Only render footnotes section if there are actual definitions and at least one reference
if (footnotes.size > 0 && referenceOrder.length > 0) { if (footnotes.size > 0 && referenceOrder.length > 0) {
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol">\n'; processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside footnotes-ol" style="list-style-type:decimal !important;">\n';
// Only include each unique footnote once, in order of first reference // Only include each unique footnote once, in order of first reference
const seen = new Set<string>(); const seen = new Set<string>();
for (const { id, label } of referenceOrder) { for (const { id, label } of referenceOrder) {

69
src/lib/utils/markdown/basicMarkdownParser.ts

@ -1,5 +1,6 @@
import { processNostrIdentifiers } from '../nostrUtils'; import { processNostrIdentifiers } from '../nostrUtils';
import * as emoji from 'node-emoji'; import * as emoji from 'node-emoji';
import { nip19 } from 'nostr-tools';
// Regular expressions for basic markdown elements // Regular expressions for basic markdown elements
const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g; const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g;
@ -26,6 +27,67 @@ const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i; const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/i;
// Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern = /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Replace Markdown links ONLY if they match the criteria
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, _label, url) => {
if (alexandriaPattern.test(url)) {
// Ignore d-tag URLs
if (/[?&]d=/.test(url)) return match;
// Convert hexid in URL to nevent if present
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
// Or use bech32 if present
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For all other links, leave the markdown link untouched
return match;
});
// 2. Replace bare Alexandria/localhost URLs only if they contain a Nostr identifier (not d-tag)
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
// Ignore d-tag URLs
if (/[?&]d=/.test(url)) return url;
// Convert hexid in URL to nevent if present
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
// Or use bech32 if present
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return url;
});
return text;
}
// Utility to strip tracking parameters from URLs // Utility to strip tracking parameters from URLs
function stripTrackingParams(url: string): string { function stripTrackingParams(url: string): string {
// List of tracking params to remove // List of tracking params to remove
@ -41,8 +103,8 @@ function stripTrackingParams(url: string): string {
} }
} }
}); });
parsed.search = parsed.searchParams.toString(); const queryString = parsed.searchParams.toString();
return parsed.origin + parsed.pathname + (parsed.search ? '?' + parsed.search : '') + (parsed.hash || ''); return parsed.origin + parsed.pathname + (queryString ? '?' + queryString : '') + (parsed.hash || '');
} else { } else {
// Relative URL: parse query string manually // Relative URL: parse query string manually
const [path, queryAndHash = ''] = url.split('?'); const [path, queryAndHash = ''] = url.split('?');
@ -68,6 +130,9 @@ function processBasicFormatting(content: string): string {
let processedText = content; let processedText = content;
try { try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process Markdown images first // Process Markdown images first
processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, url) => { processedText = processedText.replace(MARKDOWN_IMAGE, (match, alt, url) => {
url = stripTrackingParams(url); url = stripTrackingParams(url);

19
src/lib/utils/markdown/markdownTestfile.md

@ -5,6 +5,13 @@ This is a test
It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1]
# H1
## H2
### H3
#### H4
##### H5
###### H6
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser. This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser.
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.
@ -92,7 +99,17 @@ https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg
This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes.
You can even include `code inline`, like `<div class="leather min-h-full w-full flex flex-col items-center">` or You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses:
http://localhost:4173/publication?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw
But not if they have d-tags:
http://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1
And within a Markdown tag: [Markdown link title](http://alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c).
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or
``` ```
in a code block in a code block

4
src/routes/contact/+page.svelte

@ -289,7 +289,7 @@
<form class="space-y-4 mt-6" on:submit|preventDefault={handleSubmit}> <form class="space-y-4 mt-6" on:submit|preventDefault={handleSubmit}>
<div> <div>
<Label for="subject" class="mb-2">Subject</Label> <Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" class="w-full" placeholder="Issue subject" bind:value={subject} required autofocus /> <Input id="subject" class="w-full bg-white dark:bg-gray-800" placeholder="Issue subject" bind:value={subject} required autofocus />
</div> </div>
<div class="relative"> <div class="relative">
@ -366,7 +366,7 @@ Also renders nostr identifiers: npubs, nprofiles, nevents, notes, and naddrs. Wi
/> />
</div> </div>
{:else} {:else}
<div class="absolute inset-0 p-4 prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 prose-content"> <div class="absolute inset-0 p-4 max-w-none bg-white dark:bg-gray-800 prose-content markdown-content">
{#key content} {#key content}
{#await parseAdvancedMarkdown(content)} {#await parseAdvancedMarkdown(content)}
<p>Loading preview...</p> <p>Loading preview...</p>

Loading…
Cancel
Save