refactor(all): complete rewrite

This commit is contained in:
z0x 2025-02-04 23:37:40 -05:00
parent 9f928f4786
commit 757d21f0e8
67 changed files with 4053 additions and 974 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.md linguist-vendored
*.mdx linguist-vendored

View file

@ -1,25 +1,79 @@
import sitemap from "@astrojs/sitemap"; import { defineConfig } from 'astro/config'
import tailwind from "@astrojs/tailwind"; import mdx from '@astrojs/mdx'
import remarkCallout from "@r4ai/remark-callout"; import react from '@astrojs/react'
import sitemap from '@astrojs/sitemap'
import tailwind from '@astrojs/tailwind'
import umami from "@yeskunall/astro-umami"; import umami from "@yeskunall/astro-umami";
import { defineConfig } from "astro/config"; import icon from 'astro-icon'
import { transformerCopyButton } from '@rehype-pretty/transformers'
import {
transformerMetaHighlight,
transformerNotationDiff,
} from '@shikijs/transformers'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import rehypeKatex from 'rehype-katex'
import rehypeExternalLinks from 'rehype-external-links'
import rehypePrettyCode from 'rehype-pretty-code'
import remarkCallout from "@r4ai/remark-callout";
import remarkEmoji from 'remark-emoji'
import remarkMath from 'remark-math'
import remarkToc from 'remark-toc'
import sectionize from '@hbsnow/rehype-sectionize'
// https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://blog.z0x.ca", site: 'https://blog.z0x.ca',
integrations: [ integrations: [
tailwind({ tailwind({
nesting: true, applyBaseStyles: false,
}), }),
sitemap(),
mdx(),
react(),
umami({ umami({
id: "b691181e-cad7-4c23-b16a-709872a0a7ab", id: "b691181e-cad7-4c23-b16a-709872a0a7ab",
endpointUrl: "https://umami.z0x.ca", endpointUrl: "https://umami.z0x.ca",
}), }),
sitemap(), icon(),
], ],
markdown: { markdown: {
shikiConfig: { syntaxHighlight: false,
theme: "css-variables", rehypePlugins: [
[
rehypeExternalLinks,
{
target: '_blank',
rel: ['nofollow', 'noreferrer', 'noopener'],
}, },
remarkPlugins: [remarkCallout], ],
rehypeHeadingIds,
rehypeKatex,
sectionize,
[
rehypePrettyCode,
{
theme: {
light: 'github-light-high-contrast',
dark: 'github-dark-high-contrast',
}, },
}); transformers: [
transformerNotationDiff(),
transformerMetaHighlight(),
transformerCopyButton({
visibility: 'hover',
feedbackDuration: 1000,
}),
],
},
],
],
remarkPlugins: [remarkToc, remarkMath, remarkEmoji, remarkCallout],
},
server: {
port: 4321,
host: true,
},
devToolbar: {
enabled: false,
},
})

View file

@ -1,43 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": ["node_modules", "dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"overrides": [
{
"include": ["*.astro"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
}
}
}
}
]
}

1757
bun.lock Normal file

File diff suppressed because it is too large Load diff

BIN
bun.lockb

Binary file not shown.

20
components.json Normal file
View file

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View file

@ -1,26 +1,55 @@
{ {
"name": "blog.z0x.ca", "name": "blog.z0x.ca",
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.2.4",
"private": true,
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"prettier": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@astrojs/markdown-remark": "^6.1.0",
"@astrojs/mdx": "^4.0.8",
"@astrojs/react": "^4.2.0",
"@astrojs/rss": "^4.0.11", "@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1", "@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^5.1.5", "@astrojs/tailwind": "^5.1.5",
"@fontsource-variable/nunito": "^5.1.1", "@hbsnow/rehype-sectionize": "^1.0.7",
"@iconify-json/lucide": "^1.2.25",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@rehype-pretty/transformers": "^0.13.2",
"@r4ai/remark-callout": "^0.6.2", "@r4ai/remark-callout": "^0.6.2",
"@shikijs/transformers": "^1.29.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@yeskunall/astro-umami": "^0.0.3", "@yeskunall/astro-umami": "^0.0.3",
"astro": "^5.2.5", "astro": "^5.2.5",
"astro-icon": "^1.1.5",
"bootstrap-icons": "^1.11.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"rehype-pretty-code": "^0.14.0",
"remark-emoji": "^5.0.1",
"remark-math": "^6.0.0",
"remark-toc": "^9.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"devDependencies": { "devDependencies": {
@ -28,8 +57,6 @@
"@tailwindcss/typography": "^0.5.16" "@tailwindcss/typography": "^0.5.16"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@biomejs/biome", "@biomejs/biome"
"esbuild",
"sharp"
] ]
} }

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

View file

@ -26,7 +26,8 @@
fx="128.00018" fx="128.00018"
fy="128.00395" fy="128.00395"
r="73.343147" r="73.343147"
gradientUnits="userSpaceOnUse" /></defs><rect gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.0225904,0,0,1.0225904,4.1082412,0.10439132)" /></defs><rect
style="fill:#141414;fill-opacity:1;stroke-width:1.96924;stroke-linejoin:round" style="fill:#141414;fill-opacity:1;stroke-width:1.96924;stroke-linejoin:round"
id="bg" id="bg"
width="256" width="256"
@ -34,11 +35,9 @@
x="0" x="0"
y="0" y="0"
ry="16" /><path ry="16" /><path
style="display:inline;fill:url(#radialGradient15);stroke-width:1.18365" style="display:inline;fill:url(#radialGradient15);stroke-width:1.21039"
d="M 55.824663,125.40476 125.21246,55.832186 a 3.9865567,3.9865567 0.02025256 0 1 5.64732,0.002 l 69.31756,69.600498 a 4.0005896,4.0005896 90.112473 0 1 -0.0111,5.65726 l -69.34652,69.08498 a 4.0135339,4.0135339 0.01148544 0 1 -5.6664,-0.001 L 55.832634,131.06116 a 3.9993654,3.9993654 89.919265 0 1 -0.008,-5.6564 z M 125.77344,89.300192 89.179269,125.4342 a 3.9542778,3.9542778 90.033181 0 0 -0.0033,5.62424 l 36.551738,36.17581 a 4.0462516,4.0462516 0.03318674 0 0 5.68928,0.003 l 36.59418,-36.13401 a 3.9542766,3.9542766 90.033183 0 0 0.003,-5.62424 L 131.46272,89.303486 a 4.0462513,4.0462513 0.03317792 0 0 -5.68928,-0.0033 z" d="M 61.194006,128.3421 132.1493,57.197849 a 4.0766146,4.0766146 0 0 1 5.7749,0.002 l 70.88347,71.172791 a 4.0909645,4.0909645 0 0 1 -0.0113,5.78506 l -70.91309,70.64564 a 4.1042012,4.1042012 0 0 1 -5.79441,-0.001 L 61.202157,134.12628 a 4.0897127,4.0897127 0 0 1 -0.0082,-5.78418 z M 132.72295,91.42191 95.302106,128.3722 a 4.0436065,4.0436065 0 0 0 -0.0034,5.75129 l 37.377454,36.99304 a 4.137658,4.137658 0 0 0 5.8178,0.003 l 37.42086,-36.95029 a 4.0436053,4.0436053 0 0 0 0.003,-5.7513 L 138.54076,91.425279 a 4.1376577,4.1376577 0 0 0 -5.81781,-0.0034 z"
id="blur" id="blur" /><path
transform="matrix(1.0225904,0,0,1.0225904,4.1082412,0.10439132)" /><path style="display:inline;fill:#0a0a0a;fill-opacity:1;stroke:none;stroke-width:1.21039;stroke-opacity:1"
style="display:inline;fill:#0a0a0a;fill-opacity:1;stroke:none;stroke-width:1.18365;stroke-opacity:1" d="M 54.194007,125.34209 125.1493,54.197846 a 4.0766146,4.0766146 0 0 1 5.7749,0.002 l 70.88347,71.172794 a 4.0909645,4.0909645 0 0 1 -0.0113,5.78506 l -70.91309,70.64564 a 4.1042012,4.1042012 0 0 1 -5.7944,-0.001 L 54.202158,131.12627 a 4.0897127,4.0897127 0 0 1 -0.0082,-5.78418 z M 125.72296,88.421908 88.302107,125.3722 a 4.0436065,4.0436065 0 0 0 -0.0034,5.75129 l 37.377453,36.99304 a 4.137658,4.137658 0 0 0 5.81781,0.003 l 37.42085,-36.9503 a 4.0436053,4.0436053 0 0 0 0.003,-5.75129 L 131.54076,88.425276 a 4.1376577,4.1376577 0 0 0 -5.8178,-0.0034 z"
d="M 55.824663,125.40476 125.21246,55.832186 a 3.9865567,3.9865567 0.02025256 0 1 5.64732,0.002 l 69.31756,69.600498 a 4.0005896,4.0005896 90.112473 0 1 -0.0111,5.65726 l -69.34652,69.08498 a 4.0135339,4.0135339 0.01148544 0 1 -5.6664,-0.001 L 55.832634,131.06116 a 3.9993654,3.9993654 89.919265 0 1 -0.008,-5.6564 z M 125.77344,89.300192 89.179269,125.4342 a 3.9542778,3.9542778 90.033181 0 0 -0.0033,5.62424 l 36.551738,36.17581 a 4.0462516,4.0462516 0.03318674 0 0 5.68928,0.003 l 36.59418,-36.13401 a 3.9542766,3.9542766 90.033183 0 0 0.003,-5.62424 L 131.46272,89.303486 a 4.0462513,4.0462513 0.03317792 0 0 -5.68928,-0.0033 z" id="diamond" /></svg>
id="diamond"
transform="matrix(1.0225904,0,0,1.0225904,-2.8917572,-2.895611)" /></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

BIN
public/fonts/GeistVF.woff2 Normal file

Binary file not shown.

BIN
public/static/1200x630.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
public/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

16
public/static/logo.svg Normal file
View file

@ -0,0 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_271_118)">
<rect width="512" height="74" fill="#CCCCCC"/>
<rect y="146" width="512" height="74" fill="#CCCCCC"/>
<rect y="292" width="512" height="74" fill="#CCCCCC"/>
<rect y="438" width="512" height="74" fill="#CCCCCC"/>
<rect y="74" width="72" height="72" fill="#CCCCCC"/>
<rect y="366" width="72" height="72" fill="#CCCCCC"/>
<rect x="440" y="220" width="72" height="72" fill="#CCCCCC"/>
</g>
<defs>
<clipPath id="clip0_271_118">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View file

@ -1,26 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
type Props = {
entry: CollectionEntry<"blog">;
};
const { entry } = Astro.props as {
entry: CollectionEntry<"blog">;
};
---
<a href={`/${entry.id}`} class="not-prose group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pr-10 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white">
<div class="flex flex-1 flex-col truncate">
<div class="font-semibold">
{entry.data.title}
</div>
<div class="text-sm">
{entry.data.description}
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2">
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"></line>
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"></polyline>
</svg>
</a>

View file

@ -1,23 +0,0 @@
<button
id="back-to-top"
class="group relative flex w-fit flex-nowrap rounded border border-black/15 py-1.5 pl-8 pr-3 transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="absolute left-2 top-1/2 size-4 -translate-y-1/2 rotate-90 fill-none stroke-current stroke-2"
>
<line
x1="5"
y1="12"
x2="19"
y2="12"
class="translate-x-2 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100"
></line>
<polyline
points="12 5 5 12 12 19"
class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0"
></polyline>
</svg>
<div class="text-sm">Back to top</div>
</button>

View file

