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";
4
5export interface UploadedFile {
6 name: string;
7 content: Buffer;
8 mimeType: string;
9 size: number;
10}
11
12export interface FileUploadResult {
13 hash: string;
14 blobRef: BlobRef;
15}
16
17export interface ProcessedDirectory {
18 directory: Directory;
19 fileCount: number;
20}
21
22/**
23 * Process uploaded files into a directory structure
24 */
25export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory {
26 const entries: Entry[] = [];
27 let fileCount = 0;
28
29 // Group files by directory
30 const directoryMap = new Map<string, UploadedFile[]>();
31
32 for (const file of files) {
33 // Remove any base folder name from the path
34 const normalizedPath = file.name.replace(/^[^\/]*\//, '');
35 const parts = normalizedPath.split('/');
36
37 if (parts.length === 1) {
38 // Root level file
39 entries.push({
40 name: parts[0],
41 node: {
42 $type: 'place.wisp.fs#file' as const,
43 type: 'file' as const,
44 blob: undefined as any // Will be filled after upload
45 }
46 });
47 fileCount++;
48 } else {
49 // File in subdirectory
50 const dirPath = parts.slice(0, -1).join('/');
51 if (!directoryMap.has(dirPath)) {
52 directoryMap.set(dirPath, []);
53 }
54 directoryMap.get(dirPath)!.push({
55 ...file,
56 name: normalizedPath
57 });
58 }
59 }
60
61 // Process subdirectories
62 for (const [dirPath, dirFiles] of directoryMap) {
63 const dirEntries: Entry[] = [];
64
65 for (const file of dirFiles) {
66 const fileName = file.name.split('/').pop()!;
67 dirEntries.push({
68 name: fileName,
69 node: {
70 $type: 'place.wisp.fs#file' as const,
71 type: 'file' as const,
72 blob: undefined as any // Will be filled after upload
73 }
74 });
75 fileCount++;
76 }
77
78 // Build nested directory structure
79 const pathParts = dirPath.split('/');
80 let currentEntries = entries;
81
82 for (let i = 0; i < pathParts.length; i++) {
83 const part = pathParts[i];
84 const isLast = i === pathParts.length - 1;
85
86 let existingEntry = currentEntries.find(e => e.name === part);
87
88 if (!existingEntry) {
89 const newDir = {
90 $type: 'place.wisp.fs#directory' as const,
91 type: 'directory' as const,
92 entries: isLast ? dirEntries : []
93 };
94
95 existingEntry = {
96 name: part,
97 node: newDir
98 };
99 currentEntries.push(existingEntry);
100 } else if ('entries' in existingEntry.node && isLast) {
101 (existingEntry.node as any).entries.push(...dirEntries);
102 }
103
104 if (existingEntry && 'entries' in existingEntry.node) {
105 currentEntries = (existingEntry.node as any).entries;
106 }
107 }
108 }
109
110 const result = {
111 directory: {
112 $type: 'place.wisp.fs#directory' as const,
113 type: 'directory' as const,
114 entries
115 },
116 fileCount
117 };
118
119 return result;
120}
121
122/**
123 * Create the manifest record for a site
124 */
125export function createManifest(
126 siteName: string,
127 root: Directory,
128 fileCount: number
129): Record {
130 const manifest = {
131 $type: 'place.wisp.fs' as const,
132 site: siteName,
133 root,
134 fileCount,
135 createdAt: new Date().toISOString()
136 };
137
138 // Validate the manifest before returning
139 const validationResult = validateRecord(manifest);
140 if (!validationResult.success) {
141 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
142 }
143
144 return manifest;
145}
146
147/**
148 * Update file blobs in directory structure after upload
149 * Uses path-based matching to correctly match files in nested directories
150 */
151export function updateFileBlobs(
152 directory: Directory,
153 uploadResults: FileUploadResult[],
154 filePaths: string[],
155 currentPath: string = ''
156): Directory {
157 const updatedEntries = directory.entries.map(entry => {
158 if ('type' in entry.node && entry.node.type === 'file') {
159 // Build the full path for this file
160 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
161
162 // Find exact match in filePaths (need to handle normalized paths)
163 const fileIndex = filePaths.findIndex((path) => {
164 // Normalize both paths by removing leading base folder
165 const normalizedUploadPath = path.replace(/^[^\/]*\//, '');
166 const normalizedEntryPath = fullPath;
167 return normalizedUploadPath === normalizedEntryPath || path === fullPath;
168 });
169
170 if (fileIndex !== -1 && uploadResults[fileIndex]) {
171 const blobRef = uploadResults[fileIndex].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 }
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 const result = {
196 $type: 'place.wisp.fs#directory' as const,
197 type: 'directory' as const,
198 entries: updatedEntries
199 };
200
201 return result;
202}