diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a19cc1c..e969d24 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,10 +36,16 @@ function staticFilesPlugin(): Plugin { }, load(id) { if (id === resolvedVirtualModuleId) { + const HIDDEN_FILES = new Set(['sitemap.xml', 'robots.txt']); const staticDir = join(process.cwd(), 'static'); const entries = readdirSync(staticDir, { withFileTypes: true }); const files = entries - .filter((entry) => entry.isFile() && !entry.name.startsWith('.')) + .filter( + (entry) => + entry.isFile() && + !entry.name.startsWith('.') && + !HIDDEN_FILES.has(entry.name) + ) .map((entry) => entry.name) .sort(); return `export const staticFiles = ${JSON.stringify(files)};`; @@ -87,6 +93,7 @@ export const GET: RequestHandler = async () => { - ✅ No runtime overhead (file list is static, bundled at build time) - ✅ Consistent behavior across environments - ✅ Automatically excludes hidden files (those starting with `.`) +- ✅ Excludes infrastructure files not shown to terminal users (`robots.txt`, `sitemap.xml`) via a `HIDDEN_FILES` set - ✅ Alphabetically sorted file list --- @@ -332,7 +339,7 @@ export const GET: RequestHandler = async () => { **Response Format**: ```json -["about.md", "contact.md", "docs.md", "robots.txt", "social.md"] +["about.md", "contact.md", "docs.md", "profile.png", "social.md"] ``` **Characteristics**: @@ -340,6 +347,7 @@ export const GET: RequestHandler = async () => { - Returns alphabetically sorted file list - Excludes hidden files (starting with `.`) - Excludes directories +- Excludes infrastructure files not intended for terminal users (`robots.txt`, `sitemap.xml`) - Uses virtual static files plugin (works on Cloudflare) - No authentication required (public files) diff --git a/docs/DOCS_INDEX.md b/docs/DOCS_INDEX.md index b4472e6..19ac1b3 100644 --- a/docs/DOCS_INDEX.md +++ b/docs/DOCS_INDEX.md @@ -156,7 +156,9 @@ npm run build # Build for production - ✅ Real-time persistence with Cloudflare KV - ✅ Markdown rendering with XSS protection - ✅ ANSI color support (16 colors) -- ✅ 72 comprehensive tests +- ✅ Image rendering via `cat` (HEAD-only fetch, inline `` output) +- ✅ Full SEO: title, description, Open Graph, Twitter Card, JSON-LD, sitemap +- ✅ 83 comprehensive tests - ✅ TypeScript strict mode - ✅ Accessible (WCAG compliant) @@ -189,7 +191,7 @@ npm run build # Build for production - **Persist action** (async history saving in JS mode) - **API endpoints** (/api/files, form actions) - **Storage architecture** (KV vs cookies, size limits, fallback logic) -- Testing approach (72 tests, Vitest + Playwright) +- Testing approach (83 tests, Vitest + Playwright) - Deployment process (Cloudflare Pages + KV setup) - File system operations (build-time file listing) - Security measures (sanitization, httpOnly cookies, CSRF protection) diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index aaa57c4..87260c3 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -34,6 +34,7 @@ Tests command execution logic for all commands: - ✅ Case insensitive command handling - ✅ Echo with special characters - ✅ Cat command (file reading, markdown parsing, error handling) +- ✅ Cat command: image files (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.avif`) rendered as `` HTML via a HEAD-only existence check - ✅ `cmd://` links render as NoJS-compatible `
` elements; regular links open in new tab - ✅ Ls command (file listing, fetch errors, empty lists) @@ -42,6 +43,9 @@ Tests command execution logic for all commands: - Empty arguments (echo with no args) - Special characters in arguments - File not found (cat command) +- Image 404 returns file-not-found message +- Case-insensitive image extension detection (e.g. `Photo.PNG`) +- HEAD request used for images (no body downloaded) - Server vs client execution paths - Fetch failures - Empty file lists diff --git a/src/app.html b/src/app.html index f273cc5..35d0ad3 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,7 @@ + %sveltekit.head% diff --git a/src/lib/commands-utils.test.ts b/src/lib/commands-utils.test.ts index d162e5f..429cff6 100644 --- a/src/lib/commands-utils.test.ts +++ b/src/lib/commands-utils.test.ts @@ -140,6 +140,60 @@ describe('commands-utils', () => { expect(mockFetch).toHaveBeenCalledWith('/social.md'); }); + describe('image rendering', () => { + it('should render a .png as an tag with isHtml', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + const result = await executeCommand('cat', ['profile.png'], { fetch: mockFetch }); + expect(result.isHtml).toBe(true); + expect(result.output).toContain(' { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + await executeCommand('cat', ['photo.jpg'], { fetch: mockFetch }); + expect(mockFetch).toHaveBeenCalledWith('/photo.jpg', { method: 'HEAD' }); + // Must NOT have issued a second GET + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should return file-not-found when HEAD returns 404 for image', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + const result = await executeCommand('cat', ['missing.png'], { fetch: mockFetch }); + expect(result.output).toContain('No such file or directory'); + expect(result.isHtml).toBeUndefined(); + }); + + it.each(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'])( + 'should detect %s as an image extension', + async (ext) => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + const result = await executeCommand('cat', [`image${ext}`], { fetch: mockFetch }); + expect(result.isHtml).toBe(true); + expect(result.output).toContain(' { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + const result = await executeCommand('cat', ['Photo.PNG'], { fetch: mockFetch }); + expect(result.isHtml).toBe(true); + expect(result.output).toContain(' { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('plain content') + }); + const result = await executeCommand('cat', ['file.txt'], { fetch: mockFetch }); + expect(result.output).toBe('plain content'); + expect(result.isHtml).toBeUndefined(); + }); + }); + it('should render cmd:// links as NoJS-compatible forms', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/lib/commands-utils.ts b/src/lib/commands-utils.ts index 95ac722..792bf3b 100644 --- a/src/lib/commands-utils.ts +++ b/src/lib/commands-utils.ts @@ -11,7 +11,7 @@ const config = terminalConfig as TerminalConfig; export async function executeCommand( cmd: string, args: string[], - options?: { fetch?: (url: string) => Promise } + options?: { fetch?: (url: string, init?: RequestInit) => Promise } ): Promise<{ output: string; links?: Link[]; isGreeting?: boolean; isHtml?: boolean }> { const command = config.commands[cmd.toLowerCase()]; @@ -59,12 +59,29 @@ export async function executeCommand( // Use provided fetch (ASSETS binding on Cloudflare, event.fetch locally) // or fall back to globalThis.fetch (client-side) const fetchFn = options?.fetch ?? globalThis.fetch; + + // For images, use a HEAD request to check existence without downloading the body + const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif']; + if (IMAGE_EXTENSIONS.some((ext) => filename.toLowerCase().endsWith(ext))) { + const head = await fetchFn(`/${filename}`, { method: 'HEAD' }); + if (!head.ok) { + return { + output: `cat: ${filename}: No such file or directory\n\nUse "ls" to see available files` + }; + } + return { + output: `${filename}`, + isHtml: true + }; + } + const response = await fetchFn(`/${filename}`); if (!response.ok) { return { output: `cat: ${filename}: No such file or directory\n\nUse "ls" to see available files` }; } + const content = await response.text(); // If it's a markdown file, parse it to HTML diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0d8eb03..c2baf4f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,9 +1,44 @@ - + + + + + Dariush Komeili – Computer Science Student + + + + + + + + + + + + + + + + + + + + {@render children()} diff --git a/src/routes/api/files/server.test.ts b/src/routes/api/files/server.test.ts index 530cadd..67ec900 100644 --- a/src/routes/api/files/server.test.ts +++ b/src/routes/api/files/server.test.ts @@ -78,11 +78,4 @@ describe('/api/files endpoint', () => { const mdFiles = data.filter((f: string) => f.endsWith('.md')); expect(mdFiles.length).toBeGreaterThan(0); }); - - it('should include robots.txt', async () => { - const response = await GET({} as never); - const data = await response.json(); - - expect(data).toContain('robots.txt'); - }); }); diff --git a/static/profile.png b/static/profile.png new file mode 100644 index 0000000..e9dad6c Binary files /dev/null and b/static/profile.png differ diff --git a/static/robots.txt b/static/robots.txt index b6dd667..736ff75 100644 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,3 +1,5 @@ # allow crawling everything by default User-agent: * Disallow: + +Sitemap: https://dariush.dev/sitemap.xml diff --git a/static/sitemap.xml b/static/sitemap.xml new file mode 100644 index 0000000..5933f62 --- /dev/null +++ b/static/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://dariush.dev/ + 2026-02-18 + monthly + 1.0 + + diff --git a/vite.config.ts b/vite.config.ts index bc6ae59..2f046f3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,9 +24,13 @@ function staticFilesPlugin(): Plugin { if (id === resolvedVirtualModuleId) { const staticDir = join(process.cwd(), 'static'); try { + const HIDDEN_FILES = new Set(['sitemap.xml', 'robots.txt']); const entries = readdirSync(staticDir, { withFileTypes: true }); const files = entries - .filter((entry) => entry.isFile() && !entry.name.startsWith('.')) + .filter( + (entry) => + entry.isFile() && !entry.name.startsWith('.') && !HIDDEN_FILES.has(entry.name) + ) .map((entry) => entry.name) .sort(); return `export const staticFiles = ${JSON.stringify(files)};`;