forked from
nekomimi.pet/wisp.place-monorepo
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";
4import { gzipSync } from 'zlib';
5
6export interface UploadedFile {
7 name: string;
8 content: Buffer;
9 mimeType: string;
10 size: number;
11 compressed?: 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
28/**
29 * Determine if a file should be gzip compressed based on its MIME type
30 */
31export function shouldCompressFile(mimeType: string): boolean {
32 // Compress text-based files
33 const compressibleTypes = [
34 'text/html',
35 'text/css',
36 'text/javascript',
37 'application/javascript',
38 'application/json',
39 'image/svg+xml',
40 'text/xml',
41 'application/xml',
42 'text/plain',
43 'application/x-javascript'
44 ];
45
46 // Check if mime type starts with any compressible type
47 return compressibleTypes.some(type => mimeType.startsWith(type));
48}
49
50/**
51 * Compress a file using gzip
52 */
53export function compressFile(content: Buffer): Buffer {
54 return gzipSync(content, { level: 9 });
55}
56
57/**
58 * Process uploaded files into a directory structure
59 */
60export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
61 const entries: Entry[] = [];
62 let fileCount = 0;
63
64 // Group files by directory
65 const directoryMap = new Map<string, UploadedFile[]>();
66
67 for (const file of files) {
68 // Remove any base folder name from the path
69 const normalizedPath = file.name.replace(/^[^\/]*\//, '');
70 const parts = normalizedPath.split('/');
71
72 if (parts.length === 1) {
73 // Root level file
74 entries.push({
75 name: parts[0],
76 node: {
77 $type: 'place.wisp.fs#file' as const,
78 type: 'file' as const,
79 blob: undefined as any // Will be filled after upload
80 }
81 });
82 fileCount++;
83 } else {
84 // File in subdirectory
85 const dirPath = parts.slice(0, -1).join('/');
86 if (!directoryMap.has(dirPath)) {
87 directoryMap.set(dirPath, []);
88 }
89 directoryMap.get(dirPath)!.push({
90 ...file,
91 name: normalizedPath
92 });
93 }
94 }
95
96 // Process subdirectories
97 for (const [dirPath, dirFiles] of directoryMap) {
98 const dirEntries: Entry[] = [];
99
100 for (const file of dirFiles) {
101 const fileName = file.name.split('/').pop()!;
102 dirEntries.push({
103 name: fileName,
104 node: {
105 $type: 'place.wisp.fs#file' as const,
106 type: 'file' as const,
107 blob: undefined as any // Will be filled after upload
108 }
109 });
110 fileCount++;
111 }
112
113 // Build nested directory structure
114 const pathParts = dirPath.split('/');
115 let currentEntries = entries;
116
117 for (let i = 0; i < pathParts.length; i++) {
118 const part = pathParts[i];
119 const isLast = i === pathParts.length - 1;
120
121 let existingEntry = currentEntries.find(e => e.name === part);
122
123 if (!existingEntry) {
124 const newDir = {
125 $type: 'place.wisp.fs#directory' as const,
126 type: 'directory' as const,
127 entries: isLast ? dirEntries : []
128 };
129
130 existingEntry = {
131 name: part,
132 node: newDir
133 };
134 currentEntries.push(existingEntry);
135 } else if ('entries' in existingEntry.node && isLast) {
136 (existingEntry.node as any).entries.push(...dirEntries);
137 }
138
139 if (existingEntry && 'entries' in existingEntry.node) {
140 currentEntries = (existingEntry.node as any).entries;
141 }
142 }
143 }
144
145 const result = {
146 directory: {
147 $type: 'place.wisp.fs#directory' as const,
148 type: 'directory' as const,
149 entries
150 },
151 fileCount
152 };
153
154 return result;
155}
156
157/**
158 * Create the manifest record for a site
159 */
160export function createManifest(
161 siteName: string,
162 root: Directory,
163 fileCount: number
164): Record {
165 const manifest = {
166 $type: 'place.wisp.fs' as const,
167 site: siteName,
168 root,
169 fileCount,
170 createdAt: new Date().toISOString()
171 };
172
173 // Validate the manifest before returning
174 const validationResult = validateRecord(manifest);
175 if (!validationResult.success) {
176 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
177 }
178
179 return manifest;
180}
181
182/**
183 * Update file blobs in directory structure after upload
184 * Uses path-based matching to correctly match files in nested directories
185 */
186export function updateFileBlobs(
187 directory: Directory,
188 uploadResults: FileUploadResult[],
189 filePaths: string[],
190 currentPath: string = ''
191): Directory {
192 const updatedEntries = directory.entries.map(entry => {
193 if ('type' in entry.node && entry.node.type === 'file') {
194 // Build the full path for this file
195 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
196
197 // Find exact match in filePaths (need to handle normalized paths)
198 const fileIndex = filePaths.findIndex((path) => {
199 // Normalize both paths by removing leading base folder
200 const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
201 const normalizedEntryPath = fullPath;
202 return normalizedUploadPath === normalizedEntryPath || path === fullPath;
203 });
204
205 if (fileIndex !== -1 && uploadResults[fileIndex]) {
206 const result = uploadResults[fileIndex];
207 const blobRef = result.blobRef;
208
209 return {
210 ...entry,
211 node: {
212 $type: 'place.wisp.fs#file' as const,
213 type: 'file' as const,
214 blob: blobRef,
215 ...(result.encoding && { encoding: result.encoding }),
216 ...(result.mimeType && { mimeType: result.mimeType }),
217 ...(result.base64 && { base64: result.base64 })
218 }
219 };
220 } else {
221 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`);
222 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : '');
223 }
224 } else if ('type' in entry.node && entry.node.type === 'directory') {
225 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
226 return {
227 ...entry,
228 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath)
229 };
230 }
231 return entry;
232 }) as Entry[];
233
234 const result = {
235 $type: 'place.wisp.fs#directory' as const,
236 type: 'directory' as const,
237 entries: updatedEntries
238 };
239
240 return result;
241}