chore: Initial commit.

This commit is contained in:
Tera << 8 2025-02-19 16:32:24 -05:00
commit 0219b23c4e
Signed by: imterah
GPG key ID: 8FA7DD57BA6CEA37
84 changed files with 15995 additions and 0 deletions

31
.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# System files
.DS_Store
node_modules
# Tooling
.vercel
.eslintcache
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Output files
dist
# Autogenerated
.astro

5
.husky/pre-commit Executable file
View file

@ -0,0 +1,5 @@
# Lint staged files
pnpx lint-staged
# Run Astro checks
pnpm check

6
.husky/pre-push Executable file
View file

@ -0,0 +1,6 @@
# Strict lint
pnpm lint --max-warnings 0
pnpm format:check
# Run Astro checks
pnpm check

4
.lintstagedrc Normal file
View file

@ -0,0 +1,4 @@
{
"*.{js,ts,astro}": ["eslint --fix", "prettier --write"],
"*.{css,html,json,md,mdx,pcss,svg,yml}": ["prettier --write"]
}

1
.npmrc Normal file
View file

@ -0,0 +1 @@
shamefully-hoist=true

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

8
.prettierignore Normal file
View file

@ -0,0 +1,8 @@
# MDX
*.mdx
# Autogenerated
.astro
# Lockfiles
pnpm-lock.yaml

32
README.md Normal file
View file

@ -0,0 +1,32 @@
<h1 align="center">
Portfolio
<p align="center">
<a href="https://recivi.pages.dev/">
<img src="https://img.shields.io/badge/docs-recivi.pages.dev-blue" alt="Documentation"/>
</a>
</p>
</h1>
## Portfolio
Portfolio is a personal website, powered by Récivi, a new kind of résumé for
computers and humans, in that order. You can use this to quickly set up the
following:
- **Homepage:** The template sets up pages that are usually staples of personal websites
such as `/now`, `/uses`, `/colophon` and more.
- **Blog:** The template includes support for a blog or a digital garden. This makes it
ideal for someone who wants to write freely outside of a walled garden.
- **Résumé:** The template builds an interactive résumé, and also includes a pipeline to
publish a print-friendly version at `/resume.pdf`.
You can start immediately by running:
```bash
pnpm create astro@latest -- --template recivi/portfolio
# or if you prefer npm
npm create astro@latest -- --template recivi/portfolio
```
For more information, see the [documentation](https://recivi.pages.dev/).

38
astro.config.ts Normal file
View file

@ -0,0 +1,38 @@
import { defineConfig, passthroughImageService } from 'astro/config'
import mdx from '@astrojs/mdx'
import tailwind from '@astrojs/tailwind'
import alpinejs from '@astrojs/alpinejs'
import { rehypeTailwind } from './src/plugins/rehype_tailwind'
import { watchPlugins } from './src/integrations/watch_plugins'
import { site } from './src/stores/site'
// https://astro.build/config
export default defineConfig({
site: site.baseUrl,
image: {
service: passthroughImageService(),
},
markdown: {
rehypePlugins: [rehypeTailwind],
smartypants: false, // https://daringfireball.net/projects/smartypants/
shikiConfig: {
defaultColor: false,
themes: {
dark: 'catppuccin-mocha',
light: 'catppuccin-latte',
},
},
},
devToolbar: {
enabled: false,
},
integrations: [
tailwind({ applyBaseStyles: false }),
mdx(),
watchPlugins(),
alpinejs(),
],
})

48
eslint.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import globals from 'globals'
import { includeIgnoreFile } from '@eslint/compat'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import astro from 'eslint-plugin-astro'
const __filename = fileURLToPath(import.meta.url)
const srcDir = path.dirname(__filename)
const gitignorePath = path.resolve(srcDir, '.gitignore')
export default [
includeIgnoreFile(gitignorePath),
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.node,
...globals.browser,
},
},
rules: {
'import/prefer-default-export': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
},
},
js.configs.recommended,
...ts.configs.strict,
...ts.configs.stylistic,
...astro.configs.recommended,
// Type definitions
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
},
},
]

11890
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "portfolio",
"description": "My portfolio is my small personal slice of the Internet.",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "pnpm lint --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "astro check",
"dev": "astro dev --host",
"build": "astro build && pnpm print",
"preview": "astro preview --host --port 4322",
"astro": "astro",
"print": "start-server-and-test preview http://localhost:4322/ 'tsx src/scripts/print.ts'"
},
"dependencies": {
"@astrojs/alpinejs": "^0.4.1",
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.0.4",
"@astrojs/rss": "^4.0.11",
"@astrojs/tailwind": "^5.1.4",
"@iconify-json/lucide": "^1.2.22",
"@iconify-json/simple-icons": "^1.2.19",
"@recivi/schema": "^0.0.1",
"@types/alpinejs": "^3.13.11",
"@types/node": "^22.10.1",
"@vercel/og": "^0.6.4",
"alpinejs": "^3.14.7",
"astro": "^5.1.4",
"chalk": "^5.3.0",
"puppeteer": "^23.11.1",
"satori-html": "^0.3.2",
"start-server-and-test": "^2.0.9",
"tailwindcss": "^3.4.16",
"tailwindcss-safe-area": "^0.6.0",
"tsx": "^4.19.2"
},
"devDependencies": {
"@eslint/compat": "^1.2.4",
"@eslint/js": "^9.16.0",
"@types/eslint__js": "^8.42.3",
"eslint": "^9.16.0",
"eslint-plugin-astro": "^1.3.1",
"globals": "^15.13.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.9",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0"
},
"packageManager": "pnpm@9.15.0"
}

26
prettier.config.mjs Normal file
View file

@ -0,0 +1,26 @@
/** @type {import("prettier").Config} */
export default {
trailingComma: 'es5',
semi: false,
singleQuote: true,
astroAllowShorthand: true,
bracketSameLine: true,
singleAttributePerLine: true,
overrides: [
{
files: ['*.astro'],
options: {
parser: 'astro',
},
},
{
files: ['*.svg'],
options: {
parser: 'html',
},
},
],
proseWrap: 'always',
tailwindFunctions: ['tw'],
plugins: ['prettier-plugin-astro', 'prettier-plugin-tailwindcss'],
}

177
recivi.json Normal file
View file

@ -0,0 +1,177 @@
{
"$schema": "https://recivi.pages.dev/schemas/recivi-resume.json",
"bio": {
"name": "Tera",
"fullName": "Tera Bites",
"pronouns": [
{
"language": "English",
"pronouns": [
{
"firstSet": "he",
"secondSet": "him"
},
{
"firstSet": "they",
"secondSet": "them"
},
{
"firstSet": "it",
"secondSet": "its"
}
]
}
],
"summary": "Hobbyist software developer with an affinity to server-side applications",
"labels": ["software developer", "homelabber"],
"aliases": ["imterah"],
"contact": {
"emails": ["me@terah.dev", "imterah@pm.me"]
},
"profiles": [
{
"site": {
"id": "github",
"name": "GitHub"
},
"url": "https://github.com/imterah",
"username": "imterah"
},
{
"site": {
"id": "bluesky",
"name": "Bluesky"
},
"url": "https://bsky.app/profile/terah.dev",
"username": "terah.dev"
},
{
"site": {
"id": "codeberg",
"name": "Codeberg"
},
"url": "https://codeberg.org/imterah",
"username": "imterah"
},
{
"site": {
"id": "forgejo",
"name": "Personal Git Server"
},
"url": "https://git.terah.dev/imterah",
"username": "imterah"
}
],
"skills": [
{
"name": "Backend",
"id": "backend",
"subSkills": [
{ "id": "go", "name": "Golang" },
{ "id": "javascript", "name": "JS" },
{ "id": "typescript", "name": "TS" }
]
},
{
"name": "Scripting",
"subSkills": [
{ "id": "gnubash", "name": "Bash" },
{ "id": "python", "name": "Python 3" }
]
}
],
"languages": [
{
"name": "English",
"speak": "native",
"listen": "native",
"write": "native",
"read": "native"
}
],
"residence": {
"state": "IN",
"countryCode": "US"
},
"origin": {
"state": "KY",
"countryCode": "US"
}
},
"education": [],
"work": [],
"creations": [
{
"id": "hermes",
"name": "Hermes",
"url": {
"dest": "https://hermes.terah.dev",
"label": "Hermes Documentation"
},
"summary": "Port forwarding over computers",
"description": "Hermes is a modular system that lets you port forward across computers instead of over the internet directly.",
"projects": [
{
"id": "hermes_api",
"name": "API",
"summary": "The API server for Hermes.",
"role": "Creator",
"technologies": [
{
"id": "go",
"name": "Go"
},
{
"id": "sqlite",
"name": "SQLite"
},
{
"id": "postgresql",
"name": "PostgreSQL"
},
{
"id": "gorm",
"name": "GORM"
}
]
},
{
"id": "hermes_backend",
"name": "Backend Infrastructure",
"summary": "Standardized system that lets you write 'backends' (methods to proxy services) in any programming language.",
"role": "Creator",
"technologies": [
{
"id": "go",
"name": "Go"
}
]
},
{
"id": "hermes_sshbackend",
"name": "SSH Backend",
"summary": "Backend that proxies services over SSH. Only supports TCP.",
"role": "Creator",
"technologies": [
{
"id": "go",
"name": "Go"
}
]
},
{
"id": "hermes_sshappbackend",
"name": "SSH App Backend",
"summary": "Backend that proxies services over SSH + a remote Go binary to support both TCP and UDP.",
"role": "Creator",
"technologies": [
{
"id": "go",
"name": "Go"
}
]
}
]
}
]
}

7
shell.nix Normal file
View file

@ -0,0 +1,7 @@
{
pkgs ? import <nixpkgs> { },
}: pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
];
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,37 @@
---
const isDev = import.meta.env.DEV
---
{
isDev && (
<span class="text-blue">
Breakpoint:
<span class="sm:hidden">mobile</span>
<span class="hidden sm:inline md:hidden">sm</span>
<span class="hidden md:inline lg:hidden">md</span>
<span class="hidden lg:inline xl:hidden">lg</span>
<span class="hidden xl:inline 2xl:hidden">xl</span>
<span class="hidden 2xl:inline">2xl</span>
<span id="width" />
</span>
)
}
<script>
/** Populate screen width and update it on resize using `ResizeObserver`. */
const width = document.getElementById('width')
const updateWidth = () => {
if (!width) {
return
}
width.textContent = ` (${window.innerWidth}px)`
}
updateWidth()
const resizeObserver = new ResizeObserver(updateWidth)
resizeObserver.observe(document.body)
document.addEventListener('beforeunload', () => resizeObserver.disconnect())
</script>

View file

@ -0,0 +1,68 @@
---
/**
* list all the ways of contacting me
*
* All contact links have rel="me" which makes it possible to use them for the
* purpose of IndieAuth authentication. So if you set up links to your profiles,
* any website that IndieAuth supports can be used as your identity verification
* platform.
*/
import Icon from '@/components/Icon.astro'
import Link from '@/components/Link.astro'
import { recivi } from '@/stores/recivi'
const { contact: { emails = [], phones = [] } = {}, profiles = [] } = recivi.bio
const items = [
emails.map((email) => ({
icon: Object.freeze({ name: 'mail', source: 'lucide' }),
name: 'Email',
url: `mailto:${email}`,
text: email,
})),
phones.map((phone) => ({
icon: Object.freeze({ name: 'phone', source: 'lucide' }),
name: 'Phone',
url: `tel:+${phone.countryCode}${phone.number}`,
text: `+${phone.countryCode} ${phone.number}`,
})),
profiles.map((profile) => ({
icon: Object.freeze({ name: profile.site.id, source: 'simple_icons' }),
name: profile.site.name,
url: 'url' in profile ? profile.url : null,
text: 'username' in profile ? profile.username : profile.site.name,
})),
].flat()
---
<ul class="columns-2 pdf:flex pdf:justify-between">
{
items.map((item) => (
<li>
<div class="flex items-center gap-2">
<span class="text-peach pdf:text-subtle">
{item.icon.name && (
<Icon
source={item.icon.source}
name={item.icon.name}
title={item.name}
/>
)}
</span>
<span class="sr-only">{item.name}</span>
{item.url ? (
<Link
rel="me"
url={item.url}>
{item.text}
</Link>
) : (
item.text
)}
</div>
</li>
))
}
</ul>

24
src/components/Date.astro Normal file
View file

@ -0,0 +1,24 @@
---
import type { Date as RcvDate } from '@recivi/schema'
import { getRcvDate, dateReadable, dateDisplay } from '@/utils/date_fmt'
interface Props {
date: Date | RcvDate
}
let { date } = Astro.props
function isDate(value: Date | RcvDate): value is Date {
return value instanceof Date
}
let rcvDate: RcvDate = isDate(date) ? getRcvDate(date) : date
const display = dateDisplay(rcvDate)
const readable = dateReadable(rcvDate)
---
<span
class="font-mono"
set:html={display}
title={readable}
/>

