mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-14 11:29:33 +08:00
feat!: AstroPaper v6 (#631)
* feat: add initial configuration and social icons for AstroPaper theme * feat: add theme styles and design tokens * chore: configure Astro with sitemap, i18n, fonts, and tooling - Set site URL, i18n (single locale, no prefix), sitemap integration - Add Google Sans Code font via stable fonts API - Configure Shiki dual-theme markdown (min-light / night-owl) - Fix astro-paper.config.ts to use relative import (alias not available when Vite loads root-level config files) - Add @astrojs/sitemap and @typescript-eslint/parser dependencies - Wire @typescript-eslint/parser into ESLint so TypeScript syntax in Astro frontmatter (type Props, etc.) parses correctly * feat: add base styles, dark variant, and typography - global.css: dark variant via [data-theme], base layer for * / a / button selectors, scrollbar tokens, layout utilities (max-w-app, app-layout), active-nav as @utility for Tailwind variant support - html and body classes moved directly onto elements in BaseLayout (Tailwind-native approach) so only truly global selectors remain here - typography.css: prose overrides, Shiki light/dark code block switching, diff/highlight/word-highlight transformer styles * feat: add i18n types, English strings, and useTranslations helper Plain typed object approach — no third-party i18n library needed. Paraglide.js was considered but requires SSR output (hard blocker for a static Astro site). - UIStrings interface with nav, post, pagination, and a11y groups - en.ts with `satisfies UIStrings` for literal-type preservation - useTranslations(locale) helper falls back to English automatically Adding a new language is a one-file addition; full routing stays as a registry add-on. * feat: add client-side theme toggle script - Reads window.__theme.value set by the inline FOUC-prevention script to avoid re-detecting the theme on first load - Toggles data-theme on <html> and persists to localStorage - Fills <meta name="theme-color"> with the computed background colour so Android's browser chrome matches the page - Re-runs setup on astro:after-swap for View Transitions navigation - Carries theme-color across astro:before-swap to prevent navbar flicker on Android during page transitions - Syncs with OS prefers-color-scheme changes at runtime * feat: add BaseLayout and PostLayout BaseLayout (replaces scaffold Layout.astro): - Font, canonical URL via Astro.site, RSS autodiscovery - FOUC-prevention inline script sets data-theme before first paint and exposes window.__theme.value for theme.ts to reuse - html/body classes on elements directly (Tailwind-native) - <slot name="head"> for child layout injection - ClientRouter for View Transitions (respects lightAndDarkMode flag) - googleVerification rendered from config instead of .env — it is a public string, not a secret, and not environment-specific PostLayout (new): - Wraps BaseLayout; injects article-specific head content via slot - Overrides og:type to "article" - Adds article:published_time / article:modified_time conditionally - JSON-LD BlogPosting with conditional datePublished/dateModified (fixes old bug where absent dates produced "undefined" string) - Structured data only on actual post pages (not home/archives/tags) SiteConfig: add optional googleVerification field * chore: replace scaffold index page with BaseLayout placeholder * feat: add Header component * feat: add Footer and Socials component * chore: install dayjs, slugify, zod, @astrojs/mdx and add MDX integration * feat: add resolved config that applies defaults in one place Defaults for posts, features, socials, and shareLinks are merged in src/config.ts so all consuming code gets non-optional types with no scattered ?? fallbacks. * feat: add content schema, blog path, and post utilities Renames collection blog→posts, moves content to src/content/posts/, adds .mdx support, and adds a typed pages collection. Includes getSortedPosts, postFilter, getPath, and slugify utilities. * feat: add Datetime and Card components * refactor: switch layouts and components to resolved config Replaces @/astro-paper.config imports with @/config and removes optional chaining on feature flags now that the resolved config guarantees non-optional values. * feat: add home and footer i18n strings * feat: implement home page with hero, featured, and recent posts Replaces the BaseLayout placeholder with the full index page: hero section with RSS link and social links, featured posts section, recent posts capped at posts.perIndex, and an All Posts link. All section labels come from i18n. * feat: add pagination component * feat: add Breadcrumb component with locale support * feat: add Main component * refactor: update code formatting in Card.astro * feat: add posts page * fix: add missing @astrojs/mdx dependency * refactor: rename meta to pages in i18n * refactor: improve code formatting in Breadcrumb * refactor: destructure props in Main component for clarity * feat: add tag pages * feat: add About page * chore: update i18n configuration to use site language from config * feat: add Archives page * feat: integrate Pagefind search functionality * feat: add dynamic Open Graph image generation for posts * feat: add post detail page * feat: add Shiki transformers * chore: remove unused edit link text * chore: update .gitignore to include generated Pagefind directory * chore: update site URL in configuration to production link * chore: replace favicon and remove unused SVG assets, add new Open Graph image * feat: update site config with default values * feat: add default OG image path resolution * fix: ensure edit post feature checks for enabled status correctly * feat: implement dynamic Open Graph image generation * feat: add RSS feed generation functionality * feat: add 404 Not Found page * feat: add robots.txt generation for SEO optimization * refactor: change getSortedPosts to a named export * refactor: change postFilter to a named export * refactor: rename BaseLayout to Layout * refactor: standardize locale handling across components * refactor: update getPostsByGroupCondition location * chore: remove unused Welcome component * refactor(i18n): update links using getRelativeLocaleUrl * refactor(i18n): add dynamic locale loading * fix: update language attribute to use current locale * fix: correct wording for updatedAt in post translations * fix: update layout titles to use correct translation keys for tags pages * chore: use new colors for better DX and a11y * feat: add underline icon for active icon nav * feat: add toc for markdown contents * refactor: remove unused feature flags in config * refactor(i18n): enhance locale handling and path management * fix: ts unused variable hint * refactor: extract locale stripping logic into a util func * refactor(i18n): improve locale handling in tag pages * refactor: update post URL helper functions * refactor(i18n): replace pathWithBase with getRelativeLocaleUrl for locale handling * refactor: rename fileWithBase to getAssetPath * refactor: use runtime agnostic APIs for file paths * refactor: use transition directive for heading transition * refactor: remove unused title element from tag page * fix: decouple ClientRouter from lightAndDarkMode flag * refactor: rename `postPaths.ts` to `getPostPaths.ts` * refactor: remove unused `minutesRead` string from post translations * refactor: simplify AdjacentPostNav component by passing prevPost and nextPost directly * refactor: remove unused code declaration * docs: add existing blog posts * chore: update astro and @astrojs/check dependencies to latest versions * build: use experimental svg optimizer * chore(repo): restore project meta files and Docker configs * refactor: update code formatting with Prettier * docs: update README and docs * docs: add JSDoc comments to utility functions * docs: add JSDoc comments to resolved config file * fix(og): avoid double slashes when appending index.png * chore(ci): update Node.js version to 22.12.0 in CI workflow * feat: improve og image generation with Astro's getFontFileURL API (#632) * feat: improve og image generation with Astro's getFontFileURL API - remove @resvg/resvg-js and use sharp to convert SVG to PNG - update og image generation to use Astro's getFontFileURL API for better font handling * docs: update README and dynamic OG images documentation - remove outdated content and clarify dynamic OG image generation process in README. - add new image example and update instructions for handling non-Latin characters in dynamic OG images. - adjust copyright year in README to 2026. * fix(header): adjust icon button alignment in mobile * feat(docs): add ResponsiveTable to use it in MDX posts (#634) * feat(docs): add ResponsiveTable to use it in MDX posts Introduce ResponsiveTable with variant-based styling. Rename docs posts to .mdx, wrap tables in the component, and remove redundant table `code` rules from prose typography. * chore(prettier): ignore .mdx files when checking format * chore(ci): update Node.js version to 24 in CI workflow * bump: upgrade Astro and dependencies * fix: remove redundant backUrl script bug * fix(header): adjust button and icon alignment * fix(a11y): improve muted-foreground color contrast * docs: update predefined color schemes * chore: support site verification env as fallback for googleVerification * docs: add astro paper v6 updates in docs * docs: add AstroPaper-v6.png in astro-paper-6 blog post * fix: update back-to-top button DOM placement (#641) Closes #626, #131, #493
This commit is contained in:
+12
-7
@@ -3,10 +3,15 @@ import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import LinkButton from "@/components/LinkButton.astro";
|
||||
import { SITE } from "@/config";
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<Layout title={`404 Not Found | ${SITE.title}`}>
|
||||
<Layout title={`${t.notFound.title} | ${config.site.title}`}>
|
||||
<Header />
|
||||
|
||||
<main
|
||||
@@ -14,14 +19,14 @@ import { SITE } from "@/config";
|
||||
class="app-layout flex flex-1 items-center justify-center"
|
||||
>
|
||||
<div class="mb-14 flex flex-col items-center justify-center">
|
||||
<h1 class="text-9xl font-bold text-accent">404</h1>
|
||||
<span aria-hidden="true">¯\_(ツ)_/¯</span>
|
||||
<p class="mt-4 text-2xl sm:text-3xl">Page Not Found</p>
|
||||
<h1 class="text-accent text-9xl font-bold">404</h1>
|
||||
<span aria-hidden="true"> ¯\_(ツ)_/¯ </span>
|
||||
<p class="mt-4 text-2xl sm:text-3xl">{t.notFound.message}</p>
|
||||
<LinkButton
|
||||
href="/"
|
||||
href={getRelativeLocaleUrl(locale, "")}
|
||||
class="my-6 text-lg underline decoration-dashed underline-offset-8"
|
||||
>
|
||||
Go back home
|
||||
{t.notFound.goHome}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { getEntry, render } from "astro:content";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import config from "@/config";
|
||||
|
||||
const about = await getEntry("pages", "about");
|
||||
|
||||
if (!about) {
|
||||
throw new Error(
|
||||
"Missing content entry: `about.md` or `about.mdx` in `src/content/pages/`"
|
||||
);
|
||||
}
|
||||
|
||||
const { Content } = await render(about);
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`${about.data.title} | ${config.site.title}`}
|
||||
description={about.data.description}
|
||||
ogImage={about.data.ogImage}
|
||||
canonicalURL={about.data.canonicalURL}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main pageTitle={about.data.title} class="app-prose">
|
||||
<Content />
|
||||
</Main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
layout: ../layouts/AboutLayout.astro
|
||||
title: "About"
|
||||
---
|
||||
|
||||
AstroPaper is a minimal, accessible and SEO-friendly blog theme built with [Astro](https://astro.build/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||

|
||||
|
||||
AstroPaper provides a solid foundation for blogs, or even portfolios\_ with full markdown support, built-in dark mode, and a clean layout that works out-of-the-box.
|
||||
|
||||
The blog posts in this theme also serve as guides, docs or example articles\_ making AstroPaper a flexible starting point for your next content-driven site.
|
||||
|
||||
## Features
|
||||
|
||||
AstroPaper comes with a set of useful features that make content publishing easy and effective:
|
||||
|
||||
- SEO-friendly
|
||||
- Fast performance
|
||||
- Light & dark mode
|
||||
- Highly customizable
|
||||
- Organizable blog posts
|
||||
- Responsive & accessible
|
||||
- Static search with [PageFind](https://pagefind.app/)
|
||||
- Automatic social image generation
|
||||
|
||||
and so much more.
|
||||
|
||||
## Show your support
|
||||
|
||||
If you like [AstroPaper](https://github.com/satnaing/astro-paper), consider giving it a star ⭐️.
|
||||
|
||||
Found a bug 🐛 or have an improvement ✨ in mind? Feel free to open an [issue](https://github.com/satnaing/astro-paper/issues), submit a [pull request](https://github.com/satnaing/astro-paper/pulls) or start a [discussion](https://github.com/satnaing/astro-paper/discussions).
|
||||
|
||||
If you find this theme helpful, you can also [sponsor me on GitHub](https://github.com/sponsors/satnaing) or [buy me a coffee](https://buymeacoffee.com/satnaing) to show your support — every penny counts.
|
||||
|
||||
Kyay zuu! 🙏🏼
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
type GroupKey = string | number | symbol;
|
||||
type GroupFunction<T> = (item: T, index?: number) => GroupKey;
|
||||
|
||||
export function getPostsByGroupCondition(
|
||||
posts: CollectionEntry<"posts">[],
|
||||
groupFunction: GroupFunction<CollectionEntry<"posts">>
|
||||
) {
|
||||
const result: Record<GroupKey, CollectionEntry<"posts">[]> = {};
|
||||
|
||||
for (let i = 0; i < posts.length; i++) {
|
||||
const item = posts[i];
|
||||
const groupKey = groupFunction(item, i);
|
||||
|
||||
if (!result[groupKey]) {
|
||||
result[groupKey] = [];
|
||||
}
|
||||
|
||||
result[groupKey].push(item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,42 +1,41 @@
|
||||
---
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import { getCollection } from "astro:content";
|
||||
import Main from "@/layouts/Main.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Card from "@/components/Card.astro";
|
||||
import getPostsByGroupCondition from "@/utils/getPostsByGroupCondition";
|
||||
import { SITE } from "@/config";
|
||||
import { postFilter } from "@/utils/postFilter";
|
||||
import { getPostsByGroupCondition } from "./_utils/getPostsByGroupCondition";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
|
||||
// Redirect to 404 page if `showArchives` config is false
|
||||
if (!SITE.showArchives) {
|
||||
return Astro.redirect("/404");
|
||||
const notFoundUrl = getRelativeLocaleUrl(locale, "404");
|
||||
if (!config.features.showArchives && notFoundUrl) {
|
||||
return Astro.rewrite(notFoundUrl);
|
||||
}
|
||||
|
||||
const posts = await getCollection("blog", ({ data }) => !data.draft);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
const posts = await getCollection("posts");
|
||||
const filteredPosts = posts.filter(postFilter);
|
||||
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "long" });
|
||||
---
|
||||
|
||||
<Layout title={`Archives | ${SITE.title}`}>
|
||||
<Layout title={`${t.pages.archivesTitle} | ${config.site.title}`}>
|
||||
<Header />
|
||||
<Main pageTitle="Archives" pageDesc="All the articles I've archived.">
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main pageTitle={t.pages.archivesTitle} pageDesc={t.pages.archivesDesc}>
|
||||
{
|
||||
Object.entries(
|
||||
getPostsByGroupCondition(posts, post =>
|
||||
getPostsByGroupCondition(filteredPosts, post =>
|
||||
post.data.pubDatetime.getFullYear()
|
||||
)
|
||||
)
|
||||
@@ -44,7 +43,7 @@ const months = [
|
||||
.map(([year, yearGroup]) => (
|
||||
<div>
|
||||
<span class="text-2xl font-bold">{year}</span>
|
||||
<sup class="text-sm">{yearGroup.length}</sup>
|
||||
<sup class="text-muted-foreground text-sm">{yearGroup.length}</sup>
|
||||
{Object.entries(
|
||||
getPostsByGroupCondition(
|
||||
yearGroup,
|
||||
@@ -55,8 +54,14 @@ const months = [
|
||||
.map(([month, monthGroup]) => (
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<div class="mt-6 min-w-36 text-lg sm:my-6">
|
||||
<span class="font-bold">{months[Number(month) - 1]}</span>
|
||||
<sup class="text-xs">{monthGroup.length}</sup>
|
||||
<span class="font-bold">
|
||||
{monthFormatter.format(
|
||||
new Date(2000, Number(month) - 1, 1)
|
||||
)}
|
||||
</span>
|
||||
<sup class="text-muted-foreground text-xs">
|
||||
{monthGroup.length}
|
||||
</sup>
|
||||
</div>
|
||||
<ul>
|
||||
{monthGroup
|
||||
@@ -79,5 +84,6 @@ const months = [
|
||||
))
|
||||
}
|
||||
</Main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
||||
+44
-28
@@ -1,42 +1,53 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import IconRss from "@/assets/icons/IconRss.svg";
|
||||
import IconArrowRight from "@/assets/icons/IconArrowRight.svg";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Socials from "@/components/Socials.astro";
|
||||
import LinkButton from "@/components/LinkButton.astro";
|
||||
import Card from "@/components/Card.astro";
|
||||
import getSortedPosts from "@/utils/getSortedPosts";
|
||||
import IconRss from "@/assets/icons/IconRss.svg";
|
||||
import IconArrowRight from "@/assets/icons/IconArrowRight.svg";
|
||||
import { SITE } from "@/config";
|
||||
import { SOCIALS } from "@/constants";
|
||||
import LinkButton from "@/components/LinkButton.astro";
|
||||
import { getSortedPosts } from "@/utils/getSortedPosts";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const posts = await getCollection("blog");
|
||||
const { socials, posts: postsConfig } = config;
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const posts = await getCollection("posts");
|
||||
const sortedPosts = getSortedPosts(posts);
|
||||
const featuredPosts = sortedPosts.filter(({ data }) => data.featured);
|
||||
const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
|
||||
const homePath = getRelativeLocaleUrl(locale, "");
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Header />
|
||||
<main id="main-content" data-layout="index" class="app-layout">
|
||||
<section id="hero" class:list={["pt-8 pb-6", "border-b border-border"]}>
|
||||
<main
|
||||
id="main-content"
|
||||
data-layout="index"
|
||||
data-home-path={homePath}
|
||||
class="app-layout"
|
||||
>
|
||||
<section id="hero" class="border-border border-b pt-8 pb-6">
|
||||
<h1 class="my-4 inline-block text-4xl font-bold sm:my-8 sm:text-5xl">
|
||||
Mingalaba
|
||||
</h1>
|
||||
<a
|
||||
target="_blank"
|
||||
href="/rss.xml"
|
||||
href={getRelativeLocaleUrl(locale, "rss.xml")}
|
||||
class="inline-block"
|
||||
aria-label="rss feed"
|
||||
aria-label="RSS Feed"
|
||||
title="RSS Feed"
|
||||
>
|
||||
<IconRss
|
||||
width={20}
|
||||
height={20}
|
||||
class="scale-125 stroke-accent stroke-3 rtl:-rotate-90"
|
||||
class="stroke-accent scale-125 stroke-3 rtl:-rotate-90"
|
||||
/>
|
||||
<span class="sr-only">RSS Feed</span>
|
||||
</a>
|
||||
@@ -50,17 +61,18 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
|
||||
<p class="mt-2">
|
||||
Read the blog posts or check
|
||||
<LinkButton
|
||||
class="underline decoration-dashed underline-offset-4 hover:text-accent"
|
||||
class="hover:text-accent underline decoration-dashed underline-offset-4"
|
||||
href="https://github.com/satnaing/astro-paper#readme"
|
||||
>
|
||||
README
|
||||
</LinkButton> for more info.
|
||||
</p>
|
||||
{
|
||||
// only display if at least one social link is enabled
|
||||
SOCIALS.length > 0 && (
|
||||
socials.length > 0 && (
|
||||
<div class="mt-4 flex max-sm:flex-col sm:items-center">
|
||||
<div class="me-2 mb-1 whitespace-nowrap sm:mb-0">Social Links:</div>
|
||||
<div class="me-2 mb-1 whitespace-nowrap sm:mb-0">
|
||||
{t.home.socialLinks}:
|
||||
</div>
|
||||
<Socials />
|
||||
</div>
|
||||
)
|
||||
@@ -73,10 +85,12 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
|
||||
id="featured"
|
||||
class:list={[
|
||||
"pt-12 pb-6",
|
||||
{ "border-b border-border": recentPosts.length > 0 },
|
||||
{ "border-border border-b": recentPosts.length > 0 },
|
||||
]}
|
||||
>
|
||||
<h2 class="text-2xl font-semibold tracking-wide">Featured</h2>
|
||||
<h2 class="text-2xl font-semibold tracking-wide">
|
||||
{t.home.featured}
|
||||
</h2>
|
||||
<ul>
|
||||
{featuredPosts.map(data => (
|
||||
<Card variant="h3" {...data} />
|
||||
@@ -89,20 +103,21 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
|
||||
{
|
||||
recentPosts.length > 0 && (
|
||||
<section id="recent-posts" class="pt-12 pb-6">
|
||||
<h2 class="text-2xl font-semibold tracking-wide">Recent Posts</h2>
|
||||
<h2 class="text-2xl font-semibold tracking-wide">
|
||||
{t.home.recentPosts}
|
||||
</h2>
|
||||
<ul>
|
||||
{recentPosts.map(
|
||||
(data, index) =>
|
||||
index < SITE.postPerIndex && <Card variant="h3" {...data} />
|
||||
)}
|
||||
{recentPosts.slice(0, postsConfig.perIndex).map(data => (
|
||||
<Card variant="h3" {...data} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="my-8 text-center">
|
||||
<LinkButton href="/posts/">
|
||||
All Posts
|
||||
<LinkButton href={getRelativeLocaleUrl(locale, "posts")}>
|
||||
{t.home.allPosts}
|
||||
<IconArrowRight class="inline-block rtl:-rotate-180" />
|
||||
</LinkButton>
|
||||
</div>
|
||||
@@ -113,9 +128,10 @@ const recentPosts = sortedPosts.filter(({ data }) => !data.featured);
|
||||
<script>
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
const indexLayout = (document.querySelector("#main-content") as HTMLElement)
|
||||
?.dataset?.layout;
|
||||
if (indexLayout) {
|
||||
sessionStorage.setItem("backUrl", "/");
|
||||
?.dataset;
|
||||
const baseRoot = import.meta.env.BASE_URL.replace(/\/?$/, "/");
|
||||
if (indexLayout?.layout) {
|
||||
sessionStorage.setItem("backUrl", indexLayout.homePath ?? baseRoot);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+163
-4
@@ -1,9 +1,168 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { generateOgImageForSite } from "@/utils/generateOgImages";
|
||||
import satori from "satori";
|
||||
import sharp from "sharp";
|
||||
import { fontData, experimental_getFontFileURL } from "astro:assets";
|
||||
import { getFontPathByWeight } from "@/utils/getFontPathByWeight";
|
||||
import config from "@/config";
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const buffer = await generateOgImageForSite();
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
export const GET: APIRoute = async context => {
|
||||
const fonts = fontData["--font-google-sans-code"];
|
||||
const regularFontPath = getFontPathByWeight(fonts, 400);
|
||||
const boldFontPath = getFontPathByWeight(fonts, 700);
|
||||
|
||||
if (regularFontPath === undefined || boldFontPath === undefined) {
|
||||
throw new Error("Cannot find the font path.");
|
||||
}
|
||||
|
||||
const [regularData, boldData] = await Promise.all([
|
||||
fetch(experimental_getFontFileURL(regularFontPath, context.url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
fetch(experimental_getFontFileURL(boldFontPath, context.url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
]);
|
||||
|
||||
const svg = await satori(
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
background: "#fefbfb",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "Google Sans Code",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: "-1px",
|
||||
right: "-1px",
|
||||
border: "4px solid #000",
|
||||
background: "#ecebeb",
|
||||
opacity: "0.9",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
margin: "2.5rem",
|
||||
width: "88%",
|
||||
height: "80%",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
border: "4px solid #000",
|
||||
background: "#fefbfb",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
margin: "2rem",
|
||||
width: "88%",
|
||||
height: "80%",
|
||||
},
|
||||
children: {
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
margin: "20px",
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "90%",
|
||||
maxHeight: "90%",
|
||||
overflow: "hidden",
|
||||
textAlign: "center",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "p",
|
||||
props: {
|
||||
style: { fontSize: 72, fontWeight: "bold" },
|
||||
children: config.site.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
props: {
|
||||
style: { fontSize: 28 },
|
||||
children: config.site.description,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
width: "100%",
|
||||
marginBottom: "8px",
|
||||
fontSize: 28,
|
||||
},
|
||||
children: {
|
||||
type: "span",
|
||||
props: {
|
||||
style: { overflow: "hidden", fontWeight: "bold" },
|
||||
children: new URL(config.site.url).hostname,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
embedFont: true,
|
||||
fonts: [
|
||||
{
|
||||
name: "Google Sans Code",
|
||||
data: regularData,
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "Google Sans Code",
|
||||
data: boldData,
|
||||
weight: 700,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
return new Response(new Uint8Array(pngBuffer), {
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
---
|
||||
import type { GetStaticPaths } from "astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import Main from "@/layouts/Main.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Card from "@/components/Card.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Pagination from "@/components/Pagination.astro";
|
||||
import getSortedPosts from "@/utils/getSortedPosts";
|
||||
import { SITE } from "@/config";
|
||||
import { getSortedPosts } from "@/utils/getSortedPosts";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
export const getStaticPaths = (async ({ paginate }) => {
|
||||
const posts = await getCollection("blog", ({ data }) => !data.draft);
|
||||
return paginate(getSortedPosts(posts), { pageSize: SITE.postPerPage });
|
||||
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||
return paginate(getSortedPosts(posts), { pageSize: config.posts.perPage });
|
||||
}) satisfies GetStaticPaths;
|
||||
|
||||
const { page } = Astro.props;
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<Layout title={`Posts | ${SITE.title}`}>
|
||||
<Layout title={`${t.pages.postsTitle} | ${config.site.title}`}>
|
||||
<Header />
|
||||
<Main pageTitle="Posts" pageDesc="All the articles I've posted.">
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main pageTitle={t.pages.postsTitle} pageDesc={t.pages.postsDesc}>
|
||||
<ul>
|
||||
{page.data.map(data => <Card {...data} />)}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import IconArrowLeft from "@/assets/icons/IconArrowLeft.svg";
|
||||
import IconArrowRight from "@/assets/icons/IconArrowRight.svg";
|
||||
import { getPostUrl } from "@/utils/getPostPaths";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
type AdjacentPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
filePath: string | undefined;
|
||||
} | null;
|
||||
|
||||
type Props = {
|
||||
prevPost: AdjacentPost;
|
||||
nextPost: AdjacentPost;
|
||||
};
|
||||
|
||||
const { prevPost, nextPost } = Astro.props;
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<div data-pagefind-ignore class="my-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{
|
||||
prevPost && (
|
||||
<a
|
||||
href={getPostUrl(prevPost.id, prevPost.filePath, locale)}
|
||||
class="flex w-full gap-1 hover:opacity-75"
|
||||
>
|
||||
<IconArrowLeft class="inline-block flex-none rtl:rotate-180" />
|
||||
<div>
|
||||
<span>{t.post.previousPost}</span>
|
||||
<div class="text-accent/85 text-sm">{prevPost.title}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
nextPost && (
|
||||
<a
|
||||
href={getPostUrl(nextPost.id, nextPost.filePath, locale)}
|
||||
class="flex w-full justify-end gap-1 text-end hover:opacity-75 sm:col-start-2"
|
||||
>
|
||||
<div>
|
||||
<span>{t.post.nextPost}</span>
|
||||
<div class="text-accent/85 text-sm">{nextPost.title}</div>
|
||||
</div>
|
||||
<IconArrowRight class="inline-block flex-none rtl:rotate-180" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
|
||||
import LinkButton from "@/components/LinkButton.astro";
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
{
|
||||
config.features.showBackButton && (
|
||||
<div class="app-layout flex items-center justify-start">
|
||||
<LinkButton
|
||||
id="back-button"
|
||||
href={getRelativeLocaleUrl(locale, "")}
|
||||
class="focus-outline hover:text-foreground/75 -ms-2 mt-8 mb-2"
|
||||
>
|
||||
<IconChevronLeft class="inline-block size-6 rtl:rotate-180" />
|
||||
<span>{t.post.goBack}</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
function updateGoBackUrl() {
|
||||
const backButton: HTMLAnchorElement | null =
|
||||
document.querySelector("#back-button");
|
||||
|
||||
const backUrl = sessionStorage.getItem("backUrl");
|
||||
|
||||
if (backUrl && backButton) {
|
||||
backButton.href = backUrl;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", updateGoBackUrl);
|
||||
updateGoBackUrl();
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import IconArrowLeft from "@/assets/icons/IconArrowLeft.svg";
|
||||
import IconArrowNarrowUp from "@/assets/icons/IconArrowNarrowUp.svg";
|
||||
import { useTranslations } from "@/i18n";
|
||||
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
---
|
||||
|
||||
<div
|
||||
id="btt-btn-container"
|
||||
class:list={[
|
||||
"fixed inset-e-4 bottom-8 z-50",
|
||||
"md:sticky md:inset-e-auto md:float-end md:me-1",
|
||||
"translate-y-14 opacity-0 transition duration-500",
|
||||
]}
|
||||
>
|
||||
<button
|
||||
data-button="back-to-top"
|
||||
class:list={[
|
||||
"group bg-background relative px-2 py-1",
|
||||
"size-14 rounded-full shadow-xl",
|
||||
"md:h-8 md:w-fit md:rounded-md md:shadow-none md:focus-visible:rounded-none",
|
||||
"md:bg-background/35 md:bg-clip-padding md:backdrop-blur-lg",
|
||||
]}
|
||||
>
|
||||
<span
|
||||
id="progress-indicator"
|
||||
class="absolute inset-0 -z-10 block size-14 scale-110 rounded-full bg-transparent md:hidden md:h-8 md:rounded-md"
|
||||
></span>
|
||||
<IconArrowLeft class="inline-block rotate-90 md:hidden" />
|
||||
<span class="group-hover:text-accent sr-only text-sm md:not-sr-only">
|
||||
<IconArrowNarrowUp class="inline-block size-4" />
|
||||
{t.post.backToTop}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
function backToTop() {
|
||||
const rootElement = document.documentElement;
|
||||
const btnContainer = document.querySelector("#btt-btn-container");
|
||||
const backToTopBtn = document.querySelector("[data-button='back-to-top']");
|
||||
const progressIndicator = document.querySelector("#progress-indicator");
|
||||
|
||||
if (!rootElement || !btnContainer || !backToTopBtn || !progressIndicator)
|
||||
return;
|
||||
|
||||
backToTopBtn.addEventListener("click", () => {
|
||||
document.body.scrollTop = 0;
|
||||
document.documentElement.scrollTop = 0;
|
||||
});
|
||||
|
||||
let lastVisible = null;
|
||||
function handleScroll() {
|
||||
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
|
||||
const scrollTop = rootElement.scrollTop;
|
||||
const scrollPercent = Math.floor((scrollTop / scrollTotal) * 100);
|
||||
|
||||
progressIndicator.style.setProperty(
|
||||
"background-image",
|
||||
`conic-gradient(var(--accent), var(--accent) ${scrollPercent}%, transparent ${scrollPercent}%)`
|
||||
);
|
||||
|
||||
const isVisible = scrollTop / scrollTotal > 0.3;
|
||||
|
||||
if (isVisible !== lastVisible) {
|
||||
btnContainer.classList.toggle("opacity-100", isVisible);
|
||||
btnContainer.classList.toggle("translate-y-0", isVisible);
|
||||
btnContainer.classList.toggle("opacity-0", !isVisible);
|
||||
btnContainer.classList.toggle("translate-y-14", !isVisible);
|
||||
lastVisible = isVisible;
|
||||
}
|
||||
}
|
||||
|
||||
let ticking = false;
|
||||
document.addEventListener("scroll", () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
handleScroll();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
backToTop();
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import IconEdit from "@/assets/icons/IconEdit.svg";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
type Props = {
|
||||
hideEditPost?: CollectionEntry<"posts">["data"]["hideEditPost"];
|
||||
class?: string;
|
||||
post: CollectionEntry<"posts">;
|
||||
};
|
||||
|
||||
const { hideEditPost, post, class: className = "" } = Astro.props;
|
||||
|
||||
const editPost = config.features.editPost;
|
||||
const href = editPost.enabled ? `${editPost.url}${post.filePath}` : "";
|
||||
const showEditPost = editPost.enabled && !hideEditPost && href.trim() !== "";
|
||||
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
---
|
||||
|
||||
{
|
||||
showEditPost && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class:list={[
|
||||
"hover:text-accent text-muted-foreground flex justify-baseline gap-1.5",
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<IconEdit class="inline-block" />
|
||||
<span>{t.post.editPage}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import LinkButton from "@/components/LinkButton.astro";
|
||||
import { tplStr, useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const { shareLinks } = config;
|
||||
const t = useTranslations(Astro.currentLocale);
|
||||
|
||||
const icons = import.meta.glob<{
|
||||
default: (_props: Record<string, unknown>) => unknown;
|
||||
}>("/src/assets/icons/socials/*.svg");
|
||||
|
||||
const pageUrl = Astro.url;
|
||||
|
||||
const platformLabel = (name: string) =>
|
||||
name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
const items = await Promise.all(
|
||||
shareLinks.map(async ({ name, url, linkTitle }) => {
|
||||
const mod = await icons[`/src/assets/icons/socials/${name}.svg`]?.();
|
||||
const Icon = mod?.default;
|
||||
const title =
|
||||
linkTitle ??
|
||||
(name === "mail"
|
||||
? t.post.sharePostViaEmail
|
||||
: tplStr(t.post.sharePostOn, { platform: platformLabel(name) }));
|
||||
return { url, title, Icon };
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
{
|
||||
items.some(({ Icon }) => Icon) && (
|
||||
<div class="flex flex-none flex-col items-center justify-center gap-1 md:items-start">
|
||||
<span class="italic">{t.post.sharePostIntro}</span>
|
||||
<div class="text-center">
|
||||
{items.map(({ url, title, Icon }) =>
|
||||
Icon ? (
|
||||
<LinkButton
|
||||
href={`${url}${pageUrl}`}
|
||||
class="scale-90 p-2 hover:rotate-6 sm:p-1"
|
||||
title={title}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon class="inline-block size-6 scale-125 fill-transparent stroke-current stroke-2 opacity-90 group-hover:fill-transparent sm:scale-110" />
|
||||
<span class="sr-only">{title}</span>
|
||||
</LinkButton>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,265 @@
|
||||
---
|
||||
import { type CollectionEntry, getCollection } from "astro:content";
|
||||
import PostDetails from "@/layouts/PostDetails.astro";
|
||||
import getSortedPosts from "@/utils/getSortedPosts";
|
||||
import { getPath } from "@/utils/getPath";
|
||||
|
||||
type Props = {
|
||||
post: CollectionEntry<"blog">;
|
||||
};
|
||||
import { type CollectionEntry, getCollection, render } from "astro:content";
|
||||
import PostLayout from "@/layouts/PostLayout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Datetime from "@/components/Datetime.astro";
|
||||
import Tag from "@/components/Tag.astro";
|
||||
import { getPostSlug, getPostUrl } from "@/utils/getPostPaths";
|
||||
import { getSortedPosts } from "@/utils/getSortedPosts";
|
||||
import { slugifyStr } from "@/utils/slugify";
|
||||
import EditPost from "./_components/EditPost.astro";
|
||||
import ShareLinks from "./_components/ShareLinks.astro";
|
||||
import BackButton from "./_components/BackButton.astro";
|
||||
import BackToTopButton from "./_components/BackToTopButton.astro";
|
||||
import AdjacentPostNav from "./_components/AdjacentPostNav.astro";
|
||||
import config from "@/config";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection("blog", ({ data }) => !data.draft);
|
||||
const postResult = posts.map(post => ({
|
||||
params: { slug: getPath(post.id, post.filePath, false) },
|
||||
props: { post },
|
||||
}));
|
||||
const posts = await getCollection("posts");
|
||||
const sortedPosts = getSortedPosts(posts);
|
||||
|
||||
return postResult;
|
||||
return sortedPosts.map((post, index) => ({
|
||||
params: { slug: getPostSlug(post.id, post.filePath) },
|
||||
props: {
|
||||
post,
|
||||
prevPost:
|
||||
index > 0
|
||||
? {
|
||||
id: sortedPosts[index - 1].id,
|
||||
title: sortedPosts[index - 1].data.title,
|
||||
filePath: sortedPosts[index - 1].filePath,
|
||||
}
|
||||
: null,
|
||||
nextPost:
|
||||
index < sortedPosts.length - 1
|
||||
? {
|
||||
id: sortedPosts[index + 1].id,
|
||||
title: sortedPosts[index + 1].data.title,
|
||||
filePath: sortedPosts[index + 1].filePath,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
type AdjacentPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
filePath: string | undefined;
|
||||
} | null;
|
||||
|
||||
const posts = await getCollection("blog");
|
||||
const sortedPosts = getSortedPosts(posts);
|
||||
type Props = {
|
||||
post: CollectionEntry<"posts">;
|
||||
prevPost: AdjacentPost;
|
||||
nextPost: AdjacentPost;
|
||||
};
|
||||
|
||||
const { post, prevPost, nextPost } = Astro.props;
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
ogImage: initOgImage,
|
||||
canonicalURL,
|
||||
pubDatetime,
|
||||
modDatetime,
|
||||
timezone,
|
||||
tags,
|
||||
hideEditPost,
|
||||
} = post.data;
|
||||
|
||||
const { Content } = await render(post);
|
||||
|
||||
let ogImageUrl: string | undefined;
|
||||
|
||||
if (typeof initOgImage === "string") {
|
||||
ogImageUrl = initOgImage;
|
||||
} else if (initOgImage?.src) {
|
||||
ogImageUrl = initOgImage.src;
|
||||
}
|
||||
|
||||
if (!ogImageUrl && config.features.dynamicOgImage) {
|
||||
const postUrl = getPostUrl(post.id, post.filePath, locale).replace(
|
||||
/\/+$/,
|
||||
""
|
||||
);
|
||||
ogImageUrl = `${postUrl}/index.png`;
|
||||
}
|
||||
|
||||
const ogImage = ogImageUrl
|
||||
? new URL(ogImageUrl, Astro.url.origin).href
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<PostDetails post={post} posts={sortedPosts} />
|
||||
<PostLayout
|
||||
title={`${title} | ${config.site.title}`}
|
||||
description={description}
|
||||
ogImage={ogImage}
|
||||
canonicalURL={canonicalURL}
|
||||
pubDatetime={pubDatetime}
|
||||
modDatetime={modDatetime}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<BackButton />
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
class:list={["app-layout", { "mt-8": !config.features.showBackButton }]}
|
||||
data-pagefind-body
|
||||
>
|
||||
<h1
|
||||
style={{ viewTransitionName: slugifyStr(title.replaceAll(".", "-")) }}
|
||||
class="text-accent inline-block text-2xl font-bold sm:text-3xl"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div class="my-2 flex items-center gap-2">
|
||||
<Datetime {pubDatetime} {modDatetime} {timezone} size="lg" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class:list={[
|
||||
"text-muted-foreground max-sm:hidden",
|
||||
{ hidden: !config.features.editPost?.enabled || hideEditPost },
|
||||
]}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
<EditPost {hideEditPost} {post} class="max-sm:hidden" />
|
||||
</div>
|
||||
|
||||
<article
|
||||
id="article"
|
||||
class:list={[
|
||||
"mt-8 w-full",
|
||||
"app-prose max-w-app",
|
||||
"prose-pre:bg-(--shiki-light-bg) dark:prose-pre:bg-(--shiki-dark-bg)",
|
||||
]}
|
||||
>
|
||||
<Content />
|
||||
</article>
|
||||
|
||||
<hr class="my-8 border-dashed" />
|
||||
|
||||
<EditPost class="sm:hidden" {hideEditPost} {post} />
|
||||
|
||||
<BackToTopButton />
|
||||
|
||||
<ul class="mt-4 mb-8 flex flex-wrap gap-4 sm:my-8">
|
||||
{tags.map(tag => <Tag tag={slugifyStr(tag)} tagName={tag} size="sm" />)}
|
||||
</ul>
|
||||
|
||||
<ShareLinks />
|
||||
|
||||
<hr class="my-8 border-dashed" />
|
||||
|
||||
<AdjacentPostNav {prevPost} {nextPost} />
|
||||
</main>
|
||||
<Footer />
|
||||
</PostLayout>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
function createProgressBar() {
|
||||
const progressContainer = document.createElement("div");
|
||||
progressContainer.className =
|
||||
"progress-container fixed top-0 z-10 h-1 w-full bg-background";
|
||||
|
||||
const progressBar = document.createElement("div");
|
||||
progressBar.className = "progress-bar h-1 w-0 bg-accent";
|
||||
progressBar.id = "myBar";
|
||||
|
||||
progressContainer.appendChild(progressBar);
|
||||
document.body.appendChild(progressContainer);
|
||||
}
|
||||
createProgressBar();
|
||||
|
||||
function updateScrollProgress() {
|
||||
document.addEventListener("scroll", () => {
|
||||
const winScroll =
|
||||
document.body.scrollTop || document.documentElement.scrollTop;
|
||||
const height =
|
||||
document.documentElement.scrollHeight -
|
||||
document.documentElement.clientHeight;
|
||||
const scrolled = (winScroll / height) * 100;
|
||||
if (document) {
|
||||
const myBar = document.getElementById("myBar");
|
||||
if (myBar) {
|
||||
myBar.style.width = scrolled + "%";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
updateScrollProgress();
|
||||
|
||||
function addHeadingLinks() {
|
||||
const headings = Array.from(
|
||||
document.querySelectorAll("h2, h3, h4, h5, h6")
|
||||
);
|
||||
for (const heading of headings) {
|
||||
heading.classList.add("group");
|
||||
const link = document.createElement("a");
|
||||
link.className =
|
||||
"heading-link ms-2 no-underline opacity-75 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100";
|
||||
link.href = "#" + heading.id;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.ariaHidden = "true";
|
||||
span.innerText = "#";
|
||||
link.appendChild(span);
|
||||
heading.appendChild(link);
|
||||
}
|
||||
}
|
||||
addHeadingLinks();
|
||||
|
||||
function attachCopyButtons() {
|
||||
const copyButtonLabel = "Copy";
|
||||
const codeBlocks = Array.from(document.querySelectorAll("pre"));
|
||||
|
||||
for (const codeBlock of codeBlocks) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "relative";
|
||||
|
||||
const computedStyle = getComputedStyle(codeBlock);
|
||||
const hasFileNameOffset =
|
||||
computedStyle.getPropertyValue("--file-name-offset").trim() !== "";
|
||||
|
||||
const topClass = hasFileNameOffset
|
||||
? "top-(--file-name-offset)"
|
||||
: "-top-3";
|
||||
|
||||
const copyButton = document.createElement("button");
|
||||
copyButton.className = `copy-code absolute end-3 ${topClass} rounded bg-muted border border-muted px-2 py-1 text-xs leading-4 text-foreground font-medium`;
|
||||
copyButton.innerHTML = copyButtonLabel;
|
||||
codeBlock.setAttribute("tabindex", "0");
|
||||
codeBlock.appendChild(copyButton);
|
||||
|
||||
codeBlock?.parentNode?.insertBefore(wrapper, codeBlock);
|
||||
wrapper.appendChild(codeBlock);
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
await copyCode(codeBlock, copyButton);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyCode(block, button) {
|
||||
const code = block.querySelector("code");
|
||||
const text = code?.innerText;
|
||||
|
||||
await navigator.clipboard.writeText(text ?? "");
|
||||
|
||||
button.innerText = "Copied";
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerText = copyButtonLabel;
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
attachCopyButtons();
|
||||
|
||||
document.addEventListener("astro:after-swap", () =>
|
||||
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,196 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
import { getPath } from "@/utils/getPath";
|
||||
import { generateOgImageForPost } from "@/utils/generateOgImages";
|
||||
import { SITE } from "@/config";
|
||||
import { getCollection } from "astro:content";
|
||||
import { fontData, experimental_getFontFileURL } from "astro:assets";
|
||||
import satori from "satori";
|
||||
import sharp from "sharp";
|
||||
import { getFontPathByWeight } from "@/utils/getFontPathByWeight";
|
||||
import { getPostSlug } from "@/utils/getPostPaths";
|
||||
import config from "@/config";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
if (!SITE.dynamicOgImage) {
|
||||
if (!config.features.dynamicOgImage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const posts = await getCollection("blog").then(p =>
|
||||
const posts = await getCollection("posts").then(p =>
|
||||
p.filter(({ data }) => !data.draft && !data.ogImage)
|
||||
);
|
||||
|
||||
return posts.map(post => ({
|
||||
params: { slug: getPath(post.id, post.filePath, false) },
|
||||
params: { slug: getPostSlug(post.id, post.filePath) },
|
||||
props: post,
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ props }) => {
|
||||
if (!SITE.dynamicOgImage) {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: "Not found",
|
||||
});
|
||||
export const GET: APIRoute = async ({ props, url }) => {
|
||||
if (!config.features.dynamicOgImage) {
|
||||
return new Response(null, { status: 404, statusText: "Not found" });
|
||||
}
|
||||
|
||||
const buffer = await generateOgImageForPost(props as CollectionEntry<"blog">);
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
const fonts = fontData["--font-google-sans-code"];
|
||||
const regularFontPath = getFontPathByWeight(fonts, 400);
|
||||
const boldFontPath = getFontPathByWeight(fonts, 700);
|
||||
|
||||
if (regularFontPath === undefined || boldFontPath === undefined) {
|
||||
throw new Error("Cannot find the font path.");
|
||||
}
|
||||
|
||||
const [regularData, boldData] = await Promise.all([
|
||||
fetch(experimental_getFontFileURL(regularFontPath, url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
fetch(experimental_getFontFileURL(boldFontPath, url)).then(res =>
|
||||
res.arrayBuffer()
|
||||
),
|
||||
]);
|
||||
|
||||
const svg = await satori(
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
background: "#fefbfb",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: "-1px",
|
||||
right: "-1px",
|
||||
border: "4px solid #000",
|
||||
background: "#ecebeb",
|
||||
opacity: "0.9",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
margin: "2.5rem",
|
||||
width: "88%",
|
||||
height: "80%",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
border: "4px solid #000",
|
||||
background: "#fefbfb",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
margin: "2rem",
|
||||
width: "88%",
|
||||
height: "80%",
|
||||
},
|
||||
children: {
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
margin: "20px",
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "p",
|
||||
props: {
|
||||
style: {
|
||||
fontSize: 72,
|
||||
fontWeight: "bold",
|
||||
maxHeight: "84%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
children: props.data.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "div",
|
||||
props: {
|
||||
style: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
marginBottom: "8px",
|
||||
fontSize: 28,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "span",
|
||||
props: {
|
||||
children: [
|
||||
"by ",
|
||||
{
|
||||
type: "span",
|
||||
props: {
|
||||
style: { color: "transparent" },
|
||||
children: '"',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span",
|
||||
props: {
|
||||
style: {
|
||||
overflow: "hidden",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: props.data.author,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span",
|
||||
props: {
|
||||
style: { overflow: "hidden", fontWeight: "bold" },
|
||||
children: config.site.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
embedFont: true,
|
||||
fonts: [
|
||||
{
|
||||
name: "Google Sans Code",
|
||||
data: regularData,
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "Google Sans Code",
|
||||
data: boldData,
|
||||
weight: 700,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
return new Response(new Uint8Array(pngBuffer), {
|
||||
headers: { "Content-Type": "image/png" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import rss from "@astrojs/rss";
|
||||
import { getCollection } from "astro:content";
|
||||
import { getPath } from "@/utils/getPath";
|
||||
import getSortedPosts from "@/utils/getSortedPosts";
|
||||
import { SITE } from "@/config";
|
||||
import { getSortedPosts } from "@/utils/getSortedPosts";
|
||||
import { getPostUrl } from "@/utils/getPostPaths";
|
||||
import config from "@/config";
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getCollection("blog");
|
||||
const posts = await getCollection("posts");
|
||||
const sortedPosts = getSortedPosts(posts);
|
||||
|
||||
return rss({
|
||||
title: SITE.title,
|
||||
description: SITE.desc,
|
||||
site: SITE.website,
|
||||
title: config.site.title,
|
||||
description: config.site.description,
|
||||
site: config.site.url,
|
||||
items: sortedPosts.map(({ data, id, filePath }) => ({
|
||||
link: getPath(id, filePath),
|
||||
link: getPostUrl(id, filePath, config.site.lang),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
pubDate: new Date(data.modDatetime ?? data.pubDatetime),
|
||||
|
||||
+41
-8
@@ -1,19 +1,47 @@
|
||||
---
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import "@pagefind/default-ui/css/ui.css";
|
||||
import Main from "@/layouts/Main.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import { SITE } from "@/config";
|
||||
import { getAssetPath } from "@/utils/withBase";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const backUrl = SITE.showBackButton ? `${Astro.url.pathname}` : "/";
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
|
||||
// Redirect to 404 page if `search` feature is not enabled
|
||||
const notFoundUrl = getRelativeLocaleUrl(locale, "404");
|
||||
if (config.features.search !== "pagefind" && notFoundUrl) {
|
||||
return Astro.rewrite(notFoundUrl);
|
||||
}
|
||||
|
||||
const backUrl = config.features.showBackButton
|
||||
? `${Astro.url.pathname}`
|
||||
: getRelativeLocaleUrl(locale, "");
|
||||
|
||||
const pagefindBundlePath = getAssetPath("pagefind/");
|
||||
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<Layout title={`Search | ${SITE.title}`}>
|
||||
<Layout title={`${t.pages.searchTitle} | ${config.site.title}`}>
|
||||
<Header />
|
||||
<Main pageTitle="Search" pageDesc="Search any article ...">
|
||||
<div id="pagefind-search" transition:persist data-backurl={backUrl}></div>
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main pageTitle={t.pages.searchTitle} pageDesc={t.pages.searchDesc}>
|
||||
<div
|
||||
id="pagefind-search"
|
||||
transition:persist
|
||||
data-backurl={backUrl}
|
||||
data-bundle-path={pagefindBundlePath}
|
||||
>
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
||||
@@ -24,6 +52,9 @@ const backUrl = SITE.showBackButton ? `${Astro.url.pathname}` : "/";
|
||||
|
||||
if (!pageFindSearch) return;
|
||||
|
||||
const bundlePath = pageFindSearch?.dataset?.bundlePath;
|
||||
if (!bundlePath) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const onIdle = window.requestIdleCallback || (cb => setTimeout(cb, 1));
|
||||
@@ -45,14 +76,16 @@ const backUrl = SITE.showBackButton ? `${Astro.url.pathname}` : "/";
|
||||
// Init pagefind ui
|
||||
const search = new PagefindUI({
|
||||
element: "#pagefind-search",
|
||||
bundlePath,
|
||||
showImages: false,
|
||||
showSubResults: true,
|
||||
processTerm: function (term: string) {
|
||||
params.set("q", term); // Update the `q` parameter in the URL
|
||||
history.replaceState(history.state, "", "?" + params.toString()); // Push the new URL without reloading
|
||||
|
||||
const backUrl = pageFindSearch?.dataset?.backurl;
|
||||
sessionStorage.setItem("backUrl", backUrl + "?" + params.toString());
|
||||
const backUrl =
|
||||
pageFindSearch?.dataset?.backurl ?? window.location.pathname;
|
||||
sessionStorage.setItem("backUrl", `${backUrl}?${params.toString()}`);
|
||||
|
||||
return term;
|
||||
},
|
||||
|
||||
@@ -1,44 +1,51 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import type { GetStaticPathsOptions } from "astro";
|
||||
import Main from "@/layouts/Main.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Card from "@/components/Card.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import Pagination from "@/components/Pagination.astro";
|
||||
import getUniqueTags from "@/utils/getUniqueTags";
|
||||
import getPostsByTag from "@/utils/getPostsByTag";
|
||||
import { SITE } from "@/config";
|
||||
import { getUniqueTags } from "@/utils/getUniqueTags";
|
||||
import { getSortedPosts } from "@/utils/getSortedPosts";
|
||||
import { slugifyAll } from "@/utils/slugify";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||
const posts = await getCollection("blog");
|
||||
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||
const tags = getUniqueTags(posts);
|
||||
|
||||
return tags.flatMap(({ tag, tagName }) => {
|
||||
const tagPosts = getPostsByTag(posts, tag);
|
||||
const tagPosts = getSortedPosts(
|
||||
posts.filter(({ data }) => slugifyAll(data.tags).includes(tag))
|
||||
);
|
||||
|
||||
return paginate(tagPosts, {
|
||||
params: { tag },
|
||||
props: { tagName },
|
||||
pageSize: SITE.postPerPage,
|
||||
pageSize: config.posts.perPage,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const params = Astro.params;
|
||||
const { tag } = params;
|
||||
const { page, tagName } = Astro.props;
|
||||
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<Layout title={`Tag: ${tagName} | ${SITE.title}`}>
|
||||
<Layout title={`${t.pages.tagTitle}: ${tagName} | ${config.site.title}`}>
|
||||
<Header />
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main
|
||||
pageTitle={[`Tag:`, `${tagName}`]}
|
||||
titleTransition={tag}
|
||||
pageDesc={`All the articles with the tag "${tagName}".`}
|
||||
pageTitle={`${t.pages.tagTitle}: ${tagName}`}
|
||||
pageDesc={`${t.pages.tagDesc} "${tagName}".`}
|
||||
>
|
||||
<h1 slot="title" transition:name={tag}>{`Tag:${tag}`}</h1>
|
||||
<ul>
|
||||
{page.data.map(data => <Card {...data} />)}
|
||||
</ul>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import Main from "@/layouts/Main.astro";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import Tag from "@/components/Tag.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import Main from "@/components/Main.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import getUniqueTags from "@/utils/getUniqueTags";
|
||||
import { SITE } from "@/config";
|
||||
import Tag from "@/components/Tag.astro";
|
||||
import { getUniqueTags } from "@/utils/getUniqueTags";
|
||||
import { useTranslations } from "@/i18n";
|
||||
import config from "@/config";
|
||||
|
||||
const posts = await getCollection("blog");
|
||||
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||
const tags = getUniqueTags(posts);
|
||||
|
||||
let tags = getUniqueTags(posts);
|
||||
const locale = Astro.currentLocale ?? config.site.lang;
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<Layout title={`Tags | ${SITE.title}`}>
|
||||
<Layout title={`${t.pages.tagsTitle} | ${config.site.title}`}>
|
||||
<Header />
|
||||
<Main pageTitle="Tags" pageDesc="All the tags used in posts.">
|
||||
|
||||
<Breadcrumb />
|
||||
|
||||
<Main pageTitle={t.pages.tagsTitle} pageDesc={t.pages.tagsDesc}>
|
||||
<ul class="flex flex-wrap gap-6">
|
||||
{tags.map(({ tag, tagName }) => <Tag {tag} {tagName} />)}
|
||||
</ul>
|
||||
</Main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
||||
Reference in New Issue
Block a user