mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-14 11:29:33 +08:00
feat!: AstroPaper v6 (#631)
* feat: add initial configuration and social icons for AstroPaper theme * feat: add theme styles and design tokens * chore: configure Astro with sitemap, i18n, fonts, and tooling - Set site URL, i18n (single locale, no prefix), sitemap integration - Add Google Sans Code font via stable fonts API - Configure Shiki dual-theme markdown (min-light / night-owl) - Fix astro-paper.config.ts to use relative import (alias not available when Vite loads root-level config files) - Add @astrojs/sitemap and @typescript-eslint/parser dependencies - Wire @typescript-eslint/parser into ESLint so TypeScript syntax in Astro frontmatter (type Props, etc.) parses correctly * feat: add base styles, dark variant, and typography - global.css: dark variant via [data-theme], base layer for * / a / button selectors, scrollbar tokens, layout utilities (max-w-app, app-layout), active-nav as @utility for Tailwind variant support - html and body classes moved directly onto elements in BaseLayout (Tailwind-native approach) so only truly global selectors remain here - typography.css: prose overrides, Shiki light/dark code block switching, diff/highlight/word-highlight transformer styles * feat: add i18n types, English strings, and useTranslations helper Plain typed object approach — no third-party i18n library needed. Paraglide.js was considered but requires SSR output (hard blocker for a static Astro site). - UIStrings interface with nav, post, pagination, and a11y groups - en.ts with `satisfies UIStrings` for literal-type preservation - useTranslations(locale) helper falls back to English automatically Adding a new language is a one-file addition; full routing stays as a registry add-on. * feat: add client-side theme toggle script - Reads window.__theme.value set by the inline FOUC-prevention script to avoid re-detecting the theme on first load - Toggles data-theme on <html> and persists to localStorage - Fills <meta name="theme-color"> with the computed background colour so Android's browser chrome matches the page - Re-runs setup on astro:after-swap for View Transitions navigation - Carries theme-color across astro:before-swap to prevent navbar flicker on Android during page transitions - Syncs with OS prefers-color-scheme changes at runtime * feat: add BaseLayout and PostLayout BaseLayout (replaces scaffold Layout.astro): - Font, canonical URL via Astro.site, RSS autodiscovery - FOUC-prevention inline script sets data-theme before first paint and exposes window.__theme.value for theme.ts to reuse - html/body classes on elements directly (Tailwind-native) - <slot name="head"> for child layout injection - ClientRouter for View Transitions (respects lightAndDarkMode flag) - googleVerification rendered from config instead of .env — it is a public string, not a secret, and not environment-specific PostLayout (new): - Wraps BaseLayout; injects article-specific head content via slot - Overrides og:type to "article" - Adds article:published_time / article:modified_time conditionally - JSON-LD BlogPosting with conditional datePublished/dateModified (fixes old bug where absent dates produced "undefined" string) - Structured data only on actual post pages (not home/archives/tags) SiteConfig: add optional googleVerification field * chore: replace scaffold index page with BaseLayout placeholder * feat: add Header component * feat: add Footer and Socials component * chore: install dayjs, slugify, zod, @astrojs/mdx and add MDX integration * feat: add resolved config that applies defaults in one place Defaults for posts, features, socials, and shareLinks are merged in src/config.ts so all consuming code gets non-optional types with no scattered ?? fallbacks. * feat: add content schema, blog path, and post utilities Renames collection blog→posts, moves content to src/content/posts/, adds .mdx support, and adds a typed pages collection. Includes getSortedPosts, postFilter, getPath, and slugify utilities. * feat: add Datetime and Card components * refactor: switch layouts and components to resolved config Replaces @/astro-paper.config imports with @/config and removes optional chaining on feature flags now that the resolved config guarantees non-optional values. * feat: add home and footer i18n strings * feat: implement home page with hero, featured, and recent posts Replaces the BaseLayout placeholder with the full index page: hero section with RSS link and social links, featured posts section, recent posts capped at posts.perIndex, and an All Posts link. All section labels come from i18n. * feat: add pagination component * feat: add Breadcrumb component with locale support * feat: add Main component * refactor: update code formatting in Card.astro * feat: add posts page * fix: add missing @astrojs/mdx dependency * refactor: rename meta to pages in i18n * refactor: improve code formatting in Breadcrumb * refactor: destructure props in Main component for clarity * feat: add tag pages * feat: add About page * chore: update i18n configuration to use site language from config * feat: add Archives page * feat: integrate Pagefind search functionality * feat: add dynamic Open Graph image generation for posts * feat: add post detail page * feat: add Shiki transformers * chore: remove unused edit link text * chore: update .gitignore to include generated Pagefind directory * chore: update site URL in configuration to production link * chore: replace favicon and remove unused SVG assets, add new Open Graph image * feat: update site config with default values * feat: add default OG image path resolution * fix: ensure edit post feature checks for enabled status correctly * feat: implement dynamic Open Graph image generation * feat: add RSS feed generation functionality * feat: add 404 Not Found page * feat: add robots.txt generation for SEO optimization * refactor: change getSortedPosts to a named export * refactor: change postFilter to a named export * refactor: rename BaseLayout to Layout * refactor: standardize locale handling across components * refactor: update getPostsByGroupCondition location * chore: remove unused Welcome component * refactor(i18n): update links using getRelativeLocaleUrl * refactor(i18n): add dynamic locale loading * fix: update language attribute to use current locale * fix: correct wording for updatedAt in post translations * fix: update layout titles to use correct translation keys for tags pages * chore: use new colors for better DX and a11y * feat: add underline icon for active icon nav * feat: add toc for markdown contents * refactor: remove unused feature flags in config * refactor(i18n): enhance locale handling and path management * fix: ts unused variable hint * refactor: extract locale stripping logic into a util func * refactor(i18n): improve locale handling in tag pages * refactor: update post URL helper functions * refactor(i18n): replace pathWithBase with getRelativeLocaleUrl for locale handling * refactor: rename fileWithBase to getAssetPath * refactor: use runtime agnostic APIs for file paths * refactor: use transition directive for heading transition * refactor: remove unused title element from tag page * fix: decouple ClientRouter from lightAndDarkMode flag * refactor: rename `postPaths.ts` to `getPostPaths.ts` * refactor: remove unused `minutesRead` string from post translations * refactor: simplify AdjacentPostNav component by passing prevPost and nextPost directly * refactor: remove unused code declaration * docs: add existing blog posts * chore: update astro and @astrojs/check dependencies to latest versions * build: use experimental svg optimizer * chore(repo): restore project meta files and Docker configs * refactor: update code formatting with Prettier * docs: update README and docs * docs: add JSDoc comments to utility functions * docs: add JSDoc comments to resolved config file * fix(og): avoid double slashes when appending index.png * chore(ci): update Node.js version to 22.12.0 in CI workflow * feat: improve og image generation with Astro's getFontFileURL API (#632) * feat: improve og image generation with Astro's getFontFileURL API - remove @resvg/resvg-js and use sharp to convert SVG to PNG - update og image generation to use Astro's getFontFileURL API for better font handling * docs: update README and dynamic OG images documentation - remove outdated content and clarify dynamic OG image generation process in README. - add new image example and update instructions for handling non-Latin characters in dynamic OG images. - adjust copyright year in README to 2026. * fix(header): adjust icon button alignment in mobile * feat(docs): add ResponsiveTable to use it in MDX posts (#634) * feat(docs): add ResponsiveTable to use it in MDX posts Introduce ResponsiveTable with variant-based styling. Rename docs posts to .mdx, wrap tables in the component, and remove redundant table `code` rules from prose typography. * chore(prettier): ignore .mdx files when checking format * chore(ci): update Node.js version to 24 in CI workflow * bump: upgrade Astro and dependencies * fix: remove redundant backUrl script bug * fix(header): adjust button and icon alignment * fix(a11y): improve muted-foreground color contrast * docs: update predefined color schemes * chore: support site verification env as fallback for googleVerification * docs: add astro paper v6 updates in docs * docs: add AstroPaper-v6.png in astro-paper-6 blog post * fix: update back-to-top button DOM placement (#641) Closes #626, #131, #493
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import { type CollectionEntry } from "astro:content";
|
||||
import postOgImage from "./og-templates/post";
|
||||
import siteOgImage from "./og-templates/site";
|
||||
|
||||
function svgBufferToPngBuffer(svg: string) {
|
||||
const resvg = new Resvg(svg);
|
||||
const pngData = resvg.render();
|
||||
return pngData.asPng();
|
||||
}
|
||||
|
||||
export async function generateOgImageForPost(post: CollectionEntry<"blog">) {
|
||||
const svg = await postOgImage(post);
|
||||
return svgBufferToPngBuffer(svg);
|
||||
}
|
||||
|
||||
export async function generateOgImageForSite() {
|
||||
const svg = await siteOgImage();
|
||||
return svgBufferToPngBuffer(svg);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FontData } from "astro:assets";
|
||||
|
||||
export function getFontPathByWeight(
|
||||
fonts: FontData[],
|
||||
weight: number,
|
||||
options?: {
|
||||
style?: "normal" | "italic";
|
||||
format?: string;
|
||||
}
|
||||
): string | undefined {
|
||||
const style = options?.style ?? "normal";
|
||||
const format = options?.format ?? "truetype";
|
||||
|
||||
return fonts
|
||||
.find(font => font.weight === String(weight) && font.style === style)
|
||||
?.src.find(file => file.format === format)?.url;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { BLOG_PATH } from "@/content.config";
|
||||
import { slugifyStr } from "./slugify";
|
||||
|
||||
/**
|
||||
* Get full path of a blog post
|
||||
* @param id - id of the blog post (aka slug)
|
||||
* @param filePath - the blog post full file location
|
||||
* @param includeBase - whether to include `/posts` in return value
|
||||
* @returns blog post path
|
||||
*/
|
||||
export function getPath(
|
||||
id: string,
|
||||
filePath: string | undefined,
|
||||
includeBase = true
|
||||
) {
|
||||
const pathSegments = filePath
|
||||
?.replace(BLOG_PATH, "")
|
||||
.split("/")
|
||||
.filter(path => path !== "") // remove empty string in the segments ["", "other-path"] <- empty string will be removed
|
||||
.filter(path => !path.startsWith("_")) // exclude directories start with underscore "_"
|
||||
.slice(0, -1) // remove the last segment_ file name_ since it's unnecessary
|
||||
.map(segment => slugifyStr(segment)); // slugify each segment path
|
||||
|
||||
const basePath = includeBase ? "/posts" : "";
|
||||
|
||||
// Making sure `id` does not contain the directory
|
||||
const blogId = id.split("/");
|
||||
const slug = blogId.length > 0 ? blogId.slice(-1) : blogId;
|
||||
|
||||
// If not inside the sub-dir, simply return the file path
|
||||
if (!pathSegments || pathSegments.length < 1) {
|
||||
return [basePath, slug].join("/");
|
||||
}
|
||||
|
||||
return [basePath, ...pathSegments, slug].join("/");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
import { BLOG_PATH } from "@/content.config";
|
||||
import { slugifyStr } from "./slugify";
|
||||
import config from "@/config";
|
||||
|
||||
function getPostPathSegments(filePath: string | undefined): string[] {
|
||||
return (
|
||||
filePath
|
||||
?.replace(BLOG_PATH, "")
|
||||
.split("/")
|
||||
.filter(path => path !== "")
|
||||
.filter(path => !path.startsWith("_"))
|
||||
.slice(0, -1)
|
||||
.map(segment => slugifyStr(segment)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function getIdSlug(id: string): string {
|
||||
const postId = id.split("/");
|
||||
return postId.length > 0 ? String(postId[postId.length - 1]) : id;
|
||||
}
|
||||
|
||||
function getPostSlugPath(id: string, filePath: string | undefined): string {
|
||||
const pathSegments = getPostPathSegments(filePath);
|
||||
const slug = getIdSlug(id);
|
||||
return pathSegments.length > 0
|
||||
? [...pathSegments, slug].join("/")
|
||||
: String(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the slug-only path for use as a route param in `getStaticPaths`.
|
||||
* No base prefix, no locale — Astro handles those at a higher level.
|
||||
* e.g. `/examples/my-post`
|
||||
*/
|
||||
export function getPostSlug(id: string, filePath: string | undefined): string {
|
||||
return `/${getPostSlugPath(id, filePath)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fully navigable URL for use in `<a href>` and RSS links.
|
||||
* Applies both locale routing and the configured Astro base via
|
||||
* `getRelativeLocaleUrl`.
|
||||
* e.g. `/posts/my-post` or `/en/posts/my-post`
|
||||
*/
|
||||
export function getPostUrl(
|
||||
id: string,
|
||||
filePath: string | undefined,
|
||||
locale: string | undefined = config.site.lang
|
||||
): string {
|
||||
return getRelativeLocaleUrl(locale, `posts/${getPostSlugPath(id, filePath)}`);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
type GroupKey = string | number | symbol;
|
||||
|
||||
interface GroupFunction<T> {
|
||||
(item: T, index?: number): GroupKey;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import getSortedPosts from "./getSortedPosts";
|
||||
import { slugifyAll } from "./slugify";
|
||||
|
||||
const getPostsByTag = (posts: CollectionEntry<"blog">[], tag: string) =>
|
||||
getSortedPosts(
|
||||
posts.filter(post => slugifyAll(post.data.tags).includes(tag))
|
||||
);
|
||||
|
||||
export default getPostsByTag;
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import postFilter from "./postFilter";
|
||||
import { postFilter } from "./postFilter";
|
||||
|
||||
const getSortedPosts = (posts: CollectionEntry<"blog">[]) => {
|
||||
/**
|
||||
* Returns posts that are eligible to be shown to users, sorted by “last updated”
|
||||
* descending (uses `modDatetime` when present, otherwise `pubDatetime`).
|
||||
*
|
||||
* Note: filtering respects drafts and scheduled posts via `postFilter()`.
|
||||
*/
|
||||
export function getSortedPosts(posts: CollectionEntry<"posts">[]) {
|
||||
return posts
|
||||
.filter(postFilter)
|
||||
.sort(
|
||||
@@ -13,6 +19,4 @@ const getSortedPosts = (posts: CollectionEntry<"blog">[]) => {
|
||||
new Date(a.data.modDatetime ?? a.data.pubDatetime).getTime() / 1000
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default getSortedPosts;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { postFilter } from "./postFilter";
|
||||
import { slugifyStr } from "./slugify";
|
||||
import postFilter from "./postFilter";
|
||||
|
||||
interface Tag {
|
||||
type Tag = {
|
||||
tag: string;
|
||||
tagName: string;
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
|
||||
/**
|
||||
* Builds a de-duplicated, sorted tag list from posts.
|
||||
*
|
||||
* - Drafts and scheduled posts are excluded via `postFilter()`
|
||||
* - `tag` is the slug used in URLs; `tagName` is the original label for display
|
||||
* - Uniqueness is based on the slug (so differently-cased labels collapse)
|
||||
*/
|
||||
export function getUniqueTags(posts: CollectionEntry<"posts">[]) {
|
||||
const tags: Tag[] = posts
|
||||
.filter(postFilter)
|
||||
.flatMap(post => post.data.tags)
|
||||
@@ -18,6 +25,4 @@ const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
|
||||
)
|
||||
.sort((tagA, tagB) => tagA.tag.localeCompare(tagB.tag));
|
||||
return tags;
|
||||
};
|
||||
|
||||
export default getUniqueTags;
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
async function loadGoogleFont(
|
||||
font: string,
|
||||
text: string,
|
||||
weight: number
|
||||
): Promise<ArrayBuffer> {
|
||||
const API = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&text=${encodeURIComponent(text)}`;
|
||||
|
||||
const css = await (
|
||||
await fetch(API, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1",
|
||||
},
|
||||
})
|
||||
).text();
|
||||
|
||||
const resource = css.match(
|
||||
/src: url\((.+?)\) format\('(opentype|truetype)'\)/
|
||||
);
|
||||
|
||||
if (!resource) throw new Error("Failed to download dynamic font");
|
||||
|
||||
const res = await fetch(resource[1]);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to download dynamic font. Status: " + res.status);
|
||||
}
|
||||
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
async function loadGoogleFonts(
|
||||
text: string
|
||||
): Promise<
|
||||
Array<{ name: string; data: ArrayBuffer; weight: number; style: string }>
|
||||
> {
|
||||
const fontsConfig = [
|
||||
{
|
||||
name: "IBM Plex Mono",
|
||||
font: "IBM+Plex+Mono",
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
name: "IBM Plex Mono",
|
||||
font: "IBM+Plex+Mono",
|
||||
weight: 700,
|
||||
style: "bold",
|
||||
},
|
||||
];
|
||||
|
||||
const fonts = await Promise.all(
|
||||
fontsConfig.map(async ({ name, font, weight, style }) => {
|
||||
const data = await loadGoogleFont(font, text, weight);
|
||||
return { name, data, weight, style };
|
||||
})
|
||||
);
|
||||
|
||||
return fonts;
|
||||
}
|
||||
|
||||
export default loadGoogleFonts;
|
||||
@@ -1,229 +0,0 @@
|
||||
import satori from "satori";
|
||||
// import { html } from "satori-html";
|
||||
import { SITE } from "@/config";
|
||||
import loadGoogleFonts from "../loadGoogleFont";
|
||||
|
||||
// const markup = html`<div
|
||||
// style={{
|
||||
// background: "#fefbfb",
|
||||
// width: "100%",
|
||||
// height: "100%",
|
||||
// display: "flex",
|
||||
// alignItems: "center",
|
||||
// justifyContent: "center",
|
||||
// }}
|
||||
// >
|
||||
// <div
|
||||
// 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%",
|
||||
// }}
|
||||
// />
|
||||
|
||||
// <div
|
||||
// style={{
|
||||
// border: "4px solid #000",
|
||||
// background: "#fefbfb",
|
||||
// borderRadius: "4px",
|
||||
// display: "flex",
|
||||
// justifyContent: "center",
|
||||
// margin: "2rem",
|
||||
// width: "88%",
|
||||
// height: "80%",
|
||||
// }}
|
||||
// >
|
||||
// <div
|
||||
// style={{
|
||||
// display: "flex",
|
||||
// flexDirection: "column",
|
||||
// justifyContent: "space-between",
|
||||
// margin: "20px",
|
||||
// width: "90%",
|
||||
// height: "90%",
|
||||
// }}
|
||||
// >
|
||||
// <p
|
||||
// style={{
|
||||
// fontSize: 72,
|
||||
// fontWeight: "bold",
|
||||
// maxHeight: "84%",
|
||||
// overflow: "hidden",
|
||||
// }}
|
||||
// >
|
||||
// {post.data.title}
|
||||
// </p>
|
||||
// <div
|
||||
// style={{
|
||||
// display: "flex",
|
||||
// justifyContent: "space-between",
|
||||
// width: "100%",
|
||||
// marginBottom: "8px",
|
||||
// fontSize: 28,
|
||||
// }}
|
||||
// >
|
||||
// <span>
|
||||
// by{" "}
|
||||
// <span
|
||||
// style={{
|
||||
// color: "transparent",
|
||||
// }}
|
||||
// >
|
||||
// "
|
||||
// </span>
|
||||
// <span style={{ overflow: "hidden", fontWeight: "bold" }}>
|
||||
// {post.data.author}
|
||||
// </span>
|
||||
// </span>
|
||||
|
||||
// <span style={{ overflow: "hidden", fontWeight: "bold" }}>
|
||||
// {SITE.title}
|
||||
// </span>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>`;
|
||||
|
||||
export default async post => {
|
||||
return 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: post.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: post.data.author,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span",
|
||||
props: {
|
||||
style: { overflow: "hidden", fontWeight: "bold" },
|
||||
children: SITE.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
embedFont: true,
|
||||
fonts: await loadGoogleFonts(
|
||||
post.data.title + post.data.author + SITE.title + "by"
|
||||
),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import satori from "satori";
|
||||
import { SITE } from "@/config";
|
||||
import loadGoogleFonts from "../loadGoogleFont";
|
||||
|
||||
export default async () => {
|
||||
return 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: "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: SITE.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
props: {
|
||||
style: { fontSize: 28 },
|
||||
children: SITE.desc,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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(SITE.website).hostname,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
embedFont: true,
|
||||
fonts: await loadGoogleFonts(SITE.title + SITE.desc + SITE.website),
|
||||
}
|
||||
);
|
||||
};
|
||||
+11
-6
@@ -1,11 +1,16 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { SITE } from "@/config";
|
||||
import config from "@/config";
|
||||
|
||||
const postFilter = ({ data }: CollectionEntry<"blog">) => {
|
||||
/**
|
||||
* Determines whether a post is eligible to be listed/rendered.
|
||||
*
|
||||
* - Excludes drafts always
|
||||
* - In production, excludes scheduled posts until `pubDatetime` minus the configured margin
|
||||
* - In dev, always shows non-draft posts to make authoring easier
|
||||
*/
|
||||
export function postFilter({ data }: CollectionEntry<"posts">) {
|
||||
const isPublishTimePassed =
|
||||
Date.now() >
|
||||
new Date(data.pubDatetime).getTime() - SITE.scheduledPostMargin;
|
||||
new Date(data.pubDatetime).getTime() - config.posts.scheduledPostMargin;
|
||||
return !data.draft && (import.meta.env.DEV || isPublishTimePassed);
|
||||
};
|
||||
|
||||
export default postFilter;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ResolvedAstroPaperConfig } from "@/types/config";
|
||||
import { getAssetPath } from "./withBase";
|
||||
|
||||
const publicFiles = import.meta.glob("/public/*", { eager: false });
|
||||
|
||||
function existsInPublic(filename: string): boolean {
|
||||
return `/public/${filename}` in publicFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute OG image path used for pages/posts.
|
||||
*
|
||||
* Security note: `site.ogImage` must be a single filename under `public/` to avoid
|
||||
* path traversal or referencing arbitrary files.
|
||||
*
|
||||
* Behavior:
|
||||
* - When `features.dynamicOgImage` is enabled, prefers `public/{site.ogImage}` when present,
|
||||
* otherwise falls back to the generated `/og.png`.
|
||||
* - When disabled, requires `public/{site.ogImage}` to exist.
|
||||
*/
|
||||
export function resolveDefaultOgImagePath(
|
||||
config: ResolvedAstroPaperConfig
|
||||
): string {
|
||||
const filename = config.site.ogImage;
|
||||
if (
|
||||
filename.includes("..") ||
|
||||
filename.includes("/") ||
|
||||
filename.includes("\\")
|
||||
) {
|
||||
throw new Error(
|
||||
`site.ogImage must be a single filename in public/ (e.g. "default-og.jpg"), got "${filename}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.features.dynamicOgImage) {
|
||||
return existsInPublic(filename)
|
||||
? getAssetPath(filename)
|
||||
: getAssetPath("og.png");
|
||||
}
|
||||
|
||||
if (!existsInPublic(filename)) {
|
||||
throw new Error(
|
||||
`AstroPaper: missing public/${filename}. Add that file, or set site.ogImage to an existing file under public/, or enable features.dynamicOgImage to fall back to /og.png.`
|
||||
);
|
||||
}
|
||||
|
||||
return getAssetPath(filename);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import kebabcase from "lodash.kebabcase";
|
||||
import slugify from "slugify";
|
||||
|
||||
/**
|
||||
* Check if string contains non-Latin characters
|
||||
*/
|
||||
const hasNonLatin = (str: string): boolean => /[^\x00-\x7F]/.test(str);
|
||||
|
||||
/**
|
||||
* Slugify a string using a hybrid approach:
|
||||
* - For Latin-only strings: use slugify (eg: "E2E Testing" -> "e2e-testing", "TypeScript 5.0" -> "typescript-5.0")
|
||||
* - For strings with non-Latin characters: use lodash.kebabcase (preserves non-Latin chars)
|
||||
* - Latin strings: slugify (e.g. "E2E Testing" → "e2e-testing")
|
||||
* - Strings with non-Latin chars: lodash.kebabcase (preserves non-Latin chars)
|
||||
*/
|
||||
export const slugifyStr = (str: string): string => {
|
||||
if (hasNonLatin(str)) {
|
||||
// Preserve non-Latin characters (e.g., Burmese, Chinese, etc.)
|
||||
return kebabcase(str);
|
||||
}
|
||||
// Handle Latin strings with better number/acronym handling
|
||||
return slugify(str, { lower: true });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
||||
const baseRoot = base === "" ? "/" : `${base}/`;
|
||||
|
||||
/**
|
||||
* Strip a locale prefix from a root-relative pathname.
|
||||
* e.g. with locale "en": "/en/posts/foo" → "/posts/foo", "/en" → "/"
|
||||
* Paths that don't start with the locale prefix are returned unchanged.
|
||||
*/
|
||||
export function stripLocale(pathname: string, locale: string): string {
|
||||
const prefix = `/${locale}`;
|
||||
if (pathname === prefix) return "/";
|
||||
if (pathname.startsWith(`${prefix}/`)) return pathname.slice(prefix.length);
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the configured Astro `base` prefix from an absolute pathname.
|
||||
* Returns a root-relative pathname.
|
||||
*/
|
||||
export function stripBase(pathname: string): string {
|
||||
if (base === "") {
|
||||
return pathname;
|
||||
}
|
||||
if (pathname === base) {
|
||||
return "/";
|
||||
}
|
||||
if (pathname.startsWith(baseRoot)) {
|
||||
const stripped = pathname.slice(base.length);
|
||||
return stripped === "" ? "/" : stripped;
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix an asset/file path with the configured Astro `base`.
|
||||
* Does not force a trailing slash for empty paths.
|
||||
*/
|
||||
export function getAssetPath(path: string): string {
|
||||
// Strip leading slash to avoid double-slash when concatenating with baseRoot
|
||||
const normalizedPath = path.replace(/^\/+/, "");
|
||||
|
||||
if (!normalizedPath) {
|
||||
return base === "" ? "/" : base;
|
||||
}
|
||||
return baseRoot + normalizedPath;
|
||||
}
|
||||
Reference in New Issue
Block a user