diff --git a/astro.config.mjs b/astro.config.mjs index eeedf47..73a3b64 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,4 +1,25 @@ import { defineConfig } from 'astro/config'; +import curlResponse from './src/curl-response.ts'; + +const curlPreview = { + name: 'curl-preview', + configureServer(server) { + server.middlewares.use(async (request, response, next) => { + const userAgent = request.headers['user-agent'] ?? ''; + const path = request.url?.split('?', 1)[0]; + + if (request.method !== 'GET' || path !== '/' || !/^curl(?:\/|$)/i.test(userAgent)) { + next(); + return; + } + + const terminalResponse = curlResponse(); + response.statusCode = terminalResponse.status; + terminalResponse.headers.forEach((value, name) => response.setHeader(name, value)); + response.end(await terminalResponse.text()); + }); + }, +}; export default defineConfig({ site: 'https://rummage.cc', @@ -7,4 +28,7 @@ export default defineConfig({ port: 4321, }, devToolbar: { enabled: false }, + vite: { + plugins: [curlPreview], + }, }); diff --git a/functions/_middleware.ts b/functions/_middleware.ts new file mode 100644 index 0000000..d6a5dff --- /dev/null +++ b/functions/_middleware.ts @@ -0,0 +1,22 @@ +import curlResponse from "../src/curl-response.ts"; + +interface PagesContext { + request: Request; + next(): Promise; +} + +export function onRequest(context: PagesContext): Response | Promise { + const { request } = context; + const userAgent = request.headers.get("user-agent") ?? ""; + const pathname = new URL(request.url).pathname; + + if ( + request.method === "GET" && + pathname === "/" && + /^curl(?:\/|$)/i.test(userAgent) + ) { + return curlResponse(); + } + + return context.next(); +} diff --git a/src/curl-response.ts b/src/curl-response.ts new file mode 100644 index 0000000..2367e2a --- /dev/null +++ b/src/curl-response.ts @@ -0,0 +1,162 @@ +import { practice } from "./data/practice.ts"; +import { projects, websites } from "./data/projects.ts"; +import { tools } from "./data/tools.ts"; +import { work } from "./data/work.ts"; + +const width = 86; +const esc = "\x1b"; +const color = { + reset: `${esc}[0m`, + bold: `${esc}[1m`, + purple: `${esc}[38;5;141m`, + blue: `${esc}[38;5;117m`, + amber: `${esc}[38;5;215m`, + green: `${esc}[38;5;114m`, + text: `${esc}[38;5;252m`, + muted: `${esc}[38;5;245m`, + border: `${esc}[38;5;240m`, +}; + +type DisplayLine = string | { text: string; style?: string }; + +function hyperlink(label: string, url: string): string { + return `${esc}]8;;${url}${esc}\\${color.blue}${esc}[4m${label}${esc}[24m${esc}]8;;${esc}\\`; +} + +function quietHyperlink(label: string, url: string): string { + return `${esc}]8;;${url}${esc}\\${label}${esc}]8;;${esc}\\`; +} + +function decorateLinks(text: string): string { + return text.replace( + /https?:\/\/[^\s]+|[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, + (value) => hyperlink(value, value.includes("@") ? `mailto:${value}` : value), + ); +} + +function wrap(text: string, lineWidth = width): string[] { + const words = text.split(/\s+/); + const lines: string[] = []; + let line = ""; + + for (const word of words) { + if (`${line} ${word}`.trim().length > lineWidth) { + lines.push(line); + line = word; + } else { + line = `${line} ${word}`.trim(); + } + } + + if (line) lines.push(line); + return lines; +} + +function section(title: string, lines: DisplayLine[]): string { + const label = ` ${title} `; + const top = `┌─${label}${"─".repeat(width - label.length - 1)}┐`; + const body = lines + .flatMap((entry) => { + const line = typeof entry === "string" ? entry : entry.text; + const style = typeof entry === "string" ? color.text : entry.style; + return (line ? wrap(line, width - 4) : [""]).map((text) => ({ + text, + style, + })); + }) + .map(({ text, style }) => { + const content = decorateLinks(text.padEnd(width - 2)); + return `${color.border}│${color.reset} ${style ?? ""}${content}${color.reset} ${color.border}│${color.reset}`; + }) + .join("\n"); + + const styledTop = top.replace(label, `${color.bold}${color.purple}${label}${color.reset}${color.border}`); + return `${color.border}${styledTop}${color.reset}\n${body}\n${color.border}└${"─".repeat(width)}┘${color.reset}`; +} + +const experience = work.flatMap((role, index) => [ + { text: role.title.toUpperCase(), style: `${color.bold}${color.purple}` }, + { text: `${role.company} · ${role.years}`, style: color.amber }, + { text: role.description, style: color.text }, + ...(index < work.length - 1 ? [""] : []), +]); + +const projectList = [...projects, ...websites].flatMap((project, index, all) => [ + { text: project.name.toUpperCase(), style: `${color.bold}${color.purple}` }, + { text: `${project.type} · ${project.url}`, style: color.amber }, + { text: project.description, style: color.text }, + ...(index < all.length - 1 ? [""] : []), +]); + +const competencies = practice.flatMap((area, index) => [ + { text: area.title.toUpperCase(), style: `${color.bold}${color.purple}` }, + { text: area.body, style: color.text }, + ...(index < practice.length - 1 ? [""] : []), +]); + +const proficiencyOrder = [ + "Advanced", + "Proficient", + "Working knowledge", + "Familiar", +] as const; + +const skills = proficiencyOrder.flatMap((level, index) => { + const names = tools + .filter((tool) => tool.proficiency === level) + .map((tool) => tool.name) + .join(" · "); + return [ + { text: level.toUpperCase(), style: `${color.bold}${color.purple}` }, + { text: names, style: color.text }, + ...(index < proficiencyOrder.length - 1 ? [""] : []), + ]; +}); + +const page = ` +${color.purple} ▄████▄ ▄▄▄▄▄▄▄▄ + ▄██▀▀██▄ ███▀▀▀███ ${color.bold}${color.text}OWEN RUMMAGE${color.reset} +${color.purple} ███ ███ ███▄▄▄██▀ ${color.amber}Systems engineer${color.reset} +${color.purple} ▀██▄▄██▀ ███ ▀██▄ ${color.muted}Nashville, Tennessee${color.reset} +${color.purple} ▀████▀ ███ ███ +${color.border} ──────────────────────── ${color.green}Linux · networks · cloud · automation${color.reset} + +${color.text} Systems engineer specializing in Linux infrastructure, network engineering, + cloud platforms, and automation. Based in Nashville, Tennessee.${color.reset} + + ${hyperlink("hello@rummage.cc", "mailto:hello@rummage.cc")} ${hyperlink("github.com/owenrummage", "https://github.com/owenrummage")} + ${hyperlink("linkedin.com/in/orummage", "https://linkedin.com/in/orummage")} ${hyperlink("rummage.cc", "https://rummage.cc")} + +${section("EXPERIENCE", experience)} + +${section("EDUCATION", [ + { text: "ASSOCIATE OF APPLIED SCIENCE IN COMPUTER INFORMATION TECHNOLOGY", style: `${color.bold}${color.purple}` }, + { text: "Nashville State Community College · 2023 - 2026", style: color.amber }, + { text: "Concentration in Systems Administration and Management. Graduated May 2026.", style: color.text }, +])} + +${section("PROJECTS", projectList)} + +${section("CORE COMPETENCIES", competencies)} + +${section("TECHNICAL SKILLS", skills)} + +${section("APPROACH", [ + { text: "I build systems that can be understood and maintained by someone other than me.", style: `${color.bold}${color.green}` }, +])} + +${color.muted} $${color.reset} ${color.text}curl rummage.cc${color.reset} ${color.muted}Get this page${color.reset} +${color.muted} $${color.reset} ${color.text}open ${hyperlink("https://rummage.cc", "https://rummage.cc")}${color.reset} ${color.muted}Visit the web version${color.reset} +\n${color.muted} Inspired by Dave Eddy's ${quietHyperlink("ysap.sh", "https://ysap.sh")}.${color.reset} +${color.reset}`; + +export default function () { + return new Response(page, { + headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + "Content-Type": "text/plain; charset=utf-8", + Vary: "User-Agent", + "X-Content-Type-Options": "nosniff", + }, + }); +}