Browse Source

bug-fixes

Nostr-Signature: 99cb543f1e821f1b7df4bbde2b3da3ab3a09cda7a1e9a537fe1b8df79b19e8e8 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 762a7ea92457ce81cc5aae9bc644fb9d80f90c7500035fbb506f2f76a5942333b828cc8a59f7656b0e714b15a59158be0a671f51476be2e8eabe9731ced74bcb
main
Silberengel 2 weeks ago
parent
commit
000b42ba79
  1. 1
      nostr/commit-signatures.jsonl
  2. 17
      src/lib/services/nostr/releases-service.ts
  3. 4
      src/routes/api/repos/[npub]/[repo]/releases/+server.ts
  4. 36
      src/routes/repos/[npub]/[repo]/+page.svelte
  5. 181
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  6. 17
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  7. 2
      src/routes/repos/[npub]/[repo]/components/TagsTab.svelte
  8. 60
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte
  9. 16
      src/routes/repos/[npub]/[repo]/services/file-operations.ts
  10. 8
      src/routes/repos/[npub]/[repo]/services/release-operations.ts
  11. 4
      src/routes/repos/[npub]/[repo]/stores/repo-state.ts
  12. 88
      src/routes/repos/[npub]/[repo]/utils/content-renderer.ts

1
nostr/commit-signatures.jsonl

@ -110,3 +110,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772188835,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 13"]],"content":"Signed commit: refactor 13","id":"f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5","sig":"2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772188835,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 13"]],"content":"Signed commit: refactor 13","id":"f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5","sig":"2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772193104,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"02dcdcda1083cffd91dbf8906716c2ae09f06f77ef8590802afecd85f0b3108a","sig":"13d2b30ed37af03fd47dc09536058babb4dc63d1cfc55b8f38651ffd6342abcddc840b543c085b047721e9102b2d07e3dae78ff31d5990c92c04410ef1efcd5b"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772193104,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"02dcdcda1083cffd91dbf8906716c2ae09f06f77ef8590802afecd85f0b3108a","sig":"13d2b30ed37af03fd47dc09536058babb4dc63d1cfc55b8f38651ffd6342abcddc840b543c085b047721e9102b2d07e3dae78ff31d5990c92c04410ef1efcd5b"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772220851,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"d98d2d6a6eb27ba36f19015f7d6969fe3925c40b23187d70ccc9b61141c6b4b7","sig":"8727e3015e38a78d7a6105c26e5b1469dc4d6d701e58d5d6c522ab529b4daa2d39d4353eb6d091f3c1fd28ad0289eae808494c9e2722bf9065dd2b2e9001664f"}

17
src/lib/services/nostr/releases-service.ts

