feat: add Ghost-powered blog with dynamic content and internationalization

This commit is contained in:
Mauricio Siu
2025-02-27 23:12:17 -06:00
parent 6d34cc0559
commit 2e09e76e50
14 changed files with 875 additions and 5 deletions

View File

@@ -0,0 +1,145 @@
import { getPost, getPosts } from "@/lib/ghost";
import type { Post } from "@/lib/ghost";
import type { Metadata, ResolvingMetadata } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
type Props = {
params: { locale: string; slug: string };
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
return {
title: "Post Not Found",
};
}
return {
title: post.title,
description: post.custom_excerpt || post.excerpt,
openGraph: post.feature_image
? {
images: [
{
url: post.feature_image,
width: 1200,
height: 630,
alt: post.title,
},
],
}
: undefined,
};
}
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = params;
const t = await getTranslations({ locale, namespace: "blog" });
const post = await getPost(slug);
if (!post) {
notFound();
}
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<article className="container mx-auto px-4 py-12 max-w-4xl">
<Link
href="/blog"
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
{t("backToBlog")}
</Link>
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.name || "Unknown Author"}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative h-96 w-full rounded-lg overflow-hidden">
<Image
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
</header>
<div
className="prose prose-lg max-w-none prose-headings:text-foreground prose-a:text-primary hover:prose-a:text-primary/80"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,95 @@
import { getPosts } from "@/lib/ghost";
import type { Post } from "@/lib/ghost";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import Link from "next/link";
export const metadata: Metadata = {
title: "Blog | Dokploy",
description: "Latest news, updates, and articles from Dokploy",
};
export const revalidate = 3600; // Revalidate the data at most every hour
export default async function BlogPage({
params: { locale },
}: {
params: { locale: string };
}) {
const t = await getTranslations({ locale, namespace: "blog" });
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">{t("title")}</h1>
{posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-xl text-gray-600 dark:text-gray-400">
{t("noPosts")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post: Post) => (
<BlogPostCard key={post.id} post={post} locale={locale} />
))}
</div>
)}
</div>
);
}
function BlogPostCard({ post, locale }: { post: Post; locale: string }) {
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link href={`/blog/${post.slug}`} className="group">
<div className="bg-card rounded-lg overflow-hidden h-fit shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
{post.feature_image && (
<div className="relative h-48 w-full">
<Image
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h2 className="text-xl font-semibold mb-2 transition-colors">
{post.title}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{formattedDate} {post.reading_time} min read
</p>
<p className=" text-primary/80 line-clamp-3 mb-4">
{post.custom_excerpt || post.excerpt}
</p>
<div className="flex items-center">
{post.primary_author?.profile_image && (
<div className="relative h-10 w-10 rounded-full overflow-hidden mr-3">
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.name || "Unknown Author"}
</p>
</div>
</div>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,135 @@
import { getPostsByTag, getTags } from "@/lib/ghost";
import type { Post } from "@/lib/ghost";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
type Props = {
params: { locale: string; tag: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tag, locale } = params;
const t = await getTranslations({ locale, namespace: "blog" });
return {
title: `${t("tagTitle", { tag })}`,
description: t("tagDescription", { tag }),
};
}
export async function generateStaticParams() {
const tags = await getTags();
return tags.map((tag: { slug: string }) => ({
tag: tag.slug,
}));
}
export default async function TagPage({ params }: Props) {
const { locale, tag } = params;
const t = await getTranslations({ locale, namespace: "blog" });
const posts = await getPostsByTag(tag);
if (!posts || posts.length === 0) {
notFound();
}
// Get the tag name from the first post
const tagName =
posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag;
return (
<div className="container mx-auto px-4 py-12">
<Link
href="/blog"
className="inline-flex items-center mb-8 text-primary-600 hover:text-primary-800 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
{t("backToBlog")}
</Link>
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">
{t("postsTaggedWith")}{" "}
<span className="text-primary-600">"{tagName}"</span>
</h1>
<p className="text-gray-600 dark:text-gray-400">
{t("foundPosts", { count: posts.length })}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post: Post) => (
<BlogPostCard key={post.id} post={post} locale={locale} />
))}
</div>
</div>
);
}
function BlogPostCard({ post, locale }: { post: Post; locale: string }) {
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<Link href={`/blog/${post.slug}`} className="group">
<div className="dark:bg-gray-800 rounded-lg overflow-hidden shadow-lg transition-all duration-300 hover:shadow-xl">
{post.feature_image && (
<div className="relative h-48 w-full">
<Image
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h2 className="text-xl font-semibold mb-2 group-hover:text-primary-500 transition-colors">
{post.title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{formattedDate} {post.reading_time} min read
</p>
<p className="text-gray-700 dark:text-gray-300 mb-4">
{post.custom_excerpt || post.excerpt}
</p>
<div className="flex items-center">
{post.primary_author?.profile_image && (
<div className="relative h-10 w-10 rounded-full overflow-hidden mr-3">
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.name || "Unknown Author"}
</p>
</div>
</div>
</div>
</div>
</Link>
);
}

View File

@@ -127,6 +127,7 @@ function MobileNavigation() {
<MobileNavLink href={linkT("docs.intro")} target="_blank">
{t("navigation.docs")}
</MobileNavLink>
<MobileNavLink href="/blog">{t("navigation.blog")}</MobileNavLink>
<MobileNavLink href={linkT("docs.intro")} target="_blank">
<Button className=" w-full" asChild>
<Link
@@ -166,6 +167,7 @@ export function Header() {
<NavLink href={linkT("docs.intro")} target="_blank">
{t("navigation.docs")}
</NavLink>
<NavLink href="/blog">{t("navigation.blog")}</NavLink>
</div>
</div>
<div className="flex items-center gap-x-4 md:gap-x-5">

View File

@@ -0,0 +1,72 @@
import type { Post } from "@/lib/ghost";
import Image from "next/image";
import Link from "next/link";
interface BlogCardProps {
post: Post;
locale: string;
}
export function BlogCard({ post, locale }: BlogCardProps) {
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="flex flex-col overflow-hidden rounded-lg shadow-lg transition-all hover:shadow-xl">
<div className="relative h-48 w-full">
{post.feature_image ? (
<Image
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-200">
<span className="text-gray-400">No image</span>
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-between bg-white p-6">
<div className="flex-1">
{post.primary_tag && (
<p className="text-sm font-medium text-indigo-600">
{post.primary_tag.name}
</p>
)}
<Link href={`/${locale}/blog/${post.slug}`} className="mt-2 block">
<h3 className="text-xl font-semibold text-gray-900">
{post.title}
</h3>
<p className="mt-3 text-base text-gray-500">{post.excerpt}</p>
</Link>
</div>
<div className="mt-6 flex items-center">
{post.primary_author?.profile_image && (
<div className="relative h-10 w-10 flex-shrink-0">
<Image
className="rounded-full"
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
/>
</div>
)}
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">
{post.primary_author?.name || "Anonymous"}
</p>
<div className="flex space-x-1 text-sm text-gray-500">
<time dateTime={post.published_at}>{formattedDate}</time>
<span aria-hidden="true">&middot;</span>
<span>{post.reading_time} min read</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
const navigation = [
{ name: "home", href: "/" },
{ name: "features", href: "/features" },
{ name: "pricing", href: "/pricing" },
{ name: "blog", href: "/blog" },
];

127
apps/website/lib/ghost.ts Normal file
View File

@@ -0,0 +1,127 @@
import GhostContentAPI from "@tryghost/content-api";
// Ghost API configuration
const ghostConfig = {
url: "http://testing-ghost-8423be-31-220-108-27.traefik.me",
key: "d8c93e7121f36a95cd60921a04",
version: "v5.0",
};
// Initialize the Ghost API with your credentials
const api = GhostContentAPI({
url: ghostConfig.url,
key: ghostConfig.key,
version: ghostConfig.version,
// @ts-ignore
makeRequest: ({ url, method, params, headers }) => {
const apiUrl = new URL(url);
// @ts-ignore
Object.keys(params).map((key) =>
apiUrl.searchParams.set(key, encodeURIComponent(params[key])),
);
return fetch(apiUrl.toString(), { method, headers })
.then(async (res) => {
// Check if the response was successful.
if (!res.ok) {
// You can handle HTTP errors here
throw new Error(`HTTP error! status: ${res.status}`);
}
return { data: await res.json() };
})
.catch((error) => {
console.error("Fetch error:", error);
});
},
});
export interface Post {
id: string;
uuid: string;
title: string;
slug: string;
html: string;
feature_image: string | null;
featured: boolean;
visibility: string;
created_at: string;
updated_at: string;
published_at: string;
custom_excerpt: string | null;
excerpt: string;
reading_time: number;
primary_tag?: {
id: string;
name: string;
slug: string;
};
tags?: Array<{
id: string;
name: string;
slug: string;
}>;
primary_author?: {
id: string;
name: string;
slug: string;
profile_image: string | null;
bio: string | null;
};
authors?: Array<{
id: string;
name: string;
slug: string;
profile_image: string | null;
bio: string | null;
}>;
url: string;
}
export async function getPosts(options = {}): Promise<Post[]> {
try {
const result = (await api.posts.browse({
limit: "all",
include: ["tags", "authors"],
})) as Post[];
console.log(result);
return result;
} catch (error) {
console.error("Error fetching posts:", error);
return [];
}
}
export async function getPost(slug: string): Promise<Post | null> {
try {
const result = (await api.posts.read({ slug })) as Post;
return result;
} catch (error) {
console.error("Error fetching post:", error);
return null;
}
}
export async function getTags() {
try {
const result = await api.tags.browse();
return result;
} catch (error) {
console.error("Error fetching tags:", error);
return [];
}
}
export async function getPostsByTag(tag: string) {
try {
const result = await api.posts.browse({
limit: "all",
filter: `tag:${tag}`,
include: ["tags", "authors"],
});
return result;
} catch (error) {
console.error(`Error fetching posts with tag ${tag}:`, error);
return [];
}
}

View File

@@ -0,0 +1,44 @@
declare module "@tryghost/content-api" {
interface GhostContentAPIOptions {
url: string;
key: string;
version: string;
}
interface BrowseOptions {
limit?: string | number;
page?: number;
order?: string;
filter?: string;
include?: string | string[];
fields?: string | string[];
formats?: string | string[];
}
interface ReadOptions {
id?: string;
slug?: string;
include?: string | string[];
fields?: string | string[];
formats?: string | string[];
}
interface ApiObject {
browse<T>(options?: BrowseOptions): Promise<T[]>;
read<T>(options: ReadOptions): Promise<T>;
}
interface GhostAPI {
posts: ApiObject;
tags: ApiObject;
authors: ApiObject;
pages: ApiObject;
settings: {
browse<T>(): Promise<T>;
};
}
function GhostContentAPI(options: GhostContentAPIOptions): GhostAPI;
export default GhostContentAPI;
}

View File

@@ -11,7 +11,11 @@
"i18nButtonPlaceholder": "Language",
"i18nFr": "Français",
"i18nEn": "English",
"i18nZh-Hans": "简体中文"
"i18nZh-Hans": "简体中文",
"blog": "Blog",
"home": "Home",
"login": "Login",
"register": "Register"
},
"hero": {
"cloud": "Introducing Dokploy Cloud",
@@ -69,7 +73,7 @@
},
"callToAction": {
"title": "Unlock Your Deployment Potential with Dokploy Cloud",
"des": "Say goodbye to infrastructure hassles—Dokploy Cloud handles it all. Effortlessly deploy, manage Docker containers, and secure your traffic with Traefik. Focus on building, well handle the rest.",
"des": "Say goodbye to infrastructure hassles—Dokploy Cloud handles it all. Effortlessly deploy, manage Docker containers, and secure your traffic with Traefik. Focus on building, we'll handle the rest.",
"button": "Get Started Now"
},
"faq": {
@@ -173,7 +177,7 @@
},
"faq": {
"title": "Frequently asked questions",
"description": "If you cant find what youre looking for, please send us an email to",
"description": "If you can't find what you're looking for, please send us an email to",
"q1": "How does Dokploy's Open Source plan work?",
"a1": "You can host Dokploy UI on your own infrastructure and you will be responsible for the maintenance and updates.",
"q2": "Do I need to provide my own server for the managed plan?",
@@ -191,5 +195,17 @@
"q8": "Is Dokploy open-source?",
"a8": "Yes, Dokploy is fully open-source. You can contribute or modify it as needed for your projects."
}
},
"blog": {
"title": "Blog",
"description": "Latest news, updates, and articles from Dokploy",
"noPosts": "No posts found. Check back later for new content.",
"readMore": "Read more",
"backToBlog": "Back to Blog",
"tags": "Tags",
"postsTaggedWith": "Posts tagged with",
"foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}",
"tagTitle": "Posts tagged with {tag}",
"tagDescription": "Browse all blog posts tagged with {tag}"
}
}

View File

@@ -11,7 +11,11 @@
"i18nButtonPlaceholder": "Langue",
"i18nFr": "Français",
"i18nEn": "English",
"i18nZh-Hans": "简体中文"
"i18nZh-Hans": "简体中文",
"blog": "Blog",
"home": "Accueil",
"login": "Connexion",
"register": "S'inscrire"
},
"hero": {
"cloud": "Présentation de Dokploy Cloud",
@@ -190,5 +194,17 @@
"q8": "Dokploy est-il open-source ?",
"a8": "Oui, Dokploy est entièrement open-source. Vous pouvez contribuer ou modifier selon vos besoins pour vos projets."
}
},
"blog": {
"title": "Blog",
"description": "Dernières nouvelles, mises à jour et articles de Dokploy",
"noPosts": "Aucun article trouvé. Revenez plus tard pour du nouveau contenu.",
"readMore": "Lire la suite",
"backToBlog": "Retour au Blog",
"tags": "Tags",
"postsTaggedWith": "Articles tagués avec",
"foundPosts": "{count, plural, =0 {Aucun article trouvé} one {# article trouvé} other {# articles trouvés}}",
"tagTitle": "Articles tagués avec {tag}",
"tagDescription": "Parcourir tous les articles de blog tagués avec {tag}"
}
}

View File

@@ -11,7 +11,11 @@
"i18nButtonPlaceholder": "语言",
"i18nFr": "Français",
"i18nEn": "English",
"i18nZh-Hans": "简体中文"
"i18nZh-Hans": "简体中文",
"blog": "博客",
"home": "首页",
"login": "登录",
"register": "注册"
},
"hero": {
"cloud": "隆重介绍 Dokploy 云",
@@ -195,5 +199,17 @@
"q8": "Dokploy 开源吗?",
"a8": "是的Dokploy 完全开源,您可以参与贡献或者是 Fork 后自行修改以用于您的私人需求。"
}
},
"blog": {
"title": "博客",
"description": "Dokploy 的最新消息、更新和文章",
"noPosts": "未找到文章。稍后再来查看新内容。",
"readMore": "阅读更多",
"backToBlog": "返回博客",
"tags": "标签",
"postsTaggedWith": "标签为",
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
"tagTitle": "标签为 {tag} 的文章",
"tagDescription": "浏览所有标签为 {tag} 的博客文章"
}
}

