An in-browser wisp.place site explorer
at main 604 lines 15 kB view raw
1/** 2 * Lexicon record parsing for place.wisp.fs and place.wisp.subfs 3 * 4 * These types are adapted from the shared code for the browser client. 5 */ 6 7/** 8 * Represents a file entry in the wisp filesystem 9 */ 10export interface WispFile { 11 cid: string; // Blob CID for the file content 12 mimeType?: string; // MIME type from manifest 13 size?: number; // File size in bytes (optional) 14} 15 16/** 17 * Represents a directory entry in the wisp filesystem 18 */ 19export interface WispDirectory { 20 files?: Record<string, WispFile>; // Files in this directory 21 dirs?: Record<string, WispDirectory>; // Subdirectories 22} 23 24/** 25 * place.wisp.fs record structure (new format) 26 */ 27export interface PlaceWispFsRecord { 28 $type: 'place.wisp.fs'; 29 root?: WispDirectory | WispDirectoryNew; // Root directory (optional, defaults to empty) - can be new format 30 site?: string; // Site identifier (used as rkey) 31 fileCount?: number; // Number of files in the site 32 createdAt?: string; // ISO timestamp of site creation 33} 34 35/** 36 * Wisp directory node (new format with entries array) 37 */ 38export interface WispDirectoryNew { 39 type: 'directory'; 40 entries?: Array<{ 41 name: string; 42 node: WispFileNode | WispDirectoryNew; 43 }>; 44} 45 46/** 47 * CID object (as deserialized by @atproto/api) 48 */ 49export interface CidObject { 50 code: number; 51 version: number; 52 multihash: Uint8Array; 53 bytes: Uint8Array; 54 toString(): string; 55} 56 57/** 58 * Blob reference (either string $link or CID object) 59 */ 60export type BlobRef = { 61 $link: string; 62} | CidObject; 63 64/** 65 * Wisp file node (new format) 66 */ 67export interface WispFileNode { 68 type: 'file'; 69 blob: { 70 $type: 'blob'; 71 ref: BlobRef; // Can be { $link: string } or CID object 72 mimeType?: string; 73 size?: number; 74 }; 75 base64?: boolean; 76 encoding?: string; 77 mimeType?: string; // Duplicate at node level 78} 79 80/** 81 * place.wisp.subfs record structure (for large sites) 82 */ 83export interface PlaceWispSubfsRecord { 84 $type: 'place.wisp.subfs'; 85 directory: WispDirectory | WispDirectoryNew; // Directory subtree (can be either format) 86} 87 88/** 89 * File lookup result with metadata 90 */ 91export interface FileLookupResult { 92 cid: string; 93 mimeType: string; 94} 95 96/** 97 * Path resolution result 98 */ 99export type PathResolution = FileLookupResult | DirectoryLookupResult | null; 100 101export interface DirectoryLookupResult { 102 type: 'directory'; 103 files: Record<string, WispFile>; 104 dirs: string[]; 105} 106 107/** 108 * Error types for parsing 109 */ 110export class LexiconParseError extends Error { 111 constructor( 112 message: string, 113 public readonly record?: unknown 114 ) { 115 super(message); 116 this.name = 'LexiconParseError'; 117 } 118} 119 120/** 121 * Validate that a record has the correct type 122 */ 123export function validateRecordType( 124 record: unknown, 125 expectedType: 'place.wisp.fs' | 'place.wisp.subfs' 126): boolean { 127 if (!record || typeof record !== 'object') { 128 return false; 129 } 130 return (record as { $type?: string }).$type === expectedType; 131} 132 133/** 134 * Parse a place.wisp.fs record (supports both old and new formats) 135 */ 136export function parsePlaceWispFs(record: unknown): PlaceWispFsRecord { 137 if (!validateRecordType(record, 'place.wisp.fs')) { 138 throw new LexiconParseError('Invalid place.wisp.fs record type', record); 139 } 140 141 const parsed = record as Record<string, unknown>; 142 const root = parsed.root; 143 144 if (root !== undefined) { 145 // Check if it's the new format (with entries array) 146 if (isValidDirectoryNew(root)) { 147 return { 148 $type: 'place.wisp.fs', 149 root: convertDirectoryNewToOld(root as WispDirectoryNew), 150 }; 151 } 152 153 // Check if it's the old format 154 if (isValidDirectory(root)) { 155 return { 156 $type: 'place.wisp.fs', 157 root: root as WispDirectory, 158 }; 159 } 160 161 throw new LexiconParseError('Invalid directory structure in place.wisp.fs', record); 162 } 163 164 return { 165 $type: 'place.wisp.fs', 166 root: undefined, 167 }; 168} 169 170/** 171 * Validate directory structure (new format) 172 */ 173export function isValidDirectoryNew(dir: unknown): boolean { 174 if (!dir || typeof dir !== 'object') { 175 return false; 176 } 177 178 const d = dir as WispDirectoryNew; 179 // Accept both "directory" and "place.wisp.fs#directory" 180 if (d.type !== 'directory' && d.type !== 'place.wisp.fs#directory') { 181 return false; 182 } 183 184 const entries = d.entries; 185 if (!entries || !Array.isArray(entries)) { 186 return true; // Empty directory is valid 187 } 188 189 for (const entry of entries) { 190 if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') { 191 return false; 192 } 193 194 const node = entry.node; 195 if (!node || typeof node !== 'object') { 196 return false; 197 } 198 199 const nodeType = (node as { type: string }).type; 200 const isFile = nodeType === 'file' || nodeType === 'place.wisp.fs#file'; 201 const isDirectory = nodeType === 'directory' || nodeType === 'place.wisp.fs#directory'; 202 203 if (isFile) { 204 if (!isValidFileNode(node as WispFileNode)) { 205 return false; 206 } 207 } else if (isDirectory) { 208 if (!isValidDirectoryNew(node as WispDirectoryNew)) { 209 return false; 210 } 211 } else { 212 return false; 213 } 214 } 215 216 return true; 217} 218 219/** 220 * Extract CID string from blob ref 221 * Handles both { $link: string } format and CID objects 222 */ 223export function extractCidFromRef(ref: BlobRef): string { 224 // Check if it's a CID object (has code, version, multihash, etc.) 225 if ('code' in ref && 'version' in ref && typeof (ref as any).toString === 'function') { 226 return (ref as any).toString(); 227 } 228 // Otherwise it should be { $link: string } 229 if ('$link' in ref && typeof ref.$link === 'string') { 230 return ref.$link; 231 } 232 throw new Error('Invalid blob ref format'); 233} 234 235/** 236 * Validate file node (new format) 237 */ 238export function isValidFileNode(node: unknown): boolean { 239 if (!node || typeof node !== 'object') { 240 return false; 241 } 242 243 const f = node as WispFileNode; 244 // Accept both "file" and "place.wisp.fs#file" 245 if (f.type !== 'file' && f.type !== 'place.wisp.fs#file') { 246 return false; 247 } 248 249 const blob = f.blob; 250 if (!blob || typeof blob !== 'object') { 251 return false; 252 } 253 254 const ref = blob.ref; 255 if (!ref || typeof ref !== 'object') { 256 return false; 257 } 258 259 try { 260 const cid = extractCidFromRef(ref); 261 if (!cid || cid.length === 0) { 262 return false; 263 } 264 } catch { 265 return false; 266 } 267 268 return true; 269} 270 271/** 272 * Convert new directory format to old format 273 */ 274export function convertDirectoryNewToOld(dirNew: WispDirectoryNew): WispDirectory { 275 const dir: WispDirectory = { files: {}, dirs: {} }; 276 277 if (!dirNew.entries || dirNew.entries.length === 0) { 278 return dir; 279 } 280 281 dir.files = {}; 282 dir.dirs = {}; 283 284 for (const entry of dirNew.entries) { 285 const node = entry.node as { type: string }; 286 const nodeType = node.type; 287 288 if (nodeType === 'file' || nodeType === 'place.wisp.fs#file') { 289 const fileNode = entry.node as WispFileNode; 290 291 // Extract CID from blob.ref (handles both CID objects and { $link: string }) 292 const cid = extractCidFromRef(fileNode.blob.ref); 293 294 // Get MIME type from either node level or blob level 295 const mimeType = fileNode.mimeType || fileNode.blob.mimeType || 'application/octet-stream'; 296 297 dir.files![entry.name] = { 298 cid, 299 mimeType, 300 size: fileNode.blob.size, 301 }; 302 } else if (nodeType === 'directory' || nodeType === 'place.wisp.fs#directory') { 303 const dirNode = entry.node as WispDirectoryNew; 304 dir.dirs![entry.name] = convertDirectoryNewToOld(dirNode); 305 } 306 } 307 308 return dir; 309} 310 311/** 312 * Count files in a directory (recursive) 313 */ 314export function countFiles(dir: WispDirectory): number { 315 let count = 0; 316 if (dir.files) { 317 count += Object.keys(dir.files).length; 318 } 319 if (dir.dirs) { 320 for (const subDir of Object.values(dir.dirs)) { 321 count += countFiles(subDir); 322 } 323 } 324 return count; 325} 326 327/** 328 * Parse a place.wisp.subfs record 329 */ 330export function parsePlaceWispSubfs(record: unknown): PlaceWispSubfsRecord { 331 if (!validateRecordType(record, 'place.wisp.subfs')) { 332 throw new LexiconParseError('Invalid place.wisp.subfs record type', record); 333 } 334 335 const parsed = record as Record<string, unknown>; 336 const directory = parsed.directory; 337 338 if (!isValidDirectory(directory)) { 339 throw new LexiconParseError('Invalid directory structure in place.wisp.subfs', record); 340 } 341 342 return { 343 $type: 'place.wisp.subfs', 344 directory: directory as WispDirectory, 345 }; 346} 347 348/** 349 * Validate a directory structure recursively 350 */ 351export function isValidDirectory(dir: unknown): boolean { 352 if (!dir || typeof dir !== 'object') { 353 return false; 354 } 355 356 const d = dir as WispDirectory; 357 const { files, dirs } = d; 358 359 // If both are undefined, it's an empty directory (valid) 360 if (files === undefined && dirs === undefined) { 361 return true; 362 } 363 364 // Validate files if present 365 if (files !== undefined) { 366 if (typeof files !== 'object' || files === null) { 367 return false; 368 } 369 for (const fileEntry of Object.values(files)) { 370 if (!isValidFileEntry(fileEntry)) { 371 return false; 372 } 373 } 374 } 375 376 // Validate dirs if present 377 if (dirs !== undefined) { 378 if (typeof dirs !== 'object' || dirs === null) { 379 return false; 380 } 381 for (const subDir of Object.values(dirs)) { 382 if (!isValidDirectory(subDir)) { 383 return false; 384 } 385 } 386 } 387 388 return true; 389} 390 391/** 392 * Validate a file entry 393 */ 394export function isValidFileEntry(entry: unknown): boolean { 395 if (!entry || typeof entry !== 'object') { 396 return false; 397 } 398 399 const e = entry as WispFile; 400 if (typeof e.cid !== 'string' || e.cid.length === 0) { 401 return false; 402 } 403 404 if (e.mimeType !== undefined && typeof e.mimeType !== 'string') { 405 return false; 406 } 407 408 if (e.size !== undefined && typeof e.size !== 'number') { 409 return false; 410 } 411 412 return true; 413} 414 415/** 416 * Get an empty directory structure 417 */ 418export function createEmptyDirectory(): WispDirectory { 419 return {}; 420} 421 422/** 423 * Merge multiple directories into one 424 * Later directories override earlier ones for conflicting entries 425 */ 426export function mergeDirectories( 427 ...directories: (WispDirectory | null | undefined)[] 428): WispDirectory { 429 const result: WispDirectory = {}; 430 431 for (const dir of directories) { 432 if (!dir) continue; 433 434 // Merge files 435 if (dir.files) { 436 result.files = { ...result.files, ...dir.files }; 437 } 438 439 // Merge directories recursively 440 if (dir.dirs) { 441 result.dirs = result.dirs || {}; 442 for (const [name, subDir] of Object.entries(dir.dirs)) { 443 if (result.dirs[name]) { 444 // Recursively merge subdirectories 445 result.dirs[name] = mergeDirectories(result.dirs[name], subDir); 446 } else { 447 result.dirs[name] = subDir; 448 } 449 } 450 } 451 } 452 453 return result; 454} 455 456/** 457 * Normalize a file path 458 * - Remove leading/trailing slashes 459 * - Remove duplicate slashes 460 * - Handle parent directory references (..) and current directory (.) 461 */ 462export function normalizePath(path: string): string { 463 const normalized = path.trim(); 464 465 // Split into segments 466 const segments = normalized.split('/').filter(s => s !== '' && s !== '.'); 467 468 const result: string[] = []; 469 for (const segment of segments) { 470 if (segment === '..') { 471 // Go up one level if possible 472 result.pop(); 473 } else { 474 result.push(segment); 475 } 476 } 477 478 return result.join('/'); 479} 480 481/** 482 * Look up a file or directory by path 483 */ 484export function lookupPath(directory: WispDirectory, path: string): PathResolution { 485 const normalizedPath = normalizePath(path); 486 487 // Empty path or '/' refers to root directory 488 if (normalizedPath === '') { 489 return { 490 type: 'directory', 491 files: directory.files || {}, 492 dirs: Object.keys(directory.dirs || {}), 493 }; 494 } 495 496 const segments = normalizedPath.split('/'); 497 let currentDir: WispDirectory = directory; 498 499 // Debug 500 console.debug('lookupPath:', { path, normalizedPath, segments }); 501 502 // Traverse to the target directory 503 for (let i = 0; i < segments.length - 1; i++) { 504 const segment = segments[i]; 505 506 if (!currentDir.dirs || !currentDir.dirs[segment]) { 507 console.debug('Directory not found at segment:', segment); 508 return null; // Directory not found 509 } 510 511 currentDir = currentDir.dirs[segment]; 512 } 513 514 const lastSegment = segments[segments.length - 1]; 515 516 console.debug('Last segment:', lastSegment); 517 console.debug('Current dir files:', Object.keys(currentDir.files || {})); 518 console.debug('Current dir dirs:', Object.keys(currentDir.dirs || {})); 519 520 // Check if it's a file 521 if (currentDir.files && currentDir.files[lastSegment]) { 522 const file = currentDir.files[lastSegment]; 523 console.debug('File found:', file); 524 return { 525 cid: file.cid, 526 mimeType: file.mimeType || 'application/octet-stream', 527 }; 528 } 529 530 // Check if it's a directory 531 if (currentDir.dirs && currentDir.dirs[lastSegment]) { 532 const subDir = currentDir.dirs[lastSegment]; 533 return { 534 type: 'directory', 535 files: subDir.files || {}, 536 dirs: Object.keys(subDir.dirs || {}), 537 }; 538 } 539 540 console.debug('Not found'); 541 return null; // Not found 542} 543 544/** 545 * Get the MIME type for a file extension (fallback) 546 */ 547export function getMimeTypeFromExtension(filename: string): string { 548 const ext = filename.split('.').pop()?.toLowerCase() || ''; 549 550 const mimeTypes: Record<string, string> = { 551 html: 'text/html', 552 css: 'text/css', 553 js: 'text/javascript', 554 json: 'application/json', 555 xml: 'application/xml', 556 txt: 'text/plain', 557 md: 'text/markdown', 558 svg: 'image/svg+xml', 559 png: 'image/png', 560 jpg: 'image/jpeg', 561 jpeg: 'image/jpeg', 562 gif: 'image/gif', 563 webp: 'image/webp', 564 ico: 'image/x-icon', 565 bmp: 'image/bmp', 566 tiff: 'image/tiff', 567 webm: 'video/webm', 568 mp4: 'video/mp4', 569 mpeg: 'video/mpeg', 570 mp3: 'audio/mpeg', 571 wav: 'audio/wav', 572 ogg: 'audio/ogg', 573 pdf: 'application/pdf', 574 zip: 'application/zip', 575 gz: 'application/gzip', 576 wasm: 'application/wasm', 577 ttf: 'font/ttf', 578 otf: 'font/otf', 579 woff: 'font/woff', 580 woff2: 'font/woff2', 581 eot: 'application/vnd.ms-fontobject', 582 }; 583 584 return mimeTypes[ext] || 'application/octet-stream'; 585} 586 587/** 588 * Check if a path refers to an index file 589 */ 590export function isIndexPath(path: string): boolean { 591 const normalized = normalizePath(path); 592 return normalized === 'index.html' || normalized === 'index.htm'; 593} 594 595/** 596 * Get the path for a directory's index file 597 */ 598export function getIndexForPath(path: string): string { 599 const normalized = normalizePath(path); 600 if (normalized === '') { 601 return 'index.html'; 602 } 603 return `${normalized}/index.html`; 604}