Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)};`;
Expand Down Expand Up @@ -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

---
Expand Down Expand Up @@ -332,14 +339,15 @@ 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**:

- 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)

Expand Down
6 changes: 4 additions & 2 deletions docs/DOCS_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` output)
- ✅ Full SEO: title, description, Open Graph, Twitter Card, JSON-LD, sitemap
- ✅ 83 comprehensive tests
- ✅ TypeScript strict mode
- ✅ Accessible (WCAG compliant)

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` HTML via a HEAD-only existence check
- ✅ `cmd://` links render as NoJS-compatible `<form>` elements; regular links open in new tab
- ✅ Ls command (file listing, fetch errors, empty lists)

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#111827" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
Expand Down
54 changes: 54 additions & 0 deletions src/lib/commands-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,60 @@ describe('commands-utils', () => {
expect(mockFetch).toHaveBeenCalledWith('/social.md');
});

describe('image rendering', () => {
it('should render a .png as an <img> 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('<img');
expect(result.output).toContain('src="/profile.png"');
expect(result.output).toContain('alt="profile.png"');
});

it('should use a HEAD request for image existence check', async () => {
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('<img');
}
);

it('should match image extensions case-insensitively', async () => {
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('<img');
expect(result.output).toContain('src="/Photo.PNG"');
});

it('should not treat a non-image extension as an image', async () => {
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,
Expand Down
19 changes: 18 additions & 1 deletion src/lib/commands-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const config = terminalConfig as TerminalConfig;
export async function executeCommand(
cmd: string,
args: string[],
options?: { fetch?: (url: string) => Promise<Response> }
options?: { fetch?: (url: string, init?: RequestInit) => Promise<Response> }
): Promise<{ output: string; links?: Link[]; isGreeting?: boolean; isHtml?: boolean }> {
const command = config.commands[cmd.toLowerCase()];

Expand Down Expand Up @@ -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: `<img src="/${filename}" alt="${filename}" style="max-width:100%;max-height:300px;display:block;margin-top:0.5rem;" />`,
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
Expand Down
39 changes: 37 additions & 2 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';

let { children } = $props();
</script>

<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<link rel="icon" href="/profile.png" type="image/png" />

<!-- Primary -->
<title>Dariush Komeili – Computer Science Student</title>
<meta
name="description"
content="Computer Science Student at HHU. Based in Düsseldorf. Interested in XR. Monero fanatic. Privacy-conscious."
/>
<meta
name="keywords"
content="Dariush, Komeili, HHU, Informatik, Düsseldorf, Heinrich Heine, Computer Science, Student, Python, Backend, AI, Automation, Privacy"
/>
<meta name="author" content="Dariush Komeili" />
<link rel="canonical" href="https://dariush.dev" />

<!-- Open Graph -->
<meta property="og:title" content="Dariush Komeili – Computer Science Student" />
<meta
property="og:description"
content="Computer Science Student at HHU. Based in Düsseldorf. Interested in XR. Monero fanatic. Privacy-conscious."
/>
<meta property="og:image" content="https://dariush.dev/profile.png" />
<meta property="og:url" content="https://dariush.dev" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="dariush.dev" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Dariush Komeili – Computer Science Student" />
<meta
name="twitter:description"
content="Computer Science Student at HHU. Based in Düsseldorf. Interested in XR. Monero fanatic. Privacy-conscious."
/>
<meta name="twitter:image" content="https://dariush.dev/profile.png" />
</svelte:head>
{@render children()}
7 changes: 0 additions & 7 deletions src/routes/api/files/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Binary file added static/profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions static/robots.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# allow crawling everything by default
User-agent: *
Disallow:

Sitemap: https://dariush.dev/sitemap.xml
9 changes: 9 additions & 0 deletions static/sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://dariush.dev/</loc>
<lastmod>2026-02-18</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
6 changes: 5 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Comment thread
devDariush marked this conversation as resolved.
.map((entry) => entry.name)
.sort();
return `export const staticFiles = ${JSON.stringify(files)};`;
Expand Down