View file

@ -0,0 +1,49 @@
---
import Icon from '@/components/Icon.astro'
import Link from '@/components/Link.astro'
import IconName from '@/components/IconName.astro'
import type { Epic } from '@/models/recivi'
interface Props {
epic: Epic
}
const { epic } = Astro.props
---
<div class="border-b px-4 py-2">
<strong
><IconName
{...epic}
url={`/resume/epics/${epic.id}`}
/></strong
>
<ul class="mt-1">
{
epic.projects.map((project) => (
<li>
<div class="flex">
{project.name}
{/* prettier-ignore */}
<ul class="ml-auto text-subtle">
{project.technologies?.map((technology) => (
technology.id && <li class="inline after:mx-1 after:content-['·'] last:after:content-none"><Icon name={technology.id} /></li>
))}
</ul>
{/* Hack to preserve alignment when there is no link. */}
<div
class="ms-2 border-s ps-2"
class:list={{
'pointer-none invisible border-trans': !project.url,
}}
aria-hidden={!project.url}>
<Link url={project.url || 'https://dhruvkb.dev'}>link</Link>
</div>
</div>
</li>
))
}
</ul>
</div>

View file

@ -0,0 +1,89 @@
---
import Icon from '@/components/Icon.astro'
import IconName from '@/components/IconName.astro'
import Link from '@/components/Link.astro'
import type { Epic } from '@/models/recivi'
import { urlToDest } from '@/utils/recivi'
interface Props {
epic: Epic
}
const { epic } = Astro.props
---
<li class="mb-3 border-b pb-3 last:mb-0 last:border-none last:pb-0">
<div class="flex items-center justify-between">
<h3 class="font-medium">
<IconName
id={epic.id}
name={epic.name}
/>
</h3>
{
epic.role && (
<span class="text-subtle">
Built at
<IconName
id={epic.role.org.id}
name={epic.role.org.name}
/>
</span>
)
}
</div>
{
epic.url && (
<Link
url={epic.url}
class="font-mono text-sm text-subtle">
{urlToDest(epic.url)}
</Link>
)
}
{
epic.summary && (
<p
class="mb-4 mt-1"
set:html={epic.summary}
/>
)
}
<ul>
{
epic.projects
.filter((project) => project.tags?.includes('resume_pdf'))
.map((project) => (
<li class="mb-4 last:mb-0">
<div class="flex items-center justify-between">
<h4 class="font-medium">{project.name}</h4>
<Fragment>
{/* prettier-ignore */}
<ul class="text-subtle">
{project.technologies?.map((technology) => (
technology.id && <li class="inline after:mx-1 after:content-['·'] last:after:content-none"><Icon name={technology.id} /></li>
))}
</ul>
</Fragment>
</div>
{project.url && (
<Link
url={project.url}
class="font-mono text-sm text-subtle">
{urlToDest(project.url)}
</Link>
)}
<p
class="my-1"
set:html={project.summary}
/>
<ul class="list-inside list-disc">
{project.highlights?.map((hl) => (
<li set:html={hl} />
))}
</ul>
</li>
))
}
</ul>
</li>

View file

@ -0,0 +1,20 @@
<em
id="fun-fact"
class="text-peach"
>wears many other hats</em
><script>
/** Randomly display a fun fact about me. */
const facts = [
'hates computers',
'has too much servers',
'is in love with Proxmox VE',
'has an irrational hatred for smart toasters',
]
const fact = facts[Math.floor(Math.random() * facts.length)] ?? ''
const factEl = document.getElementById('fun-fact')
if (factEl) {
factEl.innerHTML = fact
}
</script>

32
src/components/Icon.astro Normal file
View file

@ -0,0 +1,32 @@
---
import { getBody } from '@/utils/icon'
interface Props {
source?: 'simple_icons' | 'lucide'
name: string
/** text to shown when the icon is hovered over */
title?: string
reserveSpace?: boolean
}
let { source, name, title, reserveSpace = false } = Astro.props
const body = getBody(name, source)
---
{
body ? (
<svg
viewBox="0 0 24 24"
class="inline size-curr align-[-0.1em]"
height="1rem"
width="1rem">
{title && <title>{title}</title>}
<Fragment set:html={body} />
</svg>
) : reserveSpace ? (
<div
class="inline-block h-4 w-4"
aria-hidden="true"
/>
) : null
}

View file

@ -0,0 +1,29 @@
---
import type { Url } from '@recivi/schema'
import Icon from '@/components/Icon.astro'
import Link from '@/components/Link.astro'
interface Props {
id?: string | undefined
name: string
/**
* If you don't wish the component to be a link, don't pass the `url` prop or
* pass `undefined`.
*/
url?: Url | undefined
}
let { id = undefined, name, url = undefined, ...attrs } = Astro.props
---
<span class="inline-block">
{
id && (
<Icon
name={id}
{...attrs}
/>
)
}
{url ? <Link {url}>{name}</Link> : <span>{name}</span>}
</span>

View file

@ -0,0 +1,34 @@
---
import Link from '@/components/Link.astro'
import DateComponent from '@/components/Date.astro'
import { certDisplay } from '@/utils/recivi'
import type { Institute } from '@/models/recivi'
interface Props {
institute: Institute
}
const { institute } = Astro.props
---
<div class="border-b px-4 py-2">
<div class="flex justify-between">
<strong>{institute.name}</strong>
{institute.url && <Link url={institute.url}>link</Link>}
</div>
<ul class="mt-1">
{
institute.certs.map((cert) => (
<li>
<div class="flex justify-between gap-3">
{certDisplay(cert)}
<span class="text-subtle">
<DateComponent date={cert.issue} />
</span>
</div>
</li>
))
}
</ul>
</div>

View file

@ -0,0 +1,61 @@
<h1
class="flex"
aria-hidden="true">
<span id="face">ʕ • ᴥ • ʔ</span>
<span
id="hand"
class="origin-bottom-left animate-wave motion-reduce:animate-none">
</span>
</h1>
<script>
/** Render a waving kaomoji with randomly-chosen facial features. */
/**
* Returns a random element from the given array. If the array is empty, it
* returns `undefined`.
*
* @param choices - the array from to choose a random element
* @returns a random element from the given array
*/
function sample<T>(choices: T[]): T | undefined {
return choices[Math.floor(Math.random() * choices.length)]
}
const EDGE_PAIRS: [string, string][] = [
['ʕ', 'ʔ'],
['(', ')'],
['[', ']'],
]
const EYE_PAIRS: [string, string][] = [
['•', '•'],
['ˇ', 'ˇ'],
['❛', '❛'],
['^', '^'],
['', ''],
['´•', '•`'],
['◕', '◕'],
]
const MOUTHS: string[] = ['ᴥ', '◡', 'ᴗ', '▽', 'ヮ']
const SPACERS: string[] = ['', ' ']
function getFace(): string {
const [edgeLeft, edgeRight] = sample(EDGE_PAIRS) ?? ['(', ')']
const [eyeLeft, eyeRight] = sample(EYE_PAIRS) ?? ['ˇ', 'ˇ']
const mouth = sample(MOUTHS) ?? '◡'
const spacer = sample(SPACERS) ?? ''
return [edgeLeft, eyeLeft, mouth, eyeRight, edgeRight].join(spacer)
}
const faceEl = document.getElementById('face')
if (faceEl) {
faceEl.innerHTML = getFace()
}
const hands: string[] = ['ノ', '', 'ノ゚', '◞*']
const handEl = document.getElementById('hand')
if (handEl) {
handEl.innerHTML = sample(hands) ?? 'ノ'
}
</script>

21
src/components/Link.astro Normal file
View file

@ -0,0 +1,21 @@
---
import type { Url } from '@recivi/schema'
import type { HTMLAttributes } from 'astro/types'
import { urlToDest } from '@/utils/recivi'
interface Props extends HTMLAttributes<'a'> {
url: Url
}
const { url, ...attrs } = Astro.props
const href = urlToDest(url)
const title = typeof url === 'object' && 'label' in url ? url.label : undefined
---
<a
{href}
{title}
{...attrs}
><slot>link</slot></a
>

18
src/components/Meta.astro Normal file
View file

@ -0,0 +1,18 @@
---
import { getPageTitle, getMetadata, type RawMetadata } from '@/utils/meta'
interface Props {
data: RawMetadata
}
const { data } = Astro.props
const { origin, pathname } = Astro.url
const pageTitle = getPageTitle(data.title)
const metaTags = getMetadata(data, pathname, origin)
---
<Fragment>
<title>{pageTitle}</title>
{metaTags.map((tag) => <meta {...tag} />)}
</Fragment>

View file

@ -0,0 +1,39 @@
---
import Link from '@/components/Link.astro'
import IconName from '@/components/IconName.astro'
import TimePeriod from '@/components/TimePeriod.astro'
import type { Org } from '@/models/recivi'
interface Props {
org: Org
}
const { org } = Astro.props
---
<div class="border-b px-4 py-2">
<div class="flex justify-between">
<strong
><IconName
{...org}
url={`/resume/orgs/${org.id}`}
/></strong
>
{org.url && <Link url={org.url}>link</Link>}
</div>
<ul class="mt-1">
{
org.roles.map((role) => (
<li>
<div class="flex justify-between gap-3">
{role.name}
<span class="text-subtle">
<TimePeriod {...role.period} />
</span>
</div>
</li>
))
}
</ul>
</div>

View file

@ -0,0 +1,83 @@
---
import Link from '@/components/Link.astro'
import IconName from '@/components/IconName.astro'
import TimePeriod from '@/components/TimePeriod.astro'
import type { Org } from '@/models/recivi'
import { urlToDest, addressDisplay, roleTypeDisplay } from '@/utils/recivi'
interface Props {
org: Org
}
const { org } = Astro.props
---
<li class="mb-3 border-b pb-3 last:mb-0 last:border-none last:pb-0">
<div class="flex items-center justify-between">
<h3 class="font-medium">
<IconName
id={org.id}
name={org.name}
/>
</h3>
{
org.address && (
<span
class="text-subtle"
set:html={addressDisplay(org.address)}
/>
)
}
</div>
{
org.url && (
<Link
url={org.url}
class="font-mono text-sm text-subtle">
{urlToDest(org.url)}
</Link>
)
}
{
org.summary && (
<p
class="mb-4 mt-1"
set:html={org.summary}
/>
)
}
<ul>
{
org.roles
.filter((role) => role.tags?.includes('resume_pdf'))
.map((role) => (
<li class="mb-4 last:mb-0">
<div class="flex items-center justify-between">
<h4 class="font-medium">
{role.name}
{role.type &&
role.type !== 'full-time' &&
`(${roleTypeDisplay(role.type)})`}
</h4>
<span class="text-subtle">
{role.period && <TimePeriod {...role.period} />}
</span>
</div>
{role.summary && (
<p
class="my-1"
set:html={role.summary}
/>
)}
{role.highlights?.length ? (
<ul class="list-inside list-disc">
{role.highlights.map((hl) => (
<li set:html={hl} />
))}
</ul>
) : undefined}
</li>
))
}
</ul>
</li>

View file

@ -0,0 +1,62 @@
---
import type { PronounSet } from '@/stores/recivi'
import { recivi } from '@/stores/recivi'
let combinedPronouns: string = ''
// @ts-ignore
if (typeof recivi.bio['pronouns'] !== 'undefined') {
// @ts-ignore
const pronounSet = recivi.bio['pronouns'] as PronounSet[]
// TODO: Implement multiple set support
if (pronounSet.length === 0) {
combinedPronouns = 'any/all'
return
}
const pronouns = pronounSet[0]
if (!pronouns) {
combinedPronouns = 'any/all'
return
}
// Use a common English-speaking convention of only showing the first pronoun if we have
// multiple pronouns specified
if (pronouns.language == 'English') {
if (pronouns.pronouns.length === 0) {
return
} else if (pronouns.pronouns.length === 1) {
const firstPronoun = pronouns.pronouns[0]
if (!firstPronoun) {
combinedPronouns = 'any/all'
return
}
combinedPronouns = `${firstPronoun.firstSet}/${firstPronoun.secondSet}`
} else {
for (const pronounInstance of pronouns.pronouns) {
combinedPronouns += pronounInstance.firstSet + '/'
}
combinedPronouns = combinedPronouns.substring(
0,
combinedPronouns.length - 1
)
}
} else {
for (const pronounInstance of pronouns.pronouns) {
combinedPronouns += pronounInstance.firstSet + '/'
}
}
}
---
<em
id="pronouns"
class="text-peach"
>{combinedPronouns}</em
>

View file

