Astro博客

basic-blog — Astro 博客模板

作者 truedaniyyel

基于 Astro 的轻量博客模板,包含明暗模式、响应式布局、Cloudflare Turnstile 表单防护与 Cloudinary 图片优化。

看看你的网站会是什么样

浏览真实页面截图,比较内容排版与手机效果。点击下方缩略图切换,放大查看整页细节。

3 个预览视图
桌面端首页1 / 3
桌面端截图首屏预览

由服务器浏览器实际访问页面后截取。

全部页面与视图 3

截图用于了解视觉与布局,交互效果请以实际项目为准。

采集于 2026-10-03查看来源

值得关注的模板亮点

从已有项目资料中,快速了解它的特点。

01

基于 Astro 构建,包含博客、项目、关于、联系、订阅与搜索等页面结构

02

提供深色与浅色模式,并适配手机、平板和桌面设备

03

使用 Cloudinary 与 LQIP 占位图处理图片加载

04

内置 Cloudflare Turnstile 机器人防护与 Astro Actions 表单处理示例

05

站点设置、SEO 元数据与页眉页脚导航集中在 src/site.config.ts 配置

06

public/_headers 中预置 CSP 与安全响应头,主要面向 Cloudflare

模板详细介绍

Basic Blog 是一个用 Astro 构建的博客模板,仓库采用 MIT 许可,可免费使用、修改和分发。模板提供博客、项目、关于、联系、订阅、搜索等页面结构,导航与页脚链接集中在 src/site.config.ts 中配置,站点标题、描述、社交账号与 OG 图片也在同一文件维护。

样式方面包含深色与浅色模式,并针对手机、平板和桌面设备做响应式适配。图片部分使用 Cloudinary 配合 LQIP 低质量占位图,在完整图片加载前显示模糊占位。字体使用 Astro 的本地字体提供器,字体文件放在 src/assets/fonts/,需在 astro.config.mjs 中注册并在 global.css 中引用变量。

表单功能依赖外部服务:联系表单与订阅表单使用 Cloudflare Turnstile 做机器人防护,并通过 Astro Actions 在服务端处理。README 中的示例使用 Resend 发送邮件、Kit 管理订阅,需要自行配置 .env 中的 Cloudinary、Turnstile 以及邮件/订阅服务密钥。安全头与 CSP 规则放在 public/_headers 中,主要面向 Cloudflare,部署到 Vercel 或 Netlify 时需要把规则迁移到对应配置文件。

需要注意:README 中关于表单服务端的示例代码由作者标注为非专业实现,仅供参考;演示图片、字体、图标等第三方素材的许可需单独核对,域名、托管及邮件、订阅、图片等外部服务可能产生费用。

Astro博客

适合你的项目吗?

适用场景

  • 搭建个人技术博客并发布文章
  • 展示个人项目与作品集页面
  • 提供联系表单与邮件订阅入口
  • 需要深色模式与移动端适配的内容站点

适合人群

  • 希望使用 Astro 构建博客的开发者
  • 需要现成导航、SEO 与订阅表单结构的个人站点
  • 计划部署在 Cloudflare 并接受使用 Turnstile 与 Cloudinary 的用户

上手与定制指南

结合仓库当前版本的说明,按下面的资料检查内容、配置与运行要求。

  1. 1

    在 .env 中配置 PUBLIC_CLOUDINARY_CLOUD_NAME、PUBLIC_TURNSTILE_SITE_KEY 等变量,表单才能正常工作

  2. 2

    在 src/site.config.ts 中修改站点标题、描述、社交账号以及页眉页脚导航链接

  3. 3

    如需使用订阅与联系表单的服务端逻辑,需创建 src/actions/index.ts 并按所选邮件或订阅服务配置密钥

  4. 4

    部署到 Vercel 或 Netlify 时,需将 public/_headers 中的 CSP 与安全头规则迁移到 vercel.json 或 netlify.toml

  5. 5

    替换字体时把 .woff2 文件放入 src/assets/fonts/,并在 astro.config.mjs 与 global.css 中同步更新配置

查看仓库使用说明

授权与来源资料

使用条件与限制

免费使用 · 保留许可声明

本次收录的是公开仓库中采用 MIT 许可的免费版本;免费范围为该版本代码,不代表外部服务或第三方素材免费。