@ -0,0 +1,53 @@
---
import { Separator } from "@/components/ui/separator";
import { formatDate, readingTime } from "@/lib/utils";
import { Image } from "astro:assets";
import type { CollectionEntry } from "astro:content";
import Link from "./Link.astro";
type Props = {
entry: CollectionEntry<"blog">;
};
const { entry } = Astro.props as {
entry: CollectionEntry<"blog">;
};
const formattedDate = formatDate(entry.data.date);
const readTime = readingTime(entry.body!);
---
<div
class="not-prose rounded-xl border p-4 transition-colors duration-300 ease-in-out hover:bg-secondary/50"
>
<Link href={`/${entry.id}`} class="flex flex-col gap-4 sm:flex-row">
{
entry.data.image && (
<div class="max-w-[200px] sm:flex-shrink-0">
<Image
src={entry.data.image}
alt={entry.data.title}
width={1200}
height={630}
class="object-cover"
/>
</div>
)
}
<div class="flex-grow">
<h3 class="mb-1 text-lg font-semibold">
{entry.data.title}
</h3>
<p class="mb-2 text-sm text-muted-foreground">
{entry.data.description}
</p>
<div
class="mb-2 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground"
>
<span>{formattedDate}</span>
<Separator orientation="vertical" className="h-4" />
<span>{readTime}</span>
</div>
</div>
</Link>
</div>

View file

@ -0,0 +1,53 @@
---
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Icon } from 'astro-icon/components'
export interface BreadcrumbItem {
href?: string
label: string
icon?: string
}
interface Props {
items: BreadcrumbItem[]
class?: string
}
const { items, class: className } = Astro.props
---
<Breadcrumb className={className}>
<BreadcrumbList>
{
items.map((item, index) => (
<>
{index !== 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{index === items.length - 1 ? (
<BreadcrumbPage>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>
<span class="flex items-center gap-x-2">
{item.icon && <Icon name={item.icon} class="size-4" />}
{item.label}
</span>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</>
))
}
</BreadcrumbList>
</Breadcrumb>

View file

@ -1,44 +0,0 @@
---
interface Props {
type: "default" | "info" | "warning" | "error";
}
const { type = "default" } = Astro.props;
let emoji = "💡";
if (type === "info") {
emoji = "";
} else if (type === "warning") {
emoji = "⚠️";
} else if (type === "error") {
emoji = "🚨";
}
---
<div class={`not-prose callout callout-${type}`}>
<span class="emoji pointer-events-none select-none">{emoji}</span>
<slot />
</div>
<style>
.callout {
@apply relative my-4 flex rounded border border-orange-800 bg-orange-100 p-3 text-orange-950 dark:border-orange-200/20 dark:bg-orange-950/20 dark:text-orange-200;
}
.emoji {
@apply pr-3 text-xl;
}
.callout-info {
@apply border-blue-800 bg-blue-100 text-blue-950 dark:border-blue-200/20 dark:bg-blue-950/20 dark:text-blue-200;
}
.callout-warning {
@apply border-yellow-800 bg-yellow-100 text-yellow-950 dark:border-yellow-200/20 dark:bg-yellow-950/20 dark:text-yellow-200;
}
.callout-error {
@apply border-red-800 bg-red-100 text-red-950 dark:border-red-200/20 dark:bg-red-950/20 dark:text-red-200;
}
</style>

View file

@ -1 +1,11 @@
<div class="mx-auto max-w-screen-sm px-3"><slot /></div> ---
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const { class: className } = Astro.props
---
<div class={cn('mx-auto max-w-screen-md px-4', className)}><slot /></div>

View file

@ -1,113 +1,24 @@
--- ---
import BackToTop from "@components/BackToTop.astro"; import Container from '@/components/Container.astro'
import Container from "@components/Container.astro"; import { Separator } from '@/components/ui/separator'
import { SITE } from "@consts"; import SocialIcons from './SocialIcons.astro'
--- ---
<footer> <footer class="py-4">
<Container> <Container>
<div class="relative"> <div
<div class="absolute -top-12 right-0"> class="flex flex-col items-center justify-center gap-y-2 sm:flex-row sm:justify-between"
<BackToTop /> >
</div> <div class="flex items-center gap-x-2">
</div> <span class="text-center text-sm text-muted-foreground">
<div class="flex items-center justify-between"> &copy; {new Date().getFullYear()} • z0x
<div>&copy; {new Date().getFullYear()} • {SITE.TITLE}</div> </span>
<div class="flex flex-wrap items-center gap-1.5"> <Separator orientation="vertical" className="h-4" />
<a <p class="text-center text-sm text-muted-foreground">
aria-label="RSS" All rights reserved.
href="/rss.xml" </p>
target="_blank"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<path d="M5 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<path d="M4 11a9 9 0 0 1 9 9"></path>
</svg>
</a>
<button
id="light-theme-button"
aria-label="Light theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button
id="dark-theme-button"
aria-label="Dark theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
id="system-theme-button"
aria-label="System theme"
class="group flex size-9 items-center justify-center rounded border border-black/15 hover:bg-black/5 focus-visible:bg-black/5 dark:border-white/20 dark:hover:bg-white/5 dark:focus-visible:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-colors duration-300 ease-in-out group-hover:animate-pulse group-hover:stroke-black group-focus-visible:animate-pulse group-focus-visible:stroke-black group-hover:dark:stroke-white dark:group-focus-visible:stroke-white"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
</div> </div>
<SocialIcons />
</div> </div>
</Container> </Container>
</footer> </footer>

View file

@ -1,17 +0,0 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString("en-SE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
}
</time>

View file

@ -1,7 +1,9 @@
--- ---
import "../styles/global.css"; import "../styles/global.css";
import "../styles/callout.css"; import "../styles/callout.css";
import "@fontsource-variable/nunito";
import { SITE } from "@/consts";
import { ClientRouter } from "astro:transitions";
interface Props { interface Props {
title: string; title: string;
@ -9,20 +11,35 @@ interface Props {
image?: string; image?: string;
} }
const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props; const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = "/static/twitter-card.png" } = Astro.props;
--- ---
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <link rel="canonical" href={canonicalURL} />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib"
crossorigin="anonymous"
/>
<title>{title}</title> <title>{title}</title>
<meta name="title" content={title} /> <meta name="title" content={title} />
<meta name="description" content={description} /> <meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} /> <meta property="og:url" content={Astro.url} />
<meta property="og:site_name" content={SITE.TITLE} />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} /> <meta property="og:image" content={new URL(image, Astro.url)} />
@ -33,172 +50,39 @@ const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
<meta property="twitter:description" content={description} /> <meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} /> <meta property="twitter:image" content={new URL(image, Astro.url)} />
<ClientRouter />
<script is:inline> <script is:inline>
function init() { function setDarkMode(document) {
preloadTheme(); const getThemePreference = () => {
onScroll(); if (
updateThemeButtons(); typeof localStorage !== "undefined" &&
addCopyCodeButtons(); localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "theme-light";
};
const isDark = getThemePreference() === "dark";
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
const backToTop = document.getElementById("back-to-top"); if (typeof localStorage !== "undefined") {
backToTop?.addEventListener("click", (event) => scrollToTop(event)); const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains("dark");
const backToPrev = document.getElementById("back-to-prev"); localStorage.setItem("theme", isDark ? "dark" : "theme-light");
backToPrev?.addEventListener("click", () => window.history.back());
const lightThemeButton = document.getElementById("light-theme-button");
lightThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "light");
toggleTheme(false);
updateThemeButtons();
}); });
observer.observe(document.documentElement, {
const darkThemeButton = document.getElementById("dark-theme-button"); attributes: true,
darkThemeButton?.addEventListener("click", () => { attributeFilter: ["class"],
localStorage.setItem("theme", "dark");
toggleTheme(true);
updateThemeButtons();
});
const systemThemeButton = document.getElementById("system-theme-button");
systemThemeButton?.addEventListener("click", () => {
localStorage.setItem("theme", "system");
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
updateThemeButtons();
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) => {
if (localStorage.theme === "system") {
toggleTheme(event.matches);
}
});
document.addEventListener("scroll", onScroll);
}
function updateThemeButtons() {
const theme = localStorage.getItem("theme");
const lightThemeButton = document.getElementById("light-theme-button");
const darkThemeButton = document.getElementById("dark-theme-button");
const systemThemeButton = document.getElementById("system-theme-button");
function removeActiveButtonTheme(button) {
button?.classList.remove("bg-black/5");
button?.classList.remove("dark:bg-white/5");
}
function addActiveButtonTheme(button) {
button?.classList.add("bg-black/5");
button?.classList.add("dark:bg-white/5");
}
removeActiveButtonTheme(lightThemeButton);
removeActiveButtonTheme(darkThemeButton);
removeActiveButtonTheme(systemThemeButton);
if (theme === "light") {
addActiveButtonTheme(lightThemeButton);
} else if (theme === "dark") {
addActiveButtonTheme(darkThemeButton);
} else {
addActiveButtonTheme(systemThemeButton);
}
}
function onScroll() {
if (window.scrollY > 0) {
document.documentElement.classList.add("scrolled");
} else {
document.documentElement.classList.remove("scrolled");
}
}
function scrollToTop(event) {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
function toggleTheme(dark) {
const css = document.createElement("style");
css.appendChild(
document.createTextNode(
`* {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
);
document.head.appendChild(css);
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
window.getComputedStyle(css).opacity;
document.head.removeChild(css);
}
function preloadTheme() {
const userTheme = localStorage.theme;
if (userTheme === "light" || userTheme === "dark") {
toggleTheme(userTheme === "dark");
} else {
toggleTheme(window.matchMedia("(prefers-color-scheme: dark)").matches);
}
}
function addCopyCodeButtons() {
let copyButtonLabel = "📋";
let codeBlocks = Array.from(document.querySelectorAll("pre"));
async function copyCode(codeBlock, copyButton) {
const codeText = codeBlock.innerText;
const buttonText = copyButton.innerText;
const textToCopy = codeText.replace(buttonText, "");
await navigator.clipboard.writeText(textToCopy);
copyButton.innerText = "✅";
setTimeout(() => {
copyButton.innerText = copyButtonLabel;
}, 2000);
}
for (let codeBlock of codeBlocks) {
const wrapper = document.createElement("div");
wrapper.style.position = "relative";
const copyButton = document.createElement("button");
copyButton.innerText = copyButtonLabel;
copyButton.classList = "copy-code";
codeBlock.setAttribute("tabindex", "0");
codeBlock.appendChild(copyButton);
codeBlock.parentNode.insertBefore(wrapper, codeBlock);
wrapper.appendChild(codeBlock);
copyButton?.addEventListener("click", async () => {
await copyCode(codeBlock, copyButton);
}); });
} }
} }
document.addEventListener("DOMContentLoaded", () => init()); setDarkMode(document);
document.addEventListener("astro:after-swap", () => init());
preloadTheme(); document.addEventListener("astro:before-swap", (ev) => {
setDarkMode(ev.newDocument);
});
</script> </script>

View file

@ -0,0 +1,25 @@
---
import Container from "@/components/Container.astro";
import Link from "@/components/Link.astro";
import { ModeToggle } from "@/components/ui/mode-toggle";
import { SITE } from "@/consts";
---
<header
class="sticky top-0 z-10 bg-background/50 backdrop-blur-md"
transition:persist
>
<Container>
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
<Link
href="https://z0x.ca"
class="flex flex-shrink-0 items-center gap-2 text-xl font-semibold transition-colors duration-300 hover:text-primary"
>
{SITE.TITLE}
</Link>
<div class="flex items-center gap-2 md:gap-4">
<ModeToggle client:load transition:persist />
</div>
</div>
</Container>
</header>

View file

@ -1,29 +1,25 @@
--- ---
import { cn } from "@lib/utils"; import { cn } from '@/lib/utils'
type Props = { type Props = {
href: string; href: string
external?: boolean; external?: boolean
underline?: boolean; class?: string
group?: boolean; underline?: boolean
}; [key: string]: any
}
const { const { href, external, class: className, underline, ...rest } = Astro.props
href,
external,
underline = true,
group = false,
...rest
} = Astro.props;
--- ---
<a <a
href={href} href={href}
target={external ? "_blank" : "_self"} target={external ? '_blank' : '_self'}
class={cn( class={cn(
"inline-block decoration-black/30 dark:decoration-white/30 hover:decoration-black/50 focus-visible:decoration-black/50 dark:hover:decoration-white/50 dark:focus-visible:decoration-white/50 text-current hover:text-black focus-visible:text-black dark:hover:text-white dark:focus-visible:text-white transition-colors duration-300 ease-in-out", 'inline-block transition-colors duration-300 ease-in-out',
underline && "underline underline-offset-[3px]", underline &&
group && "group" 'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className,
)} )}
{...rest} {...rest}
> >

View file

@ -1,33 +1,56 @@
--- ---
const { prevPost, nextPost } = Astro.props; import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
const { prevPost, nextPost } = Astro.props
--- ---
<div class="grid grid-cols-2 gap-1.5 sm:gap-3"> <div class="col-start-2 flex flex-col gap-4 sm:flex-row">
{ <Link
prevPost?.id ? ( href={nextPost ? `/${nextPost.id}` : '#'}
<a href={`/${prevPost?.id}`} class="group relative flex flex-nowrap rounded-lg border border-black/15 px-4 py-3 pl-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white"> class={cn(
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute left-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2"> buttonVariants({ variant: 'outline' }),
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" /> 'rounded-xl group flex items-center justify-start w-full sm:w-1/2 h-fit',
<polyline points="12 5 5 12 12 19" class="translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" /> !nextPost && 'pointer-events-none opacity-50 cursor-not-allowed',
</svg> )}
<div class="flex items-center text-sm">{prevPost?.data.title}</div> aria-disabled={!nextPost}
</a> >
) : ( <div class="mr-2 flex-shrink-0">
<div class="invisible" /> <Icon
) name="lucide:arrow-left"
} class="size-4 transition-transform group-hover:-translate-x-1"
/>
{ </div>
nextPost?.id ? ( <div class="flex flex-col items-start overflow-hidden">
<a href={`/${nextPost?.id}`} class="group relative flex flex-grow flex-row-reverse flex-nowrap rounded-lg border border-black/15 px-4 py-4 pr-10 no-underline transition-colors duration-300 ease-in-out hover:bg-black/5 hover:text-black focus-visible:bg-black/5 focus-visible:text-black dark:border-white/20 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:bg-white/5 dark:focus-visible:text-white"> <span class="text-left text-xs text-muted-foreground">Next Post</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="absolute right-2 top-1/2 size-5 -translate-y-1/2 fill-none stroke-current stroke-2"> <span class="w-full truncate text-left text-sm"
<line x1="5" y1="12" x2="19" y2="12" class="translate-x-3 scale-x-0 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-hover:scale-x-100 group-focus-visible:translate-x-0 group-focus-visible:scale-x-100" /> >{nextPost?.data.title || 'Latest post!'}</span
<polyline points="12 5 19 12 12 19" class="-translate-x-1 transition-transform duration-300 ease-in-out group-hover:translate-x-0 group-focus-visible:translate-x-0" /> >
</svg> </div>
<div class="flex items-center text-sm">{nextPost?.data.title}</div> </Link>
</a> <Link
) : ( href={prevPost ? `/${prevPost.id}` : '#'}
<div class="invisible" /> class={cn(
) buttonVariants({ variant: 'outline' }),
} 'rounded-xl group flex items-center justify-end w-full sm:w-1/2 h-fit',
!prevPost && 'pointer-events-none opacity-50 cursor-not-allowed',
)}
aria-disabled={!prevPost}
>
<div class="flex flex-col items-end overflow-hidden">
<span class="text-right text-xs text-muted-foreground">Previous Post</span
>
<span class="w-full truncate text-right text-sm"
>{prevPost?.data.title || 'Last post!'}</span
>
</div>
<div class="ml-2 flex-shrink-0">
<Icon
name="lucide:arrow-right"
class="size-4 transition-transform group-hover:translate-x-1"
/>
</div>
</Link>
</div> </div>

View file

@ -0,0 +1,19 @@
---
import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { Icon } from 'astro-icon/components'
---
<ul class="not-prose flex flex-wrap gap-2" role="list">
<li>
<Link
href="/rss.xml"
aria-label="RSS"
title="RSS"
class={buttonVariants({ variant: 'outline', size: 'icon' })}
external
>
<Icon name="lucide:rss" class="size-4" />
</Link>
</li>
</ul>

View file

@ -1,50 +1,117 @@
--- ---
import TableOfContentsHeading from "./TableOfContentsHeading.astro"; import { ScrollArea } from '@/components/ui/scroll-area'
import { Icon } from 'astro-icon/components'
const { headings } = Astro.props; import TableOfContentsHeading from './TableOfContentsHeading.astro'
const toc = buildToc(headings);
export interface Heading { export interface Heading {
depth: number; depth: number
slug: string; slug: string
text: string; text: string
subheadings: Heading[]
} }
function buildToc(headings: Heading[]) { const { headings } = Astro.props
const toc: Heading[] = []; const toc = buildToc(headings)
const parentHeadings = new Map();
function buildToc(headings: Heading[]): Heading[] {
const toc: Heading[] = []
const stack: Heading[] = []
headings.forEach((h) => { headings.forEach((h) => {
const heading = { ...h, subheadings: [] }; const heading = { ...h, subheadings: [] }
parentHeadings.set(heading.depth, heading);
if (heading.depth === 2) { while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) {
toc.push(heading); stack.pop()
} else {
parentHeadings.get(heading.depth - 1).subheadings.push(heading);
} }
});
return toc; if (stack.length === 0) {
toc.push(heading)
} else {
stack[stack.length - 1].subheadings.push(heading)
}
stack.push(heading)
})
return toc
} }
--- ---
<details open class="rounded-lg border border-black/15 dark:border-white/20"> <details
<summary>Table of Contents</summary> open
<nav class=""> class="group col-start-2 mx-4 block rounded-xl border p-4 xl:hidden"
<ul class="py-3"> >
<summary
class="flex cursor-pointer items-center justify-between text-xl font-semibold group-open:pb-4"
>
Table of Contents
<Icon
name="lucide:chevron-down"
class="size-5 transition-transform group-open:rotate-180"
/>
</summary>
<ScrollArea
client:load
className="flex max-h-64 flex-col overflow-y-auto"
type="always"
>
<nav>
<ul></ul>
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)} {toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</nav> </nav>
</ScrollArea>
</details> </details>
<style> <nav
summary { class="sticky top-[5.5rem] col-start-1 hidden h-[calc(100vh-5.5rem)] text-xs leading-4 xl:block"
@apply cursor-pointer rounded-t-lg px-3 py-1.5 font-medium transition-colors; >
<div class="flex justify-end">
<ScrollArea client:load className="max-h-[calc(100vh-8rem)]" type="always">
<ul
class="flex flex-col justify-end gap-y-2 overflow-y-auto px-8"
id="toc-container"
>
<li>
<h2 class="mb-2 text-lg font-semibold">Table of Contents</h2>
</li>
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</ScrollArea>
</div>
</nav>
<script>
function setupToc() {
const header = document.querySelector('header')
const headerHeight = header ? header.offsetHeight : 0
const observer = new IntersectionObserver(
(sections) => {
sections.forEach((section) => {
const heading = section.target.querySelector('h2, h3, h4, h5, h6')
if (!heading) return
const id = heading.getAttribute('id')
const link = document.querySelector(
`#toc-container li a[href="#${id}"]`,
)
if (!link) return
const addRemove = section.isIntersecting ? 'add' : 'remove'
link.classList[addRemove]('text-foreground')
})
},
{
rootMargin: `-${headerHeight}px 0px 0px 0px`,
},
)
const sections = document.querySelectorAll('.prose section')
sections.forEach((section) => {
observer.observe(section)
})
} }
summary:hover { document.addEventListener('astro:page-load', setupToc)
@apply bg-black/5 dark:bg-white/5; document.addEventListener('astro:after-swap', setupToc)
} </script>
details[open] summary {
@apply bg-black/5 dark:bg-white/5;
}
</style>