@ -0,0 +1,38 @@
---
import Breakpoint from '@/components/Breakpoint.astro'
import Icon from '@/components/Icon.astro'
import IconName from '@/components/IconName.astro'
import type { ClassList } from '@/types/class_list'
import { site } from '@/stores/site'
interface Props {
classes?: ClassList
}
const { classes = [] } = Astro.props
---
<footer
class="cntnr flex items-center justify-between border-t py-4 text-subtle"
class:list={classes}>
<div class="italic">
<Icon
name="hand"
source="lucide"
/>
Have a great day!
</div>
<Breakpoint />
{
site.showCredit && (
<div>
Built with
<IconName
id="recivi"
name="Récivi"
url="https://recivi.pages.dev"
/>
</div>
)
}
</footer>

View file

@ -0,0 +1,82 @@
---
import Link from '@/components/Link.astro'
import { site } from '@/stores/site'
import { getPages } from '@/utils/collections'
import type { ClassList } from '@/types/class_list'
interface Props {
classes?: ClassList
}
const { classes = [] } = Astro.props
// Links have a trailing slash because each page becomes a directory when built.
const pages = await getPages()
const navLinks = pages
.filter(
(page): page is typeof page & { data: { index: number } } =>
page.id !== 'index' && // Exclude the home page.
typeof page.data.index !== 'undefined' // Only include pages with an index.
)
.sort((a, b) => a.data.index - b.data.index)
.map((page) => ({
text: page.data.title,
url: `/${page.id}`,
}))
const pathName = new URL(Astro.request.url).pathname
function getNormalised(path: string): string {
return path.replace(/\/$/, '')
}
const isHome = getNormalised(pathName) === ''
function getIsMatch(url: string): boolean {
return getNormalised(pathName).startsWith(getNormalised(url))
}
function getIsExactMatch(url: string): boolean {
return getNormalised(pathName) === getNormalised(url)
}
---
<header
class="cntnr flex flex-col justify-between gap-2 border-b py-4 sm:flex-row"
class:list={classes}>
<Link url="/">
<!-- Class `inline-block` prevents underline from `Link`. -->
<span
class="inline-block font-bold hover:underline hover:decoration-wavy"
class:list={[{ 'text-peach underline decoration-peach': isHome }]}>
{site.title}
</span>
</Link>
<nav>
<ul class="flex list-none">
{
navLinks.map((link) => (
<li class="me-4 last:me-0">
<Link url={link.url}>
{/* Class `inline-block` prevents underline from `Link`. */}
<span
class="inline-block"
class:list={[
{ 'text-peach underline': getIsMatch(link.url) },
getIsExactMatch(link.url)
? ['decoration-peach', 'visited:decoration-peach']
: [
'no-underline',
'hover:underline',
'hover:decoration-wavy',
'decoration-curr',
'visited:decoration-curr',
],
]}>
{link.text}
</span>
</Link>
</li>
))
}
</ul>
</nav>
</header>

View file

@ -0,0 +1,46 @@
---
import type { Skill } from '@recivi/schema'
import IconName from '@/components/IconName.astro'
interface Props {
skill: Skill
}
const { skill } = Astro.props
const formalSkill =
typeof skill === 'string'
? {
id: null,
name: skill,
subSkills: [],
}
: skill
---
<span>
<span>
{
formalSkill.id ? (
<IconName
id={formalSkill.id}
name={formalSkill.name}
/>
) : (
formalSkill.name
)
}
</span>
{
formalSkill.subSkills?.length ? (
<span class="text-subtle">
:
{formalSkill.subSkills.map((subskill) => (
<span class="after:text-subtle after:content-['·'] last:after:content-none">
<Astro.self skill={subskill} />
</span>
))}
</span>
) : undefined
}
</span>

136
src/components/Table.astro Normal file
View file

@ -0,0 +1,136 @@
---
import type { Tech } from '@recivi/schema'
import Link from '@/components/Link.astro'
import Icon from '@/components/Icon.astro'
import IconName from '@/components/IconName.astro'
import TimePeriod from '@/components/TimePeriod.astro'
import DateComponent from '@/components/Date.astro'
import type { ColumnSpec, Row } from '@/models/table'
interface Props {
columns: ColumnSpec[]
data: Row[]
}
const { columns, data } = Astro.props
const columnIds = columns.map((column) => column.id)
const pathName = new URL(Astro.request.url).pathname
/**
* Check whether the table entry corresponds to the currently open page.
*
* @param row - the table entry
* @returns whether the current page URL matches the URL of the row
*/
function isCurr(row: Row) {
return Boolean(row.url) && pathName.includes(row.url ?? '')
}
---
<!-- Padding must be removed because cells are padded individually. -->
<table
border="1px"
class="cntnr px-0 transition"
x-data="{ activeGroup: undefined }">
<thead class="border-y bg-surface0">
<tr>
{
columns.map((column: ColumnSpec) => (
<th
scope="col"
class:list={[{ 'w-px whitespace-nowrap': !column.isExpanding }]}>
{column.name ?? column.id}
</th>
))
}
</tr>
</thead>
<tbody>
{
data.map((row, idx) => (
/*
In the `tr` element, We use `clip-0` and `translate-x-0` because the
current row indicator breaks on WebKit. Additionally, we use `::after`
instead of `::before` because before breaks the table layout by using up
a cell.
*/
<tr
x-data={JSON.stringify({
groupId: row.groupId ?? idx,
categories: 'post' in row.data && row.data.post?.data.categories,
})}
x-show="!activeCategories.length || activeCategories.some(item => categories.includes(item))"
x-bind:class="{ 'opacity-30': activeGroup && groupId !== activeGroup }"
class="clip-0 translate-x-0 transition after:absolute after:inset-y-0 after:start-0 after:bg-peach hover:bg-surface0"
class:list={{
'border-b': row.isLastSibling,
'bg-surface0 after:w-1': isCurr(row),
}}
@mouseenter="activeGroup = groupId"
@mouseleave="activeGroup = ''">
{Object.entries(row.data)
.filter(([key]) => columnIds.includes(key as keyof typeof row.data))
.sort(
([a], [b]) =>
columnIds.indexOf(a as keyof typeof row.data) -
columnIds.indexOf(b as keyof typeof row.data)
)
.map(([key, value], idx) => (
<td
class:list={{
'w-px whitespace-nowrap': !columns[idx]?.isExpanding,
}}>
{key === 'epic' || key === 'org' || key === 'institute' ? (
<Fragment>
{value && (
<IconName
{...value}
url={row.url}
reserveSpace={key !== 'institute'}
/>
)}
</Fragment>
) : key === 'post' ? (
<Fragment>
{row.url ? (
<Link url={row.url}>{value.data.title}</Link>
) : (
value.data.title
)}
{/* prettier-ignore */}
<ul class="text-subtle">
{value.data.categories.map((category: string) => (
<li x-data={JSON.stringify({ category })} x-bind:class="{ 'text-peach': activeCategories.includes(category) }" class="inline-block after:mx-1 after:content-['·'] last:after:content-none">
{category}
</li>
))}
</ul>
</Fragment>
) : key === 'link' ? (
value && <Link url={value} />
) : key === 'tech' ? (
<Fragment>
{/* prettier-ignore */}
<ul>
{value.map((item: Tech) => (
item.id && <li class="inline after:mx-1 after:text-subtle after:content-['·'] last:after:content-none"><Icon name={item.id} title={item.name} /></li>
))}
</ul>
</Fragment>
) : key === 'period' ? (
<TimePeriod {...value} />
) : key === 'published' || key === 'issued' ? (
<DateComponent date={value} />
) : (
value
)}
</td>
))}
</tr>
))
}
</tbody>
</table>

View file

@ -0,0 +1,40 @@
---
import type { Date as RcvDate } from '@recivi/schema'
import { dateReadable, dateDisplay } from '@/utils/date_fmt'
interface Props {
start?: RcvDate
end?: RcvDate
}
const { start, end } = Astro.props
const startDisplay = start && dateDisplay(start)
const startReadable = start && dateReadable(start)
const endDisplay = end && dateDisplay(end)
const endReadable = end && dateReadable(end)
---
<span class="font-mono">
<span
set:html={startDisplay}
title={startReadable}
/>
&ndash;
{
end ? (
<span
set:html={endDisplay}
title={endReadable}
/>
) : (
<Fragment>
{/* prettier-ignore */}
<Fragment>
<span class="animate-pulse pdf:animate-none text-red" aria-hidden="true">&bull;</span>Present
</Fragment>
</Fragment>
)
}
</span>

View file

@ -0,0 +1,43 @@
---
import Link from '@/components/Link.astro'
import type { Heading } from '@/utils/toc_tree'
interface Props {
heading: Heading
}
const { heading } = Astro.props
---
{
heading.children.length ? (
<details>
<summary class="cursor-pointer rounded hover:bg-surface0">
<span
class="px-1 py-0.5"
class:list={{ 'text-subtle': heading.depth === 0 }}>
{heading.slug ? (
<Link url={`#${heading.slug}`}>{heading.text}</Link>
) : (
heading.text
)}
</span>
</summary>
<ul>
{heading.children.map((child) => (
<li class="relative ml-2 border-s pl-4 before:absolute before:-left-px before:top-0 before:block before:h-4 before:w-4 before:border-b before:border-s last:border-trans">
<Astro.self heading={child} />
</li>
))}
</ul>
</details>
) : (
<span class="px-1 py-0.5">
{heading.slug ? (
<Link url={`#${heading.slug}`}>{heading.text}</Link>
) : (
heading.text
)}
</span>
)
}

20
src/constants/colors.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* mapping of color names to their hex values in both light and dark
* modes respectively
*/
export const COLORS = Object.freeze({
rosewater: ['#dc8a78', '#f5e0dc'],
flamingo: ['#dd7878', '#f2cdcd'],
pink: ['#ea76cb', '#f5c2e7'],
mauve: ['#8839ef', '#cba6f7'],
red: ['#d20f39', '#f38ba8'],
maroon: ['#e64553', '#eba0ac'],
peach: ['#fe640b', '#fab387'],
yellow: ['#df8e1d', '#f9e2af'],
green: ['#40a02b', '#a6e3a1'],
teal: ['#179299', '#94e2d5'],
sky: ['#04a5e5', '#89dceb'],
sapphire: ['#209fb5', '#74c7ec'],
blue: ['#1e66f5', '#89b4fa'],
lavender: ['#7287fd', '#b4befe'],
})

46
src/content.config.ts Normal file
View file

@ -0,0 +1,46 @@
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const posts = defineCollection({
loader: glob({ pattern: '*.mdx', base: './src/posts' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
categories: z.array(z.string()),
isDraft: z.boolean().default(false),
image: z
.object({
url: z.string(),
alt: z.string(),
})
.optional(),
series: z.string().optional(),
}),
})
const pages = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/pages' }),
schema: z.object({
title: z.string(),
description: z.string(),
// This field specifies the order in which the page should appear
// in the navigation. If not specified, the page will be skipped.
index: z.number().optional(),
banRobots: z.boolean().default(false),
// This field is used for overriding the data in the OG image.
ogImage: z
.object({
title: z.string().optional(),
})
.optional(),
}),
})
export const collections = { posts, pages }

7
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />
interface Window {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
Alpine: import('alpinejs').Alpine
}

View file

@ -0,0 +1,17 @@
import { readdirSync } from 'node:fs'
import type { AstroIntegration } from 'astro'
export function watchPlugins(): AstroIntegration {
return {
name: 'watchPlugins',
hooks: {
'astro:config:setup': ({ config, addWatchFile }) => {
const pluginsDir = './src/plugins/'
readdirSync(new URL(pluginsDir, config.root)).forEach((file) => {
addWatchFile(new URL(`${pluginsDir}${file}`, config.root))
})
},
},
}
}

39
src/layouts/content.astro Normal file
View file

@ -0,0 +1,39 @@
---
/**
* layout for all pages (except résumé and writings)
*
* root
* └─ main
* └─ content (this)
*
* All pages use this layout, except résumé and writings that extend the parent
* main layout separately due to their unique needs.
*
* Provides two slots:
* - `head` for adding metadata to the page header
* - default slot for the main content
*/
import type { CollectionEntry } from 'astro:content'
import ScreenLayout from '@/layouts/screen.astro'
import Meta from '@/components/Meta.astro'
interface Props {
frontmatter: CollectionEntry<'pages'>['data'] & { file: string; url: string }
}
const { frontmatter, ...attrs } = Astro.props
---
<ScreenLayout {...attrs}>
<slot
slot="head"
name="head">
<Meta data={frontmatter} />
</slot>
<div class="cntnr my-4">
<slot />
</div>
</ScreenLayout>

91
src/layouts/resume.astro Normal file
View file

