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 updatedEntries = directory.entries.map(entry => {
149 if ('type' in entry.node && entry.node.type === 'file') {
150 // Build the full path for this file
151 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
152
153 // Find exact match in filePaths (need to handle normalized paths)
154 const fileIndex = filePaths.findIndex((path) => {
155 // Normalize both paths by removing leading base folder
156 const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
157 const normalizedEntryPath = fullPath;
158 return normalizedUploadPath === normalizedEntryPath || path === fullPath;
159 });
160
161 if (fileIndex !== -1 && uploadResults[fileIndex]) {
162 const blobRef = uploadResults[fileIndex].blobRef;
163
164 return {
165 ...entry,
166 node: {
167 $type: 'place.wisp.fs#file' as const,
168 type: 'file' as const,
169 blob: blobRef
170 }
171 };
172 } else {
173 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`);
174 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : '');
175 }
176 } else if ('type' in entry.node && entry.node.type === 'directory') {
177 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
178 return {
179 ...entry,
180 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath)
181 };
182 }
183 return entry;
184 }) as Entry[];
185
186 const result = {
187 $type: 'place.wisp.fs#directory' as const,
188 type: 'directory' as const,
189 entries: updatedEntries
190 };
191
192 return result;
193}