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:
Sat Naing
2026-05-17 16:17:58 +07:00
committed by GitHub
parent f3005328e5
commit f0b644de3a
122 changed files with 5387 additions and 4561 deletions
-37
View File
@@ -1,37 +0,0 @@
---
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
import LinkButton from "./LinkButton.astro";
import { SITE } from "@/config";
---
{
SITE.showBackButton && (
<div class="app-layout flex items-center justify-start">
<LinkButton
id="back-button"
href="/"
class="focus-outline -ms-2 mt-8 mb-2 hover:text-foreground/75"
>
<IconChevronLeft class="inline-block size-6 rtl:rotate-180" />
<span>Go back</span>
</LinkButton>
</div>
)
}
<script>
/* Update Search Praam */
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>
-88
View File
@@ -1,88 +0,0 @@
---
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
import IconArrowNarrowUp from "@/assets/icons/IconArrowNarrowUp.svg";
---
<div
id="btt-btn-container"
class:list={[
"fixed end-4 bottom-8 z-50",
"md:sticky md:end-auto md:float-end md:me-1",
"translate-y-14 opacity-0 transition duration-500",
]}
>
<button
data-button="back-to-top"
class:list={[
"group relative bg-background 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>
<IconChevronLeft class="inline-block rotate-90 md:hidden" />
<span class="sr-only text-sm group-hover:text-accent md:not-sr-only">
<IconArrowNarrowUp class="inline-block size-4" />
Back To Top
</span>
</button>
</div>
<script is:inline data-astro-rerun>
/** Scrolls the document to the top when
* the "Back to Top" button is clicked. */
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;
// Attach click event handler for back-to-top button
backToTopBtn.addEventListener("click", () => {
document.body.scrollTop = 0; // For Safari
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
});
// Handle button visibility according to scroll position
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>
+54 -13
View File
@@ -1,24 +1,58 @@
---
// Remove current url path and remove trailing slash if exists
const currentUrlPath = Astro.url.pathname.replace(/\/+$/, "");
import { getRelativeLocaleUrl } from "astro:i18n";
import { stripBase, stripLocale } from "@/utils/withBase";
import { useTranslations } from "@/i18n";
import config from "@/config";
const locale = Astro.currentLocale ?? config.site.lang;
const t = useTranslations(locale);
// Strip base prefix and trailing slash, then locale prefix so segments
// reflect the logical page path only.
const pathWithoutBase = stripBase(Astro.url.pathname).replace(/\/+$/, "");
const currentUrlPath = stripLocale(pathWithoutBase, locale);
// Get url array from path
// eg: /tags/tailwindcss => ['tags', 'tailwindcss']
const breadcrumbList = currentUrlPath.split("/").slice(1);
const breadcrumbList = currentUrlPath.split("/").slice(1).filter(Boolean);
// if breadcrumb is Home > Posts > 1 <etc>
// replace Posts with Posts (page number)
const decodeSegment = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const navLabels: Record<string, string> = {
posts: t.nav.posts,
tags: t.nav.tags,
about: t.nav.about,
archives: t.nav.archives,
search: t.nav.search,
};
// if breadcrumb is Home > Posts > [page] <etc>
// replace Posts with localized "Posts (page number)"
if (breadcrumbList[0] === "posts") {
breadcrumbList.splice(0, 2, `Posts (page ${breadcrumbList[1] || 1})`);
breadcrumbList.splice(
0,
2,
`${t.nav.posts} (${t.pagination.page.toLowerCase()} ${breadcrumbList[1] || 1})`
);
}
// if breadcrumb is Home > Tags > [tag] > [page] <etc>
// replace [tag] > [page] with [tag] (page number)
// replace [tag] > [page] with localized [tag] (page number)
if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
breadcrumbList.splice(
1,
3,
`${breadcrumbList[1]} ${Number(breadcrumbList[2]) === 1 ? "" : "(page " + breadcrumbList[2] + ")"}`
`${decodeSegment(breadcrumbList[1])} ${
Number(breadcrumbList[2]) === 1
? ""
: `(${t.pagination.page.toLowerCase()} ${breadcrumbList[2]})`
}`
);
}
---
@@ -28,7 +62,9 @@ if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
class="font-light [&>li]:inline [&>li:not(:last-child)>a]:hover:opacity-100"
>
<li>
<a href="/" class="opacity-80">Home</a>
<a href={getRelativeLocaleUrl(locale, "")} class="opacity-80">
{t.nav.home}
</a>
<span aria-hidden="true" class="opacity-80">&raquo;</span>
</li>
{
@@ -40,15 +76,20 @@ if (breadcrumbList[0] === "tags" && !isNaN(Number(breadcrumbList[2]))) {
aria-current="page"
>
{/* make the last part lowercase in Home > Tags > some-tag */}
{decodeURIComponent(breadcrumb)}
{navLabels[breadcrumb] ?? decodeSegment(breadcrumb)}
</span>
</li>
) : (
<li>
<a href={`/${breadcrumb}/`} class="capitalize opacity-70">
{breadcrumb}
<a
href={getRelativeLocaleUrl(locale, breadcrumb)}
class="capitalize opacity-70"
>
{navLabels[breadcrumb] ?? decodeSegment(breadcrumb)}
</a>
<span aria-hidden="true">&raquo;</span>
<span aria-hidden="true" class="opacity-70">
&raquo;
</span>
</li>
)
)
+5 -7
View File
@@ -1,12 +1,12 @@
---
import type { CollectionEntry } from "astro:content";
import { getPostUrl } from "@/utils/getPostPaths";
import { slugifyStr } from "@/utils/slugify";
import { getPath } from "@/utils/getPath";
import Datetime from "./Datetime.astro";
type Props = {
variant?: "h2" | "h3";
} & CollectionEntry<"blog">;
} & CollectionEntry<"posts">;
const { variant: Heading = "h2", id, data, filePath } = Astro.props;
@@ -15,16 +15,14 @@ const { title, description, ...props } = data;
<li class="my-6">
<a
href={getPath(id, filePath)}
href={getPostUrl(id, filePath, Astro.currentLocale)}
class:list={[
"inline-block text-lg font-medium text-accent",
"text-accent inline-block text-lg font-medium",
"decoration-dashed underline-offset-4 hover:underline",
"focus-visible:no-underline focus-visible:underline-offset-0",
]}
>
<Heading
style={{ viewTransitionName: slugifyStr(title.replaceAll(".", "-")) }}
>
<Heading transition:name={slugifyStr(title.replaceAll(".", "-"))}>
{title}
</Heading>
</a>
+13 -7
View File
@@ -3,7 +3,8 @@ import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import IconCalendar from "@/assets/icons/IconCalendar.svg";
import { SITE } from "@/config";
import config from "@/config";
import { useTranslations } from "@/i18n";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -24,17 +25,20 @@ const {
timezone: postTimezone,
} = Astro.props;
/* ========== Formatted Datetime ========== */
const t = useTranslations(Astro.currentLocale);
const isModified = modDatetime && modDatetime > pubDatetime;
const datetime = dayjs(isModified ? modDatetime : pubDatetime).tz(
postTimezone || SITE.timezone
postTimezone ?? config.site.timezone
);
const date = datetime.format("D MMM, YYYY"); // e.g., '22 Mar, 2025'
const date = datetime.format("D MMM, YYYY");
---
<div class:list={["flex items-center gap-x-2 opacity-80", className]}>
<div
class:list={["text-muted-foreground flex items-center gap-x-2", className]}
>
<IconCalendar
class:list={[
"inline-block size-6 min-w-5.5",
@@ -44,12 +48,14 @@ const date = datetime.format("D MMM, YYYY"); // e.g., '22 Mar, 2025'
{
isModified && (
<span class:list={["text-sm", { "sm:text-base": size === "lg" }]}>
Updated:
{t.post.updatedAt}:
</span>
)
}
<time
class:list={["text-sm", { "sm:text-base": size === "lg" }]}
datetime={datetime.toISOString()}>{date}</time
datetime={datetime.toISOString()}
>
{date}
</time>
</div>
-34
View File
@@ -1,34 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
import IconEdit from "@/assets/icons/IconEdit.svg";
import { SITE } from "@/config";
type Props = {
hideEditPost?: CollectionEntry<"blog">["data"]["hideEditPost"];
class?: string;
post: CollectionEntry<"blog">;
};
const { hideEditPost, post, class: className = "" } = Astro.props;
const href = `${SITE.editPost.url}${post.filePath}`;
const showEditPost =
SITE.editPost.enabled && !hideEditPost && href.trim() !== "";
---
{
showEditPost && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
class:list={[
"flex justify-baseline gap-1.5 opacity-80 hover:text-accent",
className,
]}
>
<IconEdit class="inline-block" />
<span>{SITE.editPost.text}</span>
</a>
)
}
+7 -9
View File
@@ -1,14 +1,16 @@
---
import type { HTMLAttributes } from "astro/types";
import Socials from "./Socials.astro";
const currentYear = new Date().getFullYear();
import { useTranslations } from "@/i18n";
type Props = {
noMarginTop?: boolean;
} & HTMLAttributes<"footer">;
const { noMarginTop = false, class: className, ...attrs } = Astro.props;
const t = useTranslations(Astro.currentLocale);
const currentYear = new Date().getFullYear();
---
<footer
@@ -16,17 +18,13 @@ const { noMarginTop = false, class: className, ...attrs } = Astro.props;
{...attrs}
>
<div
class:list={[
"py-6 sm:py-4",
"border-t border-border",
"flex flex-col items-center justify-between sm:flex-row-reverse",
]}
class="border-border flex flex-col items-center justify-between border-t py-6 sm:flex-row-reverse sm:py-4"
>
<Socials />
<div class="my-2 flex flex-col items-center whitespace-nowrap sm:flex-row">
<span>Copyright &#169; {currentYear}</span>
<span>{t.footer.copyright} &#169; {currentYear}</span>
<span class="hidden sm:inline">&nbsp;|&nbsp;</span>
<span>All rights reserved.</span>
<span>{t.footer.allRightsReserved}</span>
</div>
</div>
</footer>
+92 -66
View File
@@ -1,23 +1,35 @@
---
import IconX from "@/assets/icons/IconX.svg";
import IconMoon from "@/assets/icons/IconMoon.svg";
import IconSearch from "@/assets/icons/IconSearch.svg";
import { getRelativeLocaleUrl } from "astro:i18n";
import IconUnderline from "@/assets/icons/IconUnderline.svg";
import IconMenuDeep from "@/assets/icons/IconMenuDeep.svg";
import IconArchive from "@/assets/icons/IconArchive.svg";
import IconSunHigh from "@/assets/icons/IconSunHigh.svg";
import IconMenuDeep from "@/assets/icons/IconMenuDeep.svg";
import IconSearch from "@/assets/icons/IconSearch.svg";
import IconMoon from "@/assets/icons/IconMoon.svg";
import IconX from "@/assets/icons/IconX.svg";
import LinkButton from "./LinkButton.astro";
import { SITE } from "@/config";
import { stripBase, stripLocale } from "@/utils/withBase";
import { useTranslations } from "@/i18n";
import config from "@/config";
const { pathname } = Astro.url;
const { site, features } = config;
// Remove trailing slash from current pathname if exists
const currentPath =
pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
const locale = Astro.currentLocale ?? config.site.lang;
const t = useTranslations(locale);
// Strip the base prefix so isActive comparisons work regardless of deploy subdirectory.
// e.g. with base "/blog": "/blog/posts/slug" → "/posts/slug"
const relativePath = stripBase(Astro.url.pathname);
const pathWithoutTrailingSlash =
relativePath.endsWith("/") && relativePath !== "/"
? relativePath.slice(0, -1)
: relativePath;
const currentPath = stripLocale(pathWithoutTrailingSlash, locale);
const isActive = (path: string) => {
const currentPathArray = currentPath.split("/").filter(p => p.trim());
const pathArray = path.split("/").filter(p => p.trim());
return currentPath === path || currentPathArray[0] === pathArray[0];
};
---
@@ -25,28 +37,22 @@ const isActive = (path: string) => {
<a
id="skip-to-content"
href="#main-content"
class="absolute start-16 -top-full z-50 bg-background px-3 py-2 text-accent backdrop-blur-lg transition-all focus:top-4"
class="bg-background text-accent absolute inset-s-16 -top-full z-50 px-3 py-2 backdrop-blur-lg transition-all focus:top-4"
>
Skip to content
{t.a11y.skipToContent}
</a>
<header
class="app-layout flex flex-col items-center justify-between sm:flex-row"
>
<div
id="top-nav-wrap"
class:list={[
"py-4 sm:py-6",
"border-b border-border",
"relative w-full bg-background",
"flex items-baseline justify-between sm:items-center",
]}
class="border-border bg-background relative flex w-full items-baseline justify-between border-b py-4 sm:items-center sm:py-6"
>
<a
href="/"
href={getRelativeLocaleUrl(locale, "")}
class="absolute py-1 text-xl leading-8 font-semibold whitespace-nowrap sm:static sm:my-auto sm:text-2xl sm:leading-none"
>
{SITE.title}
{site.title}
</a>
<nav
id="nav-menu"
@@ -55,83 +61,100 @@ const isActive = (path: string) => {
<button
id="menu-btn"
class="focus-outline self-end p-2 sm:hidden"
aria-label="Open Menu"
aria-label={t.a11y.openMenu}
aria-expanded="false"
aria-controls="menu-items"
data-label-open={t.a11y.openMenu}
data-label-close={t.a11y.closeMenu}
>
<IconX id="close-icon" class="hidden" />
<IconMenuDeep id="menu-icon" />
</button>
<ul
id="menu-items"
class:list={[
"mt-4 grid w-44 grid-cols-2 place-content-center gap-2",
"[&>li>a]:block [&>li>a]:px-4 [&>li>a]:py-3 [&>li>a]:text-center [&>li>a]:font-medium [&>li>a]:hover:text-accent sm:[&>li>a]:px-2 sm:[&>li>a]:py-1",
"hidden",
"sm:mt-0 sm:flex sm:w-auto sm:gap-x-5 sm:gap-y-0",
]}
class="[&>li>a]:hover:text-accent mt-4 hidden w-44 grid-cols-2 place-content-center gap-2 sm:mt-0 sm:flex sm:w-auto sm:gap-x-5 sm:gap-y-0 sm:[&>li]:h-8 [&>li>a]:block [&>li>a]:px-4 [&>li>a]:py-3 [&>li>a]:text-center [&>li>a]:font-medium sm:[&>li>a]:px-2 sm:[&>li>a]:py-1"
>
<li class="col-span-2">
<a href="/posts" class:list={{ "active-nav": isActive("/posts") }}>
Posts
<a
href={getRelativeLocaleUrl(locale, "posts")}
class:list={{ "active-nav": isActive("/posts") }}
>
{t.nav.posts}
</a>
</li>
<li class="col-span-2">
<a href="/tags" class:list={{ "active-nav": isActive("/tags") }}>
Tags
<a
href={getRelativeLocaleUrl(locale, "tags")}
class:list={{ "active-nav": isActive("/tags") }}
>
{t.nav.tags}
</a>
</li>
<li class="col-span-2">
<a href="/about" class:list={{ "active-nav": isActive("/about") }}>
About
<a
href={getRelativeLocaleUrl(locale, "about")}
class:list={{ "active-nav": isActive("/about") }}
>
{t.nav.about}
</a>
</li>
{
SITE.showArchives && (
features.showArchives && (
<li class="col-span-2">
<LinkButton
href="/archives"
href={getRelativeLocaleUrl(locale, "archives")}
class:list={[
"focus-outline flex justify-center p-3 sm:p-1",
{
"active-nav [&>svg]:stroke-accent": isActive("/archives"),
},
"focus-outline flex size-full justify-center p-3 sm:relative sm:size-8 sm:p-0",
{ "active-nav": isActive("/archives") },
]}
title="Archives"
aria-label="archives"
title={t.nav.archives}
aria-label={t.nav.archives}
>
<IconArchive class="hidden sm:inline-block" />
<span class="sm:sr-only">Archives</span>
<IconArchive class="hidden sm:absolute sm:top-1/2 sm:left-1/2 sm:block sm:size-6 sm:-translate-x-1/2 sm:-translate-y-1/2" />
<span class="sm:sr-only">{t.nav.archives}</span>
{isActive("/archives") && (
<IconUnderline
aria-hidden="true"
class="scale-125 max-sm:hidden sm:absolute sm:bottom-0 sm:w-6"
/>
)}
</LinkButton>
</li>
)
}
<li class="col-span-1 flex items-center justify-center">
<LinkButton
href="/search"
class:list={[
"focus-outline flex p-3 sm:p-1",
{ "[&>svg]:stroke-accent": isActive("/search") },
]}
title="Search"
aria-label="search"
>
<IconSearch />
<span class="sr-only">Search</span>
</LinkButton>
</li>
{
SITE.lightAndDarkMode && (
features.search !== false && (
<li class="col-span-1 flex items-center justify-center">
<LinkButton
href={getRelativeLocaleUrl(locale, "search")}
class:list={["focus-outline relative size-8"]}
title={t.nav.search}
aria-label={t.nav.search}
>
<IconSearch class="absolute top-1/2 left-1/2 size-6 -translate-x-1/2 -translate-y-1/2" />
<span class="sr-only">{t.nav.search}</span>
{isActive("/search") && (
<IconUnderline
aria-hidden="true"
class="absolute bottom-0 w-6 scale-125 max-sm:inset-s-2"
/>
)}
</LinkButton>
</li>
)
}
{
features.lightAndDarkMode && (
<li class="col-span-1 flex items-center justify-center">
<button
id="theme-btn"
class="focus-outline relative size-12 p-4 sm:size-8 hover:[&>svg]:stroke-accent"
title="Toggles light & dark"
class="focus-outline hover:[&>svg]:stroke-accent relative size-12 p-4 sm:size-8"
title={t.a11y.toggleTheme}
aria-label="auto"
aria-live="polite"
>
<IconMoon class="absolute top-[50%] left-[50%] -translate-[50%] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<IconSunHigh class="absolute top-[50%] left-[50%] -translate-[50%] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<IconMoon class="absolute top-[50%] left-[50%] translate-[-50%] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<IconSunHigh class="absolute top-[50%] left-[50%] translate-[-50%] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</button>
</li>
)
@@ -143,20 +166,24 @@ const isActive = (path: string) => {
<script>
function toggleNav() {
const menuBtn = document.querySelector("#menu-btn");
const menuBtn = document.querySelector<HTMLButtonElement>("#menu-btn");
const menuItems = document.querySelector("#menu-items");
const menuIcon = document.querySelector("#menu-icon");
const closeIcon = document.querySelector("#close-icon");
if (!menuBtn || !menuItems || !menuIcon || !closeIcon) return;
const openLabel = menuBtn.dataset.labelOpen ?? "Open menu";
const closeLabel = menuBtn.dataset.labelClose ?? "Close menu";
menuBtn.addEventListener("click", () => {
const openMenu = menuBtn.getAttribute("aria-expanded") === "true";
menuBtn.setAttribute("aria-expanded", openMenu ? "false" : "true");
menuBtn.setAttribute("aria-label", openMenu ? "Open Menu" : "Close Menu");
menuBtn.setAttribute("aria-label", openMenu ? openLabel : closeLabel);
menuItems.classList.toggle("hidden");
menuItems.classList.toggle("grid");
menuIcon.classList.toggle("hidden");
closeIcon.classList.toggle("hidden");
});
@@ -164,6 +191,5 @@ const isActive = (path: string) => {
toggleNav();
// Runs on view transitions navigation
document.addEventListener("astro:after-swap", toggleNav);
</script>
+38
View File
@@ -0,0 +1,38 @@
---
import { getRelativeLocaleUrl } from "astro:i18n";
import config from "@/config";
type Props = {
pageTitle: string | [string, string];
pageDesc?: string;
class?: string;
};
const { pageTitle, pageDesc, class: className } = Astro.props;
const locale = Astro.currentLocale ?? config.site.lang;
const backUrl = config.features.showBackButton
? Astro.url.pathname
: getRelativeLocaleUrl(locale, "");
---
<main
data-backUrl={backUrl}
id="main-content"
class:list={["app-layout pb-4", className]}
>
<h1 class="text-2xl font-semibold sm:text-3xl">{pageTitle}</h1>
<p class="mt-2 mb-6 italic">{pageDesc}</p>
<slot />
</main>
<script>
document.addEventListener("astro:page-load", () => {
const mainContent: HTMLElement | null =
document.querySelector("#main-content");
const backUrl = mainContent?.dataset?.backurl;
if (backUrl) {
sessionStorage.setItem("backUrl", backUrl);
}
});
</script>
+11 -8
View File
@@ -4,38 +4,41 @@ import type { CollectionEntry } from "astro:content";
import IconArrowLeft from "@/assets/icons/IconArrowLeft.svg";
import IconArrowRight from "@/assets/icons/IconArrowRight.svg";
import LinkButton from "./LinkButton.astro";
import { useTranslations } from "@/i18n";
type Props = {
page: Page<CollectionEntry<"blog">>;
page: Page<CollectionEntry<"posts">>;
};
const { page } = Astro.props;
const t = useTranslations(Astro.currentLocale);
---
{
page.lastPage > 1 && (
<nav
class="mt-auto mb-8 flex justify-center"
class="mt-auto mb-8 flex justify-center gap-4"
role="navigation"
aria-label="Pagination Navigation"
>
<LinkButton
disabled={!page.url.prev}
href={page.url.prev as string}
class:list={["me-4 select-none", { "opacity-50": !page.url.prev }]}
aria-label="Goto Previous Page"
class:list={["select-none", { "opacity-50": !page.url.prev }]}
aria-label={t.a11y.goToPreviousPage}
>
<IconArrowLeft class="inline-block rtl:rotate-180" />
Prev
{t.pagination.prev}
</LinkButton>
{page.currentPage} / {page.lastPage}
<LinkButton
disabled={!page.url.next}
href={page.url.next as string}
class:list={["ms-4 select-none", { "opacity-50": !page.url.next }]}
aria-label="Goto Next Page"
class:list={["select-none", { "opacity-50": !page.url.next }]}
aria-label={t.a11y.goToNextPage}
>
Next
{t.pagination.next}
<IconArrowRight class="inline-block rtl:rotate-180" />
</LinkButton>
</nav>
+34
View File
@@ -0,0 +1,34 @@
---
type Props = {
class?: string;
variant?: /** No borders */
| "minimal"
/** 1st, 3rd, 5th … body rows tinted — classic zebra. */
| "striped"
/** 1st, 3rd, 5th … body rows tinted + no borders. */
| "striped-minimal";
};
const { class: className, variant } = Astro.props;
const variantClasses = {
minimal: "[&_td]:border-0 [&_th]:border-0",
striped: "[&_tbody_tr]:odd:bg-muted/25",
};
---
<div
data-table-variant={variant}
class:list={[
"overflow-hidden [&_table]:my-0 [&_table]:min-w-xl",
variant === "minimal" && variantClasses.minimal,
variant === "striped" && variantClasses.striped,
variant === "striped-minimal" &&
`${variantClasses.minimal} ${variantClasses.striped}`,
className,
]}
>
<div class="relative w-full overflow-x-auto">
<slot />
</div>
</div>
-26
View File
@@ -1,26 +0,0 @@
---
import { SHARE_LINKS } from "@/constants";
import LinkButton from "./LinkButton.astro";
const URL = Astro.url;
---
{
SHARE_LINKS.length > 0 && (
<div class="flex flex-none flex-col items-center justify-center gap-1 md:items-start">
<span class="italic">Share this post on:</span>
<div class="text-center">
{SHARE_LINKS.map(social => (
<LinkButton
href={`${social.href + URL}`}
class="scale-90 p-2 hover:rotate-6 sm:p-1"
title={social.linkTitle}
>
<social.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">{social.linkTitle}</span>
</LinkButton>
))}
</div>
</div>
)
}
+28 -11
View File
@@ -1,19 +1,36 @@
---
import { SOCIALS } from "@/constants";
import config from "@/config";
import LinkButton from "./LinkButton.astro";
const { site, socials } = config;
const icons = import.meta.glob<{
default: (_props: Record<string, unknown>) => unknown;
}>("/src/assets/icons/socials/*.svg");
const items = await Promise.all(
socials.map(async ({ name, url, linkTitle }) => {
const mod = await icons[`/src/assets/icons/socials/${name}.svg`]?.();
const Icon = mod?.default;
const title =
linkTitle ??
(url.startsWith("mailto:") || name === "mail"
? `Send an email to ${site.title}`
: `${site.title} on ${name.charAt(0).toUpperCase() + name.slice(1)}`);
return { url, title, Icon };
})
);
---
<div class="flex flex-wrap items-center gap-1">
{
SOCIALS.map(social => (
<LinkButton
href={social.href}
class="p-2 hover:rotate-6 sm:p-1"
title={social.linkTitle}
>
<social.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">{social.linkTitle}</span>
</LinkButton>
))
items.map(({ url, title, Icon }) =>
Icon ? (
<LinkButton href={url} class="p-2 hover:rotate-6 sm:p-1" title={title}>
<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>
+8 -4
View File
@@ -1,5 +1,7 @@
---
import { getRelativeLocaleUrl } from "astro:i18n";
import IconHash from "@/assets/icons/IconHash.svg";
import config from "@/config";
type Props = {
tag: string;
@@ -8,17 +10,19 @@ type Props = {
};
const { tag, tagName, size = "lg" } = Astro.props;
const locale = Astro.currentLocale ?? config.site.lang;
---
<li>
<a
href={`/tags/${tag}/`}
href={getRelativeLocaleUrl(locale, `tags/${tag}/`)}
transition:name={tag}
class:list={[
"flex items-center gap-0.5",
"border-b-2 border-dashed border-foreground",
"hover:-mt-0.5 hover:border-accent hover:text-accent",
"focus-visible:border-none focus-visible:text-accent",
"border-foreground border-b-2 border-dashed",
"hover:border-accent hover:text-accent hover:-mt-0.5",
"focus-visible:text-accent focus-visible:border-none",
{ "text-sm": size === "sm" },
{ "text-lg": size === "lg" },
]}