@ -10,9 +10,11 @@ import { signEventWithNIP07 } from './nip07-signer.js';
export interface Release extends NostrEvent { export interface Release extends NostrEvent {
kind: typeof KIND.RELEASE; kind: typeof KIND.RELEASE;
title?: string;
tagName: string; tagName: string;
tagHash?: string; tagHash?: string;
releaseNotes?: string; releaseNotes?: string;
downloadUrl?: string;
isDraft?: boolean; isDraft?: boolean;
isPrerelease?: boolean; isPrerelease?: boolean;
} }
@ -49,16 +51,20 @@ export class ReleasesService {
// Parse release information from tags // Parse release information from tags
return releases.map(release => { return releases.map(release => {
const title = release.tags.find(t => t[0] === 'title')?.[1];
const tagName = release.tags.find(t => t[0] === 'tag')?.[1] || ''; const tagName = release.tags.find(t => t[0] === 'tag')?.[1] || '';
const tagHash = release.tags.find(t => t[0] === 'r' && t[2] === 'tag')?.[1]; const tagHash = release.tags.find(t => t[0] === 'r' && t[2] === 'tag')?.[1];
const downloadUrl = release.tags.find(t => t[0] === 'r' && t[2] === 'download')?.[1];
const isDraft = release.tags.some(t => t[0] === 'draft' && t[1] === 'true'); const isDraft = release.tags.some(t => t[0] === 'draft' && t[1] === 'true');
const isPrerelease = release.tags.some(t => t[0] === 'prerelease' && t[1] === 'true'); const isPrerelease = release.tags.some(t => t[0] === 'prerelease' && t[1] === 'true');
return { return {
...release, ...release,
title,
tagName, tagName,
tagHash, tagHash,
releaseNotes: release.content, releaseNotes: release.content,
downloadUrl,
isDraft, isDraft,
isPrerelease isPrerelease
}; };
@ -79,9 +85,11 @@ export class ReleasesService {
async createRelease( async createRelease(
repoOwnerPubkey: string, repoOwnerPubkey: string,
repoId: string, repoId: string,
title: string,
tagName: string, tagName: string,
tagHash: string, tagHash: string,
releaseNotes: string, releaseNotes: string,
downloadUrl: string,
isDraft: boolean = false, isDraft: boolean = false,
isPrerelease: boolean = false isPrerelease: boolean = false
): Promise<Release> { ): Promise<Release> {
@ -94,6 +102,14 @@ export class ReleasesService {
['r', tagHash, '', 'tag'] // Reference to the git tag commit ['r', tagHash, '', 'tag'] // Reference to the git tag commit
]; ];
if (title) {
tags.push(['title', title]);
}
if (downloadUrl) {
tags.push(['r', downloadUrl, '', 'download']); // Download URL with marker
}
if (isDraft) { if (isDraft) {
tags.push(['draft', 'true']); tags.push(['draft', 'true']);
} }
@ -120,6 +136,7 @@ export class ReleasesService {
tagName, tagName,
tagHash, tagHash,
releaseNotes, releaseNotes,
downloadUrl,
isDraft, isDraft,
isPrerelease isPrerelease
}; };

4
src/routes/api/repos/[npub]/[repo]/releases/+server.ts

@ -24,7 +24,7 @@ export const GET: RequestHandler = createRepoGetHandler(
export const POST: RequestHandler = withRepoValidation( export const POST: RequestHandler = withRepoValidation(
async ({ repoContext, requestContext, event }) => { async ({ repoContext, requestContext, event }) => {
const body = await event.request.json(); const body = await event.request.json();
const { tagName, tagHash, releaseNotes, isDraft, isPrerelease } = body; const { title, tagName, tagHash, releaseNotes, downloadUrl, isDraft, isPrerelease } = body;
if (!tagName || !tagHash) { if (!tagName || !tagHash) {
throw handleValidationError('Missing required fields: tagName, tagHash', { operation: 'createRelease', npub: repoContext.npub, repo: repoContext.repo }); throw handleValidationError('Missing required fields: tagName, tagHash', { operation: 'createRelease', npub: repoContext.npub, repo: repoContext.repo });
@ -42,9 +42,11 @@ export const POST: RequestHandler = withRepoValidation(
const release = await releasesService.createRelease( const release = await releasesService.createRelease(
repoContext.repoOwnerPubkey, repoContext.repoOwnerPubkey,
repoContext.repo, repoContext.repo,
title || '',
tagName, tagName,
tagHash, tagHash,
releaseNotes || '', releaseNotes || '',
downloadUrl || '',
isDraft || false, isDraft || false,
isPrerelease || false isPrerelease || false
); );

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

@ -203,11 +203,13 @@
} from './services/file-operations.js'; } from './services/file-operations.js';
// Consolidated state - all state variables in one object // Consolidated state - all state variables in one object
// @ts-expect-error - $state rune type inference issue with circular reference
let state = $state(createRepoState()); let state = $state(createRepoState());
// Local variables for component-specific state // Local variables for component-specific state
let announcementEventId = { value: null as string | null }; let announcementEventId = { value: null as string | null };
let applying: Record<string, boolean> = {}; let applying: Record<string, boolean> = {};
let docsReloadTrigger = $state(0);
// Extract fields from announcement for convenience // Extract fields from announcement for convenience
const repoAnnouncement = $derived(state.pageData.announcement); const repoAnnouncement = $derived(state.pageData.announcement);
@ -655,6 +657,9 @@
// Reload documentation // Reload documentation
await loadDocumentation(); await loadDocumentation();
// Trigger DocsTab reload
docsReloadTrigger++;
alert('Documentation event created and published successfully!'); alert('Documentation event created and published successfully!');
} catch (err) { } catch (err) {
@ -916,10 +921,11 @@
// Decode npub to get repo owner pubkey for bookmark address // Decode npub to get repo owner pubkey for bookmark address
try { try {
if (state.npub && state.npub.trim()) { if (state.npub && state.npub.trim()) {
const decoded = nip19.decode(state.npub); const decoded = nip19.decode(state.npub) as { type: 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile'; data: string | unknown };
if (decoded.type === 'npub') { // Type guard for npub - decoded.data is string for npub type
state.metadata.ownerPubkey = decoded.data as string; if (decoded.type === 'npub' && typeof decoded.data === 'string') {
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${state.metadata.ownerPubkey}:${state.repo}`; state.metadata.ownerPubkey = decoded.data;
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${decoded.data}:${state.repo}`;
} }
} }
} catch (err) { } catch (err) {
@ -987,7 +993,7 @@
error: status.error || null, error: status.error || null,
message: status.message || null, message: status.message || null,
cloneCount: status.cloneVerifications?.length || 0, cloneCount: status.cloneVerifications?.length || 0,
verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0 verifiedClones: status.cloneVerifications?.filter((cv: { verified: boolean }) => cv.verified).length || 0
}); });
} }
@ -1254,7 +1260,7 @@
</button> </button>
{/if} {/if}
{#each (state.clone.showAllUrls ? repoCloneUrls : repoCloneUrls.slice(0, 3)) as cloneUrl} {#each (state.clone.showAllUrls ? repoCloneUrls : repoCloneUrls.slice(0, 3)) as cloneUrl}
{@const cloneVerification = state.verification.status?.cloneVerifications?.find(cv => { {@const cloneVerification = state.verification.status?.cloneVerifications?.find((cv: { url: string }) => {
const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase().replace(/^https?:\/\//, ''); const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase().replace(/^https?:\/\//, '');
const normalizedCv = normalizeUrl(cv.url); const normalizedCv = normalizeUrl(cv.url);
const normalizedClone = normalizeUrl(cloneUrl); const normalizedClone = normalizeUrl(cloneUrl);
@ -1522,7 +1528,7 @@
state.git.verifyingCommits.add(hash); state.git.verifyingCommits.add(hash);
try { try {
// Trigger verification logic - find the commit and verify // Trigger verification logic - find the commit and verify
const commit = state.git.commits.find(c => (c.hash || (c as any).sha) === hash); const commit = state.git.commits.find((c: { hash?: string; sha?: string }) => (c.hash || c.sha) === hash);
if (commit) { if (commit) {
await verifyCommit(hash); await verifyCommit(hash);
} }
@ -1584,6 +1590,13 @@
onCreateRelease={(tagName, tagHash) => { onCreateRelease={(tagName, tagHash) => {
state.forms.release.tagName = tagName; state.forms.release.tagName = tagName;
state.forms.release.tagHash = tagHash; state.forms.release.tagHash = tagHash;
// Pre-fill download URL with full URL
if (typeof window !== 'undefined') {
const origin = window.location.origin;
state.forms.release.downloadUrl = `${origin}/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`;
} else {
state.forms.release.downloadUrl = `/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`;
}
state.openDialog = 'createRelease'; state.openDialog = 'createRelease';
}} }}
onLoadTags={loadTags} onLoadTags={loadTags}
@ -1633,7 +1646,7 @@
}} }}
onStatusUpdate={async (id, status) => { onStatusUpdate={async (id, status) => {
// Find issue and update status // Find issue and update status
const issue = state.issues.find(i => i.id === id); const issue = state.issues.find((i: { id: string }) => i.id === id);
if (issue) { if (issue) {
await updateIssueStatus(id, issue.author, status as 'open' | 'closed' | 'resolved' | 'draft'); await updateIssueStatus(id, issue.author, status as 'open' | 'closed' | 'resolved' | 'draft');
await loadIssues(); await loadIssues();
@ -1671,7 +1684,7 @@
}} }}
onStatusUpdate={async (id, status) => { onStatusUpdate={async (id, status) => {
// Find PR and update status - similar to updateIssueStatus // Find PR and update status - similar to updateIssueStatus
const pr = state.prs.find(p => p.id === id); const pr = state.prs.find((p: { id: string }) => p.id === id);
if (pr && state.user.pubkeyHex) { if (pr && state.user.pubkeyHex) {
// Check if user is maintainer or PR author // Check if user is maintainer or PR author
const isAuthor = state.user.pubkeyHex === pr.author; const isAuthor = state.user.pubkeyHex === pr.author;
@ -1732,7 +1745,7 @@
state.selected.patch = id; state.selected.patch = id;
}} }}
onStatusUpdate={async (id, status) => { onStatusUpdate={async (id, status) => {
const patch = state.patches.find(p => p.id === id); const patch = state.patches.find((p: { id: string }) => p.id === id);
if (patch) { if (patch) {
await updatePatchStatus(id, patch.author, status); await updatePatchStatus(id, patch.author, status);
} }
@ -1740,7 +1753,7 @@
onApply={async (id) => { onApply={async (id) => {
applying[id] = true; applying[id] = true;
try { try {
const patch = state.patches.find(p => p.id === id); const patch = state.patches.find((p: { id: string }) => p.id === id);
if (!patch) { if (!patch) {
throw new Error('Patch not found'); throw new Error('Patch not found');
} }
@ -1865,6 +1878,7 @@
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); goto(url.pathname + url.search, { replaceState: true, noScroll: true });
}} }}
isMaintainer={state.maintainers.isMaintainer} isMaintainer={state.maintainers.isMaintainer}
reloadTrigger={docsReloadTrigger}
onCreateDocumentation={() => { onCreateDocumentation={() => {
if (!state.user.pubkey || !state.maintainers.isMaintainer) return; if (!state.user.pubkey || !state.maintainers.isMaintainer) return;
state.openDialog = 'createDocumentation'; state.openDialog = 'createDocumentation';

181
src/routes/repos/[npub]/[repo]/components/DocsTab.svelte

@ -6,11 +6,13 @@
import TabLayout from './TabLayout.svelte'; import TabLayout from './TabLayout.svelte';
import DocsViewer from './DocsViewer.svelte'; import DocsViewer from './DocsViewer.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { extractAsciiDocTitle } from '../utils/content-renderer.js';
interface Props { interface Props {
npub?: string; npub?: string;
@ -22,6 +24,7 @@
onTabChange?: (tab: string) => void; onTabChange?: (tab: string) => void;
isMaintainer?: boolean; isMaintainer?: boolean;
onCreateDocumentation?: () => void; onCreateDocumentation?: () => void;
reloadTrigger?: number; // Changes when documentation is created to trigger reload
} }
let { let {
@ -33,11 +36,13 @@
tabs = [], tabs = [],
onTabChange = () => {}, onTabChange = () => {},
isMaintainer = false, isMaintainer = false,
onCreateDocumentation = () => {} onCreateDocumentation = () => {},
reloadTrigger = 0
}: Props = $props(); }: Props = $props();
let documentationContent = $state<string | null>(null); let documentationContent = $state<string | null>(null);
let documentationKind = $state<'markdown' | 'asciidoc' | 'text' | '30040' | null>(null); let documentationKind = $state<'markdown' | 'asciidoc' | 'text' | '30040' | null>(null);
let documentationTitle = $state<string | null>(null);
let indexEvent = $state<NostrEvent | null>(null); let indexEvent = $state<NostrEvent | null>(null);
let loading = $state(false); let loading = $state(false);
let loadingDocs = $state(false); let loadingDocs = $state(false);
@ -45,12 +50,20 @@
let docFiles: Array<{ name: string; path: string }> = $state([]); let docFiles: Array<{ name: string; path: string }> = $state([]);
let selectedDoc: string | null = $state(null); let selectedDoc: string | null = $state(null);
let hasReadme = $state(false); let hasReadme = $state(false);
let nostrDocs: Array<{ id: string; title: string; kind: number; event: NostrEvent }> = $state([]);
$effect(() => { $effect(() => {
if (npub && repo && currentBranch) { if (npub && repo && currentBranch) {
loadDocumentation(); loadDocumentation();
} }
}); });
// Reload when reloadTrigger changes (e.g., after creating documentation)
$effect(() => {
if (reloadTrigger > 0 && npub && repo && currentBranch) {
loadDocumentation();
}
});
async function loadDocumentation() { async function loadDocumentation() {
loading = true; loading = true;
@ -58,6 +71,7 @@
error = null; error = null;
documentationContent = null; documentationContent = null;
documentationKind = null; documentationKind = null;
documentationTitle = null; // Clear any previous title
indexEvent = null; indexEvent = null;
hasReadme = false; hasReadme = false;
@ -106,6 +120,9 @@
} }
} }
// Load Nostr documentation events (kind 30818, 30041, 30817, 30023)
await loadNostrDocumentation();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load documentation'; error = err instanceof Error ? err.message : 'Failed to load documentation';
logger.error({ error: err, npub, repo }, 'Error loading documentation'); logger.error({ error: err, npub, repo }, 'Error loading documentation');
@ -120,6 +137,10 @@
async function loadDocFile(path: string) { async function loadDocFile(path: string) {
try { try {
// Clear any Nostr doc state when loading a file
documentationTitle = null;
indexEvent = null;
const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`); const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`);
if (response.ok) { if (response.ok) {
const content = await response.text(); const content = await response.text();
@ -131,6 +152,11 @@
documentationKind = 'markdown'; documentationKind = 'markdown';
} else if (ext === 'adoc' || ext === 'asciidoc') { } else if (ext === 'adoc' || ext === 'asciidoc') {
documentationKind = 'asciidoc'; documentationKind = 'asciidoc';
// Extract title for AsciiDoc files too
const extractedTitle = extractAsciiDocTitle(content);
if (extractedTitle) {
documentationTitle = extractedTitle;
}
} else { } else {
documentationKind = 'text'; documentationKind = 'text';
} }
@ -167,6 +193,71 @@
logger.debug({ error: err }, 'No kind 30040 index found or error checking'); logger.debug({ error: err }, 'No kind 30040 index found or error checking');
} }
} }
async function loadNostrDocumentation() {
try {
const { requireNpubHex } = await import('$lib/utils/npub-utils.js');
const repoOwnerPubkey = requireNpubHex(npub);
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`;
const client = new NostrClient(relays);
// Load documentation events: 30818 (Repository State), 30041 (Publication), 30817 (Repository Announcement), 30023 (Article)
const events = await client.fetchEvents([
{
kinds: [30818, 30041, 30817, 30023],
authors: [repoOwnerPubkey],
'#a': [repoAddress],
limit: 100
}
]);
nostrDocs = events.map(event => {
const titleTag = event.tags.find(t => t[0] === 'title');
const title = titleTag?.[1] || `Documentation (kind ${event.kind})`;
return {
id: event.id,
title,
kind: event.kind,
event
};
});
logger.debug({ count: nostrDocs.length }, 'Loaded Nostr documentation events');
} catch (err) {
logger.debug({ error: err }, 'Error loading Nostr documentation events');
}
}
function loadNostrDoc(doc: { id: string; title: string; kind: number; event: NostrEvent }) {
// Clear previous content first to ensure re-render
documentationContent = null;
documentationKind = null;
documentationTitle = null;
// Set new content and type
documentationContent = doc.event.content;
// Determine content type based on kind
if (doc.kind === 30041 || doc.kind === 30818) {
documentationKind = 'asciidoc';
// Extract document title from AsciiDoc
const extractedTitle = extractAsciiDocTitle(doc.event.content);
documentationTitle = extractedTitle || doc.title;
} else {
documentationKind = 'markdown';
documentationTitle = doc.title;
}
selectedDoc = `nostr:${doc.id}`;
indexEvent = null; // Clear index event if viewing a doc
logger.debug({
docId: doc.id,
kind: doc.kind,
contentType: documentationKind,
title: documentationTitle,
contentLength: doc.event.content.length,
contentPreview: doc.event.content.substring(0, 50)
}, 'Loading Nostr documentation');
}
function handleItemClick(item: any) { function handleItemClick(item: any) {
if (item.url) { if (item.url) {
@ -210,12 +301,11 @@
<button <button
class="doc-item {selectedDoc === 'README.md' ? 'selected' : ''}" class="doc-item {selectedDoc === 'README.md' ? 'selected' : ''}"
onclick={() => { onclick={() => {
// Reload README if needed // Always reload README to ensure we have the right content
if (!documentationContent) { // Clear any Nostr doc state first
loadDocumentation(); documentationTitle = null;
} else { indexEvent = null;
selectedDoc = 'README.md'; loadDocumentation();
}
}} }}
> >
README.md README.md
@ -234,7 +324,25 @@
</li> </li>
{/each} {/each}
{/if} {/if}
{#if !hasReadme && docFiles.length === 0} {#if nostrDocs.length > 0}
{#each nostrDocs as doc}
<li class="nostr-doc-item">
<button
class="doc-item {selectedDoc === `nostr:${doc.id}` ? 'selected' : ''}"
onclick={() => loadNostrDoc(doc)}
title="Kind {doc.kind}"
>
{doc.title}
</button>
<EventCopyButton
eventId={doc.id}
kind={doc.kind}
pubkey={doc.event.pubkey}
/>
</li>
{/each}
{/if}
{#if !hasReadme && docFiles.length === 0 && nostrDocs.length === 0}
<div class="empty-sidebar"> <div class="empty-sidebar">
<p>No documentation files found</p> <p>No documentation files found</p>
</div> </div>
@ -257,14 +365,21 @@
onItemClick={handleItemClick} onItemClick={handleItemClick}
/> />
{:else if documentationContent} {:else if documentationContent}
<DocsViewer <div class="docs-panel">
content={documentationContent} {#if documentationTitle}
contentType={documentationKind || 'text'} <div class="docs-panel-header">
npub={npub} <h2 class="docs-panel-title">{documentationTitle}</h2>
repo={repo} </div>
currentBranch={currentBranch || 'HEAD'} {/if}
filePath={selectedDoc || 'README.md'} <DocsViewer
/> content={documentationContent}
contentType={documentationKind || 'text'}
npub={npub}
repo={repo}
currentBranch={currentBranch || 'HEAD'}
filePath={selectedDoc || 'README.md'}
/>
</div>
{:else} {:else}
<div class="empty-docs"> <div class="empty-docs">
<p>No documentation found</p> <p>No documentation found</p>
@ -334,6 +449,18 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
.nostr-doc-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.nostr-doc-item .doc-item {
flex: 1;
margin-bottom: 0;
}
.doc-item { .doc-item {
width: 100%; width: 100%;
@ -394,4 +521,26 @@
border-radius: 4px; border-radius: 4px;
margin: 1rem; margin: 1rem;
} }
.docs-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.docs-panel-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1rem;
flex-shrink: 0;
}
.docs-panel-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
</style> </style>

17
src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte

@ -89,12 +89,17 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
$effect(() => { $effect(() => {
if (contentType === '30040' && indexEvent) { // Explicitly track both content and contentType
const currentContent = content;
const currentContentType = contentType;
if (currentContentType === '30040' && indexEvent) {
// Publication index - handled by PublicationIndexViewer // Publication index - handled by PublicationIndexViewer
return; return;
} }
if (content) { if (currentContent) {
// Re-render when content or contentType changes
doRenderContent(); doRenderContent();
} }
}); });
@ -104,7 +109,7 @@
error = null; error = null;
try { try {
logger.operation('Rendering content', { contentType, length: content.length }); logger.operation('Rendering content', { contentType, length: content.length, preview: content.substring(0, 100) });
// Use the shared content renderer utility // Use the shared content renderer utility
// contentType '30040' is handled separately by PublicationIndexViewer // contentType '30040' is handled separately by PublicationIndexViewer
@ -114,16 +119,18 @@
} else { } else {
renderedContent = await renderContent(content, contentType as 'markdown' | 'asciidoc' | 'text'); renderedContent = await renderContent(content, contentType as 'markdown' | 'asciidoc' | 'text');
logger.debug({ contentType, renderedLength: renderedContent.length, preview: renderedContent.substring(0, 200) }, 'Content rendered');
// Rewrite image paths to use API endpoint // Rewrite image paths to use API endpoint
if (npub && repo && filePath) { if (npub && repo && filePath) {
renderedContent = rewriteImagePaths(renderedContent, filePath, npub, repo, currentBranch); renderedContent = rewriteImagePaths(renderedContent, filePath, npub, repo, currentBranch);
} }
} }
logger.operation('Content rendered', { contentType }); logger.operation('Content rendered', { contentType, renderedLength: renderedContent.length });
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to render content'; error = err instanceof Error ? err.message : 'Failed to render content';
logger.error({ error: err, contentType }, 'Error rendering content'); logger.error({ error: err, contentType, contentPreview: content.substring(0, 100) }, 'Error rendering content');
} finally { } finally {
loading = false; loading = false;
} }

2
src/routes/repos/[npub]/[repo]/components/TagsTab.svelte

@ -10,9 +10,11 @@
tags: Array<{ name: string; hash: string; message?: string; date?: number }>; tags: Array<{ name: string; hash: string; message?: string; date?: number }>;
releases: Array<{ releases: Array<{
id: string; id: string;
title?: string;
tagName: string; tagName: string;
tagHash?: string; tagHash?: string;
releaseNotes?: string; releaseNotes?: string;
downloadUrl?: string;
isDraft?: boolean; isDraft?: boolean;
isPrerelease?: boolean; isPrerelease?: boolean;
created_at: number; created_at: number;

60
src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte

@ -13,6 +13,10 @@
</script> </script>
<Modal {open} title="Create New Release" ariaLabel="Create new release" {onClose}> <Modal {open} title="Create New Release" ariaLabel="Create new release" {onClose}>
<label>
Title:
<input type="text" bind:value={state.forms.release.title} placeholder="Release title (e.g., Version 1.0.0)" />
</label>
<label> <label>
Tag Name: Tag Name:
<input type="text" bind:value={state.forms.release.tagName} placeholder="v1.0.0" /> <input type="text" bind:value={state.forms.release.tagName} placeholder="v1.0.0" />
@ -21,17 +25,28 @@
Tag Hash (commit hash): Tag Hash (commit hash):
<input type="text" bind:value={state.forms.release.tagHash} placeholder="abc1234..." /> <input type="text" bind:value={state.forms.release.tagHash} placeholder="abc1234..." />
</label> </label>
<label>
Download URL (optional):
<input type="url" bind:value={state.forms.release.downloadUrl} placeholder="/api/repos/.../download?ref=..." />
<small class="field-hint">Pre-filled with the ZIP download URL for this tag. You can change it if needed.</small>
</label>
<label> <label>
Release Notes: Release Notes:
<textarea bind:value={state.forms.release.notes} rows="10" placeholder="Release notes in markdown..."></textarea> <textarea bind:value={state.forms.release.notes} rows="10" placeholder="Release notes in markdown..."></textarea>
</label> </label>
<label> <label class="checkbox-label">
<input type="checkbox" bind:checked={state.forms.release.isDraft} /> <input type="checkbox" bind:checked={state.forms.release.isDraft} />
Draft Release <div class="checkbox-content">
<span class="checkbox-title">Draft Release</span>
<span class="checkbox-explanation">Published but marked as draft. Viewers can filter it out, but it's still visible on Nostr relays.</span>
</div>
</label> </label>
<label> <label class="checkbox-label">
<input type="checkbox" bind:checked={state.forms.release.isPrerelease} /> <input type="checkbox" bind:checked={state.forms.release.isPrerelease} />
Pre-release <div class="checkbox-content">
<span class="checkbox-title">Pre-release</span>
<span class="checkbox-explanation">Marks this as a pre-release (alpha, beta, or release candidate), not the final stable version.</span>
</div>
</label> </label>
<div class="modal-actions"> <div class="modal-actions">
<button onclick={onClose} class="cancel-button">Cancel</button> <button onclick={onClose} class="cancel-button">Cancel</button>
@ -65,6 +80,43 @@
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.checkbox-label input[type="checkbox"] {
margin-top: 0.25rem;
flex-shrink: 0;
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.checkbox-title {
font-weight: 500;
color: var(--text-primary);
}
.checkbox-explanation {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
.field-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
font-style: italic;
}
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

16
src/routes/repos/[npub]/[repo]/services/file-operations.ts

@ -75,7 +75,7 @@ export async function saveFile(
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, { await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: state.files.currentFile, path: state.files.currentFile,
content: state.files.editedContent, content: state.files.editedContent,
message: state.forms.commit.message.trim(), commitMessage: state.forms.commit.message.trim(),
authorName: authorName, authorName: authorName,
authorEmail: authorEmail, authorEmail: authorEmail,
branch: state.git.currentBranch, branch: state.git.currentBranch,
@ -153,7 +153,7 @@ export async function createFile(
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, { await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: filePath, path: filePath,
content: state.forms.file.content, content: state.forms.file.content,
message: commitMsg, commitMessage: commitMsg,
authorName: authorName, authorName: authorName,
authorEmail: authorEmail, authorEmail: authorEmail,
branch: state.git.currentBranch, branch: state.git.currentBranch,
@ -167,8 +167,9 @@ export async function createFile(
state.forms.file.content = ''; state.forms.file.content = '';
state.openDialog = null; state.openDialog = null;
// Reload file list // Reload file list - use currentPath or empty string for root
await callbacks.loadFiles(state.files.currentPath); const pathToReload = state.files.currentPath || '';
await callbacks.loadFiles(pathToReload);
alert('File created successfully!'); alert('File created successfully!');
} catch (err) { } catch (err) {
@ -233,7 +234,7 @@ export async function deleteFile(
await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, { await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, {
path: filePath, path: filePath,
message: commitMsg, commitMessage: commitMsg,
authorName: authorName, authorName: authorName,
authorEmail: authorEmail, authorEmail: authorEmail,
branch: state.git.currentBranch, branch: state.git.currentBranch,
@ -247,8 +248,9 @@ export async function deleteFile(
state.files.currentFile = null; state.files.currentFile = null;
} }
// Reload file list // Reload file list - use currentPath or empty string for root
await callbacks.loadFiles(state.files.currentPath); const pathToReload = state.files.currentPath || '';
await callbacks.loadFiles(pathToReload);
alert('File deleted successfully!'); alert('File deleted successfully!');
} catch (err) { } catch (err) {

8
src/routes/repos/[npub]/[repo]/services/release-operations.ts

@ -26,9 +26,11 @@ export async function loadReleases(
); );
state.releases = data.map((release: any) => ({ state.releases = data.map((release: any) => ({
id: release.id, id: release.id,
title: release.tags.find((t: string[]) => t[0] === 'title')?.[1],
tagName: release.tags.find((t: string[]) => t[0] === 'tag')?.[1] || '', tagName: release.tags.find((t: string[]) => t[0] === 'tag')?.[1] || '',
tagHash: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'tag')?.[1], tagHash: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'tag')?.[1],
releaseNotes: release.content || '', releaseNotes: release.content || '',
downloadUrl: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'download')?.[1],
isDraft: release.tags.some((t: string[]) => t[0] === 'draft' && t[1] === 'true'), isDraft: release.tags.some((t: string[]) => t[0] === 'draft' && t[1] === 'true'),
isPrerelease: release.tags.some((t: string[]) => t[0] === 'prerelease' && t[1] === 'true'), isPrerelease: release.tags.some((t: string[]) => t[0] === 'prerelease' && t[1] === 'true'),
created_at: release.created_at, created_at: release.created_at,
@ -64,22 +66,26 @@ export async function createRelease(
return; return;
} }
state.creating.release = true; state.creating.release = true;
state.error = null; state.error = null;
try { try {
await apiPost(`/api/repos/${state.npub}/${state.repo}/releases`, { await apiPost(`/api/repos/${state.npub}/${state.repo}/releases`, {
title: state.forms.release.title,
tagName: state.forms.release.tagName, tagName: state.forms.release.tagName,
tagHash: state.forms.release.tagHash, tagHash: state.forms.release.tagHash,
releaseNotes: state.forms.release.notes, releaseNotes: state.forms.release.notes,
downloadUrl: state.forms.release.downloadUrl,
isDraft: state.forms.release.isDraft, isDraft: state.forms.release.isDraft,
isPrerelease: state.forms.release.isPrerelease isPrerelease: state.forms.release.isPrerelease
}); });
state.openDialog = null; state.openDialog = null;
state.forms.release.title = '';
state.forms.release.tagName = ''; state.forms.release.tagName = '';
state.forms.release.tagHash = ''; state.forms.release.tagHash = '';
state.forms.release.notes = ''; state.forms.release.notes = '';
state.forms.release.downloadUrl = '';
state.forms.release.isDraft = false; state.forms.release.isDraft = false;
state.forms.release.isPrerelease = false; state.forms.release.isPrerelease = false;
await callbacks.loadReleases(); await callbacks.loadReleases();

4
src/routes/repos/[npub]/[repo]/stores/repo-state.ts

@ -95,9 +95,11 @@ export interface PatchFormData {
} }
export interface ReleaseFormData { export interface ReleaseFormData {
title: string;
tagName: string; tagName: string;
tagHash: string; tagHash: string;
notes: string; notes: string;
downloadUrl: string;
isDraft: boolean; isDraft: boolean;
isPrerelease: boolean; isPrerelease: boolean;
} }
@ -550,7 +552,7 @@ export function createRepoState(): RepoState {
issue: { subject: '', content: '', labels: [''] }, issue: { subject: '', content: '', labels: [''] },
pr: { subject: '', content: '', commitId: '', branchName: '', labels: [''] }, pr: { subject: '', content: '', commitId: '', branchName: '', labels: [''] },
patch: { content: '', subject: '' }, patch: { content: '', subject: '' },
release: { tagName: '', tagHash: '', notes: '', isDraft: false, isPrerelease: false }, release: { title: '', tagName: '', tagHash: '', notes: '', downloadUrl: '', isDraft: false, isPrerelease: false },
discussion: { threadTitle: '', threadContent: '', replyContent: '' }, discussion: { threadTitle: '', threadContent: '', replyContent: '' },
patchHighlight: { text: '', startLine: 0, endLine: 0, startPos: 0, endPos: 0, comment: '' }, patchHighlight: { text: '', startLine: 0, endLine: 0, startPos: 0, endPos: 0, comment: '' },
patchComment: { content: '', replyingTo: null }, patchComment: { content: '', replyingTo: null },

88
src/routes/repos/[npub]/[repo]/utils/content-renderer.ts

@ -4,6 +4,44 @@
* Consolidates rendering logic used by DocsViewer, PRsTab, IssuesTab, and PatchesTab * Consolidates rendering logic used by DocsViewer, PRsTab, IssuesTab, and PatchesTab
*/ */
/**
* Extract document title from AsciiDoc content
* @param content - The AsciiDoc content
* @returns The title (text after `= `) or null if not found
*/
export function extractAsciiDocTitle(content: string): string | null {
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('= ')) {
return trimmed.substring(2).trim();
}
}
return null;
}
/**
* Remove document title from AsciiDoc content
* @param content - The AsciiDoc content
* @returns Content without the title line
*/
export function removeAsciiDocTitle(content: string): string {
const lines = content.split('\n');
const result: string[] = [];
let titleRemoved = false;
for (const line of lines) {
const trimmed = line.trim();
if (!titleRemoved && trimmed.startsWith('= ')) {
titleRemoved = true;
continue; // Skip the title line
}
result.push(line);
}
return result.join('\n');
}
/** /**
* Render content as HTML based on kind or contentType * Render content as HTML based on kind or contentType
* @param content - The content to render * @param content - The content to render
@ -28,14 +66,50 @@ export async function renderContent(
if (useAsciiDoc) { if (useAsciiDoc) {
// Use AsciiDoc parser // Use AsciiDoc parser
const asciidoctor = (await import('asciidoctor')).default(); try {
const result = asciidoctor.convert(content, { // Remove document title from content before rendering
safe: 'safe', // (we'll display it separately in the header)
attributes: { const contentWithoutTitle = removeAsciiDocTitle(content);
'source-highlighter': 'highlight.js'
const asciidoctor = (await import('asciidoctor')).default();
// Convert with options to get clean HTML body
// standalone: false means no document wrapper
// header_footer: false means no HTML header/footer tags
const result = asciidoctor.convert(contentWithoutTitle, {
safe: 'safe',
standalone: false,
header_footer: false,
doctype: 'article',
attributes: {
'source-highlighter': 'highlight.js'
}
});
let html = typeof result === 'string' ? result : String(result);
// AsciiDoctor with standalone:false should give us just the body content
// But if there's still a wrapper, extract it
const bodyMatch = html.match(/<body[^>]*>(.*?)<\/body>/s);
if (bodyMatch) {
html = bodyMatch[1];
} }
});
return typeof result === 'string' ? result : String(result); // Don't extract sect1 wrapper - we want all sections, not just the first one
// The sect1 divs are fine, they contain the actual content sections
// Log for debugging
console.log('[ContentRenderer] AsciiDoc converted:', {
inputLength: content.length,
outputLength: html.length,
hasH1: html.includes('<h1'),
hasH2: html.includes('<h2'),
sect1Count: (html.match(/<div[^>]*class="sect1"/g) || []).length,
preview: html.substring(0, 300)
});
return html;
} catch (err) {
console.error('[ContentRenderer] AsciiDoc conversion error:', err);
throw new Error(`Failed to convert AsciiDoc: ${err instanceof Error ? err.message : String(err)}`);
}
} else if (typeof kindOrType === 'string' && kindOrType === 'text') { } else if (typeof kindOrType === 'string' && kindOrType === 'text') {
// Plain text - escape HTML // Plain text - escape HTML
return content return content

Loading…
Cancel
Save