Browse Source

fix build

Nostr-Signature: a37754536125d75a5c55f6af3b5521f89839e797ad1bffb69e3d313939cb7b65 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 6bcca1a025e4478ae330d3664dd2b9cff55f4bec82065ab2afb5bfb92031f7dde3264657dd892fe844396990117048b19247b0ef7423139f89d4cbf46b47f828
main
Silberengel 2 weeks ago
parent
commit
60ff074ba1
  1. 1
      nostr/commit-signatures.jsonl
  2. 603
      src/routes/repos/[npub]/[repo]/+page.svelte
  3. 49
      src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts
  4. 353
      src/routes/repos/[npub]/[repo]/utils/file-processing.ts
  5. 202
      src/routes/repos/[npub]/[repo]/utils/nostr-links.ts
  6. 164
      src/routes/repos/[npub]/[repo]/utils/user-profile.ts

1
nostr/commit-signatures.jsonl

@ -94,3 +94,4 @@ @@ -94,3 +94,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772104036,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"830b91f4efe7d208128a008d44fd3b4352c09af0a83b40ea1fab769f9c8563cf","sig":"49a9772580d5ba1b9b9800bdb53f0f4b55661f6062f9968b18cbbd4983d7a042b477281769488d44b4f43c7bdf627d621d83c16659d3d8d226fb32fe0a450756"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772105581,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fdix build"]],"content":"Signed commit: fdix build","id":"aa457cd97e3af5c7e7e6f8938d159f62de2eee27afcf9a9a415192a8b39cd038","sig":"1959bae547fefff3b3fd72e23071e989724ab71f2042bad9cb5a969133045119a068b529df17ded13db96b54372f662760df79a34f1b6072dcabf5d2f003000b"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772106804,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 3"]],"content":"Signed commit: refactor 3","id":"a761c789227ef2368eff89f7062fa7889820c4846701667360978cfdad08c3d2","sig":"9d229200ab66d3f4a0a2a21112c9100ee14d0a5d9f8409a35fef36f195f5f73c8ac2344aa1175cc476f650336a5a10ea6ac0076c8ec2cb229fea7d600c5d4399"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772107667,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"2a8db19aff5126547a397f7daf9121f711a3d61efcced642b496687d9afc48dc","sig":"7e0558fac1764e185b3f52450f5a34805b04342bdb0821b4d459b1627d057d7e2af397b3263a8831e9be2e615556ef09094bce808c22f6049261273004da74bc"}

603
src/routes/repos/[npub]/[repo]/+page.svelte

