Add multi-domain routing: middleware, Redis caching, dynamic renderers
- Migration: Add route column + performance indexes for <5ms lookups - Redis cache layer with graceful degradation - Middleware: Route custom domains to database pages - Page/Post renderers with full SEO metadata - Support for 12-section content fragments - Site-specific styling from config.custom_css Enables: aesthetichelp.com -> database pages Performance: <5ms routing with Redis caching
This commit is contained in:
153
src/lib/cache/redis.ts
vendored
Normal file
153
src/lib/cache/redis.ts
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Redis Cache Helper
|
||||
*
|
||||
* Provides caching layer for site/page lookups to achieve <5ms routing.
|
||||
* Gracefully degrades if Redis is unavailable.
|
||||
*/
|
||||
import Redis from 'ioredis';
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
export function getRedisClient(): Redis {
|
||||
if (!redis) {
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
lazyConnect: true,
|
||||
enableReadyCheck: true,
|
||||
// Timeout to prevent hanging
|
||||
connectTimeout: 1000,
|
||||
commandTimeout: 2000
|
||||
});
|
||||
|
||||
// Handle errors gracefully
|
||||
redis.on('error', (err) => {
|
||||
console.warn('Redis error:', err.message);
|
||||
});
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for site/page lookups
|
||||
* Falls back to direct DB query if Redis unavailable
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @param fetchFn - Function to fetch data if cache miss
|
||||
* @param ttl - Time to live in seconds (default: 60s)
|
||||
*/
|
||||
export async function getCached<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
ttl: number = 60
|
||||
): Promise<T> {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
|
||||
// Ensure connection
|
||||
if (redis.status !== 'ready') {
|
||||
await redis.connect().catch(() => null);
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = await redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (err) {
|
||||
// Redis unavailable - fall through to DB
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Redis cache miss, using DB:', key);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or Redis unavailable - fetch from DB
|
||||
const data = await fetchFn();
|
||||
|
||||
// Store in cache (fire and forget - don't block on cache writes)
|
||||
if (data !== null && data !== undefined) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
redis.setex(key, ttl, JSON.stringify(data)).catch(() => {
|
||||
// Ignore cache write failures
|
||||
});
|
||||
} catch (err) {
|
||||
// Cache write failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cache entries for a site
|
||||
* Use this when pages are updated/deleted
|
||||
*
|
||||
* @param domain - Site domain to invalidate
|
||||
*/
|
||||
export async function invalidateSite(domain: string) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const pattern = `site:${domain}:*`;
|
||||
|
||||
// Find all keys matching pattern
|
||||
const keys = await redis.keys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
console.log(`Cache invalidated: ${keys.length} keys for ${domain}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Cache invalidation failed:', err);
|
||||
// Non-fatal - cache will expire naturally
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific page cache
|
||||
*
|
||||
* @param domain - Site domain
|
||||
* @param route - Page route (e.g., "/about")
|
||||
*/
|
||||
export async function invalidatePage(domain: string, route: string) {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const key = `site:${domain}:page:${route}`;
|
||||
await redis.del(key);
|
||||
} catch (err) {
|
||||
console.warn('Page cache invalidation failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export async function getCacheStats() {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
const info = await redis.info('stats');
|
||||
const lines = info.split('\r\n');
|
||||
const stats: Record<string, string> = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
const [key, value] = line.split(':');
|
||||
if (key && value) {
|
||||
stats[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
hits: parseInt(stats.keyspace_hits || '0'),
|
||||
misses: parseInt(stats.keyspace_misses || '0'),
|
||||
hitRate: stats.keyspace_hits && stats.keyspace_misses
|
||||
? (parseInt(stats.keyspace_hits) / (parseInt(stats.keyspace_hits) + parseInt(stats.keyspace_misses)) * 100).toFixed(2) + '%'
|
||||
: 'N/A'
|
||||
};
|
||||
} catch (err) {
|
||||
return { hits: 0, misses: 0, hitRate: 'Redis unavailable' };
|
||||
}
|
||||
}
|
||||
176
src/middleware.ts
Normal file
176
src/middleware.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Multi-Domain Routing Middleware
|
||||
*
|
||||
* Routes custom domains (aesthetichelp.com) to pages from database.
|
||||
* Uses Redis caching for <5ms performance.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Extract domain from Host header
|
||||
* 2. Lookup site by domain (Redis cached)
|
||||
* 3. Lookup page by route (Redis cached)
|
||||
* 4. Rewrite to dynamic renderer
|
||||
*/
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { pool } from '@/lib/db';
|
||||
import { getCached } from '@/lib/cache/redis';
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const host = context.request.headers.get('host')?.split(':')[0].replace(/^www\./, '') || '';
|
||||
const pathname = context.url.pathname;
|
||||
|
||||
// Skip static assets, admin, API routes
|
||||
if (
|
||||
pathname.startsWith('/admin') ||
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/shim') ||
|
||||
pathname.startsWith('/preview') ||
|
||||
pathname.startsWith('/render') ||
|
||||
pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff|woff2|map|html|webp|gif|pdf)$/)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Platform domain - allow default routing
|
||||
const platformDomain = import.meta.env.PUBLIC_PLATFORM_DOMAIN || 'spark.jumpstartscaling.com';
|
||||
if (host.includes(platformDomain) || host === 'localhost' || host === '127.0.0.1') {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// ⚡ REDIS-CACHED SITE LOOKUP (5 min cache)
|
||||
const site = await getCached(
|
||||
`site:${host}:meta`,
|
||||
async () => {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, name, domain, status, config FROM sites WHERE domain = $1',
|
||||
[host]
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
300 // 5 min cache
|
||||
);
|
||||
|
||||
if (!site) {
|
||||
return new Response(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Site Not Found</title></head>
|
||||
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
|
||||
<h1>🔍 Site Not Found</h1>
|
||||
<p>The domain <strong>${host}</strong> is not configured.</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Please contact support if you believe this is an error.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
}
|
||||
|
||||
if (site.status !== 'active') {
|
||||
return new Response(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Site Maintenance</title></head>
|
||||
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
|
||||
<h1>🚧 Site Under Maintenance</h1>
|
||||
<p>We'll be back soon!</p>
|
||||
</body>
|
||||
</html>
|
||||
`, {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
}
|
||||
|
||||
// Inject site data into context
|
||||
context.locals.site = site;
|
||||
context.locals.siteId = site.id;
|
||||
|
||||
// ⚡ REDIS-CACHED PAGE LOOKUP (1 min cache)
|
||||
const page = await getCached(
|
||||
`site:${host}:page:${pathname}`,
|
||||
async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM pages
|
||||
WHERE site_id = $1 AND route = $2 AND status = 'published'
|
||||
LIMIT 1`,
|
||||
[site.id, pathname]
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
60 // 1 min cache
|
||||
);
|
||||
|
||||
if (page) {
|
||||
context.locals.page = page;
|
||||
return context.rewrite('/render/page');
|
||||
}
|
||||
|
||||
// Check for blog post (route: /blog/post-slug)
|
||||
if (pathname.startsWith('/blog/')) {
|
||||
const slug = pathname.replace('/blog/', '');
|
||||
|
||||
const post = await getCached(
|
||||
`site:${host}:post:${slug}`,
|
||||
async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM posts
|
||||
WHERE site_id = $1 AND slug = $2 AND status = 'published'
|
||||
LIMIT 1`,
|
||||
[site.id, slug]
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
60
|
||||
);
|
||||
|
||||
if (post) {
|
||||
context.locals.post = post;
|
||||
return context.rewrite('/render/post');
|
||||
}
|
||||
}
|
||||
|
||||
// 404 - check for custom 404 page
|
||||
const notFoundPage = await getCached(
|
||||
`site:${host}:page:/404`,
|
||||
async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM pages
|
||||
WHERE site_id = $1 AND route = '/404'
|
||||
LIMIT 1`,
|
||||
[site.id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
if (notFoundPage) {
|
||||
context.locals.page = notFoundPage;
|
||||
return context.rewrite('/render/page');
|
||||
}
|
||||
|
||||
// Default 404
|
||||
return new Response(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Page Not Found</title></head>
|
||||
<body style="font-family: system-ui; text-align: center; padding: 100px 20px;">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page <strong>${pathname}</strong> doesn't exist.</p>
|
||||
<a href="/" style="color: #667eea;">← Back to Home</a>
|
||||
</body>
|
||||
</html>
|
||||
`, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Middleware error:', err);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
191
src/pages/render/page.astro
Normal file
191
src/pages/render/page.astro
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
/**
|
||||
* Dynamic Page Renderer
|
||||
*
|
||||
* Renders pages from database with full SEO metadata.
|
||||
* Supports 12-section content fragments and site-specific styling.
|
||||
*/
|
||||
const { site, page } = Astro.locals;
|
||||
|
||||
if (!page || !site) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Extract SEO data from JSONB
|
||||
const seoData = page.seo_data || {};
|
||||
const title = page.meta_title || page.title;
|
||||
const description = page.meta_description || '';
|
||||
const ogImage = seoData.og_image || '';
|
||||
const keywords = seoData.keywords || [];
|
||||
const canonicalUrl = Astro.url.href;
|
||||
|
||||
// Site configuration
|
||||
const siteConfig = site.config || {};
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description}>
|
||||
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')}>}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalUrl}>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content={canonicalUrl}>
|
||||
<meta property="og:title" content={title}>
|
||||
<meta property="og:description" content={description}>
|
||||
<meta property="og:site_name" content={site.name}>
|
||||
{ogImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImage}>
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content={canonicalUrl}>
|
||||
<meta property="twitter:title" content={title}>
|
||||
<meta property="twitter:description" content={description}>
|
||||
{ogImage && <meta property="twitter:image" content={ogImage}>}
|
||||
|
||||
<!-- JSON-LD Schema (if provided in seo_data) -->
|
||||
{seoData.schema && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(seoData.schema)} />
|
||||
)}
|
||||
|
||||
<!-- Site-specific custom CSS from config.custom_css -->
|
||||
{siteConfig.custom_css && (
|
||||
<style set:html={siteConfig.custom_css} />
|
||||
)}
|
||||
|
||||
<!-- Default styling -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a1a;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.3;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.5rem; }
|
||||
h2 { font-size: 1.875rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #667eea;
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Site header from config (optional) -->
|
||||
{siteConfig.header && (
|
||||
<header>
|
||||
<Fragment set:html={siteConfig.header} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<!-- Main content - supports 12-section fragments -->
|
||||
<main>
|
||||
<Fragment set:html={page.content} />
|
||||
</main>
|
||||
|
||||
<!-- Site footer from config (optional) -->
|
||||
{siteConfig.footer && (
|
||||
<footer>
|
||||
<Fragment set:html={siteConfig.footer} />
|
||||
</footer>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
196
src/pages/render/post.astro
Normal file
196
src/pages/render/post.astro
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
/**
|
||||
* Dynamic Post Renderer
|
||||
*
|
||||
* Renders blog posts from database with article schema.
|
||||
*/
|
||||
const { site, post } = Astro.locals;
|
||||
|
||||
if (!post || !site) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Extract SEO data
|
||||
const seoData = post.seo_data || {};
|
||||
const title = post.meta_title || post.title;
|
||||
const description = post.meta_description || post.excerpt || '';
|
||||
const ogImage = seoData.og_image || '';
|
||||
const publishedDate = post.published_at ? new Date(post.published_at).toISOString() : new Date().toISOString();
|
||||
const canonicalUrl = Astro.url.href;
|
||||
|
||||
// Site configuration
|
||||
const siteConfig = site.config || {};
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description}>
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalUrl}>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content={canonicalUrl}>
|
||||
<meta property="og:title" content={title}>
|
||||
<meta property="og:description" content={description}>
|
||||
<meta property="og:site_name" content={site.name}>
|
||||
<meta property="article:published_time" content={publishedDate}>
|
||||
{ogImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImage}>
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content={canonicalUrl}>
|
||||
<meta property="twitter:title" content={title}>
|
||||
<meta property="twitter:description" content={description}>
|
||||
{ogImage && <meta property="twitter:image" content={ogImage}>}
|
||||
|
||||
<!-- Article JSON-LD Schema -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title,
|
||||
"description": description,
|
||||
"image": ogImage || undefined,
|
||||
"datePublished": publishedDate,
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": site.name
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": site.name
|
||||
},
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": canonicalUrl
|
||||
}
|
||||
})} />
|
||||
|
||||
<!-- Site-specific custom CSS -->
|
||||
{siteConfig.custom_css && (
|
||||
<style set:html={siteConfig.custom_css} />
|
||||
)}
|
||||
|
||||
<!-- Default post styling -->
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 20px;
|
||||
line-height: 1.7;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
article header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
article h2 {
|
||||
font-size: 1.875rem;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
article h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
article a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
article a:hover {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
article ul, article ol {
|
||||
margin-left: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
article li {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{siteConfig.header && (
|
||||
<header>
|
||||
<Fragment set:html={siteConfig.header} />
|
||||
</header>
|
||||
)}
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h1>{post.title}</h1>
|
||||
<div class="post-meta">
|
||||
Published {new Date(publishedDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Fragment set:html={post.content} />
|
||||
</article>
|
||||
|
||||
{siteConfig.footer && (
|
||||
<footer>
|
||||
<Fragment set:html={siteConfig.footer} />
|
||||
</footer>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user