feat: multi-author support, back to top

This commit is contained in:
enscribe 2024-09-12 01:33:52 -07:00
parent 4382f7165c
commit 77bf1bbdf4
No known key found for this signature in database
GPG key ID: 9BBD5C4114E25322
13 changed files with 195 additions and 105 deletions

View file

@ -1,11 +1,10 @@
--- ---
import type { CollectionEntry } from 'astro:content' import type { CollectionEntry } from 'astro:content'
import { formatDate, readingTime } from '@lib/utils' import { formatDate, readingTime, parseAuthors } from '@lib/utils'
import { Image } from 'astro:assets' import { Image } from 'astro:assets'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Link from './Link.astro' import Link from './Link.astro'
import { getEntry } from 'astro:content'
type Props = { type Props = {
entry: CollectionEntry<'blog'> entry: CollectionEntry<'blog'>
@ -17,22 +16,7 @@ const { entry } = Astro.props as {
const formattedDate = formatDate(entry.data.date) const formattedDate = formatDate(entry.data.date)
const readTime = readingTime(entry.body) const readTime = readingTime(entry.body)
const authors = await parseAuthors(entry.data.author ?? [])
let author = null
if (
entry.data.author &&
typeof entry.data.author === 'object' &&
'collection' in entry.data.author
) {
author = await getEntry(entry.data.author)
} else if (typeof entry.data.author === 'string') {
author = {
data: {
name: entry.data.author,
avatar: '/favicons/android-chrome-512x512.png',
},
}
}
--- ---
<div <div
@ -66,18 +50,20 @@ if (
class="mb-2 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground" class="mb-2 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground"
> >
{ {
author && ( authors.length > 0 && (
<> <>
<div class="flex items-center gap-1.5"> {authors.map((author) => (
<Image <div class="flex items-center gap-x-1.5">
src={author.data.avatar} <Image
alt={author.data.name} src={author.avatar}
width={18} alt={author.name}
height={18} width={18}
class="rounded-full" height={18}
/> class="rounded-full"
<span>{author.data.name}</span> />
</div> <span>{author.name}</span>
</div>
))}
<Separator orientation="vertical" className="h-4" /> <Separator orientation="vertical" className="h-4" />
</> </>
) )

View file

@ -27,7 +27,7 @@ function buildToc(headings: Heading[]) {
} }
--- ---
<details open class="group mb-8 block rounded-xl border p-3 xl:hidden"> <details open class="group mb-8 block rounded-xl border p-4 xl:hidden">
<summary <summary
class="flex cursor-pointer items-center justify-between text-xl font-semibold" class="flex cursor-pointer items-center justify-between text-xl font-semibold"
> >

View file

@ -9,14 +9,13 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: 'bg-primary text-primary-foreground hover:bg-primary/90',
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 'bg-destructive text-destructive-foreground over:bg-destructive/90',
outline: outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },

View file

@ -8,10 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn('rounded-xl border bg-card text-card-foreground', className)}
'rounded-xl border bg-card text-card-foreground shadow',
className,
)}
{...props} {...props}
/> />
)) ))

View file

@ -4,7 +4,6 @@ description: 'This a dummy post written in the year 2022.'
date: '2022-01-01' date: '2022-01-01'
tags: ['dummy', 'placeholder'] tags: ['dummy', 'placeholder']
image: '/1200x630.png' image: '/1200x630.png'
author: 'enscribe'
--- ---
This is a dummy post written in the year 2022. This is a dummy post written in the year 2022.

View file

@ -4,7 +4,7 @@ description: 'This a dummy post written in the year 2023.'
date: '2023-01-01' date: '2023-01-01'
tags: ['dummy', 'placeholder'] tags: ['dummy', 'placeholder']
image: '/1200x630.png' image: '/1200x630.png'
author: 'enscribe' author: ['enscribe']
--- ---
This is a dummy post written in the year 2023. This is a dummy post written in the year 2023.

View file

@ -4,7 +4,7 @@ description: 'This a dummy post written in the year 2024.'
date: '2024-01-01' date: '2024-01-01'
tags: ['dummy', 'placeholder'] tags: ['dummy', 'placeholder']
image: '/1200x630.png' image: '/1200x630.png'
author: 'enscribe' author: ['enscribe']
--- ---
This is a dummy post written in the year 2024. This is a dummy post written in the year 2024.

View file

@ -4,7 +4,7 @@ description: 'There should not be a single reason why you would need a command p
date: '2024-07-25' date: '2024-07-25'
tags: ['webdev', 'opinion'] tags: ['webdev', 'opinion']
image: '/1200x630.png' image: '/1200x630.png'
author: 'enscribe' author: ['enscribe', 'jktrn']
--- ---
## Introduction ## Introduction
@ -13,15 +13,23 @@ Hello! My name is enscribe, and I'm a fullstack web developer who has been fiddl
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. 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. 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. 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 features ### Welcoming some DX features
- [shadcn/ui](https://ui.shadcn.com) is a pretty controversial component library. I love it. 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-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-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 utiliy 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). 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. 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-neutral-50 text-neutral-900 dark:bg-neutral-900 dark:text-neutral-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 utiliy 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. - [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 snytax 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: - [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 snytax 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:
```css title="src/styles/global.css" caption="Styling code blocks using rehype-pretty-code (with a caption down here)" showLineNumbers{80} {10-12} /pre/ ````mdx
```css title="src/styles/global.css" caption="Styling code blocks using rehype-pretty-code (with a caption down here)" showLineNumbers{80} {10-12} /pre/ /components/
```
````
```css title="src/styles/global.css" caption="Styling code blocks using rehype-pretty-code (with a caption down here)" showLineNumbers{80} {10-12} /pre/ /components/
@layer components { @layer components {
code[data-theme*=' '] span { /* [!code ++] */ code[data-theme*=' '] span { /* [!code ++] */
color: var(--shiki-light); /* [!code ++] */ color: var(--shiki-light); /* [!code ++] */
@ -36,16 +44,67 @@ astro-erudite is written in Astro, a framework hyperoptimized for static content
} }
``` ```
The following snippet was generated with this 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} /pre/
```
````
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. 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
import { clsx, type ClassValue } 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" {10-14}
---
import { cn } from '@lib/utils'
const {
href,
external,
class: className,
underline,
'data-heading': dataHeading,
...rest
} = Astro.props
---
<a
href={href}
target={external ? '_blank' : '_self'}
class={cn(
'inline-block transition-colors duration-300 ease-in-out',
underline && 'underline underline-offset-[3px]',
className,
)}
data-heading={dataHeading}
{...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 named `1200x630.png` (the correct dimensions for an image in a Twitter card). 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'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 a post author via frontmatter. If this post author's slug 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).
### Foregoing some slop ### 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. - 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.

View file

@ -1,4 +1,4 @@
import { defineCollection, reference, z } from 'astro:content' import { defineCollection, z } from 'astro:content'
const blog = defineCollection({ const blog = defineCollection({
type: 'content', type: 'content',
@ -8,9 +8,8 @@ const blog = defineCollection({
date: z.coerce.date(), date: z.coerce.date(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
image: z.string().optional(), image: z.string().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
author: z.union([reference('authors'), z.string()]).optional(), author: z.array(z.string()).optional(),
}), }),
}) })

View file

@ -1,3 +1,4 @@
import { getEntry } from 'astro:content'
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
@ -19,3 +20,27 @@ export function readingTime(html: string) {
const readingTimeMinutes = (wordCount / 200 + 1).toFixed() const readingTimeMinutes = (wordCount / 200 + 1).toFixed()
return `${readingTimeMinutes} min read` return `${readingTimeMinutes} min read`
} }
export async function parseAuthors(authors: string[]) {
if (!authors || authors.length === 0) return []
const parseAuthor = async (slug: string) => {
try {
const author = await getEntry('authors', slug)
return {
name: author?.data?.name || slug,
avatar: author?.data?.avatar || '/512x512.png',
isRegistered: !!author,
}
} catch (error) {
console.error(`Error fetching author with slug ${slug}:`, error)
return {
name: slug,
avatar: '/512x512.png',
isRegistered: false,
}
}
}
return await Promise.all(authors.map(parseAuthor))
}

View file

@ -6,7 +6,7 @@ import { formatDate, readingTime } from '@lib/utils'
import PostNavigation from '@components/PostNavigation.astro' import PostNavigation from '@components/PostNavigation.astro'
import TableOfContents from '@components/TableOfContents.astro' import TableOfContents from '@components/TableOfContents.astro'
import { Image } from 'astro:assets' import { Image } from 'astro:assets'
import { Badge, badgeVariants } from '@/components/ui/badge' import { badgeVariants } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Breadcrumb, Breadcrumb,
@ -16,10 +16,10 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb' } from '@/components/ui/breadcrumb'
import { Hash, HomeIcon } from 'lucide-react' import { Hash, HomeIcon, ArrowUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import Link from '@components/Link.astro' import Link from '@components/Link.astro'
import { parseAuthors } from '@lib/utils'
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = (await getCollection('blog')) const posts = (await getCollection('blog'))
@ -59,21 +59,7 @@ const prevPost = getPrevPost(currentPostSlug)
const post = Astro.props const post = Astro.props
const { Content, headings } = await post.render() const { Content, headings } = await post.render()
let author = null const authors = await parseAuthors(post.data.author ?? [])
if (
post.data.author &&
typeof post.data.author === 'object' &&
'collection' in post.data.author
) {
author = await getEntry(post.data.author)
} else if (typeof post.data.author === 'string') {
author = {
data: {
name: post.data.author,
avatar: '/favicons/android-chrome-512x512.png',
},
}
}
--- ---
<Layout title={post.data.title} description={post.data.description}> <Layout title={post.data.title} description={post.data.description}>
@ -114,40 +100,45 @@ if (
</h1> </h1>
<div <div
class="flex items-center justify-center gap-3 text-sm text-muted-foreground" class="flex flex-wrap items-center justify-center gap-2 text-sm text-muted-foreground"
> >
{ {
author && ( authors.length > 0 && (
<> <>
<div class="flex items-center gap-2"> <div class="flex items-center gap-x-2">
<Image {authors.map((author) => (
src={author.data.avatar} <div class="flex items-center gap-x-1.5">
alt={author.data.name} <Image
width={24} src={author.avatar}
height={24} alt={author.name}
class="rounded-full" width={24}
/> height={24}
{typeof post.data.author === 'object' && class="rounded-full"
'collection' in post.data.author ? ( />
<a {author.isRegistered ? (
href={`/authors/${post.data.author.slug}`} <Link
class="hover:text-primary" href={`/authors/${author.name}`}
> underline
{author.data.name} class="text-foreground"
</a> >
) : ( <span>{author.name}</span>
<span>{author.data.name}</span> </Link>
)} ) : (
<span>{author.name}</span>
)}
</div>
))}
</div> </div>
<Separator orientation="vertical" className="h-4" /> <Separator orientation="vertical" className="h-4" />
</> </>
) )
} }
<span>{formatDate(post.data.date)}</span> <div class="flex items-center gap-2">
<Separator orientation="vertical" className="h-4" /> <span>{formatDate(post.data.date)}</span>
<span>{readingTime(post.body)}</span> <Separator orientation="vertical" className="h-4" />
<span>{readingTime(post.body)}</span>
</div>
</div> </div>
<div class="mt-4 flex flex-wrap justify-center gap-2"> <div class="mt-4 flex flex-wrap justify-center gap-2">
{ {
post.data.tags && post.data.tags.length > 0 ? ( post.data.tags && post.data.tags.length > 0 ? (
@ -177,4 +168,32 @@ if (
<PostNavigation prevPost={prevPost} nextPost={nextPost} /> <PostNavigation prevPost={prevPost} nextPost={nextPost} />
</Container> </Container>
<Button
variant="secondary"
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"
>
<ArrowUp
className="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')
if (scrollToTopButton) {
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
})
window.addEventListener('scroll', () => {
scrollToTopButton.classList.toggle('hidden', window.scrollY <= 300)
})
}
})
</script>
</Layout> </Layout>

View file

@ -3,6 +3,7 @@ import { type CollectionEntry, getCollection } from 'astro:content'
import Layout from '@layouts/Layout.astro' import Layout from '@layouts/Layout.astro'
import Container from '@components/Container.astro' import Container from '@components/Container.astro'
import BlogCard from '@components/BlogCard.astro' import BlogCard from '@components/BlogCard.astro'
import { Hash } from 'lucide-react'
type BlogPost = CollectionEntry<'blog'> type BlogPost = CollectionEntry<'blog'>
@ -38,8 +39,10 @@ export async function getStaticPaths() {
<div class="space-y-10"> <div class="space-y-10">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h1 class="text-3xl font-semibold">Posts tagged with</h1> <h1 class="text-3xl font-semibold">Posts tagged with</h1>
<span class="rounded-full bg-secondary px-4 py-2 text-2xl font-medium"> <span
{tag} class="flex items-center gap-x-1 rounded-full bg-secondary px-4 py-2 text-2xl font-medium"
>
<Hash className="size-6" />{tag}
</span> </span>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">

View file

@ -138,6 +138,10 @@
} }
article :not(pre) > code { article :not(pre) > code {
@apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium; @apply relative rounded bg-muted/50 px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium;
}
article {
@apply prose-headings:scroll-mt-20;
} }
} }