@ -0,0 +1,91 @@
---
/**
* layout for résumé index and org/epic pages
*
* root
* └─ main
* └─ resume (this)
*
* Provides two slots:
* - `head` for adding metadata to the page header
* - default slot for the right pane content
*/
import { getEntry } from 'astro:content'
import ScreenLayout from '@/layouts/screen.astro'
import EpicCard from '@/components/EpicCard.astro'
import Meta from '@/components/Meta.astro'
import Table from '@/components/Table.astro'
import Skill from '@/components/Skill.astro'
import { Content as ResumeIndex } from '@/pages/resume/_index.mdx'
import { projectColumns, projectData } from '@/models/table'
import { epics, recivi } from '@/stores/recivi'
// Forward all props to the parent layout `MainLayout`.
const attrs = Astro.props
const id = 'resume'
const metadata = (await getEntry('pages', id))?.data
const hasContent = Astro.slots.has('default')
---
<ScreenLayout {...attrs}>
<slot
name="head"
slot="head">
<!--
We render the résumé index metadata by default.
Subpages can override this using the `head` slot.
-->
{metadata && <Meta data={metadata} />}
</slot>
<!-- content for the left pane -->
<main class="my-4 flex-grow">
<div class="cntnr">
<ResumeIndex />
</div>
<div class="cntnr">
<h2 class="hhr text-peach">Projects</h2>
<p>Click on an epic (or org) name to know more.</p>
</div>
<div class="mb-4 w-full max-w-screen-lg">
<div class="hidden sm:block">
<Table
columns={projectColumns}
data={projectData}
/>
</div>
<div class="sm:hidden">
{epics.map((epic) => <EpicCard {epic} />)}
</div>
</div>
<div class="cntnr">
{
recivi.bio.skills?.length ? (
<Fragment>
<h2 class="hhr text-peach">Skills</h2>
<p>Apart from what's listed above, I also possess these skills:</p>
<ul class="ml-4 list-disc marker:text-peach sm:columns-2">
{recivi.bio.skills?.map((skill) => (
<li>
<Skill {skill} />
</li>
))}
</ul>
</Fragment>
) : undefined
}
</div>
</main>
<!-- content for the right pane -->
<slot slot={hasContent ? 'secondary' : undefined} />
</ScreenLayout>

62
src/layouts/root.astro Normal file
View file

@ -0,0 +1,62 @@
---
/**
* ultimate base layout
*
* Code written here will be available in every page of the site. The goal of
* this layout is to define global head tags such as meta and link tags.
*
* Provides two slots:
* - `head` for adding metadata to the page header
* - default slot for the main content
*/
import type { ClassList } from '@/types/class_list'
import '@/styles/colors.css'
interface Props {
rootClasses?: ClassList
bodyClasses?: ClassList
}
const { rootClasses = [], bodyClasses = [] } = Astro.props
---
<html
lang="en"
class="h-full bg-default text-default"
class:list={rootClasses}>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, viewport-fit=cover"
/>
<meta
name="theme-color"
content="#1e1e2e"
media="(prefers-color-scheme: dark)"
/>
<meta
name="theme-color"
content="#eff1f5"
media="(prefers-color-scheme: light)"
/>
<link
rel="manifest"
href="/site.webmanifest"
/>
<meta
name="generator"
content={Astro.generator}
/>
<slot name="head" />
</head>
<body class:list={bodyClasses}>
<slot />
</body>
</html>

84
src/layouts/screen.astro Normal file
View file

@ -0,0 +1,84 @@
---
/**
* base layout for pages meant for the screen
*
* root
* └─ main (this)
*
* This layout adds the site header and footer and can render content in two
* panes, which are intelligently rendered based on the screen width.
*
* If only primary content is provided, as is the case for most regular pages,
* it will be rendered in the left/singular column.
*
* If secondary content is also provided, it will be rendered in the right
* column on larger screens and in the left/singular column on smaller screens.
*
* It also applies the custom styles for the site.
*
* Provides three slots:
* - `head` for adding metadata to the page header
* - `secondary` for content in the right pane
* - default slot for the main content
*/
import RootLayout from '@/layouts/root.astro'
import SiteHeader from '@/components/SiteHeader.astro'
import SiteFooter from '@/components/SiteFooter.astro'
import '@/styles/screen.css'
// Forward all props to the parent layout `RootLayout`.
const attrs = Astro.props
const hasSecondaryContent = Astro.slots.has('secondary')
---
<RootLayout
{...attrs}
rootClasses={['text-sm', 'leading-normal']}
bodyClasses={{ 'xl:h-full': hasSecondaryContent }}>
<slot
slot="head"
name="head"
/>
<div
x-data="{ activeCategories: [] }"
class="grid grid-cols-1"
class:list={{
'xl:h-full xl:grid-cols-[640px,_1fr]': hasSecondaryContent,
}}>
<!--
left column;
This column is hidden on small widths when there is a right column.
-->
<div
class="flex flex-col overflow-y-auto"
class:list={{ 'hidden xl:flex': hasSecondaryContent }}>
<SiteHeader />
<main class="flex-grow">
<slot />
</main>
<SiteFooter />
</div>
<!-- right column -->
{
hasSecondaryContent && (
<div class="flex flex-col overflow-y-auto">
<SiteHeader classes="xl:hidden" />
<aside class="flex-grow overflow-y-auto xl:border-s">
<slot name="secondary" />
</aside>
<SiteFooter classes="xl:hidden" />
</div>
)
}
</div>
</RootLayout>

133
src/layouts/writings.astro Normal file
View file

@ -0,0 +1,133 @@
---
/**
* layout for writings index and post pages
*
* root
* └─ main
* └─ writings (this)
*
* Provides two slots:
* - `head` for adding metadata to the page header
* - default slot for the right pane content
*/
import { getEntry } from 'astro:content'
import ScreenLayout from '@/layouts/screen.astro'
import Meta from '@/components/Meta.astro'
import Table from '@/components/Table.astro'
import Icon from '@/components/Icon.astro'
import { Content as WritingsIndex } from '@/pages/writings/_index.mdx'
import { getPosts, getCategories } from '@/utils/collections'
import { postColumns, getPostsData } from '@/models/table'
// Forward all props to the parent layout `MainLayout`.
const attrs = Astro.props
const id = 'writings'
const metadata = (await getEntry('pages', id))?.data
const hasContent = Astro.slots.has('default')
const posts = await getPosts()
const postsData = getPostsData(posts)
const categories = await getCategories()
---
<ScreenLayout {...attrs}>
<slot
name="head"
slot="head">
<!--
We render the writings index metadata by default.
Subpages can override this using the `head` slot.
-->
{metadata && <Meta data={metadata} />}
</slot>
<!-- content for the left pane -->
<main class="my-4 flex-grow">
<div class="cntnr">
<WritingsIndex />
</div>
<!-- Only shown when JS is enabled, so it will be revealed by JS. -->
<div
id="all-tags"
class="cntnr hidden">
<h2 class="hhr text-peach">Categories</h2>
{
categories.length ? (
<Fragment>
<p>
Click on a category to only show posts in that particular
category. Click on a selected category to deselect it.
</p>
<fieldset>
<legend class="sr-only">Tags</legend>
{categories.map((category) => (
<label
x-data={`{ category: '${category}' }`}
x-bind:class="{'border-peach': activeCategories.includes(category) }"
class="my-1 mr-1 inline-block rounded border px-1 py-0.5 hover:bg-surface0">
<input
class="hidden"
type="checkbox"
x-model="activeCategories"
id={category}
name="category"
value={category}
/>
{category}
</label>
))}
</fieldset>
</Fragment>
) : (
<p class="text-yellow">
<Icon
source="lucide"
name="circle-slash-2"
/>
No categories to show.
</p>
)
}
</div>
<div class="cntnr">
<h2 class="hhr text-peach">Posts</h2>
</div>
<div id="all-posts">
{
postsData.length ? (
<Table
columns={postColumns}
data={postsData}
/>
) : (
<p class="cntnr text-yellow">
<Icon
source="lucide"
name="circle-slash-2"
/>
No posts to show.
</p>
)
}
</div>
</main>
<!-- content for the right pane -->
<slot slot={hasContent ? 'secondary' : undefined}>Hello</slot>
</ScreenLayout>
<script>
// Only show the tag filtering buttons when JS is enabled.
document.querySelector('#all-tags')?.classList.remove('hidden')
</script>

34
src/models/recivi.ts Normal file
View file

@ -0,0 +1,34 @@
import type {
Epic as RcvEpic,
Org as RcvOrg,
Project as RcvProject,
Role as RcvRole,
Institute as RcvInstitute,
Cert as RcvCert,
} from '@recivi/schema'
export interface Cert extends RcvCert {
institute: Institute
}
export interface Institute extends Omit<RcvInstitute, 'certs'> {
certs: Cert[]
}
export interface Role extends RcvRole {
org: Org
epics: Epic[]
}
export interface Org extends Omit<RcvOrg, 'roles'> {
roles: Role[]
}
export interface Project extends RcvProject {
epic: Epic
}
export interface Epic extends Omit<RcvEpic, 'projects'> {
role: Role | null
projects: Project[]
}

154
src/models/table.ts Normal file
View file

@ -0,0 +1,154 @@
import type { CollectionEntry } from 'astro:content'
import type { Period, Tech, Url, Date as RcvDate } from '@recivi/schema'
import type { Epic, Org, Institute } from '@/models/recivi'
import { roleTypeDisplay, certDisplay } from '@/utils/recivi'
import { certs, projects, roles } from '@/stores/recivi'
interface PostData {
published: Date
post: CollectionEntry<'posts'>
}
interface CertData {
institute: Institute
link: Url | undefined
name: string
issued: RcvDate
}
interface RoleData {
org: Org
name: string
epic: Epic | undefined
period: Period | undefined
type: string | undefined
}
interface ProjectData {
epic: Epic
name: string
link: Url | undefined
org: Org | undefined
tech: Tech[]
}
export type Data = PostData | CertData | RoleData | ProjectData
export type Row = {
isLastSibling: boolean
url?: string
groupId: string | number | undefined
} & (
| { type: 'post'; data: PostData }
| { type: 'cert'; data: CertData }
| { type: 'role'; data: RoleData }
| { type: 'project'; data: ProjectData }
)
export type ColumnSpec = {
name?: string
isExpanding?: boolean
} & (
| { type: 'post'; id: keyof PostData }
| { type: 'cert'; id: keyof CertData }
| { type: 'role'; id: keyof RoleData }
| { type: 'project'; id: keyof ProjectData }
)
export const postColumns: ColumnSpec[] = [
{ type: 'post', id: 'published' },
{ type: 'post', id: 'post', name: 'post & tags', isExpanding: true },
]
export const certColumns: ColumnSpec[] = [
{ type: 'cert', id: 'institute' },
{ type: 'cert', id: 'link' },
{ type: 'cert', id: 'name', isExpanding: true },
{ type: 'cert', id: 'issued' },
]
export const roleColumns: ColumnSpec[] = [
{ type: 'role', id: 'org' },
{ type: 'role', id: 'name', isExpanding: true },
{ type: 'role', id: 'epic' },
{ type: 'role', id: 'period' },
]
export const projectColumns: ColumnSpec[] = [
{ type: 'project', id: 'epic' },
{ type: 'project', id: 'name' },
{ type: 'project', id: 'link' },
{ type: 'project', id: 'org' },
{ type: 'project', id: 'tech', isExpanding: true },
]
/**
* Get the data for the posts table in a format that can be used by the `Table`
* component.
*
* @param posts - the posts to include in the table
* @returns the data for the posts table
*/
export function getPostsData(posts: CollectionEntry<'posts'>[]) {
return posts.map((post, idx): Row => {
const year = post.data.pubDate.getFullYear()
return {
type: 'post',
data: {
published: post.data.pubDate,
post,
},
isLastSibling: year !== posts[idx + 1]?.data.pubDate.getFullYear(),
groupId: year,
url: `/writings/posts/${post.id.substring(5)}`,
}
})
}
export const certData = certs.map(
(cert, idx): Row => ({
type: 'cert',
data: {
name: certDisplay(cert),
institute: cert.institute,
link: cert.institute.url,
issued: cert.issue,
},
isLastSibling: cert.institute !== certs[idx + 1]?.institute,
groupId: cert.institute.id,
})
)
export const projectData = projects.map(
(project, idx): Row => ({
type: 'project',
data: {
name: project.name,
epic: project.epic,
org: project.epic.role?.org,
link: project.url,
tech: project.technologies ?? [],
},
isLastSibling: project.epic !== projects[idx + 1]?.epic,
groupId: project.epic.id,
url: `/resume/epics/${project.epic.id}/`,
})
)
export const roleData = roles.map(
(role, idx): Row => ({
type: 'role',
data: {
name: role.name,
org: role.org,
epic: role.epics[0],
type: role.type && roleTypeDisplay(role.type),
period: role.period,
},
isLastSibling: role.org !== roles[idx + 1]?.org,
groupId: role.org.id,
url: `/resume/orgs/${role.org.id}/`,
})
)

