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
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
matrix:
node-version: [20]
node-version: ["24"]
steps:
- name: "☁️ Checkout repository"
-1
View File
@@ -24,5 +24,4 @@ pnpm-debug.log*
.idea/
# pagefind
public/pagefind
+3 -1
View File
@@ -7,8 +7,10 @@
!/.github
!tsconfig.json
!astro.config.ts
!.prettierrc.mjs
!package.json
!.prettierrc
!eslint.config.js
!README.md
# Ignore MDX files
*.mdx
+20
View File
@@ -0,0 +1,20 @@
{
"arrowParens": "avoid",
"semi": true,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}
-22
View File
@@ -1,22 +0,0 @@
/** @type {import("prettier").Config} */
export default {
arrowParens: "avoid",
semi: true,
tabWidth: 2,
printWidth: 80,
singleQuote: false,
jsxSingleQuote: false,
trailingComma: "es5",
bracketSpacing: true,
endOfLine: "lf",
plugins: ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
tailwindStylesheet: "./src/styles/global.css",
overrides: [
{
files: "*.astro",
options: {
parser: "astro",
},
},
],
};
+43 -57
View File
@@ -1,6 +1,6 @@
# AstroPaper 📄
![AstroPaper](public/astropaper-og.jpg)
![AstroPaper](public/default-og.jpg)
[![Figma](https://img.shields.io/badge/Figma-F24E1E?style=for-the-badge&logo=figma&logoColor=white)](https://www.figma.com/community/file/1356898632249991861)
![Typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)
![GitHub](https://img.shields.io/github/license/satnaing/astro-paper?color=%232F3741&style=for-the-badge)
@@ -19,12 +19,15 @@ Read [the blog posts](https://astro-paper.pages.dev/posts/) or check [the README
- [x] responsive (mobile ~ desktops)
- [x] SEO-friendly
- [x] light & dark mode
- [x] fuzzy search
- [x] static search ([Pagefind](https://pagefind.app/))
- [x] draft posts & pagination
- [x] sitemap & rss feed
- [x] MDX support
- [x] collapsible table of contents
- [x] followed best practices
- [x] highly customizable
- [x] dynamic OG image generation for blog posts [#15](https://github.com/satnaing/astro-paper/pull/15) ([Blog Post](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/))
- [x] dynamic OG image generation for blog posts ([Blog Post](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/))
- [x] i18n ready
_Note: I've tested screen-reader accessibility of AstroPaper using **VoiceOver** on Mac and **TalkBack** on Android. I couldn't test all other screen-readers out there. However, accessibility enhancements in AstroPaper should be working fine on others as well._
@@ -43,44 +46,42 @@ Inside of AstroPaper, you'll see the following folders and files:
```bash
/
├── public/
│ ├── pagefind/ # auto-generated when build
│ ├── pagefind/ # auto-generated on build
│ ├── favicon.svg
│ └── astropaper-og.jpg
│ └── default-og.jpg
├── src/
│ ├── assets/
│ │ ├── icons/
│ │ └── images/
│ ├── components/
│ ├── data/
│ │ ── blog/
│ ├── content/
│ │ ── pages/
│ │ │ └── about.md
│ │ └── posts/
│ │ └── some-blog-posts.md
│ ├── i18n/
│ ├── layouts/
│ ├── pages/
│ ├── scripts/
│ ├── styles/
│ ├── types/
│ ├── utils/
│ ├── config.ts
── constants.ts
│ ├── content.config.ts
│ ├── env.d.ts
│ └── remark-collapse.d.ts
── content.config.ts
├── astro-paper.config.ts # user-defined configurations
└── astro.config.ts
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
Any static assets, like images, can be placed in the `public/` directory.
All blog posts are stored in `src/data/blog` directory.
All blog posts are stored in the `src/content/posts/` directory. You can organise posts into subdirectories — the subdirectory name becomes part of the post URL.
## 📖 Documentation
Documentation can be read in two formats\_ _markdown_ & _blog post_.
- Configuration - [markdown](src/data/blog/how-to-configure-astropaper-theme.md) | [blog post](https://astro-paper.pages.dev/posts/how-to-configure-astropaper-theme/)
- Add Posts - [markdown](src/data/blog/adding-new-post.md) | [blog post](https://astro-paper.pages.dev/posts/adding-new-posts-in-astropaper-theme/)
- Customize Color Schemes - [markdown](src/data/blog/customizing-astropaper-theme-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/)
- Predefined Color Schemes - [markdown](src/data/blog/predefined-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/predefined-color-schemes/)
- Configuration - [markdown](src/content/posts/how-to-configure-astropaper-theme.md) | [blog post](https://astro-paper.pages.dev/posts/how-to-configure-astropaper-theme/)
- Add Posts - [markdown](src/content/posts/adding-new-post.md) | [blog post](https://astro-paper.pages.dev/posts/adding-new-posts-in-astropaper-theme/)
- Customize Color Schemes - [markdown](src/content/posts/customizing-astropaper-theme-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/)
- Predefined Color Schemes - [markdown](src/content/posts/predefined-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/predefined-color-schemes/)
## 💻 Tech Stack
@@ -88,12 +89,12 @@ Documentation can be read in two formats\_ _markdown_ & _blog post_.
**Type Checking** - [TypeScript](https://www.typescriptlang.org/)
**Styling** - [TailwindCSS](https://tailwindcss.com/)
**UI/UX** - [Figma Design File](https://www.figma.com/community/file/1356898632249991861)
**Static Search** - [FuseJS](https://pagefind.app/)
**Static Search** - [Pagefind](https://pagefind.app/)
**Icons** - [Tablers](https://tabler-icons.io/)
**Code Formatting** - [Prettier](https://prettier.io/)
**Deployment** - [Cloudflare Pages](https://pages.cloudflare.com/)
**Illustration in About Page** - [https://freesvgillustration.com](https://freesvgillustration.com/)
**Linting** - [ESLint](https://eslint.org)
**Dynamic OG images** - [Satori](https://github.com/vercel/satori) + [Sharp](https://sharp.pixelplumbing.com/) + [Astro Fonts](https://docs.astro.build/en/guides/fonts/)
## 👨🏻‍💻 Running Locally
@@ -120,26 +121,21 @@ Then start the project by running the following commands:
pnpm install
# start running the project
pnpm run dev
```
As an alternative approach, if you have Docker installed, you can use Docker to run this project locally. Here's how:
```bash
# Build the Docker image
docker build -t astropaper .
# Run the Docker container
docker run -p 4321:80 astropaper
pnpm dev
```
## Google Site Verification (optional)
You can easily add your [Google Site Verification HTML tag](https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag) in AstroPaper using an environment variable. This step is optional. If you don't add the following environment variable, the google-site-verification tag won't appear in the HTML `<head>` section.
You can add your [Google Site Verification HTML tag](https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag) by setting `site.googleVerification` in `astro-paper.config.ts`:
```bash
# in your environment variable file (.env)
PUBLIC_GOOGLE_SITE_VERIFICATION=your-google-site-verification-value
```ts file="astro-paper.config.ts"
export default defineAstroPaperConfig({
site: {
// ...
googleVerification: "your-google-site-verification-value",
},
// ...
});
```
> See [this discussion](https://github.com/satnaing/astro-paper/discussions/334#discussioncomment-10139247) for adding AstroPaper to the Google Search Console.
@@ -148,32 +144,22 @@ PUBLIC_GOOGLE_SITE_VERIFICATION=your-google-site-verification-value
All commands are run from the root of the project, from a terminal:
> **_Note!_** For `Docker` commands we must have it [installed](https://docs.docker.com/engine/install/) in your machine.
| Command | Action |
| :----------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm install` | Installs dependencies |
| `pnpm run dev` | Starts local dev server at `localhost:4321` |
| `pnpm run build` | Build your production site to `./dist/` |
| `pnpm run preview` | Preview your build locally, before deploying |
| `pnpm run format:check` | Check code format with Prettier |
| `pnpm run format` | Format codes with Prettier |
| `pnpm run sync` | Generates TypeScript types for all Astro modules. [Learn more](https://docs.astro.build/en/reference/cli-reference/#astro-sync). |
| `pnpm run lint` | Lint with ESLint |
| `docker compose up -d` | Run AstroPaper on docker, You can access with the same hostname and port informed on `dev` command. |
| `docker compose run app npm install` | You can run any command above into the docker container. |
| `docker build -t astropaper .` | Build Docker image for AstroPaper. |
| `docker run -p 4321:80 astropaper` | Run AstroPaper on Docker. The website will be accessible at `http://localhost:4321`. |
> **_Warning!_** Windows PowerShell users may need to install the [concurrently package](https://www.npmjs.com/package/concurrently) if they want to [run diagnostics](https://docs.astro.build/en/reference/cli-reference/#astro-check) during development (`astro check --watch & astro dev`). For more info, see [this issue](https://github.com/satnaing/astro-paper/issues/113).
| Command | Action |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm install` | Installs dependencies |
| `pnpm dev` | Starts local dev server at `localhost:4321` |
| `pnpm build` | Type-checks, builds the site, runs Pagefind indexing, and copies the index to `public/pagefind/` |
| `pnpm preview` | Preview your build locally, before deploying |
| `pnpm sync` | Generates TypeScript types for all Astro modules. [Learn more](https://docs.astro.build/en/reference/cli-reference/#astro-sync). |
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
## ✨ Feedback & Suggestions
If you have any suggestions/feedback, you can contact me via [my email](mailto:contact@satnaing.dev). Alternatively, feel free to open an issue if you find bugs or want to request new features.
If you have any suggestions/feedback, you can contact me via [my email](mailto:satnaingdev+astropaper@gmail.com). Alternatively, feel free to open an issue if you find bugs or want to request new features.
## 📜 License
Licensed under the MIT License, Copyright © 2025
Licensed under the MIT License, Copyright © 2026
---
+45
View File
@@ -0,0 +1,45 @@
import { defineAstroPaperConfig } from "./src/types/config";
export default defineAstroPaperConfig({
site: {
url: "https://astro-paper.pages.dev/",
title: "AstroPaper",
description: "A minimal, responsive and SEO-friendly Astro blog theme.",
author: "Sat Naing",
profile: "https://satna.ing",
ogImage: "default-og.jpg",
lang: "en",
timezone: "Asia/Bangkok",
dir: "ltr",
},
posts: {
perPage: 4,
perIndex: 4,
scheduledPostMargin: 15 * 60 * 1000,
},
features: {
lightAndDarkMode: true,
dynamicOgImage: true,
showArchives: true,
showBackButton: true,
editPost: {
enabled: true,
url: "https://github.com/satnaing/astro-paper/edit/main/",
},
search: "pagefind",
},
socials: [
{ name: "github", url: "https://github.com/satnaing/astro-paper" },
{ name: "x", url: "https://x.com/username" },
{ name: "linkedin", url: "https://www.linkedin.com/in/username/" },
{ name: "mail", url: "mailto:yourmail@gmail.com" },
],
shareLinks: [
{ name: "whatsapp", url: "https://wa.me/?text=" },
{ name: "facebook", url: "https://www.facebook.com/sharer.php?u=" },
{ name: "x", url: "https://x.com/intent/post?url=" },
{ name: "telegram", url: "https://t.me/share/url?url=" },
{ name: "pinterest", url: "https://pinterest.com/pin/create/button/?url=" },
{ name: "mail", url: "mailto:?subject=See%20this%20post&body=" },
],
});
+31 -28
View File
@@ -1,5 +1,11 @@
import { defineConfig, envField, fontProviders } from "astro/config";
import {
defineConfig,
envField,
fontProviders,
svgoOptimizer,
} from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import remarkToc from "remark-toc";
import remarkCollapse from "remark-collapse";
@@ -9,20 +15,27 @@ import {
transformerNotationWordHighlight,
} from "@shikijs/transformers";
import { transformerFileName } from "./src/utils/transformers/fileName";
import { SITE } from "./src/config";
import config from "./astro-paper.config";
// https://astro.build/config
export default defineConfig({
site: SITE.website,
site: config.site.url,
integrations: [
mdx(),
sitemap({
filter: page => SITE.showArchives || !page.endsWith("/archives"),
filter: page =>
config.features?.showArchives !== false || !page.endsWith("/archives/"),
}),
],
i18n: {
locales: ["en"],
defaultLocale: "en",
routing: {
prefixDefaultLocale: false,
},
},
markdown: {
remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]],
shikiConfig: {
// For more themes, visit https://shiki.style/themes
themes: { light: "min-light", dark: "night-owl" },
defaultColor: false,
wrap: false,
@@ -35,19 +48,19 @@ export default defineConfig({
},
},
vite: {
// eslint-disable-next-line
// @ts-ignore
// This will be fixed in Astro 6 with Vite 7 support
// See: https://github.com/withastro/astro/issues/14030
plugins: [tailwindcss()],
optimizeDeps: {
exclude: ["@resvg/resvg-js"],
},
fonts: [
{
name: "Google Sans Code",
cssVariable: "--font-google-sans-code",
provider: fontProviders.google(),
fallbacks: ["monospace"],
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
formats: ["woff", "ttf"],
},
},
image: {
responsiveStyles: true,
layout: "constrained",
},
],
env: {
schema: {
PUBLIC_GOOGLE_SITE_VERIFICATION: envField.string({
@@ -58,16 +71,6 @@ export default defineConfig({
},
},
experimental: {
preserveScriptOrder: true,
fonts: [
{
name: "Google Sans Code",
cssVariable: "--font-google-sans-code",
provider: fontProviders.google(),
fallbacks: ["monospace"],
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
},
],
svgOptimizer: svgoOptimizer(),
},
});
+11 -7
View File
@@ -1,18 +1,22 @@
import eslintPluginAstro from "eslint-plugin-astro";
import globals from "globals";
import tseslint from "typescript-eslint";
import tsParser from "@typescript-eslint/parser";
export default [
...tseslint.configs.recommended,
...eslintPluginAstro.configs.recommended,
{
files: ["**/*.astro"],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
parserOptions: {
parser: tsParser,
},
},
},
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tsParser,
},
},
{ rules: { "no-console": "error" } },
{ ignores: ["dist/**", ".astro", "public/pagefind/**"] },
{ ignores: ["dist/**", ".astro/**", "public/pagefind/**"] },
];
+25 -24
View File
@@ -1,7 +1,10 @@
{
"name": "astro-paper",
"name": "astro-paper-v6",
"type": "module",
"version": "5.5.1",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build && pagefind --site dist && cp -r dist/pagefind public/",
@@ -13,35 +16,33 @@
"lint": "eslint ."
},
"dependencies": {
"@astrojs/rss": "^4.0.14",
"@astrojs/sitemap": "^3.6.0",
"@resvg/resvg-js": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.6",
"dayjs": "^1.11.19",
"@astrojs/mdx": "^5.0.6",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@tailwindcss/vite": "^4.3.0",
"astro": "^6.3.3",
"dayjs": "^1.11.20",
"lodash.kebabcase": "^4.1.1",
"remark-collapse": "^0.1.2",
"remark-toc": "^9.0.0",
"satori": "^0.18.3",
"satori": "^0.26.0",
"sharp": "^0.34.5",
"slugify": "^1.6.6",
"tailwindcss": "^4.1.18"
"slugify": "^1.6.9",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@pagefind/default-ui": "^1.4.0",
"@shikijs/transformers": "^3.20.0",
"@astrojs/check": "^0.9.9",
"@pagefind/default-ui": "^1.5.2",
"@shikijs/transformers": "^4.0.2",
"@tailwindcss/typography": "^0.5.19",
"@types/lodash.kebabcase": "^4.1.9",
"@typescript-eslint/parser": "^8.51.0",
"eslint": "^9.39.2",
"eslint-plugin-astro": "^1.5.0",
"globals": "^16.5.0",
"pagefind": "^1.4.0",
"prettier": "^3.7.4",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0"
"@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.4.0",
"eslint-plugin-astro": "^1.7.0",
"pagefind": "^1.5.2",
"prettier": "3.8.3",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"typescript": "^6.0.3"
}
}
+1960 -1886
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>

Before

Width:  |  Height:  |  Size: 346 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 181 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M92.1951 34.9707C81.4183 35.4458 74.5487 30.1331 70.9707 20.242C68.4805 13.3456 63.9436 10.5669 56.6016 10.682C40.7584 10.9124 25.3302 13.144 10.6177 19.1622C6.1381 20.9907 3.4762 18.6295 0.885762 16.1532C-1.26101 14.0799 0.671043 9.90465 4.77854 8.59448C11.591 6.42046 18.5036 4.07366 25.545 3.18102C38.1967 1.5829 50.9913 0.992615 63.7145 0.0423814C70.7416 -0.475928 74.9781 3.82887 78.3557 9.17033C79.658 11.2148 80.3164 13.6912 81.2324 15.9804C84.0947 23.1359 85.3255 24.5324 92.968 23.2942C105.705 21.2498 118.286 18.2119 130.952 15.6492C145.664 12.6545 160.377 9.60229 175.147 6.86677C176.921 6.53563 179.039 8.13373 181 8.8248C179.97 10.3077 179.254 12.6257 177.88 13.1152C169.851 16.0091 161.765 18.8455 153.521 20.9907C136.762 25.3531 119.903 29.298 103.058 33.3005C99.5085 34.15 95.816 34.4379 92.1951 34.985V34.9707Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 941 B

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 406 B

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 624 B

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 509 B

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 429 B

Before

Width:  |  Height:  |  Size: 493 B

After

Width:  |  Height:  |  Size: 493 B

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 374 B

Before

Width:  |  Height:  |  Size: 484 B

After

Width:  |  Height:  |  Size: 484 B

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+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" },
]}
+40 -22
View File
@@ -1,23 +1,41 @@
export const SITE = {
website: "https://astro-paper.pages.dev/", // replace this with your deployed domain
author: "Sat Naing",
profile: "https://satnaing.dev/",
desc: "A minimal, responsive and SEO-friendly Astro blog theme.",
title: "AstroPaper",
ogImage: "astropaper-og.jpg",
lightAndDarkMode: true,
postPerIndex: 4,
postPerPage: 4,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
showArchives: true,
showBackButton: true, // show back button in post detail
editPost: {
enabled: true,
text: "Edit page",
url: "https://github.com/satnaing/astro-paper/edit/main/",
/**
* Internal resolved configuration used throughout the codebase.
*
* Prefer editing `astro-paper.config.ts` instead of this file. This module exists to
* apply defaults and expose a fully-resolved config shape (`ResolvedAstroPaperConfig`).
*/
import userConfig from "@/astro-paper.config";
import type { ResolvedAstroPaperConfig } from "./types/config";
import { PUBLIC_GOOGLE_SITE_VERIFICATION } from "astro:env/client";
const DEFAULT_OG_IMAGE = "default-og.jpg";
const config: ResolvedAstroPaperConfig = {
site: {
...userConfig.site,
ogImage: userConfig.site.ogImage ?? DEFAULT_OG_IMAGE,
lang: userConfig.site.lang ?? "en",
timezone: userConfig.site.timezone ?? "UTC",
dir: userConfig.site.dir ?? "ltr",
googleVerification:
userConfig.site.googleVerification || PUBLIC_GOOGLE_SITE_VERIFICATION,
},
dynamicOgImage: true,
dir: "ltr", // "rtl" | "auto"
lang: "en", // html lang code. Set this empty and default will be "en"
timezone: "Asia/Bangkok", // Default global timezone (IANA format) https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
} as const;
posts: {
perPage: userConfig.posts?.perPage ?? 4,
perIndex: userConfig.posts?.perIndex ?? 4,
scheduledPostMargin:
userConfig.posts?.scheduledPostMargin ?? 15 * 60 * 1000,
},
features: {
lightAndDarkMode: userConfig.features?.lightAndDarkMode ?? true,
dynamicOgImage: userConfig.features?.dynamicOgImage ?? true,
showArchives: userConfig.features?.showArchives ?? true,
showBackButton: userConfig.features?.showBackButton ?? true,
editPost: userConfig.features?.editPost ?? { enabled: false },
search: userConfig.features?.search ?? "pagefind",
},
socials: userConfig.socials ?? [],
shareLinks: userConfig.shareLinks ?? [],
};
export default config;
-83
View File
@@ -1,83 +0,0 @@
import type { Props } from "astro";
import IconMail from "@/assets/icons/IconMail.svg";
import IconGitHub from "@/assets/icons/IconGitHub.svg";
import IconBrandX from "@/assets/icons/IconBrandX.svg";
import IconLinkedin from "@/assets/icons/IconLinkedin.svg";
import IconWhatsapp from "@/assets/icons/IconWhatsapp.svg";
import IconFacebook from "@/assets/icons/IconFacebook.svg";
import IconTelegram from "@/assets/icons/IconTelegram.svg";
import IconPinterest from "@/assets/icons/IconPinterest.svg";
import { SITE } from "@/config";
interface Social {
name: string;
href: string;
linkTitle: string;
icon: (_props: Props) => Element;
}
export const SOCIALS: Social[] = [
{
name: "GitHub",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on GitHub`,
icon: IconGitHub,
},
{
name: "X",
href: "https://x.com/username",
linkTitle: `${SITE.title} on X`,
icon: IconBrandX,
},
{
name: "LinkedIn",
href: "https://www.linkedin.com/in/username/",
linkTitle: `${SITE.title} on LinkedIn`,
icon: IconLinkedin,
},
{
name: "Mail",
href: "mailto:yourmail@gmail.com",
linkTitle: `Send an email to ${SITE.title}`,
icon: IconMail,
},
] as const;
export const SHARE_LINKS: Social[] = [
{
name: "WhatsApp",
href: "https://wa.me/?text=",
linkTitle: `Share this post via WhatsApp`,
icon: IconWhatsapp,
},
{
name: "Facebook",
href: "https://www.facebook.com/sharer.php?u=",
linkTitle: `Share this post on Facebook`,
icon: IconFacebook,
},
{
name: "X",
href: "https://x.com/intent/post?url=",
linkTitle: `Share this post on X`,
icon: IconBrandX,
},
{
name: "Telegram",
href: "https://t.me/share/url?url=",
linkTitle: `Share this post via Telegram`,
icon: IconTelegram,
},
{
name: "Pinterest",
href: "https://pinterest.com/pin/create/button/?url=",
linkTitle: `Share this post on Pinterest`,
icon: IconPinterest,
},
{
name: "Mail",
href: "mailto:?subject=See%20this%20post&body=",
linkTitle: `Share this post via email`,
icon: IconMail,
},
] as const;
+18 -7
View File
@@ -1,14 +1,15 @@
import { defineCollection, z } from "astro:content";
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import { glob } from "astro/loaders";
import { SITE } from "@/config";
import config from "@/config";
export const BLOG_PATH = "src/data/blog";
export const BLOG_PATH = "src/content/posts";
const blog = defineCollection({
loader: glob({ pattern: "**/[^_]*.md", base: `./${BLOG_PATH}` }),
const posts = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: `./${BLOG_PATH}` }),
schema: ({ image }) =>
z.object({
author: z.string().default(SITE.author),
author: z.string().default(config.site.author),
pubDatetime: z.date(),
modDatetime: z.date().optional().nullable(),
title: z.string(),
@@ -23,4 +24,14 @@ const blog = defineCollection({
}),
});
export const collections = { blog };
const pages = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/pages" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
ogImage: z.string().optional(),
canonicalURL: z.string().optional(),
}),
});
export const collections = { posts, pages };
@@ -1,11 +1,11 @@
---
layout: ../layouts/AboutLayout.astro
title: "About"
description: "A bit about me and this blog."
---
AstroPaper is a minimal, accessible and SEO-friendly blog theme built with [Astro](https://astro.build/) and [Tailwind CSS](https://tailwindcss.com/).
![Astro Paper](public/astropaper-og.jpg)
![Astro Paper](@/assets/images/astropaper-og.jpg)
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

@@ -0,0 +1,235 @@
---
author: Sat Naing
pubDatetime: 2022-09-26T12:13:24Z
modDatetime: 2026-05-16T11:29:26.658Z
title: Predefined color schemes
slug: predefined-color-schemes
featured: false
draft: false
tags:
- color-schemes
description:
Some of the well-crafted, updated predefined color schemes for AstroPaper.
---
import ResponsiveTable from '@/components/ResponsiveTable.astro';
AstroPaper includes a collection of predefined color schemes that can be applied to customize the theme appearance. Each scheme defines a complete set of CSS custom properties (variables) for light and dark modes.
## Table of contents
## Quick Start
To apply a predefined color scheme, copy the CSS variable definitions into your theme configuration. For detailed setup instructions, see the [color scheme configuration guide](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/).
## CSS Variables Reference
All color schemes use the following CSS custom properties:
<ResponsiveTable variant="striped-minimal">
| Variable | Purpose |
| --------------------- | -------------------------------------------------------- |
| `--background` | Primary background color |
| `--foreground` | Primary text color |
| `--accent` | Accent/interactive elements (links, buttons, highlights) |
| `--accent-foreground` | Text color on accent backgrounds |
| `--muted` | Secondary background color for subtle sections |
| `--muted-foreground` | Text color for secondary content |
| `--border` | Border and divider color |
</ResponsiveTable>
## Light Schemes
Light color schemes are defined using the CSS selectors `:root` and `[data-theme="light"]`.
### Paper Light
Default AstroPaper light theme.
![Paper Light](assets/paper-light.png)
```css
:root,
[data-theme="light"] {
--background: #fdfdfd;
--foreground: #282728;
--accent: #006cac;
--accent-foreground: #ffffff;
--muted: #e6e6e6;
--muted-foreground: #6b7280;
--border: #ece9e9;
}
```
### Kha-Yan
Purple-focused light scheme with warm background.
![Kha-Yan](assets/kha-yan.png)
```css
:root,
[data-theme="light"] {
--background: #fefaec;
--foreground: #120e01;
--accent: #6e10cf;
--accent-foreground: #fefaec;
--muted: #dcdcdc;
--muted-foreground: #6b7280;
--border: #cdc4d6;
}
```
### Nila
Light purple scheme with cool blue undertones.
![Nila](assets/nila.png)
```css
:root,
[data-theme="light"] {
--background: #f6f6fb;
--foreground: #0c0c19;
--accent: #6760b4;
--accent-foreground: #f3f3f3;
--muted: #dddcea;
--muted-foreground: #54515b;
--border: #d8d6ec;
}
```
### Jadeite
Teal-accented light scheme with neutral background.
![Jadeite](assets/jadeite.png)
```css
:root,
[data-theme="light"] {
--background: #f6fcf7;
--foreground: #060b07;
--accent: #027c6d;
--accent-foreground: #ffffff;
--muted: #c9e4e2;
--muted-foreground: #6b7280;
--border: #d4e1df;
}
```
### Pyit Tine Htaung
Red and gold accent scheme with warm tones.
![Pyit Tine Htaung](assets/pyit-tine-htaung.png)
```css
:root,
[data-theme="light"] {
--background: #fffaf6;
--foreground: #060503;
--accent: #aa0215;
--accent-foreground: #ffcf75;
--muted: #ffdc98;
--muted-foreground: #54515b;
--border: #ffdc98;
}
```
## Dark Schemes
Dark color schemes are defined using the CSS selector `[data-theme="dark"]`.
### Paper Dark
Original AstroPaper dark theme with cyan accents.
![AstroPaper 1 default dark theme](https://user-images.githubusercontent.com/53733092/215769153-13b0ad8d-5ba2-44b1-af06-e5ae61293f62.png)
```css
[data-theme="dark"] {
--background: #2f3741;
--foreground: #e6e6e6;
--accent: #1ad9d9;
--accent-foreground: #0d2b2b;
--muted: #596b81;
--muted-foreground: #8faabb;
--border: #3b4655;
}
```
### Paper Dark II
Current default dark theme with orange accents.
![new dark color scheme - low contrast](https://user-images.githubusercontent.com/53733092/215772856-d5b7ae35-ddaa-4ed6-b0bf-3fa5dbcf834c.png)
```css
[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--accent-foreground: #ffffff;
--muted: #343f60;
--muted-foreground: #afb9ca;
--border: #ab4b08;
}
```
### Deep Purple
Vibrant magenta accents dark scheme.
![AstroPaper v3 new theme](https://github.com/satnaing/astro-paper/assets/53733092/c8b5d7e1-a3bc-4852-a5ad-4abf7b3cec79)
```css
[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #eb3fd3;
--accent-foreground: #1a0d1a;
--muted: #513f51;
--muted-foreground: #c09abc;
--border: #642451;
}
```
### Ember
Warm, muted dark scheme with red accents.
![Ember](assets/ember.png)
```css
[data-theme="dark"] {
--background: #1a1a1a;
--foreground: #f5efe4;
--accent: #ff3737;
--accent-foreground: #1a1a1a;
--muted: #38342f;
--muted-foreground: #a59a8c;
--border: #6f5648;
}
```
### Espresso
Brown-focused warm dark scheme.
![Coffee](assets/espresso.png)
```css
[data-theme="dark"] {
--background: #2f2f2f;
--foreground: #ebe5e1;
--accent: #ee781e;
--accent-foreground: #1a1a1a;
--muted: #4f4b44;
--muted-foreground: #ddbfa7;
--border: #6f5648;
}
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

@@ -2,7 +2,7 @@
pubDatetime: 2025-03-08T08:18:19.693Z
title: AstroPaper 5.0
slug: astro-paper-v5
featured: true
featured: false
ogImage: ../../../assets/images/AstroPaper-v5.png
tags:
- release
@@ -0,0 +1,199 @@
---
pubDatetime: 2026-05-17T07:15:45.792Z
title: AstroPaper 6.0
slug: astro-paper-v6
featured: true
ogImage: assets/AstroPaper-v6.png
tags:
- release
description: "AstroPaper v6: a from-scratch rewrite on Astro v6, Tailwind v4, and a new config system."
---
AstroPaper v6 is a complete rewrite built on Astro v6, Tailwind CSS v4, and TypeScript v6. This release replaces the legacy `SITE` / `constants.ts` configuration with a single unified config file and introduces several structural improvements across the codebase.
![AstroPaper v6](assets/AstroPaper-v6.png)
## Table of contents
## Major Changes
### Upgrade to Astro v6
AstroPaper now ships with Astro v6.3, which includes:
- **Stable Content Layer API** — `glob()` loader replaces the old `type: "content"` collection pattern.
- **Stable Fonts API** — `experimental.fonts` has graduated to a top-level `fonts` key in `astro.config.ts`.
- **TypeScript v6** — full support for the latest TypeScript compiler.
### New unified config system
The flat `SITE` object in `src/config.ts` and the separate `constants.ts` file have been replaced by a single `astro-paper.config.ts` at the project root. Use `defineAstroPaperConfig()` for full IntelliSense:
```ts file="astro-paper.config.ts"
import { defineAstroPaperConfig } from "./src/types/config";
export default defineAstroPaperConfig({
site: {
url: "https://your-site.com/",
title: "AstroPaper",
description: "…",
author: "Your Name",
lang: "en",
timezone: "UTC",
googleVerification: "your-verification-value",
},
posts: {
perPage: 4,
perIndex: 4,
scheduledPostMargin: 15 * 60 * 1000, // ms
},
features: {
lightAndDarkMode: true,
dynamicOgImage: true,
showArchives: true,
showBackButton: true,
editPost: { enabled: true, url: "https://github.com/…/edit/main/" },
search: "pagefind",
},
socials: [{ name: "github", url: "https://github.com/…" }],
shareLinks: [{ name: "x", url: "https://x.com/intent/post?url=" }],
});
```
All options — site metadata, pagination, feature flags, social links, and share links — now live in one file.
### Stable Fonts API
Font configuration has graduated from `experimental.fonts` to a top-level `fonts` key in `astro.config.ts`, matching Astro v6's stable API:
```ts file="astro.config.ts"
export default defineConfig({
fonts: [
{
name: "Google Sans Code",
cssVariable: "--font-google-sans-code",
provider: fontProviders.google(),
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
},
],
});
```
### MDX support
`@astrojs/mdx` is now included. Posts can use the `.mdx` extension to embed components, use JSX expressions, and import from other files. The content loader pattern `**/[^_]*.{md,mdx}` picks up both formats automatically.
### Content collection restructure
Blog posts have moved from `src/data/blog/` to `src/content/posts/`, aligning with Astro conventions. A new `pages` collection at `src/content/pages/` covers standalone pages (About, etc.). The `posts` collection uses Astro's `glob()` loader — `defineCollection` with `type: "content"` is no longer used:
```ts file="src/content.config.ts"
const posts = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/posts" }),
schema: ({ image }) =>
z.object({
author: z.string(),
pubDatetime: z.date(),
title: z.string(),
tags: z.array(z.string()).default(["others"]),
description: z.string(),
// …
}),
});
```
### Design token system
The 5-token color palette from v5 has grown to 7 tokens in `src/styles/theme.css`. Tokens are defined as CSS custom properties and registered to Tailwind v4 via `@theme inline`:
```css file="src/styles/theme.css"
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
}
:root,
[data-theme="light"] {
--background: #fdfdfd;
--foreground: #282728;
--accent: #006cac;
--accent-foreground: #ffffff;
--muted: #e6e6e6;
--muted-foreground: #6b7280;
--border: #ece9e9;
}
[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--accent-foreground: #ffffff;
--muted: #343f60;
--muted-foreground: #afb9ca;
--border: #ab4b08;
}
```
`theme.css` is a separate file imported by `global.css`. The two new tokens are `--accent-foreground` and `--muted-foreground`.
### i18n string extraction
All UI strings are extracted to `src/i18n/lang/en.ts` with the `UIStrings` interface. Adding a new language requires only a new file in `src/i18n/lang/`:
```ts file="src/i18n/lang/en.ts"
export default {
nav: { home: "Home", posts: "Posts" /* … */ },
post: { publishedAt: "Published at" /* … */ },
/* … */
} satisfies UIStrings;
```
The `tplStr()` helper handles parameterized strings so translators can reorder tokens freely.
### Base path and subdirectory deploy support
All internal links go through `getRelativeLocaleUrl()` and the `withBase.ts` helpers (`stripLocale`, `stripBase`, `getAssetPath`). Deploying to a subdirectory (e.g. `/astro-paper`) works without manual link updates.
### Google Site Verification via config
The preferred way to set Google Site Verification is `site.googleVerification` in `astro-paper.config.ts`:
```ts file="astro-paper.config.ts"
export default defineAstroPaperConfig({
site: {
// …
googleVerification: "your-google-site-verification-value",
},
});
```
The `PUBLIC_GOOGLE_SITE_VERIFICATION` environment variable is still supported as a fallback for cases where you prefer not to commit the value to the config file.
```bash file=".env"
PUBLIC_GOOGLE_SITE_VERIFICATION=your-google-site-verification-value
```
When both are set, `site.googleVerification` takes precedence.
## Other Notable Changes
- Updated and renamed helper/util functions.
- Adjacent post navigation (prev/next) is now computed once in `getStaticPaths` and passed as props — the component no longer fetches all posts per page.
- `_components/` scoping: post-specific components live under `pages/posts/[...slug]/_components/` and do not pollute the global `src/components/` directory.
- `PostLayout.astro` handles structured data and SEO only — post page logic lives in the page file itself.
## Summary
AstroPaper v6 retains its minimal, clean look while rebuilding the internals around Astro v6's new primitives. The config system is simpler, the codebase is easier to navigate, and the theme ships ready for i18n and subdirectory deployments out of the box.
## See also
- [Predefined Color Schemes](/posts/predefined-color-schemes/)
- [How to configure AstroPaper theme](/posts/how-to-configure-astropaper-theme/)
- [Adding new posts in AstroPaper](/posts/adding-new-posts-in-astropaper-theme)
+239
View File
@@ -0,0 +1,239 @@
---
author: Sat Naing
pubDatetime: 2022-09-23T15:22:00Z
modDatetime: 2026-05-17T04:47:08.212Z
title: Adding new posts in AstroPaper theme
slug: adding-new-posts-in-astropaper-theme
featured: true
draft: false
tags:
- docs
description: "Some rules & recommendations for creating or adding new posts using AstroPaper theme."
---
import ResponsiveTable from '@/components/ResponsiveTable.astro';
This guide covers the rules and conventions for creating new posts in AstroPaper — file placement, frontmatter fields, images, and syntax highlighting.
<figure>
<img
src="https://images.pexels.com/photos/159618/still-life-school-retro-ink-159618.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1"
alt="Free Classic wooden desk with writing materials, vintage clock, and a leather bag. Stock Photo"
/>
<figcaption class="text-center">
Photo by{" "}
<a href="https://www.pexels.com/photo/brown-wooden-desk-159618/">Pixabay</a>
</figcaption>
</figure>
## Table of contents
## Creating a Blog Post
To write a new blog post, create a markdown (or MDX) file inside the `src/content/posts/` directory.
You can organize posts into subdirectories to make content easier to manage. The subdirectory name becomes part of the post URL. For example, `src/content/posts/2025/example-post.md` will be available at `/posts/2025/example-post`.
If you want a subdirectory for organization only, without it affecting the URL, prefix the folder name with an underscore (`_`).
```bash
# Example: post file paths and their URLs
src/content/posts/very-first-post.md -> mysite.com/posts/very-first-post
src/content/posts/2025/example-post.md -> mysite.com/posts/2025/example-post
src/content/posts/_2026/another-post.md -> mysite.com/posts/another-post
src/content/posts/docs/_legacy/how-to.md -> mysite.com/posts/docs/how-to
src/content/posts/Example Dir/Dummy Post.md -> mysite.com/posts/example-dir/dummy-post
```
> Files and directories prefixed with `_` are excluded from routing. Use them for drafts, shared assets, or internal-only content.
## Frontmatter
Frontmatter is the main place to store metadata about a blog post. It lives at the top of the file in YAML format. Read more about frontmatter and its usage in [Astro documentation](https://docs.astro.build/en/guides/markdown-content/).
Here is the list of frontmatter properties for each post:
<ResponsiveTable variant="striped-minimal">
| Property | Description | Remark |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| **_title_** | Title of the post. (h1) | required<sup>\*</sup> |
| **_description_** | Description of the post. Used in post excerpt and site description of the post. | required<sup>\*</sup> |
| **_pubDatetime_** | Published datetime in ISO 8601 format. | required<sup>\*</sup> |
| **_modDatetime_** | Modified datetime in ISO 8601 format. (only add this property when a blog post is modified) | optional |
| **_author_** | Author of the post. | default = `site.author` |
| **_featured_** | Whether or not to display this post in the featured section of the home page. | default = false |
| **_draft_** | Mark this post as 'unpublished'. | default = false |
| **_tags_** | Related keywords for this post. Written in array YAML format. | default = others |
| **_ogImage_** | OG image of the post. Useful for social media sharing and SEO. Can be a remote URL or an image path relative to the current folder. | default = `site.ogImage` or generated OG image |
| **_canonicalURL_** | Canonical URL (absolute), in case the article already exists on another source. | default = `Astro.site` + `Astro.url.pathname` |
| **_hideEditPost_** | Hide the edit-post button under the post title. Applies only to the current post. | default = false |
| **_timezone_** | Specify a timezone in IANA format for the current post. Overrides the global `site.timezone` config for this post only. | default = `site.timezone` |
</ResponsiveTable>
> Tip! You can get an ISO 8601 datetime by running `new Date().toISOString()` in the console.
Only `title`, `description`, and `pubDatetime` fields in frontmatter must be specified.
Title and description (excerpt) are important for search engine optimization (SEO) and thus AstroPaper encourages you to include these in all blog posts.
If you omit `tags` in a blog post (in other words, if no tag is specified), the default tag `others` will be used as a tag for that post. You can set the default tag in `src/content.config.ts`:
```ts file="src/content.config.ts"
// ...
tags: z.array(z.string()).default(["others"]), // replace "others" with whatever you want
// ...
```
### Sample Frontmatter
Here is sample frontmatter for a post.
```yaml file="src/content/posts/sample-post.md"
---
title: The title of the post
author: your name
pubDatetime: 2022-09-21T05:17:19Z
featured: true
draft: false
tags:
- some
- example
- tags
ogImage: ../../assets/images/example.png # src/assets/images/example.png
# ogImage: "https://example.org/remote-image.png" # remote URL
description: This is the example description of the example post.
canonicalURL: https://example.org/my-article-was-already-posted-here
---
```
### VS Code snippets (optional)
AstroPaper includes workspace snippets to speed up creating new posts:
- **frontmatter**: inserts the recommended frontmatter block
- **template**: inserts a basic post template (including `## Table of contents`)
These snippets live in `.vscode/astro-paper.code-snippets`. If you use VS Code (or Cursor), they should be available automatically when you open the workspace.
## Adding table of contents
By default, a post does not include any table of contents (TOC). To include one, write `Table of contents` as an h2 heading (`##` in Markdown) and place it where you want it to appear:
```md
---
# frontmatter
---
Here are some recommendations, tips & tricks for creating new posts in AstroPaper blog theme.
<!-- [!code ++] -->
## Table of contents
<!-- the rest of the post -->
```
## Headings
There's one thing to note about headings. AstroPaper blog posts use `title` (from frontmatter) as the main heading of the post. Therefore, the rest of the headings in the post should use `h2` \~ `h6`.
This rule is not mandatory, but highly recommended for visual, accessibility, and SEO purposes.
## Syntax Highlighting
AstroPaper uses [Shiki](https://shiki.style/) as the default syntax highlighter, with [@shikijs/transformers](https://shiki.style/packages/transformers) for enhanced fenced code blocks. If you don't want to use the transformers, you can remove them:
```bash
pnpm remove @shikijs/transformers
```
```ts file="astro.config.ts"
// ...
// [!code --:5]
import {
transformerNotationDiff,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from "@shikijs/transformers";
export default defineConfig({
// ...
markdown: {
remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]],
shikiConfig: {
themes: { light: "min-light", dark: "night-owl" },
defaultColor: false,
wrap: false,
transformers: [
transformerFileName(),
// [!code --:3]
transformerNotationHighlight(),
transformerNotationWordHighlight(),
transformerNotationDiff({ matchAlgorithm: "v3" }),
],
},
},
// ...
});
```
## Storing Images for Blog Content
Here are two methods for storing images and using them inside a markdown file.
> Note: If you need to style optimized images in markdown, you should [use MDX](https://docs.astro.build/en/guides/images/#images-in-mdx-files).
### Inside `src/assets/` directory (recommended)
You can store images inside the `src/assets/` directory. These images will be automatically optimized by Astro through the [Image Service API](https://docs.astro.build/en/reference/image-service-reference/).
You can use a relative path or alias path (`@/assets/`) to reference these images.
Example: suppose you want to display `example.jpg` whose path is `src/assets/images/example.jpg`.
```md
![something](@/assets/images/example.jpg)
<!-- OR -->
![something](../../assets/images/example.jpg)
<!-- Using img tag or Image component won't work in markdown ❌ -->
<img src="@/assets/images/example.jpg" alt="something">
<!-- ^^ This is wrong -->
```
> Technically, you can store images inside any directory under `src`. `src/assets` is just a recommendation.
### Inside `public/` directory
You can store images inside the `public/` directory. Keep in mind that images stored in `public/` remain untouched by Astro, meaning they will be unoptimized and you need to handle image optimization yourself.
For these images, use an absolute path. They can be displayed using [markdown image syntax](https://www.markdownguide.org/basic-syntax/#images-1) or an HTML `img` tag.
Example: assume `example.jpg` is located at `public/assets/images/example.jpg`.
```md
![something](/assets/images/example.jpg)
<!-- OR -->
<img src="/assets/images/example.jpg" alt="something">
```
## Bonus
### Image compression
When putting images in a blog post (especially those in the `public/` directory), it is recommended to compress them. This will affect the overall performance of the website.
Recommended image compression sites:
- [TinyPng](https://tinypng.com/)
- [TinyJPG](https://tinyjpg.com/)
### OG Image
The default OG image will be used if a post does not specify one. Though not required, an OG image relevant to the post should be specified in the frontmatter. The recommended size for OG images is **_1200 X 640_** px.
> Since AstroPaper v1.4.0, OG images are generated automatically if not specified. Check out [the announcement](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/).
@@ -0,0 +1,103 @@
---
author: Sat Naing
pubDatetime: 2022-09-25T15:20:35Z
modDatetime: 2026-05-17T04:57:06.476Z
title: Customizing AstroPaper theme color schemes
featured: false
draft: false
tags:
- color-schemes
- docs
description:
How you can enable/disable light & dark mode; and customize color schemes
of AstroPaper theme.
---
import ResponsiveTable from '@/components/ResponsiveTable.astro';
This guide covers how to enable or disable light and dark mode, and how to customize the color scheme for the entire site.
## Table of contents
## Enable/disable light & dark mode
AstroPaper theme includes light and dark mode by default. This default behavior can be disabled in `astro-paper.config.ts`:
```ts file="astro-paper.config.ts"
export default defineAstroPaperConfig({
// ...
features: {
lightAndDarkMode: true, // [!code highlight]
// ...
},
});
```
To disable `light & dark mode`, set `features.lightAndDarkMode` to `false`. When disabled, the site will use only the light color scheme defined in `src/styles/theme.css`.
## Customize color schemes
Both light and dark color schemes of AstroPaper theme are defined in `src/styles/theme.css`.
```css file="src/styles/theme.css"
/* Light theme values */
:root,
[data-theme="light"] {
--background: #fdfdfd;
--foreground: #282728;
--accent: #006cac;
--accent-foreground: #ffffff;
--muted: #e6e6e6;
--muted-foreground: #6b7280;
--border: #ece9e9;
}
/* Dark theme values */
[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--accent-foreground: #ffffff;
--muted: #343f60;
--muted-foreground: #afb9ca;
--border: #ab4b08;
}
```
The `:root` and `[data-theme="light"]` selectors define the light color scheme, while `[data-theme="dark"]` defines the dark color scheme.
To customize your own color scheme, specify your light colors inside `:root, [data-theme="light"]`, and your dark colors inside `[data-theme="dark"]`.
Here is a detailed explanation of each color property:
<ResponsiveTable variant="minimal" class="max-sm:-mx-4 [&_td]:first-of-type:text-nowrap">
| Color Property | Definition & Usage |
| --------------------- | --------------------------------------------------------------------- |
| `--background` | Primary color of the website. Usually the main background. |
| `--foreground` | Secondary color of the website. Usually the text color. |
| `--accent` | Accent color. Used for links, hover states, and interactive elements. |
| `--accent-foreground` | Foreground color displayed on top of `--accent` backgrounds. |
| `--muted` | Muted background color. Used for cards, tags, and hover states. |
| `--muted-foreground` | Text color displayed on top of `--muted` backgrounds. |
| `--border` | Border color. Used for dividers and visual separation. |
</ResponsiveTable>
Here is an example of changing the light color scheme:
```css file="src/styles/theme.css"
/* ... */
:root,
[data-theme="light"] {
--background: #f6eee1;
--foreground: #012c56;
--accent: #e14a39;
--accent-foreground: #ffffff;
--muted: #efd8b0;
--muted-foreground: #6b7280;
--border: #dc9891;
}
/* ... */
```
> Check out some [predefined color schemes](https://astro-paper.pages.dev/posts/predefined-color-schemes/) AstroPaper has already crafted for you.
+93
View File
@@ -0,0 +1,93 @@
---
author: Sat Naing
pubDatetime: 2022-12-28T04:59:04.866Z
modDatetime: 2026-05-04T00:00:00Z
title: Dynamic OG image generation in AstroPaper blog posts
slug: dynamic-og-image-generation-in-astropaper-blog-posts
featured: false
draft: false
tags:
- docs
- release
description: New feature in AstroPaper v1.4.0, introducing dynamic OG image generation for blog posts.
---
New feature in AstroPaper v1.4.0, introducing dynamic OG image generation for blog posts.
![Dynamic OG image generation in AstroPaper blog posts](/posts/dynamic-og-image-generation-in-astropaper-blog-posts/index.png)
## Table of contents
## Intro
OG images (aka Social Images) play an important role in social media engagements. In case you don't know what OG image means, it is an image displayed whenever we share our website URL on social media such as Facebook, Discord etc.
> The Social Image used for Twitter is technically not called OG image. However, in this post, I'll be using the term OG image for all types of Social Images.
## Default/Static OG image (the old way)
AstroPaper already provided a way to add an OG image to a blog post. The author can specify the OG image in the frontmatter `ogImage`. Even when the author doesn't define the OG image in the frontmatter, the default OG image will be used as a fallback (in this case `public/default-og.jpg`). But the problem is that the default OG image is static, which means every blog post that does not include an OG image in the frontmatter will always use the same default OG image despite each post title/content being different from others.
## Dynamic OG Image
Generating a dynamic OG image for each post allows the author to avoid specifying an OG image for every single blog post. Besides, this will prevent the fallback OG image from being identical to all blog posts.
In AstroPaper v1.4.0, Vercel's [Satori](https://github.com/vercel/satori) package is used for dynamic OG image generation.
In AstroPaper v6+, the same idea remains (Satori renders SVG, then PNG is produced via [Sharp](https://sharp.pixelplumbing.com/)), but fonts are sourced from Astro's **Fonts** configuration and loaded via [`experimental_getFontFileURL()`](https://astro.build/blog/astro-620/) so OG generation can reuse the same font pipeline as the site.
Dynamic OG images will be generated at build time for blog posts that:
- don't include OG image in the frontmatter
- are not marked as draft.
## Anatomy of AstroPaper dynamic OG image
Dynamic OG images include _the blog post title_, _author name_, and _site title_. Author name and site title are retrieved from `site.author` and `site.title` in `astro-paper.config.ts`. The title is generated from the blog post frontmatter `title`.
![Example Dynamic OG Image link](https://user-images.githubusercontent.com/53733092/209704501-e9c2236a-3f4d-4c67-bab3-025aebd63382.png)
### Issue with Non-Latin Characters
Titles with non-latin characters won't display properly out of the box. In AstroPaper v6, dynamic OG images load font files from Astro's **Fonts** configuration (`astro.config.ts`) and register them with Satori.
To fix missing glyphs, switch the Google font family to one that covers your writing system, and make sure you include **both** `400` and `700` weights (Satori uses separate buffers for regular + bold).
```ts file="astro.config.ts"
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
// Example: Japanese coverage (pick what you need for your audience)
name: "Noto Sans JP",
cssVariable: "--font-google-sans-code",
provider: fontProviders.google(),
fallbacks: ["monospace"],
weights: [400, 700],
styles: ["normal", "italic"],
formats: ["woff", "ttf"],
},
],
});
```
If you change `cssVariable`, also update the matching key in:
- `src/pages/og.png.ts`
- `src/pages/posts/[...slug]/index.png.ts`
> Check out [this PR](https://github.com/satnaing/astro-paper/pull/318) for more info.
## Trade-off
While this is a nice feature to have, there's still a trade-off: AstroPaper generates one PNG per eligible post at build time (when og image is not specified in the frontmatter), so total build time grows with content volume.
In AstroPaper v6, OG image generation is significantly faster (PR [#632](https://github.com/satnaing/astro-paper/pull/632)) than earlier implementations, so the per-image overhead is much lower in practice. If you still want to minimize build time on very large sites, you can disable it by setting `features.dynamicOgImage: false` in `astro-paper.config.ts` (and provide per-post `ogImage` files instead).
## Limitations
At the time of writing this, [Satori](https://github.com/vercel/satori) is fairly new and has not reached major release yet. So, there are still some limitations to this dynamic OG image feature.
- RTL languages are not supported yet.
- [Using emoji](https://github.com/vercel/satori#emojis) in the title might be a little bit tricky.
@@ -0,0 +1,304 @@
---
author: Sat Naing
pubDatetime: 2022-09-23T04:58:53Z
modDatetime: 2026-05-17T05:50:08.212Z
title: How to configure AstroPaper theme
slug: how-to-configure-astropaper-theme
featured: true
draft: false
tags:
- configuration
- docs
description: How you can make AstroPaper theme absolutely yours.
---
import ResponsiveTable from '@/components/ResponsiveTable.astro';
This guide covers the available configuration options in AstroPaper — from site metadata and feature flags to fonts, social links, and layout settings.
## Table of contents
## Configuring astro-paper.config.ts
All site-wide configuration lives in `astro-paper.config.ts` at the root of the project. Use `defineAstroPaperConfig()` to get full IntelliSense support:
```ts file="astro-paper.config.ts"
import { defineAstroPaperConfig } from "./src/types/config";
export default defineAstroPaperConfig({
site: {
url: "https://your-site.com/", // replace with your deployed URL
title: "AstroPaper",
description: "A minimal, responsive and SEO-friendly Astro blog theme.",
author: "Sat Naing",
profile: "https://satnaing.dev",
ogImage: "default-og.jpg",
lang: "en",
timezone: "Asia/Bangkok",
dir: "ltr",
},
posts: {
perPage: 4,
perIndex: 4,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
},
features: {
lightAndDarkMode: true,
dynamicOgImage: true,
showArchives: true,
showBackButton: true,
editPost: {
enabled: true,
url: "https://github.com/satnaing/astro-paper/edit/main/",
},
search: "pagefind",
},
socials: [
{ name: "github", url: "https://github.com/satnaing/astro-paper" },
{ name: "x", url: "https://x.com/username" },
{ name: "linkedin", url: "https://www.linkedin.com/in/username/" },
{ name: "mail", url: "mailto:yourmail@gmail.com" },
],
shareLinks: [
{ name: "whatsapp", url: "https://wa.me/?text=" },
{ name: "facebook", url: "https://www.facebook.com/sharer.php?u=" },
{ name: "x", url: "https://x.com/intent/post?url=" },
{ name: "telegram", url: "https://t.me/share/url?url=" },
{ name: "mail", url: "mailto:?subject=See%20this%20post&body=" },
],
});
```
### `site` options
<ResponsiveTable>
| Option | Description |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | Your deployed website URL. Used for canonical URLs, OG image URLs, RSS feed, and sitemap. In production this must be set correctly. |
| `title` | Your site name. |
| `description` | Your site description. Useful for SEO and social media sharing. |
| `author` | Your name. Used as the default post author. |
| `profile` | Your personal/portfolio website URL, used for structured data. Set to `undefined` if you don't have one. |
| `ogImage` | Default OG image filename in `/public` (e.g. `"default-og.jpg"`). Used when no post-specific OG image is set and `dynamicOgImage` is disabled. |
| `lang` | HTML ISO language code for `<html lang="...">`. Defaults to `"en"`. |
| `timezone` | IANA timezone for post dates (e.g. `"Asia/Bangkok"`). Ensures consistent timestamps across localhost and your deployed site. |
| `dir` | Text direction for `<html dir="...">`. Supports `"ltr"` \| `"rtl"` \| `"auto"`. |
| `googleVerification` | Google Search Console verification meta tag value. Optional. Takes precedence over the `PUBLIC_GOOGLE_SITE_VERIFICATION` environment variable. |
</ResponsiveTable>
### `posts` options
<ResponsiveTable>
| Option | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `perPage` | Number of posts shown per page on paginated listing pages. Defaults to `4`. |
| `perIndex` | Number of posts shown in the Recent section on the home page. Defaults to `4`. |
| `scheduledPostMargin` | Posts with a future `pubDatetime` within this window (in ms) are treated as published. Defaults to 15 minutes (`15 * 60 * 1000`). |
</ResponsiveTable>
### `features` options
<ResponsiveTable>
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lightAndDarkMode` | Enable or disable the light/dark mode toggle. Defaults to `true`. |
| `dynamicOgImage` | Generate a dynamic OG image per post when no `ogImage` is specified in frontmatter. Defaults to `true`. See the [trade-off](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/#trade-off) for details. |
| `showArchives` | Show the `/archives` page and its header link. Defaults to `true`. |
| `showBackButton` | Show the "Go back" button on post pages. Defaults to `true`. |
| `editPost` | An "Edit page" link shown under post titles. Set `enabled: true` and provide the base `url` for your repository's edit URL. Per-post override via `hideEditPost` frontmatter. |
| `search` | Search provider. `"pagefind"` is the default. Set to `false` to disable search entirely. |
</ResponsiveTable>
## Update layout width
The default `max-width` for the entire blog is `768px` (`max-w-3xl`). If you'd like to change it, update the `max-w-app` utility in `src/styles/global.css`:
```css file="src/styles/global.css"
@utility max-w-app {
/* [!code --:1] */
@apply max-w-3xl;
/* [!code ++:1] */
@apply max-w-4xl xl:max-w-5xl;
}
```
You can explore more `max-width` values in the [Tailwind CSS docs](https://tailwindcss.com/docs/max-width).
## Configuring logo or title
![An arrow pointing at the website logo](https://res.cloudinary.com/noezectz/v1663911318/astro-paper/AstroPaper-logo-config_goff5l.png)
There are 3 options you can do:
### Option 1: Site title text
This is the easiest option. Update `site.title` in `astro-paper.config.ts`.
### Option 2: Astro's SVG component
You might want to use this option if you want to use an SVG logo.
- First add an SVG inside `src/assets/` directory. (e.g. `src/assets/dummy-logo.svg`)
- Then import that SVG inside `Header.astro`
```astro file="src/components/Header.astro"
---
// ...
import DummyLogo from "@/assets/dummy-logo.svg";
---
```
- Finally, replace `{config.site.title}` with imported logo.
```html
<a
href="/"
class="absolute py-1 text-left text-2xl leading-7 font-semibold whitespace-nowrap sm:static"
>
<DummyLogo class="scale-75 dark:invert" />
<!-- {config.site.title} -->
</a>
```
The best part of this approach is that you can customize your SVG styles as needed. In the example above, you can see how the SVG logo color can be inverted in dark mode.
### Option 3: Astro's Image component
If your logo is an image but not SVG, you can use Astro's Image component.
- Add your logo inside `src/assets/` directory. (e.g. `src/assets/dummy-logo.png`)
- Import `Image` and your logo in `Header.astro`
```astro file="src/components/Header.astro"
---
// ...
import { Image } from "astro:assets";
import dummyLogo from "@/assets/dummy-logo.png";
---
```
- Then, replace `{config.site.title}` with imported logo.
```html
<a
href="/"
class="absolute py-1 text-left text-2xl leading-7 font-semibold whitespace-nowrap sm:static"
>
<image src="{dummyLogo}" alt="My Blog" class="dark:invert" />
<!-- {config.site.title} -->
</a>
```
With this approach, you can still adjust your image's appearance using CSS classes. However, this might not always fit what you want. If you need to display different logo images based on light or dark mode, check how light/dark icons are handled inside the `Header.astro` component.
## Configuring social links
![An arrow pointing at social link icons](https://github.com/user-attachments/assets/8b895400-d088-442f-881b-02d2443e00cf)
Social links are configured in the `socials` array inside `astro-paper.config.ts`. Each entry requires a `name` matching an SVG filename in `src/assets/icons/socials/` and a `url`:
```ts file="astro-paper.config.ts"
export default defineAstroPaperConfig({
// ...
socials: [
{ name: "github", url: "https://github.com/satnaing/astro-paper" },
{ name: "x", url: "https://x.com/username" },
{ name: "linkedin", url: "https://www.linkedin.com/in/username/" },
{ name: "mail", url: "mailto:yourmail@gmail.com" },
],
});
```
To add a social not in the defaults, add its SVG icon to `src/assets/icons/socials/` and add an entry to the array. The `name` must match the SVG filename without the `.svg` extension.
## Configuring share links
![An arrow pointing at share link icons](https://github.com/user-attachments/assets/4f930b68-b625-45df-8c41-e076dd2b838e)
Share links are configured in the `shareLinks` array. Each entry requires a `name` (matching an SVG in `src/assets/icons/socials/`) and a base `url` to which the post URL is appended:
```ts file="astro-paper.config.ts"
export default defineAstroPaperConfig({
// ...
shareLinks: [
{ name: "whatsapp", url: "https://wa.me/?text=" },
{ name: "facebook", url: "https://www.facebook.com/sharer.php?u=" },
{ name: "x", url: "https://x.com/intent/post?url=" },
{ name: "telegram", url: "https://t.me/share/url?url=" },
{ name: "mail", url: "mailto:?subject=See%20this%20post&body=" },
],
});
```
## Configuring fonts
AstroPaper uses Astro's [fonts API](https://docs.astro.build/en/guides/fonts/) with [Google Sans Code](https://fonts.google.com/specimen/Google+Sans+Code) as the default font. This provides consistent typography across all platforms with automatic font optimizations including preloading and caching.
### Using the default font
The font is automatically configured in `astro.config.ts` and loaded in `Layout.astro`. No additional configuration is needed to use the default Google Sans Code font.
### Customizing the font
To use a different font, update three places:
1. **Update the font configuration in `astro.config.ts`:**
```ts file="astro.config.ts"
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
// ...
fonts: [
{
name: "Your Font Name", // [!code highlight]
cssVariable: "--font-your-font", // [!code highlight]
provider: fontProviders.google(),
fallbacks: ["monospace"],
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
},
],
});
```
2. **Update the Font component in `Layout.astro`:**
```astro file="src/layouts/Layout.astro"
---
import { Font } from "astro:assets";
// ...
---
<head>
<!-- ... -->
<Font
cssVariable="--font-your-font"
preload={[{ subset: "latin", weight: 400, style: "normal" }]}
/>
<!-- ... -->
</head>
```
3. **Update the CSS variable mapping in `src/styles/theme.css`:**
```css file="src/styles/theme.css"
@theme inline {
--font-app: var(--font-your-font); /* [!code highlight] */
/* ... */
}
```
The `--font-app` variable is used throughout the theme via the `font-app` Tailwind utility class, so updating this single variable applies your custom font everywhere.
> **Note**: Make sure the font name matches exactly as it appears on [Google Fonts](https://fonts.google.com). For other font providers or local fonts, refer to the [Astro Fonts documentation](https://docs.astro.build/en/guides/fonts/).
## See also
- [Customizing AstroPaper theme color schemes](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/) — change or add color schemes via `src/styles/theme.css`.
- [Adding new posts](https://astro-paper.pages.dev/posts/adding-new-posts-in-astropaper-theme/) — frontmatter reference and file conventions.
-249
View File
@@ -1,249 +0,0 @@
---
author: Sat Naing
pubDatetime: 2022-09-23T15:22:00Z
modDatetime: 2025-06-13T16:52:45.934Z
title: Adding new posts in AstroPaper theme
slug: adding-new-posts-in-astropaper-theme
featured: true
draft: false
tags:
- docs
description:
Some rules & recommendations for creating or adding new posts using AstroPaperr
theme.
---
Here are some rules/recommendations, tips & ticks for creating new posts in AstroPaper blog theme.
<figure>
<img
src="https://images.pexels.com/photos/159618/still-life-school-retro-ink-159618.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1"
alt="Free Classic wooden desk with writing materials, vintage clock, and a leather bag. Stock Photo"
/>
<figcaption class="text-center">
Photo by <a href="https://www.pexels.com/photo/brown-wooden-desk-159618/">Pixabay</a>
</figcaption>
</figure>
## Table of contents
## Creating a Blog Post
To write a new blog post, create a markdown file inside the `src/data/blog/` directory.
> Prior to AstroPaper v5.1.0, all blog posts had to be in `src/data/blog/`, meaning you couldn't organize them into subdirectories.
Starting from AstroPaper v5.1.0, you can now organize blog posts into subdirectories, making it easier to manage your content.
For example, if you want to group posts under `2025`, you can place them in `src/data/blog/2025/`. This also affects the post URL, so `src/data/blog/2025/example-post.md` will be available at `/posts/2025/example-post`.
If you dont want subdirectories to affect the post URL, just prefix the folder name with an underscore `_`.
```bash
# Example: blog post structure and URLs
src/data/blog/very-first-post.md -> mysite.com/posts/very-first-post
src/data/blog/2025/example-post.md -> mysite.com/posts/2025/example-post
src/data/blog/_2026/another-post.md -> mysite.com/posts/another-post
src/data/blog/docs/_legacy/how-to.md -> mysite.com/posts/docs/how-to
src/data/blog/Example Dir/Dummy Post.md -> mysite.com/posts/example-dir/dummy-post
```
> 💡 Tip: You can override a blog posts slug in the frontmatter as well. See the next section for more details.
If the subdirectory URL doesnt appear in the build output, remove node_modules, reinstall packages, and then rebuild.
## Frontmatter
Frontmatter is the main place to store some important information about the blog post (article). Frontmatter lies at the top of the article and is written in YAML format. Read more about frontmatter and its usage in [astro documentation](https://docs.astro.build/en/guides/markdown-content/).
Here is the list of frontmatter property for each post.
| Property | Description | Remark |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| **_title_** | Title of the post. (h1) | required<sup>\*</sup> |
| **_description_** | Description of the post. Used in post excerpt and site description of the post. | required<sup>\*</sup> |
| **_pubDatetime_** | Published datetime in ISO 8601 format. | required<sup>\*</sup> |
| **_modDatetime_** | Modified datetime in ISO 8601 format. (only add this property when a blog post is modified) | optional |
| **_author_** | Author of the post. | default = SITE.author |
| **_slug_** | Slug for the post. This field is optional. | default = slugified file name |
| **_featured_** | Whether or not display this post in featured section of home page | default = false |
| **_draft_** | Mark this post 'unpublished'. | default = false |
| **_tags_** | Related keywords for this post. Written in array yaml format. | default = others |
| **_ogImage_** | OG image of the post. Useful for social media sharing and SEO. This can be a remote URL or an image path relative to current folder. | default = `SITE.ogImage` or generated OG image |
| **_canonicalURL_** | Canonical URL (absolute), in case the article already exists on other source. | default = `Astro.site` + `Astro.url.pathname` |
| **_hideEditPost_** | Hide editPost button under blog title. This applies only to the current blog post. | default = false |
| **_timezone_** | Specify a timezone in IANA format for the current blog post. This will override the `SITE.timezone` config for the current blog post. | default = `SITE.timezone` |
> Tip! You can get ISO 8601 datetime by running `new Date().toISOString()` in the console. Make sure you remove quotes though.
Only `title`, `description` and `pubDatetime` fields in frontmatter must be specified.
Title and description (excerpt) are important for search engine optimization (SEO) and thus AstroPaper encourages to include these in blog posts.
`slug` is the unique identifier of the url. Thus, `slug` must be unique and different from other posts. The whitespace of `slug` should to be separated with `-` or `_` but `-` is recommended. Slug is automatically generated using the blog post file name. However, you can define your `slug` as a frontmatter in your blog post.
For example, if the blog file name is `adding-new-post.md` and you don't specify the slug in your frontmatter, Astro will automatically create a slug for the blog post using the file name. Thus, the slug will be `adding-new-post`. But if you specify the `slug` in the frontmatter, this will override the default slug. You can read more about this in [Astro Docs](https://docs.astro.build/en/guides/content-collections/#defining-custom-slugs).
If you omit `tags` in a blog post (in other words, if no tag is specified), the default tag `others` will be used as a tag for that post. You can set the default tag in the `content.config.ts` file.
```ts file="src/content.config.ts"
export const blogSchema = z.object({
// ...
draft: z.boolean().optional(),
// [!code highlight:1]
tags: z.array(z.string()).default(["others"]), // replace "others" with whatever you want
// ...
});
```
### Sample Frontmatter
Here is the sample frontmatter for a post.
```yaml file="src/data/blog/sample-post.md"
---
title: The title of the post
author: your name
pubDatetime: 2022-09-21T05:17:19Z
slug: the-title-of-the-post
featured: true
draft: false
tags:
- some
- example
- tags
ogImage: ../../assets/images/example.png # src/assets/images/example.png
# ogImage: "https://example.org/remote-image.png" # remote URL
description: This is the example description of the example post.
canonicalURL: https://example.org/my-article-was-already-posted-here
---
```
## Adding table of contents
By default, a post (article) does not include any table of contents (toc). To include toc, you have to specify it in a specific way.
Write `Table of contents` in h2 format (## in markdown) and place it where you want it to be appeared on the post.
For instance, if you want to place your table of contents just under the intro paragraph (like I usually do), you can do that in the following way.
<!-- prettier-ignore-start -->
```md
---
# frontmatter
---
Here are some recommendations, tips & ticks for creating new posts in AstroPaper blog theme.
<!-- [!code ++] -->
## Table of contents
<!-- the rest of the post -->
```
<!-- prettier-ignore-end -->
## Headings
There's one thing to note about headings. The AstroPaper blog posts use title (title in the frontmatter) as the main heading of the post. Therefore, the rest of the heading in the post should be using h2 \~ h6.
This rule is not mandatory, but highly recommended for visual, accessibility and SEO purposes.
## Syntax Highlighting
AstroPaper uses [Shiki](https://shiki.style/) as the default syntax highlighting. Starting from AstroPaper v5.4, [@shikijs/transformers](https://shiki.style/packages/transformers) is used to enhance better fenced code blocks. If you don't want to use it, you can simply remove it like this
```bash
pnpm remove @shikijs/transformers
```
```js file="astro.config.ts"
// ...
// [!code --:5]
import {
transformerNotationDiff,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from "@shikijs/transformers";
export default defineConfig({
// ...
markdown: {
remarkPlugins: [remarkToc, [remarkCollapse, { test: "Table of contents" }]],
shikiConfig: {
// For more themes, visit https://shiki.style/themes
themes: { light: "min-light", dark: "night-owl" },
defaultColor: false,
wrap: false,
transformers: [
transformerFileName(),
// [!code --:3]
transformerNotationHighlight(),
transformerNotationWordHighlight(),
transformerNotationDiff({ matchAlgorithm: "v3" }),
],
},
},
// ...
}
```
## Storing Images for Blog Content
Here are two methods for storing images and displaying them inside a markdown file.
> Note! If it's a requirement to style optimized images in markdown you should [use MDX](https://docs.astro.build/en/guides/images/#images-in-mdx-files).
### Inside `src/assets/` directory (recommended)
You can store images inside `src/assets/` directory. These images will be automatically optimized by Astro through [Image Service API](https://docs.astro.build/en/reference/image-service-reference/).
You can use relative path or alias path (`@/assets/`) to serve these images.
Example: Suppose you want to display `example.jpg` whose path is `/src/assets/images/example.jpg`.
```md
![something](@/assets/images/example.jpg)
<!-- OR -->
![something](../../assets/images/example.jpg)
<!-- Using img tag or Image component won't work ❌ -->
<img src="@/assets/images/example.jpg" alt="something">
<!-- ^^ This is wrong -->
```
> Technically, you can store images inside any directory under `src`. In here, `src/assets` is just a recommendation.
### Inside `public` directory
You can store images inside the `public` directory. Keep in mind that images stored in the `public` directory remain untouched by Astro, meaning they will be unoptimized and you need to handle image optimization by yourself.
For these images, you should use an absolute path; and these images can be displayed using [markdown annotation](https://www.markdownguide.org/basic-syntax/#images-1) or [HTML img tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img).
Example: Assume `example.jpg` is located at `/public/assets/images/example.jpg`.
```md
![something](/assets/images/example.jpg)
<!-- OR -->
<img src="/assets/images/example.jpg" alt="something">
```
## Bonus
### Image compression
When you put images in the blog post (especially for images under `public` directory), it is recommended that the image is compressed. This will affect the overall performance of the website.
My recommendation for image compression sites.
- [TinyPng](https://tinypng.com/)
- [TinyJPG](https://tinyjpg.com/)
### OG Image
The default OG image will be placed if a post does not specify the OG image. Though not required, OG image related to the post should be specify in the frontmatter. The recommended size for OG image is **_1200 X 640_** px.
> Since AstroPaper v1.4.0, OG images will be generated automatically if not specified. Check out [the announcement](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/).
@@ -1,148 +0,0 @@
---
author: Sat Naing
pubDatetime: 2022-09-25T15:20:35Z
modDatetime: 2026-01-09T15:00:15.170Z
title: Customizing AstroPaper theme color schemes
featured: false
draft: false
tags:
- color-schemes
- docs
description:
How you can enable/disable light & dark mode; and customize color schemes
of AstroPaper theme.
---
This post will explain how you can enable/disable light & dark mode for the website. Moreover, you'll learn how you can customize color schemes of the entire website.
## Table of contents
## Enable/disable light & dark mode
AstroPaper theme will include light and dark mode by default. In other words, there will be two color schemes\_ one for light mode and another for dark mode. This default behavior can be disabled in `SITE` configuration object.
```js file="src/config.ts"
export const SITE = {
website: "https://astro-paper.pages.dev/", // replace this with your deployed domain
author: "Sat Naing",
profile: "https://satnaing.dev/",
desc: "A minimal, responsive and SEO-friendly Astro blog theme.",
title: "AstroPaper",
ogImage: "astropaper-og.jpg",
lightAndDarkMode: true, // [!code highlight]
postPerIndex: 4,
postPerPage: 4,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
showArchives: true,
showBackButton: true, // show back button in post detail
editPost: {
enabled: true,
text: "Suggest Changes",
url: "https://github.com/satnaing/astro-paper/edit/main/",
},
dynamicOgImage: true,
lang: "en", // html lang code. Set this empty and default will be "en"
timezone: "Asia/Bangkok", // Default global timezone (IANA format) https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
} as const;
```
To disable `light & dark mode` set `SITE.lightAndDarkMode` to `false`.
## Choose initial color scheme
By default, if we disable `SITE.lightAndDarkMode`, we will only get system's prefers-color-scheme.
Thus, to choose an initial color scheme instead of prefers-color-scheme, we have to set color scheme in the `initialColorScheme` variable inside `theme.ts`.
```ts file="src/scripts/theme.ts"
// Initial color scheme
// Can be "light", "dark", or empty string for system's prefers-color-scheme
const initialColorScheme = ""; // "light" | "dark" // [!code hl]
function getPreferTheme(): string {
// get theme data from local storage (user's explicit choice)
const currentTheme = localStorage.getItem("theme");
if (currentTheme) return currentTheme;
// return initial color scheme if it is set (site default)
if (initialColorScheme) return initialColorScheme;
// return user device's prefer color scheme (system fallback)
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
// ...
```
The **initialColorScheme** variable can hold two values\_ `"light"`, `"dark"`. You can leave the empty string (default) if you don't want to specify an initial color scheme.
- `""` - system's prefers-color-scheme. (default)
- `"light"` - use light mode as initial color scheme.
- `"dark"` - use dark mode as initial color scheme.
<details>
<summary>Why initialColorScheme is not inside config.ts?</summary>
To avoid color flickering on page reload, we have to place the theme initialization JavaScript code as early as possible when the page loads. The theme script is split into two parts: a minimal inline script in the `<head>` that sets the theme immediately, and the full script that loads asynchronously. This approach prevents FOUC (Flash of Unstyled Content) while maintaining optimal performance.
</details>
## Customize color schemes
Both light & dark color schemes of AstroPaper theme can be customized in the `global.css` file.
```css file="src/styles/global.css"
@import "tailwindcss";
@import "./typography.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
:root,
html[data-theme="light"] {
--background: #fdfdfd;
--foreground: #282728;
--accent: #006cac;
--muted: #e6e6e6;
--border: #ece9e9;
}
html[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--muted: #343f60bf;
--border: #ab4b08;
}
/* ... */
```
In the AstroPaper theme, the `:root` and `html[data-theme="light"]` selectors define the light color scheme, while `html[data-theme="dark"]` defines the dark color scheme.
To customize your own color scheme, specify your light colors inside `:root, html[data-theme="light"]`, and your dark colors inside `html[data-theme="dark"]`.
Here is the detail explanation of color properties.
| Color Property | Definition & Usage |
| -------------- | ------------------------------------------------------------- |
| `--background` | Primary color of the website. Usually the main background. |
| `--foreground` | Secondary color of the website. Usually the text color. |
| `--accent` | Accent color of the website. Link color, hover color etc. |
| `--muted` | Card and scrollbar background color for hover state etc. |
| `--border` | Border color. Used for border utilities and visual separation |
Here is an example of changing the light color scheme.
```css file="src/styles/global.css"
/* ... */
:root,
html[data-theme="light"] {
--background: #f6eee1;
--foreground: #012c56;
--accent: #e14a39;
--muted: #efd8b0;
--border: #dc9891;
}
/* ... */
```
> Check out some [predefined color schemes](https://astro-paper.pages.dev/posts/predefined-color-schemes/) AstroPaper has already crafted for you.
-95
View File
@@ -1,95 +0,0 @@
---
author: Sat Naing
pubDatetime: 2022-12-28T04:59:04.866Z
modDatetime: 2025-03-12T13:39:20.763Z
title: Dynamic OG image generation in AstroPaper blog posts
slug: dynamic-og-image-generation-in-astropaper-blog-posts
featured: false
draft: false
tags:
- docs
- release
description: New feature in AstroPaper v1.4.0, introducing dynamic OG image generation for blog posts.
---
New feature in AstroPaper v1.4.0, introducing dynamic OG image generation for blog posts.
## Table of contents
## Intro
OG images (aka Social Images) play an important role in social media engagements. In case you don't know what OG image means, it is an image displayed whenever we share our website URL on social media such as Facebook, Discord etc.
> The Social Image used for Twitter is technically not called OG image. However, in this post, I'll be using the term OG image for all types of Social Images.
## Default/Static OG image (the old way)
AstroPaper already provided a way to add an OG image to a blog post. The author can specify the OG image in the frontmatter `ogImage`. Even when the author doesn't define the OG image in the frontmatter, the default OG image will be used as a fallback (in this case `public/astropaper-og.jpg`). But the problem is that the default OG image is static, which means every blog post that does not include an OG image in the frontmatter will always use the same default OG image despite each post title/content being different from others.
## Dynamic OG Image
Generating a dynamic OG image for each post allows the author to avoid specifying an OG image for every single blog post. Besides, this will prevent the fallback OG image from being identical to all blog posts.
In AstroPaper v1.4.0, Vercel's [Satori](https://github.com/vercel/satori) package is used for dynamic OG image generation.
Dynamic OG images will be generated at build time for blog posts that
- don't include OG image in the frontmatter
- are not marked as draft.
## Anatomy of AstroPaper dynamic OG image
Dynamic OG image of AstroPaper includes _the blog post title_, _author name_ and _site title_. Author name and site title will be retrieved via `SITE.author` and `SITE.title` of **"src/config.ts"** file. The title is generated from the blog post frontmatter `title`.
![Example Dynamic OG Image link](https://user-images.githubusercontent.com/53733092/209704501-e9c2236a-3f4d-4c67-bab3-025aebd63382.png)
### Issue Non-Latin Characters
Titles with non-latin characters won't display properly out of the box. To resolve this, we have to replace `fontsConfig` inside `loadGoogleFont.ts` with your preferred font.
```ts file=src/utils/loadGoogleFont.ts
async function loadGoogleFonts(
text: string
): Promise<
Array<{ name: string; data: ArrayBuffer; weight: number; style: string }>
> {
const fontsConfig = [
{
name: "Noto Sans JP",
font: "Noto+Sans+JP",
weight: 400,
style: "normal",
},
{
name: "Noto Sans JP",
font: "Noto+Sans+JP:wght@700",
weight: 700,
style: "normal",
},
{ name: "Noto Sans", font: "Noto+Sans", weight: 400, style: "normal" },
{
name: "Noto Sans",
font: "Noto+Sans:wght@700",
weight: 700,
style: "normal",
},
];
// ...
}
```
> Check out [this PR](https://github.com/satnaing/astro-paper/pull/318) for more info.
## Trade-off
While this is a nice feature to have, there's a trade-off. Each OG image takes roughly one second to generate. This might not be noticeable at first, but as the number of blog posts grows, you might want to disable this feature. Since every OG image takes time to generate, having many of them will increase the build time linearly.
For example: If one OG image takes one second to generate, then 60 images will take around one minute, and 600 images will take approximately 10 minutes. This can significantly impact build times as your content scales.
Related issue: [#428](https://github.com/satnaing/astro-paper/issues/428)
## Limitations
At the time of writing this, [Satori](https://github.com/vercel/satori) is fairly new and has not reached major release yet. So, there are still some limitations to this dynamic OG image feature.
- Besides, RTL languages are not supported yet.
- [Using emoji](https://github.com/vercel/satori#emojis) in the title might be a little bit tricky.
@@ -1,271 +0,0 @@
---
author: Sat Naing
pubDatetime: 2022-09-23T04:58:53Z
modDatetime: 2026-01-10T13:04:53.851Z
title: How to configure AstroPaper theme
slug: how-to-configure-astropaper-theme
featured: true
draft: false
tags:
- configuration
- docs
description: How you can make AstroPaper theme absolutely yours.
---
AstroPaper is a highly customizable Astro blog theme. With AstroPaper, you can customize everything according to your personal taste. This article will explain how you can make some customizations easily in the config file.
## Table of contents
## Configuring SITE
The important configurations resides in `src/config.ts` file. Within that file, you'll see the `SITE` object where you can specify your website's main configurations.
During development, it's okay to leave `SITE.website` empty. But in production mode, you should specify your deployed url in `SITE.website` option since this will be used for canonical URL, social card URL etc.. which are important for SEO.
```js file=src/config.ts
export const SITE = {
website: "https://astro-paper.pages.dev/", // replace this with your deployed domain
author: "Sat Naing",
profile: "https://satnaing.dev/",
desc: "A minimal, responsive and SEO-friendly Astro blog theme.",
title: "AstroPaper",
ogImage: "astropaper-og.jpg",
lightAndDarkMode: true,
postPerIndex: 4,
postPerPage: 4,
scheduledPostMargin: 15 * 60 * 1000, // 15 minutes
showArchives: true,
showBackButton: true, // show back button in post detail
editPost: {
enabled: true,
text: "Suggest Changes",
url: "https://github.com/satnaing/astro-paper/edit/main/",
},
dynamicOgImage: true, // enable automatic dynamic og-image generation
dir: "ltr", // "rtl" | "auto"
lang: "en", // html lang code. Set this empty and default will be "en"
timezone: "Asia/Bangkok", // Default global timezone (IANA format) https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
} as const;
```
Here are SITE configuration options
| Options | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `website` | Your deployed website URL |
| `author` | Your name |
| `profile` | Your personal/portfolio website URL which is used for better SEO. Put `null` or empty string `""` if you don't have any. |
| `desc` | Your site description. Useful for SEO and social media sharing. |
| `title` | Your site name |
| `ogImage` | Your default OG image for the site. Useful for social media sharing. OG images can be an external image URL or they can be placed under `/public` directory. |
| `lightAndDarkMode` | Enable or disable `light & dark mode` for the website. If disabled, primary color scheme will be used. This option is enabled by default. |
| `postPerIndex` | The number of posts to be displayed at the home page under `Recent` section. |
| `postPerPage` | You can specify how many posts will be displayed in each posts page. (eg: if you set `SITE.postPerPage` to 3, each page will only show 3 posts per page) |
| `scheduledPostMargin` | In Production mode, posts with a future `pubDatetime` will not be visible. However, if a post's `pubDatetime` is within the next 15 minutes, it will be visible. You can set `scheduledPostMargin` if you don't like the default 15 minutes margin. |
| `showArchives` | Determines whether to display the `Archives` menu (positioned between the `About` and `Search` menus) and its corresponding page on the site. This option is set to `true` by default. |
| `showBackButton` | Determines whether to display the `Go back` button in each blog post. |
| `editPost` | This option allows users to suggest changes to a blog post by providing an edit link under blog post titles. This feature can be disabled by setting `SITE.editPost.enabled` to `false`. |
| `dynamicOgImage` | This option controls whether to [generate dynamic og-image](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/) if no `ogImage` is specified in the blog post frontmatter. If you have many blog posts, you might want to disable this feature. See the [trade-off](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/#trade-off) for more details. |
| `dir` | Specifies the text direction of the entire blog. Used as [HTML dir attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir) in `<html dir="ltr">`. Supported values: `ltr` \| `rtl` \| `auto` |
| `lang` | Used as HTML ISO Language code in `<html lang"en">`. Default is `en`. |
| `timezone` | This option allows you to specify your timezone using the [IANA format](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Setting this ensures consistent timestamps across your localhost and deployed site, eliminating time differences. |
## Update layout width
The default `max-width` for the entire blog is `768px` (`max-w-3xl`). If you'd like to change it, you can easily update the `max-w-app` utility in your `global.css`. For instance:
```css file=src/styles/global.css
@utility max-w-app {
/* [!code --:1] */
@apply max-w-3xl;
/* [!code ++:1] */
@apply max-w-4xl xl:max-w-5xl;
}
```
You can explore more `max-width` values in the [Tailwind CSS docs](https://tailwindcss.com/docs/max-width).
## Configuring logo or title
Prior to AstroPaper v5, you can update your site name/logo in `LOGO_IMAGE` object inside `src/config.ts` file. However, in AstroPaper v5, this option has been removed in favor of Astro's built-in SVG and Image components.
![An arrow pointing at the website logo](https://res.cloudinary.com/noezectz/v1663911318/astro-paper/AstroPaper-logo-config_goff5l.png)
There are 3 options you can do:
### Option 1: SITE title text
This is the easiest option. You just have to update `SITE.title` in `src/config.ts` file.
### Option 2: Astro's SVG component
You might want to use this option if you want to use an SVG logo.
- First add an SVG inside `src/assets` directory. (eg: `src/assets/dummy-logo.svg`)
- Then import that SVG inside `Header.astro`
```astro file=src/components/Header.astro
---
// ...
import DummyLogo from "@/assets/dummy-logo.svg";
---
```
- Finally, replace `{SITE.title}` with imported logo.
```html
<a
href="/"
class="absolute py-1 text-left text-2xl leading-7 font-semibold whitespace-nowrap sm:static"
>
<DummyLogo class="scale-75 dark:invert" />
<!-- {SITE.title} -->
</a>
```
The best part of this approach is that you can customize your SVG styles as needed. In the example above, you can see how the SVG logo color can be inverted in dark mode.
### Option 3: Astro's Image component
If your logo is an image but not SVG, you can use Astro's Image component.
- Add your logo inside `src/assets` directory. (eg: `src/assets/dummy-logo.png`)
- Import `Image` and your logo in `Header.astro`
```astro file=src/components/Header.astro
---
// ...
import { Image } from "astro:assets";
import dummyLogo from "@/assets/dummy-logo.png";
---
```
- Then, replace `{SITE.title}` with imported logo.
<!-- prettier-ignore -->
```html
<a
href="/"
class="absolute py-1 text-left text-2xl leading-7 font-semibold whitespace-nowrap sm:static"
>
<Image src="{dummyLogo}" alt="Dummy Blog" class="dark:invert" />
<!-- {SITE.title} -->
</a>
```
With this approach, you can still adjust your image's appearance using CSS classes. However, this might not always fit what you want. If you need to display different logo images based on light or dark mode, check how light/dark icons are handled inside the `Header.astro` component.
## Configuring social links
![An arrow pointing at social link icons](https://github.com/user-attachments/assets/8b895400-d088-442f-881b-02d2443e00cf)
You can configure social links in `SOCIALS` object inside `constants.ts`.
```ts file=src/constants.ts
export const SOCIALS = [
{
name: "GitHub",
href: "https://github.com/satnaing/astro-paper",
linkTitle: ` ${SITE.title} on GitHub`,
icon: IconGitHub,
},
{
name: "X",
href: "https://x.com/username",
linkTitle: `${SITE.title} on X`,
icon: IconBrandX,
},
{
name: "LinkedIn",
href: "https://www.linkedin.com/in/username/",
linkTitle: `${SITE.title} on LinkedIn`,
icon: IconLinkedin,
},
{
name: "Mail",
href: "mailto:yourmail@gmail.com",
linkTitle: `Send an email to ${SITE.title}`,
icon: IconMail,
},
] as const;
```
## Configuring share links
You can configure share links in `SHARE_LINKS` object inside `src/constants.ts`.
![An arrow pointing at share link icons](https://github.com/user-attachments/assets/4f930b68-b625-45df-8c41-e076dd2b838e)
## Configuring fonts
AstroPaper uses Astro's [experimental fonts API](https://docs.astro.build/en/reference/experimental-flags/fonts/) with [Google Sans Code](https://fonts.google.com/specimen/Google+Sans+Code) as the default font. This provides consistent typography across all platforms with automatic font optimizations including preloading and caching.
### Using the default font
The font is automatically configured in `astro.config.ts` and loaded in `Layout.astro`. No additional configuration is needed to use the default Google Sans Code font.
### Customizing the font
To use a different font, you need to update three places:
1. **Update the font configuration in `astro.config.ts`:**
```ts file=astro.config.ts
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
// ...
experimental: {
fonts: [
{
name: "Your Font Name", // [!code highlight]
cssVariable: "--font-your-font", // [!code highlight]
provider: fontProviders.google(),
fallbacks: ["monospace"],
weights: [300, 400, 500, 600, 700],
styles: ["normal", "italic"],
},
],
},
});
```
1. **Update the Font component in `Layout.astro`:**
```astro file=src/layouts/Layout.astro
---
import { Font } from "astro:assets";
// ...
---
<head>
<!-- ... -->
// [!code highlight:4]
<Font
cssVariable="--font-your-font"
preload={[{ subset: "latin", weight: 400, style: "normal" }]}
/>
<!-- ... -->
</head>
```
1. **Update the CSS variable mapping in `global.css`:**
```css file=src/styles/global.css
@theme inline {
--font-app: var(--font-your-font); /* [!code highlight] */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-muted: var(--muted);
--color-border: var(--border);
}
```
The `--font-app` variable is used throughout the theme via the `font-app` Tailwind utility class, so updating this single variable will apply your custom font everywhere.
> **Note**: Make sure the font name matches exactly as it appears on [Google Fonts](https://fonts.google.com). For other font providers or local fonts, refer to the [Astro Experimental Fonts API documentation](https://docs.astro.build/en/reference/experimental-flags/fonts/).
## Conclusion
This is the brief specification of how you can customize this theme. You can customize more if you know some coding. For customizing styles, please read [this article](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/). Thanks for reading.✌🏻
-171
View File
@@ -1,171 +0,0 @@
---
author: Sat Naing
pubDatetime: 2022-09-26T12:13:24Z
modDatetime: 2024-01-04T09:09:06Z
title: Predefined color schemes
slug: predefined-color-schemes
featured: false
draft: false
tags:
- color-schemes
description:
Some of the well-crafted, predefined color schemes for AstroPaper blog
theme.
---
I've crafted some predefined color schemes for this AstroPaper blog theme. You can replace these color schemes with the original ones.
If you don't know how you can configure color schemes, check [this blog post](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/).
## Table of contents
## Light color schemes
Light color scheme has to be defined using the css selector `:root` and `html[data-theme="light"]`.
### Lobster
![lobster-color-scheme](https://user-images.githubusercontent.com/53733092/192282447-1d222faf-a3ce-44a9-9cfe-ac873155e5a9.png)
```css
:root,
html[data-theme="light"] {
--background: #f6eee1;
--foreground: #012c56;
--accent: #e14a39;
--muted: #efd8b0;
--border: #dc9891;
}
```
### Leaf Blue
![leaf-blue-color-scheme](https://user-images.githubusercontent.com/53733092/192318782-e80e3c39-54b5-423e-8f4b-9ae60402fc8d.png)
```css
:root,
html[data-theme="light"] {
--background: #f2f5ec;
--foreground: #353538;
--accent: #1158d1;
--muted: #bbc789;
--border: #7cadff;
}
```
### Pinky light
![pinky-color-scheme](https://user-images.githubusercontent.com/53733092/192286510-892d0042-2d6d-471e-bb72-954221ae2d17.png)
```css
:root,
html[data-theme="light"] {
--background: #fafcfc;
--foreground: #222e36;
--accent: #d3006a;
--muted: #f1bad4;
--border: #e3a9c6;
}
```
## Dark color schemes
Dark color scheme has to be defined as `html[data-theme="dark"]`.
### AstroPaper 1 original Dark Theme
![AstroPaper 1 default dark theme](https://user-images.githubusercontent.com/53733092/215769153-13b0ad8d-5ba2-44b1-af06-e5ae61293f62.png)
```css
html[data-theme="dark"] {
--background: #2f3741;
--foreground: #e6e6e6;
--accent: #1ad9d9;
--muted: #596b81;
--border: #3b4655;
}
```
### Deep Oyster
![deep-oyster-color-scheme](https://user-images.githubusercontent.com/53733092/192314524-45ec5904-3d8f-450a-9edf-1e32c5e11d6c.png)
```css
html[data-theme="dark"] {
--background: #21233d;
--foreground: #f4f7f5;
--accent: #ff5256;
--muted: #4a4e86;
--border: #b12f32;
}
```
### Pikky dark
![pinky-dark-color-scheme](https://user-images.githubusercontent.com/53733092/192307050-fbd55326-911c-4001-87c6-a8ad9378ac2e.png)
```css
html[data-theme="dark"] {
--background: #353640;
--foreground: #e9edf1;
--accent: #ff78c8;
--muted: #715566;
--border: #86436b;
}
```
### Astro dark (High Contrast)
![astro-dark-color-scheme](https://user-images.githubusercontent.com/53733092/215680520-59427bb0-f4cb-48c0-bccc-f182a428d72d.svg)
```css
html[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--muted: #8a3302;
--border: #ab4b08;
}
```
### Astro dark (New default dark theme in AstroPaper 2)
![new dark color scheme - low contrast](https://user-images.githubusercontent.com/53733092/215772856-d5b7ae35-ddaa-4ed6-b0bf-3fa5dbcf834c.png)
```css
html[data-theme="dark"] {
--background: #212737; /* lower contrast background */
--foreground: #eaedf3;
--accent: #ff6b01;
--muted: #8a3302;
--border: #ab4b08;
}
```
### Astro Deep Purple (New dark theme in AstroPaper 3)
![AstroPaper v3 new theme](https://github.com/satnaing/astro-paper/assets/53733092/c8b5d7e1-a3bc-4852-a5ad-4abf7b3cec79)
```css
html[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #eb3fd3;
--muted: #7d4f7c;
--border: #642451;
}
```
### AstroPaper v4 Special (New dark theme in AstroPaper 4)
![AstroPaper v4 new theme](https://github.com/satnaing/astro-paper/assets/53733092/66eb74dc-7a0e-4f2e-982d-25f5c443b25a)
```css
html[data-theme="dark"] {
--background: #000123;
--accent: #617bff;
--foreground: #eaedf3;
--muted: #0c0e4f;
--border: #303f8a;
}
```
-9
View File
@@ -1,9 +0,0 @@
interface Window {
theme?: {
themeValue: string;
setPreference: () => void;
reflectPreference: () => void;
getTheme: () => string;
setTheme: (val: string) => void;
};
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Replace `{{key}}` placeholders in UI strings.
* Translators can reorder placeholders freely within the sentence.
*/
export function tplStr(
template: string,
vars: Record<string, string | number>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
const value = vars[key];
return value !== undefined && value !== null ? String(value) : "";
});
}
+18
View File
@@ -0,0 +1,18 @@
import type { UIStrings } from "./types";
export { tplStr } from "./format";
const modules = import.meta.glob<{ default: UIStrings }>("./lang/*.ts", {
eager: true,
});
const translations: Record<string, UIStrings> = {};
for (const [path, mod] of Object.entries(modules)) {
const locale = path.slice("./lang/".length, -".ts".length);
translations[locale] = mod.default;
}
/** Returns UI strings for the given locale, falling back to English. */
export function useTranslations(locale: string = "en"): UIStrings {
return translations[locale] ?? translations["en"];
}
+71
View File
@@ -0,0 +1,71 @@
import type { UIStrings } from "../types";
export default {
nav: {
home: "Home",
posts: "Posts",
tags: "Tags",
about: "About",
archives: "Archives",
search: "Search",
},
post: {
publishedAt: "Published at",
updatedAt: "Updated",
sharePostIntro: "Share this post:",
sharePostOn: "Share this post on {{platform}}",
sharePostViaEmail: "Share this post via email",
tagLabel: "Tags",
backToTop: "Back to top",
goBack: "Go back",
editPage: "Edit page",
previousPost: "Previous Post",
nextPost: "Next Post",
},
pagination: {
prev: "Prev",
next: "Next",
page: "Page",
},
home: {
socialLinks: "Social Links",
featured: "Featured",
recentPosts: "Recent Posts",
allPosts: "All Posts",
},
footer: {
copyright: "Copyright",
allRightsReserved: "All rights reserved.",
},
pages: {
tagTitle: "Tag",
tagDesc: "All the articles with the tag",
tagsTitle: "Tags",
tagsDesc: "All the tags used in posts.",
postsTitle: "Posts",
postsDesc: "All the articles I've posted.",
archivesTitle: "Archives",
archivesDesc: "All the articles I've archived.",
searchTitle: "Search",
searchDesc: "Search any article ...",
},
a11y: {
skipToContent: "Skip to content",
openMenu: "Open menu",
closeMenu: "Close menu",
toggleTheme: "Toggle theme",
searchPlaceholder: "Search posts...",
noResults: "No results found",
goToPreviousPage: "Go to previous page",
goToNextPage: "Go to next page",
},
notFound: {
title: "404 Not Found",
message: "Page Not Found",
goHome: "Go back home",
},
} satisfies UIStrings;
+69
View File
@@ -0,0 +1,69 @@
export interface UIStrings {
nav: {
home: string;
posts: string;
tags: string;
about: string;
archives: string;
search: string;
};
post: {
publishedAt: string;
updatedAt: string;
sharePostIntro: string;
sharePostOn: string;
sharePostViaEmail: string;
tagLabel: string;
backToTop: string;
goBack: string;
editPage: string;
previousPost: string;
nextPost: string;
};
pagination: {
prev: string;
next: string;
page: string;
};
home: {
socialLinks: string;
featured: string;
recentPosts: string;
allPosts: string;
};
footer: {
copyright: string;
allRightsReserved: string;
};
pages: {
tagTitle: string;
tagDesc: string;
tagsTitle: string;
tagsDesc: string;
postsTitle: string;
postsDesc: string;
archivesTitle: string;
archivesDesc: string;
searchTitle: string;
searchDesc: string;
};
a11y: {
skipToContent: string;
openMenu: string;
closeMenu: string;
toggleTheme: string;
searchPlaceholder: string;
noResults: string;
goToPreviousPage: string;
goToNextPage: string;
};
notFound: {
title: string;
message: string;
goHome: string;
};
}
-24
View File
@@ -1,24 +0,0 @@
---
import type { MarkdownLayoutProps } from "astro";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import Breadcrumb from "@/components/Breadcrumb.astro";
import Layout from "./Layout.astro";
import { SITE } from "@/config";
type Props = MarkdownLayoutProps<{ title: string }>;
const { frontmatter } = Astro.props;
---
<Layout title={`${frontmatter.title} | ${SITE.title}`}>
<Header />
<Breadcrumb />
<main id="main-content" class="app-layout">
<section id="about" class="app-prose mb-28 max-w-app prose-img:border-0">
<h1 class="text-2xl tracking-wider sm:text-3xl">{frontmatter.title}</h1>
<slot />
</section>
</main>
<Footer />
</Layout>
+59 -110
View File
@@ -1,176 +1,125 @@
---
import { Font } from "astro:assets";
import { ClientRouter } from "astro:transitions";
import { PUBLIC_GOOGLE_SITE_VERIFICATION } from "astro:env/client";
import { SITE } from "@/config";
import { getRelativeLocaleUrl } from "astro:i18n";
import { resolveDefaultOgImagePath } from "@/utils/resolveDefaultOgImagePath";
import { getAssetPath } from "@/utils/withBase";
import config from "@/config";
import "@/styles/global.css";
type Props = {
title?: string;
author?: string;
profile?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
pubDatetime?: Date;
modDatetime?: Date | null;
scrollSmooth?: boolean;
};
const { site } = config;
const {
title = SITE.title,
author = SITE.author,
profile = SITE.profile,
description = SITE.desc,
ogImage = SITE.ogImage ? `/${SITE.ogImage}` : "/og.png",
canonicalURL = new URL(Astro.url.pathname, Astro.url),
pubDatetime,
modDatetime,
scrollSmooth = false,
title = site.title,
description = site.description,
ogImage = resolveDefaultOgImagePath(config),
canonicalURL = new URL(Astro.url.pathname, Astro.site).href,
} = Astro.props;
const socialImageURL = new URL(ogImage, Astro.url);
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: `${title}`,
image: `${socialImageURL}`,
datePublished: `${pubDatetime?.toISOString()}`,
...(modDatetime && { dateModified: modDatetime.toISOString() }),
author: [
{
"@type": "Person",
name: `${author}`,
...(profile && { url: profile }),
},
],
};
const socialImageURL = new URL(ogImage, Astro.site ?? Astro.url);
const rssHref = getRelativeLocaleUrl(
Astro.currentLocale ?? config.site.lang,
"rss.xml"
);
---
<!doctype html>
<html
dir={SITE.dir}
lang=`${SITE.lang ?? "en"}`
class={`${scrollSmooth && "scroll-smooth"}`}
dir={site.dir ?? "ltr"}
lang={Astro.currentLocale ?? site.lang}
class="overflow-y-scroll scroll-smooth"
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href={getAssetPath("favicon.svg")} />
<link rel="icon" href={getAssetPath("favicon.ico")} />
<link rel="canonical" href={canonicalURL} />
<meta name="generator" content={Astro.generator} />
<!-- Load Font -->
<!-- Font -->
<Font
cssVariable="--font-google-sans-code"
preload={[{ subset: "latin", weight: 400, style: "normal" }]}
/>
<!-- General Meta Tags -->
<!-- Primary meta -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="sitemap" href="/sitemap-index.xml" />
<meta name="author" content={site.author} />
<link rel="sitemap" href={getAssetPath("sitemap-index.xml")} />
<!-- Open Graph / Facebook -->
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content={site.title} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImageURL} />
<!-- Article Published/Modified time -->
{
pubDatetime && (
<meta
property="article:published_time"
content={pubDatetime.toISOString()}
/>
)
}
{
modDatetime && (
<meta
property="article:modified_time"
content={modDatetime.toISOString()}
/>
)
}
<!-- Twitter -->
<!-- Twitter / X -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
<!-- Google JSON-LD Structured data -->
<script
type="application/ld+json"
is:inline
set:html={JSON.stringify(structuredData)}
/>
<!-- Enable RSS feed auto-discovery -->
<!-- https://docs.astro.build/en/recipes/rss/#enabling-rss-feed-auto-discovery -->
<!-- RSS autodiscovery -->
<link
rel="alternate"
type="application/rss+xml"
title={SITE.title}
href={new URL("rss.xml", Astro.site)}
title={site.title}
href={new URL(rssHref, Astro.site)}
/>
<!-- Filled at runtime by theme.ts to match the current background colour -->
<meta name="theme-color" content="" />
<!-- Extra head content injected by child layouts (e.g. JSON-LD, article meta) -->
<slot name="head" />
{
// If PUBLIC_GOOGLE_SITE_VERIFICATION is set in the environment variable,
// include google-site-verification tag in the heading
// Learn more: https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag
PUBLIC_GOOGLE_SITE_VERIFICATION && (
site.googleVerification && (
<meta
name="google-site-verification"
content={PUBLIC_GOOGLE_SITE_VERIFICATION}
content={site.googleVerification}
/>
)
}
<ClientRouter />
<!-- Minimal inline script to prevent FOUC - sets theme immediately -->
<!--
Inline FOUC-prevention script: sets data-theme on <html> before
the browser paints. Runs synchronously, no defer/async.
-->
<script is:inline>
(function () {
const initialColorScheme = ""; // "light" | "dark"
const currentTheme = localStorage.getItem("theme");
function getPreferTheme() {
if (currentTheme) return currentTheme;
if (initialColorScheme) return initialColorScheme;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const themeValue = getPreferTheme();
// Set theme immediately to prevent flash
document.firstElementChild?.setAttribute("data-theme", themeValue);
// Export minimal API for external script
window.theme = {
themeValue: themeValue,
getTheme: () => window.theme.themeValue,
setTheme: val => {
window.theme.themeValue = val;
},
};
const stored = localStorage.getItem("theme");
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const theme = stored ?? (prefersDark ? "dark" : "light");
document.firstElementChild?.setAttribute("data-theme", theme);
// Expose value so theme.ts can skip re-detection.
window.__theme = { value: theme };
})();
</script>
</head>
<body>
<slot />
<!-- Load full theme logic -->
<script src="../scripts/theme.ts"></script>
<ClientRouter />
</head>
<body
class="bg-background font-app text-foreground selection:bg-accent/75 selection:text-accent-foreground flex min-h-svh flex-col"
>
<slot />
<script>
import "@/scripts/theme";
</script>
</body>
</html>
-42
View File
@@ -1,42 +0,0 @@
---
import Breadcrumb from "@/components/Breadcrumb.astro";
import { SITE } from "@/config";
type StringTitle = { pageTitle: string };
type ArrayTitle = { pageTitle: [string, string]; titleTransition: string };
type Props = (StringTitle | ArrayTitle) & { pageDesc?: string };
const { props } = Astro;
const backUrl = SITE.showBackButton ? Astro.url.pathname : "/";
---
<Breadcrumb />
<main data-backUrl={backUrl} id="main-content" class="app-layout pb-4">
{
"titleTransition" in props ? (
<h1 class="text-2xl font-semibold sm:text-3xl">
{props.pageTitle[0]}
<span transition:name={props.titleTransition}>
{props.pageTitle[1]}
</span>
</h1>
) : (
<h1 class="text-2xl font-semibold sm:text-3xl">{props.pageTitle}</h1>
)
}
<p class="mt-2 mb-6 italic">{props.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>
-285
View File
@@ -1,285 +0,0 @@
---
import { render, type CollectionEntry } from "astro:content";
import Layout from "@/layouts/Layout.astro";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import Tag from "@/components/Tag.astro";
import Datetime from "@/components/Datetime.astro";
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 { getPath } from "@/utils/getPath";
import { slugifyStr } from "@/utils/slugify";
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
import IconChevronRight from "@/assets/icons/IconChevronRight.svg";
import { SITE } from "@/config";
type Props = {
post: CollectionEntry<"blog">;
posts: CollectionEntry<"blog">[];
};
const { post, posts } = Astro.props;
const {
title,
author,
description,
ogImage: initOgImage,
canonicalURL,
pubDatetime,
modDatetime,
timezone,
tags,
hideEditPost,
} = post.data;
const { Content } = await render(post);
let ogImageUrl: string | undefined;
// Determine OG image source
if (typeof initOgImage === "string") {
ogImageUrl = initOgImage; // Remote OG image (absolute URL)
} else if (initOgImage?.src) {
ogImageUrl = initOgImage.src; // Local asset
}
// Use dynamic OG image if enabled and no remote|local ogImage
if (!ogImageUrl && SITE.dynamicOgImage) {
ogImageUrl = `${getPath(post.id, post.filePath)}/index.png`;
}
// Resolve OG image URL (or fallback to SITE.ogImage / default `og.png`)
const ogImage = ogImageUrl
? new URL(ogImageUrl, Astro.url.origin).href
: undefined;
const layoutProps = {
title: `${title} | ${SITE.title}`,
author,
description,
pubDatetime,
modDatetime,
canonicalURL,
ogImage,
scrollSmooth: true,
};
/* ========== Prev/Next Posts ========== */
const allPosts = posts.map(({ data: { title }, id, filePath }) => ({
id,
title,
filePath,
}));
const currentPostIndex = allPosts.findIndex(a => a.id === post.id);
const prevPost = currentPostIndex !== 0 ? allPosts[currentPostIndex - 1] : null;
const nextPost =
currentPostIndex !== allPosts.length ? allPosts[currentPostIndex + 1] : null;
---
<Layout {...layoutProps}>
<Header />
<BackButton />
<main
id="main-content"
class:list={["app-layout pb-12", { "mt-8": !SITE.showBackButton }]}
data-pagefind-body
>
<h1
transition:name={slugifyStr(title.replaceAll(".", "-"))}
class="inline-block text-2xl font-bold text-accent 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={[
"max-sm:hidden",
{ hidden: !SITE.editPost.enabled || hideEditPost },
]}>|</span
>
<EditPost {hideEditPost} {post} class="max-sm:hidden" />
</div>
<article
id="article"
class="app-prose mt-8 w-full 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} />
<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>
<BackToTopButton />
<ShareLinks />
<hr class="my-6 border-dashed" />
<!-- Previous/Next Post Buttons -->
<div data-pagefind-ignore class="grid grid-cols-1 gap-6 sm:grid-cols-2">
{
prevPost && (
<a
href={getPath(prevPost.id, prevPost.filePath)}
class="flex w-full gap-1 hover:opacity-75"
>
<IconChevronLeft class="inline-block flex-none rtl:rotate-180" />
<div>
<span>Previous Post</span>
<div class="text-sm text-accent/85">{prevPost.title}</div>
</div>
</a>
)
}
{
nextPost && (
<a
href={getPath(nextPost.id, nextPost.filePath)}
class="flex w-full justify-end gap-1 text-end hover:opacity-75 sm:col-start-2"
>
<div>
<span>Next Post</span>
<div class="text-sm text-accent/85">{nextPost.title}</div>
</div>
<IconChevronRight class="inline-block flex-none rtl:rotate-180" />
</a>
)
}
</div>
</main>
<Footer />
</Layout>
<script is:inline data-astro-rerun>
/** Create a progress indicator
* at the top */
function createProgressBar() {
// Create the main container div
const progressContainer = document.createElement("div");
progressContainer.className =
"progress-container fixed top-0 z-10 h-1 w-full bg-background";
// Create the progress bar div
const progressBar = document.createElement("div");
progressBar.className = "progress-bar h-1 w-0 bg-accent";
progressBar.id = "myBar";
// Append the progress bar to the progress container
progressContainer.appendChild(progressBar);
// Append the progress container to the document body or any other desired parent element
document.body.appendChild(progressContainer);
}
createProgressBar();
/** Update the progress bar
* when user scrolls */
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();
/** Attaches links to headings in the document,
* allowing sharing of sections easily */
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();
/** Attaches copy buttons to code blocks in the document,
* allowing users to copy code easily. */
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";
// Check if --file-name-offset custom property exists
const computedStyle = getComputedStyle(codeBlock);
const hasFileNameOffset =
computedStyle.getPropertyValue("--file-name-offset").trim() !== "";
// Determine the top positioning class
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);
// wrap codebock with relative parent element
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 ?? "");
// visual feedback that task is completed
button.innerText = "Copied";
setTimeout(() => {
button.innerText = copyButtonLabel;
}, 700);
}
}
attachCopyButtons();
/* Go to page start after page swap */
document.addEventListener("astro:after-swap", () =>
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
);
</script>
+66
View File
@@ -0,0 +1,66 @@
---
import Layout from "./Layout.astro";
import config from "@/config";
type Props = {
title?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
pubDatetime?: Date;
modDatetime?: Date | null;
};
const { site } = config;
const { title, description, ogImage, canonicalURL, pubDatetime, modDatetime } =
Astro.props;
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title ?? site.title,
image: ogImage,
...(pubDatetime && { datePublished: pubDatetime.toISOString() }),
...(modDatetime && { dateModified: modDatetime.toISOString() }),
author: [
{
"@type": "Person",
name: site.author,
...(site.profile && { url: site.profile }),
},
],
};
---
<Layout {title} {description} {ogImage} {canonicalURL}>
<Fragment slot="head">
<!-- Override og:type for article pages -->
<meta property="og:type" content="article" />
{
pubDatetime && (
<meta
property="article:published_time"
content={pubDatetime.toISOString()}
/>
)
}
{
modDatetime && (
<meta
property="article:modified_time"
content={modDatetime.toISOString()}
/>
)
}
<script
type="application/ld+json"
is:inline
set:html={JSON.stringify(structuredData)}
/>
</Fragment>
<slot />
</Layout>
+12 -7
View File
@@ -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>
+36
View File
@@ -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,25 +1,24 @@
import type { CollectionEntry } from "astro:content";
type GroupKey = string | number | symbol;
type GroupFunction<T> = (item: T, index?: number) => GroupKey;
interface GroupFunction<T> {
(item: T, index?: number): GroupKey;
}
export function getPostsByGroupCondition(
posts: CollectionEntry<"posts">[],
groupFunction: GroupFunction<CollectionEntry<"posts">>
) {
const result: Record<GroupKey, CollectionEntry<"posts">[]> = {};
const getPostsByGroupCondition = (
posts: CollectionEntry<"blog">[],
groupFunction: GroupFunction<CollectionEntry<"blog">>
) => {
const result: Record<GroupKey, CollectionEntry<"blog">[]> = {};
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;
};
export default getPostsByGroupCondition;
return result;
}
+32 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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" },
});
};
+16 -8
View File
@@ -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>
@@ -1,26 +1,30 @@
---
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
import LinkButton from "./LinkButton.astro";
import { SITE } from "@/config";
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);
---
{
SITE.showBackButton && (
config.features.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"
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>Go back</span>
<span>{t.post.goBack}</span>
</LinkButton>
</div>
)
}
<script>
/* Update Search Praam */
function updateGoBackUrl() {
const backButton: HTMLAnchorElement | null =
document.querySelector("#back-button");
@@ -1,20 +1,23 @@
---
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
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 end-4 bottom-8 z-50",
"md:sticky md:end-auto md:float-end md:me-1",
"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 relative bg-background px-2 py-1",
"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",
@@ -24,17 +27,15 @@ import IconArrowNarrowUp from "@/assets/icons/IconArrowNarrowUp.svg";
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">
<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" />
Back To Top
{t.post.backToTop}
</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");
@@ -44,13 +45,11 @@ import IconArrowNarrowUp from "@/assets/icons/IconArrowNarrowUp.svg";
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
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
});
// Handle button visibility according to scroll position
let lastVisible = null;
function handleScroll() {
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
@@ -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>
)
}
+256 -18
View File
@@ -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>
+177 -15
View File
@@ -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" },
});
};
+9 -8
View File
@@ -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
View File
@@ -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;
},
+22 -15
View File
@@ -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>
+16 -8
View File
@@ -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>
+34 -81
View File
@@ -1,114 +1,67 @@
// Constants
const THEME = "theme";
const THEME_KEY = "theme";
const LIGHT = "light";
const DARK = "dark";
// Initial color scheme
// Can be "light", "dark", or empty string for system's prefers-color-scheme
const initialColorScheme = "";
function getPreferTheme(): string {
// get theme data from local storage (user's explicit choice)
const currentTheme = localStorage.getItem(THEME);
if (currentTheme) return currentTheme;
// return initial color scheme if it is set (site default)
if (initialColorScheme) return initialColorScheme;
// return user device's prefer color scheme (system fallback)
function getPreferredTheme(): string {
const stored = localStorage.getItem(THEME_KEY);
if (stored) return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? DARK
: LIGHT;
}
// Use existing theme value from inline script if available, otherwise detect
let themeValue = window.theme?.themeValue ?? getPreferTheme();
// Reuse the value already set by the inline FOUC-prevention script if available.
let themeValue: string =
(window as unknown as { __theme?: { value: string } }).__theme?.value ??
getPreferredTheme();
function setPreference(): void {
localStorage.setItem(THEME, themeValue);
reflectPreference();
function persist(): void {
localStorage.setItem(THEME_KEY, themeValue);
reflect();
}
function reflectPreference(): void {
function reflect(): void {
document.firstElementChild?.setAttribute("data-theme", themeValue);
document.querySelector("#theme-btn")?.setAttribute("aria-label", themeValue);
// Get a reference to the body element
const body = document.body;
// Check if the body element exists before using getComputedStyle
if (body) {
// Get the computed styles for the body element
const computedStyles = window.getComputedStyle(body);
// Get the background color property
const bgColor = computedStyles.backgroundColor;
// Set the background color in <meta theme-color ... />
document
.querySelector("meta[name='theme-color']")
?.setAttribute("content", bgColor);
}
// Fill <meta name="theme-color"> with the computed background colour so
// Android's browser chrome matches the page background.
const bg = window.getComputedStyle(document.body).backgroundColor;
document
.querySelector("meta[name='theme-color']")
?.setAttribute("content", bg);
}
// Update the global theme API
if (window.theme) {
window.theme.setPreference = setPreference;
window.theme.reflectPreference = reflectPreference;
} else {
window.theme = {
themeValue,
setPreference,
reflectPreference,
getTheme: () => themeValue,
setTheme: (val: string) => {
themeValue = val;
},
};
}
// Ensure theme is reflected (in case body wasn't ready when inline script ran)
reflectPreference();
function setThemeFeature(): void {
// set on load so screen readers can get the latest value on the button
reflectPreference();
// now this script can find and listen for clicks on the control
function setup(): void {
reflect();
document.querySelector("#theme-btn")?.addEventListener("click", () => {
themeValue = themeValue === LIGHT ? DARK : LIGHT;
window.theme?.setTheme(themeValue);
setPreference();
persist();
});
}
// Set up theme features after page load
setThemeFeature();
setup();
// Runs on view transitions navigation
document.addEventListener("astro:after-swap", setThemeFeature);
// Re-run after View Transitions navigation.
document.addEventListener("astro:after-swap", setup);
// Set theme-color value before page transition
// to avoid navigation bar color flickering in Android dark mode
// Carry the theme-color value across View Transitions to prevent the
// Android navigation bar from flashing during page transitions.
document.addEventListener("astro:before-swap", event => {
const astroEvent = event;
const bgColor = document
const color = document
.querySelector("meta[name='theme-color']")
?.getAttribute("content");
if (bgColor) {
astroEvent.newDocument
if (color) {
(event as { newDocument: Document }).newDocument
.querySelector("meta[name='theme-color']")
?.setAttribute("content", bgColor);
?.setAttribute("content", color);
}
});
// sync with system changes
// Sync with OS-level dark/light preference changes.
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches: isDark }) => {
themeValue = isDark ? DARK : LIGHT;
window.theme?.setTheme(themeValue);
setPreference();
.addEventListener("change", ({ matches }) => {
themeValue = matches ? DARK : LIGHT;
persist();
});
+4 -37
View File
@@ -1,49 +1,18 @@
@import "tailwindcss";
@import "./theme.css";
@import "./typography.css";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
:root,
html[data-theme="light"] {
--background: #fdfdfd;
--foreground: #282728;
--accent: #006cac;
--muted: #e6e6e6;
--border: #ece9e9;
}
html[data-theme="dark"] {
--background: #212737;
--foreground: #eaedf3;
--accent: #ff6b01;
--muted: #343f60;
--border: #ab4b08;
}
@theme inline {
--font-app: var(--font-google-sans-code);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-muted: var(--muted);
--color-border: var(--border);
}
@layer base {
* {
@apply border-border outline-accent/75;
scrollbar-width: auto;
scrollbar-color: var(--color-muted) transparent;
}
html {
@apply overflow-y-scroll scroll-smooth;
}
body {
@apply flex min-h-svh flex-col bg-background font-app text-foreground selection:bg-accent/75 selection:text-background;
}
a,
button {
@apply outline-offset-1 outline-accent focus-visible:no-underline focus-visible:outline-2 focus-visible:outline-dashed;
@apply outline-accent outline-offset-1 focus-visible:no-underline focus-visible:outline-2 focus-visible:outline-dashed;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
@@ -56,15 +25,13 @@ html[data-theme="dark"] {
}
@utility app-layout {
@apply mx-auto w-full max-w-app px-4;
@apply max-w-app mx-auto w-full px-4;
}
.active-nav {
@utility active-nav {
@apply underline decoration-wavy decoration-2 underline-offset-8;
}
/* Source: https://piccalil.li/blog/a-more-modern-css-reset/ */
/* Anything that has been anchored to should have extra scroll margin */
:target {
scroll-margin-block: 1rem;
}

Some files were not shown because too many files have changed in this diff Show More