Built resume template
24
.eslintrc.cjs
Normal file
@ -0,0 +1,24 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ['plugin:astro/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest'
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.astro'],
|
||||
parser: 'astro-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
extraFileExtensions: ['.astro']
|
||||
},
|
||||
rules: {
|
||||
// override/add rules settings here, such as:
|
||||
// "astro/no-set-html-directive": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
.vercel/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules/**
|
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
11
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
1
.vscode/settings.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
79
README.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Astro Resume
|
||||
|
||||
## Features
|
||||
|
||||
- ESLint / Prettier pre-installed and pre-configured
|
||||
- Astro v4
|
||||
- TailwindCSS Utility classes
|
||||
- Accessible, semantic HTML markup
|
||||
- Responsive & SEO-friendly
|
||||
- Dark / Light mode, using Tailwind and CSS variables (referenced from shadcn)
|
||||
- [Astro Assets Integration](https://docs.astro.build/en/guides/assets/) for optimised images
|
||||
- MD & [MDX](https://docs.astro.build/en/guides/markdown-content/#mdx-only-features) posts
|
||||
- Pagination
|
||||
- [Automatic RSS feed](https://docs.astro.build/en/guides/rss)
|
||||
- Auto-generated [sitemap](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
|
||||
- [Expressive Code](https://expressive-code.com/) source code and syntax highlighter
|
||||
|
||||
## Credits
|
||||
|
||||
1. [astro-theme-cactus](https://github.com/chrismwilliams/astro-theme-cactus) for blog design
|
||||
2. [minirezume-framer](https://minirezume.framer.website/) for resume homepage design
|
||||
|
||||
## Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
├── public/
|
||||
├── src/
|
||||
├── assets/
|
||||
│ ├── components/
|
||||
│ ├── content/
|
||||
│ ├── layouts/
|
||||
| ├── pages/
|
||||
| ├── styles/
|
||||
| ├── utils/
|
||||
| ├── site.config.ts
|
||||
│ └── types.ts
|
||||
├── .elintrc.cjs
|
||||
├── .gitignore
|
||||
├── .prettierignore
|
||||
├── package.json
|
||||
├── prettier.config.cjs
|
||||
├── README.md
|
||||
├── tailwind.config.js
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Editing guide
|
||||
|
||||
### Site info
|
||||
|
||||
To edit site info such as site title and description, edit the `src/site.config.ts` file.
|
||||
|
||||
### Page contents
|
||||
|
||||
To edit the resume homepage content and design, edit the `src/pages/index.astro` file.
|
||||
|
||||
### Page components
|
||||
|
||||
To edit page components found site-wide such as the card used in the homepage, edit the files found in the `src/components/` directory.
|
||||
|
||||
### Layouts
|
||||
|
||||
To edit the base layouts of all pages, edit the `src/layouts/BaseLayout.astro` file.
|
||||
|
||||
To edit the layout of a blog article, edit the `src/layouts/BlogPost.astro` file.
|
||||
|
||||
### Blog content
|
||||
|
||||
To add blog content, insert `.md` files in the `src/content/` directory.
|
||||
|
||||
To add images in blog articles, insert a folder in the `src/content/` directory, add both the `.md` and image files into the new folder, and reference the image in your `.md` file.
|
||||
|
||||
## Theming
|
||||
|
||||
To change the theme colours of the site, edit the `src/styles/app.css` file.
|
||||
|
||||
To change the fonts of the site, add your font files into `/public`, add it as a `@font-face` in the `src/styles/app.css` file, as a `fontFamily` in the `tailwind.config.js` file, and apply the new font class to the `body` tag in the `src/layouts/BaseLayout.astro` file.
|
46
astro.config.mjs
Normal file
@ -0,0 +1,46 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import tailwind from '@astrojs/tailwind'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import { remarkReadingTime } from './src/utils/remarkReadingTime.ts'
|
||||
import remarkUnwrapImages from 'remark-unwrap-images'
|
||||
import rehypeExternalLinks from 'rehype-external-links'
|
||||
import expressiveCode from 'astro-expressive-code'
|
||||
import { expressiveCodeOptions } from './src/site.config'
|
||||
|
||||
import vercel from '@astrojs/vercel/serverless'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://example.me',
|
||||
integrations: [
|
||||
expressiveCode(expressiveCodeOptions),
|
||||
tailwind({
|
||||
applyBaseStyles: false
|
||||
}),
|
||||
sitemap(),
|
||||
mdx()
|
||||
],
|
||||
markdown: {
|
||||
remarkPlugins: [remarkUnwrapImages, remarkReadingTime],
|
||||
rehypePlugins: [
|
||||
[
|
||||
rehypeExternalLinks,
|
||||
{
|
||||
target: '_blank',
|
||||
rel: ['nofollow, noopener, noreferrer']
|
||||
}
|
||||
]
|
||||
],
|
||||
remarkRehype: {
|
||||
footnoteLabelProperties: {
|
||||
className: ['']
|
||||
}
|
||||
}
|
||||
},
|
||||
prefetch: true,
|
||||
output: 'server',
|
||||
adapter: vercel({
|
||||
webAnalytics: { enabled: true }
|
||||
})
|
||||
})
|
45
package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "astro-blog",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro check --watch & astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,svelte,astro}\" && eslint --fix \"src/**/*.{js,ts,jsx,tsx,svelte,astro}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.5.6",
|
||||
"@astrojs/mdx": "^2.1.1",
|
||||
"@astrojs/rss": "^4.0.5",
|
||||
"@astrojs/sitemap": "^3.1.1",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@astrojs/vercel": "^7.3.6",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"astro": "^4.4.15",
|
||||
"astro-expressive-code": "^0.33.5",
|
||||
"clsx": "^2.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-unwrap-images": "^4.0.0",
|
||||
"sharp": "^0.33.2",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-astro": "^0.31.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-config-standard": "^7.0.0",
|
||||
"prettier-plugin-astro": "^0.13.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.12"
|
||||
}
|
||||
}
|
6776
pnpm-lock.yaml
generated
Normal file
22
prettier.config.cjs
Normal file
@ -0,0 +1,22 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
module.exports = {
|
||||
// i am just using the standard config, change if you need something else
|
||||
...require('prettier-config-standard'),
|
||||
pluginSearchDirs: [__dirname],
|
||||
plugins: [
|
||||
'prettier-plugin-astro',
|
||||
'prettier-plugin-tailwindcss'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.astro',
|
||||
options: {
|
||||
parser: 'astro'
|
||||
}
|
||||
},
|
||||
],
|
||||
useTabs: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'none',
|
||||
printWidth: 100
|
||||
}
|
BIN
public/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
public/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.0 KiB |
BIN
public/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 377 B |
BIN
public/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 742 B |
BIN
public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
11
public/favicon/site.webmanifest
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
BIN
public/fonts/ClashDisplay-Variable.ttf
Normal file
BIN
public/fonts/Satoshi-Variable.ttf
Normal file
BIN
public/fonts/Satoshi-VariableItalic.ttf
Normal file
BIN
public/images/image.png
Normal file
After Width: | Height: | Size: 2.3 MiB |
BIN
public/social-card.png
Normal file
After Width: | Height: | Size: 283 KiB |
BIN
src/assets/about-astro.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/coming-soon.png
Normal file
After Width: | Height: | Size: 73 KiB |
89
src/components/BaseHead.astro
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import '../styles/app.css'
|
||||
|
||||
import type { SiteMeta } from '@/types'
|
||||
import { siteConfig } from '@/site-config'
|
||||
|
||||
type Props = SiteMeta
|
||||
|
||||
const { articleDate, description, ogImage, title } = Astro.props
|
||||
|
||||
const titleSeparator = '•'
|
||||
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
|
||||
const socialImageURL = new URL(ogImage ? ogImage : '/social-card.png', Astro.url).href
|
||||
---
|
||||
|
||||
<meta charset='utf-8' />
|
||||
<meta content='width=device-width, initial-scale=1.0, shrink-to-fit=no' name='viewport' />
|
||||
<meta content='IE=edge' http-equiv='X-UA-Compatible' />
|
||||
<title>{siteTitle}</title>
|
||||
|
||||
{/* Icons / Favicon */}
|
||||
<!-- <link href="favicon/favicon.ico" rel="icon" sizes="any" />
|
||||
<link href="favicon/icon.svg" rel="icon" type="image/svg+xml" />
|
||||
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||
<link href="/manifest.webmanifest" rel="manifest" /> -->
|
||||
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
||||
<link rel='manifest' href='/favicon/site.webmanifest' />
|
||||
|
||||
{/* Font preloads */}
|
||||
<!-- <link rel='preload' href='/fonts/Satoshi-Variable.ttf' as='font' type='font/ttf' crossorigin />
|
||||
<link
|
||||
rel='preload'
|
||||
href='/fonts/Satoshi-VariableItalic.ttf'
|
||||
as='font'
|
||||
type='font/ttf'
|
||||
crossorigin
|
||||
/>
|
||||
<link rel='preload' href='/fonts/ClashDisplay-Variable.ttf' as='font' type='font/ttf' crossorigin /> -->
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link rel='canonical' href={canonicalURL} />
|
||||
|
||||
{/* Primary Meta Tags */}
|
||||
<meta content={siteTitle} name='title' />
|
||||
<meta content={description} name='description' />
|
||||
<meta content={siteConfig.author} name='author' />
|
||||
|
||||
{/* Theme Colour */}
|
||||
<meta content='' name='theme-color' />
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta content={articleDate ? 'article' : 'website'} property='og:type' />
|
||||
<meta content={title} property='og:title' />
|
||||
<meta content={description} property='og:description' />
|
||||
<meta content={canonicalURL} property='og:url' />
|
||||
<meta content={siteConfig.title} property='og:site_name' />
|
||||
<meta content={siteConfig.ogLocale} property='og:locale' />
|
||||
<meta content={socialImageURL} property='og:image' />
|
||||
<meta content='1200' property='og:image:width' />
|
||||
<meta content='630' property='og:image:height' />
|
||||
{
|
||||
articleDate && (
|
||||
<>
|
||||
<meta content={siteConfig.author} property='article:author' />
|
||||
<meta content={articleDate} property='article:published_time' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta content='summary_large_image' property='twitter:card' />
|
||||
<meta content={canonicalURL} property='twitter:url' />
|
||||
<meta content={title} property='twitter:title' />
|
||||
<meta content={description} property='twitter:description' />
|
||||
<meta content={socialImageURL} property='twitter:image' />
|
||||
|
||||
{/* Sitemap */}
|
||||
<link href='/sitemap-index.xml' rel='sitemap' />
|
||||
|
||||
{/* RSS auto-discovery */}
|
||||
<link href='/rss.xml' rel='alternate' title={siteConfig.title} type='application/rss+xml' />
|
||||
|
||||
<meta content={Astro.generator} name='generator' />
|
19
src/components/Button.astro
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
import { cn } from '@/utils'
|
||||
const { as: Tag = 'a', class: className, title, href, style = 'button' } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
class={cn(
|
||||
className,
|
||||
'inline-flex items-center gap-x-1 rounded-lg bg-primary-foreground border border-border px-2 py-1 text-sm transition-all hover:bg-input',
|
||||
!href && 'cursor-default',
|
||||
style === 'pill' && 'rounded-xl'
|
||||
)}
|
||||
href={href}
|
||||
data-astro-prefetch
|
||||
>
|
||||
<slot name='icon-before' />
|
||||
<p>{title}</p>
|
||||
<slot name='icon-after' />
|
||||
</Tag>
|
44
src/components/Card.astro
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
import { Image } from 'astro:assets'
|
||||
import type { ImageMetadata } from 'astro'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
class: className,
|
||||
href,
|
||||
heading,
|
||||
subheading,
|
||||
date,
|
||||
imagePath,
|
||||
altText,
|
||||
imageClass
|
||||
} = Astro.props
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('/src/assets/*.{jpeg,jpg,png,gif}')
|
||||
if (!images[imagePath])
|
||||
throw new Error(`"${imagePath}" does not exist in glob: "src/assets/*.{jpeg,jpg,png,gif}"`)
|
||||
---
|
||||
|
||||
<Tag
|
||||
class={cn(
|
||||
className,
|
||||
'relative rounded-2xl border border-border bg-primary-foreground px-5 py-3',
|
||||
href && 'transition-all hover:border-foreground/25 hover:shadow-sm'
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
<Image
|
||||
src={images[imagePath]()}
|
||||
alt={altText}
|
||||
class={cn('mb-3 md:absolute md:mb-0', imageClass)}
|
||||
loading='eager'
|
||||
/>
|
||||
<div class='flex flex-col gap-y-1.5'>
|
||||
<div class='flex flex-col gap-y-0.5'>
|
||||
<h1 class='text-lg font-medium'>{heading}</h1>
|
||||
<h2 class='text-muted-foreground'>{subheading}</h2>
|
||||
<h2 class='text-muted-foreground'>{date}</h2>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</Tag>
|
18
src/components/FormattedDate.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
import { getFormattedDate } from '@/utils'
|
||||
|
||||
type Props = HTMLAttributes<'time'> & {
|
||||
date: Date
|
||||
dateTimeOptions?: Intl.DateTimeFormatOptions
|
||||
}
|
||||
|
||||
const { date, dateTimeOptions, ...attrs } = Astro.props
|
||||
|
||||
const postDate = getFormattedDate(date, dateTimeOptions)
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()} {...attrs}>
|
||||
{postDate}
|
||||
</time>
|
18
src/components/Label.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import { cn } from '@/utils'
|
||||
|
||||
const { class: className, as: Tag = 'div', title, href, ...props } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
class={cn(
|
||||
className,
|
||||
'flex flex-row items-center justify-center gap-x-2',
|
||||
href && 'hover:opacity-75 transition-all'
|
||||
)}
|
||||
href={href}
|
||||
{...props}
|
||||
>
|
||||
<slot name='icon' />
|
||||
<p>{title}</p>
|
||||
</Tag>
|
29
src/components/Paginator.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { PaginationLink } from '@/types'
|
||||
|
||||
interface Props {
|
||||
nextUrl?: PaginationLink
|
||||
prevUrl?: PaginationLink
|
||||
}
|
||||
|
||||
const { nextUrl, prevUrl } = Astro.props
|
||||
---
|
||||
|
||||
{
|
||||
(prevUrl || nextUrl) && (
|
||||
<nav class='mt-8 flex items-center gap-x-4'>
|
||||
{prevUrl && (
|
||||
<a class='me-auto py-2' data-astro-prefetch href={prevUrl.url}>
|
||||
{prevUrl.srLabel && <span class='sr-only'>{prevUrl.srLabel}</span>}
|
||||
{prevUrl.text ? prevUrl.text : 'Previous'}
|
||||
</a>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<a class='ms-auto py-2' data-astro-prefetch href={nextUrl.url}>
|
||||
{nextUrl.srLabel && <span class='sr-only'>{nextUrl.srLabel}</span>}
|
||||
{nextUrl.text ? nextUrl.text : 'Next'}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
40
src/components/ProjectCard.astro
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
import { Image } from 'astro:assets'
|
||||
import type { ImageMetadata } from 'astro'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
const {
|
||||
as: Tag = 'a',
|
||||
class: className,
|
||||
href,
|
||||
heading,
|
||||
subheading,
|
||||
imagePath,
|
||||
altText
|
||||
} = Astro.props
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('/src/assets/*.{jpeg,jpg,png,gif}')
|
||||
if (!images[imagePath])
|
||||
throw new Error(`"${imagePath}" does not exist in glob: "src/assets/*.{jpeg,jpg,png,gif}"`)
|
||||
---
|
||||
|
||||
<Tag
|
||||
class={cn(
|
||||
className,
|
||||
'flex flex-col gap-y-3 rounded-2xl border border-border bg-primary-foreground ',
|
||||
href && 'transition-all hover:border-foreground/25 hover:shadow-sm'
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
<Image
|
||||
src={images[imagePath]()}
|
||||
alt={altText}
|
||||
class='h-48 w-full rounded-2xl rounded-bl-none rounded-br-none object-cover'
|
||||
loading='eager'
|
||||
/>
|
||||
<div class='flex flex-col gap-y-0.5 px-5 py-4'>
|
||||
<h1 class='text-lg font-medium'>{heading}</h1>
|
||||
<h2 class='text-muted-foreground'>{subheading}</h2>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</Tag>
|
14
src/components/Section.astro
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
import { cn } from '@/utils'
|
||||
|
||||
const { class: className, title } = Astro.props
|
||||
---
|
||||
|
||||
<section class={cn(className, 'flex flex-col gap-y-5 md:flex-row md:gap-y-0')}>
|
||||
<div class='text-xl font-semibold md:w-1/3'>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<div class='flex flex-col gap-y-3 md:w-2/3'>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
11
src/components/SkillLayout.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
import Button from './Button.astro'
|
||||
const { title, skills } = Astro.props
|
||||
---
|
||||
|
||||
<div class='flex flex-col gap-y-2 md:flex-row md:gap-x-5 md:gap-y-0'>
|
||||
<h3 class='w-1/5 font-medium'>{title}</h3>
|
||||
<div class='flex w-4/5 flex-row flex-wrap gap-x-4 gap-y-2'>
|
||||
{skills.map((skill: string[]) => <Button as='button' title={skill} style='pill' />)}
|
||||
</div>
|
||||
</div>
|
42
src/components/ThemeProvider.astro
Normal file
@ -0,0 +1,42 @@
|
||||
<script is:inline>
|
||||
const lightModePref = window.matchMedia('(prefers-color-scheme: light)')
|
||||
|
||||
// Get user preference from local storage or from browser preference
|
||||
function getUserPref() {
|
||||
const storedTheme = localStorage.getItem('theme') ?? undefined
|
||||
return storedTheme || (lightModePref.matches ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
if (newTheme !== 'light' && newTheme !== 'dark') {
|
||||
return console.log(`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`)
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// if current dark theme and new theme is dark, return
|
||||
if (newTheme === 'dark' && root.classList.contains('dark')) {
|
||||
return
|
||||
} else if (newTheme === 'light' && !root.classList.contains('dark')) {
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.toggle('dark')
|
||||
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
|
||||
// Initial Setup
|
||||
setTheme(getUserPref())
|
||||
|
||||
// View Transitions hook to restore theme
|
||||
document.addEventListener('astro:after-swap', () => setTheme(getUserPref()))
|
||||
|
||||
// Listen for theme-change custom event
|
||||
document.addEventListener('theme-change', (e) => {
|
||||
setTheme(e.detail.theme)
|
||||
})
|
||||
|
||||
// Listen for prefers-color-scheme change
|
||||
lightModePref.addEventListener('change', (e) => setTheme(e.matches ? 'light' : 'dark'))
|
||||
</script>
|
89
src/components/blog/Hero.astro
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import { Image } from 'astro:assets'
|
||||
import FormattedDate from '../FormattedDate.astro'
|
||||
|
||||
interface Props {
|
||||
content: CollectionEntry<'post'>
|
||||
}
|
||||
|
||||
const {
|
||||
content: { data, render }
|
||||
} = Astro.props
|
||||
|
||||
const { remarkPluginFrontmatter } = await render()
|
||||
|
||||
const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'long'
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
data.coverImage && (
|
||||
<div class='aspect-h-9 aspect-w-16 mb-6'>
|
||||
<Image
|
||||
alt={data.coverImage.alt}
|
||||
class='rounded-2xl object-cover'
|
||||
fetchpriority='high'
|
||||
loading='eager'
|
||||
src={data.coverImage.src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.draft ? <span class='text-red-500'>(Draft)</span> : null}
|
||||
<div class='flex flex-wrap items-center gap-x-3 gap-y-2'>
|
||||
<p class='text-xs'>
|
||||
<FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{' '}
|
||||
{remarkPluginFrontmatter.minutesRead}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class='mt-2 text-3xl font-medium sm:mb-1'>
|
||||
{data.title}
|
||||
</h1>
|
||||
|
||||
{
|
||||
!!data.tags?.length && (
|
||||
<div class='mt-3 flex flex-row items-center gap-x-1'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
class='me-1 inline-block h-6 w-6'
|
||||
fill='none'
|
||||
focusable='false'
|
||||
stroke='currentColor'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
stroke-width='1.5'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M0 0h24v24H0z' fill='none' stroke='none' />
|
||||
<path d='M7.859 6h-2.834a2.025 2.025 0 0 0 -2.025 2.025v2.834c0 .537 .213 1.052 .593 1.432l6.116 6.116a2.025 2.025 0 0 0 2.864 0l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-6.117 -6.116a2.025 2.025 0 0 0 -1.431 -.593z' />
|
||||
<path d='M17.573 18.407l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-7.117 -7.116' />
|
||||
<path d='M6 9h-.01' />
|
||||
</svg>
|
||||
{data.tags.map((tag, i) => (
|
||||
<div>
|
||||
<a
|
||||
aria-label={`View more blogs with the tag ${tag}`}
|
||||
class="inline-block before:content-['#'] hover:underline hover:underline-offset-4"
|
||||
data-pagefind-filter='tag'
|
||||
href={`/tags/${tag}/`}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
{i < data.tags.length - 1 && ', '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
data.updatedDate && (
|
||||
<p class='mt-6 text-base'>
|
||||
Last Updated:
|
||||
<FormattedDate class='ms-1' date={data.updatedDate} dateTimeOptions={dateTimeOptions} />
|
||||
</p>
|
||||
)
|
||||
}
|
36
src/components/blog/PostPreview.astro
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import FormattedDate from '../FormattedDate.astro'
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
post: CollectionEntry<'post'>
|
||||
withDesc?: boolean
|
||||
}
|
||||
|
||||
const { as: Tag = 'div', post, withDesc = false } = Astro.props
|
||||
const postDate = post.data.updatedDate ?? post.data.publishDate
|
||||
---
|
||||
|
||||
<li class='flex flex-col gap-2 sm:flex-row sm:gap-x-4 [&_q]:basis-full'>
|
||||
<FormattedDate class='min-w-[120px]' date={postDate} />
|
||||
|
||||
<Tag>
|
||||
{post.data.draft && <span class='text-red-500'>(Draft) </span>}
|
||||
<a
|
||||
data-astro-prefetch
|
||||
href={`/blog/${post.slug}/`}
|
||||
class='transition-all hover:text-muted-foreground'
|
||||
>
|
||||
{post.data.title}
|
||||
</a>
|
||||
{
|
||||
withDesc && (
|
||||
<p class='line-clamp-3 block text-sm italic text-muted-foreground'>
|
||||
{post.data.description}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
</li>
|
21
src/components/blog/TOC.astro
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
import type { MarkdownHeading } from 'astro'
|
||||
import { generateToc } from '@/utils'
|
||||
|
||||
import TOCHeading from './TOCHeading.astro'
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[]
|
||||
}
|
||||
|
||||
const { headings } = Astro.props
|
||||
|
||||
const toc = generateToc(headings)
|
||||
---
|
||||
|
||||
<aside class='sticky top-20 order-2 -me-28 hidden basis-60 lg:flex lg:flex-col'>
|
||||
<h2 class='font-semibold'>TABLE OF CONTENTS</h2>
|
||||
<ul class='text-card-foreground'>
|
||||
{toc.map((heading) => <TOCHeading heading={heading} />)}
|
||||
</ul>
|
||||
</aside>
|
28
src/components/blog/TOCHeading.astro
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
import type { TocItem } from '@/utils'
|
||||
|
||||
interface Props {
|
||||
heading: TocItem
|
||||
}
|
||||
|
||||
const {
|
||||
heading: { depth, slug, subheadings, text }
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<li class={`${depth > 2 ? 'ms-2' : ''}`}>
|
||||
<a
|
||||
aria-label={`Scroll to section: ${text}`}
|
||||
class={`block line-clamp-2 ${depth <= 2 ? 'mt-2' : 'mt-1 text-sm'} text-foreground/75 transition-all hover:text-foreground`}
|
||||
href={`#${slug}`}>{text}</a
|
||||
>
|
||||
{
|
||||
!!subheadings.length && (
|
||||
<ul>
|
||||
{subheadings.map((subheading) => (
|
||||
<Astro.self heading={subheading} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
</li>
|
48
src/components/layout/Footer.astro
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<footer class='mx-auto mt-24 w-full'>
|
||||
<div class='border-t border-border pt-5'>
|
||||
<div
|
||||
class='flex flex-col items-center gap-y-3 sm:flex sm:flex-row sm:items-center sm:justify-between sm:gap-y-0'
|
||||
>
|
||||
<div class='flex gap-x-4 text-sm'>
|
||||
<!-- <a
|
||||
class='inline-flex gap-x-2 text-gray-600 hover:text-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600'
|
||||
href='/terms'>Terms</a
|
||||
>
|
||||
|
||||
<a
|
||||
class='inline-flex gap-x-2 text-gray-600 hover:text-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600'
|
||||
href='/privacy'>Privacy</a
|
||||
> -->
|
||||
<p class=''>© 2024 lorem. All rights reserved.</p>
|
||||
</div>
|
||||
|
||||
<div class='flex items-center justify-between'>
|
||||
<!-- Social Brands -->
|
||||
<div class='flex items-center gap-x-4'>
|
||||
<!-- Linkedin -->
|
||||
<a
|
||||
class='inline-block text-muted-foreground transition-all hover:text-muted-foreground/75'
|
||||
href='https://www.linkedin.com/in/example/'
|
||||
>
|
||||
<svg
|
||||
class='h-4 w-4 flex-shrink-0'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='1em'
|
||||
height='1em'
|
||||
viewBox='0 0 16 16'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248c-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586c.173-.431.568-.878 1.232-.878c.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252c-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z'
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<!-- End Social Brands -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
62
src/components/layout/Header.astro
Normal file
@ -0,0 +1,62 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<header class='mb-12 flex w-full flex-wrap pb-3 text-sm sm:flex-nowrap sm:justify-start'>
|
||||
<nav
|
||||
class='relative mx-auto flex w-full items-center justify-between sm:flex sm:items-center'
|
||||
aria-label='global'
|
||||
>
|
||||
<a class='flex-none text-xl font-semibold' href='/' aria-label='Brand'>lorem ipsum</a>
|
||||
|
||||
<div class='flex flex-row items-center justify-center gap-x-7'>
|
||||
<a
|
||||
href='/blog'
|
||||
class='flex-none text-[1.05rem] font-medium hover:text-foreground/75'
|
||||
aria-label='Nav Menu'>Blog</a
|
||||
>
|
||||
<button
|
||||
id='toggleDarkMode'
|
||||
class='relative rounded-md border border-border p-1.5 transition-all hover:bg-border'
|
||||
>
|
||||
<span class='sr-only'>Dark Theme</span>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='32'
|
||||
height='32'
|
||||
viewBox='0 0 24 24'
|
||||
class='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:hidden dark:-rotate-90 dark:scale-0'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M12 15q1.25 0 2.125-.875T15 12q0-1.25-.875-2.125T12 9q-1.25 0-2.125.875T9 12q0 1.25.875 2.125T12 15m0 1q-1.671 0-2.836-1.164T8 12q0-1.671 1.164-2.836T12 8q1.671 0 2.836 1.164T16 12q0 1.671-1.164 2.836T12 16m-7-3.5H1.5v-1H5zm17.5 0H19v-1h3.5zM11.5 5V1.5h1V5zm0 17.5V19h1v3.5zM6.746 7.404l-2.16-2.098l.695-.744l2.111 2.134zM18.72 19.438l-2.117-2.14l.652-.702l2.16 2.098zM16.596 6.746l2.098-2.16l.744.695l-2.134 2.111zM4.562 18.72l2.14-2.117l.663.652l-2.078 2.179zM12 12'
|
||||
></path></svg
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='32'
|
||||
height='32'
|
||||
viewBox='0 0 24 24'
|
||||
class='hidden h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:block dark:rotate-0 dark:scale-100'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M12.058 20q-3.334 0-5.667-2.333Q4.058 15.333 4.058 12q0-3.038 1.98-5.27Q8.02 4.5 10.942 4.097q.081 0 .159.006t.153.017q-.506.706-.801 1.57q-.295.865-.295 1.811q0 2.667 1.866 4.533q1.867 1.867 4.534 1.867q.952 0 1.813-.295q.862-.295 1.548-.801q.012.075.018.153q.005.078.005.158q-.384 2.923-2.615 4.904T12.057 20'
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
function getCurrentTheme() {
|
||||
return localStorage.getItem('theme')
|
||||
}
|
||||
const toggleDarkModeButton = document.getElementById('toggleDarkMode')
|
||||
|
||||
toggleDarkModeButton?.addEventListener('click', () => {
|
||||
const toggleDarkModeEvent = new CustomEvent('theme-change', {
|
||||
detail: { theme: getCurrentTheme() === 'light' ? 'dark' : 'light' }
|
||||
})
|
||||
document.dispatchEvent(toggleDarkModeEvent)
|
||||
})
|
||||
</script>
|
36
src/content/config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { defineCollection, z } from 'astro:content'
|
||||
|
||||
function removeDupsAndLowerCase(array: string[]) {
|
||||
if (!array.length) return array
|
||||
const lowercaseItems = array.map((str) => str.toLowerCase())
|
||||
const distinctItems = new Set(lowercaseItems)
|
||||
return Array.from(distinctItems)
|
||||
}
|
||||
|
||||
const post = defineCollection({
|
||||
type: 'content',
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string().max(60),
|
||||
description: z.string().min(50).max(160),
|
||||
publishDate: z
|
||||
.string()
|
||||
.or(z.date())
|
||||
.transform((val) => new Date(val)),
|
||||
updatedDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((str) => (str ? new Date(str) : undefined)),
|
||||
coverImage: z
|
||||
.object({
|
||||
src: image(),
|
||||
alt: z.string()
|
||||
})
|
||||
.optional(),
|
||||
draft: z.boolean().default(false),
|
||||
tags: z.array(z.string()).default([]).transform(removeDupsAndLowerCase),
|
||||
ogImage: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const collections = { post }
|
BIN
src/content/post/cover-image/cover.png
Normal file
After Width: | Height: | Size: 591 KiB |
10
src/content/post/cover-image/index.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Example Cover Image"
|
||||
description: "This post is an example of how to add a cover/hero image"
|
||||
publishDate: "04 July 2023"
|
||||
updatedDate: "14 August 2023"
|
||||
coverImage:
|
||||
src: "./cover.png"
|
||||
alt: "Astro build wallpaper"
|
||||
tags: ["test", "image"]
|
||||
---
|
9
src/content/post/draft-post.md
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "A working draft title"
|
||||
description: "This post is for testing the draft post functionality"
|
||||
publishDate: "10 Sept 2023"
|
||||
tags: ["test"]
|
||||
draft: true
|
||||
---
|
||||
|
||||
If this is working correctly, this post should only be accessible in a dev environment, as well as any tags that are unique to this post.
|
8
src/content/post/long-title.md
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "Lorem ipsum dolor sit, amet consectetur adipisicing elit. Id"
|
||||
description: "This post is purely for testing if the css is correct for the title on the page"
|
||||
publishDate: "01 Feb 2023"
|
||||
tags: ["test"]
|
||||
---
|
||||
|
||||
## Testing the title tag
|
161
src/content/post/markdown-elements/index.md
Normal file
@ -0,0 +1,161 @@
|
||||
---
|
||||
title: "A post of Markdown elements"
|
||||
description: "This post is for testing and listing a number of different markdown elements"
|
||||
publishDate: "22 Feb 2023"
|
||||
updatedDate: 22 Jan 2024
|
||||
tags: ["test", "markdown"]
|
||||
---
|
||||
|
||||
## This is a H2 Heading
|
||||
|
||||
### This is a H3 Heading
|
||||
|
||||
#### This is a H4 Heading
|
||||
|
||||
##### This is a H5 Heading
|
||||
|
||||
###### This is a H6 Heading
|
||||
|
||||
## Horizontal Rules
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Emphasis
|
||||
|
||||
**This is bold text**
|
||||
|
||||
_This is italic text_
|
||||
|
||||
~~Strikethrough~~
|
||||
|
||||
## Quotes
|
||||
|
||||
"Double quotes" and 'single quotes'
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> Blockquotes can also be nested...
|
||||
>
|
||||
> > ...by using additional greater-than signs right next to each other...
|
||||
|
||||
## References
|
||||
|
||||
An example containing a clickable reference[^1] with a link to the source.
|
||||
|
||||
Second example containing a reference[^2] with a link to the source.
|
||||
|
||||
[^1]: Reference first footnote with a return to content link.
|
||||
[^2]: Second reference with a link.
|
||||
|
||||
If you check out this example in `src/content/post/markdown-elements/index.md`, you'll notice that the references and the heading "Footnotes" are added to the bottom of the page via the [remark-rehype](https://github.com/remarkjs/remark-rehype#options) plugin.
|
||||
|
||||
## Lists
|
||||
|
||||
Unordered
|
||||
|
||||
- Create a list by starting a line with `+`, `-`, or `*`
|
||||
- Sub-lists are made by indenting 2 spaces:
|
||||
- Marker character change forces new list start:
|
||||
- Ac tristique libero volutpat at
|
||||
- Facilisis in pretium nisl aliquet
|
||||
- Nulla volutpat aliquam velit
|
||||
- Very easy!
|
||||
|
||||
Ordered
|
||||
|
||||
1. Lorem ipsum dolor sit amet
|
||||
2. Consectetur adipiscing elit
|
||||
3. Integer molestie lorem at massa
|
||||
|
||||
4. You can use sequential numbers...
|
||||
5. ...or keep all the numbers as `1.`
|
||||
|
||||
Start numbering with offset:
|
||||
|
||||
57. foo
|
||||
1. bar
|
||||
|
||||
## Code
|
||||
|
||||
Inline `code`
|
||||
|
||||
Indented code
|
||||
|
||||
// Some comments
|
||||
line 1 of code
|
||||
line 2 of code
|
||||
line 3 of code
|
||||
|
||||
Block code "fences"
|
||||
|
||||
```
|
||||
Sample text here...
|
||||
```
|
||||
|
||||
Syntax highlighting
|
||||
|
||||
```js
|
||||
var foo = function (bar) {
|
||||
return bar++;
|
||||
};
|
||||
|
||||
console.log(foo(5));
|
||||
```
|
||||
|
||||
### Expressive code examples
|
||||
|
||||
Adding a title
|
||||
|
||||
```js title="file.js"
|
||||
console.log("Title example");
|
||||
```
|
||||
|
||||
A bash terminal
|
||||
|
||||
```bash
|
||||
echo "A base terminal example"
|
||||
```
|
||||
|
||||
Highlighting code lines
|
||||
|
||||
```js title="line-markers.js" del={2} ins={3-4} {6}
|
||||
function demo() {
|
||||
console.log("this line is marked as deleted");
|
||||
// This line and the next one are marked as inserted
|
||||
console.log("this is the second inserted line");
|
||||
|
||||
return "this line uses the neutral default marker type";
|
||||
}
|
||||
```
|
||||
|
||||
[Expressive Code](https://expressive-code.com/) can do a ton more than shown here, and includes a lot of [customisation](https://expressive-code.com/reference/configuration/).
|
||||
|
||||
## Tables
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ------------------------------------------------------------------------- |
|
||||
| data | path to data files to supply the data that will be passed into templates. |
|
||||
| engine | engine to be used for processing templates. Handlebars is the default. |
|
||||
| ext | extension to be used for dest files. |
|
||||
|
||||
Right aligned columns
|
||||
|
||||
| Option | Description |
|
||||
| -----: | ------------------------------------------------------------------------: |
|
||||
| data | path to data files to supply the data that will be passed into templates. |
|
||||
| engine | engine to be used for processing templates. Handlebars is the default. |
|
||||
| ext | extension to be used for dest files. |
|
||||
|
||||
## Images
|
||||
|
||||
Image in the same folder: `src/content/post/markdown-elements/logo.png`
|
||||
|
||||

|
||||
|
||||
## Links
|
||||
|
||||
[Content from markdown-it](https://markdown-it.github.io/)
|
BIN
src/content/post/markdown-elements/logo.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
6
src/content/post/missing-content.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "This post doesn't have any content"
|
||||
description: "This post is purely for testing the table of content, which should not be rendered"
|
||||
publishDate: "22 Feb 2023"
|
||||
tags: ["test", "toc"]
|
||||
---
|
22
src/content/post/social-image.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "Example OG Social Image"
|
||||
publishDate: "27 January 2023"
|
||||
description: "An example post for Astro Cactus, detailing how to add a custom social image card in the frontmatter"
|
||||
tags: ["example", "blog", "image"]
|
||||
ogImage: "/social-card.png"
|
||||
---
|
||||
|
||||
## Adding your own social image to a post
|
||||
|
||||
This post is an example of how to add a custom [open graph](https://ogp.me/) social image, also known as an OG image, to a blog post.
|
||||
By adding the optional ogImage property to the frontmatter of a post, you opt out of [satori](https://github.com/vercel/satori) automatically generating an image for this page.
|
||||
|
||||
If you open this markdown file `src/content/post/social-image.md` you'll see the ogImage property set to an image which lives in the public folder[^1].
|
||||
|
||||
```yaml
|
||||
ogImage: "/social-card.png"
|
||||
```
|
||||
|
||||
You can view the one set for this template page [here](https://astro-cactus.chriswilliams.dev/social-card.png).
|
||||
|
||||
[^1]: The image itself can be located anywhere you like.
|
12
src/content/post/unique-tags.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Unique tags validation"
|
||||
publishDate: "30 January 2023"
|
||||
description: "This post is used for validating if duplicate tags are removed, regardless of the string case"
|
||||
tags: ["blog", "blog", "Blog", "test", "bloG", "Test", "BLOG"]
|
||||
---
|
||||
|
||||
## This post is to test zod transform
|
||||
|
||||
If you open the file `src/content/post/unique-tags.md`, the tags array has a number of duplicate blog strings of various cases.
|
||||
|
||||
These are removed as part of the removeDupsAndLowercase function found in `src/content/config.ts`.
|
2
src/env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
35
src/layouts/BaseLayout.astro
Normal file
@ -0,0 +1,35 @@
|
||||
---
|
||||
import type { SiteMeta } from '@/types'
|
||||
|
||||
import BaseHead from '@/components/BaseHead.astro'
|
||||
import Footer from '@/components/layout/Footer.astro'
|
||||
import Header from '@/components/layout/Header.astro'
|
||||
import ThemeProvider from '@/components/ThemeProvider.astro'
|
||||
|
||||
import { siteConfig } from '@/site-config'
|
||||
|
||||
interface Props {
|
||||
meta: SiteMeta
|
||||
}
|
||||
|
||||
const {
|
||||
meta: { articleDate, description = siteConfig.description, ogImage, title }
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<html lang={siteConfig.lang} class=''>
|
||||
<head>
|
||||
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
|
||||
</head>
|
||||
|
||||
<body class='flex justify-center bg-background'>
|
||||
<ThemeProvider />
|
||||
<main
|
||||
class='flex min-h-screen w-screen max-w-[60rem] flex-col items-center px-6 pb-10 pt-7 font-satoshi text-[0.92rem] leading-relaxed sm:px-10 lg:px-10'
|
||||
>
|
||||
<Header />
|
||||
<slot />
|
||||
<Footer />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
91
src/layouts/BlogPost.astro
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import BlogHero from '@/components/blog/Hero.astro'
|
||||
import TOC from '@/components/blog/TOC.astro'
|
||||
import Button from '@/components/Button.astro'
|
||||
|
||||
import PageLayout from './BaseLayout.astro'
|
||||
|
||||
interface Props {
|
||||
post: CollectionEntry<'post'>
|
||||
}
|
||||
|
||||
const { post } = Astro.props
|
||||
const {
|
||||
data: { description, ogImage, publishDate, title, updatedDate },
|
||||
slug
|
||||
} = post
|
||||
|
||||
const socialImage = ogImage ?? `/og-image/${slug}.png`
|
||||
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString()
|
||||
const { headings } = await post.render()
|
||||
---
|
||||
|
||||
<PageLayout meta={{ articleDate, description, ogImage: socialImage, title }}>
|
||||
<div class='w-full'>
|
||||
<Button title='Back' href='/blog' style='button'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
slot='icon-before'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='m6.921 12.5l5.792 5.792L12 19l-7-7l7-7l.713.708L6.921 11.5H19v1z'
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
<div class='mt-8 gap-x-10 lg:flex lg:items-start'>
|
||||
{!!headings.length && <TOC headings={headings} />}
|
||||
<article class='flex-grow break-words' data-pagefind-body>
|
||||
<div id='blog-hero'><BlogHero content={post} /></div>
|
||||
<div
|
||||
class='prose prose-base prose-zinc mt-12 text-muted-foreground dark:prose-invert prose-headings:font-medium prose-headings:text-foreground prose-headings:before:absolute prose-headings:before:-ms-4 prose-th:before:content-none'
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
aria-label='Back to Top'
|
||||
class='z-90 fixed bottom-8 end-4 flex h-8 w-8 translate-y-28 items-center justify-center rounded-full border-2 border-transparent bg-primary-foreground text-3xl opacity-0 transition-all duration-300 hover:border-border/75 data-[show=true]:translate-y-0 data-[show=true]:opacity-100 sm:end-8 sm:h-12 sm:w-12'
|
||||
data-show='false'
|
||||
id='to-top-btn'
|
||||
><svg
|
||||
aria-hidden='true'
|
||||
class='h-4 w-4'
|
||||
fill='none'
|
||||
focusable='false'
|
||||
stroke='currentColor'
|
||||
stroke-width='2'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M4.5 15.75l7.5-7.5 7.5 7.5' stroke-linecap='round' stroke-linejoin='round'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
<script>
|
||||
const scrollBtn = document.getElementById('to-top-btn') as HTMLButtonElement
|
||||
const targetHeader = document.getElementById('blog-hero') as HTMLDivElement
|
||||
|
||||
function callback(entries: IntersectionObserverEntry[]) {
|
||||
entries.forEach((entry) => {
|
||||
// only show the scroll to top button when the heading is out of view
|
||||
scrollBtn.dataset.show = (!entry.isIntersecting).toString()
|
||||
})
|
||||
}
|
||||
|
||||
scrollBtn.addEventListener('click', () => {
|
||||
document.documentElement.scrollTo({ behavior: 'smooth', top: 0 })
|
||||
})
|
||||
|
||||
const observer = new IntersectionObserver(callback)
|
||||
observer.observe(targetHeader)
|
||||
</script>
|
33
src/pages/404.astro
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||
import Button from '@/components/Button.astro'
|
||||
|
||||
const meta = {
|
||||
description: 'Not found',
|
||||
title: '404'
|
||||
}
|
||||
---
|
||||
|
||||
<PageLayout meta={meta}>
|
||||
<div class='px-4 py-10 text-center sm:px-6 lg:px-8'>
|
||||
<h1 class='block text-7xl font-bold sm:text-9xl'>404</h1>
|
||||
<p class='mt-3 text-muted-foreground'>Oops, something went wrong.</p>
|
||||
<p class=''>Sorry, we couldn't find your page.</p>
|
||||
<Button title='Back to home' href='/' style='button' class='mt-5'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
slot='icon-after'
|
||||
class='-scale-x-100'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='m6.921 12.5l5.792 5.792L12 19l-7-7l7-7l.713.708L6.921 11.5H19v1z'
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
113
src/pages/blog/[...page].astro
Normal file
@ -0,0 +1,113 @@
|
||||
---
|
||||
export const prerender = true
|
||||
|
||||
import type { GetStaticPaths, Page } from 'astro'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import Button from '@/components/Button.astro'
|
||||
import Pagination from '@/components/Paginator.astro'
|
||||
import PostPreview from '@/components/blog/PostPreview.astro'
|
||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||
import { getAllPosts, getUniqueTags, sortMDByDate } from '@/utils'
|
||||
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const allPosts = await getAllPosts()
|
||||
const allPostsByDate = sortMDByDate(allPosts)
|
||||
const uniqueTags = getUniqueTags(allPosts)
|
||||
return paginate(allPostsByDate, { pageSize: 10, props: { uniqueTags } })
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
interface Props {
|
||||
page: Page<CollectionEntry<'post'>>
|
||||
uniqueTags: string[]
|
||||
}
|
||||
|
||||
const { page, uniqueTags } = Astro.props
|
||||
|
||||
const meta = {
|
||||
description: 'Posts',
|
||||
title: 'Blog'
|
||||
}
|
||||
|
||||
const paginationProps = {
|
||||
...(page.url.prev && {
|
||||
prevUrl: {
|
||||
text: `← Previous Posts`,
|
||||
url: page.url.prev
|
||||
}
|
||||
}),
|
||||
...(page.url.next && {
|
||||
nextUrl: {
|
||||
text: `Next Posts →`,
|
||||
url: page.url.next
|
||||
}
|
||||
})
|
||||
}
|
||||
---
|
||||
|
||||
<PageLayout meta={meta}>
|
||||
<div class='w-full'>
|
||||
<Button title='Back' href='/' style='button'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
slot='icon-before'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='m6.921 12.5l5.792 5.792L12 19l-7-7l7-7l.713.708L6.921 11.5H19v1z'
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<h1 class='mb-6 mt-5 text-2xl font-bold'>Blog</h1>
|
||||
<div class='grid gap-y-16 sm:grid-cols-[3fr_1fr] sm:gap-x-8'>
|
||||
<section aria-label='Blog posts list'>
|
||||
<ul class='flex flex-col gap-y-4 text-start'>
|
||||
{page.data.map((p) => <PostPreview post={p} withDesc />)}
|
||||
</ul>
|
||||
<Pagination {...paginationProps} />
|
||||
</section>
|
||||
{
|
||||
!!uniqueTags.length && (
|
||||
<aside>
|
||||
<h2 class='mb-4 flex items-center text-lg font-semibold'>
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
class='h-6 w-6'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke-linecap='round'
|
||||
stroke-linejoin='round'
|
||||
stroke-width='1.5'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path d='M0 0h24v24H0z' fill='none' stroke='none' />
|
||||
<path d='M7.859 6h-2.834a2.025 2.025 0 0 0 -2.025 2.025v2.834c0 .537 .213 1.052 .593 1.432l6.116 6.116a2.025 2.025 0 0 0 2.864 0l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-6.117 -6.116a2.025 2.025 0 0 0 -1.431 -.593z' />
|
||||
<path d='M17.573 18.407l2.834 -2.834a2.025 2.025 0 0 0 0 -2.864l-7.117 -7.116' />
|
||||
<path d='M6 9h-.01' />
|
||||
</svg>
|
||||
Tags
|
||||
</h2>
|
||||
<ul class='text-bgColor flex flex-wrap gap-2'>
|
||||
{uniqueTags.map((tag) => (
|
||||
<li>
|
||||
<Button title={tag} href={`/tags/${tag}/`} style='pill' />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span class='mt-4 block sm:text-end'>
|
||||
<a aria-label='View all blog categories' class='' href='/tags/' data-astro-prefetch>
|
||||
View all →
|
||||
</a>
|
||||
</span>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
25
src/pages/blog/[slug].astro
Normal file
@ -0,0 +1,25 @@
|
||||
---
|
||||
export const prerender = true
|
||||
|
||||
import type { GetStaticPaths, InferGetStaticPropsType } from 'astro'
|
||||
|
||||
import PostLayout from '@/layouts/BlogPost.astro'
|
||||
import { getAllPosts } from '@/utils'
|
||||
|
||||
export const getStaticPaths = (async () => {
|
||||
const blogEntries = await getAllPosts()
|
||||
return blogEntries.map((entry) => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry }
|
||||
}))
|
||||
}) satisfies GetStaticPaths
|
||||
|
||||
type Props = InferGetStaticPropsType<typeof getStaticPaths>
|
||||
|
||||
const { entry } = Astro.props
|
||||
const { Content } = await entry.render()
|
||||
---
|
||||
|
||||
<PostLayout post={entry}>
|
||||
<Content />
|
||||
</PostLayout>
|
215
src/pages/index.astro
Normal file
@ -0,0 +1,215 @@
|
||||
---
|
||||
import PageLayout from '../layouts/BaseLayout.astro'
|
||||
import Section from '../components/Section.astro'
|
||||
import Card from '../components/Card.astro'
|
||||
import ProjectCard from '../components/ProjectCard.astro'
|
||||
import Label from '../components/Label.astro'
|
||||
import SkillLayout from '../components/SkillLayout.astro'
|
||||
import PostPreview from '@/components/blog/PostPreview.astro'
|
||||
|
||||
import { Image } from 'astro:assets'
|
||||
import astro from '../assets/about-astro.png'
|
||||
|
||||
import { getAllPosts, sortMDByDate } from '@/utils'
|
||||
|
||||
const languages = ['lorem', 'ipsum']
|
||||
const frontend = ['lorem', 'ipsum', 'lorem', 'ipsum', 'lorem', 'ipsum', 'lorem']
|
||||
const backend = ['lorem', 'ipsum', 'lorem', 'ipsum']
|
||||
const others = ['lorem', 'ipsum', 'lorem', 'ipsum', 'lorem']
|
||||
|
||||
const MAX_POSTS = 10
|
||||
const allPosts = await getAllPosts()
|
||||
const allPostsByDate = sortMDByDate(allPosts).slice(0, MAX_POSTS)
|
||||
---
|
||||
|
||||
<PageLayout meta={{ title: 'Home' }}>
|
||||
<div class='flex w-full flex-col gap-y-10'>
|
||||
<section class='flex flex-col items-center gap-y-7'>
|
||||
<Image
|
||||
src={astro}
|
||||
alt='profile photo'
|
||||
class='h-28 w-auto rounded-full p-2 bg-[#FFBE98]'
|
||||
loading='eager'
|
||||
/>
|
||||
|
||||
<div class='flex flex-col items-center gap-y-4'>
|
||||
<h1 class='text-3xl font-bold'>Lorem ipsum dolor</h1>
|
||||
<div
|
||||
class='flex flex-row items-center gap-x-3 rounded-3xl border border-input px-4 py-2 text-sm shadow-sm'
|
||||
>
|
||||
<span class='relative flex items-center justify-center'>
|
||||
<span
|
||||
class='absolute inline-flex h-2 w-2 animate-ping rounded-full border border-green-400 bg-green-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-green-400'></span>
|
||||
</span>
|
||||
|
||||
<p class=''>Available for work</p>
|
||||
</div>
|
||||
<div class='flex gap-x-7'>
|
||||
<Label title='Lorem Ipsum'>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' class='h-5 w-5' slot='icon'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M4.615 20q-.69 0-1.152-.462Q3 19.075 3 18.385v-9.77q0-.69.463-1.152Q3.925 7 4.615 7H9V5.615q0-.69.463-1.152Q9.925 4 10.615 4h2.77q.69 0 1.153.463q.462.462.462 1.152V7h4.385q.69 0 1.152.463q.463.462.463 1.152v9.77q0 .69-.462 1.152q-.463.463-1.153.463zm0-1h14.77q.23 0 .423-.192q.192-.193.192-.423v-9.77q0-.23-.192-.423Q19.615 8 19.385 8H4.615q-.23 0-.423.192Q4 8.385 4 8.615v9.77q0 .23.192.423q.193.192.423.192M10 7h4V5.615q0-.23-.192-.423Q13.615 5 13.385 5h-2.77q-.23 0-.423.192q-.192.193-.192.423zM4 19V8z'
|
||||
></path></svg
|
||||
>
|
||||
</Label>
|
||||
<Label title='Ipsum'>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' class='h-5 w-5' slot='icon'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M12.003 11.73q.668 0 1.14-.475q.472-.475.472-1.143t-.475-1.14q-.476-.472-1.143-.472t-1.14.476q-.472.475-.472 1.143t.475 1.14q.476.472 1.143.472M12 19.677q2.82-2.454 4.458-4.991q1.638-2.538 1.638-4.39q0-2.744-1.737-4.53T12 3.981q-2.621 0-4.359 1.785t-1.737 4.53q0 1.852 1.638 4.39q1.639 2.537 4.458 4.99m0 1.343q-3.525-3.117-5.31-5.814q-1.786-2.697-1.786-4.909q0-3.173 2.066-5.234Q9.037 3 12 3t5.03 2.062q2.066 2.061 2.066 5.234q0 2.212-1.785 4.909q-1.786 2.697-5.311 5.814m0-10.904'
|
||||
></path></svg
|
||||
>
|
||||
</Label>
|
||||
<Label
|
||||
title='Connect on Linkedin'
|
||||
as='a'
|
||||
href='https://www.linkedin.com/in/example/'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 16 16'
|
||||
slot='icon'
|
||||
class='h-5 w-5 text-foreground/75'
|
||||
><path
|
||||
fill='currentColor'
|
||||
d='M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248c-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586c.173-.431.568-.878 1.232-.878c.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252c-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z'
|
||||
></path></svg
|
||||
>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section title='About'>
|
||||
<p class='text-muted-foreground'>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Reiciendis atque quia omnis
|
||||
consectetur, voluptas praesentium veniam blanditiis ratione asperiores accusantium laborum
|
||||
odit commodi quis deserunt incidunt et dolor iure ut! Lorem ipsum dolor sit, amet
|
||||
consectetur adipisicing elit. Alias quia, doloribus ut beatae perspiciatis voluptatibus.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title='Posts'>
|
||||
<ul class='flex flex-col gap-y-2'>
|
||||
{
|
||||
allPostsByDate.map((p) => (
|
||||
<li class='flex flex-col gap-x-2 sm:flex-row'>
|
||||
<PostPreview post={p} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title='Experience'>
|
||||
<Card
|
||||
heading='Lorem Ipsum'
|
||||
subheading='Sit amet consectetur'
|
||||
date='Dec 2022 - Nov 2023'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-12 w-auto md:-left-16'
|
||||
>
|
||||
<ul class='ml-4 list-disc text-muted-foreground'>
|
||||
<li>
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Dolore debitis recusandae, ut
|
||||
molestiae laboriosam pariatur!
|
||||
|
||||
<li>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestiae, pariatur!</li>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
heading='Lorem Ipsum'
|
||||
subheading='Sit amet consectetur'
|
||||
date='Dec 2022 - Nov 2023'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-12 w-auto md:-left-16'
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title='Education'>
|
||||
<Card
|
||||
heading='Lorem Ipsum'
|
||||
subheading='Sit amet consectetur'
|
||||
date='Dec 2022 - Nov 2023'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-12 w-auto md:-left-16'
|
||||
>
|
||||
<ul class='ml-4 list-disc text-muted-foreground'>
|
||||
<li>
|
||||
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Dolore debitis recusandae, ut
|
||||
molestiae laboriosam pariatur!
|
||||
|
||||
<li>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestiae, pariatur!</li>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
heading='Lorem Ipsum'
|
||||
subheading='Sit amet consectetur'
|
||||
date='Dec 2022 - Nov 2023'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-12 w-auto md:-left-16'
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title='Projects'>
|
||||
<div class='flex flex-col gap-y-3 sm:flex-row sm:gap-x-3 sm:gap-y-0'>
|
||||
<ProjectCard
|
||||
href='https://www.google.com'
|
||||
heading='Consectetur'
|
||||
subheading='Lorem ipsum dolor sit amet consectetur adipisicing elit.'
|
||||
imagePath='/src/assets/coming-soon.png'
|
||||
altText='Example'
|
||||
class='w-full sm:w-1/2'
|
||||
/>
|
||||
<ProjectCard
|
||||
as='div'
|
||||
heading='Coming soon...'
|
||||
subheading=''
|
||||
imagePath='/src/assets/coming-soon.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
class='w-full sm:w-1/2'
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title='Certifications'>
|
||||
<Card
|
||||
as='a'
|
||||
heading='Lorem ipsum, dolor sit amet consectetur adipisicing.'
|
||||
subheading='Lorem ipsum dolor sit amet consectetur adipisicing elit. Aperiam dicta magni consequuntur corrupti.'
|
||||
date='Mar 2024 - Mar 2024'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-11 w-auto md:-left-16'
|
||||
href='https://www.google.com'
|
||||
/>
|
||||
<Card
|
||||
as='a'
|
||||
heading='Lorem ipsum, dolor sit amet'
|
||||
subheading='Lorem ipsum dolor sit amet consectetur adipisicing elit. Aperiam dicta.'
|
||||
date='Mar 2029 - Mar 2032'
|
||||
imagePath='/src/assets/about-astro.png'
|
||||
altText='Lorem, ipsum dolor sit'
|
||||
imageClass='h-11 w-auto md:-left-16'
|
||||
href='https://www.google.com'
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title='Skills'>
|
||||
<SkillLayout title='Languages' skills={languages} />
|
||||
<SkillLayout title='Frontend' skills={frontend} />
|
||||
<SkillLayout title='Backend' skills={backend} />
|
||||
<SkillLayout title='Others' skills={others} />
|
||||
</Section>
|
||||
</div>
|
||||
</PageLayout>
|
19
src/pages/rss.xml.js
Normal file
@ -0,0 +1,19 @@
|
||||
import rss from '@astrojs/rss'
|
||||
import { siteConfig } from '@/site-config'
|
||||
import { getAllPosts } from '@/utils'
|
||||
|
||||
export const GET = async () => {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return rss({
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
site: import.meta.env.SITE,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
pubDate: post.data.publishDate,
|
||||
link: `/blog/${post.slug}`
|
||||
}))
|
||||
})
|
||||
}
|
83
src/pages/tags/[tag]/[...page].astro
Normal file
@ -0,0 +1,83 @@
|
||||
---
|
||||
export const prerender = true
|
||||
|
||||
import type { GetStaticPaths, Page } from 'astro'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import Pagination from '@/components/Paginator.astro'
|
||||
import PostPreview from '@/components/blog/PostPreview.astro'
|
||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||
import Button from '@/components/Button.astro'
|
||||
import { getAllPosts, getUniqueTags, sortMDByDate } from '@/utils'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
|
||||
const allPosts = await getAllPosts()
|
||||
const allPostsByDate = sortMDByDate(allPosts)
|
||||
const uniqueTags = getUniqueTags(allPostsByDate)
|
||||
|
||||
return uniqueTags.flatMap((tag) => {
|
||||
const filterPosts = allPostsByDate.filter((post) => post.data.tags.includes(tag))
|
||||
return paginate(filterPosts, {
|
||||
pageSize: 10,
|
||||
params: { tag }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface Props {
|
||||
page: Page<CollectionEntry<'post'>>
|
||||
}
|
||||
|
||||
const { page } = Astro.props
|
||||
const { tag } = Astro.params
|
||||
|
||||
const meta = {
|
||||
description: `View all posts with the tag - ${tag}`,
|
||||
title: `Tag: ${tag}`
|
||||
}
|
||||
|
||||
const paginationProps = {
|
||||
...(page.url.prev && {
|
||||
prevUrl: {
|
||||
text: `← Previous Tags`,
|
||||
url: page.url.prev
|
||||
}
|
||||
}),
|
||||
...(page.url.next && {
|
||||
nextUrl: {
|
||||
text: `Next Tags →`,
|
||||
url: page.url.next
|
||||
}
|
||||
})
|
||||
}
|
||||
---
|
||||
|
||||
<PageLayout meta={meta}>
|
||||
<div class='w-full'>
|
||||
<Button title='Back' href='/blog' style='button'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
slot='icon-before'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='m6.921 12.5l5.792 5.792L12 19l-7-7l7-7l.713.708L6.921 11.5H19v1z'
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
<h1 class='mb-6 mt-5 flex items-end gap-x-2 text-2xl font-bold'>
|
||||
Tags:
|
||||
<span class='text-xl'>#{tag}</span>
|
||||
</h1>
|
||||
<section aria-label='Blog post list'>
|
||||
<ul class='flex flex-col gap-y-3 text-start'>
|
||||
{page.data.map((p) => <PostPreview as='h2' post={p} withDesc />)}
|
||||
</ul>
|
||||
<Pagination {...paginationProps} />
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
55
src/pages/tags/index.astro
Normal file
@ -0,0 +1,55 @@
|
||||
---
|
||||
import Button from '@/components/Button.astro'
|
||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||
import { getAllPosts, getUniqueTagsWithCount } from '@/utils'
|
||||
|
||||
const allPosts = await getAllPosts()
|
||||
const allTags = getUniqueTagsWithCount(allPosts)
|
||||
|
||||
const meta = {
|
||||
description: "A list of all the topics I've written about in my posts",
|
||||
title: 'All Tags'
|
||||
}
|
||||
---
|
||||
|
||||
<PageLayout meta={meta}>
|
||||
<div class='w-full'>
|
||||
<Button title='Back' href='/blog' style='button'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 24 24'
|
||||
slot='icon-before'
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='m6.921 12.5l5.792 5.792L12 19l-7-7l7-7l.713.708L6.921 11.5H19v1z'
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<h1 class='mb-6 mt-5 text-2xl font-bold'>Tags</h1>
|
||||
|
||||
<ul class='flex flex-col gap-y-3'>
|
||||
{
|
||||
allTags.map(([tag, val]) => (
|
||||
<li class='flex items-center gap-x-2 '>
|
||||
<a
|
||||
class='inline-block underline underline-offset-4 hover:text-foreground/75'
|
||||
data-astro-prefetch
|
||||
href={`/tags/${tag}/`}
|
||||
title={`View posts with the tag: ${tag}`}
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
<span class='inline-block'>
|
||||
- {val} post{val > 1 && 's'}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</PageLayout>
|
65
src/site.config.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { SiteConfig } from '@/types'
|
||||
import type { AstroExpressiveCodeOptions } from 'astro-expressive-code'
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
// Used as both a meta property (src/components/BaseHead.astro L:31 + L:49) & the generated satori png (src/pages/og-image/[slug].png.ts)
|
||||
author: 'lorem ipsum',
|
||||
// Meta property used to construct the meta title property, found in src/components/BaseHead.astro L:11
|
||||
title: 'lorem',
|
||||
// Meta property used as the default description meta property
|
||||
description: 'The official website of Lorem Ipsum',
|
||||
// HTML lang property, found in src/layouts/Base.astro L:18
|
||||
lang: 'en-GB',
|
||||
// Meta property, found in src/components/BaseHead.astro L:42
|
||||
ogLocale: 'en_GB',
|
||||
// Date.prototype.toLocaleDateString() parameters, found in src/utils/date.ts.
|
||||
date: {
|
||||
locale: 'en-GB',
|
||||
options: {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const menuLinks: Array<{ title: string; path: string }> = [
|
||||
{
|
||||
title: 'Home',
|
||||
path: '/'
|
||||
},
|
||||
{
|
||||
title: 'Blog',
|
||||
path: '/blog/'
|
||||
}
|
||||
]
|
||||
|
||||
// https://expressive-code.com/reference/configuration/
|
||||
export const expressiveCodeOptions: AstroExpressiveCodeOptions = {
|
||||
// One dark, one light theme => https://expressive-code.com/guides/themes/#available-themes
|
||||
themes: ['dracula', 'github-light'],
|
||||
themeCssSelector(theme, { styleVariants }) {
|
||||
// If one dark and one light theme are available
|
||||
// generate theme CSS selectors compatible with cactus-theme dark mode switch
|
||||
if (styleVariants.length >= 2) {
|
||||
const baseTheme = styleVariants[0]?.theme
|
||||
const altTheme = styleVariants.find((v) => v.theme.type !== baseTheme?.type)?.theme
|
||||
if (theme === baseTheme || theme === altTheme) return `[data-theme='${theme.type}']`
|
||||
}
|
||||
// return default selector
|
||||
return `[data-theme="${theme.name}"]`
|
||||
},
|
||||
useThemedScrollbars: false,
|
||||
styleOverrides: {
|
||||
frames: {
|
||||
frameBoxShadowCssValue: 'none'
|
||||
},
|
||||
uiLineHeight: 'inherit',
|
||||
codeFontSize: '0.875rem',
|
||||
codeLineHeight: '1.7142857rem',
|
||||
borderRadius: '4px',
|
||||
codePaddingInline: '1rem',
|
||||
codeFontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;'
|
||||
}
|
||||
}
|
75
src/styles/app.css
Normal file
@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Clash Display';
|
||||
src: url('/fonts/ClashDisplay-Variable.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/Satoshi-Variable.ttf');
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/Satoshi-VariableItalic.ttf');
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 33% 99%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 72.22% 50.59%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
24
src/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type SiteConfig = {
|
||||
author: string
|
||||
title: string
|
||||
description: string
|
||||
lang: string
|
||||
ogLocale: string
|
||||
date: {
|
||||
locale: string | string[] | undefined
|
||||
options: Intl.DateTimeFormatOptions
|
||||
}
|
||||
}
|
||||
|
||||
export type PaginationLink = {
|
||||
url: string
|
||||
text?: string
|
||||
srLabel?: string
|
||||
}
|
||||
|
||||
export type SiteMeta = {
|
||||
title: string
|
||||
description?: string
|
||||
ogImage?: string | undefined
|
||||
articleDate?: string | undefined
|
||||
}
|
17
src/utils/date.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { siteConfig } from '@/site-config'
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat(siteConfig.date.locale, siteConfig.date.options)
|
||||
|
||||
export function getFormattedDate(
|
||||
date: string | number | Date,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
) {
|
||||
if (typeof options !== 'undefined') {
|
||||
return new Date(date).toLocaleDateString(siteConfig.date.locale, {
|
||||
...(siteConfig.date.options as Intl.DateTimeFormatOptions),
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
return dateFormat.format(new Date(date))
|
||||
}
|
11
src/utils/domElement.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function toggleClass(element: HTMLElement, className: string) {
|
||||
element.classList.toggle(className)
|
||||
}
|
||||
|
||||
export function elementHasClass(element: HTMLElement, className: string) {
|
||||
return element.classList.contains(className)
|
||||
}
|
||||
|
||||
export function rootInDarkMode() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
41
src/utils/generateToc.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { MarkdownHeading } from 'astro'
|
||||
|
||||
export interface TocItem extends MarkdownHeading {
|
||||
subheadings: Array<TocItem>
|
||||
}
|
||||
|
||||
function diveChildren(item: TocItem, depth: number): Array<TocItem> {
|
||||
if (depth === 1 || !item.subheadings.length) {
|
||||
return item.subheadings
|
||||
} else {
|
||||
// e.g., 2
|
||||
return diveChildren(item.subheadings[item.subheadings.length - 1] as TocItem, depth - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function generateToc(headings: ReadonlyArray<MarkdownHeading>) {
|
||||
// this ignores/filters out h1 element(s)
|
||||
const bodyHeadings = [...headings.filter(({ depth }) => depth > 1)]
|
||||
const toc: Array<TocItem> = []
|
||||
|
||||
bodyHeadings.forEach((h) => {
|
||||
const heading: TocItem = { ...h, subheadings: [] }
|
||||
|
||||
// add h2 elements into the top level
|
||||
if (heading.depth === 2) {
|
||||
toc.push(heading)
|
||||
} else {
|
||||
const lastItemInToc = toc[toc.length - 1]!
|
||||
if (heading.depth < lastItemInToc.depth) {
|
||||
throw new Error(`Orphan heading found: ${heading.text}.`)
|
||||
}
|
||||
|
||||
// higher depth
|
||||
// push into children, or children's children
|
||||
const gap = heading.depth - lastItemInToc.depth
|
||||
const target = diveChildren(lastItemInToc, gap)
|
||||
target.push(heading)
|
||||
}
|
||||
})
|
||||
return toc
|
||||
}
|
6
src/utils/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { cn } from './tailwind'
|
||||
export { getAllPosts, sortMDByDate, getUniqueTags, getUniqueTagsWithCount } from './post'
|
||||
export { getFormattedDate } from './date'
|
||||
export { generateToc } from './generateToc'
|
||||
export type { TocItem } from './generateToc'
|
||||
export { elementHasClass, toggleClass, rootInDarkMode } from './domElement'
|
39
src/utils/post.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
/** Note: this function filters out draft posts based on the environment */
|
||||
export async function getAllPosts() {
|
||||
return await getCollection('post', ({ data }) => {
|
||||
return import.meta.env.PROD ? data.draft !== true : true
|
||||
})
|
||||
}
|
||||
|
||||
export function sortMDByDate(posts: Array<CollectionEntry<'post'>>) {
|
||||
return posts.sort((a, b) => {
|
||||
const aDate = new Date(a.data.updatedDate ?? a.data.publishDate).valueOf()
|
||||
const bDate = new Date(b.data.updatedDate ?? b.data.publishDate).valueOf()
|
||||
return bDate - aDate
|
||||
})
|
||||
}
|
||||
|
||||
/** Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so. */
|
||||
export function getAllTags(posts: Array<CollectionEntry<'post'>>) {
|
||||
return posts.flatMap((post) => [...post.data.tags])
|
||||
}
|
||||
|
||||
/** Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so. */
|
||||
export function getUniqueTags(posts: Array<CollectionEntry<'post'>>) {
|
||||
return [...new Set(getAllTags(posts))]
|
||||
}
|
||||
|
||||
/** Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so. */
|
||||
export function getUniqueTagsWithCount(
|
||||
posts: Array<CollectionEntry<'post'>>
|
||||
): Array<[string, number]> {
|
||||
return [
|
||||
...getAllTags(posts).reduce(
|
||||
(acc, t) => acc.set(t, (acc.get(t) || 0) + 1),
|
||||
new Map<string, number>()
|
||||
)
|
||||
].sort((a, b) => b[1] - a[1])
|
||||
}
|
13
src/utils/remarkReadingTime.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import getReadingTime from 'reading-time'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
export function remarkReadingTime() {
|
||||
// @ts-expect-error:next-line
|
||||
return function (tree, { data }) {
|
||||
const textOnPage = toString(tree)
|
||||
const readingTime = getReadingTime(textOnPage)
|
||||
// readingTime.text will give us minutes read as a friendly string,
|
||||
// i.e. "3 min read"
|
||||
data.astro.frontmatter.minutesRead = readingTime.text
|
||||
}
|
||||
}
|
6
src/utils/tailwind.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
70
tailwind.config.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { fontFamily } from 'tailwindcss/defaultTheme'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const config = {
|
||||
darkMode: ['class'],
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
safelist: ['dark'],
|
||||
corePlugins: {
|
||||
aspectRatio: false
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography'), require('@tailwindcss/aspect-ratio')],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border) / <alpha-value>)',
|
||||
input: 'hsl(var(--input) / <alpha-value>)',
|
||||
ring: 'hsl(var(--ring) / <alpha-value>)',
|
||||
background: 'hsl(var(--background) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...fontFamily.sans],
|
||||
satoshi: ['Satoshi', 'sans'],
|
||||
display: ['Clash Display', 'display']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"paths": {
|
||||
"@/assets/*": ["src/assets/*"],
|
||||
"@/components/*": ["src/components/*"],
|
||||
"@/layouts/*": ["src/layouts/*"],
|
||||
"@/utils": ["src/utils/index.ts"],
|
||||
"@/types": ["src/types.ts"],
|
||||
"@/site-config": ["src/site.config.ts"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "**/node_modules/*", ".vscode", "dist"]
|
||||
}
|