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 mimeTypeMismatches: string[] = []; 149 150 const updatedEntries = directory.entries.map(entry => { 151 if ('type' in entry.node && entry.node.type === 'file') { 152 // Build the full path for this file 153 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 154 155 // Find exact match in filePaths (need to handle normalized paths) 156 const fileIndex = filePaths.findIndex((path) => { 157 // Normalize both paths by removing leading base folder 158 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 159 const normalizedEntryPath = fullPath; 160 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 161 }); 162 163 if (fileIndex !== -1 && uploadResults[fileIndex]) { 164 const blobRef = uploadResults[fileIndex].blobRef; 165 const uploadedPath = filePaths[fileIndex]; 166 167 // Check if MIME types make sense for this file extension 168 const expectedMime = getExpectedMimeType(entry.name); 169 if (expectedMime && blobRef.mimeType !== expectedMime && !blobRef.mimeType.startsWith(expectedMime)) { 170 mimeTypeMismatches.push(`${fullPath}: expected ${expectedMime}, got ${blobRef.mimeType} (from upload: ${uploadedPath})`); 171 } 172 173 return { 174 ...entry, 175 node: { 176 $type: 'place.wisp.fs#file' as const, 177 type: 'file' as const, 178 blob: blobRef 179 } 180 }; 181 } else { 182 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`); 183 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : ''); 184 } 185 } else if ('type' in entry.node && entry.node.type === 'directory') { 186 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 187 return { 188 ...entry, 189 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath) 190 }; 191 } 192 return entry; 193 }) as Entry[]; 194 195 if (mimeTypeMismatches.length > 0) { 196 console.error('\n⚠️ MIME TYPE MISMATCHES DETECTED IN MANIFEST:'); 197 mimeTypeMismatches.forEach(m => console.error(` ${m}`)); 198 console.error(''); 199 } 200 201 const result = { 202 $type: 'place.wisp.fs#directory' as const, 203 type: 'directory' as const, 204 entries: updatedEntries 205 }; 206 207 return result; 208} 209 210function getExpectedMimeType(filename: string): string | null { 211 const ext = filename.toLowerCase().split('.').pop(); 212 const mimeMap: Record<string, string> = { 213 'html': 'text/html', 214 'htm': 'text/html', 215 'css': 'text/css', 216 'js': 'text/javascript', 217 'mjs': 'text/javascript', 218 'json': 'application/json', 219 'jpg': 'image/jpeg', 220 'jpeg': 'image/jpeg', 221 'png': 'image/png', 222 'gif': 'image/gif', 223 'webp': 'image/webp', 224 'svg': 'image/svg+xml', 225 }; 226 return ext ? (mimeMap[ext] || null) : null; 227}