chore: Initial commit.
This commit is contained in:
commit
0219b23c4e
84 changed files with 15995 additions and 0 deletions
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
5
.husky/pre-commit
Executable file
|
@ -0,0 +1,5 @@
|
|||
# Lint staged files
|
||||
pnpx lint-staged
|
||||
|
||||
# Run Astro checks
|
||||
pnpm check
|
6
.husky/pre-push
Executable file
6
.husky/pre-push
Executable file
|
@ -0,0 +1,6 @@
|
|||
# Strict lint
|
||||
pnpm lint --max-warnings 0
|
||||
pnpm format:check
|
||||
|
||||
# Run Astro checks
|
||||
pnpm check
|
4
.lintstagedrc
Normal file
4
.lintstagedrc
Normal 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
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
shamefully-hoist=true
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
22
|
8
.prettierignore
Normal file
8
.prettierignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
# MDX
|
||||
*.mdx
|
||||
|
||||
# Autogenerated
|
||||
.astro
|
||||
|
||||
# Lockfiles
|
||||
pnpm-lock.yaml
|
32
README.md
Normal file
32
README.md
Normal 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
38
astro.config.ts
Normal 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
48
eslint.config.mjs
Normal 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
11890
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
58
package.json
Normal file
58
package.json
Normal 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
26
prettier.config.mjs
Normal 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
177
recivi.json
Normal 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
7
shell.nix
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}: pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs
|
||||
];
|
||||
}
|
BIN
src/assets/fonts/Inter-Bold.ttf
Normal file
BIN
src/assets/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Inter-Regular.ttf
Normal file
BIN
src/assets/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
src/assets/fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
37
src/components/Breakpoint.astro
Normal file
37
src/components/Breakpoint.astro
Normal 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>
|
68
src/components/ContactList.astro
Normal file
68
src/components/ContactList.astro
Normal 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
24
src/components/Date.astro
Normal 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}
|
||||
/>
|
49
src/components/EpicCard.astro
Normal file
49
src/components/EpicCard.astro
Normal 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>
|
89
src/components/EpicEntry.astro
Normal file
89
src/components/EpicEntry.astro
Normal 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>
|
20
src/components/FunFact.astro
Normal file
20
src/components/FunFact.astro
Normal 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
32
src/components/Icon.astro
Normal 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
|
||||
}
|
29
src/components/IconName.astro
Normal file
29
src/components/IconName.astro
Normal 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>
|
34
src/components/InstituteCard.astro
Normal file
34
src/components/InstituteCard.astro
Normal 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>
|
61
src/components/Kaomoji.astro
Normal file
61
src/components/Kaomoji.astro
Normal 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
21
src/components/Link.astro
Normal 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
18
src/components/Meta.astro
Normal 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>
|
39
src/components/OrgCard.astro
Normal file
39
src/components/OrgCard.astro
Normal 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>
|
83
src/components/OrgEntry.astro
Normal file
83
src/components/OrgEntry.astro
Normal 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>
|
62
src/components/Pronouns.astro
Normal file
62
src/components/Pronouns.astro
Normal 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
|
||||
>
|
38
src/components/SiteFooter.astro
Normal file
38
src/components/SiteFooter.astro
Normal 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>
|
82
src/components/SiteHeader.astro
Normal file
82
src/components/SiteHeader.astro
Normal 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>
|
46
src/components/Skill.astro
Normal file
46
src/components/Skill.astro
Normal 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
136
src/components/Table.astro
Normal 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>
|
40
src/components/TimePeriod.astro
Normal file
40
src/components/TimePeriod.astro
Normal 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}
|
||||
/>
|
||||
–
|
||||
{
|
||||
end ? (
|
||||
<span
|
||||
set:html={endDisplay}
|
||||
title={endReadable}
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
{/* prettier-ignore */}
|
||||
<Fragment>
|
||||
<span class="animate-pulse pdf:animate-none text-red" aria-hidden="true">•</span>Present
|
||||
</Fragment>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
</span>
|
43
src/components/TocNode.astro
Normal file
43
src/components/TocNode.astro
Normal 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
20
src/constants/colors.ts
Normal 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
46
src/content.config.ts
Normal 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
7
src/env.d.ts
vendored
Normal 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
|
||||
}
|
17
src/integrations/watch_plugins.ts
Normal file
17
src/integrations/watch_plugins.ts
Normal 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
39
src/layouts/content.astro
Normal 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
91
src/layouts/resume.astro
Normal 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
62
src/layouts/root.astro
Normal 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
84
src/layouts/screen.astro
Normal 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
133
src/layouts/writings.astro
Normal 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
34
src/models/recivi.ts
Normal 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
154
src/models/table.ts
Normal 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
11
src/pages/404.mdx
Normal 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
54
src/pages/index.mdx
Normal 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 />
|
30
src/pages/resume/_index.mdx
Normal file
30
src/pages/resume/_index.mdx
Normal 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.
|
125
src/pages/resume/epics/[id].astro
Normal file
125
src/pages/resume/epics/[id].astro
Normal 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>
|
5
src/pages/resume/index.astro
Normal file
5
src/pages/resume/index.astro
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import ResumeLayout from '@/layouts/resume.astro'
|
||||
---
|
||||
|
||||
<ResumeLayout />
|
125
src/pages/resume/orgs/[id].astro
Normal file
125
src/pages/resume/orgs/[id].astro
Normal 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
125
src/pages/resume/pdf.astro
Normal 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
39
src/pages/rss.xml.ts
Normal 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.
|
||||
})
|
||||
}
|
21
src/pages/site.webmanifest.ts
Normal file
21
src/pages/site.webmanifest.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
18
src/pages/writings/_index.mdx
Normal file
18
src/pages/writings/_index.mdx
Normal 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.
|
5
src/pages/writings/index.astro
Normal file
5
src/pages/writings/index.astro
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import WritingsLayout from '@/layouts/writings.astro'
|
||||
---
|
||||
|
||||
<WritingsLayout />
|
113
src/pages/writings/posts/[slug].astro
Normal file
113
src/pages/writings/posts/[slug].astro
Normal 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>
|
125
src/plugins/rehype_tailwind.ts
Normal file
125
src/plugins/rehype_tailwind.ts
Normal 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
42
src/scripts/print.ts
Normal 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
0
src/stores/recivi.d.ts
vendored
Normal file
80
src/stores/recivi.ts
Normal file
80
src/stores/recivi.ts
Normal 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
38
src/stores/site.ts
Normal 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
110
src/styles/colors.css
Normal 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
21
src/styles/print.css
Normal 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
134
src/styles/screen.css
Normal 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
4
src/types/class_list.ts
Normal 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
4
src/types/extensions.ts
Normal 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
32
src/utils/collections.ts
Normal 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
88
src/utils/date_fmt.ts
Normal 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
14
src/utils/hash.ts
Normal 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
44
src/utils/icon.ts
Normal 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
72
src/utils/meta.ts
Normal 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
40
src/utils/mod_date.ts
Normal 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
101
src/utils/og_image.ts
Normal 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
135
src/utils/recivi.ts
Normal 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
5
src/utils/tailwind.ts
Normal 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
45
src/utils/toc_tree.ts
Normal 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
110
tailwind.config.ts
Normal 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
10
tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strictest",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue