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