@ -47,7 +47,6 @@ @@ -47,7 +47,6 @@
// Note: Announcements are now stored in nostr/repo-events.jsonl, not .nostr-announcement
import type { NostrEvent } from '$lib/types/nostr.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
import { createRepoState, type RepoState } from './stores/repo-state.js';
import {
usePageDataEffect,
@ -61,6 +60,23 @@ @@ -61,6 +60,23 @@
useTabChangeEffect,
useBranchChangeEffect
} from './hooks/use-repo-effects.js';
import {
getHighlightLanguage,
supportsPreview,
isImageFileType,
renderCsvAsTable,
escapeHtml,
applySyntaxHighlighting as applySyntaxHighlightingUtil,
renderFileAsHtml as renderFileAsHtmlUtil
} from './utils/file-processing.js';
import {
parseNostrLinks
} from './utils/nostr-links.js';
// formatDiscussionTime is defined locally (slightly different format than utility version)
import {
getUserEmail as getUserEmailUtil,
getUserName as getUserNameUtil
} from './utils/user-profile.js';
// Consolidated state - all state variables in one object
let state = $state(createRepoState());
@ -292,34 +308,7 @@ @@ -292,34 +308,7 @@
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
// Parse nostr: links from content and extract IDs/pubkeys
function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> {
const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = [];
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g;
let match;
while ((match = nostrLinkRegex.exec(content)) !== null) {
const fullMatch = match[0];
const prefix = match[1];
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile';
if (prefix === 'nevent1') type = 'nevent';
else if (prefix === 'naddr1') type = 'naddr';
else if (prefix === 'note1') type = 'note1';
else if (prefix === 'npub1') type = 'npub';
else if (prefix === 'profile1') type = 'profile';
else continue;
links.push({
type,
value: fullMatch,
start: match.index,
end: match.index + fullMatch.length
});
}
return links;
}
// parseNostrLinks is now imported from utils/nostr-links.ts
// Load events/profiles from nostr: links
async function loadNostrLinks(content: string) {
@ -398,8 +387,8 @@ @@ -398,8 +387,8 @@
}
}
// Get event from nostr: link
function getEventFromNostrLink(link: string): NostrEvent | undefined {
// Get event from nostr: link (local version that uses state)
function getEventFromNostrLinkLocal(link: string): NostrEvent | undefined {
try {
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) {
const decoded = nip19.decode(link.replace('nostr:', ''));
@ -426,12 +415,12 @@ @@ -426,12 +415,12 @@
return undefined;
}
// Get pubkey from nostr: npub/profile link
function getPubkeyFromNostrLink(link: string): string | undefined {
// Get pubkey from nostr: npub/profile link (local version that uses state)
function getPubkeyFromNostrLinkLocal(link: string): string | undefined {
return state.discussion.nostrLinkProfiles.get(link);
}
// Process content with nostr links into parts for rendering
// Process content with nostr links into parts for rendering (local version that uses state)
function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> {
const links = parseNostrLinks(content);
if (links.length === 0) {
@ -451,8 +440,8 @@ @@ -451,8 +440,8 @@
}
// Add link
const event = getEventFromNostrLink(link.value);
const pubkey = getPubkeyFromNostrLink(link.value);
const event = getEventFromNostrLinkLocal(link.value);
const pubkey = getPubkeyFromNostrLinkLocal(link.value);
if (event) {
parts.push({ type: 'event', value: link.value, event });
} else if (pubkey) {
@ -863,414 +852,23 @@ @@ -863,414 +852,23 @@
}
}
// Map file extensions to highlight.js language names
function getHighlightLanguage(ext: string): string {
const langMap: Record<string, string> = {
'js': 'javascript',
'ts': 'typescript',
'jsx': 'javascript',
'tsx': 'typescript',
'json': 'json',
'css': 'css',
'html': 'xml',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'py': 'python',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'h': 'c',
'hpp': 'cpp',
'sh': 'bash',
'bash': 'bash',
'zsh': 'bash',
'sql': 'sql',
'php': 'php',
'swift': 'swift',
'kt': 'kotlin',
'scala': 'scala',
'r': 'r',
'm': 'objectivec',
'mm': 'objectivec',
'vue': 'xml',
'svelte': 'xml',
'dockerfile': 'dockerfile',
'toml': 'toml',
'ini': 'ini',
'conf': 'ini',
'log': 'plaintext',
'txt': 'plaintext',
'md': 'markdown',
'markdown': 'markdown',
'mdown': 'markdown',
'mkdn': 'markdown',
'mkd': 'markdown',
'mdwn': 'markdown',
'adoc': 'asciidoc',
'asciidoc': 'asciidoc',
'ad': 'asciidoc',
};
return langMap[ext.toLowerCase()] || 'plaintext';
}
// Check if file type supports preview mode
function supportsPreview(ext: string): boolean {
const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv'];
return previewExtensions.includes(ext.toLowerCase());
}
// Check if a file is an image based on extension
function isImageFileType(ext: string): boolean {
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'apng', 'avif'];
return imageExtensions.includes(ext.toLowerCase());
}
// File processing utilities are now imported from utils/file-processing.ts
// Render markdown, asciidoc, or HTML files as HTML
async function renderFileAsHtml(content: string, ext: string) {
try {
const lowerExt = ext.toLowerCase();
if (lowerExt === 'md' || lowerExt === 'markdown') {
// Render markdown
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (__) {}
}
try {
return hljs.highlightAuto(str).value;
} catch (__) {}
return '';
}
});
let rendered = md.render(content);
// Rewrite image paths to point to repository API
rendered = rewriteImagePaths(rendered, state.files.currentFile);
state.preview.file.html = rendered;
} else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') {
// Render asciidoc
const Asciidoctor = (await import('@asciidoctor/core')).default;
const asciidoctor = Asciidoctor();
const converted = asciidoctor.convert(content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
await renderFileAsHtmlUtil(content, ext, state.files.currentFile, (html: string) => {
state.preview.file.html = html;
});
let rendered = typeof converted === 'string' ? converted : String(converted);
// Rewrite image paths to point to repository API
rendered = rewriteImagePaths(rendered, state.files.currentFile);
state.preview.file.html = rendered;
} else if (lowerExt === 'html' || lowerExt === 'htm') {
// HTML files - rewrite image paths
let rendered = content;
rendered = rewriteImagePaths(rendered, state.files.currentFile);
state.preview.file.html = rendered;
} else if (lowerExt === 'csv') {
// Parse CSV and render as HTML table
state.preview.file.html = renderCsvAsTable(content);
}
} catch (err) {
console.error('Error rendering file as HTML:', err);
state.preview.file.html = '';
}
}
// Parse CSV content and render as HTML table
function renderCsvAsTable(csvContent: string): string {
try {
// Parse CSV - handle quoted fields and escaped quotes
const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length === 0) {
return '<div class="csv-empty"><p>Empty CSV file</p></div>';
}
const rows: string[][] = [];
for (const line of lines) {
const row: string[] = [];
let currentField = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
// Escaped quote
currentField += '"';
i++; // Skip next quote
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
row.push(currentField);
currentField = '';
} else {
currentField += char;
}
}
// Add the last field
row.push(currentField);
rows.push(row);
}
if (rows.length === 0) {
return '<div class="csv-empty"><p>No data in CSV file</p></div>';
}
// Find the maximum number of columns to ensure consistent table structure
const maxColumns = Math.max(...rows.map(row => row.length));
// Determine if first row should be treated as header (if it has more than 1 row)
const hasHeader = rows.length > 1;
const headerRow = hasHeader ? rows[0] : null;
const dataRows = hasHeader ? rows.slice(1) : rows;
// Build HTML table
let html = '<div class="csv-table-wrapper"><table class="csv-table">';
// Add header row if we have one
if (hasHeader && headerRow) {
html += '<thead><tr>';
for (let i = 0; i < maxColumns; i++) {
const cell = headerRow[i] || '';
html += `<th>${escapeHtml(cell)}</th>`;
}
html += '</tr></thead>';
}
// Add data rows
html += '<tbody>';
for (const row of dataRows) {
html += '<tr>';
for (let i = 0; i < maxColumns; i++) {
const cell = row[i] || '';
html += `<td>${escapeHtml(cell)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table></div>';
return html;
} catch (err) {
console.error('Error parsing CSV:', err);
return `<div class="csv-state.error"><p>Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}</p></div>`;
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
// CSV and HTML utilities are now imported from utils/file-processing.ts
async function applySyntaxHighlighting(content: string, ext: string) {
try {
const hljsModule = await import('highlight.js');
// highlight.js v11+ uses default export
const hljs = hljsModule.default || hljsModule;
const lang = getHighlightLanguage(ext);
// Register Markdown language if needed (not in highlight.js by default)
if (lang === 'markdown' && !hljs.getLanguage('markdown')) {
hljs.registerLanguage('markdown', function(hljs) {
return {
name: 'Markdown',
aliases: ['md', 'mkdown', 'mkd'],
contains: [
// Headers
{
className: 'section',
begin: /^#{1,6}\s+/,
relevance: 10
},
// Bold
{
className: 'strong',
begin: /\*\*[^*]+\*\*/,
relevance: 0
},
{
className: 'strong',
begin: /__[^_]+__/,
relevance: 0
},
// Italic
{
className: 'emphasis',
begin: /\*[^*]+\*/,
relevance: 0
},
{
className: 'emphasis',
begin: /_[^_]+_/,
relevance: 0
},
// Inline code
{
className: 'code',
begin: /`[^`]+`/,
relevance: 0
},
// Code blocks
{
className: 'code',
begin: /^```[\w]*/,
end: /^```$/,
contains: [{ begin: /./ }]
},
// Links
{
className: 'link',
begin: /\[/,
end: /\]/,
contains: [
{
className: 'string',
begin: /\(/,
end: /\)/
}
]
},
// Images
{
className: 'string',
begin: /!\[/,
end: /\]/
},
// Lists
{
className: 'bullet',
begin: /^(\s*)([*+-]|\d+\.)\s+/,
relevance: 0
},
// Blockquotes
{
className: 'quote',
begin: /^>\s+/,
relevance: 0
},
// Horizontal rules
{
className: 'horizontal_rule',
begin: /^(\*{3,}|-{3,}|_{3,})$/,
relevance: 0
}
]
};
});
}
// Register AsciiDoc language if needed (not in highlight.js by default)
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) {
hljs.registerLanguage('asciidoc', function(hljs) {
return {
name: 'AsciiDoc',
aliases: ['adoc', 'asciidoc', 'ad'],
contains: [
// Headers
{
className: 'section',
begin: /^={1,6}\s+/,
relevance: 10
},
// Bold
{
className: 'strong',
begin: /\*\*[^*]+\*\*/,
relevance: 0
},
// Italic
{
className: 'emphasis',
begin: /_[^_]+_/,
relevance: 0
},
// Inline code
{
className: 'code',
begin: /`[^`]+`/,
relevance: 0
},
// Code blocks
{
className: 'code',
begin: /^----+$/,
end: /^----+$/,
contains: [{ begin: /./ }]
},
// Lists
{
className: 'bullet',
begin: /^(\*+|\.+|-+)\s+/,
relevance: 0
},
// Links
{
className: 'link',
begin: /link:/,
end: /\[/,
contains: [{ begin: /\[/, end: /\]/ }]
},
// Comments
{
className: 'comment',
begin: /^\/\/.*$/,
relevance: 0
},
// Attributes
{
className: 'attr',
begin: /^:.*:$/,
relevance: 0
}
]
};
await applySyntaxHighlightingUtil(content, ext, (html: string) => {
state.preview.file.highlightedContent = html;
});
}
// Apply highlighting
if (lang === 'plaintext') {
state.preview.file.highlightedContent = `<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`;
} else if (hljs.getLanguage(lang)) {
state.preview.file.highlightedContent = `<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`;
} else {
// Fallback to auto-detection
state.preview.file.highlightedContent = `<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`;
}
} catch (err) {
console.error('Error applying syntax highlighting:', err);
// Fallback to plain text
state.preview.file.highlightedContent = `<pre><code class="hljs">${content}</code></pre>`;
}
}
async function loadForkInfo() {
try {
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, {
@ -3290,142 +2888,11 @@ @@ -3290,142 +2888,11 @@
let fetchingUserName = false;
async function getUserEmail(): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userEmail && settings.userEmail.trim()) {
cachedUserEmail = settings.userEmail.trim();
return cachedUserEmail;
}
} catch (err) {
console.warn('Failed to get userEmail from settings:', err);
}
// Return cached email if available
if (cachedUserEmail) {
return cachedUserEmail;
}
// If no user pubkey, can't proceed
if (!state.user.pubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingUserEmail) {
// Wait a bit and retry (shouldn't happen, but just in case)
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedUserEmail) {
return cachedUserEmail;
}
}
fetchingUserEmail = true;
let prefillEmail: string;
try {
// Fetch from kind 0 event (cache or relays)
prefillEmail = await fetchUserEmail(state.user.pubkeyHex, state.user.pubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for email:', err);
// Fallback to shortenednpub@gitrepublic.web
const npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown');
const shortenedNpub = npubFromPubkey.substring(0, 20);
prefillEmail = `${shortenedNpub}@gitrepublic.web`;
} finally {
fetchingUserEmail = false;
}
// Prompt user for email address
const userEmail = prompt(
'Please enter your email address for git commits.\n\n' +
'This will be used as the author email in your commits.\n' +
'You can use any email address you prefer.',
prefillEmail
);
if (userEmail && userEmail.trim()) {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(userEmail.trim())) {
cachedUserEmail = userEmail.trim();
// Save to settings store
settingsStore.setSetting('userEmail', cachedUserEmail).catch(console.error);
return cachedUserEmail;
} else {
alert('Invalid email format. Using fallback email address.');
}
}
// Use fallback if user cancelled or entered invalid email
cachedUserEmail = prefillEmail;
return cachedUserEmail;
return getUserEmailUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName });
}
async function getUserName(): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userName && settings.userName.trim()) {
cachedUserName = settings.userName.trim();
return cachedUserName;
}
} catch (err) {
console.warn('Failed to get userName from settings:', err);
}
// Return cached name if available
if (cachedUserName) {
return cachedUserName;
}
// If no user pubkey, can't proceed
if (!state.user.pubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingUserName) {
// Wait a bit and retry (shouldn't happen, but just in case)
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedUserName) {
return cachedUserName;
}
}
fetchingUserName = true;
let prefillName: string;
try {
// Fetch from kind 0 event (cache or relays)
prefillName = await fetchUserName(state.user.pubkeyHex, state.user.pubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for name:', err);
// Fallback to shortened npub (20 chars)
const npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown');
prefillName = npubFromPubkey.substring(0, 20);
} finally {
fetchingUserName = false;
}
// Prompt user for name
const userName = prompt(
'Please enter your name for git commits.\n\n' +
'This will be used as the author name in your commits.\n' +
'You can use any name you prefer.',
prefillName
);
if (userName && userName.trim()) {
cachedUserName = userName.trim();
// Save to settings store
settingsStore.setSetting('userName', cachedUserName).catch(console.error);
return cachedUserName;
}
// Use fallback if user cancelled
cachedUserName = prefillName;
return cachedUserName;
return getUserNameUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName });
}
async function setupAutoSave() {
@ -5478,8 +4945,8 @@ @@ -5478,8 +4945,8 @@
return;
}
const authorEmail = await fetchUserEmail(state.user.pubkeyHex || '', state.user.pubkey || undefined);
const authorName = await fetchUserName(state.user.pubkeyHex || '', state.user.pubkey || undefined);
const authorEmail = await getUserEmail();
const authorName = await getUserName();
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches/${id}/apply`, {
method: 'POST',

49
src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
/**
* Discussion utilities
* Handles discussion event processing and formatting
*/
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
/**
* Format discussion timestamp
*/
export function formatDiscussionTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
}
/**
* Get discussion event by ID
*/
export function getDiscussionEvent(eventId: string, events: Map<string, NostrEvent>): NostrEvent | undefined {
return events.get(eventId);
}
/**
* Get referenced event from discussion
*/
export function getReferencedEventFromDiscussion(
event: NostrEvent,
events: Map<string, NostrEvent>
): NostrEvent | undefined {
// Check for 'e' tags (event references)
const eTags = event.tags.filter(t => t[0] === 'e' && t[1]);
if (eTags.length > 0) {
const referencedId = eTags[0][1] as string;
return events.get(referencedId);
}
return undefined;
}

353
src/routes/repos/[npub]/[repo]/utils/file-processing.ts

@ -0,0 +1,353 @@ @@ -0,0 +1,353 @@
/**
* File processing utilities
* Handles syntax highlighting, HTML rendering, and file type detection
*/
// Note: highlight.js, marked, and asciidoctor are imported dynamically in functions
/**
* Get highlight.js language from file extension
*/
export function getHighlightLanguage(ext: string): string {
const langMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'cc': 'cpp',
'cxx': 'cpp',
'h': 'c',
'hpp': 'cpp',
'hxx': 'cpp',
'cs': 'csharp',
'php': 'php',
'swift': 'swift',
'kt': 'kotlin',
'scala': 'scala',
'clj': 'clojure',
'sh': 'bash',
'bash': 'bash',
'zsh': 'bash',
'fish': 'bash',
'ps1': 'powershell',
'sql': 'sql',
'html': 'html',
'htm': 'html',
'xml': 'xml',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'yaml': 'yaml',
'yml': 'yaml',
'toml': 'toml',
'ini': 'ini',
'conf': 'ini',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'mk': 'makefile',
'cmake': 'cmake',
'r': 'r',
'R': 'r',
'm': 'objectivec',
'mm': 'objectivec',
'vue': 'xml',
'svelte': 'xml',
'graphql': 'graphql',
'gql': 'graphql',
'proto': 'protobuf',
'md': 'markdown',
'markdown': 'markdown',
'adoc': 'asciidoc',
'asciidoc': 'asciidoc',
'rst': 'restructuredtext',
'org': 'org',
'vim': 'vim',
'lua': 'lua',
'pl': 'perl',
'pm': 'perl',
'tcl': 'tcl',
'dart': 'dart',
'elm': 'elm',
'ex': 'elixir',
'exs': 'elixir',
'erl': 'erlang',
'hrl': 'erlang',
'fs': 'fsharp',
'fsx': 'fsharp',
'fsi': 'fsharp',
'ml': 'ocaml',
'mli': 'ocaml',
'hs': 'haskell',
'lhs': 'haskell',
'nim': 'nim',
'zig': 'zig',
'cr': 'crystal',
'jl': 'julia',
'matlab': 'matlab',
'tex': 'latex',
'latex': 'latex',
'bib': 'bibtex',
'log': 'plaintext',
'txt': 'plaintext',
'diff': 'diff',
'patch': 'diff'
};
return langMap[ext.toLowerCase()] || 'plaintext';
}
/**
* Check if file extension supports HTML preview
*/
export function supportsPreview(ext: string): boolean {
const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv'];
return previewExtensions.includes(ext.toLowerCase());
}
/**
* Check if file extension is an image type
*/
export function isImageFileType(ext: string): boolean {
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'avif', 'heic', 'heif'];
return imageExtensions.includes(ext.toLowerCase());
}
/**
* Rewrite image paths in HTML to be relative to file path
*/
export function rewriteImagePaths(html: string, filePath: string | null): string {
if (!filePath || !html) return html;
// Get directory path (remove filename)
const dirPath = filePath.split('/').slice(0, -1).join('/');
const basePath = dirPath ? `/${dirPath}/` : '/';
// Rewrite relative image paths
// Match: src="image.png" or src='image.png' or src=image.png
html = html.replace(/src=["']([^"']+)["']/g, (match, path) => {
// Skip absolute URLs and data URLs
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) {
return match;
}
// Make path relative to file directory
return `src="${basePath}${path}"`;
});
return html;
}
/**
* Render CSV content as HTML table
*/
export function renderCsvAsTable(csvContent: string): string {
const lines = csvContent.split('\n').filter(line => line.trim());
if (lines.length === 0) return '<p>Empty CSV file</p>';
// Parse CSV (simple parser - handles basic cases)
const rows: string[][] = [];
for (const line of lines) {
const cells: string[] = [];
let currentCell = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
cells.push(currentCell.trim());
currentCell = '';
} else {
currentCell += char;
}
}
cells.push(currentCell.trim());
rows.push(cells);
}
if (rows.length === 0) return '<p>No data in CSV file</p>';
// Generate HTML table
let html = '<table class="csv-table"><thead><tr>';
const headerRow = rows[0];
for (const cell of headerRow) {
html += `<th>${escapeHtml(cell)}</th>`;
}
html += '</tr></thead><tbody>';
for (let i = 1; i < rows.length; i++) {
html += '<tr>';
for (const cell of rows[i]) {
html += `<td>${escapeHtml(cell)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
/**
* Escape HTML special characters
*/
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
/**
* Apply syntax highlighting to content
*/
export async function applySyntaxHighlighting(
content: string,
ext: string,
setHighlightedContent: (html: string) => void
): Promise<void> {
try {
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const lang = getHighlightLanguage(ext);
// Register Markdown language if needed
if (lang === 'markdown' && !hljs.getLanguage('markdown')) {
hljs.registerLanguage('markdown', function(hljs) {
return {
name: 'Markdown',
aliases: ['md', 'mkdown', 'mkd'],
contains: [
{ className: 'section', begin: /^#{1,6}\s+/, relevance: 10 },
{ className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 },
{ className: 'strong', begin: /__[^_]+__/, relevance: 0 },
{ className: 'emphasis', begin: /\*[^*]+\*/, relevance: 0 },
{ className: 'emphasis', begin: /_[^_]+_/, relevance: 0 },
{ className: 'code', begin: /`[^`]+`/, relevance: 0 },
{ className: 'code', begin: /^```[\w]*/, end: /^```$/, contains: [{ begin: /./ }] },
{ className: 'link', begin: /\[/, end: /\]/, contains: [{ className: 'string', begin: /\(/, end: /\)/ }] },
{ className: 'string', begin: /!\[/, end: /\]/ },
{ className: 'bullet', begin: /^(\s*)([*+-]|\d+\.)\s+/, relevance: 0 },
{ className: 'quote', begin: /^>\s+/, relevance: 0 },
{ className: 'horizontal_rule', begin: /^(\*{3,}|-{3,}|_{3,})$/, relevance: 0 }
]
};
});
}
// Register AsciiDoc language if needed
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) {
hljs.registerLanguage('asciidoc', function(hljs) {
return {
name: 'AsciiDoc',
aliases: ['adoc', 'asciidoc', 'ad'],
contains: [
{ className: 'section', begin: /^={1,6}\s+/, relevance: 10 },
{ className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 },
{ className: 'emphasis', begin: /_[^_]+_/, relevance: 0 },
{ className: 'code', begin: /`[^`]+`/, relevance: 0 },
{ className: 'code', begin: /^----+$/, end: /^----+$/, contains: [{ begin: /./ }] },
{ className: 'bullet', begin: /^(\*+|\.+|-+)\s+/, relevance: 0 },
{ className: 'link', begin: /link:/, end: /\[/, contains: [{ begin: /\[/, end: /\]/ }] },
{ className: 'comment', begin: /^\/\/.*$/, relevance: 0 },
{ className: 'attr', begin: /^:.*:$/, relevance: 0 }
]
};
});
}
// Apply highlighting
if (lang === 'plaintext') {
setHighlightedContent(`<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`);
} else if (hljs.getLanguage(lang)) {
setHighlightedContent(`<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`);
} else {
setHighlightedContent(`<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`);
}
} catch (err) {
console.error('Error applying syntax highlighting:', err);
setHighlightedContent(`<pre><code class="hljs">${escapeHtml(content)}</code></pre>`);
}
}
/**
* Render file content as HTML
*/
export async function renderFileAsHtml(
content: string,
ext: string,
filePath: string | null,
setHtml: (html: string) => void
): Promise<void> {
try {
const lowerExt = ext.toLowerCase();
if (lowerExt === 'md' || lowerExt === 'markdown') {
// Render markdown using markdown-it
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (__) {}
}
try {
return hljs.highlightAuto(str).value;
} catch (__) {}
return '';
}
});
let rendered = md.render(content);
rendered = rewriteImagePaths(rendered, filePath);
setHtml(rendered);
} else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') {
// Render asciidoc
const Asciidoctor = (await import('@asciidoctor/core')).default;
const asciidoctor = Asciidoctor();
const converted = asciidoctor.convert(content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
});
let rendered = typeof converted === 'string' ? converted : String(converted);
rendered = rewriteImagePaths(rendered, filePath);
setHtml(rendered);
} else if (lowerExt === 'html' || lowerExt === 'htm') {
// HTML files - rewrite image paths
let rendered = content;
rendered = rewriteImagePaths(rendered, filePath);
setHtml(rendered);
} else if (lowerExt === 'csv') {
// Parse CSV and render as HTML table
const html = renderCsvAsTable(content);
setHtml(html);
} else {
setHtml('');
}
} catch (err) {
console.error('Error rendering file as HTML:', err);
setHtml('');
}
}

202
src/routes/repos/[npub]/[repo]/utils/nostr-links.ts

@ -0,0 +1,202 @@ @@ -0,0 +1,202 @@
/**
* Nostr link processing utilities
* Handles parsing and loading of nostr: links
*/
import type { NostrEvent } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
export interface ParsedNostrLink {
type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile';
value: string;
start: number;
end: number;
}
/**
* Parse nostr: links from content
*/
export function parseNostrLinks(content: string): ParsedNostrLink[] {
const links: ParsedNostrLink[] = [];
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g;
let match;
while ((match = nostrLinkRegex.exec(content)) !== null) {
const fullMatch = match[0];
const prefix = match[1];
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile';
if (prefix === 'nevent1') type = 'nevent';
else if (prefix === 'naddr1') type = 'naddr';
else if (prefix === 'note1') type = 'note1';
else if (prefix === 'npub1') type = 'npub';
else if (prefix === 'nprofile1') type = 'profile';
else continue;
links.push({
type,
value: fullMatch,
start: match.index,
end: match.index + fullMatch.length
});
}
return links;
}
/**
* Get event from nostr link
*/
export function getEventFromNostrLink(link: string): NostrEvent | undefined {
try {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'nevent' || decoded.type === 'note') {
return decoded.data as NostrEvent;
}
} catch {
// Invalid link
}
return undefined;
}
/**
* Get pubkey from nostr link
*/
export function getPubkeyFromNostrLink(link: string): string | undefined {
try {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
return decoded.data as string;
}
} catch {
// Invalid link
}
return undefined;
}
/**
* Process content with nostr links, replacing them with event/profile data
*/
export function processContentWithNostrLinks(
content: string,
events: Map<string, NostrEvent>,
profiles: Map<string, any>
): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> {
const links = parseNostrLinks(content);
const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = [];
let lastIndex = 0;
for (const link of links) {
// Add text before link
if (link.start > lastIndex) {
const textPart = content.slice(lastIndex, link.start);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
// Add link
const event = getEventFromNostrLink(link.value);
const pubkey = getPubkeyFromNostrLink(link.value);
if (event) {
parts.push({ type: 'event', value: link.value, event });
} else if (pubkey) {
parts.push({ type: 'profile', value: link.value, pubkey });
} else {
parts.push({ type: 'placeholder', value: link.value });
}
lastIndex = link.end;
}
// Add remaining text
if (lastIndex < content.length) {
const textPart = content.slice(lastIndex);
if (textPart) {
parts.push({ type: 'text', value: textPart });
}
}
return parts.length > 0 ? parts : [{ type: 'text', value: content }];
}
/**
* Load events and profiles from nostr links
*/
export async function loadNostrLinks(
content: string,
setEvents: (events: Map<string, NostrEvent>) => void,
setProfiles: (profiles: Map<string, any>) => void
): Promise<void> {
const links = parseNostrLinks(content);
if (links.length === 0) return;
const eventIds: string[] = [];
const aTags: string[] = [];
const npubs: string[] = [];
for (const link of links) {
try {
const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'nevent') {
const data = decoded.data as { id: string; relays?: string[] };
if (data.id) eventIds.push(data.id);
} else if (decoded.type === 'naddr') {
const data = decoded.data as { identifier: string; pubkey: string; relays?: string[] };
if (data.identifier && data.pubkey) {
aTags.push(`${data.pubkey}:${data.identifier}`);
}
} else if (decoded.type === 'note') {
eventIds.push(decoded.data as string);
} else if (decoded.type === 'npub' || decoded.type === 'nprofile') {
npubs.push(decoded.data as string);
}
} catch {
// Invalid link, skip
}
}
if (eventIds.length === 0 && aTags.length === 0 && npubs.length === 0) return;
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const eventsMap = new Map<string, NostrEvent>();
const profilesMap = new Map<string, any>();
// Load events
if (eventIds.length > 0) {
try {
const events = await client.fetchEvents([
{ ids: eventIds, limit: eventIds.length }
]);
for (const event of events) {
eventsMap.set(event.id, event);
}
} catch (err) {
console.warn('Failed to load events from nostr links:', err);
}
}
// Load profiles
if (npubs.length > 0) {
try {
const profiles = await client.fetchEvents([
{ kinds: [0], authors: npubs, limit: npubs.length }
]);
for (const profile of profiles) {
try {
const data = JSON.parse(profile.content);
profilesMap.set(profile.pubkey, data);
} catch {
// Invalid JSON
}
}
} catch (err) {
console.warn('Failed to load profiles from nostr links:', err);
}
}
setEvents(eventsMap);
setProfiles(profilesMap);
}

164
src/routes/repos/[npub]/[repo]/utils/user-profile.ts

@ -0,0 +1,164 @@ @@ -0,0 +1,164 @@
/**
* User profile utilities
* Handles fetching and caching user email/name
*/
import { nip19 } from 'nostr-tools';
import { settingsStore } from '$lib/services/settings-store.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
interface CachedUserData {
email: string | null;
name: string | null;
}
interface FetchingFlags {
email: boolean;
name: boolean;
}
/**
* Get user email with caching
*/
export async function getUserEmail(
userPubkeyHex: string | null,
userPubkey: string | null,
cachedData: CachedUserData,
fetchingFlags: FetchingFlags
): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userEmail && settings.userEmail.trim()) {
cachedData.email = settings.userEmail.trim();
return cachedData.email;
}
} catch (err) {
console.warn('Failed to get userEmail from settings:', err);
}
// Return cached email if available
if (cachedData.email) {
return cachedData.email;
}
// If no user pubkey, can't proceed
if (!userPubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingFlags.email) {
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedData.email) {
return cachedData.email;
}
}
fetchingFlags.email = true;
let prefillEmail: string;
try {
prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for email:', err);
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const shortenedNpub = npubFromPubkey.substring(0, 20);
prefillEmail = `${shortenedNpub}@gitrepublic.web`;
} finally {
fetchingFlags.email = false;
}
// Prompt user for email address
const userEmail = prompt(
'Please enter your email address for git commits.\n\n' +
'This will be used as the author email in your commits.\n' +
'You can use any email address you prefer.',
prefillEmail
);
if (userEmail && userEmail.trim()) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(userEmail.trim())) {
cachedData.email = userEmail.trim();
settingsStore.setSetting('userEmail', cachedData.email).catch(console.error);
return cachedData.email;
} else {
alert('Invalid email format. Using fallback email address.');
}
}
cachedData.email = prefillEmail;
return cachedData.email;
}
/**
* Get user name with caching
*/
export async function getUserName(
userPubkeyHex: string | null,
userPubkey: string | null,
cachedData: CachedUserData,
fetchingFlags: FetchingFlags
): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userName && settings.userName.trim()) {
cachedData.name = settings.userName.trim();
return cachedData.name;
}
} catch (err) {
console.warn('Failed to getUserName from settings:', err);
}
// Return cached name if available
if (cachedData.name) {
return cachedData.name;
}
// If no user pubkey, can't proceed
if (!userPubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingFlags.name) {
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedData.name) {
return cachedData.name;
}
}
fetchingFlags.name = true;
let prefillName: string;
try {
prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for name:', err);
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const shortenedNpub = npubFromPubkey.substring(0, 20);
prefillName = shortenedNpub;
} finally {
fetchingFlags.name = false;
}
// Prompt user for name
const userName = prompt(
'Please enter your name for git commits.\n\n' +
'This will be used as the author name in your commits.\n' +
'You can use any name you prefer.',
prefillName
);
if (userName && userName.trim()) {
cachedData.name = userName.trim();
settingsStore.setSetting('userName', cachedData.name).catch(console.error);
return cachedData.name;
}
cachedData.name = prefillName;
return cachedData.name;
}
Loading…
Cancel
Save