Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import type { BlobRef } from "@atproto/api"; 2import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs"; 3 4export interface UploadedFile { 5 name: string; 6 content: Buffer; 7 mimeType: string; 8 size: number; 9} 10 11export interface FileUploadResult { 12 hash: string; 13 blobRef: BlobRef; 14} 15 16export interface ProcessedDirectory { 17 directory: Directory; 18 fileCount: number; 19} 20 21/** 22 * Process uploaded files into a directory structure 23 */ 24export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 25 const entries: Entry[] = []; 26 let fileCount = 0; 27 28 // Group files by directory 29 const directoryMap = new Map<string, UploadedFile[]>(); 30 31 for (const file of files) { 32 // Remove any base folder name from the path 33 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 34 const parts = normalizedPath.split('/'); 35 36 if (parts.length === 1) { 37 // Root level file 38 entries.push({ 39 name: parts[0], 40 node: { 41 $type: 'place.wisp.fs#file' as const, 42 type: 'file' as const, 43 blob: undefined as any // Will be filled after upload 44 } 45 }); 46 fileCount++; 47 } else { 48 // File in subdirectory 49 const dirPath = parts.slice(0, -1).join('/'); 50 if (!directoryMap.has(dirPath)) { 51 directoryMap.set(dirPath, []); 52 } 53 directoryMap.get(dirPath)!.push({ 54 ...file, 55 name: normalizedPath 56 }); 57 } 58 } 59 60 // Process subdirectories 61 for (const [dirPath, dirFiles] of directoryMap) { 62 const dirEntries: Entry[] = []; 63 64 for (const file of dirFiles) { 65 const fileName = file.name.split('/').pop()!; 66 dirEntries.push({ 67 name: fileName, 68 node: { 69 $type: 'place.wisp.fs#file' as const, 70 type: 'file' as const, 71 blob: undefined as any // Will be filled after upload 72 } 73 }); 74 fileCount++; 75 } 76 77 // Build nested directory structure 78 const pathParts = dirPath.split('/'); 79 let currentEntries = entries; 80 81 for (let i = 0; i < pathParts.length; i++) { 82 const part = pathParts[i]; 83 const isLast = i === pathParts.length - 1; 84 85 let existingEntry = currentEntries.find(e => e.name === part); 86 87 if (!existingEntry) { 88 const newDir = { 89 $type: 'place.wisp.fs#directory' as const, 90 type: 'directory' as const, 91 entries: isLast ? dirEntries : [] 92 }; 93 94 existingEntry = { 95 name: part, 96 node: newDir 97 }; 98 currentEntries.push(existingEntry); 99 } else if ('entries' in existingEntry.node && isLast) { 100 (existingEntry.node as any).entries.push(...dirEntries); 101 } 102 103 if (existingEntry && 'entries' in existingEntry.node) { 104 currentEntries = (existingEntry.node as any).entries; 105 } 106 } 107 } 108 109 const result = { 110 directory: { 111 $type: 'place.wisp.fs#directory' as const, 112 type: 'directory' as const, 113 entries 114 }, 115 fileCount 116 }; 117 118 return result; 119} 120 121/** 122 * Create the manifest record for a site 123 */ 124export function createManifest( 125 siteName: string, 126 root: Directory, 127 fileCount: number 128): Record { 129 return { 130 $type: 'place.wisp.fs' as const, 131 site: siteName, 132 root, 133 fileCount, 134 createdAt: new Date().toISOString() 135 }; 136} 137 138/** 139 * Update file blobs in directory structure after upload 140 * Uses path-based matching to correctly match files in nested directories 141 */ 142export function updateFileBlobs( 143 directory: Directory, 144 uploadResults: FileUploadResult[], 145 filePaths: string[], 146 currentPath: string = '' 147): Directory { 148 const updatedEntries = directory.entries.map(entry => { 149 if ('type' in entry.node && entry.node.type === 'file') { 150 // Build the full path for this file 151 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 152 153 // Find exact match in filePaths (need to handle normalized paths) 154 const fileIndex = filePaths.findIndex((path) => { 155 // Normalize both paths by removing leading base folder 156 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 157 const normalizedEntryPath = fullPath; 158 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 159 }); 160 161 if (fileIndex !== -1 && uploadResults[fileIndex]) { 162 const blobRef = uploadResults[fileIndex].blobRef; 163 164 return { 165 ...entry, 166 node: { 167 $type: 'place.wisp.fs#file' as const, 168 type: 'file' as const, 169 blob: blobRef 170 } 171 }; 172 } else { 173 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`); 174 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : ''); 175 } 176 } else if ('type' in entry.node && entry.node.type === 'directory') { 177 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 178 return { 179 ...entry, 180 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath) 181 }; 182 } 183 return entry; 184 }) as Entry[]; 185 186 const result = { 187 $type: 'place.wisp.fs#directory' as const, 188 type: 'directory' as const, 189 entries: updatedEntries 190 }; 191 192 return result; 193}