Leaflet Blog in Deno Fresh

fixy

+199 -127
-2
.github/workflows/deploy.yml
··· 32 32 project: "roscoerubin-blog-23" 33 33 entrypoint: "main.ts" 34 34 root: "." 35 - 36 -
+18 -17
components/bsky-comments/Comment.tsx
··· 1 - import { AppBskyFeedDefs, AppBskyFeedPost } from 'npm:@atproto/api'; 1 + import { AppBskyFeedDefs, AppBskyFeedPost } from "npm:@atproto/api"; 2 2 3 3 type CommentProps = { 4 4 comment: AppBskyFeedDefs.ThreadViewPost; ··· 12 12 if (!AppBskyFeedPost.isRecord(comment.post.record)) return null; 13 13 // filter out replies that match any of the commentFilters, by ensuring they all return false 14 14 if (filters && !filters.every((filter) => !filter(comment))) return null; 15 - 16 15 17 16 const styles = ` 18 17 .container { ··· 173 172 target="_blank" 174 173 rel="noreferrer noopener" 175 174 > 176 - {author.avatar ? ( 177 - <img 178 - src={comment.post.author.avatar} 179 - alt="avatar" 180 - className={avatarClassName} 181 - /> 182 - ) : ( 183 - <div className={avatarClassName} /> 184 - )} 175 + {author.avatar 176 + ? ( 177 + <img 178 + src={comment.post.author.avatar} 179 + alt="avatar" 180 + className={avatarClassName} 181 + /> 182 + ) 183 + : <div className={avatarClassName} />} 185 184 <p className="authorName"> 186 - {author.displayName ?? author.handle}{' '} 185 + {author.displayName ?? author.handle}{" "} 187 186 <span className="handle">@{author.handle}</span> 188 187 </p> 189 188 </a> 190 189 <a 191 - href={`https://bsky.app/profile/${author.did}/post/${comment.post.uri 192 - .split('/') 193 - .pop()}`} 190 + href={`https://bsky.app/profile/${author.did}/post/${ 191 + comment.post.uri 192 + .split("/") 193 + .pop() 194 + }`} 194 195 target="_blank" 195 196 rel="noreferrer noopener" 196 197 > ··· 272 273 if ( 273 274 !AppBskyFeedDefs.isThreadViewPost(a) || 274 275 !AppBskyFeedDefs.isThreadViewPost(b) || 275 - !('post' in a) || 276 - !('post' in b) 276 + !("post" in a) || 277 + !("post" in b) 277 278 ) { 278 279 return 0; 279 280 }
+26 -18
components/bsky-comments/CommentFilters.tsx
··· 1 - import { AppBskyFeedPost, type AppBskyFeedDefs } from 'npm:@atproto/api'; 1 + import { type AppBskyFeedDefs, AppBskyFeedPost } from "npm:@atproto/api"; 2 2 3 3 const MinLikeCountFilter = ( 4 - min: number 5 - ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 4 + min: number, 5 + ): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => { 6 6 return (comment: AppBskyFeedDefs.ThreadViewPost) => { 7 7 return (comment.post.likeCount ?? 0) < min; 8 8 }; 9 9 }; 10 10 11 11 const MinCharacterCountFilter = ( 12 - min: number 13 - ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 12 + min: number, 13 + ): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => { 14 14 return (comment: AppBskyFeedDefs.ThreadViewPost) => { 15 15 if (!AppBskyFeedPost.isRecord(comment.post.record)) { 16 16 return false; ··· 21 21 }; 22 22 23 23 const TextContainsFilter = ( 24 - text: string 25 - ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 24 + text: string, 25 + ): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => { 26 26 return (comment: AppBskyFeedDefs.ThreadViewPost) => { 27 27 if (!AppBskyFeedPost.isRecord(comment.post.record)) { 28 28 return false; ··· 33 33 }; 34 34 35 35 const ExactMatchFilter = ( 36 - text: string 37 - ): ((comment: AppBskyFeedDefs.ThreadViewPost) => boolean) => { 36 + text: string, 37 + ): (comment: AppBskyFeedDefs.ThreadViewPost) => boolean => { 38 38 return (comment: AppBskyFeedDefs.ThreadViewPost) => { 39 39 if (!AppBskyFeedPost.isRecord(comment.post.record)) { 40 40 return false; ··· 44 44 }; 45 45 }; 46 46 47 - /* 48 - * This function allows you to filter out comments based on likes, 49 - * characters, text, pins, or exact matches. 50 - */ 47 + /* 48 + * This function allows you to filter out comments based on likes, 49 + * characters, text, pins, or exact matches. 50 + */ 51 51 export const Filters: { 52 - MinLikeCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 53 - MinCharacterCountFilter: (min: number) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 54 - TextContainsFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 55 - ExactMatchFilter: (text: string) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 52 + MinLikeCountFilter: ( 53 + min: number, 54 + ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 55 + MinCharacterCountFilter: ( 56 + min: number, 57 + ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 58 + TextContainsFilter: ( 59 + text: string, 60 + ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 61 + ExactMatchFilter: ( 62 + text: string, 63 + ) => (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 56 64 NoLikes: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 57 65 NoPins: (comment: AppBskyFeedDefs.ThreadViewPost) => boolean; 58 66 } = { ··· 61 69 TextContainsFilter, 62 70 ExactMatchFilter, 63 71 NoLikes: MinLikeCountFilter(0), 64 - NoPins: ExactMatchFilter('📌'), 72 + NoPins: ExactMatchFilter("📌"), 65 73 }; 66 74 67 75 export default Filters;
+2 -2
components/bsky-comments/PostSummary.tsx
··· 1 - import { AppBskyFeedDefs } from 'npm:@atproto/api'; 1 + import { AppBskyFeedDefs } from "npm:@atproto/api"; 2 2 3 3 type PostSummaryProps = { 4 4 postUrl: string; ··· 216 216 </a> 217 217 <h2 className="commentsTitle">Comments</h2> 218 218 <p className="replyText"> 219 - Join the conversation by{' '} 219 + Join the conversation by{" "} 220 220 <a 221 221 className="link" 222 222 href={postUrl}
+10 -3
components/footer.tsx
··· 1 - import { siBluesky as BlueskyIcon, siGithub as GithubIcon } from "npm:simple-icons"; 1 + import { 2 + siBluesky as BlueskyIcon, 3 + siGithub as GithubIcon, 4 + } from "npm:simple-icons"; 2 5 import { useState } from "preact/hooks"; 3 6 import { env } from "../lib/env.ts"; 4 7 ··· 25 28 > 26 29 <path d={BlueskyIcon.path} /> 27 30 </svg> 28 - <span class="opacity-50 group-hover:opacity-100 transition-opacity">Bluesky</span> 31 + <span class="opacity-50 group-hover:opacity-100 transition-opacity"> 32 + Bluesky 33 + </span> 29 34 <div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" /> 30 35 </a> 31 36 <a ··· 45 50 > 46 51 <path d={GithubIcon.path} /> 47 52 </svg> 48 - <span class="opacity-50 group-hover:opacity-100 transition-opacity">GitHub</span> 53 + <span class="opacity-50 group-hover:opacity-100 transition-opacity"> 54 + GitHub 55 + </span> 49 56 <div class="absolute bottom-0 left-0 w-full h-px bg-current scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-in-out group-hover:origin-left group-data-[hovered=false]:origin-right" /> 50 57 </a> 51 58 </footer>
+7 -5
components/post-info.tsx
··· 31 31 children?: ComponentChildren; 32 32 }) { 33 33 const readingTime = getReadingTime(content); 34 - 34 + 35 35 return ( 36 36 <Paragraph className={className}> 37 37 {includeAuthor && ( ··· 47 47 )} 48 48 {createdAt && ( 49 49 <> 50 - <time dateTime={createdAt}>{date(new Date(createdAt))}</time> 51 - {" "}&middot;{" "} 50 + <time dateTime={createdAt}>{date(new Date(createdAt))}</time>{" "} 51 + &middot;{" "} 52 52 </> 53 53 )} 54 - <span > 55 - <span style={{ lineHeight: 1, marginRight: '0.25rem' }}>{readingTime} min read</span> 54 + <span> 55 + <span style={{ lineHeight: 1, marginRight: "0.25rem" }}> 56 + {readingTime} min read 57 + </span> 56 58 </span> 57 59 {children} 58 60 </Paragraph>
+9 -2
components/typography.tsx
··· 48 48 className, 49 49 ...props 50 50 }: h.JSX.HTMLAttributes<HTMLParagraphElement>) { 51 - return <p className={cx("font-sans text-pretty", className?.toString())} {...props} />; 51 + return ( 52 + <p 53 + className={cx("font-sans text-pretty", className?.toString())} 54 + {...props} 55 + /> 56 + ); 52 57 } 53 58 54 - export function Code({ className, ...props }: h.JSX.HTMLAttributes<HTMLElement>) { 59 + export function Code( 60 + { className, ...props }: h.JSX.HTMLAttributes<HTMLElement>, 61 + ) { 55 62 return ( 56 63 <code 57 64 className={cx(
+3
deno.json
··· 22 22 ], 23 23 "imports": { 24 24 "$fresh/": "https://deno.land/x/fresh@1.7.3/", 25 + "@atcute/atproto": "npm:@atcute/atproto@^3.0.1", 26 + "@atcute/client": "npm:@atcute/client@^4.0.1", 25 27 "@atcute/whitewind": "npm:@atcute/whitewind@^3.0.1", 26 28 "@deno/gfm": "jsr:@deno/gfm@^0.10.0", 27 29 "@preact-icons/cg": "jsr:@preact-icons/cg@^1.0.13", ··· 31 33 "preact/": "https://esm.sh/preact@10.22.0/", 32 34 "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", 33 35 "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", 36 + "rss": "npm:rss@^1.2.2", 34 37 "tailwindcss": "npm:tailwindcss@3.4.1", 35 38 "tailwindcss/": "npm:/tailwindcss@3.4.1/", 36 39 "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
+66 -40
islands/CommentSection.tsx
··· 1 - import { useState, useEffect } from 'preact/hooks'; 2 - import { AppBskyFeedDefs, type AppBskyFeedGetPostThread } from 'npm:@atproto/api'; 3 - import { CommentOptions } from '../components/bsky-comments/types.tsx'; 4 - import { PostSummary } from '../components/bsky-comments/PostSummary.tsx'; 5 - import { Comment } from '../components/bsky-comments/Comment.tsx'; 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { 3 + AppBskyFeedDefs, 4 + type AppBskyFeedGetPostThread, 5 + } from "npm:@atproto/api"; 6 + import { CommentOptions } from "../components/bsky-comments/types.tsx"; 7 + import { PostSummary } from "../components/bsky-comments/PostSummary.tsx"; 8 + import { Comment } from "../components/bsky-comments/Comment.tsx"; 6 9 7 10 const getAtUri = (uri: string): string => { 8 - if (!uri.startsWith('at://') && uri.includes('bsky.app/profile/')) { 11 + if (!uri.startsWith("at://") && uri.includes("bsky.app/profile/")) { 9 12 const match = uri.match(/profile\/([\w:.]+)\/post\/([\w]+)/); 10 13 if (match) { 11 14 const [, did, postId] = match; ··· 16 19 }; 17 20 18 21 /** 19 - * This component displays a comment section for a post. 20 - * It fetches the comments for a post and displays them in a threaded format. 21 - */ 22 + * This component displays a comment section for a post. 23 + * It fetches the comments for a post and displays them in a threaded format. 24 + */ 22 25 export const CommentSection = ({ 23 26 uri: propUri, 24 27 author, ··· 26 29 commentFilters, 27 30 }: CommentOptions): any => { 28 31 const [uri, setUri] = useState<string | null>(null); 29 - const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(null); 32 + const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>( 33 + null, 34 + ); 30 35 const [error, setError] = useState<string | null>(null); 31 36 const [visibleCount, setVisibleCount] = useState(5); 32 37 ··· 223 228 224 229 useEffect(() => { 225 230 let isSubscribed = true; 226 - 231 + 227 232 const initializeUri = async () => { 228 233 if (propUri) { 229 234 setUri(propUri); ··· 233 238 if (author) { 234 239 try { 235 240 const currentUrl = window.location.href; 236 - const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent( 237 - currentUrl 238 - )}&author=${author}&sort=top`; 239 - 241 + const apiUrl = 242 + `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${ 243 + encodeURIComponent( 244 + currentUrl, 245 + ) 246 + }&author=${author}&sort=top`; 247 + 240 248 const response = await fetch(apiUrl); 241 249 const data = await response.json(); 242 250 ··· 245 253 const post = data.posts[0]; 246 254 setUri(post.uri); 247 255 } else { 248 - setError('No matching post found'); 256 + setError("No matching post found"); 249 257 if (onEmpty) { 250 - onEmpty({ code: 'not_found', message: 'No matching post found' }); 258 + onEmpty({ 259 + code: "not_found", 260 + message: "No matching post found", 261 + }); 251 262 } 252 263 } 253 264 } 254 265 } catch (err) { 255 266 if (isSubscribed) { 256 - setError('Error fetching post'); 267 + setError("Error fetching post"); 257 268 if (onEmpty) { 258 - onEmpty({ code: 'fetching_error', message: 'Error fetching post' }); 269 + onEmpty({ 270 + code: "fetching_error", 271 + message: "Error fetching post", 272 + }); 259 273 } 260 274 } 261 275 } ··· 274 288 275 289 const fetchThreadData = async () => { 276 290 if (!uri) return; 277 - 291 + 278 292 try { 279 293 const thread = await getPostThread(uri); 280 294 if (isSubscribed) { ··· 282 296 } 283 297 } catch (err) { 284 298 if (isSubscribed) { 285 - setError('Error loading comments'); 299 + setError("Error loading comments"); 286 300 if (onEmpty) { 287 301 onEmpty({ 288 - code: 'comment_loading_error', 289 - message: 'Error loading comments', 302 + code: "comment_loading_error", 303 + message: "Error loading comments", 290 304 }); 291 305 } 292 306 } ··· 307 321 if (!uri) return null; 308 322 309 323 if (error) { 310 - return <div className="container"><style>{styles}</style><p className="errorText">{error}</p></div>; 324 + return ( 325 + <div className="container"> 326 + <style>{styles}</style> 327 + <p className="errorText">{error}</p> 328 + </div> 329 + ); 311 330 } 312 331 313 332 if (!thread) { 314 - return <div className="container"><style>{styles}</style><p className="loadingText">Loading comments...</p></div>; 333 + return ( 334 + <div className="container"> 335 + <style>{styles}</style> 336 + <p className="loadingText">Loading comments...</p> 337 + </div> 338 + ); 315 339 } 316 340 317 341 let postUrl: string = uri; 318 - if (uri.startsWith('at://')) { 319 - const [, , did, _, rkey] = uri.split('/'); 342 + if (uri.startsWith("at://")) { 343 + const [, , did, _, rkey] = uri.split("/"); 320 344 postUrl = `https://bsky.app/profile/${did}/post/${rkey}`; 321 345 } 322 346 ··· 328 352 </div> 329 353 ); 330 354 } 331 - 355 + 332 356 // Safe sort - ensure we're working with valid objects 333 - const sortedReplies = [...thread.replies].filter(reply => 357 + const sortedReplies = [...thread.replies].filter((reply) => 334 358 AppBskyFeedDefs.isThreadViewPost(reply) 335 359 ).sort(sortByLikes); 336 360 ··· 360 384 ); 361 385 }; 362 386 363 - const getPostThread = async (uri: string): Promise<AppBskyFeedDefs.ThreadViewPost> => { 387 + const getPostThread = async ( 388 + uri: string, 389 + ): Promise<AppBskyFeedDefs.ThreadViewPost> => { 364 390 const atUri = getAtUri(uri); 365 391 const params = new URLSearchParams({ uri: atUri }); 366 392 367 393 const res = await fetch( 368 - 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?' + 394 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?" + 369 395 params.toString(), 370 396 { 371 - method: 'GET', 397 + method: "GET", 372 398 headers: { 373 - Accept: 'application/json', 399 + Accept: "application/json", 374 400 }, 375 - cache: 'no-store', 376 - } 401 + cache: "no-store", 402 + }, 377 403 ); 378 404 379 405 if (!res.ok) { 380 406 console.error(await res.text()); 381 - throw new Error('Failed to fetch post thread'); 407 + throw new Error("Failed to fetch post thread"); 382 408 } 383 409 384 410 const data = (await res.json()) as AppBskyFeedGetPostThread.OutputSchema; 385 411 386 412 if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) { 387 - throw new Error('Could not find thread'); 413 + throw new Error("Could not find thread"); 388 414 } 389 415 390 416 return data.thread; ··· 394 420 if ( 395 421 !AppBskyFeedDefs.isThreadViewPost(a) || 396 422 !AppBskyFeedDefs.isThreadViewPost(b) || 397 - !('post' in a) || 398 - !('post' in b) 423 + !("post" in a) || 424 + !("post" in b) 399 425 ) { 400 426 return 0; 401 427 } 402 428 const aPost = a as AppBskyFeedDefs.ThreadViewPost; 403 429 const bPost = b as AppBskyFeedDefs.ThreadViewPost; 404 430 return (bPost.post.likeCount ?? 0) - (aPost.post.likeCount ?? 0); 405 - }; 431 + };
+3 -3
islands/layout.tsx
··· 42 42 </a> 43 43 <div class="h-4 w-px bg-slate-200 dark:bg-slate-700"></div> 44 44 <div class="text-base flex items-center gap-7"> 45 - <a 46 - href="/" 47 - class="relative group" 45 + <a 46 + href="/" 47 + class="relative group" 48 48 data-current={isActive("/")} 49 49 data-hovered={blogHovered} 50 50 onMouseEnter={() => setBlogHovered(true)}
+3 -1
islands/post-list.tsx
··· 7 7 uri: string; 8 8 } 9 9 10 - export default function PostList({ posts: initialPosts }: { posts: PostRecord[] }) { 10 + export default function PostList( 11 + { posts: initialPosts }: { posts: PostRecord[] }, 12 + ) { 11 13 const posts = useSignal(initialPosts); 12 14 13 15 useEffect(() => {
+9 -3
lib/api.ts
··· 1 1 import { bsky } from "./bsky.ts"; 2 2 import { env } from "./env.ts"; 3 3 4 - import { type ComAtprotoRepoListRecords } from "npm:@atcute/client/lexicons"; 4 + import { type ActorIdentifier } from "npm:@atcute/lexicons"; 5 5 import { type ComWhtwndBlogEntry } from "@atcute/whitewind"; 6 + import { type ComAtprotoRepoListRecords } from "npm:@atcute/atproto"; 6 7 7 8 export async function getPosts() { 8 9 const posts = await bsky.get("com.atproto.repo.listRecords", { 9 10 params: { 10 - repo: env.NEXT_PUBLIC_BSKY_DID, 11 + repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier, 11 12 collection: "com.whtwnd.blog.entry", 12 13 // todo: pagination 13 14 }, 14 15 }); 16 + 17 + if ('error' in posts.data) { 18 + throw new Error(posts.data.error); 19 + } 20 + 15 21 return posts.data.records.filter( 16 22 drafts, 17 23 ) as (ComAtprotoRepoListRecords.Record & { ··· 28 34 export async function getPost(rkey: string) { 29 35 const post = await bsky.get("com.atproto.repo.getRecord", { 30 36 params: { 31 - repo: env.NEXT_PUBLIC_BSKY_DID, 37 + repo: env.NEXT_PUBLIC_BSKY_DID as ActorIdentifier, 32 38 rkey: rkey, 33 39 collection: "com.whtwnd.blog.entry", 34 40 },
+4 -5
lib/bsky.ts
··· 1 - import { CredentialManager, XRPC } from "npm:@atcute/client"; 1 + import { Client, simpleFetchHandler } from "@atcute/client"; 2 2 3 3 import { env } from "./env.ts"; 4 4 5 - const handler = new CredentialManager({ 6 - service: env.NEXT_PUBLIC_BSKY_PDS, 7 - fetch, 5 + const handler = simpleFetchHandler({ 6 + service: env.NEXT_PUBLIC_BSKY_PDS 8 7 }); 9 - export const bsky = new XRPC({ handler }); 8 + export const bsky = new Client({ handler });
+4 -1
routes/post/[slug].tsx
··· 111 111 <> 112 112 <Head> 113 113 <title>{post.value.title} — knotbin</title> 114 - <meta name="description" content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"} /> 114 + <meta 115 + name="description" 116 + content={post.value.subtitle || "by Roscoe Rubin-Rottenberg"} 117 + /> 115 118 {/* Merge GFM's default styles with our dark-mode overrides */} 116 119 <style 117 120 dangerouslySetInnerHTML={{ __html: CSS + transparentDarkModeCSS }}
+13 -13
routes/rss.ts
··· 21 21 }); 22 22 23 23 for (const post of posts) { 24 - const description = post.value.subtitle 24 + const description = post.value.subtitle 25 25 ? `${post.value.subtitle}\n\n${await unified() 26 - .use(remarkParse) 27 - .use(remarkRehype) 28 - .use(rehypeFormat) 29 - .use(rehypeStringify) 30 - .process(post.value.content) 31 - .then((v) => v.toString())}` 26 + .use(remarkParse) 27 + .use(remarkRehype) 28 + .use(rehypeFormat) 29 + .use(rehypeStringify) 30 + .process(post.value.content) 31 + .then((v) => v.toString())}` 32 32 : await unified() 33 - .use(remarkParse) 34 - .use(remarkRehype) 35 - .use(rehypeFormat) 36 - .use(rehypeStringify) 37 - .process(post.value.content) 38 - .then((v) => v.toString()); 33 + .use(remarkParse) 34 + .use(remarkRehype) 35 + .use(rehypeFormat) 36 + .use(rehypeStringify) 37 + .process(post.value.content) 38 + .then((v) => v.toString()); 39 39 40 40 rss.item({ 41 41 title: post.value.title ?? "Untitled",
+22 -12
static/styles.css
··· 1 - @import url('https://api.fonts.coollabs.io/css2?family=Inter:wght@400;700&display=swap'); 2 - @import url('https://api.fonts.coollabs.io/css2?family=Libre+Bodoni:ital,wght@0,400;0,700;1,400&display=swap'); 1 + @import url("https://api.fonts.coollabs.io/css2?family=Inter:wght@400;700&display=swap"); 2 + @import url("https://api.fonts.coollabs.io/css2?family=Libre+Bodoni:ital,wght@0,400;0,700;1,400&display=swap"); 3 3 @font-face { 4 - font-family: 'Berkeley Mono'; 5 - src: url('/path/to/local/fonts/BerkeleyMono-Regular.woff2') format('woff2'), 6 - url('/path/to/local/fonts/BerkeleyMono-Regular.woff') format('woff'); 4 + font-family: "Berkeley Mono"; 5 + src: 6 + url("/path/to/local/fonts/BerkeleyMono-Regular.woff2") format("woff2"), 7 + url("/path/to/local/fonts/BerkeleyMono-Regular.woff") format("woff"); 7 8 font-weight: 400; 8 9 font-style: normal; 9 10 } ··· 18 19 } 19 20 20 21 :root { 21 - --font-sans: 'Inter', sans-serif; 22 - --font-serif: 'Libre Bodoni', serif; 23 - --font-mono: 'Berkeley Mono', monospace; 22 + --font-sans: "Inter", sans-serif; 23 + --font-serif: "Libre Bodoni", serif; 24 + --font-mono: "Berkeley Mono", monospace; 24 25 } 25 26 26 - .font-sans { font-family: var(--font-sans); } 27 - .font-serif { font-family: var(--font-serif); } 28 - .font-mono { font-family: var(--font-mono); } 29 - .font-serif-italic { font-family: var(--font-serif); font-style: italic;} 27 + .font-sans { 28 + font-family: var(--font-sans); 29 + } 30 + .font-serif { 31 + font-family: var(--font-serif); 32 + } 33 + .font-mono { 34 + font-family: var(--font-mono); 35 + } 36 + .font-serif-italic { 37 + font-family: var(--font-serif); 38 + font-style: italic; 39 + } 30 40 31 41 /* 32 42 The default border color has changed to `currentColor` in Tailwind CSS v4,