View file

@ -1,17 +1,22 @@
--- ---
import type { Heading } from "./TableOfContents.astro"; import Link from './Link.astro'
import Link from "./Link.astro"; import type { Heading } from './TableOfContents.astro'
const { heading } = Astro.props; const { heading } = Astro.props
--- ---
<li class="list-inside list-disc px-6 py-1.5 text-sm"> <li
<Link href={"#" + heading.slug} underline> class="list-inside list-disc px-4 text-sm text-foreground/60 xl:list-none xl:p-0"
>
<Link
href={'#' + heading.slug}
class="underline decoration-transparent underline-offset-[3px] transition-colors duration-200 hover:decoration-inherit py-1 xl:py-0"
>
{heading.text} {heading.text}
</Link> </Link>
{ {
heading.subheadings.length > 0 && ( heading.subheadings.length > 0 && (
<ul class="translate-x-3"> <ul class="translate-x-3 xl:ml-4 xl:mt-2 xl:flex xl:translate-x-0 xl:flex-col xl:gap-2">
{heading.subheadings.map((subheading: Heading) => ( {heading.subheadings.map((subheading: Heading) => (
<Astro.self heading={subheading} /> <Astro.self heading={subheading} />
))} ))}

View file

@ -0,0 +1,114 @@
import { cn } from '@/lib/utils'
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Slot } from '@radix-ui/react-slot'
import * as React from 'react'
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = 'Breadcrumb'
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
))
BreadcrumbList.displayName = 'BreadcrumbList'
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
))
BreadcrumbItem.displayName = 'BreadcrumbItem'
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a'
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = 'BreadcrumbLink'
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
))
BreadcrumbPage.displayName = 'BreadcrumbPage'
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
}

View file

@ -0,0 +1,54 @@
import { cn } from '@/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { type VariantProps, cva } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-secondary/50',
destructive:
'bg-destructive text-destructive-foreground over:bg-destructive/50',
outline: 'border border-input bg-background hover:bg-secondary/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View file

@ -0,0 +1,202 @@
import { cn } from '@/lib/utils'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from '@radix-ui/react-icons'
import * as React from 'react'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="size-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}

View file