11
src/pages/404.mdx Normal file
View file

@ -0,0 +1,11 @@
---
layout: '@/layouts/content.astro'
title: 404 Not Found
description: Sorry, but the content you are looking for cannot be found.
banRobots: true
---
# Not Found
Sorry, but there's nothing at this URL. Use the nav bar to find your way around.

54
src/pages/index.mdx Normal file
View file

@ -0,0 +1,54 @@
---
layout: '@/layouts/content.astro'
title: Home
description: Hi! I'm Tera, a hobbyist software developer, with an affinity for Linux
index: 0
---
import ContactList from '@/components/ContactList.astro'
import Pronouns from '@/components/Pronouns.astro'
import FunFact from '@/components/FunFact.astro'
import Kaomoji from '@/components/Kaomoji.astro'
import { recivi } from '@/stores/recivi'
import { addressDisplay, labelsDisplay } from '@/utils/recivi'
export const { origin, residence, labels } = recivi.bio
<h1 className="sr-only">About</h1>
Hello! I'm **{
recivi.bio.name
}** (<Pronouns />), {
labels && labelsDisplay(labels)
} {
residence && `based in ${addressDisplay(residence)}`
} {
origin && `(originally from ${addressDisplay(origin)})`
} who <FunFact />.
Welcome to my personal website! Please feel free to look around. I would recommend
looking at [my résumé / project portfolio](/resume).
*NOTE*: Not all projects are listed. If you want to find other projects, look at my GitHub.
## Keys
- OpenPGP: public key is on the keyserver `keyserver.ubuntu.com` with the email `me@terah.dev`
and fingerprint `1038C5D362F00D61532E9FD18FA7DD57BA6CEA37`.
## Credits
This website was based on [Recivi's Portfolio Example](https://github.com/recivi/portfolio).
I'd recommend checking it out, and the [Recivi](https://recivi.pages.dev/) project in general.
## Contact
If you want or need to reach out for me to any reason, please feel free to.
If you have a new and interesting idea that I could collaborate on, or want to
ask a question that I might be able to answer, or simply want to talk, you
should drop by and say hi!
<ContactList />

View file

@ -0,0 +1,30 @@
---
slug: resume
title: Résumé
description: Peruse my résumé, encompassing my time developing software both as a hobbyist and full-time for many years.
index: 2
---
import Link from '@/components/Link.astro'
import { recivi } from '@/stores/recivi'
export const githubUrl = recivi.bio.profiles?.find(
(profile) => profile.site.id === 'github' || profile.site.name === 'GitHub'
)?.url
# Résumé
{/* Spans `#hobby-percent` and `#job-percent` are filled by JS. */}
I've developed software as a hobbyist for <span class="border-dashed" id="hobby-percent">almost all</span>
of my life.
All the code I write for non-commercial use is open-source / source-available
software. Most of it is open-source. You can find my code on various Git hosting
sites, which are all listed on my <Link url='/'>contact page</Link>.
If you like what you see here and want to work with me, you can grab the
<Link url={import.meta.env.DEV ? '/resume/pdf' : '/resume.pdf'}>PDF version</Link>
of this résumé. Print responsibly.

View file

@ -0,0 +1,125 @@
---
import type { Tech } from '@recivi/schema'
import ResumeLayout from '@/layouts/resume.astro'
import IconName from '@/components/IconName.astro'
import Meta from '@/components/Meta.astro'
import Link from '@/components/Link.astro'
import type { Epic } from '@/models/recivi'
import { epics } from '@/stores/recivi'
import { urlToDest } from '@/utils/recivi'
export async function getStaticPaths() {
return epics.map((epic) => ({
params: {
id: epic.id,
},
props: {
epic,
},
}))
}
interface Props {
epic: Epic
}
const { epic } = Astro.props
const metadata = {
title: `Epic: ${epic.name}`,
description: `Know about the epic ${epic.name} and my work on it.`,
banRobots: true,
}
---
<ResumeLayout>
<slot
name="head"
slot="head">
<Meta data={metadata} />
</slot>
<div class="cntnr my-4">
<h1 class="text-3xl text-peach sm:text-5xl">
<IconName {...epic} />
</h1>
<!-- Org -->
{
epic.role?.org && (
<p>
<span class="text-subtle">Org:</span>
<IconName
{...epic.role.org}
url={`/resume/orgs/${epic.role.org.id}`}
/>
</p>
)
}
{
epic.description ? (
<p set:html={epic.description} />
) : epic.summary ? (
<p set:html={epic.summary} />
) : undefined
}
{
epic.projects.map((project) => (
<div>
<h2 class="hhr text-peach">{project.name}</h2>
<dl class="mb-2 grid grid-cols-[auto,_1fr] gap-x-2 [&:not(:has(dt))]:hidden">
{project.url && (
<Fragment>
<dt class="text-subtle">URL:</dt>
<dd>
<Link url={project.url}>{urlToDest(project.url)}</Link>
</dd>
</Fragment>
)}
{project.role && (
<Fragment>
<dt class="text-subtle">Role:</dt>
<dd>{project.role}</dd>
</Fragment>
)}
</dl>
{project.description ? (
<p set:html={project.description} />
) : project.summary ? (
<p set:html={project.summary} />
) : undefined}
{project.highlights?.length ? (
<Fragment>
<h3 class="hhrs text-peach">Highlights</h3>
<ul class="list-disc pl-4 marker:text-peach">
{project.highlights?.map((hl) => (
<li set:html={hl} />
))}
</ul>
</Fragment>
) : undefined}
{project.technologies?.length && (
<Fragment>
<h3 class="hhrs text-peach">Technologies</h3>
<ul class="list-disc columns-2 pl-4 marker:text-peach">
{project.technologies?.map((item: Tech) => (
<li>
<IconName {...item} />
</li>
))}
</ul>
</Fragment>
)}
</div>
))
}
</div>
</ResumeLayout>

View file

@ -0,0 +1,5 @@
---
import ResumeLayout from '@/layouts/resume.astro'
---
<ResumeLayout />

View file

@ -0,0 +1,125 @@
---
import ResumeLayout from '@/layouts/resume.astro'
import IconName from '@/components/IconName.astro'
import Meta from '@/components/Meta.astro'
import TimePeriod from '@/components/TimePeriod.astro'
import type { Org } from '@/models/recivi'
import { orgs } from '@/stores/recivi'
import { addressDisplay } from '@/utils/recivi'
import { roleTypeDisplay, roleLocationDisplay } from '@/utils/recivi'
export async function getStaticPaths() {
return orgs.map((org) => ({
params: {
id: org.id,
},
props: {
org,
},
}))
}
interface Props {
org: Org
}
const { org } = Astro.props
const metadata = {
title: `Org: ${org.name}`,
description: `Know more about the org ${org.name} and my work there.`,
banRobots: true,
}
---
<ResumeLayout>
<slot
name="head"
slot="head">
<Meta data={metadata} />
</slot>
<div class="cntnr my-4">
<h1 class="text-3xl text-peach sm:text-5xl">
<IconName {...org} />
</h1>
{
org.address && (
<p>
<span class="text-subtle">Address:</span>
{addressDisplay(org.address)}
</p>
)
}
{
org.description ? (
<p set:html={org.description} />
) : org.summary ? (
<p set:html={org.summary} />
) : undefined
}
{
org.roles.map((role) => (
<div>
<h2 class="hhr text-peach">
{role.name}
{role.type &&
role.type !== 'full-time' &&
`(${roleTypeDisplay(role.type)})`}
</h2>
<dl class="[:not(:has(dt))]:hidden mb-2 grid grid-cols-[auto,_1fr] gap-x-2">
{role.period && (
<Fragment>
<dt class="text-subtle">Period:</dt>
<dd>
<TimePeriod {...role.period} />
</dd>
</Fragment>
)}
{role.location && (
<Fragment>
<dt class="text-subtle">Location:</dt>
<dd>{roleLocationDisplay(role.location)}</dd>
</Fragment>
)}
{role.epics.length ? (
<Fragment>
<dt class="text-subtle">Epics:</dt>
<dd>
{role.epics.map((epic) => (
<IconName
{...epic}
url={`/resume/epics/${epic.id}`}
/>
))}
</dd>
</Fragment>
) : undefined}
</dl>
{role.description ? (
<p set:html={role.description} />
) : role.summary ? (
<p set:html={role.summary} />
) : undefined}
{role.highlights?.length ? (
<Fragment>
<h3 class="hhrs text-peach">Highlights</h3>
<ul class="mb-4 list-disc pl-4 marker:text-peach">
{role.highlights?.map((hl) => (
<li set:html={hl} />
))}
</ul>
</Fragment>
) : undefined}
</div>
))
}
</div>
</ResumeLayout>

125
src/pages/resume/pdf.astro Normal file
View file

@ -0,0 +1,125 @@
---
import RootLayout from '@/layouts/root.astro'
import Link from '@/components/Link.astro'
import ContactList from '@/components/ContactList.astro'
import EpicEntry from '@/components/EpicEntry.astro'
import OrgEntry from '@/components/OrgEntry.astro'
import { recivi, orgs, epics } from '@/stores/recivi'
import { site } from '@/stores/site'
import { getPageTitle } from '@/utils/meta'
import { skillDisplay } from '@/utils/recivi'
import '@/styles/print.css'
// Forward all props to the parent layout `RootLayout`.
const { ...attrs } = Astro.props
const pageTitle = getPageTitle('Résumé PDF')
---
<RootLayout
{...attrs}
rootClasses={['print:light-theme', 'pdf', 'text-[12px]', 'leading-snug']}>
<slot
slot="head"
name="head">
<title>{pageTitle}</title>
<link
rel="preconnect"
href="https://fonts.googleapis.com"
crossorigin=""
/>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin=""
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Spectral:ital,wght@0,400..800;1,400..800&display=swap"
/>
</slot>
<div>
<!-- Button panel -->
<div class="mx-auto my-4 w-[210mm] print:hidden">
<Link
url="/resume"
class="text-[1.167rem] underline decoration-blue before:mx-0.5 before:inline-block before:text-blue before:content-['←'] visited:decoration-mauve visited:before:text-mauve hover:decoration-wavy hover:before:-translate-x-1"
>Back to résumé</Link
>
</div>
<!-- Page -->
<div
class="print:bg-white relative mx-auto mt-2 h-[297mm] w-[210mm] origin-top border p-[5mm] shadow-md after:absolute after:inset-[5mm] after:-z-10 after:border after:border-red hover:shadow-lg print:m-0 print:scale-100 print:border-none print:shadow-none print:after:hidden print:hover:shadow-none">
<!-- Top segment -->
<header class="mb-[5mm] text-center">
<h1 class="font-serif text-xl font-bold">
<Link url={site.baseUrl}>Tera</Link>
</h1>
{
recivi.bio.summary && (
<p
class="mb-2"
set:html={recivi.bio.summary}
/>
)
}
<ContactList />
</header>
<!-- Two-pane content -->
<main class="grid grid-cols-2 gap-[5mm]">
<!-- Left pane -->
<div>
<h2 class="hhre mb-2 font-serif text-lg font-medium">Projects</h2>
<ul>
{
epics
.filter((epic) =>
epic.projects.some((project) =>
project.tags?.includes('resume_pdf')
)
)
.map((epic) => <EpicEntry {epic} />)
}
</ul>
<h2 class="hhre mb-2 mt-[5mm] font-serif text-lg font-medium">
Other skills
</h2>
{
recivi.bio.skills?.length ? (
<ul>
{recivi.bio.skills?.map((skill) => (
<Fragment>
{/* prettier-ignore */}
<li class="inline after:content-[','] last:after:content-none">{skillDisplay(skill)}</li>
</Fragment>
))}
</ul>
) : undefined
}
</div>
<!-- Right pane -->
<div>
<h2 class="hhre mb-2 font-serif text-lg font-medium">Roles</h2>
<ul>
{
orgs
.filter((org) =>
org.roles.some((role) => role.tags?.includes('resume_pdf'))
)
.map((org) => <OrgEntry {org} />)
}
</ul>
</div>
</main>
</div>
</div>
</RootLayout>

39
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,39 @@
import type { APIRoute } from 'astro'
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import rss from '@astrojs/rss'
import { getContainerRenderer as getMDXRenderer } from '@astrojs/mdx'
import { loadRenderers } from 'astro:container'
import { render } from 'astro:content'
import { getEntry } from 'astro:content'
import { site as siteStore } from '@/stores/site'
import { getPosts } from '@/utils/collections'
export const GET: APIRoute<Record<string, never>> = async function ({
site,
url,
}) {
const posts = await getPosts()
const items = []
const renderers = await loadRenderers([getMDXRenderer()])
const container = await AstroContainer.create({ renderers })
for (const post of posts) {
const { Content } = await render(post)
const content = await container.renderToString(Content)
// Astro's RSS feed uses trailing slashes.
const link = new URL(
`/writings/posts/${post.id.substring(5)}/`,
url.origin
).toString()
items.push({ ...post.data, link, content })
}
return await rss({
title: siteStore.title,
description: (await getEntry('pages', 'writings'))?.data.description ?? '',
site: site ?? '',
items,
// TODO: Consider including an XSL stylesheet for the feed.
})
}

View file

@ -0,0 +1,21 @@
import type { APIRoute } from 'astro'
import { site as siteStore } from '@/stores/site'
export const GET: APIRoute = async function () {
return new Response(
JSON.stringify({
name: siteStore.title,
start_url: '/',
icons: [
{ src: '/icon-512.png', type: 'image/png', sizes: '512x512' },
{
src: '/icon-512-maskable.png',
type: 'image/png',
sizes: '512x512',
purpose: 'maskable',
},
],
})
)
}

View file

@ -0,0 +1,18 @@
---
slug: writings
title: Blog Posts
description: Read entries in my digital garden about my experiences in building software, and learning new things along the way.
index: 1
---
import Icon from '@/components/Icon.astro'
# Writings
Welcome to my blog, known to my friends as my "crazy person ramblings". I might
write something here, but it probably won't be *too* often.
If you like what you read here, please [tell me](/)! You can subscribe to
the [<Icon source="lucide" name="rss"/> RSS feed](/rss.xml) to get updates on
the bi-yearly posts.

View file

@ -0,0 +1,5 @@
---
import WritingsLayout from '@/layouts/writings.astro'
---
<WritingsLayout />

View file

@ -0,0 +1,113 @@
---
import { render, type CollectionEntry } from 'astro:content'
import WritingsLayout from '@/layouts/writings.astro'
import Meta from '@/components/Meta.astro'
import Link from '@/components/Link.astro'
import DateComponent from '@/components/Date.astro'
import Table from '@/components/Table.astro'
import TocNode from '@/components/TocNode.astro'
import { getPosts } from '@/utils/collections'
import { getModDate } from '@/utils/mod_date'
import { buildTree } from '@/utils/toc_tree'
import { postColumns, getPostsData } from '@/models/table'
export async function getStaticPaths() {
const posts = await getPosts()
return posts.map((post) => ({
params: {
slug: post.id.substring(5),
},
props: {
post,
},
}))
}
interface Props {
post: CollectionEntry<'posts'>
}
const { post } = Astro.props
const { Content, headings } = await render(post)
const rootHeading = buildTree(headings)
const modDate = getModDate(post.id)
const allPosts = await getPosts()
const seriesPosts = allPosts.filter(
(item) => item.data.series === post.data.series
)
const seriesPostsData = getPostsData(seriesPosts)
const metadata = {
title: `Post: ${post.data.title}`,
description: post.data.description,
}
---
<WritingsLayout>
<slot
name="head"
slot="head">
<Meta data={metadata} />
</slot>
<div class="cntnr my-4">
<h1 class="text-3xl text-peach sm:text-5xl">{post.data.title}</h1>
<dl class="mb-2 grid grid-cols-[auto,_1fr] gap-x-2">
<dt class="text-subtle">Tags:</dt>
<dd>
<ul class="inline">
{
post.data.categories.map((category: string) => (
<li class="inline-block after:mr-1 after:text-subtle after:content-['·'] last:after:content-none">
{category}
</li>
))
}
</ul>
</dd>
<dt class="text-subtle">First published:</dt>
<dd><DateComponent date={post.data.pubDate} /></dd>
<dt class="text-subtle">Last updated:</dt>
<dd><DateComponent date={modDate} /></dd>
</dl>
<TocNode heading={rootHeading} />
<hr class="-mx-4 my-4" />
<article>
<Content />
</article>
<h2 class="hhr text-peach">Responses</h2>
<p>
If you have thoughts or feelings about this post, send them my way via
your preferred <Link url="/">communication channel</Link>. Webmention
support will be added soon.
</p>
</div>
{
post.data.series && (
<Fragment>
<div class="cntnr">
<h2 class="hhr text-peach">Series: '{post.data.series}'</h2>
<p>
This post is part of the '{post.data.series}' series. If you liked
it, you might also like these other posts in the series.
</p>
</div>
<Table
columns={postColumns}
data={seriesPostsData}
/>
</Fragment>
)
}
</WritingsLayout>

View file

@ -0,0 +1,125 @@
/**
* The site contains three types of content.
*
* - Astro components
* - MDX pages
* - MDX posts
*
* Styles which must apply only to MDX posts and pages, but not to Astro
* components, go here.
*/
import type { VFile } from 'vfile'
import type { Element, Node, Root } from 'hast'
import type { MdxJsxTextElement, MdxJsxFlowElement } from 'mdast-util-mdx'
import type { RehypePlugin } from '@astrojs/markdown-remark'
import { visit } from 'unist-util-visit'
// We have to use relative imports because this file is used in Astro config.
import { tw } from '../utils/tailwind'
const TAG_UTIL_MAP = {
common: {
blockquote: tw`border-l-2 border-l-peach pl-2`,
hr: tw`my-4 flex h-fit items-center justify-center border-none text-subtle after:content-["*_*_*"]`,
kbd: tw`rounded border border-b-2 border-b-peach bg-surface0 px-1 pb-0.5 pt-1`,
li: tw`marker:text-peach gfm-done:marker:text-green gfm-done:marker:content-["✓_"] gfm-todo:marker:content-["▢_"] [&.task-list-item_input]:appearance-none [&[id^="user-content-fn"]_p]:my-0`,
ol: tw`ml-4 list-decimal [:not(li)>&]:my-4 [li>&]:list-lower-roman`,
pre: tw`my-4 border p-2 [&>code>span.line:empty]:hidden`,
ul: tw`ml-4 list-disc marker:mr-2 [:not(li)>&]:my-4`,
} as Record<string, string>,
page: {
h1: tw`text-3xl text-peach sm:text-5xl`,
h2: tw`hhr text-peach`,
h3: tw`hhrs text-peach`,
} as Record<string, string>,
post: {
h2: tw`text-2xl`,
h3: tw`text-xl`,
h4: tw`text-lg`,
h5: tw`text-base`,
h6: tw`text-sm`,
table: tw`border`,
} as Record<string, string>,
}
type SourceType = 'page' | 'post'
function isElement(elem: Node): elem is Element {
return elem.type === 'element'
}
function isMdxJsxTextElement(
elem: Node
): elem is MdxJsxTextElement | MdxJsxFlowElement {
return elem.type === 'mdxJsxTextElement' || elem.type === 'mdxJsxFlowElement'
}
function getVisitor(type: SourceType) {
return function visitor(elem: Node) {
if (isElement(elem)) {
styleElem(type, elem)
} else if (isMdxJsxTextElement(elem)) {
styleMdxJsxElem(type, elem)
}
}
}
function styleMdxJsxElem(
type: SourceType,
elem: MdxJsxTextElement | MdxJsxFlowElement
) {
if (!elem.name) {
return
}
const existingClasses = elem.attributes.find(
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'class'
)?.value
const value = [
existingClasses,
TAG_UTIL_MAP[type]?.[elem.name],
TAG_UTIL_MAP.common[elem.name],
]
.flat()
.filter(Boolean)
.join(' ')
if (value) {
elem.attributes.push({ type: 'mdxJsxAttribute', name: 'class', value })
}
}
function styleElem(type: SourceType, elem: Element) {
if (elem.properties.id === 'footnote-label') {
// Despite being part of a post's content, the footnote section heading is
// styled like a page heading.
elem.properties.className = TAG_UTIL_MAP.page.h2
return
}
const className = [
elem.properties.className,
elem.properties.class,
TAG_UTIL_MAP[type]?.[elem.tagName],
TAG_UTIL_MAP.common[elem.tagName],
]
.flat()
.filter(Boolean)
.join(' ')
if (className) {
elem.properties.className = className
}
}
/**
* a rehype plugin to add Tailwind classes to HTML elements to enable styling
* without running into specificity issues
*/
export const rehypeTailwind: RehypePlugin = () => {
return (tree: Root, file: VFile) => {
const type: SourceType = file.path.includes('/posts') ? 'post' : 'page'
visit(tree, getVisitor(type))
}
}

42
src/scripts/print.ts Normal file
View file

@ -0,0 +1,42 @@
import { join, resolve } from 'node:path'
import chalk from 'chalk'
import puppeteer from 'puppeteer'
const projectRoot = resolve(import.meta.filename, '../../..')
const pdfUrl = join(projectRoot, 'dist/resume.pdf')
/**
* Returns the current time in %H:%M:%S format, which is the format used
* by Astro in the output of the build subcommand.
*
* @returns the current time formatted as %H:%M:%S
*/
function timestamp() {
const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false })
return chalk.dim(timestamp)
}
/**
* Runs Puppeteer, visits the PDF page in the preview site and "prints"
* the page to a PDF file inside the `dist/` directory.
*/
async function printToPdf() {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto('http://localhost:4322/resume/pdf', {
waitUntil: 'networkidle0',
})
await page.pdf({
path: pdfUrl,
format: 'A4',
margin: { top: 0, right: 0, bottom: 0, left: 0 }, // Page already has margins.
printBackground: true, // Divider lines use background colors.
})
await browser.close()
}
console.log(chalk.green.inverse(' building résumé PDF '))
console.log(`${timestamp()} ${chalk.blue('[print]')} Building PDF...`)
await printToPdf()
console.log(`${timestamp()} ${chalk.blue('[print]')} ${chalk.green('✓ built')}`)

