Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
at main 270 lines 7.7 kB view raw
1import type { BlobRef } from "@atproto/api"; 2import type { Directory, Entry, File } from "@wispplace/lexicons/types/place/wisp/fs"; 3import { extractBlobCid } from "@wispplace/atproto-utils"; 4 5export interface UploadedFile { 6 name: string; 7 content: Buffer; 8 mimeType: string; 9 size: number; 10 compressed?: boolean; 11 base64Encoded?: boolean; 12 originalMimeType?: string; 13} 14 15export interface FileUploadResult { 16 hash: string; 17 blobRef: BlobRef; 18 encoding?: 'gzip'; 19 mimeType?: string; 20 base64?: boolean; 21} 22 23export interface ProcessedDirectory { 24 directory: Directory; 25 fileCount: number; 26} 27 28export interface ProcessUploadedFilesOptions { 29 /** 30 * Skip path normalization (stripping first folder segment). 31 * Use true for CLI where paths are already relative to site directory. 32 * Use false (default) for web uploads where paths include the folder name. 33 */ 34 skipNormalization?: boolean; 35} 36 37/** 38 * Process uploaded files into a directory structure 39 */ 40export function processUploadedFiles(files: UploadedFile[], options?: ProcessUploadedFilesOptions): ProcessedDirectory { 41 const { skipNormalization = false } = options || {}; 42 const entries: Entry[] = []; 43 let fileCount = 0; 44 45 // Group files by directory 46 const directoryMap = new Map<string, UploadedFile[]>(); 47 48 for (const file of files) { 49 // Skip undefined/null files (defensive) 50 if (!file || !file.name) { 51 console.error('Skipping undefined or invalid file in processUploadedFiles'); 52 continue; 53 } 54 55 // Remove any base folder name from the path (for web uploads) 56 // Skip if paths are already relative (CLI use case) 57 const normalizedPath = skipNormalization ? file.name : file.name.replace(/^[^\/]*\//, ''); 58 59 // Skip files in .git directories 60 if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') { 61 continue; 62 } 63 64 const parts = normalizedPath.split('/'); 65 66 if (parts.length === 1) { 67 // Root level file 68 entries.push({ 69 name: parts[0]!, 70 node: { 71 $type: 'place.wisp.fs#file' as const, 72 type: 'file' as const, 73 blob: undefined as any // Will be filled after upload 74 } 75 }); 76 fileCount++; 77 } else { 78 // File in subdirectory 79 const dirPath = parts.slice(0, -1).join('/'); 80 if (!directoryMap.has(dirPath)) { 81 directoryMap.set(dirPath, []); 82 } 83 directoryMap.get(dirPath)!.push({ 84 ...file, 85 name: normalizedPath 86 }); 87 } 88 } 89 90 // Process subdirectories 91 for (const [dirPath, dirFiles] of directoryMap) { 92 const dirEntries: Entry[] = []; 93 94 for (const file of dirFiles) { 95 const fileName = file.name.split('/').pop()!; 96 dirEntries.push({ 97 name: fileName, 98 node: { 99 $type: 'place.wisp.fs#file' as const, 100 type: 'file' as const, 101 blob: undefined as any // Will be filled after upload 102 } 103 }); 104 fileCount++; 105 } 106 107 // Build nested directory structure 108 const pathParts = dirPath.split('/'); 109 let currentEntries = entries; 110 111 for (let i = 0; i < pathParts.length; i++) { 112 const part = pathParts[i]; 113 const isLast = i === pathParts.length - 1; 114 115 let existingEntry = currentEntries.find(e => e.name === part); 116 117 if (!existingEntry) { 118 const newDir = { 119 $type: 'place.wisp.fs#directory' as const, 120 type: 'directory' as const, 121 entries: isLast ? dirEntries : [] 122 }; 123 124 existingEntry = { 125 name: part!, 126 node: newDir 127 }; 128 currentEntries.push(existingEntry); 129 } else if ('entries' in existingEntry.node && isLast) { 130 (existingEntry.node as any).entries.push(...dirEntries); 131 } 132 133 if (existingEntry && 'entries' in existingEntry.node) { 134 currentEntries = (existingEntry.node as any).entries; 135 } 136 } 137 } 138 139 const result = { 140 directory: { 141 $type: 'place.wisp.fs#directory' as const, 142 type: 'directory' as const, 143 entries 144 }, 145 fileCount 146 }; 147 148 return result; 149} 150 151export interface UpdateFileBlobsOptions { 152 /** 153 * Skip path normalization when matching files. 154 * Use true for CLI where paths are already relative to site directory. 155 */ 156 skipNormalization?: boolean; 157} 158 159/** 160 * Update file blobs in directory structure after upload 161 * Uses path-based matching to correctly match files in nested directories 162 * Filters out files that were not successfully uploaded 163 */ 164export function updateFileBlobs( 165 directory: Directory, 166 uploadResults: FileUploadResult[], 167 filePaths: string[], 168 currentPath: string = '', 169 successfulPaths?: Set<string>, 170 options?: UpdateFileBlobsOptions 171): Directory { 172 const { skipNormalization = false } = options || {}; 173 const updatedEntries = directory.entries.map(entry => { 174 if ('type' in entry.node && entry.node.type === 'file') { 175 // Build the full path for this file 176 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 177 178 // If successfulPaths is provided, skip files that weren't successfully uploaded 179 if (successfulPaths && !successfulPaths.has(fullPath)) { 180 return null; // Filter out failed files 181 } 182 183 // Find exact match in filePaths 184 const fileIndex = filePaths.findIndex((path) => { 185 if (skipNormalization) { 186 // Direct match for CLI use case 187 return path === fullPath; 188 } 189 // Normalize both paths by removing leading base folder (web upload case) 190 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 191 const normalizedEntryPath = fullPath; 192 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 193 }); 194 195 if (fileIndex !== -1 && uploadResults[fileIndex]) { 196 const result = uploadResults[fileIndex]; 197 const blobRef = result.blobRef; 198 199 return { 200 ...entry, 201 node: { 202 $type: 'place.wisp.fs#file' as const, 203 type: 'file' as const, 204 blob: blobRef, 205 ...(result.encoding && { encoding: result.encoding }), 206 ...(result.mimeType && { mimeType: result.mimeType }), 207 ...(result.base64 && { base64: result.base64 }) 208 } 209 }; 210 } else { 211 console.error(`Could not find blob for file: ${fullPath}`); 212 return null; // Filter out files without blobs 213 } 214 } else if ('type' in entry.node && entry.node.type === 'directory') { 215 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 216 return { 217 ...entry, 218 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath, successfulPaths, options) 219 }; 220 } 221 return entry; 222 }).filter(entry => entry !== null) as Entry[]; // Remove null entries (failed files) 223 224 const result = { 225 $type: 'place.wisp.fs#directory' as const, 226 type: 'directory' as const, 227 entries: updatedEntries 228 }; 229 230 return result; 231} 232 233/** 234 * Count files in a directory tree 235 */ 236export function countFilesInDirectory(directory: Directory): number { 237 let count = 0; 238 for (const entry of directory.entries) { 239 if ('type' in entry.node && entry.node.type === 'file') { 240 count++; 241 } else if ('type' in entry.node && entry.node.type === 'directory') { 242 count += countFilesInDirectory(entry.node as Directory); 243 } 244 } 245 return count; 246} 247 248/** 249 * Recursively collect file CIDs from entries for incremental update tracking 250 */ 251export function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void { 252 for (const entry of entries) { 253 const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 254 const node = entry.node; 255 256 if ('type' in node && node.type === 'directory' && 'entries' in node) { 257 collectFileCidsFromEntries(node.entries, currentPath, fileCids); 258 } else if ('type' in node && node.type === 'file' && 'blob' in node) { 259 const fileNode = node as File; 260 if (fileNode.blob) { 261 const cid = extractBlobCid(fileNode.blob); 262 if (cid) { 263 fileCids[currentPath] = cid; 264 } else { 265 console.warn(`[collectFileCids] Could not extract CID for file: ${currentPath}`); 266 } 267 } 268 } 269 } 270}