View File

@@ -10,6 +10,12 @@ const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
domains: [
"static.ghost.org",
"testing-ghost-8423be-31-220-108-27.traefik.me",
],
},
};
module.exports = withNextIntl(nextConfig);

View File

@@ -23,8 +23,10 @@
"@radix-ui/react-tabs": "1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tabler/icons-react": "3.21.0",
"@tryghost/content-api": "^1.11.21",
"@types/node": "20.4.6",
"autoprefixer": "^10.4.12",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.3.19",

188
pnpm-lock.yaml generated
View File

@@ -124,12 +124,18 @@ importers:
'@tabler/icons-react':
specifier: 3.21.0
version: 3.21.0(react@18.2.0)
'@tryghost/content-api':
specifier: ^1.11.21
version: 1.11.21
'@types/node':
specifier: 20.4.6
version: 20.4.6
autoprefixer:
specifier: ^10.4.12
version: 10.4.19(postcss@8.4.47)
axios:
specifier: ^1.8.1
version: 1.8.1
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -1490,6 +1496,9 @@ packages:
'@tanstack/virtual-core@3.8.3':
resolution: {integrity: sha512-vd2A2TnM5lbnWZnHi9B+L2gPtkSeOtJOAw358JqokIH1+v2J7vUAzFVPwB/wrye12RFOurffXu33plm4uQ+JBQ==}
'@tryghost/content-api@1.11.21':
resolution: {integrity: sha512-ozJqEMHDUO7D0SGxPbUnG+RvwBbzC3zmdGOW8cFvkcKzrhe7uOAmVKyq7/J3kRAM2QthTlmiDpqp7NEo9ZLlKg==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@@ -1615,6 +1624,9 @@ packages:
resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==}
hasBin: true
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.4.19:
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
engines: {node: ^10 || ^12 || >=14}
@@ -1629,6 +1641,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.8.1:
resolution: {integrity: sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -1660,6 +1675,10 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -1757,6 +1776,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -1841,6 +1864,10 @@ packages:
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -1865,6 +1892,10 @@ packages:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -1894,6 +1925,22 @@ packages:
error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -1990,6 +2037,15 @@ packages:
resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
engines: {node: '>=18'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
foreach@2.0.6:
resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==}
@@ -1997,6 +2053,10 @@ packages:
resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==}
engines: {node: '>=14'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@@ -2075,10 +2135,18 @@ packages:
resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
@@ -2110,6 +2178,10 @@ packages:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -2121,6 +2193,14 @@ packages:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2381,6 +2461,10 @@ packages:
markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-find-and-replace@3.0.1:
resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==}
@@ -2553,6 +2637,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
@@ -2855,6 +2947,9 @@ packages:
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -4803,6 +4898,12 @@ snapshots:
'@tanstack/virtual-core@3.8.3': {}
'@tryghost/content-api@1.11.21':
dependencies:
axios: 1.8.1
transitivePeerDependencies:
- debug
'@types/acorn@4.0.6':
dependencies:
'@types/estree': 1.0.5
@@ -4921,6 +5022,8 @@ snapshots:
astring@1.8.6: {}
asynckit@0.4.0: {}
autoprefixer@10.4.19(postcss@8.4.47):
dependencies:
browserslist: 4.23.2
@@ -4941,6 +5044,14 @@ snapshots:
postcss: 8.4.47
postcss-value-parser: 4.2.0
axios@1.8.1:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -4973,6 +5084,11 @@ snapshots:
dependencies:
streamsearch: 1.1.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
camelcase-css@2.0.1: {}
@@ -5068,6 +5184,10 @@ snapshots:
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
commander@12.1.0: {}
@@ -5136,6 +5256,8 @@ snapshots:
dependencies:
character-entities: 2.0.2
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
detect-libc@2.0.3:
@@ -5155,6 +5277,12 @@ snapshots:
dependencies:
is-obj: 2.0.0
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.2: {}
@@ -5175,6 +5303,21 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -5336,6 +5479,8 @@ snapshots:
path-exists: 5.0.0
unicorn-magic: 0.1.0
follow-redirects@1.15.9: {}
foreach@2.0.6: {}
foreground-child@3.2.1:
@@ -5343,6 +5488,13 @@ snapshots:
cross-spawn: 7.0.3
signal-exit: 4.1.0
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
fraction.js@4.3.7: {}
framer-motion@11.3.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
@@ -5461,8 +5613,26 @@ snapshots:
get-east-asian-width@1.2.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@8.0.1: {}
get-tsconfig@4.8.1:
@@ -5498,6 +5668,8 @@ snapshots:
dependencies:
ini: 4.1.1
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
gray-matter@4.0.3:
@@ -5509,6 +5681,12 @@ snapshots:
has-flag@3.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -5783,6 +5961,8 @@ snapshots:
markdown-table@3.0.3: {}
math-intrinsics@1.1.0: {}
mdast-util-find-and-replace@3.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -6226,6 +6406,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mimic-fn@4.0.0: {}
mimic-function@5.0.1: {}
@@ -6461,6 +6647,8 @@ snapshots:
property-information@6.5.0: {}
proxy-from-env@1.1.0: {}
queue-microtask@1.2.3: {}
queue@6.0.2: