12 changed files with 2270 additions and 411 deletions
@ -0,0 +1,378 @@
@@ -0,0 +1,378 @@
|
||||
<script lang="ts"> |
||||
import { onMount, onDestroy } from 'svelte'; |
||||
import { EditorView } from '@codemirror/view'; |
||||
import { EditorState } from '@codemirror/state'; |
||||
import { keymap } from '@codemirror/view'; |
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; |
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; |
||||
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; |
||||
import { markdown } from '@codemirror/lang-markdown'; |
||||
import { StreamLanguage } from '@codemirror/language'; |
||||
import { asciidoc } from 'codemirror-asciidoc'; |
||||
import { oneDark } from '@codemirror/theme-one-dark'; |
||||
|
||||
interface Props { |
||||
value: string; |
||||
mode: 'markdown' | 'asciidoc'; |
||||
onUpdate?: (value: string) => void; |
||||
onClose?: () => void; |
||||
} |
||||
|
||||
let { value, mode, onUpdate, onClose }: Props = $props(); |
||||
|
||||
let editorContainer = $state<HTMLElement | null>(null); |
||||
let editorView: EditorView | null = $state(null); |
||||
let isDark = $state(false); |
||||
let initialized = $state(false); |
||||
|
||||
// Check for dark mode preference |
||||
onMount(() => { |
||||
if (initialized || !editorContainer) return; |
||||
|
||||
const checkDarkMode = () => { |
||||
isDark = document.documentElement.classList.contains('dark') || |
||||
window.matchMedia('(prefers-color-scheme: dark)').matches; |
||||
}; |
||||
|
||||
checkDarkMode(); |
||||
|
||||
// Watch for dark mode changes |
||||
const observer = new MutationObserver(checkDarkMode); |
||||
observer.observe(document.documentElement, { |
||||
attributes: true, |
||||
attributeFilter: ['class'] |
||||
}); |
||||
|
||||
// Initialize editor |
||||
try { |
||||
const language = mode === 'asciidoc' |
||||
? StreamLanguage.define(asciidoc) |
||||
: markdown(); |
||||
|
||||
const extensions = [ |
||||
history(), |
||||
closeBrackets(), |
||||
autocompletion(), |
||||
highlightSelectionMatches(), |
||||
keymap.of([ |
||||
...closeBracketsKeymap, |
||||
...defaultKeymap, |
||||
...searchKeymap, |
||||
...historyKeymap, |
||||
...completionKeymap |
||||
]), |
||||
language, |
||||
EditorView.updateListener.of((update) => { |
||||
if (update.docChanged && onUpdate) { |
||||
const newValue = update.state.doc.toString(); |
||||
onUpdate(newValue); |
||||
} |
||||
}), |
||||
EditorView.theme({ |
||||
'&': { |
||||
fontSize: '14px', |
||||
height: '100%' |
||||
}, |
||||
'.cm-editor': { |
||||
height: '100%' |
||||
}, |
||||
'.cm-scroller': { |
||||
height: '100%', |
||||
overflow: 'auto' |
||||
}, |
||||
'.cm-content': { |
||||
minHeight: '400px', |
||||
padding: '1rem', |
||||
fontFamily: 'SF Mono, Monaco, Inconsolata, Fira Code, Droid Sans Mono, Source Code Pro, monospace', |
||||
lineHeight: '1.6' |
||||
}, |
||||
'.cm-focused': { |
||||
outline: 'none' |
||||
} |
||||
}) |
||||
]; |
||||
|
||||
// Add dark theme if needed |
||||
if (isDark) { |
||||
extensions.push(oneDark); |
||||
} |
||||
|
||||
const state = EditorState.create({ |
||||
doc: value, |
||||
extensions |
||||
}); |
||||
|
||||
editorView = new EditorView({ |
||||
state, |
||||
parent: editorContainer |
||||
}); |
||||
|
||||
initialized = true; |
||||
} catch (error) { |
||||
console.error('Error initializing CodeMirror:', error); |
||||
} |
||||
|
||||
return () => { |
||||
observer.disconnect(); |
||||
if (editorView) { |
||||
editorView.destroy(); |
||||
editorView = null; |
||||
initialized = false; |
||||
} |
||||
}; |
||||
}); |
||||
|
||||
onDestroy(() => { |
||||
if (editorView) { |
||||
editorView.destroy(); |
||||
editorView = null; |
||||
initialized = false; |
||||
} |
||||
}); |
||||
|
||||
function handleSave() { |
||||
if (editorView && onUpdate) { |
||||
const content = editorView.state.doc.toString(); |
||||
onUpdate(content); |
||||
} |
||||
if (onClose) { |
||||
onClose(); |
||||
} |
||||
} |
||||
|
||||
function handleCancel() { |
||||
if (onClose) { |
||||
onClose(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="advanced-editor-modal" role="dialog" aria-modal="true" aria-labelledby="editor-title"> |
||||
<div class="editor-container"> |
||||
<div class="editor-header"> |
||||
<h2 id="editor-title" class="editor-title"> |
||||
Advanced Editor - {mode === 'asciidoc' ? 'AsciiDoc' : 'Markdown'} |
||||
</h2> |
||||
<button |
||||
class="close-button" |
||||
onclick={handleCancel} |
||||
aria-label="Close editor" |
||||
> |
||||
× |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="editor-body"> |
||||
<div class="editor-wrapper" bind:this={editorContainer}></div> |
||||
</div> |
||||
|
||||
<div class="editor-footer"> |
||||
<button class="cancel-button" onclick={handleCancel}> |
||||
Cancel |
||||
</button> |
||||
<button class="save-button" onclick={handleSave}> |
||||
Save & Close |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.advanced-editor-modal { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(15, 23, 42, 0.75); |
||||
backdrop-filter: blur(4px); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
z-index: 2000; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.editor-container { |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 100%; |
||||
max-width: 1200px; |
||||
max-height: 90vh; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .editor-container { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2); |
||||
} |
||||
|
||||
.editor-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem 1.5rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .editor-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.editor-title { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .editor-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: none; |
||||
border: none; |
||||
font-size: 1.75rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text, #64748b); |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.editor-body { |
||||
flex: 1; |
||||
overflow: hidden; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 400px; |
||||
} |
||||
|
||||
.editor-wrapper { |
||||
flex: 1; |
||||
overflow: hidden; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
margin: 1rem 1.5rem; |
||||
} |
||||
|
||||
:global(.dark) .editor-wrapper { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.editor-footer { |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
gap: 0.75rem; |
||||
padding: 1rem 1.5rem; |
||||
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .editor-footer { |
||||
border-top-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.cancel-button, |
||||
.save-button { |
||||
padding: 0.625rem 1.25rem; |
||||
border-radius: 0.375rem; |
||||
font-size: 0.875rem; |
||||
font-weight: 500; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #475569); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.cancel-button:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .cancel-button:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.save-button { |
||||
background: var(--fog-accent, #64748b); |
||||
color: var(--fog-text, #f1f5f9); |
||||
border: none; |
||||
} |
||||
|
||||
:global(.dark) .save-button { |
||||
background: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.save-button:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
/* Mobile optimizations */ |
||||
@media (max-width: 768px) { |
||||
.advanced-editor-modal { |
||||
padding: 0; |
||||
} |
||||
|
||||
.editor-container { |
||||
max-width: 100%; |
||||
max-height: 100vh; |
||||
border-radius: 0; |
||||
border-left: none; |
||||
border-right: none; |
||||
} |
||||
|
||||
.editor-header { |
||||
padding: 0.75rem 1rem; |
||||
} |
||||
|
||||
.editor-title { |
||||
font-size: 1.125rem; |
||||
} |
||||
|
||||
.editor-wrapper { |
||||
margin: 0.75rem 1rem; |
||||
min-height: calc(100vh - 200px); |
||||
} |
||||
|
||||
.editor-footer { |
||||
padding: 0.75rem 1rem; |
||||
flex-direction: column-reverse; |
||||
} |
||||
|
||||
.cancel-button, |
||||
.save-button { |
||||
width: 100%; |
||||
padding: 0.75rem 1.25rem; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,420 @@
@@ -0,0 +1,420 @@
|
||||
/** |
||||
* Kind metadata including help text, examples, and form configuration |
||||
* Based on NIPs from nips-silberengel directory |
||||
*/ |
||||
|
||||
import { KIND, KIND_LOOKUP, type KindInfo } from './kind-lookup.js'; |
||||
|
||||
export interface KindMetadata extends KindInfo { |
||||
helpText: { |
||||
description: string; |
||||
suggestedTags: string[]; |
||||
}; |
||||
exampleJSON: (pubkey: string, eventId: string, relay: string, timestamp: number) => object; |
||||
writable?: boolean; // Whether this kind can be created via the write form
|
||||
requiresContent?: boolean; // Whether content field is required (default: true)
|
||||
} |
||||
|
||||
// Kinds that can be written via the form
|
||||
const WRITABLE_KINDS = [ |
||||
KIND.SHORT_TEXT_NOTE, |
||||
KIND.DISCUSSION_THREAD, |
||||
KIND.PICTURE_NOTE, |
||||
KIND.VIDEO_NOTE, |
||||
KIND.SHORT_VIDEO_NOTE, |
||||
KIND.PUBLIC_MESSAGE, |
||||
KIND.POLL, |
||||
KIND.VOICE_NOTE, |
||||
KIND.HIGHLIGHTED_ARTICLE, |
||||
KIND.RSS_FEED, |
||||
KIND.LONG_FORM_NOTE, |
||||
KIND.PUBLICATION_INDEX, |
||||
KIND.PUBLICATION_CONTENT, |
||||
KIND.WIKI_MARKDOWN, |
||||
KIND.WIKI_ASCIIDOC, |
||||
] as const; |
||||
|
||||
export const KIND_METADATA: Record<number, KindMetadata> = { |
||||
[KIND.SHORT_TEXT_NOTE]: { |
||||
...KIND_LOOKUP[KIND.SHORT_TEXT_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'A simple plaintext note (NIP-10). The content property contains some human-readable text.', |
||||
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.SHORT_TEXT_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'Hello nostr! This is a simple text note.', |
||||
tags: [ |
||||
['e', eventId, relay], |
||||
['p', pubkey], |
||||
['t', 'nostr'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.DISCUSSION_THREAD]: { |
||||
...KIND_LOOKUP[KIND.DISCUSSION_THREAD], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'A thread (NIP-7D). A thread is a kind 11 event. Threads SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).', |
||||
suggestedTags: ['title (required)', 't (topics/hashtags)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.DISCUSSION_THREAD, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'This is a discussion thread about a topic.', |
||||
tags: [ |
||||
['title', 'Discussion Thread Title'], |
||||
['t', 'topic1'], |
||||
['t', 'topic2'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.PICTURE_NOTE]: { |
||||
...KIND_LOOKUP[KIND.PICTURE_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Picture-first feeds (NIP-68). Event kind 20 for picture-first clients. Images must be self-contained. They are hosted externally and referenced using imeta tags.', |
||||
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.PICTURE_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'A beautiful sunset photo', |
||||
tags: [ |
||||
['title', 'Sunset Photo'], |
||||
['imeta', 'url https://nostr.build/i/image.jpg', 'm image/jpeg', 'dim 3024x4032', 'alt A scenic sunset'], |
||||
['t', 'photography'], |
||||
['location', 'San Francisco, CA'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.VIDEO_NOTE]: { |
||||
...KIND_LOOKUP[KIND.VIDEO_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Video Events (NIP-71). Normal videos representing a dedicated post of externally hosted content. The content is a summary or description on the video content.', |
||||
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.VIDEO_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'A detailed video about Nostr protocol', |
||||
tags: [ |
||||
['title', 'Introduction to Nostr'], |
||||
['imeta', 'url https://example.com/video.mp4', 'dim 1920x1080', 'm video/mp4', 'duration 300', 'bitrate 3000000'], |
||||
['published_at', timestamp.toString()], |
||||
['t', 'tutorial'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.SHORT_VIDEO_NOTE]: { |
||||
...KIND_LOOKUP[KIND.SHORT_VIDEO_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Video Events (NIP-71). Short videos (stories/reels style) representing a dedicated post of externally hosted content. The content is a summary or description on the video content.', |
||||
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.SHORT_VIDEO_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'Quick video update', |
||||
tags: [ |
||||
['title', 'Quick Update'], |
||||
['imeta', 'url https://example.com/short.mp4', 'dim 1080x1920', 'm video/mp4', 'duration 15'], |
||||
['published_at', timestamp.toString()] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.PUBLIC_MESSAGE]: { |
||||
...KIND_LOOKUP[KIND.PUBLIC_MESSAGE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Public Messages (NIP-A4). A simple plaintext message to one or more Nostr users. The content contains the message. p tags identify one or more receivers. Designed to be shown and replied to from notification screens.', |
||||
suggestedTags: ['p (receiver pubkeys, required)', 'expiration (recommended)', 'q (quoted events)', 'imeta (for image/video links)', 't (hashtags)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.PUBLIC_MESSAGE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'Hello! This is a public message.', |
||||
tags: [ |
||||
['p', pubkey], |
||||
['expiration', (timestamp + 86400).toString()], |
||||
['t', 'message'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.POLL]: { |
||||
...KIND_LOOKUP[KIND.POLL], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Polls (NIP-88). The poll event is defined as a kind 1068 event. The content key holds the label for the poll.', |
||||
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.POLL, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'What is your favorite color?', |
||||
tags: [ |
||||
['option', 'opt1', 'Red'], |
||||
['option', 'opt2', 'Blue'], |
||||
['option', 'opt3', 'Green'], |
||||
['relay', relay], |
||||
['polltype', 'singlechoice'], |
||||
['endsAt', (timestamp + 86400).toString()] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.VOICE_NOTE]: { |
||||
...KIND_LOOKUP[KIND.VOICE_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Voice Messages (NIP-A0). Root messages for short voice messages, typically up to 60 seconds in length. Content MUST be a URL pointing directly to an audio file (audio/mp4 recommended).', |
||||
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.VOICE_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'https://example.com/audio/voice-message.m4a', |
||||
tags: [ |
||||
['imeta', 'url https://example.com/audio/voice-message.m4a', 'waveform 0 7 35 8 100 100 49', 'duration 8'], |
||||
['t', 'voice'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.HIGHLIGHTED_ARTICLE]: { |
||||
...KIND_LOOKUP[KIND.HIGHLIGHTED_ARTICLE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Highlights (NIP-84). A highlight event to signal content a user finds valuable. The content of these events is the highlighted portion of the text.', |
||||
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.HIGHLIGHTED_ARTICLE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'This is the highlighted text portion.', |
||||
tags: [ |
||||
['e', eventId, relay], |
||||
['p', pubkey, '', 'author'], |
||||
['context', 'This is the highlighted text portion within the surrounding context text...'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.RSS_FEED]: { |
||||
...KIND_LOOKUP[KIND.RSS_FEED], |
||||
writable: true, |
||||
requiresContent: false, |
||||
helpText: { |
||||
description: 'RSS Feed subscription event. Lists external RSS feeds to subscribe to. Content should be empty.', |
||||
suggestedTags: ['u (RSS feed URL, repeat for multiple feeds)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.RSS_FEED, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '', |
||||
tags: [ |
||||
['u', 'https://example.com/feed.rss'], |
||||
['u', 'https://another-site.com/rss.xml'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.LONG_FORM_NOTE]: { |
||||
...KIND_LOOKUP[KIND.LONG_FORM_NOTE], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Long-form Content (NIP-23). Long-form text content, generally referred to as "articles" or "blog posts". The content should be a string text in Markdown syntax. Include a d tag for editability.', |
||||
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.LONG_FORM_NOTE, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '# Long-form Article\n\nThis is a long-form article written in Markdown...', |
||||
tags: [ |
||||
['d', 'article-slug'], |
||||
['title', 'My Long-form Article'], |
||||
['summary', 'A brief summary of the article'], |
||||
['published_at', timestamp.toString()], |
||||
['t', 'article'], |
||||
['t', 'longform'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.PUBLICATION_INDEX]: { |
||||
...KIND_LOOKUP[KIND.PUBLICATION_INDEX], |
||||
writable: true, |
||||
requiresContent: false, |
||||
helpText: { |
||||
description: 'Publication Index (NKBIP-01). A publication index defines the structure and metadata of a publication. The content field MUST be empty.', |
||||
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.PUBLICATION_INDEX, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '', |
||||
tags: [ |
||||
['d', 'publication-slug'], |
||||
['title', 'My Publication'], |
||||
['author', 'Author Name'], |
||||
['summary', 'Publication summary'], |
||||
['type', 'book'], |
||||
['a', `30041:${pubkey}:chapter-1`, relay], |
||||
['a', `30041:${pubkey}:chapter-2`, relay], |
||||
['auto-update', 'ask'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.PUBLICATION_CONTENT]: { |
||||
...KIND_LOOKUP[KIND.PUBLICATION_CONTENT], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Publication Content (NKBIP-01). Also known as sections, zettels, episodes, or chapters contain the actual content that makes up a publication. The content field MUST contain text meant for display to the end user and MAY contain AsciiDoc markup.', |
||||
suggestedTags: ['d (required)', 'title (required)', 'wikilink'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.PUBLICATION_CONTENT, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '= Chapter Title\n\nChapter content with [[wikilinks]]...', |
||||
tags: [ |
||||
['d', 'publication-chapter-1'], |
||||
['title', 'Chapter 1: Introduction'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.WIKI_MARKDOWN]: { |
||||
...KIND_LOOKUP[KIND.WIKI_MARKDOWN], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.', |
||||
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.WIKI_MARKDOWN, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '= AsciiDoc Document\n\nContent in AsciiDoc format...', |
||||
tags: [ |
||||
['d', 'asciidoc-doc'], |
||||
['title', 'AsciiDoc Document'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
|
||||
[KIND.WIKI_ASCIIDOC]: { |
||||
...KIND_LOOKUP[KIND.WIKI_ASCIIDOC], |
||||
writable: true, |
||||
helpText: { |
||||
description: 'Wiki (NIP-54). Descriptions (or encyclopedia entries) of particular subjects. Articles are identified by lowercase, normalized d tags. The content should be Asciidoc with wikilinks and nostr:... links.', |
||||
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)'] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind: KIND.WIKI_ASCIIDOC, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: '= Wiki Article\n\nThis is a wiki article written in AsciiDoc.', |
||||
tags: [ |
||||
['d', 'wiki-article'], |
||||
['title', 'Wiki Article'], |
||||
['summary', 'A brief summary'] |
||||
], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}, |
||||
}; |
||||
|
||||
/** |
||||
* Get metadata for a kind, with fallback for unknown kinds |
||||
*/ |
||||
export function getKindMetadata(kind: number): KindMetadata { |
||||
const metadata = KIND_METADATA[kind]; |
||||
if (metadata) return metadata; |
||||
|
||||
// Fallback for unknown kinds
|
||||
const kindInfo = KIND_LOOKUP[kind] || { number: kind, description: `Kind ${kind}`, showInFeed: false }; |
||||
return { |
||||
...kindInfo, |
||||
writable: false, |
||||
helpText: { |
||||
description: `Custom kind ${kind}. Refer to the relevant NIP specification for tag requirements.`, |
||||
suggestedTags: [] |
||||
}, |
||||
exampleJSON: (pubkey, eventId, relay, timestamp) => ({ |
||||
kind, |
||||
pubkey, |
||||
created_at: timestamp, |
||||
content: 'Custom event content', |
||||
tags: [['example', 'tag', 'value']], |
||||
id: '...', |
||||
sig: '...' |
||||
}) |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Get all writable kinds for the form |
||||
*/ |
||||
export function getWritableKinds(): Array<{ value: number; label: string }> { |
||||
const writableKinds = WRITABLE_KINDS.map(kind => { |
||||
const metadata = KIND_METADATA[kind]; |
||||
return { |
||||
value: kind, |
||||
label: `${kind} - ${metadata?.description || KIND_LOOKUP[kind]?.description || 'Unknown'}` |
||||
}; |
||||
}); |
||||
return [...writableKinds, { value: -1, label: 'Unknown Kind' }]; |
||||
} |
||||
@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
|
||||
/** |
||||
* Utility to process content and add "nostr:" prefix to valid Nostr bech32 addresses |
||||
*
|
||||
* Processes naddr, nevent, note1, npub, and nprofile addresses that: |
||||
* - Are at the start of a line or preceded by whitespace |
||||
* - Are not part of a URL |
||||
* - Are valid, complete bech32 addresses (can be decoded) |
||||
*/ |
||||
|
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
// Regex patterns for Nostr bech32 addresses
|
||||
const NOSTR_ADDRESS_PATTERNS = { |
||||
naddr: /^naddr1[a-z0-9]+$/i, |
||||
nevent: /^nevent1[a-z0-9]+$/i, |
||||
note: /^note1[a-z0-9]+$/i, |
||||
npub: /^npub1[a-z0-9]+$/i, |
||||
nprofile: /^nprofile1[a-z0-9]+$/i, |
||||
}; |
||||
|
||||
// Combined pattern to match any Nostr address
|
||||
// Matches addresses at start of line or after whitespace, followed by end of string, whitespace, or punctuation
|
||||
const NOSTR_ADDRESS_REGEX = /(?:^|\s)(naddr1|nevent1|note1|npub1|nprofile1)([a-z0-9]+)(?=\s|$|[.,;:!?)\]}>])/gi; |
||||
|
||||
// URL pattern to avoid matching addresses inside URLs
|
||||
const URL_PATTERN = /https?:\/\/[^\s]+/gi; |
||||
|
||||
/** |
||||
* Check if a bech32 string is valid and complete |
||||
*/ |
||||
function isValidBech32(address: string): boolean { |
||||
try { |
||||
// Try to decode it - if it fails, it's invalid
|
||||
const decoded = nip19.decode(address); |
||||
return decoded !== null; |
||||
} catch { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Check if a position in text is part of a URL |
||||
*/ |
||||
function isInURL(text: string, matchStart: number, matchEnd: number): boolean { |
||||
// Find all URLs in the text
|
||||
const urlMatches = Array.from(text.matchAll(URL_PATTERN)); |
||||
|
||||
for (const urlMatch of urlMatches) { |
||||
const urlStart = urlMatch.index!; |
||||
const urlEnd = urlStart + urlMatch[0].length; |
||||
|
||||
// Check if our match overlaps with any URL
|
||||
if (matchStart >= urlStart && matchStart < urlEnd) { |
||||
return true; |
||||
} |
||||
if (matchEnd > urlStart && matchEnd <= urlEnd) { |
||||
return true; |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* Process content to add "nostr:" prefix to valid Nostr addresses |
||||
*
|
||||
* @param content - The content to process |
||||
* @returns Processed content with "nostr:" prefixes added |
||||
*/ |
||||
export function processNostrLinks(content: string): string { |
||||
if (!content || typeof content !== 'string') { |
||||
return content; |
||||
} |
||||
|
||||
// Find all potential Nostr addresses
|
||||
const matches: Array<{ fullMatch: string; prefix: string; address: string; start: number; end: number }> = []; |
||||
|
||||
let match; |
||||
const regex = new RegExp(NOSTR_ADDRESS_REGEX.source, NOSTR_ADDRESS_REGEX.flags); |
||||
|
||||
while ((match = regex.exec(content)) !== null) { |
||||
const prefix = match[1]; // naddr1, nevent1, etc.
|
||||
const bech32Data = match[2]; // The bech32 data part
|
||||
const fullAddress = prefix + bech32Data; |
||||
const matchStart = match.index! + (match[0].indexOf(prefix)); // Start of the actual address
|
||||
const matchEnd = matchStart + fullAddress.length; |
||||
|
||||
// Check if it's already prefixed with "nostr:"
|
||||
const beforeMatch = content.substring(Math.max(0, matchStart - 6), matchStart); |
||||
if (beforeMatch.toLowerCase().endsWith('nostr:')) { |
||||
continue; // Already has nostr: prefix
|
||||
} |
||||
|
||||
// Check if it's part of a URL
|
||||
if (isInURL(content, matchStart, matchEnd)) { |
||||
continue; // Skip if it's in a URL
|
||||
} |
||||
|
||||
// Check if it's a valid, complete address
|
||||
if (!isValidBech32(fullAddress)) { |
||||
continue; // Skip invalid addresses
|
||||
} |
||||
|
||||
matches.push({ |
||||
fullMatch: match[0], |
||||
prefix, |
||||
address: fullAddress, |
||||
start: matchStart, |
||||
end: matchEnd, |
||||
}); |
||||
} |
||||
|
||||
// Process matches in reverse order to maintain correct indices
|
||||
let processedContent = content; |
||||
for (let i = matches.length - 1; i >= 0; i--) { |
||||
const { address, start, end } = matches[i]; |
||||
processedContent =
|
||||
processedContent.substring(0, start) +
|
||||
'nostr:' + address +
|
||||
processedContent.substring(end); |
||||
} |
||||
|
||||
return processedContent; |
||||
} |
||||
Loading…
Reference in new issue