使用时需要遵守

  • 允许免费使用、修改、商用和分发该版本代码。
  • 分发代码副本或实质性部分时,保留原作者版权声明和完整 MIT 许可声明。
  • 代码按现状提供;担保与责任限制以原许可证及适用法律为准。

额外费用与待核对事项

  • 联系表单与订阅表单依赖 Cloudflare Turnstile 机器人防护,必须自行申请并配置 Turnstile 密钥,否则表单无法工作。
  • 图片优化依赖 Cloudinary 服务,需自行注册 Cloudinary 账号并配置云名称,图片托管与流量可能产生费用。
  • README 示例使用 Resend 发送联系表单邮件,需自行注册 Resend 并配置 API 密钥,属于外部付费/限额服务。
  • 订阅功能示例使用 Kit(ConvertKit)管理订阅者,需自行注册 Kit 并配置 API 密钥与表单 ID,属于外部服务。
  • 预置的 CSP 与安全头规则主要面向 Cloudflare,部署到 Vercel 或 Netlify 时需手动迁移规则到对应配置文件。
  • README 中表单服务端示例代码由作者标注为非专业实现,仅供参考,使用前需自行验证与调整。
  • 演示图片、字体、图标及其他第三方素材需按各自许可核对,不能仅凭代码的 MIT 许可判断。
  • 域名、托管、数据库、支付或 AI 等外部服务可能另外收费。
查看原文依据

Permission is hereby granted, free of charge

许可证原文

For forms to work, you must add your Turnstile keys to your `.env` file

README 原文

PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name_here

README 原文

const resend = new Resend(import.meta.env.RESEND_API_KEY);

README 原文

const KIT_API_KEY = import.meta.env.KIT_API_KEY;

README 原文

If you are deploying to Vercel or Netlify, simply move these rules to a `vercel.json` or `netlify.toml` file respectively.

README 原文

*Note: I'm not a pro with this specific setup yet, so take this with a grain of salt.*

README 原文
https://github.com/truedaniyyel/basic-blog/blob/main/LICENSEhttps://github.com/truedaniyyel/basic-blog/blob/main/README.md

DeepSeek 根据仓库许可证和 README 整理 ·2026-10-03。结果仅覆盖已读取资料,依赖、图片、字体及后续条款变更以各自原文为准。

项目核对记录与介绍依据

模板资料

来源平台
Astro Themes
资料与维护参考分
资料较完整 · 87 / 100
技术栈
Astro
网站类型
博客
这个分数如何计算

本站按许可资料(最高30分)、演示链接(12分)、预览图(10分)、Star数(15分)、记录的维护时间(15分)、描述完整度(8分)、功能记录(5分)和分类资料(5分)计算。它不是代码、安全或性能测试,也不是 Google 评分;有链接不代表已实测可用。

这个项目需要核对什么

记录的仓库更新时间
2026-04-13
截图检查日期
2026-10-03

已收录演示链接。请用你的内容与屏幕尺寸检查;有演示不代表表单、认证或支付已经可用。

目录记录的主要技术栈是 Astro。请通过依赖清单和锁文件核对实际版本与托管要求。这些说明基于收录资料,不代表代码审计或部署实测。

查看具体选型方法 →

介绍的制作与核对状态

介绍由 AI 辅助整理目录与仓库资料。仓库作者不等于这篇介绍的审核者。

审核声明: [email protected] · 2026-10-04

查看当时使用的来源资料

SHA-256: b636c4348dcf7826572218d728b5288ce7ba69cfc317d7144cda099b39059bcb

2026-10-03T14:16:30.118Z

来源文件 / 仓库
# Basic Blog

**Basic Blog** is a mostly lightweight, high-performance blog template built with Astro 6.

