Cloudflare Pages 示例
Cloudflare Pages全栈Web应用示例,包含函数、边缘部署和现代Web框架集成
💻 Hello World 基础 javascript
🟢 simple
简单的Cloudflare Pages应用入门
// Cloudflare Pages Hello World
// Complete setup with static site and functions
// 1. Public/index.html - Main HTML file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloudflare Pages Hello World</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 10px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
max-width: 500px;
width: 90%;
text-align: center;
}
h1 {
color: #667eea;
margin-bottom: 1rem;
font-size: 2rem;
}
.logo {
width: 80px;
height: 80px;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin: 2rem 0;
}
.feature {
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.3s ease;
}
.feature:hover {
border-color: #667eea;
transform: translateY(-2px);
}
.btn {
display: inline-block;
padding: 12px 24px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
text-decoration: none;
border-radius: 6px;
margin: 0.5rem;
transition: transform 0.2s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-secondary {
background: #6c757d;
}
.info {
background: #e3f2fd;
border-left: 4px solid #667eea;
padding: 1rem;
margin: 1rem 0;
text-align: left;
}
.loading {
display: none;
color: #667eea;
}
.response {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 6px;
text-align: left;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
@media (max-width: 480px) {
.container {
padding: 1rem;
}
h1 {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="logo">☁️</div>
<h1>Cloudflare Pages</h1>
<p>Deploy static sites and serverless functions at the edge</p>
<div class="feature-grid">
<div class="feature">
<h3>⚡ Lightning Fast</h3>
<p>Global CDN with automatic HTTPS</p>
</div>
<div class="feature">
<h3>🚀 Zero Config</h3>
<p>Deploy from Git in seconds</p>
</div>
<div class="feature">
<h3>📦 Functions</h3>
<p>Serverless edge computing</p>
</div>
<div class="feature">
<h3>🔒 Secure</h3>
<p>DDoS protection and WAF</p>
</div>
</div>
<div class="info">
<strong>Try the serverless functions:</strong><br>
This page includes several API endpoints powered by Cloudflare Pages Functions.
</div>
<button class="btn" onclick="testAPI('/api/hello')">
Test Hello API
</button>
<button class="btn" onclick="testAPI('/api/time')">
Get Server Time
</button>
<button class="btn" onclick="testAPI('/api/info')">
Get Request Info
</button>
<button class="btn btn-secondary" onclick="testAPI('/api/echo', {message: 'Hello from Cloudflare Pages!'})">
Test Echo API
</button>
<div class="loading" id="loading">Loading...</div>
<div class="response" id="response" style="display: none;"></div>
</div>
<script>
async function testAPI(endpoint, data = null) {
const loading = document.getElementById('loading');
const response = document.getElementById('response');
loading.style.display = 'block';
response.style.display = 'none';
try {
const options = {
method: data ? 'POST' : 'GET',
headers: {
'Content-Type': 'application/json',
},
};
if (data) {
options.body = JSON.stringify(data);
}
const res = await fetch(endpoint, options);
const result = await res.json();
response.textContent = JSON.stringify(result, null, 2);
response.style.display = 'block';
} catch (error) {
response.textContent = `Error: ${error.message}`;
response.style.display = 'block';
} finally {
loading.style.display = 'none';
}
}
// Get initial info
window.addEventListener('load', () => {
testAPI('/api/info');
});
</script>
</body>
</html>
// 2. Functions/api/hello.js - Simple hello function
export function onRequest(context) {
return new Response(
JSON.stringify({
message: "Hello from Cloudflare Pages Functions! 🎉",
method: context.request.method,
url: context.request.url,
timestamp: new Date().toISOString(),
environment: context.env.CF_PAGES ? 'production' : 'preview'
}),
{
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60"
}
}
);
}
// 3. Functions/api/time.js - Server time function
export function onRequest() {
const now = new Date();
return new Response(
JSON.stringify({
serverTime: now.toISOString(),
unixTimestamp: Math.floor(now.getTime() / 1000),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
formatted: {
date: now.toLocaleDateString(),
time: now.toLocaleTimeString(),
datetime: now.toLocaleString()
}
}),
{
headers: {
"Content-Type": "application/json"
}
}
);
}
// 4. Functions/api/info.js - Request information
export function onRequest(context) {
const request = context.request;
const url = new URL(request.url);
return new Response(
JSON.stringify({
request: {
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers.entries()),
cf: request.cf
},
environment: {
pages: context.env.CF_PAGES ? true : false,
branch: context.env.CF_PAGES_BRANCH || 'unknown',
commitSha: context.env.CF_PAGES_COMMIT_SHA || 'unknown'
},
location: {
country: request.cf.country,
city: request.cf.city,
colo: request.cf.colo,
timezone: request.cf.timezone
},
device: {
mobile: request.cf.device.mobile,
model: request.cf.device.model,
brand: request.cf.device.brand
}
}),
{
headers: {
"Content-Type": "application/json"
}
}
);
}
// 5. Functions/api/echo.js - Echo function
export async function onRequest(context) {
const request = context.request;
if (request.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { "Content-Type": "application/json" } }
);
}
try {
const body = await request.json();
return new Response(
JSON.stringify({
echo: body,
receivedAt: new Date().toISOString(),
requestInfo: {
method: request.method,
contentType: request.headers.get('content-type'),
userAgent: request.headers.get('user-agent')
}
}),
{
headers: {
"Content-Type": "application/json"
}
}
);
} catch (error) {
return new Response(
JSON.stringify({
error: 'Invalid JSON',
message: error.message
}),
{
status: 400,
headers: { "Content-Type": "application/json" }
}
);
}
}
// 6. Functions/api/proxy.js - API proxy example
export async function onRequest(context) {
const url = new URL(context.request.url);
const targetUrl = url.searchParams.get('url');
if (!targetUrl) {
return new Response(
JSON.stringify({
error: 'Missing url parameter',
usage: 'GET /api/proxy?url=https://api.example.com/data'
}),
{
status: 400,
headers: { "Content-Type": "application/json" }
}
);
}
try {
const response = await fetch(targetUrl);
const data = await response.text();
return new Response(data, {
status: response.status,
headers: {
"Content-Type": response.headers.get('content-type') || 'text/plain',
"X-Proxy-For": targetUrl
}
});
} catch (error) {
return new Response(
JSON.stringify({
error: 'Proxy request failed',
message: error.message,
targetUrl
}),
{
status: 500,
headers: { "Content-Type": "application/json" }
}
);
}
}
// 7. Functions/api/image.js - Image processing
export async function onRequest(context) {
const request = context.request;
const url = new URL(request.url);
const imageUrl = url.searchParams.get('url');
const width = parseInt(url.searchParams.get('width')) || 800;
const quality = parseInt(url.searchParams.get('quality')) || 80;
if (!imageUrl) {
return new Response(
JSON.stringify({
error: 'Missing url parameter',
usage: 'GET /api/image?url=https://example.com/image.jpg&width=800&quality=80'
}),
{
status: 400,
headers: { "Content-Type": "application/json" }
}
);
}
try {
// Fetch original image
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`Failed to fetch image: ${imageResponse.status}`);
}
const originalImage = await imageResponse.arrayBuffer();
// For now, just pass through the image
// In a real application, you could use Cloudflare's Image Resizing
return new Response(originalImage, {
headers: {
"Content-Type": imageResponse.headers.get('content-type') || 'image/jpeg',
"Cache-Control": "public, max-age=31536000" // 1 year
}
});
} catch (error) {
return new Response(
JSON.stringify({
error: 'Image processing failed',
message: error.message,
imageUrl
}),
{
status: 500,
headers: { "Content-Type": "application/json" }
}
);
}
}
💻 Pages Functions javascript
🟡 intermediate
服务端函数和API路由
// Cloudflare Pages Advanced Functions
// Complete API with middleware, auth, and database integration
// 1. Functions/_middleware.js - Global middleware
import { getCookie, setCookie } from 'https://deno.land/[email protected]/http/cookie.ts';
export async function onRequest(context) {
const { request, env, next, params } = context;
const url = new URL(request.url);
const pathname = url.pathname;
// CORS middleware
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Custom-Header',
};
// Handle preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Authentication middleware for protected routes
if (pathname.startsWith('/api/protected')) {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response(
JSON.stringify({ error: 'Missing or invalid authorization header' }),
{
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
}
);
}
const token = authHeader.replace('Bearer ', '');
try {
const user = await verifyJWT(token, env.JWT_SECRET);
context.user = user;
} catch (error) {
return new Response(
JSON.stringify({ error: 'Invalid or expired token' }),
{
status: 401,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
}
);
}
}
// Rate limiting middleware
if (pathname.startsWith('/api/')) {
const clientIP = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For');
const key = `rate_limit:${clientIP}:${pathname}`;
const limit = await env.RATE_LIMITER.get(key);
const current = parseInt(limit?.value || '0');
if (current > 100) { // 100 requests per minute
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60',
...corsHeaders
}
}
);
}
await env.RATE_LIMITER.put(key, (current + 1).toString(), { expirationTtl: 60 });
}
// Continue to the next handler
const response = await next();
// Add CORS headers to response
Object.entries(corsHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
// 2. Functions/api/auth/login.js - Authentication
export async function onRequest(context) {
const { request, env } = context;
if (request.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const { email, password } = await request.json();
// Validate input
if (!email || !password) {
return new Response(
JSON.stringify({ error: 'Email and password are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Find user in database
const user = await env.DB.prepare(
'SELECT id, email, password_hash, name FROM users WHERE email = ?'
).bind(email).first();
if (!user) {
return new Response(
JSON.stringify({ error: 'Invalid credentials' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Verify password (using a simple hash for demo - use bcrypt in production)
const passwordHash = await hashString(password);
if (passwordHash !== user.password_hash) {
return new Response(
JSON.stringify({ error: 'Invalid credentials' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Generate JWT
const token = await generateJWT(
{ userId: user.id, email: user.email },
env.JWT_SECRET,
'24h'
);
// Update last login
await env.DB.prepare(
'UPDATE users SET last_login = ? WHERE id = ?'
).bind(new Date().toISOString(), user.id).run();
return new Response(
JSON.stringify({
message: 'Login successful',
token,
user: {
id: user.id,
email: user.email,
name: user.name
}
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
}
}
);
} catch (error) {
return new Response(
JSON.stringify({
error: 'Internal server error',
message: error.message
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
// 3. Functions/api/posts/index.js - Posts CRUD API
export async function onRequest(context) {
const { request, env, user } = context;
if (request.method === 'GET') {
// Get posts with pagination
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = parseInt(url.searchParams.get('limit')) || 10;
const offset = (page - 1) * limit;
try {
const posts = await env.DB.prepare(`
SELECT p.*, u.name as author_name, u.email as author_email
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.published = true
ORDER BY p.created_at DESC
LIMIT ? OFFSET ?
`).bind(limit, offset).all();
const totalResult = await env.DB.prepare(
'SELECT COUNT(*) as total FROM posts WHERE published = true'
).first();
return new Response(
JSON.stringify({
posts: posts.results,
pagination: {
page,
limit,
total: totalResult.total,
pages: Math.ceil(totalResult.total / limit)
}
}),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to fetch posts' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (request.method === 'POST') {
// Create new post (protected route)
if (!user) {
return new Response(
JSON.stringify({ error: 'Authentication required' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const { title, content, excerpt, tags } = await request.json();
// Validate input
if (!title || !content) {
return new Response(
JSON.stringify({ error: 'Title and content are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const postId = crypto.randomUUID();
const now = new Date().toISOString();
await env.DB.prepare(`
INSERT INTO posts (id, title, content, excerpt, tags, author_id, created_at, updated_at, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
postId,
title,
content,
excerpt || null,
tags ? JSON.stringify(tags) : null,
user.userId,
now,
now,
false // Draft by default
).run();
const post = await env.DB.prepare(
'SELECT * FROM posts WHERE id = ?'
).bind(postId).first();
return new Response(
JSON.stringify({
message: 'Post created successfully',
post
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to create post' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
}
// 4. Functions/api/posts/[id].js - Individual post operations
export async function onRequest(context) {
const { request, env, params, user } = context;
const postId = params.id;
if (request.method === 'GET') {
try {
const post = await env.DB.prepare(`
SELECT p.*, u.name as author_name, u.email as author_email
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.id = ? AND (p.published = true OR p.author_id = ?)
`).bind(postId, user?.userId).first();
if (!post) {
return new Response(
JSON.stringify({ error: 'Post not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
// Increment view count
await env.DB.prepare(
'UPDATE posts SET views = views + 1 WHERE id = ?'
).bind(postId).run();
return new Response(
JSON.stringify({ post }),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to fetch post' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (request.method === 'PUT') {
// Update post (protected route)
if (!user) {
return new Response(
JSON.stringify({ error: 'Authentication required' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const existingPost = await env.DB.prepare(
'SELECT author_id FROM posts WHERE id = ?'
).bind(postId).first();
if (!existingPost) {
return new Response(
JSON.stringify({ error: 'Post not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
if (existingPost.author_id !== user.userId) {
return new Response(
JSON.stringify({ error: 'Unauthorized to edit this post' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
const { title, content, excerpt, tags, published } = await request.json();
await env.DB.prepare(`
UPDATE posts
SET title = ?, content = ?, excerpt = ?, tags = ?, published = ?, updated_at = ?
WHERE id = ?
`).bind(
title,
content,
excerpt || null,
tags ? JSON.stringify(tags) : null,
published || false,
new Date().toISOString(),
postId
).run();
const updatedPost = await env.DB.prepare(
'SELECT * FROM posts WHERE id = ?'
).bind(postId).first();
return new Response(
JSON.stringify({
message: 'Post updated successfully',
post: updatedPost
}),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to update post' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (request.method === 'DELETE') {
// Delete post (protected route)
if (!user) {
return new Response(
JSON.stringify({ error: 'Authentication required' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const existingPost = await env.DB.prepare(
'SELECT author_id FROM posts WHERE id = ?'
).bind(postId).first();
if (!existingPost) {
return new Response(
JSON.stringify({ error: 'Post not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
if (existingPost.author_id !== user.userId) {
return new Response(
JSON.stringify({ error: 'Unauthorized to delete this post' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
await env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(postId).run();
return new Response(
JSON.stringify({ message: 'Post deleted successfully' }),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to delete post' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
}
// 5. Functions/api/upload.js - File upload with image processing
export async function onRequest(context) {
const { request, env, user } = context;
if (!user) {
return new Response(
JSON.stringify({ error: 'Authentication required' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
if (request.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return new Response(
JSON.stringify({ error: 'No file provided' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return new Response(
JSON.stringify({ error: 'Invalid file type. Only images are allowed.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return new Response(
JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Generate unique filename
const fileExtension = file.name.split('.').pop();
const fileName = `${user.userId}_${Date.now()}.${fileExtension}`;
// Upload to R2 (or any storage service)
const uploadUrl = `https://${env.R2_BUCKET_NAME}.${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`;
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD'
},
body: file
});
// Create database record
const fileId = crypto.randomUUID();
await env.DB.prepare(`
INSERT INTO files (id, user_id, filename, original_name, file_type, file_size, upload_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
fileId,
user.userId,
fileName,
file.name,
file.type,
file.size,
uploadUrl,
new Date().toISOString()
).run();
return new Response(
JSON.stringify({
message: 'File uploaded successfully',
file: {
id: fileId,
filename: fileName,
originalName: file.name,
fileType: file.type,
fileSize: file.size,
uploadUrl
}
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
return new Response(
JSON.stringify({ error: 'Failed to upload file' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
// 6. Utility functions
async function generateJWT(payload, secret, expiresIn = '1h') {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const exp = now + (expiresIn === '24h' ? 86400 : 3600);
const encodedHeader = btoa(JSON.stringify(header));
const encodedPayload = btoa(JSON.stringify({ ...payload, iat: now, exp }));
const signature = await hmacSha256(
`${encodedHeader}.${encodedPayload}`,
secret
);
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
async function verifyJWT(token, secret) {
const [encodedHeader, encodedPayload, signature] = token.split('.');
const expectedSignature = await hmacSha256(
`${encodedHeader}.${encodedPayload}`,
secret
);
if (signature !== expectedSignature) {
throw new Error('Invalid signature');
}
const payload = JSON.parse(atob(encodedPayload));
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
return payload;
}
async function hmacSha256(message, secret) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(message);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, messageData);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
async function hashString(str) {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)));
}
💻 静态网站生成
🟡 intermediate
使用现代框架构建静态网站
// Cloudflare Pages Static Site with Astro
// astro.config.mjs - Astro configuration
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import { imagetools } from 'astro-image-tools';
export default defineConfig({
// Build options
output: 'static',
trailingSlash: 'never',
// Integrations
integrations: [
react({
jsxImportSource: 'react'
}),
tailwind({
applyBaseStyles: false
}),
sitemap({
changefreq: 'weekly',
priority: 0.7,
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr', 'de', 'pt', 'ru']
}
}),
imagetools({
defaultDirectives: (url) => {
// Optimize images by default
return new URLSearchParams({
format: 'webp',
quality: '80'
});
}
})
],
// Site configuration
site: 'https://your-site.pages.dev',
base: '/',
// Markdown options
markdown: {
shikiConfig: {
theme: 'github-dark',
langs: ['javascript', 'typescript', 'jsx', 'tsx', 'css', 'html', 'json', 'bash']
},
remarkPlugins: [
'remark-gfm',
'remark-toc'
],
rehypePlugins: [
'rehype-slug',
'rehype-autolink-headings'
]
},
// Vite configuration
vite: {
build: {
minify: 'terser',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
astro: ['astro']
}
}
}
},
optimizeDeps: {
exclude: ['@astroimage/tailwindcss']
}
},
// Experimental features
experimental: {
assets: true,
buildElapsed: true
}
});
// src/pages/index.astro - Homepage
---
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero.astro';
import Features from '../components/Features.astro';
import RecentPosts from '../components/RecentPosts.astro';
import Newsletter from '../components/Newsletter.astro';
const pageTitle = 'Welcome to My Blog';
const pageDescription = 'A modern blog built with Astro and Cloudflare Pages';
// Get recent posts
const recentPosts = await Astro.glob('../content/blog/*.md');
const sortedPosts = recentPosts
.filter(post => post.frontmatter.published)
.sort((a, b) => new Date(b.frontmatter.pubDate) - new Date(a.frontmatter.pubDate))
.slice(0, 3);
---
<Layout title={pageTitle} description={pageDescription}>
<main>
<Hero />
<Features />
<RecentPosts posts={sortedPosts} />
<Newsletter />
</main>
</Layout>
<style>
main {
min-height: 100vh;
}
/* Ensure smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>
// src/layouts/Layout.astro - Main layout
---
export interface Props {
title: string;
description?: string;
image?: string;
noIndex?: boolean;
}
const {
title,
description = 'A modern blog built with Astro and Cloudflare Pages',
image = '/images/og-default.jpg',
noIndex = false
}: Props = Astro.props;
// Get site data
const { site } = Astro.config;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
// Social media
const socialLinks = [
{ name: 'Twitter', href: 'https://twitter.com/yourusername', icon: '🐦' },
{ name: 'GitHub', href: 'https://github.com/yourusername', icon: '🐙' },
{ name: 'LinkedIn', href: 'https://linkedin.com/in/yourusername', icon: '💼' }
];
// Navigation
const navigation = [
{ name: 'Home', href: '/' },
{ name: 'Blog', href: '/blog' },
{ name: 'About', href: '/about' },
{ name: 'Contact', href: '/contact' }
];
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta -->
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
{noIndex && <meta name="robots" content="noindex, nofollow" />}
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.site)} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(image, Astro.site)} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<style>
:root {
--font-family: 'Inter', system-ui, sans-serif;
}
</style>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<!-- Skip to content link -->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-blue-600 text-white px-4 py-2 rounded-md">
Skip to content
</a>
<!-- Navigation -->
<header class="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
<div class="flex-shrink-0">
<a href="/" class="flex items-center space-x-2">
<div class="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-sm">M</span>
</div>
<span class="text-xl font-bold text-gray-900">MyBlog</span>
</a>
</div>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-8">
{navigation.map(item => (
<a
href={item.href}
class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm font-medium transition-colors duration-200"
>
{item.name}
</a>
))}
</div>
<!-- Social Links -->
<div class="flex items-center space-x-4">
{socialLinks.map(link => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="text-gray-400 hover:text-gray-600 transition-colors duration-200"
aria-label={link.name}
>
<span class="text-xl">{link.icon}</span>
</a>
))}
<!-- Mobile menu button -->
<button
type="button"
class="md:hidden p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
aria-label="Toggle menu"
id="mobile-menu-button"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
<!-- Mobile Navigation -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-2 pt-2 pb-3 space-y-1 bg-white border-t border-gray-200">
{navigation.map(item => (
<a
href={item.href}
class="block px-3 py-2 rounded-md text-base font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50"
>
{item.name}
</a>
))}
</div>
</div>
</nav>
</header>
<!-- Main Content -->
<main id="main">
<slot />
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Company Info -->
<div class="col-span-1 md:col-span-2">
<div class="flex items-center space-x-2 mb-4">
<div class="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold text-sm">M</span>
</div>
<span class="text-xl font-bold">MyBlog</span>
</div>
<p class="text-gray-300 max-w-md">
A modern blog platform built with Astro and deployed on Cloudflare Pages.
Fast, secure, and sustainable web hosting at the edge.
</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
Quick Links
</h3>
<ul class="space-y-2">
{navigation.map(item => (
<li>
<a href={item.href} class="text-gray-300 hover:text-white transition-colors">
{item.name}
</a>
</li>
))}
</ul>
</div>
<!-- Legal -->
<div>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
Legal
</h3>
<ul class="space-y-2">
<li>
<a href="/privacy" class="text-gray-300 hover:text-white transition-colors">
Privacy Policy
</a>
</li>
<li>
<a href="/terms" class="text-gray-300 hover:text-white transition-colors">
Terms of Service
</a>
</li>
</ul>
</div>
</div>
<!-- Bottom bar -->
<div class="mt-8 pt-8 border-t border-gray-800">
<div class="flex flex-col md:flex-row justify-between items-center">
<p class="text-gray-400 text-sm">
© {new Date().getFullYear()} MyBlog. All rights reserved.
</p>
<p class="text-gray-400 text-sm mt-2 md:mt-0">
Powered by <a href="https://astro.build" target="_blank" class="hover:text-white transition-colors">Astro</a> &
<a href="https://pages.cloudflare.com" target="_blank" class="hover:text-white transition-colors">Cloudflare Pages</a>
</p>
</div>
</div>
</div>
</footer>
<!-- Scripts -->
<script>
// Mobile menu toggle
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (mobileMenuButton && mobileMenu) {
mobileMenuButton.addEventListener('click', () => {
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', (event) => {
if (!mobileMenuButton.contains(event.target) && !mobileMenu.contains(event.target)) {
mobileMenu.classList.add('hidden');
}
});
}
// Theme toggle (optional)
const initTheme = () => {
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark');
}
};
initTheme();
// Service Worker registration for PWA
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
}
</script>
</body>
</html>
// src/pages/blog/[...slug].astro - Dynamic blog routes
---
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post }
}));
}
const { post }: { post: CollectionEntry<'blog'> } = Astro.props;
// Get related posts
const relatedPosts = await getCollection('blog', ({ slug }) => slug !== post.slug);
const filteredRelated = relatedPosts
.filter(relatedPost => {
// Find posts with similar tags
const commonTags = post.data.tags?.filter(tag =>
relatedPost.data.tags?.includes(tag)
);
return (commonTags?.length || 0) > 0;
})
.slice(0, 3);
// Table of contents
const { Content, headings } = await post.render();
---
<Layout
title={post.data.title}
description={post.data.description}
image={post.data.image}
>
<article class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Article Header -->
<header class="mb-12">
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-4">
<time datetime={post.data.pubDate}>
{new Date(post.data.pubDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<span>•</span>
<span>{Math.ceil(post.data.readingTime || 5)} min read</span>
{post.data.updatedDate && (
<>
<span>•</span>
<span>Updated {new Date(post.data.updatedDate).toLocaleDateString()}</span>
</>
)}
</div>
<h1 class="text-4xl font-bold text-gray-900 mb-6 leading-tight">
{post.data.title}
</h1>
{post.data.description && (
<p class="text-xl text-gray-600 mb-8 leading-relaxed">
{post.data.description}
</p>
)}
{/* Author and tags */}
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center space-x-3">
{post.data.authorImage && (
<img
src={post.data.authorImage}
alt={post.data.author}
class="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<p class="font-medium text-gray-900">{post.data.author}</p>
<p class="text-sm text-gray-500">{post.data.authorTitle || 'Author'}</p>
</div>
</div>
{post.data.tags && post.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2">
{post.data.tags.map(tag => (
<span class="px-3 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full">
{tag}
</span>
))}
</div>
)}
</div>
</header>
{/* Table of Contents */}
{headings.length > 0 && (
<aside class="mb-12 p-6 bg-gray-50 rounded-lg">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Table of Contents</h2>
<nav>
<ul class="space-y-2">
{headings.map(heading => (
<li>
<a
href={`#${heading.slug}`}
class="text-gray-600 hover:text-gray-900 text-sm transition-colors"
style={`padding-left: ${(heading.depth - 1) * 16}px`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
)}
{/* Article Content */}
<div class="prose prose-lg max-w-none prose-headings:scroll-mt-20">
<Content />
</div>
{/* Article Footer */}
<footer class="mt-16 pt-8 border-t border-gray-200">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="text-sm text-gray-500">
Published on {new Date(post.data.pubDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
{/* Share buttons */}
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">Share:</span>
{[
{
name: 'Twitter',
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(post.data.title)}&url=${encodeURIComponent(Astro.url.pathname)}`,
icon: '🐦'
},
{
name: 'LinkedIn',
href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(Astro.url.pathname)}`,
icon: '💼'
},
{
name: 'Copy Link',
href: '#',
icon: '🔗',
action: 'copy'
}
].map(social => (
<a
href={social.href}
target={social.action !== 'copy' ? '_blank' : undefined}
rel={social.action !== 'copy' ? 'noopener noreferrer' : undefined}
class="text-gray-400 hover:text-gray-600 transition-colors"
onClick={social.action === 'copy' ? 'navigator.clipboard.writeText(window.location.href); return false;' : undefined}
aria-label={social.name}
>
<span class="text-lg">{social.icon}</span>
</a>
))}
</div>
</div>
</footer>
{/* Related Posts */}
{filteredRelated.length > 0 && (
<section class="mt-16">
<h2 class="text-2xl font-bold text-gray-900 mb-8">Related Posts</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredRelated.map(relatedPost => (
<article class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow duration-200">
{relatedPost.data.image && (
<a href={`/blog/${relatedPost.slug}`} class="block aspect-video overflow-hidden">
<img
src={relatedPost.data.image}
alt={relatedPost.data.title}
class="w-full h-full object-cover hover:scale-105 transition-transform duration-200"
/>
</a>
)}
<div class="p-6">
<time datetime={relatedPost.data.pubDate} class="text-sm text-gray-500 block mb-2">
{new Date(relatedPost.data.pubDate).toLocaleDateString()}
</time>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
<a href={`/blog/${relatedPost.slug}`} class="hover:text-blue-600 transition-colors">
{relatedPost.data.title}
</a>
</h3>
{relatedPost.data.description && (
<p class="text-gray-600 text-sm line-clamp-2">
{relatedPost.data.description}
</p>
)}
</div>
</article>
))}
</div>
</section>
)}
</article>
</Layout>
<style>
/* Custom prose styles */
.prose h2 {
@apply text-2xl font-bold text-gray-900 mt-12 mb-6;
}
.prose h3 {
@apply text-xl font-semibold text-gray-900 mt-8 mb-4;
}
.prose p {
@apply mb-6 leading-relaxed;
}
.prose code {
@apply bg-gray-100 text-gray-800 px-2 py-1 rounded text-sm font-mono;
}
.prose pre {
@apply bg-gray-900 text-gray-100 p-6 rounded-lg overflow-x-auto;
}
.prose pre code {
@apply bg-transparent text-inherit p-0;
}
.prose blockquote {
@apply border-l-4 border-blue-500 pl-6 italic text-gray-600;
}
.prose a {
@apply text-blue-600 hover:text-blue-800 underline;
}
.prose ul, .prose ol {
@apply mb-6 pl-8;
}
.prose li {
@apply mb-2;
}
/* Line clamp utility */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
// public/sw.js - Service Worker for PWA
const CACHE_NAME = 'myblog-v1';
const STATIC_ASSETS = [
'/',
'/about',
'/contact',
'/styles/global.css',
'/scripts/main.js',
'/images/logo.png',
'/favicon.ico'
];
// Install event
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(STATIC_ASSETS);
})
);
});
// Activate event
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});
// Fetch event
self.addEventListener('fetch', (event) => {
const { request } = event;
// Skip non-GET requests
if (request.method !== 'GET') return;
// Try cache first
event.respondWith(
caches.match(request)
.then(cachedResponse => {
// Return cached version if found
if (cachedResponse) {
return cachedResponse;
}
// Otherwise fetch from network
return fetch(request)
.then(response => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone response for caching
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache);
});
return response;
})
.catch(() => {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});