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