0
src/stores/recivi.d.ts vendored Normal file
View file

80
src/stores/recivi.ts Normal file
View file

@ -0,0 +1,80 @@
import { readFileSync } from 'node:fs'
import type { Resume } from '@recivi/schema'
import type { Cert, Institute, Epic, Org, Project, Role } from '@/models/recivi'
import { site } from '@/stores/site'
async function loadRecivi() {
if (site.reciviUrl.startsWith('file://')) {
const text = readFileSync(site.reciviUrl.replace('file://', ''), 'utf-8')
return JSON.parse(text) as Resume
}
const res = await fetch(site.reciviUrl)
return (await res.json()) as Resume
}
export interface Pronouns {
firstSet: string
secondSet: string
}
export interface PronounSet {
language: string
pronouns: Pronouns[]
}
export const recivi: Resume = await loadRecivi()
export const institutes: Institute[] =
recivi.education?.map((rcvInstitute) => {
const institute: Institute = {
...rcvInstitute,
certs: [] as Cert[],
}
institute.certs =
rcvInstitute.certs?.map((cert) => ({ ...cert, institute })) ?? []
return institute
}) ?? []
export const certs: Cert[] = institutes.flatMap(
(institute: Institute) => institute.certs
)
export const epics: Epic[] =
recivi.creations?.map((rcvEpic) => {
const epic: Epic = {
...rcvEpic,
role: null,
projects: [] as Project[],
}
epic.projects =
rcvEpic.projects?.map((project) => ({ ...project, epic })) ?? []
return epic
}) ?? []
export const projects: Project[] = epics.flatMap((epic: Epic) => epic.projects)
export const orgs: Org[] =
recivi.work?.map((rcvOrg) => {
const org: Org = {
...rcvOrg,
roles: [] as Role[],
}
org.roles = rcvOrg.roles?.map((role) => ({ ...role, org, epics: [] })) ?? []
return org
}) ?? []
export const roles: Role[] = orgs.flatMap((org: Org) => org.roles)
// Link roles and epics
roles.forEach((role) => {
epics.forEach((epic) => {
if (epic.id && role.epicIds?.includes(epic.id)) {
epic.role = role
role.epics.push(epic)
}
})
})

38
src/stores/site.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Configure your portfolio site here.
*/
interface Site {
/** the final URL where the site is deployed, no trailing slash */
baseUrl: string
/**
* the URL to the Récivi data file to populate the site
*
* This can be configured to either refer to a local JSON file (using the
* `file://` scheme) or a remote URL.
*/
reciviUrl: string
/** the creator ID that is used for author attribution in the Fediverse */
fediverse?: string
/** the title of the website
*
* This title is used in the site header, Open Graph images, tab/window title
* and meta tags.
*/
title: string
/**
* whether include a link about Récivi in the footer
*
* We'd appreciate it a lot if you keep this enabled to help us spread the
* word about Récivi.
*/
showCredit?: boolean
}
export const site: Site = {
baseUrl: 'https://terah.dev',
reciviUrl: 'file://./recivi.json',
title: "Tera's Corner of the Web",
showCredit: false,
}

110
src/styles/colors.css Normal file
View file

