Built resume template

This commit is contained in:
srleom
2024-03-19 16:14:57 +08:00
parent b266e46946
commit 36936150cf
73 changed files with 8970 additions and 0 deletions

24
.eslintrc.cjs Normal file
View 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
View 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
View File

@ -0,0 +1 @@
node_modules/**

4
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View 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
View File

@ -0,0 +1 @@
{}

79
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

22
prettier.config.cjs Normal file
View 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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
public/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/images/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
public/social-card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
src/assets/about-astro.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
src/assets/coming-soon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View 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' />

View 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
View 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>

View 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>

View 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>

View 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>
)
}

View 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>

View 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>

View 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>

View 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>

View 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>
)
}

View 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>

View 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>

View 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>

View 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>

View 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
View 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 }

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View 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"]
---

View 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.

View 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

View 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`
![Astro theme cactus logo](logo.png)
## Links
[Content from markdown-it](https://markdown-it.github.io/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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"]
---

View 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.

View 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
View File

@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />

View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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}`
}))
})
}

View 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>

View 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}`}
>
&#35;{tag}
</a>
<span class='inline-block'>
- {val} post{val > 1 && 's'}
</span>
</li>
))
}
</ul>
</div>
</PageLayout>

65
src/site.config.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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])
}

View 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
View 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
View 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
View 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"]
}