![basic-blog](https://github.com/user-attachments/assets/b365f571-3047-4af3-9e79-44e18cdbb32b)

**Features**

- Astro 6+ Ready:
  - Native Font Optimization
  - Pre-configured CSP (Content Security Policy) and security headers (optimized for Cloudflare, easily adaptable for Vercel or Netlify).
   ![HTTP Observatory](https://github.com/user-attachments/assets/9f6b93d8-25fa-4b15-ab78-431255ad8e23)
- Dark and Light mode
- Responsive: mobile, tablet, and desktop devices.
- Stack: Astro, Tailwind, and JS.

## Deployment

You will find a `_headers` file located in the `/public` folder. This file handles the Content Security Policy (CSP) and XSS protections mainly for Cloudflare.

If you are deploying to Vercel or Netlify, simply move these rules to a `vercel.json` or `netlify.toml` file respectively.

## Configuration

### Environment Variables

For forms to work, you must add your Turnstile keys to your `.env` file:

```bash
PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name_here
PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAA...
```

### Site Settings & SEO

Global settings, SEO metadata, and navigation links are managed in `src/site.config.ts`.

Update `SITE_SETTINGS` to change your site title, description, and social media handles:

```typescript
export const SITE_SETTINGS = {
  title: "Daniel Adrian's site",
  description: "A space to explore ideas and share what I'm learning",
  owner: "Daniel Adrian",
  ogImages:
    "https://res.cloudinary.com/dellp9a4z/image/upload/v1756758385/og-home_ejuqq8.png",
  socials: [
    {
      icon: "github",
      label: "GitHub",
      url: "https://github.com/truedaniyyel",
      handle: "truedaniyyel",
    },
  ],
};
```

### Navigation (Header & Footer)

You can easily manage your site's menu structure by editing the header and footer exports:

```typescript
export const header = [
  {
    name: "Blog",
    url: "/blog",
  },
  {
    name: "Projects",
    url: "/projects",
  },
  {
    name: "About",
    url: "/about",
  },
  {
    name: "Contact",
    url: "/contact",
  },
  {
    name: "Newsletter",
    url: "/newsletter",
  },
];

export const footer = [
  {
    title: "Content",
    links: [
      {
        name: "Search",
        url: "/search",
      },
      {
        name: "Blog",
        url: "/blog",
      },
      {
        name: "Projects",
        url: "/projects",
      },
      {
        name: "About",
        url: "/about",
      },
    ],
  },
  {
    title: "Resources",
    links: [
      {
        name: "Contact",
        url: "/contact",
      },
      {
        name: "Newsletter",
        url: "/newsletter",
      },
      {
        name: "RSS",
        url: "/rss.xml",
      },
      {
        name: "Sitemap",
        url: "/sitemap-index.xml",
      },
    ],
  },
];
```

## Metadata & OG Images

You can define custom Open Graph images for both static pages and posts.

### For Static Pages

Pass the `ogImage` prop directly into the `<BaseLayout>` component:

```astro
<BaseLayout
    title="Search"
    description="Search articles and projects from Daniel Adrian."
    ogImage="https://res.cloudinary.com/.../og-search.png"
>
    </BaseLayout>
```

### For Blog Posts

Add the image details to the Frontmatter of your Markdown files. The template will automatically use this for the post's social sharing card:

```yaml
image:
  src: "https://res.cloudinary.com/.../how_to_build_trust.webp"
  alt: "A hand-drawn sketch representing stability with labels: 'Know', 'Like', and 'Trust'."
ogImage:
  src: "https://res.cloudinary.com/.../how_to_build_trust.webp"
```

## Image Optimization

This template uses Cloudinary paired with LQIP (Low-Quality Image Placeholders) to ensure a smooth loading experience. When an image is requested, a blurred placeholder is shown until the full-resolution image is ready.

You can find the implementation logic in `src/components/ui/OptimizedImage.astro`:

```astro
<div
    class={cn(
        "relative overflow-hidden bg-neutral-200 dark:bg-neutral-700",
        wrapperClass,
    )}
>
    {
        lqipUrl && (
            <img
                data-pagefind-ignore
                src={lqipUrl}
                alt=""
                aria-hidden="true"
                class={cn(
                    "absolute inset-0 h-full w-full scale-105 object-cover blur-xl",
                    imageClass,
                )}
                loading="eager"
                decoding="async"
            />
        )
    }

    <CldImage
        src={src}
        alt={alt}
        class={cn("absolute inset-0 h-full w-full object-cover", imageClass)}
        loading={loading}
        fetchpriority={fetchPriorityVal}
        format="auto"
        quality="auto"
        {...rest}
    />
</div>
```

## Fonts

Local fonts are stored in `src/assets/fonts/`. To add or change fonts:

1. Drop your `.woff2` files into the fonts folder.
2. Update the `astro.config.mjs` font provider:

```mjs
fonts: [
    {
      provider: fontProviders.local(),
      name: "YourFontName",
      cssVariable: "--font-custom",
      options: {
        variants: [{
            src: ["./src/assets/fonts/YourFont.woff2"],
            weight: "normal",
            style: "normal",
          }],
      },
    },
  ],
```

3. Update the variable in `src/styles/global.css`:

```css
font-family: var(--font-custom), system-ui, sans-serif;
```

*For more info on using remote fonts (Google Fonts, etc.), check the [Astro Font Provider Reference.](https://docs.astro.build/en/reference/font-provider-reference/)*

## Forms & Bot Protection

The template uses **Cloudflare Turnstile** for non-intrusive bot protection and Astro Actions for server-side processing.

### Newsletter Subscription

Handled via `utils/client-newsletter.ts`. It uses AJAX to submit the form without refreshing the page, providing instant feedback and resetting the Turnstile widget upon success.

### Contact Form

Located in `src/pages/contact.astro`.
- **State Persistence:** Includes a script that caches form data in `sessionStorage`. If the user refreshes or a validation fails, their message isn't lost.
- **Success Handling:** Automatically redirects to a success state and clears the cache once the message is sent.

### Turnstile Implementation

Turnstile code is in `src/components/integrations/turnstile/`:
- `TurnstileWidget.astro`: The UI component that renders the challenge.
- `TurnstileController.astro`: Manages the lifecycle of the widget, including theme switching (light/dark) and automatic cleanup/re-initialization.

### Server-Side Actions

To make the newsletter and contact form functional, you must:

1. Uncomment `export const prerender = false;` in the respective page files (if using SSR).
2. Create `src/actions/index.ts` to define your logic.

**Implementation Example (Resend & Kit)**

*Note: I'm not a pro with this specific setup yet, so take this with a grain of salt.*

```typescript
// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from "astro/zod";
import { escapeHtml } from '@utils/escapeHtml';
import { stripCtl } from '@utils/stripCtl';
import { Resend } from 'resend';

const resend = new Resend(import.meta.env.RESEND_API_KEY);

const KIT_FORM_ID = import.meta.env.KIT_FORM_ID;
const KIT_API_KEY = import.meta.env.KIT_API_KEY;

const TURNSTILE_SECRET = import.meta.env.TURNSTILE_SECRET_KEY;

const turnstileField = import.meta.env.DEV
	? z
			.string()
			.optional()
			.default('') // dev: missing → ''
	: z.string().min(1); // prod: must be non-empty

const fetchWithTimeout = async (url: string, init: RequestInit = {}, ms = 8000) => {
	const controller = new AbortController();
	const t = setTimeout(() => controller.abort(), ms);
	return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(t));
};

async function verifyTurnstile(token: string, remoteip?: string, expectAction?: string) {
	if (import.meta.env.DEV) return true;
	if (!TURNSTILE_SECRET) {
		console.error('TURNSTILE_SECRET_KEY missing');
		return false;
	}

	const body = new URLSearchParams({
		secret: TURNSTILE_SECRET,
		response: token,
		...(remoteip ? { remoteip } : {}),
	});

	const res = await fetchWithTimeout('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
		method: 'POST',
		headers: { 'content-type': 'application/x-www-form-urlencoded' },
		body,
	}).catch((err) => {
		console.warn('Turnstile verify fetch failed/aborted', err);
		return undefined;
	});

	if (!res || !res.ok) {
		console.warn('Turnstile verify non-OK', res?.status);
		return false;
	}

	type VerifyResp = {
		success: boolean;
		hostname?: string;
		'error-codes'?: string[];
		action?: string;
		cdata?: string;
		challenge_ts?: string;
	};

	const json = (await res.json().catch(() => ({}))) as VerifyResp;

	if (!json.success) {
		console.warn('Turnstile failed', json);
		return false;
	}

	if (expectAction && json.action && json.action !== expectAction) {
		console.warn('Unexpected action', json.action);
		return false;
	}

	return true;
}

function getRemoteIp(request: Request): string | undefined {
	const h = request.headers;
	return (
		h.get('CF-Connecting-IP') ??
		h.get('x-real-ip') ??
		h.get('x-forwarded-for')?.split(',')[0]?.trim() ??
		undefined
	);
}

export const server = {
	contact: defineAction({
		accept: 'form',
		input: z.object({
			name: z.string().min(1).max(100),
			email: z.string().email().max(254),
			message: z.string().min(1).max(5000),
			'cf-turnstile-response': turnstileField,
		}),
		handler: async ({ name, email, message, 'cf-turnstile-response': token }, ctx) => {
			const remoteip = getRemoteIp(ctx.request);

			const ok = await verifyTurnstile(token, remoteip, 'contact');
			if (!ok) {
				throw new ActionError({ code: 'BAD_REQUEST', message: 'Turnstile verification failed.' });
			}

			const safeName = stripCtl(name);
			const safeEmail = stripCtl(email);

			const escapeName = escapeHtml(safeName);
			const escapeEmail = escapeHtml(safeEmail);
			const escapeMessage = escapeHtml(message).replace(/\n/g, '<br>');

			try {
				const { data, error } = await resend.emails.send({
					from: 'Contact Form <[email protected]>',
					to: ['[email protected]'],
					subject: `New Contact Form Submission from ${safeName}`,
					replyTo: `${safeName} <${safeEmail}>`,
					html: `
                        <p>You have a new contact form submission:</p>
						<p><strong>Name:</strong> ${escapeName}</p>
						<p><strong>Email:</strong> <a href="mailto:${escapeEmail}">${escapeEmail}</a></p>
						<p><strong>Message:</strong></p>
						<p>${escapeMessage}</p>
						<p style="color:#666">Sent from truedaniyyel.com</p>
                    `,
					text:
						'You have a new contact form submission:\n\n' +
						`Name: ${safeName}\n` +
						`Email: ${safeEmail}\n\n` +
						`Message:\n${messag}\n` +
						'— Sent from truedaniyyel.com',
				});

				const id = data?.id;

				if (error || !id) {
					console.error('Resend failed', { error, data });
					throw new ActionError({
						code: 'BAD_REQUEST',
						message: 'Email could not be sent.',
					});
				}

				console.info('Mail sent', { id });

				return { ok: true };
			} catch (error) {
				console.error('Action error', error);
				throw new ActionError({
					code: 'INTERNAL_SERVER_ERROR',
					message: 'Email could not be sent.',
				});
			}
		},
	}),

	newsletterSubscribe: defineAction({
		accept: 'form',
		input: z.object({
			email: z.string().email().max(254),
			'cf-turnstile-response': turnstileField,
		}),
		handler: async ({ email, 'cf-turnstile-response': token }, ctx) => {
			const remoteip = getRemoteIp(ctx.request);

			const ok = await verifyTurnstile(token, remoteip, 'newsletter');
			if (!ok) {
				throw new ActionError({ code: 'BAD_REQUEST', message: 'Turnstile verification failed.' });
			}

			if (!KIT_API_KEY || !KIT_FORM_ID) {
				console.error('Kit env missing', {
					hasKey: Boolean(KIT_API_KEY),
					hasFormId: Boolean(KIT_FORM_ID),
				});
				throw new ActionError({
					code: 'INTERNAL_SERVER_ERROR',
					message: 'Subscription failed.',
				});
			}

			const email_address = stripCtl(email);

			const headers = {
				'Content-Type': 'application/json',
				'X-Kit-Api-Key': KIT_API_KEY,
			};

			try {
				const upsertRes = await fetchWithTimeout('https://api.kit.com/v4/subscribers', {
					method: 'POST',
					headers,
					body: JSON.stringify({ email_address }),
				});
				const upsertJson = await upsertRes.json().catch(() => ({}));
				if (!upsertRes.ok) {
					console.error('Kit upsert failed', { status: upsertRes.status, upsertJson });
					throw new ActionError({ code: 'BAD_REQUEST', message: 'Subscription failed.' });
				}

				const attachRes = await fetchWithTimeout(
					`https://api.kit.com/v4/forms/${KIT_FORM_ID}/subscribers`,
					{
						method: 'POST',
						headers,
						body: JSON.stringify({ email_address }),
					},
				);
				const attachJson = await attachRes.json().catch(() => ({}));
				if (!attachRes.ok) {
					console.error('Kit form attach failed', { status: attachRes.status, attachJson });
					throw new ActionError({ code: 'BAD_REQUEST', message: 'Subscription failed.' });
				}

				return { ok: true };
			} catch (error) {
				console.error('Newsletter action error', error);
				throw new ActionError({
					code: 'INTERNAL_SERVER_ERROR',
					message: 'Subscription failed.',
				});
			}
		},
	}),
};
```

## Technical Details

Search: Powered by [Pagefind](https://pagefind.app/) for fast, static search.
Image Zoom: Integrated with `medium-zoom` for a clean light-box experience.
Icons: Handled via `astro-icon`.
Code Blocks: Rendered using `astro-expressive-code`.
反馈内容错误

看看同类模板

对比同技术栈与用途的模板,找到更适合的视觉与内容结构。