@ -0,0 +1,110 @@
/*
This sheet defines colors from Catppuccin Latte and Catppuccin Mocha that are
subsequently used by Tailwind. Using CSS variables avoids the need to use
`dark:` utilities separately.
Imported by `root.astro`.
*/
:root {
color-scheme: normal;
--color-text: #4c4f69;
--color-subtext1: #5c5f77;
--color-subtext0: #6c6f85;
--color-overlay2: #7c7f93;
--color-overlay1: #8c8fa1;
--color-overlay0: #9ca0b0;
--color-surface2: #acb0be;
--color-surface1: #bcc0cc;
--color-surface0: #ccd0da;
--color-base: #eff1f5;
--color-mantle: #e6e9ef; /* avoid */
--color-crust: #dce0e8; /* avoid */
--color-rosewater: #dc8a78;
--color-flamingo: #dd7878;
--color-pink: #ea76cb;
--color-mauve: #8839ef;
--color-red: #d20f39;
--color-maroon: #e64553;
--color-peach: #fe640b;
--color-yellow: #df8e1d;
--color-green: #40a02b;
--color-teal: #179299;
--color-sky: #04a5e5;
--color-sapphire: #209fb5;
--color-blue: #1e66f5;
--color-lavender: #7287fd;
}
@media (prefers-color-scheme: dark) {
:root:not(.light-theme) {
color-scheme: dark;
--color-text: #cdd6f4;
--color-subtext1: #bac2de;
--color-subtext0: #a6adc8;
--color-overlay2: #9399b2;
--color-overlay1: #7f849c;
--color-overlay0: #6c7086;
--color-surface2: #585b70;
--color-surface1: #45475a;
--color-surface0: #313244;
--color-base: #1e1e2e;
--color-mantle: #181825; /* avoid */
--color-crust: #11111b; /* avoid */
--color-rosewater: #f5e0dc;
--color-flamingo: #f2cdcd;
--color-pink: #f5c2e7;
--color-mauve: #cba6f7;
--color-red: #f38ba8;
--color-maroon: #eba0ac;
--color-peach: #fab387;
--color-yellow: #f9e2af;
--color-green: #a6e3a1;
--color-teal: #94e2d5;
--color-sky: #89dceb;
--color-sapphire: #74c7ec;
--color-blue: #89b4fa;
--color-lavender: #b4befe;
}
}
@media print {
:root {
/* Use black text on white background for print */
--color-text: #222222;
--color-overlay1: #878787;
}
}
/*
* The code below is copied from Shiki docs, with modifications as specified by
* Astro, and additional customisations to avoid use of `!important`.
* https://shiki.style/guide/dual-themes#query-based-dark-mode
* https://docs.astro.build/en/guides/markdown-content/#shiki-configuration
* https://shiki.style/guide/dual-themes#without-default-color
*/
.astro-code,
.astro-code span {
color: var(--shiki-light);
background-color: var(--shiki-light-bg);
font-style: var(--shiki-light-font-style);
font-weight: var(--shiki-light-font-weight);
text-decoration: var(--shiki-light-text-decoration);
}
@media (prefers-color-scheme: dark) {
.astro-code,
.astro-code span {
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
font-style: var(--shiki-dark-font-style);
font-weight: var(--shiki-dark-font-weight);
text-decoration: var(--shiki-dark-text-decoration);
}
}

21
src/styles/print.css Normal file
View file

@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Element specific styles */
a {
@apply underline decoration-blue visited:decoration-mauve hover:decoration-wavy print:no-underline;
}
code {
@apply border bg-surface0 px-1 py-0.5 font-mono;
}
}
@layer components {
.hhre {
@apply flex items-center gap-2 after:h-px after:flex-grow after:bg-surface1;
}
}

134
src/styles/screen.css Normal file
View file

@ -0,0 +1,134 @@
/*
This sheet defines styles that must apply to the complete site, which includes both
content inside Markdown and markup defined in Astro components.
Styles that apply to components that would normally only appear in MDX content should
be defined in `rehype_tailwind.ts`.
Imported by `main.astro`.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Apply universal styles, kind of like an anti-reset. */
@layer base {
/* Add vertical margins to block components. */
h1,
h2,
h3,
h4,
h5,
h6,
p,
table {
@apply my-4;
}
/* Top level headings should be in bold font... */
h1 {
@apply font-bold;
}
/* ...but for everything else, bold is too heavy. */
h2,
h3,
h4,
h5,
h6,
strong,
th {
@apply font-medium;
}
/* Element specific styles */
a {
@apply underline decoration-blue visited:decoration-mauve hover:decoration-wavy;
}
a[data-rel='parent'] {
/* Parent links */
@apply before:mx-0.5 before:inline-block before:text-blue before:transition-transform before:content-["←"] visited:before:text-mauve hover:before:-translate-x-1;
}
a[href$='.pdf'],
a[href^='http'],
a[href^='mailto:'],
a[href^='tel:'] {
/* Special links */
@apply after:mx-0.5 after:inline-block after:text-blue after:transition-transform visited:after:text-mauve hover:after:transform;
}
/* Download links */
a[href$='.pdf'] {
@apply after:content-["↓"] hover:after:translate-y-1;
}
a[href^='http'] {
/* External links */
/* IDEA: ⤴ is another nice arrow that conveys a similar idea to ↗. */
@apply after:content-["↗"] hover:after:-translate-y-1 hover:after:translate-x-1;
}
a[href^='mailto:'],
a[href^='tel:'] {
/* Non-web links */
@apply after:content-["→"] hover:after:translate-x-1;
}
h1 {
@apply text-3xl text-peach sm:text-5xl;
}
table {
@apply w-full;
}
th {
@apply border-y bg-surface0 text-left;
}
th,
td {
@apply px-2 py-0.5 first:pl-4 last:pr-4;
}
code {
@apply px-1 py-0.5 [:not(pre)>&]:border [:not(pre)>&]:bg-surface0;
}
}
@layer components {
.cntnr {
@apply w-full px-4 mx-safe sm:max-w-screen-sm;
}
.hhr-core {
@apply flex items-center gap-2;
}
.hhrs {
/* Uses `-ms-4` to reverse the padding applied by the container. */
@apply hhr-core -ms-4 before:h-px before:w-2 before:bg-peach;
}
.hhre {
/* Uses `-me-4` to reverse the padding applied by the container. */
@apply hhr-core -me-4 after:h-px after:w-2 after:flex-grow after:bg-surface1;
}
.hhr {
@apply hhrs hhre;
}
}
@layer utilities {
.clip-0 {
clip-path: inset(0);
}
}

4
src/types/class_list.ts Normal file
View file

@ -0,0 +1,4 @@
/**
* describes the type of the `class:list` attribute in Astro
*/
export type ClassList = string | Record<string, unknown> | ClassList[]

4
src/types/extensions.ts Normal file
View file

@ -0,0 +1,4 @@
/**
* a type with specific keys converted to optional
*/
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

32
src/utils/collections.ts Normal file
View file

@ -0,0 +1,32 @@
import { type CollectionEntry, getCollection } from 'astro:content'
export async function getPages(): Promise<CollectionEntry<'pages'>[]> {
const pages: CollectionEntry<'pages'>[] = await getCollection('pages')
return pages
}
/**
* Get the list of posts, excluding the posts that have been marked as a draft.
* Posts have numerical prefixes so they are always sorted.
*
* @returns a collection of posts
*/
export async function getPosts(): Promise<CollectionEntry<'posts'>[]> {
const posts: CollectionEntry<'posts'>[] = await getCollection('posts')
return posts
.filter((item) => import.meta.env.DEV || !item.data.isDraft)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()) // sort in reverse order
}
/**
* Get the list of categories, sorted alphabetically. Categories are not a
* collection but rather they are obtained by iterating through the collection
* of posts.
*
* @returns a list of categories
*/
export async function getCategories(): Promise<string[]> {
const posts = await getPosts()
const tags = posts.flatMap((post) => post.data.categories)
return [...new Set(tags)].sort()
}

88
src/utils/date_fmt.ts Normal file
View file

@ -0,0 +1,88 @@
import type { Date as RcvDate } from '@recivi/schema'
type Y = [number]
type YM = [number, number]
type YMD = [number, number, number]
const MONTHS = Array.from({ length: 12 }, (_, i) =>
new Date(0, i).toLocaleString('default', { month: 'short' })
)
/**
* Convert a JS native `Date` object into a Récivi date.
*
* @param date - the date to convert
* @returns the date in Récivi's `YMD` format
*/
export function getRcvDate(date: Date): YMD {
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
}
/**
* Convert the given date from a Récivi format to an array format with either
* one, two or three elements.
*
* @param date - the date in a known Récivi formats
* @returns the date as an array with either one, two or three elements
*/
function parseRcvDate(date: RcvDate): Y | YM | YMD {
if (Array.isArray(date)) {
return date
}
return [date.year, date.month, date.day]
}
/**
* Convert the date into a stylised human-readable format. Depending on the
* known components, the format can be one of the following:
* - numeric year (e.g. 2022)
* - short month name, numeric year (e.g. Jan-2022)
* - two-digit date, short month name, numeric year (e.g. 06-Jan-2022)
*
* @param date - the date as per the Récivi specification
* @param useMarkup - whether to wrap the date components in HTML markup
*/
export function dateDisplay(date: RcvDate, useMarkup = true): string {
const [year, month, day] = parseRcvDate(date)
const dateComponents = []
if (year) {
dateComponents.push(year.toString())
}
if (month) {
dateComponents.push(MONTHS[month - 1])
}
if (day) {
dateComponents.push(day.toString().padStart(2, '0'))
}
return dateComponents
.reverse()
.join(useMarkup ? '<span class="text-subtle">-</span>' : '-')
}
/**
* Convert the date into a plain human-readable format. Depending on the known
* components, the format can be one of the following:
* - numeric year (e.g. 2022)
* - full month name, numeric year (e.g. January 2022)
* - full month name, numeric date, numeric year (e.g. January 6, 2022)
*
* @param date - the date as per the Récivi specification
* @returns the data formatted according to the user's locale
*/
export function dateReadable(date: RcvDate): string {
const [year, month, day] = parseRcvDate(date)
const dateObj = new Date(year, (month ?? 1) - 1, day ?? 1)
const opt: Intl.DateTimeFormatOptions = {}
if (year) {
opt.year = 'numeric'
}
if (month) {
opt.month = 'long'
}
if (day) {
opt.day = 'numeric'
}
return dateObj.toLocaleDateString(undefined, opt)
}

14
src/utils/hash.ts Normal file
View file

@ -0,0 +1,14 @@
/**
* Calculate a hash for a string between 0 and `len`.
*
* @param text - the string to hash
* @param len - the maximum value of the hash (exclusive)
* @returns the hash as a number
*/
export function getHash(text: string, len: number): number {
let hash = 0
for (let i = 0; i < text.length; i++) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0
}
return hash % len
}

44
src/utils/icon.ts Normal file
View file

@ -0,0 +1,44 @@
import { icons as siIcons } from '@iconify-json/simple-icons'
import { icons as lIcons } from '@iconify-json/lucide'
// import _2fac from '@/assets/icons/2fac.svg?raw'
const knownIcons = {
// '2fac': _2fac,
} as Record<string, string>
// These are generally epics that don't have their own icon and just use the
// the icon of the parent org.
const aliases = {
vocabulary: 'creativecommons',
omniport: 'img',
platform: 'browserstack',
} as Record<string, string>
export type Source = 'simple_icons' | 'lucide'
/**
* Get the SVG body of an icon by its name and source. The source is
* ignored if that name is in the known icons list.
*
* @param name - the name of the icon
* @param source - the source of the icon
* @returns the SVG body of the icon
*/
export function getBody(name: string, source: Source = 'simple_icons') {
const identifier = aliases[name] ?? name
const icon = knownIcons[identifier]
if (icon) {
// Strip the SVG opening and closing tags, including all attributes.
return icon.replace(/<\/?svg[^>]*>/g, '')
}
if (source === 'simple_icons') {
return siIcons.icons[identifier]?.body
} else if (source === 'lucide') {
return lIcons.icons[identifier]?.body
}
return undefined
}

72
src/utils/meta.ts Normal file
View file

@ -0,0 +1,72 @@
import { recivi } from '@/stores/recivi'
import { site } from '@/stores/site'
export interface RawMetadata {
title: string
description: string
banRobots?: boolean
}
/**
* describes the attributes of a `<meta>` tag; Some `<meta>` tags use a name-content
* pair whereas others use a property-content pair so this type accepts both.
*/
export type MetaTag = { content: string } & (
| { name: string }
| { property: string }
)
/**
* Get the title to render in the page tab. This consists of the two parts, the page
* title and the site title, joined by a separator.
*
* @param rawMetadata - page data that is needed to generate the page title
* @returns the complete of the page
*/
export function getPageTitle(title: string): string {
return `${title} - ${site.title}`
}
/**
* Get a list of objects to render as `<meta>` tags in the page head.
*
* @param rawMetadata - page data that is needed to generate the meta tags
* @param path - the path of the page, with a leading slash
* @param origin - the scheme and domain name of the website, without a trailing slash
* @returns a list of meta tags to render in the page head
*/
export function getMetadata(
rawMetadata: RawMetadata,
path: string,
origin: string
): MetaTag[] {
const { title, description, banRobots = false } = rawMetadata
const pageUrl = `${origin}${path}`
const pageTitle = getPageTitle(title)
const imageUrl = `${origin}/og${
path === '/' ? '/index' : path.replace(/\/$/, '')
}.png`
const metaTags = [
{ name: 'author', content: recivi.bio.name },
{ name: 'description', content: description },
// Open Graph
{ property: 'og:type', content: 'article' },
{ property: 'og:site_name', content: site.title },
{ property: 'og:url', content: pageUrl },
{ property: 'og:title', content: pageTitle },
{ property: 'og:description', content: description },
{ property: 'og:image', content: imageUrl },
]
// Fediverse
if (site.fediverse) {
metaTags.push({ property: 'fediverse:creator', content: site.fediverse })
}
// Robots
if (banRobots) {
metaTags.push({ name: 'robots', content: 'noindex' })
}
return metaTags
}

40
src/utils/mod_date.ts Normal file
View file

@ -0,0 +1,40 @@
import { execSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const srcDir = import.meta.env.DEV
? path.resolve(__filename, '../..')
: path.resolve(__filename, '../../../src')
/**
* Get the modification timestamp of a file from Git. In case this doesn't work,
* it returns a blank string.
*
* @param path - the path to the file whose modification timestamp is being read
* @returns the modification timestamp of the file
*/
function getGitMtime(path: string): string {
return execSync(`git log -1 --format=%cI ${path}`).toString()
}
/**
* Get the modification timestamp of a file from the filesystem. This should
* always work.
*
* @param path - the path to the file whose modification timestamp is being read
* @returns the modification timestamp of the file
*/
function getFsMtime(path: string): string {
return statSync(path).mtime.toISOString()
}
export function getModDate(slug: string): Date {
const filePath = path.resolve(srcDir, 'posts', `${slug}.mdx`)
const gitTimestamp = getGitMtime(filePath)
const fsTimestamp = getFsMtime(filePath)
const timestamp = gitTimestamp || fsTimestamp
return new Date(timestamp)
}

101
src/utils/og_image.ts Normal file
View file

@ -0,0 +1,101 @@
import fs from 'node:fs'
import path from 'node:path'
import { ImageResponse } from '@vercel/og'
import { html } from 'satori-html'
import { site } from '@/stores/site'
import { getBody } from '@/utils/icon'
const fonts = (
[
{ family: 'JetBrainsMono', variant: 'Regular', weight: 400 },
{ family: 'Inter', variant: 'Regular', weight: 400 },
{ family: 'Inter', variant: 'Bold', weight: 700 },
] as const
).map(({ family, variant, weight }) => ({
name: family,
weight,
style: 'normal' as const,
data: fs.readFileSync(
path.resolve(`./src/assets/fonts/${family}-${variant}.ttf`)
),
}))
/**
* Convert the given text, which may contain HTML markup into plain-text
* that can be rendered into the OG image.
*
* @param html - the HTML markup to convert
* @returns the plain-text representation
*/
function markupToText(html: string): string {
return html.replace(/<\/?code>/g, '`').replace(/<[^>]*>/g, '')
}
/**
* Create an image response with the given content.
*
* @param content - the content to render into the image
* @returns the image response
*/
export function getOgImage(content: {
title: string
description: string
left?: string
right?: string
icon?: string | undefined
}): ImageResponse {
const left = content.left ?? ''
const right = content.right ?? ''
const icon = content.icon ?? ''
const markup = `
<div
class="flex h-full w-full flex-col justify-between p-16"
style="background-image: linear-gradient(to bottom, #1e1e2e, #181825); background-size: 1200px 600px; color: #cdd6f4; font-family: Inter;">
<!-- Header -->
<div
class="mb-10 flex justify-between text-4xl"
style="color: #7f849c;">
<div class="flex items-center">
<div class="font-bold">${site.title}</div>
${left && '/'}${left}
</div>
${right}
</div>
<!-- Main -->
<div class="flex flex-grow flex-col">
<!-- Title -->
<div
class="mb-6 flex items-center text-8xl font-bold"
style="color: #fab387;">
${
icon &&
`
<svg
viewBox="0 0 24 24"
class="mr-4 h-20 w-20"
fill="currentColor">
${getBody(icon)}
</svg>
`
}
${content.title}
</div>
<!-- Description -->
<div class="text-4xl leading-normal">
${markupToText(content.description)}
</div>
</div>
</div>
`
const element = html(markup)
const config = {
width: 1200,
height: 600,
fonts,
}
return new ImageResponse(element, config)
}

135
src/utils/recivi.ts Normal file
View file

@ -0,0 +1,135 @@
import type {
RoleLocation,
RoleType,
Url,
Skill,
Address,
Cert,
} from '@recivi/schema'
const ROLE_TYPE_DISPLAYS = {
contract: 'Contract',
foss: 'FOSS',
'full-time': 'Full-time',
internship: 'Internship',
'part-time': 'Part-time',
freelance: 'Freelance',
temp: 'Temporary',
volunteer: 'Volunteer',
other: 'Other',
} as const
const ROLE_LOCATION_DISPLAY = {
remote: 'Remote',
onsite: 'On-site',
hybrid: 'Hybrid',
} as const
/**
* Get the display name for a given role type code.
*
* @param roleType - the code for the type of the role
* @returns the display name for the role type
*/
export function roleTypeDisplay(roleType: RoleType): string {
return ROLE_TYPE_DISPLAYS[roleType]
}
/**
* Get the display name for a given role location code.
*
* @param roleLocation - the code for the location of the role
* @returns the display name for the role location
*/
export function roleLocationDisplay(roleLocation: RoleLocation): string {
return ROLE_LOCATION_DISPLAY[roleLocation]
}
/**
* Convert a `Url` object into a string. Récivi URLs can be
*/
export function urlToDest(url: Url): string {
return typeof url === 'object' && 'dest' in url ? url.dest : url
}
/**
* Convert a given country code into the country's flag emoji. This is easier
* than mapping it to a country name, considering there can be multiple
* representations of the name.
*
* @param countryCode - the ISO 3166-1 Alpha-2 code for the country
* @returns the flag emoji
*/
function countryCodeDisplay(countryCode: string): string {
return countryCode
.toUpperCase()
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397))
}
/**
* Convert the address into a string.
*
* @param address - the address to stringify
* @returns the string representation of the address
*/
export function addressDisplay(address: Address) {
let text = countryCodeDisplay(address.countryCode)
if (address.state) {
text = `${address.state}, ${text}`
}
if (address.city) {
text = `${address.city}, ${text}`
}
return text
}
/**
* Convert a given skill and all its sub-skills into a flat textual
* representation.
*
* @param skill - the skill to convert to a string
* @returns the string representation of the skill and all its sub-skills
*/
export function skillDisplay(skill: Skill): string {
if (typeof skill === 'string') {
return skill
}
const { name, subSkills } = skill
let output = name
if (subSkills?.length) {
output = `${output} (+ ${subSkills.map(skillDisplay).join(', ')})`
}
return output
}
/**
* Convert a certificate into a textual representation consisting of the
* certificate's short name (like "B. Tech.") and the field of study (like
* "Computer Science").
*
* @param cert - the certificate to convert to a string
* @returns the string representation of the certificate
*/
export function certDisplay(cert: Cert): string {
let output = cert.shortName ?? cert.name
if (cert.field) {
output = `${output} (${cert.field})`
}
return output
}
/**
* Convert the list of labels into a grammatically correct string, using
* commas between two entries and "and" between the last two entries.
*
* @param labels - the list of labels that apply to the person
* @returns the human-readable textual representation
*/
export function labelsDisplay(labels: string[]): string {
return labels
?.map(
(label, idx) =>
`${idx === 0 ? 'a ' : idx === labels.length - 1 ? ' and ' : ', '}${label}`
)
.join('')
}

5
src/utils/tailwind.ts Normal file
View file

@ -0,0 +1,5 @@
/**
* Enable Tailwind's Prettier plugin by returning the same string unchanged.
*/
export const tw = (strings: readonly string[], ...values: unknown[]) =>
String.raw({ raw: strings }, ...values)

45
src/utils/toc_tree.ts Normal file
View file

@ -0,0 +1,45 @@
import type { MarkdownHeading } from 'astro'
export interface Heading extends MarkdownHeading {
children: Heading[]
}
/**
* Convert the flat list of headings into a nested tree structure. This
* function returns the root node of the tree.
*
* @param headings - the flat list of headings parsed by Astro
* @returns the root node of the tree
*/
export function buildTree(headings: MarkdownHeading[]): Heading {
const root: Heading = {
text: 'Contents',
slug: '',
depth: 0,
children: [],
}
const currentPath: Heading[] = []
headings.forEach((heading) => {
const node: Heading = { ...heading, children: [] }
// Pop nodes from current path until we find a parent of lower depth.
while ((currentPath[currentPath.length - 1]?.depth ?? 0) >= node.depth) {
currentPath.pop()
}
// Add to root if no parent, otherwise add to parent's children.
if (currentPath.length === 0) {
root.children.push(node)
} else {
const parent = currentPath[currentPath.length - 1]
if (parent) {
parent.children.push(node)
}
}
currentPath.push(node)
})
return root
}

110
tailwind.config.ts Normal file
View file

@ -0,0 +1,110 @@
import defaultTheme from 'tailwindcss/defaultTheme'
import plugin from 'tailwindcss/plugin'
import type { Config } from 'tailwindcss'
import tailwindcssSafeArea from 'tailwindcss-safe-area'
export default {
content: ['./src/**/*.{astro,html,css,js,md,mdx,ts,vue}'],
theme: {
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
serif: ['Spectral', ...defaultTheme.fontFamily.serif],
mono: 'monospace',
},
colors: {
rosewater: 'var(--color-rosewater)',
flamingo: 'var(--color-flamingo)',
pink: 'var(--color-pink)',
mauve: 'var(--color-mauve)',
red: 'var(--color-red)',
maroon: 'var(--color-maroon)',
peach: 'var(--color-peach)',
yellow: 'var(--color-yellow)',
green: 'var(--color-green)',
teal: 'var(--color-teal)',
sky: 'var(--color-sky)',
sapphire: 'var(--color-sapphire)',
blue: 'var(--color-blue)',
lavender: 'var(--color-lavender)',
curr: 'currentColor',
trans: 'transparent',
},
extend: {
textColor: {
default: 'var(--color-text)',
// sub headlines, labels
// subtext0: 'var(--color-subtext0)',
// subtext1: 'var(--color-subtext1)',
// subtle
subtle: 'var(--color-overlay1)',
},
backgroundColor: {
default: 'var(--color-base)',
// surface elements
surface0: 'var(--color-surface0)',
surface1: 'var(--color-surface1)',
// surface2: 'var(--color-surface2)',
// overlays
// overlay0: 'var(--color-overlay0)',
// overlay1: 'var(--color-overlay1)',
// overlay2: 'var(--color-overlay2)',
},
borderColor: {
DEFAULT: 'var(--color-surface1)',
},
listStyleType: {
'lower-roman': 'lower-roman',
},
// Make line height `tight` for font sizes `5xl` through `9xl`.
fontSize: {
'5xl': [
defaultTheme.fontSize['5xl'][0],
{ lineHeight: defaultTheme.lineHeight.tight },
],
'6xl': [
defaultTheme.fontSize['6xl'][0],
{ lineHeight: defaultTheme.lineHeight.tight },
],
'7xl': [
defaultTheme.fontSize['7xl'][0],
{ lineHeight: defaultTheme.lineHeight.tight },
],
'8xl': [
defaultTheme.fontSize['8xl'][0],
{ lineHeight: defaultTheme.lineHeight.tight },
],
'9xl': [
defaultTheme.fontSize['9xl'][0],
{ lineHeight: defaultTheme.lineHeight.tight },
],
},
spacing: {
curr: '1em',
e1: defaultTheme.spacing[1].replace('rem', 'em'),
},
animation: {
wave: 'wave 1s ease-in-out infinite',
},
keyframes: {
wave: {
'0%, 100%': {
transform: 'rotate(0deg)',
},
'50%': {
transform: 'rotate(15deg)',
},
},
},
},
},
plugins: [
// variants for targeting task lists in GFM
plugin(function ({ addVariant }) {
addVariant('gfm-done', '&:has(input[checked])')
addVariant('gfm-todo', '&:has(input:not([checked]))')
addVariant('pdf', '&:where(.pdf &)')
}),
tailwindcssSafeArea,
],
} satisfies Config

10
tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strictest",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["dist"]
}