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}