Rewrite website as a Hyprland desktop
This commit is contained in:
1 parent
7c52779cf6
commit
3065ad6092
197 files changed
+5609
-17779
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { profile } from '#lib/content';
|
||||
import { allSitePages } from '#lib/site/pages';
|
||||
import { blankLine, errorLine, line, span } from '../output';
|
||||
import { infoCommands } from './info';
|
||||
import { systemCommands } from './system';
|
||||
import type { Command, CommandContext } from './types';
|
||||
|
||||
export type { Command, CommandContext } from './types';
|
||||
|
||||
const helpCommand: Command = {
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'List all available commands',
|
||||
run: (_args, context) => {
|
||||
commands
|
||||
.filter((command) => !command.hidden)
|
||||
.forEach((command) => {
|
||||
const aliases = command.aliases?.length ? ` (${command.aliases.join(', ')})` : '';
|
||||
context.print(
|
||||
line(span(command.name.padEnd(12), 'accent', { bold: true }), command.description, span(aliases, 'muted'))
|
||||
);
|
||||
});
|
||||
context.print(blankLine, line(span('Tip: try ', 'muted'), span('fastfetch', 'accent'), span(', ', 'muted'), span('cowsay hi', 'accent'), span(' or ', 'muted'), span('cd projects', 'accent')), blankLine);
|
||||
}
|
||||
};
|
||||
|
||||
const pageShortcuts: Command[] = allSitePages.map((page) => ({
|
||||
name: page.name,
|
||||
description: `Open ${page.title}`,
|
||||
hidden: true,
|
||||
run: (_args, context) => context.openPage(page.path)
|
||||
}));
|
||||
|
||||
const socialShortcuts: Command[] = profile.socials.map((social) => ({
|
||||
name: social.name.toLowerCase(),
|
||||
description: `Open ${social.name}`,
|
||||
hidden: true,
|
||||
run: (_args, context) => context.openUrl(social.url)
|
||||
}));
|
||||
|
||||
export const commands: Command[] = [helpCommand, ...infoCommands, ...systemCommands, ...pageShortcuts, ...socialShortcuts];
|
||||
|
||||
export function findCommand(name: string): Command | undefined {
|
||||
const lowerName = name.toLowerCase();
|
||||
return commands.find((command) => command.name === lowerName || command.aliases?.includes(lowerName));
|
||||
}
|
||||
|
||||
export function visibleCommandNames(): string[] {
|
||||
return commands.filter((command) => !command.hidden).map((command) => command.name);
|
||||
}
|
||||
|
||||
export function runCommand(input: string, context: CommandContext) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const [name, ...args] = trimmed.split(/\s+/);
|
||||
const command = findCommand(name);
|
||||
if (!command) {
|
||||
context.print(errorLine(`command not found: ${name}. Type help for a list`));
|
||||
return;
|
||||
}
|
||||
command.run(args, context);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { experience, profile, projectCategories, findProjectCategory } from '#lib/content';
|
||||
import { blankLine, errorLine, line, link, span, type Tone } from '../output';
|
||||
import type { Command } from './types';
|
||||
import { whoamiCommand } from './whoami';
|
||||
|
||||
const skillTones: Tone[] = ['blue', 'magenta', 'green', 'yellow', 'orange', 'cyan', 'accent'];
|
||||
|
||||
export const infoCommands: Command[] = [
|
||||
whoamiCommand,
|
||||
{
|
||||
name: 'fastfetch',
|
||||
aliases: ['neofetch', 'banner'],
|
||||
description: 'Display system info',
|
||||
run: (_args, context) => context.print({ kind: 'fetch' })
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
description: 'List skills by category',
|
||||
run: (_args, context) => {
|
||||
Object.entries(profile.skills).forEach(([category, skills], index) => {
|
||||
const tone = skillTones[index % skillTones.length];
|
||||
context.print(line(span(category, tone, { bold: true })), line(' ', span(skills.join(' · '), 'foreground')));
|
||||
});
|
||||
context.print(blankLine);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
aliases: ['socials'],
|
||||
description: 'Show social links',
|
||||
run: (_args, context) => {
|
||||
context.print(line(span('Find me online:', 'accent', { bold: true })));
|
||||
profile.socials.forEach((social) =>
|
||||
context.print(line(span(` ${social.name.padEnd(10)}`, 'muted'), link(social.url.replace(/^mailto:/, ''), social.url)))
|
||||
);
|
||||
context.print(blankLine);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Show contact email',
|
||||
run: (_args, context) => context.print(line(link(profile.email, `mailto:${profile.email}`)))
|
||||
},
|
||||
{
|
||||
name: 'experience',
|
||||
aliases: ['work'],
|
||||
description: 'Show work experience',
|
||||
run: (_args, context) => {
|
||||
experience.forEach((job) => {
|
||||
context.print(
|
||||
line(span(job.role, 'orange', { bold: true }), span(` @ ${job.organization}`, 'accent')),
|
||||
line(span(` ${job.period}`, 'muted'))
|
||||
);
|
||||
job.awards?.forEach((award) => context.print(line(span(` * ${award}`, 'green'))));
|
||||
});
|
||||
context.print(blankLine);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
description: 'List projects, optionally by category',
|
||||
usage: 'projects [opensource|packages|hackathons|research]',
|
||||
argumentOptions: () => projectCategories.map((category) => category.id),
|
||||
run: (args, context) => {
|
||||
const categories = args[0] ? [findProjectCategory(args[0])] : projectCategories;
|
||||
if (!categories[0]) {
|
||||
context.print(errorLine(`unknown category ${args[0]}`));
|
||||
return;
|
||||
}
|
||||
categories.forEach((category) => {
|
||||
if (!category) return;
|
||||
context.print(line(span(`${category.title}/`, 'blue', { bold: true })));
|
||||
category.projects.forEach((project, index) => {
|
||||
const branch = index === category.projects.length - 1 ? '└──' : '├──';
|
||||
const marker = project.awards?.length ? span(' [award]', 'yellow') : '';
|
||||
context.print(line(span(` ${branch} `, 'muted'), project.name, marker));
|
||||
});
|
||||
});
|
||||
context.print(line(span('Open one with ', 'muted'), span('cd projects/<category>', 'accent')), blankLine);
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,134 @@
|
||||
import { profile } from '#lib/content';
|
||||
import { themes } from '#lib/theme/themes';
|
||||
import { allSitePages, findSitePage, sitePages } from '#lib/site/pages';
|
||||
import { blankLine, errorLine, line, span } from '../output';
|
||||
import type { Command } from './types';
|
||||
|
||||
function cowsay(message: string): string[] {
|
||||
const text = message || 'Moo?';
|
||||
const border = '-'.repeat(text.length + 2);
|
||||
return [
|
||||
` ${border}`,
|
||||
`< ${text} >`,
|
||||
` ${border}`,
|
||||
' \\ ^__^',
|
||||
' \\ (oo)\\_______',
|
||||
' (__)\\ )\\/\\',
|
||||
' ||----w |',
|
||||
' || ||'
|
||||
];
|
||||
}
|
||||
|
||||
function easternDate(): string {
|
||||
return new Date().toLocaleString('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
export const systemCommands: Command[] = [
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['cls'],
|
||||
description: 'Clear the terminal',
|
||||
run: (_args, context) => context.clear()
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
aliases: ['dir', 'tree'],
|
||||
description: 'List the pages you can open',
|
||||
run: (_args, context) => {
|
||||
context.print(line(span('~', 'blue', { bold: true })));
|
||||
sitePages.forEach((page, pageIndex) => {
|
||||
const isLastPage = pageIndex === sitePages.length - 1;
|
||||
context.print(line(span(isLastPage ? '└── ' : '├── ', 'muted'), span(`${page.name}/`, 'blue', { bold: true })));
|
||||
page.children?.forEach((child, childIndex) => {
|
||||
const isLastChild = childIndex === page.children!.length - 1;
|
||||
const indent = isLastPage ? ' ' : '│ ';
|
||||
context.print(line(span(`${indent}${isLastChild ? '└── ' : '├── '}`, 'muted'), span(`${child.name}/`, 'cyan')));
|
||||
});
|
||||
});
|
||||
context.print(blankLine);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cd',
|
||||
aliases: ['open', 'goto'],
|
||||
description: 'Open a page in a window',
|
||||
usage: 'cd <page>, for example cd projects/hackathons',
|
||||
argumentOptions: () => ['~', ...allSitePages.map((page) => page.path.slice(1))],
|
||||
run: (args, context) => {
|
||||
const target = args[0] ?? '~';
|
||||
if (target === '~' || target === '/' || target === 'home') {
|
||||
context.openPage('/');
|
||||
return;
|
||||
}
|
||||
const page = findSitePage(target);
|
||||
if (!page) {
|
||||
context.print(errorLine(`no such page: ${target}. Try ls`));
|
||||
return;
|
||||
}
|
||||
context.print(line(span('opening ', 'muted'), span(page.path, 'accent')));
|
||||
context.openPage(page.path);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'List or set the color theme',
|
||||
usage: 'theme [name]',
|
||||
argumentOptions: () => themes.map((theme) => theme.id),
|
||||
run: (args, context) => {
|
||||
if (!args[0]) {
|
||||
themes.forEach((theme) => {
|
||||
const isCurrent = theme.id === context.currentThemeId();
|
||||
context.print(line(span(isCurrent ? '* ' : ' ', 'accent'), span(theme.id, isCurrent ? 'accent' : 'foreground'), span(` ${theme.mode}`, 'muted')));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (context.setTheme(args[0].toLowerCase())) context.print(line(span('theme set to ', 'muted'), span(args[0], 'accent')));
|
||||
else context.print(errorLine(`unknown theme ${args[0]}. Run theme to list them`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Print text back',
|
||||
usage: 'echo <text>',
|
||||
run: (args, context) => context.print(line(args.join(' ')))
|
||||
},
|
||||
{
|
||||
name: 'cowsay',
|
||||
description: 'Make a cow say something',
|
||||
usage: 'cowsay <text>',
|
||||
run: (args, context) => context.print(...cowsay(args.join(' ')).map((text) => line(span(text, 'accent'))))
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
description: 'Show the current date and time (Eastern)',
|
||||
run: (_args, context) => context.print(line(easternDate()))
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
description: 'Close this terminal',
|
||||
run: (_args, context) => context.exit()
|
||||
},
|
||||
{
|
||||
name: 'sudo',
|
||||
hidden: true,
|
||||
description: 'Elevated privileges',
|
||||
run: (_args, context) =>
|
||||
context.print(errorLine(`${profile.username} is not in the sudoers file. This incident will be reported.`))
|
||||
},
|
||||
{
|
||||
name: 'pwd',
|
||||
hidden: true,
|
||||
description: 'Print working directory',
|
||||
run: (_args, context) => context.print(line(`/home/${profile.username}`))
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OutputBlock } from '../output';
|
||||
|
||||
export interface CommandContext {
|
||||
print: (...blocks: OutputBlock[]) => void;
|
||||
clear: () => void;
|
||||
exit: () => void;
|
||||
openPage: (path: string) => void;
|
||||
openUrl: (url: string) => void;
|
||||
setTheme: (themeId: string) => boolean;
|
||||
currentThemeId: () => string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
aliases?: string[];
|
||||
hidden?: boolean;
|
||||
argumentOptions?: () => string[];
|
||||
run: (args: string[], context: CommandContext) => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { experience, profile } from '#lib/content';
|
||||
import { blankLine, line, link, span, type OutputBlock, type Span } from '../output';
|
||||
import type { Command } from './types';
|
||||
|
||||
const labelWidth = 11;
|
||||
|
||||
function field(label: string, ...value: (string | Span)[]): OutputBlock {
|
||||
return line(span(` ${label.padEnd(labelWidth)}`, 'blue', { bold: true }), ...value);
|
||||
}
|
||||
|
||||
function joinWithDots(spans: Span[]): Span[] {
|
||||
return spans.flatMap((item, index) => (index === 0 ? [item] : [span(' · ', 'muted'), item]));
|
||||
}
|
||||
|
||||
function whoamiOutput(): OutputBlock[] {
|
||||
const currentRole = experience[0];
|
||||
const header = `${profile.username}@${profile.hostname}`;
|
||||
const interests = profile.interests;
|
||||
const socialLinks = profile.socials.map((social) => link(social.name.toLowerCase(), social.url));
|
||||
|
||||
return [
|
||||
line(span(profile.username, 'accent', { bold: true }), span('@', 'muted'), span(profile.hostname, 'accent', { bold: true })),
|
||||
line(span('─'.repeat(header.length), 'muted')),
|
||||
field('alias', profile.displayName),
|
||||
field('role', profile.title),
|
||||
field('school', `${profile.education.school}, ${profile.education.degree}`),
|
||||
...(currentRole ? [field('now', span(currentRole.role, 'orange'), span(' @ ', 'muted'), currentRole.organization)] : []),
|
||||
...(interests.length ? [field('interests', ...joinWithDots(interests.map((interest) => span(interest, 'magenta'))))] : []),
|
||||
blankLine,
|
||||
line(span(' ', 'muted'), span(profile.bio, 'foreground')),
|
||||
blankLine,
|
||||
field('links', ...joinWithDots(socialLinks)),
|
||||
blankLine,
|
||||
line(
|
||||
span(' try ', 'muted'),
|
||||
span('skills', 'green'),
|
||||
span(', ', 'muted'),
|
||||
span('experience', 'green'),
|
||||
span(', ', 'muted'),
|
||||
span('projects', 'green'),
|
||||
span(' or ', 'muted'),
|
||||
span('cd portfolio', 'green')
|
||||
),
|
||||
blankLine
|
||||
];
|
||||
}
|
||||
|
||||
export const whoamiCommand: Command = {
|
||||
name: 'whoami',
|
||||
description: 'Show who I am',
|
||||
run: (_args, context) => context.print(...whoamiOutput())
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { findCommand, visibleCommandNames } from './commands';
|
||||
|
||||
export interface CompletionResult {
|
||||
input: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
function sharedPrefix(words: string[]): string {
|
||||
return words.reduce((prefix, word) => {
|
||||
let length = 0;
|
||||
while (length < prefix.length && prefix[length] === word[length]) length++;
|
||||
return prefix.slice(0, length);
|
||||
});
|
||||
}
|
||||
|
||||
function candidatesFor(words: string[]): string[] {
|
||||
if (words.length <= 1) return visibleCommandNames();
|
||||
if (words.length > 2) return [];
|
||||
return findCommand(words[0])?.argumentOptions?.() ?? [];
|
||||
}
|
||||
|
||||
export function complete(input: string): CompletionResult {
|
||||
const words = input.trimStart().split(/\s+/);
|
||||
const partial = words.at(-1) ?? '';
|
||||
const matches = [...new Set(candidatesFor(words))].filter((candidate) =>
|
||||
candidate.toLowerCase().startsWith(partial.toLowerCase())
|
||||
);
|
||||
|
||||
if (matches.length === 0) return { input, options: [] };
|
||||
|
||||
const before = input.slice(0, input.length - partial.length);
|
||||
if (matches.length === 1) return { input: `${before}${matches[0]} `, options: [] };
|
||||
|
||||
const prefix = sharedPrefix(matches);
|
||||
const madeProgress = prefix.length > partial.length;
|
||||
return { input: `${before}${madeProgress ? prefix : partial}`, options: madeProgress ? [] : matches.sort() };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type Tone =
|
||||
| 'foreground'
|
||||
| 'muted'
|
||||
| 'accent'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'orange'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'magenta';
|
||||
|
||||
export interface Span {
|
||||
text: string;
|
||||
tone?: Tone;
|
||||
bold?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export type OutputBlock =
|
||||
| { kind: 'prompt'; command: string }
|
||||
| { kind: 'line'; spans: Span[] }
|
||||
| { kind: 'fetch' };
|
||||
|
||||
export function span(text: string, tone?: Tone, options: Omit<Span, 'text' | 'tone'> = {}): Span {
|
||||
return { text, tone, ...options };
|
||||
}
|
||||
|
||||
export function line(...parts: (string | Span)[]): OutputBlock {
|
||||
return { kind: 'line', spans: parts.map((part) => (typeof part === 'string' ? { text: part } : part)) };
|
||||
}
|
||||
|
||||
export function link(text: string, href: string, tone: Tone = 'accent'): Span {
|
||||
return { text, tone, href };
|
||||
}
|
||||
|
||||
export function errorLine(message: string): OutputBlock {
|
||||
return line(span('error: ', 'red', { bold: true }), message);
|
||||
}
|
||||
|
||||
export const blankLine: OutputBlock = line('');
|
||||
Reference in new issue
Block a user