@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Laptop, Moon, Sun } from 'lucide-react'
import * as React from 'react'
export function ModeToggle() {
const [theme, setThemeState] = React.useState<
'theme-light' | 'dark' | 'system'
>('theme-light')
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark')
setThemeState(isDarkMode ? 'dark' : 'theme-light')
}, [])
React.useEffect(() => {
const isDark =
theme === 'dark' ||
(theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.add('disable-transitions')
document.documentElement.classList[isDark ? 'add' : 'remove']('dark')
window
.getComputedStyle(document.documentElement)
.getPropertyValue('opacity')
requestAnimationFrame(() => {
document.documentElement.classList.remove('disable-transitions')
})
}, [theme])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="group"
title="Toggle theme"
>
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background">
<DropdownMenuItem onClick={() => setThemeState('theme-light')}>
<Sun className="mr-2 size-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState('dark')}>
<Moon className="mr-2 size-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState('system')}>
<Laptop className="mr-2 size-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,174 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type ButtonProps, buttonVariants } from '@/components/ui/button'
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
Pagination.displayName = 'Pagination'
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
))
PaginationContent.displayName = 'PaginationContent'
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
))
PaginationItem.displayName = 'PaginationItem'
type PaginationLinkProps = {
isActive?: boolean
isDisabled?: boolean
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>
const PaginationLink = ({
className,
isActive,
isDisabled,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
isDisabled && 'pointer-events-none opacity-50',
className,
)}
{...props}
/>
)
PaginationLink.displayName = 'PaginationLink'
const PaginationPrevious = ({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = 'PaginationPrevious'
const PaginationNext = ({
className,
isDisabled,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
isDisabled={isDisabled}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = 'PaginationNext'
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = 'PaginationEllipsis'
interface PaginationProps {
currentPage: number
totalPages: number
baseUrl: string
}
const PaginationComponent: React.FC<PaginationProps> = ({
currentPage,
totalPages,
baseUrl,
}) => {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
const getPageUrl = (page: number) => {
if (page === 1) return baseUrl
return `${baseUrl}${page}`
}
return (
<Pagination>
<PaginationContent className="flex-wrap">
<PaginationItem>
<PaginationPrevious
href={currentPage > 1 ? getPageUrl(currentPage - 1) : undefined}
isDisabled={currentPage === 1}
/>
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href={getPageUrl(page)}
isActive={page === currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
))}
{totalPages > 5 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
href={
currentPage < totalPages ? getPageUrl(currentPage + 1) : undefined
}
isDisabled={currentPage === totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
export default PaginationComponent

View file

@ -0,0 +1,46 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import * as React from 'react'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View file

@ -1,8 +1,19 @@
import type { Metadata, Site } from "@types"; export type Site = {
TITLE: string
DESCRIPTION: string
POSTS_PER_PAGE: number
SITEURL: string
}
export type Link = {
href: string
label: string
}
export const SITE: Site = { export const SITE: Site = {
TITLE: "z0x", TITLE: "z0x's blog",
DESCRIPTION: "z0x's blog", DESCRIPTION: "z0x's blog",
}; POSTS_PER_PAGE: 3,
export const HOME: Metadata = { SITEURL: 'https://astro-erudite.vercel.app',
TITLE: "blog", }
};

View file

@ -1,14 +1,27 @@
import { defineCollection, z } from "astro:content"; import { glob } from 'astro/loaders'
import { glob } from "astro/loaders"; import { defineCollection, z } from 'astro:content'
const blog = defineCollection({ const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content" }), loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({ schema: ({ image }) =>
title: z.string(), z.object({
description: z.string(), title: z
.string()
.max(
60,
'Title should be 60 characters or less for optimal Open Graph display.',
),
description: z
.string()
.max(
155,
'Description should be 155 characters or less for optimal Open Graph display.',
),
date: z.coerce.date(), date: z.coerce.date(),
image: image().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
}), }),
}); })
export const collections = { blog };
export const collections = { blog }

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

9
src/content/blog/2022-post/index.mdx vendored Normal file
View file

@ -0,0 +1,9 @@
---
title: '2022 Post'
description: 'This a dummy post written in the year 2022.'
date: 2022-06-01
tags: ['dummy', 'placeholder']
image: './2022.png'
---
This is a dummy post written in the year 2022.

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

9
src/content/blog/2023-post/index.mdx vendored Normal file
View file

@ -0,0 +1,9 @@
---
title: '2023 Post'
description: 'This a dummy post written in the year 2023.'
date: 2023-06-01
tags: ['dummy', 'placeholder']
image: './2023.png'
---
This is a dummy post written in the year 2023.

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

153
src/content/blog/2024-post/index.mdx vendored Normal file
View file

@ -0,0 +1,153 @@
---
title: '2024 Post'
description: 'This a dummy post written in the year 2024 (with multiple authors).'
date: 2024-06-01
tags: ['dummy', 'placeholder']
image: './2024.png'
---
This is a dummy post written in the year 2024! Here is a long blog post with heavily nested headers, which can be used to test the table of contents:
## Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
###### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
###### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
###### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
###### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
#### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
###### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Test
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?

View file

@ -3,6 +3,7 @@ title: "Artix Linux install guide"
description: "Guide to installing Artix Linux with OpenRC and full disk encryption for UEFI and BIOS systems." description: "Guide to installing Artix Linux with OpenRC and full disk encryption for UEFI and BIOS systems."
date: "2025-01-07" date: "2025-01-07"
--- ---
--- ---
## Introduction ## Introduction
@ -31,7 +32,7 @@ Use [Rufus](https://rufus.ie/en), here is a [guide](https://www.wikihow.com/Use-
2. Find the corresponding block device for the flash drive in `/dev` folder. Usually it is `/dev/sdb`. 2. Find the corresponding block device for the flash drive in `/dev` folder. Usually it is `/dev/sdb`.
3. Burn the image to the flash drive (assuming your flash drive is /dev/sdb and that your terminal is opened in the directory of the image) 3. Burn the image to the flash drive (assuming your flash drive is /dev/sdb and that your terminal is opened in the directory of the image)
``` ```sh
sudo dd bs=4M if=./artix-base-openrc-YYYY.MM.DD-x86_64.iso of=/dev/sdb conv=fsync oflag=direct status=progress sudo dd bs=4M if=./artix-base-openrc-YYYY.MM.DD-x86_64.iso of=/dev/sdb conv=fsync oflag=direct status=progress
``` ```
@ -65,7 +66,7 @@ Connect the computer via an Ethernet cable
> [!Note] > [!Note]
>When encountering a code block as below throughout this guide, execute the commands within it directly in the terminal. >When encountering a code block as below throughout this guide, execute the commands within it directly in the terminal.
``` ```sh
sudo rfkill unblock wifi sudo rfkill unblock wifi
sudo ip link set wlan0 up sudo ip link set wlan0 up
connmanctl connmanctl
@ -74,7 +75,7 @@ connmanctl
> [!Tip] > [!Tip]
>Network names can be tab-completed. >Network names can be tab-completed.
``` ```sh
agent on agent on
scan wifi scan wifi
services services
@ -83,7 +84,7 @@ services
> [!example] > [!example]
>connect wifi_dc85de828967_38303944616e69656c73_managed_psk >connect wifi_dc85de828967_38303944616e69656c73_managed_psk
``` ```sh
connect {WiFi name} connect {WiFi name}
quit quit
``` ```
@ -91,7 +92,7 @@ quit
### Verify internet connectivity ### Verify internet connectivity
Check for internet Check for internet
``` ```sh
ping artixlinux.org ping artixlinux.org
``` ```
@ -100,7 +101,7 @@ ping artixlinux.org
## Update the system clock ## Update the system clock
Activate the NTP daemon to synchronize the computer's real-time clock: Activate the NTP daemon to synchronize the computer's real-time clock:
``` ```sh
rc-service ntpd start rc-service ntpd start
``` ```
@ -109,7 +110,7 @@ rc-service ntpd start
## Partition the disk ## Partition the disk
1. Install `gdisk`. 1. Install `gdisk`.
``` ```sh
pacman -Sy gdisk pacman -Sy gdisk
``` ```
@ -119,17 +120,17 @@ pacman -Sy gdisk
> `nvme0n1` will be used as the target install drive throughout this guide, adapt it to your drive name. > `nvme0n1` will be used as the target install drive throughout this guide, adapt it to your drive name.
> If you have an hdd, your drive will start with `sda`. > If you have an hdd, your drive will start with `sda`.
``` ```sh
gdisk /dev/nvme0n1 gdisk /dev/nvme0n1
``` ```
2. Delete any existing partitions 2. Delete any existing partitions
``` ```txt
Command (m for help): d Command (m for help): d
``` ```
3. Create a boot partition 3. Create a boot partition
``` ```txt
Command (m for help): n Command (m for help): n
Partition number (1-128, default 1): Partition number (1-128, default 1):
First sector (...): First sector (...):
@ -138,7 +139,7 @@ Hex code or GUID (...): ef00
``` ```
4. Create a root partition 4. Create a root partition
``` ```txt
Command (m for help): n Command (m for help): n
Partition number (2-128, default 1): Partition number (2-128, default 1):
First sector (...): First sector (...):
@ -147,20 +148,20 @@ Hex code or GUID (...): 8300
``` ```
5. Save the changes 5. Save the changes
``` ```txt
Command (m for help): w Command (m for help): w
Do you want to proceed? (Y/N): y Do you want to proceed? (Y/N): y
``` ```
6. Verify partitioning 6. Verify partitioning
``` ```sh
lsblk lsblk
``` ```
> [!Note] > [!Note]
>It should look something like this: >It should look something like this:
``` ```txt
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
nvme0n1 259:0 0 465,8G 0 disk nvme0n1 259:0 0 465,8G 0 disk
├─nvme0n1p1 259:1 0 512M 0 part ├─nvme0n1p1 259:1 0 512M 0 part
@ -176,13 +177,13 @@ nvme0n1 259:0 0 465,8G 0 disk
> [!Tip] > [!Tip]
>Make sure to enter a secure passphrase and to write it down in a secure place as you will not be able to change it later >Make sure to enter a secure passphrase and to write it down in a secure place as you will not be able to change it later
``` ```sh
cryptsetup luksFormat /dev/nvme0n1p2 cryptsetup luksFormat /dev/nvme0n1p2
Are you sure (Type `yes` in capital letters): YES Are you sure (Type `yes` in capital letters): YES
``` ```
2. Open the encrypted partition 2. Open the encrypted partition
``` ```sh
cryptsetup open /dev/nvme0n1p2 root cryptsetup open /dev/nvme0n1p2 root
``` ```
@ -191,12 +192,12 @@ cryptsetup open /dev/nvme0n1p2 root
## Create file systems ## Create file systems
1. Create the boot file system 1. Create the boot file system
``` ```sh
mkfs.fat -F32 /dev/nvme0n1p1 mkfs.fat -F32 /dev/nvme0n1p1
``` ```
1. Create the root file system 1. Create the root file system
``` ```sh
mkfs.ext4 /dev/mapper/root mkfs.ext4 /dev/mapper/root
``` ```
@ -205,24 +206,24 @@ mkfs.ext4 /dev/mapper/root
## Mount file systems ## Mount file systems
1. Mount the root file system 1. Mount the root file system
``` ```sh
mount /dev/mapper/root /mnt mount /dev/mapper/root /mnt
``` ```
2. Mount the boot file system 2. Mount the boot file system
``` ```sh
mount -m /dev/nvme0n1p1 /mnt/boot mount -m /dev/nvme0n1p1 /mnt/boot
``` ```
3. Verify mounting 3. Verify mounting
``` ```sh
lsblk lsblk
``` ```
> [!Note] > [!Note]
>It should look something like this: >It should look something like this:
``` ```txt
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
nvme0n1 259:0 0 465,8G 0 disk nvme0n1 259:0 0 465,8G 0 disk
├─nvme0n1p1 259:1 0 512M 0 part /mnt/boot ├─nvme0n1p1 259:1 0 512M 0 part /mnt/boot
@ -236,7 +237,7 @@ nvme0n1 259:0 0 465,8G 0 disk
Install the base system, kernel, init system and other essential packages. Install the base system, kernel, init system and other essential packages.
``` ```sh
basestrap /mnt base linux linux-firmware openrc elogind-openrc cryptsetup cryptsetup-openrc efibootmgr doas nano basestrap /mnt base linux linux-firmware openrc elogind-openrc cryptsetup cryptsetup-openrc efibootmgr doas nano
``` ```
@ -247,7 +248,7 @@ basestrap /mnt base linux linux-firmware openrc elogind-openrc cryptsetup crypts
Install AMD CPU microcode updates Install AMD CPU microcode updates
``` ```sh
basestrap /mnt amd-ucode basestrap /mnt amd-ucode
``` ```
@ -255,7 +256,7 @@ basestrap /mnt amd-ucode
Install Intel CPU microcode updates Install Intel CPU microcode updates
``` ```sh
basestrap /mnt intel-ucode basestrap /mnt intel-ucode
``` ```
@ -263,7 +264,7 @@ basestrap /mnt intel-ucode
## Generate File System Table ## Generate File System Table
``` ```sh
fstabgen -U /mnt >> /mnt/etc/fstab fstabgen -U /mnt >> /mnt/etc/fstab
``` ```
@ -271,7 +272,7 @@ fstabgen -U /mnt >> /mnt/etc/fstab
## Switch to New Installation ## Switch to New Installation
``` ```sh
artix-chroot /mnt bash artix-chroot /mnt bash
``` ```
@ -279,7 +280,7 @@ artix-chroot /mnt bash
## Network stack ## Network stack
``` ```sh
pacman -S wpa_supplicant networkmanager networkmanager-openrc iwd iwd-openrc pacman -S wpa_supplicant networkmanager networkmanager-openrc iwd iwd-openrc
rc-update add NetworkManager rc-update add NetworkManager
rc-update add iwd rc-update add iwd
@ -295,7 +296,7 @@ EOF
> [!Info] > [!Info]
>MAC randomization can be used for increased privacy by not disclosing your real MAC address to the WiFi network. >MAC randomization can be used for increased privacy by not disclosing your real MAC address to the WiFi network.
``` ```sh
cat << EOF >> /etc/NetworkManager/conf.d/00-macrandomize.conf cat << EOF >> /etc/NetworkManager/conf.d/00-macrandomize.conf
[device-mac-randomization] [device-mac-randomization]
wifi.scan-rand-mac-address=yes wifi.scan-rand-mac-address=yes
@ -314,12 +315,12 @@ EOF
>Feel free to change `en_DK.UTF-8` to your preferred locale such as `en_US.UTF-8` or `en_GB.UTF-8` >Feel free to change `en_DK.UTF-8` to your preferred locale such as `en_US.UTF-8` or `en_GB.UTF-8`
1. Uncomment `en_DK.UTF-8` 1. Uncomment `en_DK.UTF-8`
``` ```sh
nano /etc/locale.gen nano /etc/locale.gen
``` ```
2. Generate locales. 2. Generate locales.
``` ```sh
locale-gen locale-gen
echo 'LANG=en_DK.UTF-8' > /etc/locale.conf echo 'LANG=en_DK.UTF-8' > /etc/locale.conf
``` ```
@ -331,7 +332,7 @@ echo 'LANG=en_DK.UTF-8' > /etc/locale.conf
> [!example] > [!example]
>`ln -sf /usr/share/zoneinfo/Asia/Dubai /etc/localtime` >`ln -sf /usr/share/zoneinfo/Asia/Dubai /etc/localtime`
``` ```sh
ln -sf /usr/share/zoneinfo/Region/City /etc/localtime ln -sf /usr/share/zoneinfo/Region/City /etc/localtime
``` ```
@ -339,7 +340,7 @@ ln -sf /usr/share/zoneinfo/Region/City /etc/localtime
## Set hardware clock from system clock ## Set hardware clock from system clock
``` ```sh
hwclock --systohc hwclock --systohc
``` ```
@ -350,24 +351,24 @@ hwclock --systohc
> [!Note] > [!Note]
>Change `artix` to your desired hostname in all of the following commands >Change `artix` to your desired hostname in all of the following commands
``` ```sh
echo 'artix' > /etc/hostname echo 'artix' > /etc/hostname
``` ```
1. Edit `/etc/conf.d/hostname` 1. Edit `/etc/conf.d/hostname`
``` ```sh
nano /etc/conf.d/hostname nano /etc/conf.d/hostname
``` ```
2. Replace `hostname="localhost"` with `hostname="artix"` 2. Replace `hostname="localhost"` with `hostname="artix"`
3. Edit `/etc/hosts` 3. Edit `/etc/hosts`
``` ```sh
nano /etc/hosts nano /etc/hosts
``` ```
4. Add the following: 4. Add the following:
``` ```conf
127.0.0.1 localhost 127.0.0.1 localhost
::1 localhost ::1 localhost
127.0.1.1 artix.localdomain artix 127.0.1.1 artix.localdomain artix
@ -378,7 +379,7 @@ nano /etc/hosts
## Initramfs ## Initramfs
1. Edit `/etc/mkinitcpio.conf` 1. Edit `/etc/mkinitcpio.conf`
``` ```sh
nano /etc/mkinitcpio.conf nano /etc/mkinitcpio.conf
``` ```
@ -387,13 +388,13 @@ nano /etc/mkinitcpio.conf
> [!Note] > [!Note]
>It should look something like this: >It should look something like this:
``` ```conf
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck) HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck)
``` ```
3. Run this 3. Run this
``` ```sh
mkinitcpio -P mkinitcpio -P
``` ```
--- ---
@ -401,7 +402,7 @@ mkinitcpio -P
## Add a user ## Add a user
1. Set the root password. 1. Set the root password.
``` ```sh
passwd passwd
``` ```
@ -410,7 +411,7 @@ passwd
> [!Tip] > [!Tip]
>Change `artix` to your desired username >Change `artix` to your desired username
``` ```sh
useradd -m artix useradd -m artix
passwd artix passwd artix
``` ```
@ -420,19 +421,19 @@ passwd artix
## Configure doas ## Configure doas
1. Create the config file. 1. Create the config file.
``` ```sh
touch /etc/doas.conf touch /etc/doas.conf
chown -c root:root /etc/doas.conf chown -c root:root /etc/doas.conf
chmod -c 0400 /etc/doas.conf chmod -c 0400 /etc/doas.conf
``` ```
2. Edit `/etc/doas.conf` 2. Edit `/etc/doas.conf`
``` ```sh
nano /etc/doas.conf nano /etc/doas.conf
``` ```
3. Add the following: 3. Add the following:
``` ```conf
permit artix as root permit artix as root
permit nopass artix as root cmd pacman permit nopass artix as root cmd pacman
``` ```
@ -446,14 +447,14 @@ permit nopass artix as root cmd pacman
>If you see a bunch of files listed, use EFISTUB. >If you see a bunch of files listed, use EFISTUB.
>If you do not see a bunch of files listed, your system does not support UEFI and you should use GRUB. >If you do not see a bunch of files listed, your system does not support UEFI and you should use GRUB.
``` ```sh
ls /sys/firmware/efi/efivars ls /sys/firmware/efi/efivars
``` ```
### EFISTUB ### EFISTUB
1. Get the UUID of your root partition. 1. Get the UUID of your root partition.
``` ```sh
blkid -s UUID -o value /dev/nvme0n1p2 blkid -s UUID -o value /dev/nvme0n1p2
``` ```
@ -463,44 +464,44 @@ blkid -s UUID -o value /dev/nvme0n1p2
>Replace xxxx with the UUID that you obtained earlier. >Replace xxxx with the UUID that you obtained earlier.
>Replace `intel-ucode.img` with `amd-ucode.img` if you have an AMD CPU >Replace `intel-ucode.img` with `amd-ucode.img` if you have an AMD CPU
``` ```sh
efibootmgr -c -d /dev/nvme0n1 -p 1 -l /vmlinuz-linux -L "Artix" -u "cryptdevice=UUID=xxxx:root root=/dev/mapper/root rw initrd=\intel-ucode.img initrd=\initramfs-linux.img loglevel=3 quiet" efibootmgr -c -d /dev/nvme0n1 -p 1 -l /vmlinuz-linux -L "Artix" -u "cryptdevice=UUID=xxxx:root root=/dev/mapper/root rw initrd=\intel-ucode.img initrd=\initramfs-linux.img loglevel=3 quiet"
``` ```
### GRUB ### GRUB
1. Install grub on your boot partition 1. Install grub on your boot partition
``` ```sh
pacman -S grub pacman -S grub
grub-install /dev/sda grub-install /dev/sda
``` ```
2. Get the UUID of your root partition. 2. Get the UUID of your root partition.
``` ```sh
blkid -s UUID -o value /dev/nvme0n1p2 blkid -s UUID -o value /dev/nvme0n1p2
``` ```
3. Edit `/etc/default/grub` 3. Edit `/etc/default/grub`
``` ```sh
nano /etc/default/grub nano /etc/default/grub
``` ```
4. Add the following to the `GRUB_CMDLINE_LINUX` line, where xxxx is the UUID that you obtained earlier. 4. Add the following to the `GRUB_CMDLINE_LINUX` line, where xxxx is the UUID that you obtained earlier.
``` ```conf
cryptdevice=UUID=xxxx:root root=/dev/mapper/root cryptdevice=UUID=xxxx:root root=/dev/mapper/root
``` ```
> [!Note] > [!Note]
>It should look something like this: >It should look something like this:
``` ```conf
GRUB_CMDLINE_LINUX="cryptdevice=UUID=550e8400-e29b-41d4-a716-446655440000:root root=/dev/mapper/root" GRUB_CMDLINE_LINUX="cryptdevice=UUID=550e8400-e29b-41d4-a716-446655440000:root root=/dev/mapper/root"
``` ```
5. Uncomment `#GRUB_ENABLE_CRYPTODISK=y` 5. Uncomment `#GRUB_ENABLE_CRYPTODISK=y`
6. Generate the config file. 6. Generate the config file.
``` ```sh
grub-mkconfig -o /boot/grub/grub.cfg grub-mkconfig -o /boot/grub/grub.cfg
``` ```
@ -509,7 +510,7 @@ grub-mkconfig -o /boot/grub/grub.cfg
## Reboot ## Reboot
1. You can reboot and enter into your new installation. 1. You can reboot and enter into your new installation.
``` ```sh
exit exit
umount -R /mnt umount -R /mnt
reboot now reboot now
@ -529,26 +530,26 @@ Login using the credentials that you set, if you followed the example your usern
#### Add arch extra repository #### Add arch extra repository
1. Install packages 1. Install packages
``` ```sh
doas pacman -Syu artix-archlinux-support curl doas pacman -Syu artix-archlinux-support curl
doas pacman-key --populate archlinux doas pacman-key --populate archlinux
doas sh -c "curl https://archlinux.org/mirrorlist/all -o /etc/pacman.d/mirrorlist-arch" doas sh -c "curl https://archlinux.org/mirrorlist/all -o /etc/pacman.d/mirrorlist-arch"
``` ```
2. Edit `/etc/pacman.d/mirrorlist-arch` 2. Edit `/etc/pacman.d/mirrorlist-arch`
``` ```sh
doas nano /etc/pacman.d/mirrorlist-arch doas nano /etc/pacman.d/mirrorlist-arch
``` ```
3. Uncomment the first server entries under the worldwide section 3. Uncomment the first server entries under the worldwide section
4. Edit `/etc/pacman.conf` 4. Edit `/etc/pacman.conf`
``` ```sh
doas nano /etc/pacman.conf doas nano /etc/pacman.conf
``` ```
5. Add the following to the bottom of the file 5. Add the following to the bottom of the file
``` ```conf
##Arch ##Arch
[extra] [extra]
Include = /etc/pacman.d/mirrorlist-arch Include = /etc/pacman.d/mirrorlist-arch
@ -559,7 +560,7 @@ Include = /etc/pacman.d/mirrorlist-arch
#### Sort for fastest mirrors #### Sort for fastest mirrors
``` ```sh
doas pacman -Syu reflector pacman-contrib doas pacman -Syu reflector pacman-contrib
doas reflector --verbose -p https -l 30 -f 5 --sort rate --save /etc/pacman.d/mirrorlist-arch doas reflector --verbose -p https -l 30 -f 5 --sort rate --save /etc/pacman.d/mirrorlist-arch
doas sh -c "curl https://gitea.artixlinux.org/packages/artix-mirrorlist/raw/branch/master/mirrorlist -o /etc/pacman.d/mirrorlist.bak" doas sh -c "curl https://gitea.artixlinux.org/packages/artix-mirrorlist/raw/branch/master/mirrorlist -o /etc/pacman.d/mirrorlist.bak"
@ -569,7 +570,7 @@ doas sh -c "rankmirrors -v -n 5 /etc/pacman.d/mirrorlist.bak > /etc/pacman.d/mir
### AUR ### AUR
#### Install paru #### Install paru
``` ```sh
doas pacman -S --needed base-devel doas pacman -S --needed base-devel
git clone https://aur.archlinux.org/paru.git git clone https://aur.archlinux.org/paru.git
cd paru cd paru
@ -580,7 +581,7 @@ rm -rf paru
#### Replace sudo with doas #### Replace sudo with doas
``` ```sh
doas pacman -Rdd sudo doas pacman -Rdd sudo
doas ln -s /usr/bin/doas /usr/bin/sudo doas ln -s /usr/bin/doas /usr/bin/sudo
``` ```
@ -589,7 +590,7 @@ doas ln -s /usr/bin/doas /usr/bin/sudo
Install and enable the powerprofiles daemon Install and enable the powerprofiles daemon
``` ```sh
doas pacman -S power-profiles-daemon power-profiles-daemon-openrc doas pacman -S power-profiles-daemon power-profiles-daemon-openrc
doas rc-update add power-profiles-daemon doas rc-update add power-profiles-daemon
doas rc-service power-profiles-daemon start doas rc-service power-profiles-daemon start
@ -597,7 +598,7 @@ doas rc-service power-profiles-daemon start
### Add swap ### Add swap
``` ```sh
doas fallocate -l 4G /swapfile doas fallocate -l 4G /swapfile
doas chmod 600 /swapfile doas chmod 600 /swapfile
doas mkswap /swapfile doas mkswap /swapfile
@ -608,7 +609,7 @@ echo '/swapfile none swap sw 0 0' | doas tee -a /etc/fstab
### Auto-mount an external LUKS encrypted drive ### Auto-mount an external LUKS encrypted drive
``` ```sh
doas fdisk /dev/sdb doas fdisk /dev/sdb
>g, n, w >g, n, w

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -0,0 +1,182 @@
---
title: 'The State of Static Blogs in 2024'
description: 'There should not be a single reason why you would need a command palette search bar to find a blog post on your own site.'
date: 2024-07-25
tags: ['webdev', 'opinion']
image: './1200x630.png'
---
## Introduction
Hello! My name is enscribe ([jktrn](https://github.com/jktrn) on GitHub), and I'm a fullstack web developer who has been fiddling with blogging platforms for a couple of years now. I run a blog at [enscribe.dev](https://enscribe.dev), where I write about cybersecurity and the capture-the-flag (CTF) scene.
I have a lot of opinions about what makes a great blogging template. As a cumulative result of all the slop, bullshit, and outright terrible design decisions I've had to deal with working with various templates and frameworks, I bring you [astro-erudite](https://github.com/jktrn/astro-erudite), which should hopefully bring a better developer and user experience in terms of ease of use, customization, and performance.
astro-erudite is written in Astro, a framework hyperoptimized for static content such as blogs. Aesthetically, it is also designed to be as boring as possible while still maintaining maximum functionality, as to allow for the freedom of the developer (or the designer they hire) to make their blog uniquely their own. Within the codebase of this template I've included many nuances that, in my opinion (and there will be many, many opinions here), make the developer experience significantly more pleasant. I've also _excluded_ many features that, frankly, you don't need.
## Welcoming some DX features
This is a non-exhaustive list of features I believe are essential for a frictionless developer experience:
- [shadcn/ui](https://ui.shadcn.com) is a pretty controversial component library. I love it. I don't care much for the components themselves as they are literally [Radix](https://www.radix-ui.com/) primitive wrappers&mdash;however, the best part is arguably its take on [theming](https://ui.shadcn.com/docs/theming), which introduces a convention involving CSS colors such as `background` and `foreground` into your Tailwind configuration so that styling is a breeze. These classes also automatically adapt to the user's selected theme, and as such you don't need to worry about adding an equivalent `dark:` style to all of your theming. shadcn/ui turns `"bg-stone-50 text-stone-900 dark:bg-stone-900 dark:text-stone-50"` into `"bg-background text-foreground"`, both more semantic and easier to blanket edit (if you wanted to change all your blues in your site to indigos, you would need to go around every single class and change it rather than editing a single CSS variable). Other utility colors such as `secondary`, `muted`, `accent`, and `destructive` also exist and are very self-explanatory in name (and also have an equivalent `-foreground` class, e.g. `secondary-foreground`, which you can apply to text on top of these colors).
- [Tailwind Typography](https://github.com/tailwindlabs/tailwindcss-typography) is a plugin that automatically styles any content surrounded by an `<article>{:html}` tag in a way which makes it readable and blog-post-friendly. It does this via a `prose` class which you can wrap anything with to style the interior content. This is especially useful for HTML you don't control, e.g. a post rendered from Markdown. Although your control over the rendering is a bit less fine-grained, you're also already using Tailwind so this right has long been forsaken.
- [Shiki](https://github.com/shikijs/shiki) is a syntax highlighter for code blocks. Although Astro code blocks utilize Shiki under the hood, I've actually disabled the default code blocks in this template so that they don't collide with my preferred library [rehype-pretty-code](https://rehype-pretty.pages.dev), which is _also_ powered by Shiki but allows for line numbers, line highlighting, inline code syntax highlighting, and a transformers API for advanced customization such as manual `diff` visualization and line blurring. This library does not ship with any CSS, and it's up to you to style the code blocks and code block titles as you see fit. I've provided styles in `src/styles/global.css` within the `@layer components{:css}` directive if you wish to fiddle with them. The following code block is an example of how to style code blocks using rehype-pretty-code, and was generated with the following Markdown code:
````mdx
```css title="src/styles/global.css" caption="Styling code blocks using rehype-pretty-code (with a caption down here)" showLineNumbers{80} {10-12} /apply/ /components/
```
````
```css title="src/styles/global.css" caption="Styling code blocks using rehype-pretty-code (with a caption down here)" showLineNumbers{80} {10-12} /apply/ /components/
@layer components {
article {
@apply prose-headings:scroll-mt-20;
.katex-display {
@apply overflow-x-auto overflow-y-hidden;
}
/* Removes background from <mark> elements */
mark {
@apply bg-transparent;
}
/* Blanket syntax highlighting */
code[data-theme*=' '] {
span { /* [!code ++] */
color: var(--shiki-light); /* [!code ++] */
} /* [!code ++] */
.dark & span { /* [!code --] */
color: var(--shiki-dark); /* [!code --] */
} /* [!code --] */
}
```
When I added those two diff additions and deletions, I simply added `/* [!code ++] */` and `/* [!code --] */` to the lines I wanted to highlight. Just use the comment syntax of whatever language you're attempting to highlight.
- The `cn(){:ts}` function is a utility function which combines [clsx](https://www.npmjs.com/package/clsx) and [tailwind-merge](https://www.npmjs.com/package/tailwind-merge), two packages which allow painless conditional class addition and concatenation:
```tsx title="src/lib/utils.ts" caption="A utility function for class name concatenation" showLineNumbers
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
This needs to be in every single template. This is an example of it being used in my `<Link>{:tsx}` component:
```astro showLineNumbers title="src/components/Link.astro" caption="A custom Link component with tailwind-merge and clsx" {16-20}
---
import { cn } from '@/lib/utils'
const { href, external, class: className, underline, ...rest } = Astro.props
---
<a
href={href}
target={external ? '_blank' : '_self'}
class={cn(
'inline-block transition-colors duration-300 ease-in-out',
underline &&
'underline decoration-muted-foreground underline-offset-[3px] hover:decoration-foreground',
className,
)}
{...rest}
>
<slot />
</a>
```
We were able to, in a single helper function:
1. Concatenate whatever the user passed via the `class` prop to our base styles
2. Conditionally add an underline if the `underline` prop is true
Awesome!
## Welcoming some UX features
Within the blog itself (as in the layout, appearance, and navigation) are features that I believe are essential for a great user experience:
- Images are awesome and, by default, your blog post should have an image associated with it as part of the post's [Open Graph](https://ogp.me/) metadata. Since you can do whatever you want with the image, all of my dummy posts will have a placeholder image placed within their folder in `src/content/blog/`. Whenever you load into a blog post, splat in the middle will be the image associated with that post in its frontmatter.
- Theme selectors should be self-explanatory. I've added one on the top right of the header, which is also `sticky` and not `absolute` such that it doesn't ignore the document flow (and thus you won't have to add `mt-20` to the top of every single page).
- The table of contents of a post shouldn't be reduced to a `<details closed>{:html}` at the start of a blog post on desktop. You'd need to go to the top of the page to navigate through items. I've added a sticky `TableOfContents` component which always hangs out around the unused left side margin of a blog post. I also attached a very tiny client-side script using [`IntersectionObserver{:ts}`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver) to highlight all of the headings you're viewing within the TOC as you scroll through the page&mdash;it also will handle nested headings in that the parent heading of a visible child will still be highlighted even if off-screen (see the dummy [2024 Post](/blog/2024-post) for an example of this). I'll still use a collapsible `<details>{:html}` element for the table of contents on mobile though since obviously a table of contents on the side is unfeasible for small screens.
- Every page, except the homepage, will have a `<Breadcrumb>{:tsx}` component which shows you your current location in the site hierarchy. I don't see these often in blog templates even though they are so amazing for both discoverability (SEO and crawling) and user experience (the user always knows how "deep" they are in the site).
- You can specify multiple post authors via frontmatter. If this post author's id is found within the `Authors` collection, then it will render particular info from that author's frontmatter file, `[author-name].md` (e.g. avatar, link to profile). For example, the previous post (2024 Post) has two authors: "enscribe" and "jktrn", where "enscribe" is the only author with a custom avatar since "jktrn" is unregistered.
- Each author will have their own page, which lists all of their posts. If you're the only author throughout the entire blog then you can simply disregard all aspects regarding both inserting authors and the `Authors` collection.
- Each tag will also have their own page, which lists all of the posts under that tag!
- $\LaTeX$ is fully supported with [KaTeX](https://katex.org/):
<blockquote>
To solve the cubic equation $t^3 + pt + q = 0$ (where the real numbers
$p, q$ satisfy ${4p^3 + 27q^2} > 0$) one can use Cardano's formula:
$$
\sqrt[{3}]{
-\frac{q}{2}
+\sqrt{\frac{q^2}{4} + {\frac{p^{3}}{27}}}
}+
\sqrt[{3}]{
-\frac{q}{2}
-\sqrt{\frac{q^2}{4} + {\frac{p^{3}}{27}}}
}
$$
For any $u_1, \dots, u_n \in \mathbb{C}$ and
$v_1, \dots, v_n \in \mathbb{C}$, the CauchyBunyakovskySchwarz
inequality can be written as follows:
$$
\left| \sum_{k=1}^n {u_k \bar{v_k}} \right|^2
\leq
{
\left( \sum_{k=1}^n {|u_k|} \right)^2
\left( \sum_{k=1}^n {|v_k|} \right)^2
}
$$
Finally, the determinant of a Vandermonde matrix can be calculated
using the following expression:
$$
\begin{vmatrix}
1 & x_1 & x_1^2 & \dots & x_1^{n-1} \\
1 & x_2 & x_2^2 & \dots & x_2^{n-1} \\
1 & x_3 & x_3^2 & \dots & x_3^{n-1} \\
\vdots & \vdots & \vdots & \ddots & \vdots \\
1 & x_n & x_n^2 & \dots & x_n^{n-1} \\
\end{vmatrix}
= {\prod_{1 \leq {i,j} \leq n} {(x_i - x_j)}}
$$
—<cite>[Three famous mathematical formulas](https://developer.mozilla.org/en-US/docs/Learn/MathML/First_steps/Three_famous_mathematical_formulas) (Mozilla Docs)</cite>
</blockquote>
## Foregoing some slop
- Goodbye, [ESLint](https://eslint.org/)! There have been so many occasions where I've had to deal with blogging templates with in-built pre-commit hooks which enforce contrived and arbitrary linting rules that, frankly, I couldn't be bothered with. Obviously, linting is awesome for ensuring consistency and best practice, but that's with shared and large codebases. You're dealing with, at most, your mediocre MDX blog posts and some interior fetching. It's just not worth the hypertension.
- You really don't need analytics via [Umami](https://umami.is) or [Plausible](https://plausible.io). Let's be realistic: for many personal blogs, unless you're an anime profile picture Twitter microcelebrity and/or the daughter of Taylor Swift you don't need to know how many of your avid fans click Big Button A versus how many click Big Button B.
- You really don't need a comments section via [Giscus](https://giscus.app). This opens up a can of worms involving the ability to spam comments and the necessity to moderate them. If you want organic discussion about your blog posts to happen, then share on social media and let people discuss there.
- Speaking of sharing on social media, let's get rid of the share buttons. Please inform me of a single time you have used a share button on a blog post.
- You really don't need a <abbr title="Content Management System">CMS</abbr> unless you have thousands of posts and/or are willing to navigate through a clunky management interface. Markdown and folders is really all you need, which you can organize to your preference via folder or file naming conventions.
- If you have literally anything involving an `.env` file in a blogging site, please think about what you are doing very carefully.
- Please do not override the browser's <kbd>Ctrl</kbd> + <kbd>K</kbd> functionality to open up a command palette. There should not be a single reason why a user would use a small context menu to browse your blog over the `/blog` route. Most of the time, command palettes on sites do nothing more than regurgitate shortcuts that are already on the same page you're hiding with the palette's modal.
## Something important
Before we wrap up, I want to emphasize that everything that I've shared here is based on my own personal opinions and experiences. While I believe these practices and choices lead to a better blogging experience, you're absolutely free to disagree.
The web development community, especially in spaces like Twitter and various online forums, is constantly engaged in heated debates about what constitutes "best practices." You'll find a wide spectrum of viewpoints:
1. Fundamentalists who adhere strictly to established patterns and completely disregard change,
2. Accelerationists who gobble up whatever Vercel cooks as if it's the second coming of Christ,
3. and everyone in between this spectrum who just wants to ship.
I'm just another guy who loves to blog, and I wanted to share what particular technology stack worked the best for me in this particular use case. A stack for one project can be completely unusable for another. If you vehemently hate any of the design choices I've made then simply get rid of them. MIT license! Happy blogging.

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

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

View file

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="128"
height="128"
viewBox="0 0 128 128"
version="1.1"
xml:space="preserve"
style="clip-rule:evenodd;fill-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"
id="svg3"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs3"><linearGradient
id="linearGradient26"><stop
style="stop-color:#ffffff;stop-opacity:0.30000001;"
offset="0"
id="stop26" /><stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="0.9638136"
id="stop27" /></linearGradient><radialGradient
xlink:href="#linearGradient26"
id="radialGradient27"
cx="250"
cy="250"
fx="250"
fy="250"
r="250"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.6,0,0,0.6,100.00001,100.00002)" /><filter
style="color-interpolation-filters:sRGB"
id="filter1"
x="-0.243"
y="-0.243"
width="1.486"
height="1.486"><feGaussianBlur
stdDeviation="30.375"
id="feGaussianBlur1" /></filter></defs>
<rect
style="clip-rule:evenodd;fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:2"
id="bg"
width="128"
height="128"
x="0"
y="0"
ry="16.384001" /><circle
style="display:inline;fill:url(#radialGradient27);fill-opacity:1;stroke-width:0.300003;filter:url(#filter1)"
id="backlight"
cx="250"
cy="250"
r="150"
transform="matrix(0.256,0,0,0.256,2.2888184e-6,2.2888184e-6)" /><path
d="M 64.00001,6.4 68.608,38.656 c 0.79735,4.95473 3.36351,9.8663 6.912,13.41429 3.54847,3.5487 8.86942,6.52423 13.824,7.32171 L 121.6,64 89.344,68.608 C 84.38942,69.40548 79.06847,72.38102 75.52,75.92971 71.97151,79.4777 69.40535,84.38927 68.608,89.344 L 64.00001,121.6 59.392,89.344 C 58.59465,84.38927 56.02847,79.4777 52.48,75.92971 48.93151,72.38102 43.61058,69.40548 38.656,68.608 L 6.4,64 38.656,59.392 C 43.61058,58.59452 48.93151,55.61899 52.48,52.07029 56.02847,48.5223 58.59465,43.61073 59.392,38.656 Z"
style="clip-rule:evenodd;display:inline;fill:#ffffff;fill-rule:evenodd;stroke-width:0.0707615;stroke-linejoin:round;stroke-miterlimit:2"
id="star" /></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -1,24 +1,36 @@
--- ---
import Footer from "@components/Footer.astro"; import Footer from '@/components/Footer.astro'
import Head from "@components/Head.astro"; import Head from '@/components/Head.astro'
import { SITE } from "@consts"; import Header from '@/components/Header.astro'
import { SITE } from '@/consts'
type Props = { type Props = {
title: string; title: string
description: string; description: string
}; image?: string
}
const { title, description } = Astro.props; const { title, description, image } = Astro.props
--- ---
<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<Head title={`${title} | ${SITE.TITLE}`} description={description} /> <Head
title={`${title} | ${SITE.TITLE}`}
description={description}
image={image}
/>
</head> </head>
<body> <body>
<main> <div
class="box-border flex h-fit min-h-screen flex-col gap-y-6 font-sans antialiased"
>
<Header />
<main class="flex-grow">
<slot /> <slot />
</main> </main>
<Footer /> <Footer />
</div>
</body> </body>
</html> </html>

View file

@ -1,21 +1,21 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from 'clsx'
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }
export function formatDate(date: Date) { export function formatDate(date: Date) {
return Intl.DateTimeFormat("en-SE", { return Intl.DateTimeFormat('en-US', {
year: "numeric", year: 'numeric',
month: "2-digit", month: 'long',
day: "2-digit", day: 'numeric',
}).format(date); }).format(date)
} }
export function readingTime(html: string) { export function readingTime(html: string) {
const textOnly = html.replace(/<[^>]+>/g, ""); const textOnly = html.replace(/<[^>]+>/g, '')
const wordCount = textOnly.split(/\s+/).length; const wordCount = textOnly.split(/\s+/).length
const readingTimeMinutes = (wordCount / 200 + 1).toFixed(); const readingTimeMinutes = (wordCount / 200 + 1).toFixed()
return `${readingTimeMinutes} min read`; return `${readingTimeMinutes} min read`
} }

View file

@ -1,15 +1,37 @@
--- ---
import Container from "@components/Container.astro"; import Breadcrumbs from '@/components/Breadcrumbs.astro'
import { SITE } from "@consts"; import Container from '@/components/Container.astro'
import Layout from "@layouts/Layout.astro"; import Link from '@/components/Link.astro'
import { buttonVariants } from '@/components/ui/button'
import { SITE } from '@/consts'
import Layout from '@/layouts/Layout.astro'
import { cn } from '@/lib/utils'
--- ---
<Layout title="404" description={SITE.DESCRIPTION}> <Layout title="404" description={SITE.DESCRIPTION}>
<Container> <Container class="flex grow flex-col gap-y-6">
<div class="text-center"> <Breadcrumbs items={[{ label: '???', icon: 'lucide:circle-help' }]} />
<h4 class="text-2xl font-semibold text-black dark:text-white">
404: Page not found <section
</h4> class="flex flex-col items-center justify-center gap-y-4 text-center"
>
<div class="max-w-md">
<h1 class="mb-4 text-3xl font-bold">404: Page not found</h1>
<p class="prose prose-neutral dark:prose-invert">
Oops! The page you're looking for doesn't exist.
</p>
</div> </div>
<Link
href="/"
class={cn(
buttonVariants({ variant: 'outline' }),
'flex gap-x-1.5 group',
)}
>
<span class="transition-transform group-hover:-translate-x-1"
>&larr;</span
> Go to home page
</Link>
</section>
</Container> </Container>
</Layout> </Layout>

View file

@ -1,11 +1,14 @@
--- ---
import Breadcrumbs from "@/components/Breadcrumbs.astro";
import PostNavigation from "@/components/PostNavigation.astro";
import TableOfContents from "@/components/TableOfContents.astro";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import Layout from "@/layouts/Layout.astro";
import { formatDate, readingTime } from "@/lib/utils";
import { Icon } from "astro-icon/components";
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection, render } from "astro:content"; import { type CollectionEntry, getCollection, render } from "astro:content";
import Container from "@components/Container.astro";
import FormattedDate from "@components/FormattedDate.astro";
import PostNavigation from "@components/PostNavigation.astro";
import TableOfContents from "@components/TableOfContents.astro";
import Layout from "@layouts/Layout.astro";
import { readingTime } from "@lib/utils";
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = (await getCollection("blog")) const posts = (await getCollection("blog"))
@ -22,55 +25,121 @@ const posts = (await getCollection("blog"))
.filter((post) => !post.data.draft) .filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
function getNextPost() { function getPostIndex(id: string): number {
let postIndex; return posts.findIndex((post) => post.id === id);
for (const post of posts) {
if (post.id === Astro.params.id) {
postIndex = posts.indexOf(post);
return posts[postIndex + 1];
}
}
} }
function getPrevPost() { function getPrevPost(id: string): Props | null {
let postIndex; const postIndex = getPostIndex(id);
for (const post of posts) { return postIndex !== -1 && postIndex < posts.length - 1
if (post.id === Astro.params.id) { ? posts[postIndex + 1]
postIndex = posts.indexOf(post); : null;
return posts[postIndex - 1];
}
}
} }
const nextPost = getNextPost(); function getNextPost(id: string): Props | null {
const prevPost = getPrevPost(); const postIndex = getPostIndex(id);
return postIndex > 0 ? posts[postIndex - 1] : null;
}
const currentPostId = Astro.params.id;
const nextPost = getNextPost(currentPostId);
const prevPost = getPrevPost(currentPostId);
const post = Astro.props; const post = Astro.props;
const { Content, headings } = await render(post); const { Content, headings } = await render(post);
--- ---
<Layout title={post.data.title} description={post.data.description}> <Layout
<Container> title={post.data.title}
<div class="my-10 space-y-1"> description={post.data.description}
<div class="flex items-center gap-1.5"> image={post.data.image?.src ?? "/static/1200x630.png"}
<div class="font-base text-sm"> >
<FormattedDate date={post.data.date} /> <section
</div> class="grid grid-cols-[minmax(0px,1fr)_min(768px,100%)_minmax(0px,1fr)] gap-y-6 *:px-4"
&bull; >
<Breadcrumbs
items={[
{ href: "/", label: "", icon: "lucide:archive" },
{ label: post.data.title, icon: "lucide:file-text" },
]}
class="col-start-2"
/>
{ {
post.body && ( post.data.image && (
<div class="font-base text-sm">{readingTime(post.body)}</div> <Image
src={post.data.image}
alt={post.data.title}
width={1200}
height={630}
class="col-span-full mx-auto w-full max-w-[1000px] object-cover"
/>
) )
} }
</div> <section class="col-start-2 flex flex-col gap-y-6 text-center">
<h1 class="text-3xl font-semibold text-black dark:text-white"> <div class="flex flex-col gap-y-4">
<h1 class="text-4xl font-bold leading-tight sm:text-5xl">
{post.data.title} {post.data.title}
</h1> </h1>
<div
class="flex flex-wrap items-center justify-center gap-2 text-sm text-muted-foreground"
>
<div class="flex items-center gap-2">
<span>{formatDate(post.data.date)}</span>
<Separator orientation="vertical" className="h-4" />
<span>{readingTime(post.body!)}</span>
</div> </div>
{headings.length > 0 && <TableOfContents headings={headings} />} </div>
<article> </div>
<Content />
<PostNavigation prevPost={prevPost} nextPost={nextPost} /> <PostNavigation prevPost={prevPost} nextPost={nextPost} />
</section>
{headings.length > 0 && <TableOfContents headings={headings} />}
<article
class="prose prose-neutral col-start-2 max-w-none dark:prose-invert"
>
<Content />
</article> </article>
</Container>
<PostNavigation prevPost={prevPost} nextPost={nextPost} />
</section>
<Button
variant="outline"
size="icon"
className="group fixed bottom-8 right-8 z-50 hidden"
id="scroll-to-top"
title="Scroll to top"
aria-label="Scroll to top"
>
<Icon
name="lucide:arrow-up"
class="mx-auto size-4 transition-all group-hover:-translate-y-0.5"
/>
</Button>
<script>
document.addEventListener("astro:page-load", () => {
const scrollToTopButton = document.getElementById("scroll-to-top");
const footer = document.querySelector("footer");
if (scrollToTopButton && footer) {
scrollToTopButton.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
window.addEventListener("scroll", () => {
const footerRect = footer.getBoundingClientRect();
const isFooterVisible = footerRect.top <= window.innerHeight;
scrollToTopButton.classList.toggle(
"hidden",
window.scrollY <= 300 || isFooterVisible,
);
});
}
});
</script>
</Layout> </Layout>

72
src/pages/[...page].astro Normal file
View file

@ -0,0 +1,72 @@
---
import BlogCard from "@/components/BlogCard.astro";
import Breadcrumbs from "@/components/Breadcrumbs.astro";
import Container from "@/components/Container.astro";
import PaginationComponent from "@/components/ui/pagination";
import { SITE } from "@/consts";
import Layout from "@/layouts/Layout.astro";
import type { PaginateFunction } from "astro";
import { type CollectionEntry, getCollection } from "astro:content";
export async function getStaticPaths({
paginate,
}: {
paginate: PaginateFunction;
}) {
const allPosts = await getCollection("blog", ({ data }) => !data.draft);
return paginate(
allPosts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()),
{ pageSize: SITE.POSTS_PER_PAGE },
);
}
const { page } = Astro.props;
const postsByYear = page.data.reduce(
(acc: Record<string, CollectionEntry<"blog">[]>, post) => {
const year = post.data.date.getFullYear().toString();
(acc[year] ??= []).push(post);
return acc;
},
{},
);
const years = Object.keys(postsByYear).sort(
(a, b) => parseInt(b) - parseInt(a),
);
---
<Layout title="home" description={SITE.DESCRIPTION}>
<Container class="flex grow flex-col gap-y-6">
<Breadcrumbs
items={[
{ label: "", href: "/", icon: "lucide:archive" },
{ label: `Page ${page.currentPage}`, icon: "lucide:folder-open" },
]}
/>
<div class="flex min-h-[calc(100vh-18rem)] flex-col gap-y-8">
{
years.map((year) => (
<section class="flex flex-col gap-y-4">
<div class="font-semibold">{year}</div>
<ul class="not-prose flex flex-col gap-4">
{postsByYear[year].map((post) => (
<li>
<BlogCard entry={post} />
</li>
))}
</ul>
</section>
))
}
</div>
<PaginationComponent
currentPage={page.currentPage}
totalPages={page.lastPage}
baseUrl="/"
client:load
/>
</Container>
</Layout>

View file

@ -1,58 +0,0 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import ArrowCard from "@components/ArrowCard.astro";
import Container from "@components/Container.astro";
import { SITE } from "@consts";
import { HOME } from "@consts";
import Layout from "@layouts/Layout.astro";
const data = (await getCollection("blog"))
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
type Acc = {
[year: string]: CollectionEntry<"blog">[];
};
const posts = data.reduce((acc: Acc, post) => {
const year = post.data.date.getFullYear().toString();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(posts).sort(
(a, b) => Number.parseInt(b) - Number.parseInt(a),
);
---
<Layout title={HOME.TITLE} description={SITE.DESCRIPTION}>
<Container>
<aside>
<div class="space-y-10">
<div class="space-y-4">
{
years.map((year) => (
<section class="space-y-4">
<div class="font-semibold text-black dark:text-white">
{year}
</div>
<div>
<ul class="not-prose flex flex-col gap-4">
{posts[year].map((post) => (
<li>
<ArrowCard entry={post} />
</li>
))}
</ul>
</div>
</section>
))
}
</div>
</div>
</aside>
</Container>
</Layout>

62
src/pages/robots.txt.ts Normal file
View file

@ -0,0 +1,62 @@
import type { APIRoute } from 'astro'
const getRobotsTxt = (sitemapURL: URL) => `
User-agent: AI2Bot
User-agent: Ai2Bot-Dolma
User-agent: Amazonbot
User-agent: anthropic-ai
User-agent: Applebot
User-agent: Applebot-Extended
User-agent: Bytespider
User-agent: CCBot
User-agent: ChatGPT-User
User-agent: Claude-Web
User-agent: ClaudeBot
User-agent: cohere-ai
User-agent: cohere-training-data-crawler
User-agent: Crawlspace
User-agent: Diffbot
User-agent: DuckAssistBot
User-agent: FacebookBot
User-agent: FriendlyCrawler
User-agent: Google-Extended
User-agent: GoogleOther
User-agent: GoogleOther-Image
User-agent: GoogleOther-Video
User-agent: GPTBot
User-agent: iaskspider/2.0
User-agent: ICC-Crawler
User-agent: ImagesiftBot
User-agent: img2dataset
User-agent: ISSCyberRiskCrawler
User-agent: Kangaroo Bot
User-agent: Meta-ExternalAgent
User-agent: Meta-ExternalFetcher
User-agent: OAI-SearchBot
User-agent: omgili
User-agent: omgilibot
User-agent: PanguBot
User-agent: PerplexityBot
User-agent: PetalBot
User-agent: Scrapy
User-agent: SemrushBot-OCOB
User-agent: SemrushBot-SWA
User-agent: Sidetrade indexer bot
User-agent: Timpibot
User-agent: VelenPublicWebCrawler
User-agent: Webzio-Extended
User-agent: YouBot
Disallow: /
DisallowAITraining: /
# Block any non-specified AI crawlers
User-Agent: *
DisallowAITraining: /within
Sitemap: ${sitemapURL.href}
`
export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site)
return new Response(getRobotsTxt(sitemapURL))
}

View file

@ -1,23 +0,0 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import { SITE } from "@consts";
export async function GET(context) {
const blog = (await getCollection("blog")).filter((post) => !post.data.draft);
const items = [...blog].sort(
(a, b) => new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf(),
);
return rss({
title: SITE.TITLE,
description: SITE.DESCRIPTION,
site: context.site,
items: items.map((item) => ({
title: item.data.title,
description: item.data.description,
pubDate: item.data.date,
link: `/${item.id}/`,
})),
});
}

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

@ -0,0 +1,32 @@
import { SITE } from '@/consts'
import rss from '@astrojs/rss'
import type { APIContext } from 'astro'
import { getCollection } from 'astro:content'
export async function GET(context: APIContext) {
try {
const blog = (await getCollection('blog')).filter(
(post) => !post.data.draft,
)
const items = [...blog].sort(
(a, b) =>
new Date(b.data.date).valueOf() - new Date(a.data.date).valueOf(),
)
return rss({
title: SITE.TITLE,
description: SITE.DESCRIPTION,
site: context.site ?? SITE.SITEURL,
items: items.map((item) => ({
title: item.data.title,
description: item.data.description,
pubDate: item.data.date,
link: `/${item.id}/`,
})),
})
} catch (error) {
console.error('Error generating RSS feed:', error)
return new Response('Error generating RSS feed', { status: 500 })
}
}

View file

@ -2,107 +2,190 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html { @font-face {
overflow-y: auto; font-family: 'Geist';
color-scheme: light; src: url('/fonts/GeistVF.woff2') format('woff2');
scroll-padding-top: 100px; font-weight: 100 900;
font-style: normal;
font-display: swap;
} }
html.dark { @font-face {
font-family: 'Geist Mono';
src: url('/fonts/GeistMonoVF.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 80.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 80.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 80.1%;
--accent-foreground: 0 0% 9%;
--additive: 112 50% 36%;
--additive-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--ring: 0 0% 3.9%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--additive: 112 50% 36%;
--additive-foreground: 0 0% 9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
*,
*::before,
*::after {
@apply border-border;
}
html {
color-scheme: light;
@apply bg-background text-foreground;
&.dark {
color-scheme: dark; color-scheme: dark;
} }
html, ::-webkit-scrollbar-corner {
body { @apply bg-transparent;
@apply size-full; }
} }
body { .disable-transitions,
@apply font-sans antialiased; .disable-transitions * {
@apply flex flex-col; @apply !transition-none;
@apply bg-neutral-100 dark:bg-neutral-900; }
@apply text-black/75 dark:text-white/75;
}
header {
@apply fixed left-0 right-0 top-0 z-50 py-6;
@apply bg-neutral-100/75 dark:bg-neutral-900/75;
@apply saturate-200 backdrop-blur-sm;
}
main {
@apply flex-1 py-10;
}
footer {
@apply py-6 text-sm;
} }
@layer components {
article { article {
@apply prose prose-neutral max-w-full dark:prose-invert prose-img:mx-auto prose-img:my-auto; @apply prose-headings:scroll-mt-20 prose-headings:break-words first:prose-headings:mt-0 prose-p:break-words prose-a:!break-words prose-a:!decoration-muted-foreground prose-a:underline-offset-[3px] prose-a:transition-colors hover:prose-a:!decoration-foreground prose-pre:!px-0 prose-img:mx-auto;
@apply prose-headings:font-semibold;
@apply prose-headings:text-black prose-headings:dark:text-white; .katex-display {
@apply overflow-x-auto overflow-y-hidden py-4;
} }
@layer utilities { /* Removes background from <mark> elements */
article a { mark {
@apply font-sans text-current underline underline-offset-[3px]; @apply bg-transparent;
@apply decoration-black/30 dark:decoration-white/30;
@apply transition-colors duration-300 ease-in-out;
} }
article a:hover {
@apply text-black dark:text-white; /* Blanket syntax highlighting */
@apply decoration-black/50 dark:decoration-white/50; code[data-theme*=' '] {
span {
color: var(--shiki-light);
}
.dark & span {
color: var(--shiki-dark);
} }
} }
/* Inline code */
html #back-to-top { :not(pre) > code {
@apply pointer-events-none opacity-0; @apply relative rounded bg-muted/50 px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium;
} }
html.scrolled #back-to-top { /* Code blocks */
@apply pointer-events-auto opacity-100; figure[data-rehype-pretty-code-figure] {
@apply relative;
/* Code block titles */
[data-rehype-pretty-code-title] {
@apply break-words rounded-t-xl border-x border-t px-4 py-2 text-sm font-medium text-foreground;
/* Remove top margin from code block if a title is present */
& + pre {
@apply mt-0 rounded-t-none;
}
} }
/* Shadcn-like scrollbar */
pre::-webkit-scrollbar {
@apply h-2.5 w-2.5;
}
pre::-webkit-scrollbar-track {
@apply bg-transparent;
}
pre::-webkit-scrollbar-thumb {
@apply rounded-full bg-border bg-clip-padding p-px;
}
/* Code block styles */
pre { pre {
@apply border border-black/15 py-5 dark:border-white/20; @apply static max-h-[600px] overflow-auto rounded-xl border bg-secondary/20 py-4 text-sm leading-loose;
/* Code block content */
> code {
@apply whitespace-pre-wrap;
counter-reset: line;
/* For code blocks with line numbers */
&[data-line-numbers] {
> [data-line]::before {
counter-increment: line;
content: counter(line);
@apply mr-4 inline-block w-4 text-right text-muted-foreground;
}
} }
:root { /* For each line in the code block */
--astro-code-foreground: #09090b; > [data-line] {
--astro-code-background: #fafafa; @apply px-4;
--astro-code-token-comment: #a19595;
--astro-code-token-keyword: #f47067;
--astro-code-token-string: #00a99a;
--astro-code-token-function: #429996;
--astro-code-token-constant: #2b70c5;
--astro-code-token-parameter: #4e8fdf;
--astro-code-token-string-expression: #ae42a0;
--astro-code-token-punctuation: #8996a3;
--astro-code-token-link: #8d85ff;
} }
.dark { /* Highlighted lines */
--astro-code-foreground: #fafafa; [data-highlighted-line] {
--astro-code-background: #09090b; @apply bg-foreground/10;
--astro-code-token-comment: #a19595;
--astro-code-token-keyword: #f47067;
--astro-code-token-string: #00a99a;
--astro-code-token-function: #6eafad;
--astro-code-token-constant: #b3cceb;
--astro-code-token-parameter: #4e8fdf;
--astro-code-token-string-expression: #bf7db6;
--astro-code-token-punctuation: #8996a3;
--astro-code-token-link: #8d85ff;
} }
.copy-code { /* Highlighted characters */
@apply absolute right-3 top-3 grid size-9 place-content-center rounded border border-black/15 bg-neutral-100 text-center duration-300 ease-in-out dark:border-white/20 dark:bg-neutral-900; [data-highlighted-chars] > span {
@apply bg-muted-foreground/40 py-[7px];
}
/* Diff lines */
.diff {
&.add {
@apply bg-additive/15;
}
&.remove {
@apply bg-destructive/15;
}
}
/* Copy button */
> button:has(> span) {
@apply right-0.5 top-[3px] m-0 size-8 rounded-md bg-background p-1 backdrop-blur-none transition-all;
}
}
}
} }
.copy-code:hover {
@apply bg-[#E9E9E9] transition-colors dark:bg-[#232323];
} }
.copy-code:active {
@apply scale-90 transition-transform;
} }

View file

@ -1,7 +0,0 @@
export type Site = {
TITLE: string;
DESCRIPTION: string;
};
export type Metadata = {
TITLE: string;
};

View file

@ -1,14 +0,0 @@
import defaultTheme from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
fontFamily: {
sans: ["Nunito", ...defaultTheme.fontFamily.sans],
},
},
},
plugins: [require("@tailwindcss/typography")],
};

48
tailwind.config.ts Normal file
View file

@ -0,0 +1,48 @@
import type { Config } from 'tailwindcss'
import defaultTheme from 'tailwindcss/defaultTheme'
const config: Config = {
darkMode: ['selector'],
content: ['./src/**/*.{astro,md,mdx,ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Geist', ...defaultTheme.fontFamily.sans],
mono: ['Geist Mono', ...defaultTheme.fontFamily.mono],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
additive: {
DEFAULT: 'hsl(var(--additive))',
foreground: 'hsl(var(--additive-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
ring: 'hsl(var(--ring))',
},
},
},
plugins: [require('@tailwindcss/typography'), require('tailwindcss-animate')],
}
export default config

View file

@ -1,12 +1,12 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": { "compilerOptions": {
"strictNullChecks": true, "strictNullChecks": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@*": ["./src/*"] "@/*": ["./src/*"]
} },
"jsx": "react-jsx",
"jsxImportSource": "react"
} }
} }