diff --git a/README.md b/README.md index 22b4468..6b96fa7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ Terminal-style personal website with command-driven navigation. +## Terminal Virtual Filesystem + +The terminal now exposes a read-only portfolio filesystem generated from the +same JSON data that powers the site: + +```bash +pwd # print the current virtual directory +ls # list generated files and folders +ls -la projects # long listing, including hidden files with -a +cd projects # update the prompt path +cat link-converter.txt # read generated project details +ls --commands # show the command catalog +``` + +The filesystem starts at `/home/guest` and includes generated `projects/`, +`repos/`, `skills/`, `experience/`, and `blog/` directories. + ## Tech Stack - Astro diff --git a/src/lib/terminal/index.js b/src/lib/terminal/index.js index 8501964..657a59a 100644 --- a/src/lib/terminal/index.js +++ b/src/lib/terminal/index.js @@ -3,6 +3,7 @@ export const COMMAND_LIST = [ "banner", "blog", "calc", + "cat", "clear", "contact", "converter", @@ -49,6 +50,379 @@ export const COMMAND_LIST = [ "exit", ]; +export const VIRTUAL_ROOT_PATH = "/"; +export const VIRTUAL_HOME_PATH = "/home/guest"; + +function createDir(children = {}) { + return { type: "dir", children }; +} + +function createFile(content) { + return { type: "file", content: String(content || "") }; +} + +function safeVirtualName(name, fallback = "item") { + const normalized = String(name || fallback) + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +function uniqueVirtualFileName(base, usedNames, extension = ".txt") { + const safeBase = safeVirtualName(base); + let candidate = `${safeBase}${extension}`; + let index = 2; + while (usedNames.has(candidate)) { + candidate = `${safeBase}-${index}${extension}`; + index++; + } + usedNames.add(candidate); + return candidate; +} + +function formatProjectFile(project) { + const lines = [ + project.name || "Untitled project", + "=".repeat(String(project.name || "Untitled project").length), + "", + ]; + if (project.description) lines.push(`Description: ${project.description}`); + if (project.language) lines.push(`Language: ${project.language}`); + if (project.url) lines.push(`URL: ${project.url}`); + if (project.repo && project.repo !== project.url) { + lines.push(`Source: ${project.repo}`); + } + return lines.join("\n"); +} + +function formatSkillFile(category) { + const lines = [category.name || "Skills", "=".repeat(20), ""]; + (category.skills || []).forEach((skill) => { + const level = Number(skill.level); + const suffix = Number.isFinite(level) ? ` (${level}%)` : ""; + lines.push(`- ${skill.name || "Unnamed skill"}${suffix}`); + if (skill.note) lines.push(` ${skill.note}`); + }); + return lines.join("\n"); +} + +function formatExperienceFile(entry) { + const lines = [ + entry.title || "Experience", + "=".repeat(String(entry.title || "Experience").length), + "", + ]; + if (entry.org) lines.push(`Organization: ${entry.org}`); + if (entry.period) lines.push(`Period: ${entry.period}`); + if (entry.description) lines.push("", entry.description); + if (Array.isArray(entry.tags) && entry.tags.length > 0) { + lines.push("", `Tags: ${entry.tags.join(", ")}`); + } + return lines.join("\n"); +} + +function formatBlogFile(post) { + const lines = [ + post.title || post.slug || "Untitled post", + "=".repeat(String(post.title || post.slug || "Untitled post").length), + "", + ]; + if (post.date) lines.push(`Date: ${post.date}`); + if (post.summary) lines.push(`Summary: ${post.summary}`); + if (post.content) lines.push("", post.content); + return lines.join("\n"); +} + +export function buildVirtualFileSystem(data = {}) { + const projects = Array.isArray(data.projects) ? data.projects : []; + const skills = Array.isArray(data.skills) ? data.skills : []; + const experience = Array.isArray(data.experience) ? data.experience : []; + const posts = Array.isArray(data.posts) ? data.posts : []; + const repositories = buildRepositoryCatalog(data.projectGroups); + + const projectFiles = {}; + const projectNames = new Set(); + projects.forEach((project) => { + if (!project || !project.name) return; + projectFiles[uniqueVirtualFileName(project.name, projectNames)] = + createFile(formatProjectFile(project)); + }); + + const repoFiles = {}; + const repoNames = new Set(); + repositories.forEach((repo) => { + repoFiles[uniqueVirtualFileName(repo.repoName || repo.name, repoNames)] = + createFile(formatProjectFile(repo)); + }); + + const skillFiles = {}; + const skillNames = new Set(); + skills.forEach((category) => { + if (!category || !category.name) return; + skillFiles[uniqueVirtualFileName(category.name, skillNames)] = createFile( + formatSkillFile(category), + ); + }); + + const experienceFiles = {}; + const experienceNames = new Set(); + experience.forEach((entry) => { + if (!entry || !entry.title) return; + experienceFiles[uniqueVirtualFileName(entry.title, experienceNames)] = + createFile(formatExperienceFile(entry)); + }); + + const blogFiles = {}; + const blogNames = new Set(); + posts.forEach((post) => { + if (!post || (!post.title && !post.slug)) return; + blogFiles[uniqueVirtualFileName(post.slug || post.title, blogNames)] = + createFile(formatBlogFile(post)); + }); + + const homeChildren = { + ".profile": createFile( + [ + "USER=guest", + "SHELL=/bin/jjalangtry", + "HOME=/home/guest", + "EDITOR=terminal", + ].join("\n"), + ), + "README.md": createFile( + [ + "# Jakob Langtry terminal home", + "", + "This is a read-only virtual filesystem generated from the portfolio data.", + "Try: ls, cd projects, cat README.md, cat contact.txt", + ].join("\n"), + ), + "about.txt": createFile( + [ + "Jakob Langtry - Software Engineering Student at Rochester Institute of Technology.", + "Passionate about web development, backend systems, and creating useful applications.", + "Currently seeking opportunities in software engineering.", + ].join("\n"), + ), + "contact.txt": createFile( + [ + "Email: jjalangtry@gmail.com", + "GitHub: https://github.com/JJALANGTRY", + `Resume: ${data.siteConfig?.resumeUrl || "https://resume.jjalangtry.com"}`, + "Website: https://jjalangtry.com", + ].join("\n"), + ), + projects: createDir({ + "README.txt": createFile( + `Portfolio projects generated from public/data/projects.json.\n${projects.length} project(s) available.`, + ), + ...projectFiles, + }), + repos: createDir({ + "README.txt": createFile( + `Repository catalog generated from public/data/projects.json.\n${repositories.length} source entr${repositories.length === 1 ? "y" : "ies"} available.`, + ), + ...repoFiles, + }), + skills: createDir({ + "README.txt": createFile( + `Skill categories generated from public/data/skills.json.\n${skills.length} categor${skills.length === 1 ? "y" : "ies"} available.`, + ), + ...skillFiles, + }), + experience: createDir({ + "README.txt": createFile( + `Experience entries generated from public/data/experience.json.\n${experience.length} entr${experience.length === 1 ? "y" : "ies"} available.`, + ), + ...experienceFiles, + }), + blog: createDir({ + "README.txt": createFile( + posts.length + ? `Blog posts generated from public/data/posts.json.\n${posts.length} post(s) available.` + : "No built-in blog posts are currently published.", + ), + ...blogFiles, + }), + }; + + return createDir({ + home: createDir({ + guest: createDir(homeChildren), + }), + }); +} + +export function resolveVirtualPath( + currentPath = VIRTUAL_HOME_PATH, + target = "", +) { + const rawTarget = String(target || "").trim(); + let path; + + if (!rawTarget || rawTarget === "~") { + path = VIRTUAL_HOME_PATH; + } else if (rawTarget.startsWith("~/")) { + path = `${VIRTUAL_HOME_PATH}/${rawTarget.slice(2)}`; + } else if (rawTarget.startsWith("/")) { + path = rawTarget; + } else { + path = `${currentPath || VIRTUAL_HOME_PATH}/${rawTarget}`; + } + + const parts = []; + path.split("/").forEach((part) => { + if (!part || part === ".") return; + if (part === "..") { + parts.pop(); + } else { + parts.push(part); + } + }); + + return parts.length > 0 ? `/${parts.join("/")}` : VIRTUAL_ROOT_PATH; +} + +export function formatVirtualPromptPath(path = VIRTUAL_HOME_PATH) { + const normalized = resolveVirtualPath(VIRTUAL_HOME_PATH, path); + if (normalized === VIRTUAL_HOME_PATH) return "~"; + if (normalized.startsWith(`${VIRTUAL_HOME_PATH}/`)) { + return `~/${normalized.slice(VIRTUAL_HOME_PATH.length + 1)}`; + } + return normalized; +} + +export function getVirtualEntry(fileSystem, path = VIRTUAL_HOME_PATH) { + const normalized = resolveVirtualPath(VIRTUAL_HOME_PATH, path); + if (normalized === VIRTUAL_ROOT_PATH) return fileSystem; + const parts = normalized.split("/").filter(Boolean); + let entry = fileSystem; + for (const part of parts) { + if (!entry || entry.type !== "dir" || !entry.children?.[part]) { + return null; + } + entry = entry.children[part]; + } + return entry || null; +} + +export function parseLsArgs(argsString = "") { + const result = { + all: false, + long: false, + commands: false, + path: "", + error: "", + }; + const tokens = String(argsString || "") + .trim() + .split(/\s+/) + .filter(Boolean); + + for (const token of tokens) { + if (token === "--commands") { + result.commands = true; + } else if (token.startsWith("-") && token.length > 1) { + for (const flag of token.slice(1)) { + if (flag === "a") result.all = true; + else if (flag === "l") result.long = true; + else { + result.error = `ls: invalid option -- '${flag}'`; + return result; + } + } + } else if (!result.path) { + result.path = token; + } else { + result.error = `ls: cannot access '${token}': too many path arguments`; + return result; + } + } + + return result; +} + +export function formatVirtualDirectoryListing( + fileSystem, + path = VIRTUAL_HOME_PATH, + options = {}, +) { + const entry = getVirtualEntry(fileSystem, path); + if (!entry) { + return { error: `ls: cannot access '${path}': No such file or directory` }; + } + if (entry.type === "file") { + return { output: path.split("/").filter(Boolean).pop() || path }; + } + + const children = Object.entries(entry.children || {}) + .filter(([name]) => options.all || !name.startsWith(".")) + .sort(([aName, aEntry], [bName, bEntry]) => { + if (aEntry.type !== bEntry.type) return aEntry.type === "dir" ? -1 : 1; + return aName.localeCompare(bName); + }); + + if (options.long) { + const lines = children.map(([name, child]) => { + const mode = child.type === "dir" ? "dr-xr-xr-x" : "-r--r--r--"; + const size = + child.type === "dir" + ? "-" + : String(child.content.length).padStart(5, " "); + return `${mode} 1 guest guest ${size} ${name}${child.type === "dir" ? "/" : ""}`; + }); + return { output: lines.join("\n") }; + } + + return { + output: children + .map(([name, child]) => `${name}${child.type === "dir" ? "/" : ""}`) + .join(" "), + }; +} + +export function readVirtualFile(fileSystem, path = VIRTUAL_HOME_PATH) { + const entry = getVirtualEntry(fileSystem, path); + if (!entry) { + return { error: `cat: ${path}: No such file or directory` }; + } + if (entry.type === "dir") { + return { error: `cat: ${path}: Is a directory` }; + } + return { content: entry.content }; +} + +export function completeVirtualPath( + fileSystem, + currentPath = VIRTUAL_HOME_PATH, + input = "", +) { + const raw = String(input || ""); + const slashIndex = raw.lastIndexOf("/"); + const prefix = slashIndex >= 0 ? raw.slice(0, slashIndex + 1) : ""; + const partial = slashIndex >= 0 ? raw.slice(slashIndex + 1) : raw; + const dirTarget = prefix || "."; + const dirPath = resolveVirtualPath(currentPath, dirTarget); + const entry = getVirtualEntry(fileSystem, dirPath); + if (!entry || entry.type !== "dir") return []; + + const partialLower = partial.toLowerCase(); + return Object.entries(entry.children || {}) + .filter(([name]) => { + if (name.startsWith(".") && !partial.startsWith(".")) return false; + return name.toLowerCase().startsWith(partialLower); + }) + .sort(([aName, aEntry], [bName, bEntry]) => { + if (aEntry.type !== bEntry.type) return aEntry.type === "dir" ? -1 : 1; + return aName.localeCompare(bName); + }) + .map( + ([name, child]) => `${prefix}${name}${child.type === "dir" ? "/" : ""}`, + ); +} + export function buildProjectsListOutput(projects) { if (!Array.isArray(projects) || projects.length === 0) { return "No projects are configured yet."; diff --git a/src/scripts/terminal.js b/src/scripts/terminal.js index cae331d..ce0ede5 100644 --- a/src/scripts/terminal.js +++ b/src/scripts/terminal.js @@ -1,10 +1,20 @@ import { + COMMAND_LIST, + VIRTUAL_HOME_PATH, + buildVirtualFileSystem, + completeVirtualPath, + formatVirtualDirectoryListing, + formatVirtualPromptPath, formatUptime, formatHistoryOutput, + getVirtualEntry, grepFilter, + parseLsArgs, parseGrepArgs, expandGlob, parsePipeline, + readVirtualFile, + resolveVirtualPath, buildNeofetchOutput, formatManPage, buildSkillsOutput, @@ -204,60 +214,10 @@ function loadTerminalDataFromDOM() { } const terminalData = loadTerminalDataFromDOM(); - -// Load terminal logic functions. In a real module setup we would import, but -// for a vanilla script we need to ensure the logic exists here if not bundled. -// To keep things simple, we keep the COMMAND_LIST local but synced. -const commandList = [ - "alias", - "banner", - "blog", - "calc", - "clear", - "contact", - "converter", - "countdown", - "curl", - "qr", - "date", - "echo", - "email", - "experience", - "flip", - "fortune", - "github", - "grep", - "help", - "history", - "hostname", - "ls", - "man", - "matrix", - "neofetch", - "projects", - "pwd", - "repos", - "repo", - "resume", - "skills", - "snake", - "stats", - "theme", - "uptime", - "weather", - "which", - "whoami", - "write", - "edit", - "export", - "rss", - "login", - "logout", - "sudo", - "cd", - "close", - "exit", -]; +const commandList = COMMAND_LIST; +const virtualFileSystem = buildVirtualFileSystem(terminalData); +let currentDirectory = VIRTUAL_HOME_PATH; +let previousDirectory = VIRTUAL_HOME_PATH; function applyTheme(theme) { if (theme === "light") { @@ -396,8 +356,7 @@ function createInputLine() { inputLine.className = "terminal-input-line"; const prompt = document.createElement("span"); prompt.className = "prompt"; - const user = isAdmin ? "admin" : "guest"; - prompt.textContent = `${user}@jjalangtry.com:~$ `; + prompt.textContent = getPromptPrefix(); const inputWrapper = document.createElement("div"); inputWrapper.className = "input-wrapper"; @@ -754,6 +713,70 @@ function buildCommandsListOutput() { return output; } +function displayVirtualDirectory(argsString = "") { + const parsed = parseLsArgs(argsString); + if (parsed.error) { + appendOutput(parsed.error, "error-text"); + return; + } + if (parsed.commands) { + appendOutput(buildCommandsListOutput(), "info-text"); + return; + } + + const targetPath = resolveVirtualPath(currentDirectory, parsed.path || "."); + const listing = formatVirtualDirectoryListing(virtualFileSystem, targetPath, { + all: parsed.all, + long: parsed.long, + }); + if (listing.error) { + appendOutput(listing.error, "error-text"); + return; + } + appendOutput(listing.output || "(empty directory)", "info-text"); +} + +function changeVirtualDirectory(argsString = "") { + const target = argsString.trim(); + const nextPath = + target === "-" + ? previousDirectory + : resolveVirtualPath(currentDirectory, target || "~"); + const entry = getVirtualEntry(virtualFileSystem, nextPath); + + if (!entry) { + appendOutput(`cd: no such file or directory: ${target}`, "error-text"); + return; + } + if (entry.type !== "dir") { + appendOutput(`cd: not a directory: ${target}`, "error-text"); + return; + } + + const oldPath = currentDirectory; + currentDirectory = nextPath; + previousDirectory = oldPath; + updatePromptUser(); + if (target === "-") { + appendOutput(currentDirectory, "info-text"); + } +} + +function displayVirtualFile(argsString = "") { + const target = argsString.trim(); + if (!target) { + appendOutput("Usage: cat [file]", "info-text"); + return; + } + const targetPath = resolveVirtualPath(currentDirectory, target); + const result = readVirtualFile(virtualFileSystem, targetPath); + if (result.error) { + appendOutput(result.error, "error-text"); + return; + } + appendOutput(result.content, "info-text"); +} + function buildProjectsListOutput() { const projects = terminalData.projects || []; if (!projects.length) { @@ -813,23 +836,24 @@ function executeCommand(command, options = {}) { banner show banner calc math expressions whoami about jakob converter link converter experience work & education countdown visual countdown - skills skill proficiency curl HTTP simulation - contact contact info date current date/time - email email jakob echo print text - github github profile flip upside-down text - repo repo explorer repos repo summary - resume view resume grep regex search/pipe - blog read blog posts fortune random quote - projects projects pane matrix digital rain + skills skill proficiency cat read virtual files + contact contact info curl HTTP simulation + email email jakob date current date/time + github github profile echo print text + repo repo explorer flip upside-down text + resume view resume repos repo summary + blog read blog posts grep regex search/pipe + projects projects pane fortune random quote + close close pane matrix digital rain qr QR code generator - close close pane weather weather forecast + weather weather forecast snake play snake SYSTEM AUTH & CONTENT (login required) ──────────────────────────────── ──────────────────────────────── help this screen login authenticate man command manual logout end session - ls list commands write create blog post + ls list files write create blog post history command history edit edit site content clear clear terminal export export posts theme toggle dark/light @@ -845,6 +869,7 @@ function executeCommand(command, options = {}) { sudo sudo mode ────────────────────────────────────────────────────────────────────────── + FILES pwd · ls · cd projects · cat contact.txt PIPES help | grep [term] · MAN man [command] · [cmd] --help ──────────────────────────────────────────────────────────────────────────`; @@ -852,7 +877,7 @@ function executeCommand(command, options = {}) { appendOutput(helpText, "info-text"); break; case "ls": - appendOutput(buildCommandsListOutput(), "info-text"); + displayVirtualDirectory(); break; case "clear": // Save the command history @@ -904,7 +929,7 @@ Currently seeking opportunities in software engineering.`, displayContact(); break; case "pwd": - appendOutput("/home/guest", "info-text"); + appendOutput(currentDirectory, "info-text"); break; case "hostname": appendOutput("jjalangtry.com", "info-text"); @@ -912,6 +937,9 @@ Currently seeking opportunities in software engineering.`, case "alias": appendOutput("alias exit='close'", "info-text"); break; + case "cat": + appendOutput("Usage: cat [file]\nExample: cat README.md", "info-text"); + break; case "skills": displaySkills(); break; @@ -1097,12 +1125,13 @@ Currently seeking opportunities in software engineering.`, normalizedCommand.startsWith("cd ") || normalizedCommand === "cd" ) { - const dir = command.substring(3).trim(); - if (!dir || dir === "~") { - appendOutput("You are already in your home directory.", "info-text"); - } else { - appendOutput(`cd: no such file or directory: ${dir}`, "error-text"); - } + changeVirtualDirectory(command.substring(2).trim()); + break; + } else if (normalizedCommand.startsWith("ls ")) { + displayVirtualDirectory(command.substring(3).trim()); + break; + } else if (normalizedCommand.startsWith("cat ")) { + displayVirtualFile(command.substring(4).trim()); break; } else if (normalizedCommand.startsWith("curl ")) { const args = parseCurlCommand(command.substring(5).trim()); @@ -2938,6 +2967,24 @@ function getHelpDetails() { notes: "Supports +, -, *, /, ^ (power), % (modulo), sqrt, abs, sin, cos, tan, log (base 10), ln (natural). Constants: pi, e.", }, + cat: { + desc: "Read files from the read-only virtual portfolio filesystem.", + usage: "cat [file]", + examples: [ + "cat README.md", + "cat contact.txt", + "cat projects/link-converter.txt", + ], + notes: + "Paths are resolved from the current directory. Use ls and cd to discover generated portfolio files.", + }, + cd: { + desc: "Change the current directory in the virtual portfolio filesystem.", + usage: "cd [path]", + examples: ["cd projects", "cd ../skills", "cd ~", "cd -"], + notes: + "The filesystem is read-only and generated from portfolio data. The prompt updates to show the current path.", + }, countdown: { desc: "Start a visual countdown timer with large ASCII digits.", usage: "countdown [seconds]", @@ -3060,11 +3107,11 @@ function getHelpDetails() { "History is persisted in localStorage (up to 50 commands). Use 'history clear' to reset.", }, ls: { - desc: "List available terminal commands.", - usage: "ls", - examples: ["ls"], + desc: "List files in the read-only virtual portfolio filesystem.", + usage: "ls [-la] [path]", + examples: ["ls", "ls -la", "ls projects", "ls --commands"], notes: - "This terminal-style ls command lists supported commands rather than filesystem entries.", + "Directory entries are generated from site data. Use 'ls --commands' for the legacy command catalog.", }, man: { desc: "Display the manual page for a command.", @@ -3094,6 +3141,13 @@ function getHelpDetails() { notes: "Click any project to open it. Type 'close' or press Ctrl+B then q to dismiss the pane.", }, + pwd: { + desc: "Print the current virtual working directory.", + usage: "pwd", + examples: ["pwd"], + notes: + "The terminal starts at /home/guest. Use cd to move around the generated portfolio filesystem.", + }, repos: { desc: "Display GitHub repositories and contributions in a terminal-style ASCII view.", usage: "repos", @@ -3773,7 +3827,7 @@ function setPromptText(text) { function getPromptPrefix() { const user = isAdmin ? "admin" : "guest"; - return `${user}@jjalangtry.com:~$ `; + return `${user}@jjalangtry.com:${formatVirtualPromptPath(currentDirectory)}$ `; } function updatePromptUser() { @@ -4034,7 +4088,7 @@ function executePipeline(input) { // Echo the full pipeline const echoLine = document.createElement("div"); - echoLine.textContent = `guest@jjalangtry.com:~$ ${input}`; + echoLine.textContent = `${getPromptPrefix()}${input}`; cliOutput.insertBefore(echoLine, inputLine); const firstCmd = filtered[0].trim().toLowerCase(); @@ -4219,7 +4273,7 @@ function initCLI() { e.preventDefault(); const currentText = e.target.value; const commandLine = document.createElement("div"); - commandLine.textContent = `guest@jjalangtry.com:~$ ${currentText}^C`; + commandLine.textContent = `${getPromptPrefix()}${currentText}^C`; cliOutput.insertBefore(commandLine, inputLine); e.target.value = ""; @@ -4257,6 +4311,7 @@ function initCLI() { if (spaceIdx > 0) { const baseCmd = currentLower.substring(0, spaceIdx); const arg = currentLower.substring(spaceIdx + 1); + const rawArg = currentText.substring(spaceIdx + 1); let completions = []; if (baseCmd === "blog") { completions = getAllPosts() @@ -4275,12 +4330,16 @@ function initCLI() { completions = (terminalData.skills || []) .map((c) => c.name.toLowerCase()) .filter((n) => n.startsWith(catArg)); + } else if (["cd", "cat", "ls"].includes(baseCmd)) { + completions = rawArg.startsWith("-") + ? [] + : completeVirtualPath(virtualFileSystem, currentDirectory, rawArg); } if (completions.length === 1) { e.target.value = `${baseCmd} ${completions[0]}`; } else if (completions.length > 1) { const commandLine = document.createElement("div"); - commandLine.textContent = `guest@jjalangtry.com:~$ ${currentText}`; + commandLine.textContent = `${getPromptPrefix()}${currentText}`; cliOutput.insertBefore(commandLine, inputLine); appendOutput(completions.join(" "), "info-text"); cliOutput.scrollTop = cliOutput.scrollHeight; @@ -4295,7 +4354,7 @@ function initCLI() { e.target.value = matches[0] + " "; } else if (matches.length > 1) { const commandLine = document.createElement("div"); - commandLine.textContent = `guest@jjalangtry.com:~$ ${currentText}`; + commandLine.textContent = `${getPromptPrefix()}${currentText}`; cliOutput.insertBefore(commandLine, inputLine); appendOutput(matches.join(" "), "info-text"); cliOutput.scrollTop = cliOutput.scrollHeight; diff --git a/tests/unit/terminal.test.js b/tests/unit/terminal.test.js index d3bce00..fcfb5ba 100644 --- a/tests/unit/terminal.test.js +++ b/tests/unit/terminal.test.js @@ -1,9 +1,15 @@ import { describe, it, expect } from "vitest"; import { COMMAND_LIST, + VIRTUAL_HOME_PATH, + buildVirtualFileSystem, buildProjectsListOutput, + completeVirtualPath, findProjectByCommand, + formatVirtualDirectoryListing, + formatVirtualPromptPath, celsiusToFahrenheit, + getVirtualEntry, getOutputA11yAttrs, normalizeThemeCommand, autocompleteCommand, @@ -13,6 +19,7 @@ import { formatHistoryOutput, grepFilter, parseGrepArgs, + parseLsArgs, globToRegex, expandGlob, parsePipeline, @@ -39,11 +46,14 @@ import { flipText, safeCalc, renderBigTime, + readVirtualFile, + resolveVirtualPath, } from "../../src/lib/terminal/index.js"; describe("terminal helpers", () => { it("includes core command surface", () => { expect(COMMAND_LIST).toContain("help"); + expect(COMMAND_LIST).toContain("cat"); expect(COMMAND_LIST).toContain("projects"); expect(COMMAND_LIST).toContain("weather"); expect(COMMAND_LIST).toContain("sudo"); @@ -74,6 +84,157 @@ describe("terminal helpers", () => { expect(COMMAND_LIST).toContain("matrix"); }); + it("builds a read-only virtual filesystem from portfolio data", () => { + const fs = buildVirtualFileSystem({ + siteConfig: { resumeUrl: "https://resume.example.com" }, + projects: [ + { + name: "Link Converter", + url: "https://convert.example.com", + repo: "https://github.com/jjalangtry/link-converter", + description: "Convert links", + language: "TypeScript", + }, + ], + projectGroups: { + featured: [ + { + name: "Link Converter", + url: "https://convert.example.com", + repo: "https://github.com/jjalangtry/link-converter", + }, + ], + contributions: [], + github: [], + }, + skills: [ + { + name: "Languages", + skills: [{ name: "C", level: 90, note: "systems work" }], + }, + ], + experience: [ + { + title: "Software Engineer", + org: "Example Co", + period: "2026", + description: "Built useful things.", + tags: ["Work"], + }, + ], + posts: [ + { + slug: "hello-terminal", + title: "Hello Terminal", + date: "2026-06-07", + summary: "A test post", + content: "Post body", + }, + ], + }); + + expect(getVirtualEntry(fs, VIRTUAL_HOME_PATH)?.type).toBe("dir"); + expect(getVirtualEntry(fs, "/home/guest/projects")?.type).toBe("dir"); + expect( + readVirtualFile(fs, "/home/guest/projects/link-converter.txt").content, + ).toContain("Convert links"); + expect(readVirtualFile(fs, "~/contact.txt").content).toContain( + "https://resume.example.com", + ); + expect(readVirtualFile(fs, "~/skills/languages.txt").content).toContain( + "systems work", + ); + expect( + readVirtualFile(fs, "~/experience/software-engineer.txt").content, + ).toContain("Example Co"); + expect(readVirtualFile(fs, "~/blog/hello-terminal.txt").content).toContain( + "Post body", + ); + }); + + it("resolves virtual paths and formats prompt paths", () => { + expect(resolveVirtualPath(VIRTUAL_HOME_PATH, "projects")).toBe( + "/home/guest/projects", + ); + expect(resolveVirtualPath("/home/guest/projects", "../skills/./")).toBe( + "/home/guest/skills", + ); + expect(resolveVirtualPath("/home/guest/projects", "~/blog")).toBe( + "/home/guest/blog", + ); + expect(resolveVirtualPath("/home/guest", "../../")).toBe("/"); + expect(formatVirtualPromptPath("/home/guest")).toBe("~"); + expect(formatVirtualPromptPath("/home/guest/projects")).toBe("~/projects"); + expect(formatVirtualPromptPath("/home")).toBe("/home"); + }); + + it("parses ls arguments and formats virtual directory listings", () => { + const fs = buildVirtualFileSystem({ + projects: [{ name: "Alpha App", url: "https://alpha.example.com" }], + projectGroups: { featured: [], contributions: [], github: [] }, + }); + + expect(parseLsArgs("-la projects")).toMatchObject({ + all: true, + long: true, + path: "projects", + }); + expect(parseLsArgs("--commands")).toMatchObject({ commands: true }); + expect(parseLsArgs("-z").error).toBe("ls: invalid option -- 'z'"); + expect(parseLsArgs("one two").error).toContain("too many path arguments"); + + const home = formatVirtualDirectoryListing(fs, VIRTUAL_HOME_PATH); + expect(home.output).toContain("projects/"); + expect(home.output).not.toContain(".profile"); + + const all = formatVirtualDirectoryListing(fs, VIRTUAL_HOME_PATH, { + all: true, + }); + expect(all.output).toContain(".profile"); + + const long = formatVirtualDirectoryListing(fs, "/home/guest/projects", { + long: true, + }); + expect(long.output).toContain("alpha-app.txt"); + expect(long.output).toContain("-r--r--r--"); + expect( + formatVirtualDirectoryListing(fs, "/home/guest/contact.txt").output, + ).toBe("contact.txt"); + + expect(formatVirtualDirectoryListing(fs, "/missing").error).toContain( + "No such file or directory", + ); + }); + + it("reads virtual files and completes virtual paths", () => { + const fs = buildVirtualFileSystem({ + projects: [{ name: "Alpha App", url: "https://alpha.example.com" }], + projectGroups: { featured: [], contributions: [], github: [] }, + }); + + expect(readVirtualFile(fs, "/home/guest").error).toContain( + "Is a directory", + ); + expect(readVirtualFile(fs, "/home/guest/nope.txt").error).toContain( + "No such file", + ); + expect(completeVirtualPath(fs, VIRTUAL_HOME_PATH, "pro")).toEqual([ + "projects/", + ]); + expect(completeVirtualPath(fs, VIRTUAL_HOME_PATH, ".")).toContain( + ".profile", + ); + const homeCompletions = completeVirtualPath(fs, VIRTUAL_HOME_PATH, ""); + expect(homeCompletions.indexOf("projects/")).toBeLessThan( + homeCompletions.indexOf("README.md"), + ); + expect(homeCompletions).toContain("about.txt"); + expect(completeVirtualPath(fs, "/home/guest", "projects/al")).toEqual([ + "projects/alpha-app.txt", + ]); + expect(completeVirtualPath(fs, "/missing", "")).toEqual([]); + }); + it("builds repos output with project groups", () => { const projectGroups = { featured: [