An in-browser wisp.place site explorer
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}